[Python] ParamSpec & Concatenate

Created
April 27, 2025
Created by
D
DaEun Kim
Tags
Python
Property

파이썬 3.10 미만의 버전에서는 decorated 함수 파라미터의 타입 추적이 되지 않는다.

이는 mypy 에서도 잡아내지 못하기 때문에 런타임 오류를 일으키기 쉽다.

  1 import logging
  1 import logging
  2 from typing import Callable, TypeVar
  3
  4 logger = logging.getLogger(__name__)
  5 logger.setLevel(logging.INFO)
  6 logger.addHandler(logging.StreamHandler())
  7
  8 ANYTHING = TypeVar("ANYTHING")  # 리턴 타입이 아무거나 다 될 수 있음을 나타낼 용도
  9
 10
 11 def log_to_database():
 12     logger.info("Logging to database")
 13
 14
 15 def add_logging(f: Callable[..., ANYTHING]) -> Callable[..., ANYTHING]:
 16   def inner(*args: object, **kwargs: object) -> ANYTHING:
 17     log_to_database()
 18     return f(*args, **kwargs)
 19   return inner
 20
 21 @add_logging
 22 def takes_int_str(x: int, y: str) -> int:
 23   return x + 7
 24
 25
 26 takes_int_str(1, "A")
 27 takes_int_str("B", 2)  # 런타임 오류 발생
$ mypy runtime_error_1.py 
Success: no issues found in 1 source file
$ param_spec python runtime_error_1.py 
Logging to database
Logging to database
Traceback (most recent call last):
  File "/Users/daeun/python-study/param_spec/runtime_error_1.py", line 27, in <module>
    takes_int_str("B", 2)
    ~~~~~~~~~~~~~^^^^^^^^
  File "/Users/daeun/python-study/param_spec/runtime_error_1.py", line 18, in inner
    return f(*args, **kwargs)
  File "/Users/daeun/python-study/param_spec/runtime_error_1.py", line 23, in takes_int_str
    return x + 7
           ~~^~~
TypeError: can only concatenate str (not "int") to str

파이썬 버전 3.10 에 추가된 ParamSpec 으로 데코레이터의 파라미터 타입을 지정하면 decorated 함수 파라미터의 타입을 보존할 수 있다.

  1 import logging
  2 from typing import Callable, ParamSpec, TypeVar
  3
  4 P = ParamSpec("P")
  5 ANYTHING = TypeVar("ANYTHING")
  6
  7 logger = logging.getLogger(__name__)
  8 logger.setLevel(logging.INFO)
  9 logger.addHandler(logging.StreamHandler())
 10
 11
 12 def log_to_database():
 13     logger.info("Logging to database")
 14
 15
 16 def add_logging(f: Callable[P, ANYTHING]) -> Callable[P, ANYTHING]:
 17   def inner(*args: P.args, **kwargs: P.kwargs) -> ANYTHING:
 18     log_to_database()
 19     return f(*args, **kwargs)
 20   return inner
 21
 22 @add_logging
 23 def takes_int_str(x: int, y: str) -> int:
 24   return x + 7
 25
 26 takes_int_str(1, "A")
 27 takes_int_str("B", 2)
$ mypy param_spec_1.py 
param_spec_1.py:27: error: Argument 1 to "takes_int_str" has incompatible type "str"; expected "int"  [arg-type]
param_spec_1.py:27: error: Argument 2 to "takes_int_str" has incompatible type "int"; expected "str"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

만약 필수로 넘겨야 하는 파라미터의 타입을 명시하고 싶다면 Concatenate() 를 활용하면 된다.

아래 add_logging() 데코레이터에는 위치 인자를 통해 필수로 전달받아야 하는 2개 파라미터의 타입이 명시되어 있다.

  1 import logging
  2 from typing import Callable, TypeVar, Concatenate
  3
  4 from typing_extensions import ParamSpec
  5
  6 logger = logging.getLogger(__name__)
  7 logger.setLevel(logging.INFO)
  8 logger.addHandler(logging.StreamHandler())
  9
 10
 11 P = ParamSpec("P")
 12 ANYTHING = TypeVar("ANYTHING")
 13
 14
 15 def log_to_database():
 16     logger.info("Logging to database")
 17
 18
 19 def add_logging(f: Callable[Concatenate[int, int, P], ANYTHING]) -> Callable[Concatenate[int, int, P], ANYTHING]:
 20   def inner(x: int, y: int, *args: P.args, **kwargs: P.kwargs) -> ANYTHING:
 21     log_to_database()
 22     return f(x, y, *args, **kwargs)
 23   return inner
 24
 25 @add_logging
 26 def plus(x: int, y: int, z: str) -> int:
 27   return x + y
 28
 29
 30 plus(1, 1, "Z1")
 31 plus(1, "1", "Z1")
 32 plus(2, y=2, z="Z2")
 33 plus(x=2, y=2, z="Z3")
 34 plus(2, 2, z=3)
 35 plus(2, z=3)
$ mypy concatenate.py
concatenate.py:31: error: Argument 2 to "plus" has incompatible type "str"; expected "int"  [arg-type]
concatenate.py:32: error: Unexpected keyword argument "y" for "plus"  [call-arg]
concatenate.py:33: error: Unexpected keyword argument "x" for "plus"  [call-arg]
concatenate.py:33: error: Unexpected keyword argument "y" for "plus"  [call-arg]
concatenate.py:34: error: Argument "z" to "plus" has incompatible type "int"; expected "str"  [arg-type]
concatenate.py:35: error: Too few arguments for "plus"  [call-arg]
concatenate.py:35: error: Argument "z" to "plus" has incompatible type "int"; expected "str"  [arg-type]
Found 7 errors in 1 file (checked 1 source file)

아래 예시코드는 functool.partial() 과 동작이 유사한데, plus() 를 호출하는 외부에서 매번 Request 객체를 만들지 않고 데코레이터 내부에서 객체를 만들어 전달한다.

여기에 Concatenate() 를 통해 plus() 함수의 req 파라미터에 대한 타입 보존이 가능하다.

  1 import logging
  1 import logging
  1 import logging
  1 import logging
  2 from typing import Callable, TypeVar, Concatenate
  3
  4 from typing_extensions import ParamSpec
  5
  6 logger = logging.getLogger(__name__)
  7 logger.setLevel(logging.INFO)
  8 logger.addHandler(logging.StreamHandler())
  9
 10
 11 P = ParamSpec("P")
 12 ANYTHING = TypeVar("ANYTHING")
 13
 14 class Request:
 15     pass
 16
 17 class AnotherRequest:
 18     pass
 19
 20 def log_to_database():
 21     logger.info("Logging to database")
 22
 23
 24 def add_logging(f: Callable[Concatenate[Request, P], ANYTHING]) -> Callable[P, ANYTHING]:
 25   def inner(*args: P.args, **kwargs: P.kwargs) -> ANYTHING:
 26     log_to_database()
 27     return f(AnotherRequest(), *args, **kwargs)
 28   return inner
 29
 30 @add_logging
 31 def plus(req: Request, x: int, y: int, z: str) -> int:
 32   print(id(req))
 33   return x + y
 34
 35
 36 plus(1, 1, "Z1")
$ mypy concatenate2.py  
concatenate2.py:27: error: Argument 1 has incompatible type "AnotherRequest"; expected "Request"  [arg-type]
Found 1 error in 1 file (checked 1 source file)