Python3에서 함수의 인자 다루기

flask를 써보며 Variable Rules가 어떻게 구현되는지 궁금했었는데, 어제 전문가를 위한 파이썬(루시아누 하말류 저, 강권학 역, 원제: Fluent Python)을 읽고 어떤 방법으로 구현될 수 있는지 알 게 되었습니다.

python에는 inspect라는 라이브러리가 제공된다. 이 라이브러리의 signature를 이용해 어떤 인자가 있는지, 그 인자가 어떤 속성을 가지는지 알 수 있습니다.

from inspect import signature


def sample(a, b=10, *args, c=None, **kwargs):
    pass

sig = signature(sample)
for param in sig.parameters.values():
    pass

signature의 프로퍼티 parameters는 각 파라미터가 param_name: inspect.Parameter 형식으로 들어있는 OrderedDict 객체다. 파라미터의 이름은 inspect.Parameter 객체를 통해 확인할 수 있으니 오브젝트만 불러와서 정보를 확인하면 됩니다.

inspect.Parameter에서 확인해야할 프로퍼티는 name, default 그리고 kind입니다.

from inspect import signature


def sample(a, b=10, *args, c=None, **kwargs):
    pass

sig = signature(sample)
for param in sig.parameters.values():
    print(param.name)
    print(' -', param.default)
    print(' -', param.kind)

위의 코드를 실행하면 결과는 아래와 같이 출력됩니다.

a
 - <class 'inspect._empty'>
 - POSITIONAL_OR_KEYWORD
b
 - 10
 - POSITIONAL_OR_KEYWORD
args
 - <class 'inspect._empty'>
 - VAR_POSITIONAL
c
 - None
 - KEYWORD_ONLY
kwargs
 - <class 'inspect._empty'>
 - VAR_KEYWORD

namedefault는 이름만으로도 용도가 짐작가듯이 인자의 이름과 기본값을 뜻합니다. 그런데 sample 함수의 경우 a, *args, **args는 기본값이 설정되지 않았습니다. 프로퍼티 값으로는 inspect._empty라는 결과가 나오는데 이를 통해 기본값이 설정되지 않은 것을 확인할 수 있습니다. 이 값이 왜 None이 아닌지는 c 파라미터를 통해 볼 수 있습니다. 기본값으로는 None도 사용가능하기 때문에 따로 선언되어 있습니다.

그럼 이 값이 empty인지는 어떻게 확인할까요? inspect._emptyimport해 확인해야 할까요? 다행히 확인하기 쉽게 inspect.Parameter의 프로퍼티로 empty가 있습니다. 따라서 param.default is param.empty만으로 기본값이 있는지 없는지를 확인해 볼 수 있습니다.

다음은 kind입니다. kind는 파라미터가 어떤 종류인지를 확인할 수 있습니다. 선언해 둔 def sample(a, b=10, *args, c=None, **kwargs)*args**kwargs를 볼 수 있습니다. 둘은 파이썬에서 지원하는 파라미터 관련 기능인데, 인자명 앞에 *을 붙이면 키워드가 지정되지 않고 파라미터가 정의 되지 않은 인자를 모두 가져올 수 있습니다. 그리고 **를 붙이면 키워드가 지정된 파라미터가 정의되지 않은 인자를 가져올 수 있습니다. 말로는 이해가 쉽지 않으니 sample 함수를 조금 바꾼 뒤 확인 해보도록 하겠습니다.

>>> def sample(a, b=10, *args, c=None, **kwargs):
...     print('a', a)
...     print('b', b)
...     print('c', c)
...     print('args', args)
...     print('kwargs', kwargs)
...
>>> sample(1, 2, 3, 4, c=5, d=6, e=7)
a 1
b 2
c 5
args (3, 4)
kwargs {'e': 7, 'd': 6}

이 결과를 위에서 본 kind 정보와 함께 보겠습니다.

