[PyCon KR] Backend.AI에 점진적 typing 적용하기

Ed7b6f41ac2581f1be3fd9b5bc883875?s=47 Joongi Kim
September 26, 2020

[PyCon KR] Backend.AI에 점진적 typing 적용하기

Backend.AI 개발과정에서 Python의 새로운 기능인 type annotation을 활용하며 알게 된 것들을 정리해보았습니다.

Ed7b6f41ac2581f1be3fd9b5bc883875?s=128

Joongi Kim

September 26, 2020
Tweet

Transcript

  1. Backend.AI에 점진적 typing 적용하기 김준기 Lablup Inc.

  2. None
  3. § CTO @ Lablup Inc. – Backend.AI 주 개발자 (2015년

    공개) About Me @achimnol @achimnol
  4. § 타입 주석 개념과 도입 효과 § 타입 주석을 활용하는

    개발환경 준비하기 (VSCode, vim-lsp) § 코루틴과 제네레이터에 타입 주석 넣기 § Protocol로 복잡한 타입 정의 및 조합하기 § Generic으로 플러그인 시스템 설계하기 § 동적 타입 검사를 보완적으로 활용하기 (JSON + trafaret) § Python 미래 버전의 타입 관련 기능 소개 Contents
  5. [1] https://twitter.com/mmastrac/status/536332443398057984

  6. § Python은 타입이 없다? 약한 타입 언어다? No! § Python은

    강타입 언어이며 동적 타입 언어이다. – Strong type: 오브젝트의 타입이 중간이 예측 불가능한 방식으로 변하지 않음 – Dynamic type: 변수가 아닌 오브젝트에 타입이 지정됨 üb = 1; b = 'a'가 가능한 이유 : 변수 b는 오브젝트를 가리키는 이름일 뿐이며 1이나 'a'의 타입은 변하지 않음 ü단점 : IDE나 타입검사 도구가 변수의 타입을 정적으로 결정하기 어려움 (코드의 가능한 모든 분기를 분석하거나 코드를 실행해봐야 알 수 있음) Myth busting
  7. § PEP-484: Type Hints – https://docs.python.org/3/library/typing.html – 동적 타입인 Python

    코드를 정적 타입(static typing)으로 작성하기 위한 명세 – 함수 인자, 함수의 반환값, 변수 (property 포함) 타입 정의 가능 – 말 그대로 주석이므로 런타임에 별도의 검사가 이뤄지지 않고 무시됨 § 도입 효과 – 여러 사람이 작성하는 규모가 큰 프로젝트에서 코드 유지보수가 쉬워짐 – IDE나 type checker가 사전에 타입 불일치에 의한 버그를 발견할 수 있게 됨 ü특히 값이 None인 경우에 대한 처리 빠뜨리는 코드 – 읽을 거리 : https://dropbox.tech/application/our-journey-to-type-checking-4- million-lines-of-python 타입 주석(type annotation)
  8. 타입 주석 예제 def myatoi(arg1: int = None) -> str:

    ... class Document: title: str body: str metadata: Mapping[str, Any] parser: ClassVar[Parser] def somecode(): ... q: asyncio.Queue[bytes] = asyncio.Queue() ... def somecode2(): ... q: asyncio.Queue[bytes] q = asyncio.Queue() 함수의 인자와 반환 타입 지정 § 현존 static type checker 구현들은 인자 기본값이 None인 경우 Optional 붙은 것으로 자동 해석함 § mypy는 반환 타입이 지정되었을 때만 함수 본문의 타입 검사를 수행함 클래스·인스턴스 변수의 타입 지정 § 명시하지 않은 경우, __init__() 메소드 내에서 self의 속성에 할당하는 값의 형식에 따라 자동 추론하기도 함 일반 변수의 타입 지정 § 특히 다른 변수들을 담을 수 있는 컨테이너(자료구조) 타입에 대해서는 generic을 활용하여 하위 타입을 정의할 수 있음 일반 변수의 사용 전 타입 선언 § 런타임에는 아무 기능을 하지 않는 코드이지만 별도의 문장으로 타입 선언만 먼저 해두는 것도 가능함 ⚠
  9. 타입 주석 활용한 구조체 class Point(typing.NamedTuple): x: int y: int

    class Point(typing.TypedDict): x: int y: int @dataclass.dataclass class Point: x: int y: int @attr.s(auto_attribs=True) class Point: x: int y: int 내장 dataclass 패키지 및 외부 attr 패키지 § 인스턴스 변수의 타입 선언을 런타임에 읽어들여 그에 맞는 타입을 갖는 생성자 및 속성 접근자를 제공하는 구조체 클래스를 생성 collections.namedtuple의 다른 선언 방법 § 필드의 타입을 지정한 named tuple을 클래스로 선언 § __annotations__ 속성이 추가 제공되어 필드별 타입을 dict 형식으로 조회할 수 있음 dict의 key별 타입을 고정하여 사용하는 방법 § 런타임 동작은 일반 dict와 동일하지만 static type checker들이 키의 존재 유무 및 타입 검사가 가능하도록 도와줌
  10. § VSCode + Python – Microsoft에서 공식 개발한 확장기능 제공

    타입 주석 활용 개발환경 만들기
  11. § vim/emacs + LSP plugins + python-language-server – python-language-server는 해당

    프로젝트의 virtualenv 환경 안에 설치 타입 주석 활용 개발환경 만들기
  12. § PyCharm – 타입 해석기·검사기 내장 및 편집 중 실시간

    리포트 제공 타입 주석 활용 개발환경 만들기 https://blog.jetbrains.com/pycharm/2015/11/python-3-5-type-hinting-in-pycharm-5/
  13. 코루틴과 제네레이터 타입 § 함수가 반환하는 값이 다른 값들을 생성하는

    generator 및 iterator인 경우 – 둘의 차이에 대해서는 관련 블로그 글 및 번역문을 참고! https://mingrammer.com/translation-iterators-vs-generators/ § Iterator 자리에는 Generator를 넣을 수 있지만, Generator 자리에는 Iterator를 항상 넣을 수는 없음 Generator[YieldType, SendType, ReturnType] AsyncGenerator[YieldType, SendType] Iterator[YieldType] AsyncIterator[YieldType] Coroutine[YieldType, SendType, ReturnType] = Generator + __awaitable__() Awaitable[ReturnType]
  14. async 함수의 타입 def myfunc() -> int: return 1 async

    def myfunc() -> int: return 1 Type check Result callable(myfunc), inspect.isfunction(myfunc) True inspect.iscoroutinefunction(myfunc) False type(myfunc) <class 'function'> type(myfunc()) int Type check Result callable(myfunc), inspect.isfunction(myfunc) True inspect.iscoroutinefunction(myfunc) True type(myfunc) <class 'function'> type(myfunc()) <class 'coroutine'> inspect.iscoroutine(myfunc()) True type(await myfunc()) int
  15. async generator의 타입 def myfunc( ) -> Generator[int, None, None]:

    yield 1 async def myfunc( ) -> AsyncGenerator[int, None]: yield 1 Type check Result callable(myfunc), inspect.isfunction(myfunc) True inspect.iscoroutinefunction(myfunc) False type(myfunc) <class 'function'> type(myfunc()) <class 'generator'> inspect.iscoroutine(myfync()) False inspect.isgenerator(myfunc()) True inspect.isasyncgen(myfunc()) False Type check Result callable(myfunc), inspect.isfunction(myfunc) True inspect.iscoroutinefunction(myfunc) False type(myfunc) <class 'function'> type(myfunc()) <class 'async_generator'> inspect.iscoroutine(myfunc()) False inspect.isgenerator(myfunc()) False inspect.isasyncgen(myfunc()) True Python generator에 대해서 inpsect.iscoroutine()은 False이지만, 개념적으로는 호출자와 generator 간의 양방향 소통이 가능하다는 점에서는 corotuine으로 간주할 수 있음. 다만 Python의 용어로는 coroutine은 async 함수만을 뜻함.
  16. async context manager의 타입 § contextlib.contextmanager는 한 번만 yield하는 Iterator를

    감싸는 형태 § contextlib.asynccontextmanager는 마찬가지로 AsyncIterator를 감쌈 from contextlib import contextmanager @contextmanager def myctx(location: str) -> Iterator[Resource]: r = Resource.open(location) try: yield r finally: r.close() from contextlib import asynccontextmanager @asynccontextmanager async def myctx(location: str) -> AsyncIterator[Resource]: r = await Resource.open(location) try: yield r finally: await r.close()
  17. § PEP-544: Protocols: Structural subtyping (static duck typing) – Mixin처럼

    특정 메소드들의 부분적 인터페이스 요건만 따로 정의할 수 있음 – 하나의 오브젝트가 여러 Protocol을 구현할 수 있음 (=조합할 수 있음) – 예시) Iterable, Iterator, Callable, Sized, Hashable, ... Protocol로 부분적 타입 정의하기 from typing import Protocol class Closable(Protocol): def close(self) -> None: pass def close_all( things: Iterable[Closable] ) -> None: for t in things: t.close() f = open('foo.txt') close_all([f]) # OK! close_all([1]) # Error: 'int' has no 'close' method
  18. § 가장 큰 활용처 : Callback 함수의 signature가 복잡할 경우

    Protocol 클래스의 __call__ dunder method를 활용할 수 있음 Protocol로 "복잡한" 타입 정의하기 from typing import Protocol class MyCallback(Protocol): def __call__( self, context: Context, *, options: Mapping[str, Any] = None, ) -> None: pass from typing import Callable MyCallback = Callable[[Context, ?], None] keyword argument를 표현할 방법이 없다?! Protocol 클래스를 활용하면 keyword argument를 포함한 callable 인터페이스를 정의할 수 있다!
  19. § Generic + bound TypeVar 이용해서 type hierarchy 구성 Generic으로

    플러그인 설계하기 from typing import TypeVar, Generic, ... P = TypeVar('P', bound=AbstractPlugin) class BasePluginContext(Generic[P]): ... @classmethod def discover_plugins( cls, plugin_group: str, blocklist: Container[str] = None, ) -> Iterator[Tuple[str, Type[P]]]: ... class HookPluginContext(BasePluginContext[HookPlugin]): ... from abc import ABCMeta class AbstractPlugin(metaclass=ABCMeta): ... class HookPlugin( AbstractPlugin, metaclass=ABCMeta ): ... class ConcreteHookPlugin(HookPlugin): ...
  20. § 정적 타입은 Python의 클래스/변수에만 적용 가능 – JSON, msgpack과

    같은 경우는 어떻게 할까? § 런타임에 타입 검사하기 – typeguard : 타입 주석을 런타임에 읽어들여 검사 – trafaret : 임의의 직렬화 데이터를 런타임에 구조 검사 동적 타입 검사 기법(feat. JSON) import datetime import trafaret as t date = t.Dict(year=t.Int, month=t.Int, day=t.Int) >> (lambda d: datetime.datetime(**d)) assert date.check({'year': 2012, 'month': 1, 'day': 12}) == datetime.datetime(2012, 1, 12) (example from https://github.com/Deepwalker/trafaret/)
  21. § REST API의 JSON body 또는 query string으로 전달된 인자

    검사 및 변환 Backend.AI의 trafaret 활용 @attr.s(auto_attribs=True, slots=True, frozen=True) class DestroySessionParams: session_id: str @classmethod def as_trafaret(cls) -> t.Trafaret: return t.Dict({t.Key('session_id'): t.String}) async def destroy_session( request: web.Request ) -> web.Response: params = await check_params(request, DestroySessionParams) await do_destroy(params.session_id) return web.Response(status=204) async def check_params(request, param_cls): try: raw_params = await request.json() checked_params = param_cls.as_trafaret() \ .check(raw_params) return param_cls(**checked_params) except t.DataError as e: raise web.HTTPInvalidRequest(body=json.dumps({ "title": "Invalid API parameters", "type": "https://api.backend.ai/probs" "/invalid-api-params", "data": e.as_dict(), }), content_type="application/problem+json") t.DataError는 다중 key를 가진 t.Dict에서 여러 key에서 오류가 발견될 경우 모든 오류 key에 대한 오류 설명을 포함하고 있기 때문에 디버깅 및 사용자 친화적 오류 UX 설계에 도움이 됨
  22. § TOML/etcd로부터 읽어들인 config 검사 및 변환 Backend.AI의 trafaret 활용

    local_config_iv = t.Dict({ t.Key('agent'): t.Dict({ t.Key('node_id'): t.String, t.Key('service_addr', default=('127.0.0.1', 6001)): tx.HostPortPair, }).allow_extras('*'), }).allow_extras('*') # for forward-compatibility ※ tx는 ai.backend.common.validators 모듈[1]의 alias로 trafaret이 기본제공하지 않는 확장 변환기들을 추가 제공함 (예: UUID, Slug, TimeDuration, JsonWebToken 등) raw_config = toml.loads("agent.toml") try: config = local_config_iv.check(raw_config) except t.DataError as e: raise ConfigurationError(e.as_dict()) ... [1] https://github.com/lablup/backend.ai-common/blob/master/src/ai/backend/common/validators.py
  23. § PEP-563: Postponed Evaluation of Annotations – 모듈 로딩 시

    타입주석을 AST 구문분석만 하고 실제로 실행하지 않음 – Python 3.7부터 __future__.annotations 불러와 활성화 가능 (Python 3.10에서 기본으로 활성화) § PEP-585: Type Hinting Generics In Standard Collections – Python 3.9 주요 신기능! – 내장 컨테이너 타입들에 제네릭 요소 타입 지정 기능 곧 도입될 기능들 from typing import List a: List[int] a: list[int]