인자명 kind
a 1 POSITIONAL_OR_KEYWORD
b 2 POSITIONAL_OR_KEYWORD
c 5 KEYWORD_ONLY
args (3, 4) VAR_POSITIONAL
kwargs {‘e’: 7, ’d': 6} VAR_KEYWORD

ab는 1, 2가 순서대로 들어갔습니다. 하지만 c는 값을 지정한 5가 들어갔는데, 앞에 *args를 선언해서 위치만으로는 입력이 되지 않기 때문입니다. 이러한 정보는 kind로 확인할 수 있습니다. c를 보면 KEYWORD_ONLY로 지정되어 있습니다. abPOSITIONAL_OR_KEYWORD인데 이걸로 확인할 수 있듯이 c는 키워드로만 인자를 쓸 수 있고, ab는 키워드를 지정하지 않아도 쓸 수 있음을 알 수 있습니다. kind 타입 목록은 kind에 대한 설명에서 확인할 수 있습니다. 단, POSITION_ONLY는 아직 논의중입니다.

위의 목록을 보면 argsVAR_POSITIONAL가, kwargsVAR_KEYWORD로 되어있는 것을 볼 수 있습니다. 여기까지 확인했으면 간단한 데코레이터를 만들어 보겠습니다.

from functools import wraps
from inspect import signature

    
def param_info(func):
    sig = signature(func)
    for param in sig.parameters.values():
        print(param.name)
        print(' -', param.default)
        print(' -', param.kind)


def safe_param(func):
    ok_args = False
    ok_kwargs = False
    
    list_params = []
    keyword_params = set()
    
    sig = signature(func)
    for param in sig.parameters.values():
        if param.kind == param.VAR_POSITIONAL:
            ok_args = True
        if param.kind == param.VAR_KEYWORD:
            ok_kwargs = True
            
        if param.kind in [param.POSITIONAL_OR_KEYWORD]:
            list_params.append(param.name)
        if param.kind in [param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY]:
            keyword_params.add(param.name)
            
    def get_default_value(param_name):
        original = sig.parameters[param_name]
        no_default = original.default is original.empty
        return None if original.default is original.empty else original.default
            
    @wraps(func)
    def wrap(*args, **kwargs):
        if not ok_args:
            args = args[:len(list_params)]
        
        if not ok_kwargs:
            temp = {k: v for k, v in kwargs.items() if k in keyword_params}
            kwargs = temp
        
        if len(args) < len(list_params):
            not_set_list_params = list_params[len(args):]
            for param in not_set_list_params:
                if param in kwargs:
                    continue
                    
                kwargs[param] = get_default_value(param)
        
        not_set_keyword_params = keyword_params - set(list_params) - set(kwargs.keys())
        for param in not_set_keyword_params:
            kwargs[param] = get_default_value(param)
        
        return func(*args, **kwargs)
    return wrap

param_info 함수는 위에서 파라미터 정보를 볼 때 사용한 코드를 함수로 바꾼 겁니다. 아래의 safe_param 함수는 인자로 값을 넣지 않아도 자동으로 None으로 설정하고, 혹은 *args**kwargs가 없어도 너무 많이 넣거나 정의되지 않은 인자는 제외해주는 함수입니다.

>>> def sample(a, b, *args, c, **kwargs):
...     print('a', a)
...     print('b', b)
...     print('c', c)
...     print('args', args)
...     print('kwargs', kwargs)
... 
>>> safe_sample = safe_param(sample)    
>>> param_info(sample)
a
 - <class 'inspect._empty'>
 - POSITIONAL_OR_KEYWORD
b
 - <class 'inspect._empty'>
 - POSITIONAL_OR_KEYWORD
args
 - <class 'inspect._empty'>
 - VAR_POSITIONAL
c
 - <class 'inspect._empty'>
 - KEYWORD_ONLY
kwargs
 - <class 'inspect._empty'>
 - VAR_KEYWORD
>>> param_info(safe_sample)
a
 - <class 'inspect._empty'>
 - POSITIONAL_OR_KEYWORD
b
 - <class 'inspect._empty'>
 - POSITIONAL_OR_KEYWORD
args
 - <class 'inspect._empty'>
 - VAR_POSITIONAL
c
 - <class 'inspect._empty'>
 - KEYWORD_ONLY
kwargs
 - <class 'inspect._empty'>
 - VAR_KEYWORD

safe_param으로 데코레이팅 해도 functools.wraps를 사용했기 때문에 파라미터 정보는 그대로 추출 가능합니다. 잘 돌아가는지 확인해보겠습니다.

>>> safe_sample(1,2,3,4,5,6,7, d=10)
a 1
b 2
c None
args (3, 4, 5, 6, 7)
kwargs {'d': 10}
>>> sample(1,2,3,4,5,6,7, d=10)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: sample() missing 1 required keyword-only argument: 'c'

safe_param으로 데코레이팅한 함수는 문제 없이 작동합니다. cKEYWORD_ONLY라서 3이 아닌 기본 값 None이 설정되었습니다. 데코레이팅 하지 않은 원본 함수는 c 파라미터가 없어서 에러가 납니다. 위의 데코레이터가 잘 작동하는 것을 확인할 수 있습니다.

>>> def sample2(a, b, *, c):
...     print('a', a)
...     print('b', b)
...     print('c', c)
...
>>> safe_sample2 = safe_param(sample2)
>>> 
>>> safe_sample2(1,2,3,4,5,6,7, d=10)
a 1
b 2
c None
>>> sample2(1,2,3,4,5,6,7, d=10)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: sample2() got an unexpected keyword argument 'd'

이번에는 조금 수정해서 *args**kwargs를 제외하고 함수를 작성해 봤습니다. *args**kwargs가 없어도 잘 동작하는 것을 확인할 수 있습니다. 조금 더 수정해서 기본값을 설정할 수 있는 데코레이터로 만들어 보겠습니다.

def safe_param(default=None):
    def deco(func):
        ok_args = False
        ok_kwargs = False

        list_params = []
        keyword_params = set()

        sig = signature(func)
        for param in sig.parameters.values():
            if param.kind == param.VAR_POSITIONAL:
                ok_args = True
            if param.kind == param.VAR_KEYWORD:
                ok_kwargs = True

            if param.kind in [param.POSITIONAL_OR_KEYWORD]:
                list_params.append(param.name)
            if param.kind in [param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY]:
                keyword_params.add(param.name)

        def get_default_value(param_name):
            original = sig.parameters[param_name]
            no_default = original.default is original.empty
            return default if original.default is original.empty else original.default

        @wraps(func)
        def wrap(*args, **kwargs):
            if not ok_args:
                args = args[:len(list_params)]

            if not ok_kwargs:
                temp = {k: v for k, v in kwargs.items() if k in keyword_params}
                kwargs = temp

            if len(args) < len(list_params):
                not_set_list_params = list_params[len(args):]
                for param in not_set_list_params:
                    if param in kwargs:
                        continue

                    kwargs[param] = get_default_value(param)

            not_set_keyword_params = keyword_params - set(list_params) - set(kwargs.keys())
            for param in not_set_keyword_params:
                kwargs[param] = get_default_value(param)

            return func(*args, **kwargs)
        return wrap
    return deco

>>> @safe_param('is default')
... def sample3(a, b, *args, c, **kwargs):
...     print('a', a)
...     print('b', b)
...     print('c', c)
...     print('args', args)
...     print('kwargs', kwargs)
... 
>>> sample3(1,2,3,4,5,6,7, d=10)
a 1
b 2
c is default
args (3, 4, 5, 6, 7)
kwargs {'d': 10}

잘 작동하는 것을 확인할 수 있습니다. 이로써 원하는 inspect.signature로 함수의 파라미터를 읽어 원하는 방식으로 처리해 볼 수 있었습니다.

위의 코드들은 Github 저장소에서 확인할 수 있고, MIT License로 사용 가능합니다.