Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 749 – PEP 649 구현

Author:
Jelle Zijlstra <jelle.zijlstra at gmail.com>
Discussions-To:
Discourse thread
Status:
Final
Type:
Standards Track
Topic:
Typing
Requires:
649
Created:
28-May-2024
Python-Version:
3.14
Post-History:
04-Jun-2024
Resolution:
05-May-2025

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

Important

This PEP is a historical document. The up-to-date, canonical documentation can now be found at Annotations and annotationlib.

×

See PEP 1 for how to propose changes.

초록

이 PEP는 PEP 649의 명세를 여러 가지로 조정하고 보충하여 이를 보완합니다:

  • from __future__ import annotations (PEP 563)는 적어도 Python 3.13의 지원이 종료될 때까지 현재 동작을 유지한 채 계속 존재합니다. 그 이후에는 폐지 예정(deprecated)으로 지정되고 최종적으로 제거됩니다.
  • 어노테이션을 위한 도구를 제공하는 새로운 표준 라이브러리 모듈 annotationlib이 추가됩니다. 여기에는 get_annotations() 함수, 어노테이션 형식을 위한 enum, ForwardRef 클래스, 그리고 __annotate__ 함수를 호출하기 위한 헬퍼 함수가 포함됩니다.
  • REPL에서의 어노테이션은 다른 모듈 수준 어노테이션과 마찬가지로 지연 평가됩니다.
  • classmethod()나 functools.wraps()를 사용하는 코드처럼 어노테이션을 제공하는 래퍼 객체의 동작을 명세합니다.
  • “가짜 전역(fake globals)” 환경에서 실행할 수 있는 __annotate__ 함수를 표시하기 위한 코드 플래그는 두지 않습니다. 그 대신 네 번째 형식인 VALUE_WITH_FAKE_GLOBALS를 추가하여, annotate 함수를 구현하는 서드파티가 자신이 지원하는 형식을 나타낼 수 있도록 합니다.
  • __annotations__ 속성을 직접 삭제하면 __annotate__도 함께 지워집니다.
  • 타입 별칭 값과 타입 매개변수의 상한 및 기본값(PEP 695와 PEP 696에서 추가됨)을 PEP 649와 유사한 의미 체계로 평가할 수 있게 하는 기능을 추가합니다.
  • 명확성을 높이고 사용자가 혼동할 위험을 줄이기 위해 SOURCE 형식의 이름을 STRING으로 바꿉니다.
  • 조건부로 정의된 클래스 및 모듈 어노테이션을 올바르게 처리합니다.
  • 부분적으로 실행된 모듈에서 어노테이션에 접근하면 그때까지 실행된 어노테이션이 반환되지만, 캐시되지는 않습니다.

동기

PEP 649는 Python 어노테이션의 의미 체계를 개선하기 위한 훌륭한 프레임워크를 제공합니다. 이 PEP는 정적 타입 힌트를 사용하는 사용자와 런타임 타이핑을 사용하는 사용자를 포함하여 어노테이션 사용자들이 흔히 겪는 문제를 해결하며, 언어를 더 우아하고 강력하게 만듭니다. 이 PEP는 원래 2021년에 Python 3.10을 대상으로 제안되었고, 2023년에 수락되었습니다. 그러나 구현이 예상보다 오래 걸렸으며, 현재는 이 PEP가 Python 3.14에서 구현될 것으로 예상됩니다.

저는 CPython에서 이 PEP의 구현 작업을 시작했습니다. 그 과정에서 이 PEP가 일부 영역을 충분히 명세하지 않았고, 코너 케이스에 대한 일부 결정에는 의문의 여지가 있다는 것을 발견했습니다. 이 새로운 PEP는 이러한 문제를 해결하기 위해 명세에 대한 몇 가지 변경 및 추가 사항을 제안합니다.

이 PEP는 PEP 649를 대체하는 것이 아니라 보완합니다. 여기에서 제안하는 변경 사항은 전반적인 사용자 경험을 개선하겠지만, 이전 PEP의 전반적인 프레임워크를 바꾸지는 않습니다.

from __future__ import annotations의 미래

앞서 PEP 563은 모든 어노테이션을 문자열로 바꾸는 future 임포트 from __future__ import annotations를 도입했습니다. PEP 649는 이 future 임포트가 필요 없는 대안적 접근 방식을 제안하며, 다음과 같이 명시합니다:

이 PEP가 수락되면 PEP 563은 폐지 예정으로 지정되고 최종적으로 제거됩니다.

그러나 이 PEP는 이 폐지에 대한 상세한 계획을 제시하지 않습니다.

이 주제에 대해서는 이전에 Discourse에서 논의된 바가 있습니다(링크된 게시물에서 제가 제안한 내용은 여기에서 제안하는 것과 다르다는 점에 유의하십시오).

명세

다음과 같은 폐지(deprecation) 계획을 제안합니다.

  • Python 3.14에서 from __future__ import annotations는 이전과 마찬가지로 계속 동작하며, 어노테이션을 문자열로 변환합니다.
    • future 임포트가 활성화되어 있으면, 어노테이션이 있는 객체의 __annotate__ 함수는 VALUE 형식으로 호출될 때 __annotations__의 동작을 반영하여 어노테이션을 문자열로 반환합니다.
  • PEP 649 의미 체계를 지원하지 않는 마지막 릴리스(3.13으로 예상됩니다)가 지원 종료(end-of-life)에 도달한 후 어느 시점에 from __future__ import annotations는 폐지됩니다. future 임포트를 사용하는 코드를 컴파일하면 DeprecationWarning이 발생합니다. 이는 빨라도 Python 3.13이 지원 종료에 도달한 후의 첫 번째 릴리스에서 이루어지며, 커뮤니티가 더 오래 기다리기로 결정할 수도 있습니다.
  • 최소 두 릴리스가 지난 후에는 future 임포트가 제거되며, 어노테이션은 항상 PEP 649에 따라 평가됩니다. future 임포트를 계속 사용하는 코드는 정의되지 않은 다른 future 임포트와 마찬가지로 SyntaxError를 발생시킵니다.

기각된 대안

future 임포트를 즉시 no-op으로 만들기: Python 3.14의 모든 코드에 PEP 649 의미 체계를 적용하여 future 임포트를 아무 동작도 하지 않는 no-op으로 만드는 방안을 검토했습니다. 그러나 이렇게 하면 다음 조건들이 모두 충족될 때 3.13에서 동작하던 코드가 깨집니다.

  • __future__ import annotations가 활성화되어 있습니다
  • 전방 참조(forward reference)에 의존하는 어노테이션이 있습니다
  • 어노테이션이 임포트 시점에 즉시 평가됩니다. 예를 들어 메타클래스나 클래스 데코레이터 또는 함수 데코레이터가 이를 평가하는 경우입니다. 예를 들어, 현재 릴리스된 버전의 typing_extensions.TypedDict가 이에 해당합니다.

이는 흔한 패턴일 것으로 예상되므로, 3.13에서 3.14로 업그레이드하는 과정에서 이러한 코드가 깨지는 것은 감수할 수 없습니다.

이러한 코드는 future 임포트가 결국 제거될 때에는 여전히 깨집니다. 그러나 이는 여러 해 뒤의 일이므로, 영향을 받는 라이브러리들이 코드를 수정할 시간이 충분합니다.

future 임포트를 즉시 폐지하기: Python 3.13이 지원 종료에 도달할 때까지 기다리는 대신, future 임포트가 사용될 때 즉시 경고를 내보내기 시작할 수도 있습니다. 그러나 이미 많은 라이브러리가 어노테이션에서 제약 없는 전방 참조를 가능하게 하는 우아한 방법으로 from __future__ import annotations를 사용하고 있습니다. future 임포트를 즉시 폐지하면, 이러한 라이브러리들은 폐지 경고를 피하면서 지원하는 모든 Python 버전에서 제약 없는 전방 참조를 사용할 수 없게 됩니다. 표준 라이브러리에서 폐지되는 다른 기능과 달리 __future__ 임포트는 해당 모듈의 첫 번째 문장이어야 하므로, Python 3.13 이하에서만 조건부로 __future__.annotations를 임포트하는 것이 불가능하기 때문입니다. (필요한 sys.version_info 검사가 __future__ 임포트보다 앞선 문장으로 간주됩니다.)

future 임포트를 영원히 유지하기: future 임포트를 무기한 유지하기로 결정할 수도 있습니다. 그러나 이렇게 하면 Python 언어의 동작이 영구적으로 두 갈래로 나뉩니다. 이는 바람직하지 않습니다. 언어는 영구적으로 서로 다른 두 가지 모드가 아니라 단 하나의 의미 체계만 가져야 합니다.

향후 future 임포트를 no-op으로 만들기: 결국 from __future__ import annotations를 SyntaxError로 만드는 대신, Python 3.13이 지원 종료에 도달한 후 어느 시점에 이를 아무 동작도 하지 않도록 만들 수도 있습니다. 생태계가 적응할 시간은 훨씬 더 길어지겠지만, 이 방안에도 지금 no-op으로 만드는 것과 관련하여 위에서 설명한 문제 중 일부가 여전히 남아 있습니다. 사용자가 문자열화된 어노테이션에 의존하지 않음을 확인한 뒤 향후 코드에서 future 임포트를 명시적으로 제거하도록 하는 편이 더 낫습니다.

새로운 annotationlib 모듈

PEP 649는 어노테이션 관련 도구를 inspect 모듈에 추가할 것을 제안합니다. 그러나 그 모듈은 상당히 크고, 적어도 35개의 다른 표준 라이브러리 모듈에 직접 또는 간접적인 의존성이 있으며, 임포트가 매우 느려서 다른 표준 라이브러리 모듈이 이를 임포트하지 않도록 권장되는 경우가 많습니다. 또한 inspect.get_annotations() 함수와 VALUE, FORWARDREF, SOURCE 형식 외에도 더 많은 도구를 추가할 것으로 예상합니다.

새로운 표준 라이브러리 모듈은 이 기능을 위한 논리적인 자리가 되며, 어노테이션을 사용하는 쪽에 유용한 도구를 더 추가할 수 있게 해 줍니다.

근거

PEP 649는 inspect.get_annotations()에서 FORWARDREF 형식을 구현하는 데 typing.ForwardRef를 사용해야 한다고 명시합니다. 그러나 typing.ForwardRef의 기존 구현은 typing 모듈의 나머지 부분과 얽혀 있으며, 범용 함수인 get_annotations()에 typing 전용 동작을 추가하는 것은 타당하지 않습니다. 게다가 typing.ForwardRef는 문제가 있는 클래스입니다. 공개되어 있고 문서화되어 있지만, 문서에는 이 클래스의 속성이나 메서드가 전혀 나열되어 있지 않습니다. 그럼에도 서드파티 라이브러리들은 이 클래스의 문서화되지 않은 속성 중 일부를 사용합니다. 예를 들어 Pydantic과 Typeguard는 _evaluate 메서드를 사용하고, beartype과 pyanalyze는 __forward_arg__ 속성을 사용합니다.

기존에 존재하지만 명세가 부실한 typing.ForwardRef를 새로운 클래스인 annotationlib.ForwardRef로 대체합니다. 이 클래스는 typing.ForwardRef 클래스의 기존 사용 방식과 대부분 호환되도록 설계되었지만, typing 모듈에 특화된 동작은 포함하지 않습니다. 기존 사용자와의 호환성을 위해 비공개 _evaluate 메서드는 유지하되, 폐지 예정(deprecated)으로 표시합니다. 이 메서드는 typing 모듈의 새로운 공개 함수인 typing.evaluate_forward_ref에 처리를 위임하며, 이 함수는 타입 힌트에 특화된 방식으로 전방 참조를 평가하도록 설계되었습니다.

__annotate__ 함수 호출을 돕는 헬퍼로 annotationlib.call_annotate_function 함수를 추가합니다. 이 함수는 클래스가 생성되는 도중에 어노테이션을 부분적으로 평가해야 하는 기능을 구현할 때 유용한 구성 요소입니다. 예를 들어 typing.NamedTuple의 구현은 namedtuple 클래스 자체가 생성되기 전에 클래스 네임스페이스 딕셔너리에서 어노테이션을 가져와야 합니다. 어노테이션이 namedtuple에 어떤 필드가 존재하는지를 결정하기 때문입니다.

명세

표준 라이브러리에 annotationlib이라는 새 모듈이 추가됩니다. 이 모듈의 목적은 어노테이션을 인트로스펙션하고 래핑하는 도구를 제공하는 것입니다.

이 모듈의 설계는 표준 라이브러리(예: dataclasses 및 typing.TypedDict)가 PEP 649 의미론을 사용하도록 갱신한 경험을 바탕으로 합니다.

이 모듈은 다음 기능을 포함합니다:

  • get_annotations(): 함수, 모듈 또는 클래스의 어노테이션을 반환하는 함수입니다. 이 함수는 inspect.get_annotations()를 대체합니다. 후자는 새 함수에 처리를 위임합니다. 후자는 결국 폐지될 수도 있지만, 혼란을 최소화하기 위해 즉각적인 폐지는 제안하지 않습니다.
  • get_annotate_from_class_namespace(namespace: Mapping[str, Any]): 클래스 네임스페이스 딕셔너리에서 __annotate__ 함수를 반환하고, 없으면 None을 반환하는 함수입니다. 이 함수는 클래스 생성 중에 메타클래스에서 유용합니다. __annotate__ 함수의 내부 저장 방식에 관한 구현 세부 사항이 노출되지 않도록 별도의 함수로 제공합니다(아래 참조).
  • Format: 어노테이션이 가질 수 있는 형식들을 담은 열거형입니다. 이 열거형은 PEP 649의 VALUE, FORWARDREF, SOURCE 형식을 대체합니다. PEP 649는 이 값들을 inspect 모듈의 전역 멤버로 만들 것을 제안했지만, 이 PEP에서는 이 값들을 열거형 안에 두는 방식을 선호합니다. 네 번째 형식인 VALUE_WITH_FAKE_GLOBALS를 추가할 것을 제안합니다(아래 참조).
  • ForwardRef: 전방 참조를 나타내는 클래스로, 형식이 FORWARDREF일 때 get_annotations()가 반환할 수 있습니다. 기존 클래스 typing.ForwardRef는 이 클래스의 별칭이 됩니다. 이 클래스의 멤버는 다음과 같습니다:
    • __forward_arg__: 전방 참조의 문자열 인자입니다.
    • evaluate(globals=None, locals=None, type_params=None, owner=None): 전방 참조의 평가를 시도하는 메서드입니다. ForwardRef 객체는 자신이 유래한 객체의 전역 네임스페이스 및 기타 네임스페이스에 대한 참조를 보유할 수 있습니다. 그런 경우 이 네임스페이스들을 사용하여 전방 참조를 평가할 수 있습니다. owner 인자는 클래스 객체나 모듈 객체처럼 원래 어노테이션을 보유한 객체일 수 있으며, 전역 및 지역 네임스페이스가 제공되지 않은 경우 이 네임스페이스들을 추출하는 데 사용됩니다.
    • _evaluate(), 기존 ForwardRef._evaluate 메서드와 같은 인터페이스를 갖는 메서드입니다. 이 메서드는 문서화되지 않으며 즉시 폐지 예정(deprecated)으로 지정됩니다. 이 메서드는 typing.ForwardRef의 기존 사용자와의 호환성을 위해 제공됩니다.
  • call_annotate_function(func: Callable, format: Format): 주어진 형식으로 __annotate__ 함수를 호출하기 위한 도우미 함수입니다. 함수가 이 형식을 지원하지 않으면 call_annotate_function()은 PEP 649에 설명된 대로 “가짜 전역(fake globals)” 환경을 구성하고, 그 환경을 사용하여 원하는 어노테이션 형식을 반환합니다.
  • call_evaluate_function(func: Callable | None, format: Format): call_annotate_function과 비슷하지만, 함수가 어노테이션 딕셔너리를 반환하는 것에 의존하지 않습니다. 이 함수는 PEP 695 및 PEP 696에서 도입된 지연된 속성을 평가하는 데 사용하기 위한 것입니다. 자세한 내용은 아래를 참조하십시오. 편의를 위해 func는 None일 수 있으며, None이 전달되면 이 함수도 None을 반환합니다.
  • annotations_to_string(annotations: dict[str, object]) -> dict[str, str]: 어노테이션 딕셔너리의 각 값을 문자열 표현으로 변환하는 함수입니다. 이 함수는 typing.TypedDict의 함수형 문법처럼 원본 소스를 사용할 수 없는 경우에 SOURCE 형식을 구현하는 데 유용합니다.
  • type_repr(value: object) -> str: 단일 값을 문자열 표현으로 변환하는 함수입니다. 이 함수는 annotations_to_string에서 사용됩니다. 대부분의 값에는 repr()을 사용하지만, 타입에 대해서는 완전한 정규화된 이름을 반환합니다. 또한 typing 및 collections.abc 모듈에 있는 여러 객체의 repr()을 위한 도우미로도 유용합니다.

typing 모듈에도 새 함수 typing.evaluate_forward_ref가 추가됩니다. 이 함수는 ForwardRef.evaluate 메서드를 감싸는 래퍼이지만, 타입 힌트에 특화된 추가 작업을 수행합니다. 예를 들어, 복잡한 타입 내부로 재귀적으로 들어가 그 타입 안에 있는 추가 전방 참조들을 평가합니다.

PEP 649와 달리, 어노테이션 형식(VALUE, FORWARDREF, SOURCE)은 inspect 모듈의 전역 멤버로 추가되지 않습니다. 이 상수들을 참조하는 유일한 권장 방법은 annotationlib.Format.VALUE와 같은 형태가 될 것입니다.

기각된 대안

다른 이름 사용: 이름 짓기는 어려운 일이며, 저는 여러 아이디어를 고려했습니다:

  • annotations: 가장 명확한 이름이지만, 사용자가 같은 모듈에 import annotations와 from __future__ import annotations를 모두 둘 수 있으므로 기존의 from __future__ import annotations와 혼동을 일으킬 수 있습니다. 흔한 단어를 이름으로 사용하면 모듈을 검색하기가 더 어려워집니다. PyPI 패키지 annotations가 있지만, 2015년에 단 한 번 릴리스되었을 뿐이며 방치된 것으로 보입니다.
  • annotation (단수형): 비슷하지만, future 임포트와 혼동을 일으키지는 않습니다. 방치된 PyPI 패키지 annotation이 있지만, 아무런 아티팩트도 릴리스한 적이 없는 것으로 보입니다.
  • annotools: itertools 및 functools와 유사하지만, “anno”는 “iter”나 “func”보다 덜 명확한 약어입니다. 이 글을 쓰는 시점에는 이 이름의 PyPI 패키지가 없습니다.
  • annotationtools: 더 명시적인 버전입니다. PyPI에 annotationtools 패키지가 있으며, 2023년에 릴리스된 적이 있습니다.
  • annotation_tools: 위 이름의 변형이지만 PyPI와의 충돌이 없습니다. 그러나 다른 공개 표준 라이브러리 모듈 중에는 이름에 밑줄이 들어간 것이 없습니다.
  • annotationslib: tomllib, pathlib, importlib과 유사한 이름입니다. 이 이름을 가진 PyPI 패키지는 없습니다.
  • annotationlib: 위와 유사하지만 한 글자 더 짧고, 주관적으로 더 잘 읽힙니다. 이 이름 역시 PyPI에서 사용되고 있지 않습니다.

annotationlib이 가장 좋은 선택지로 보입니다.

inspect 모듈에 기능 추가: 앞서 설명한 것처럼 inspect 모듈은 이미 상당히 크며, 일부 사용 사례에서는 임포트 시간이 감당하기 어려울 정도로 깁니다.

typing 모듈에 기능 추가: 어노테이션은 대부분 타입 지정에 사용되지만, 다른 목적으로도 사용될 수 있습니다. 저희는 어노테이션을 인트로스펙션하는 기능과 오로지 타입 힌트만을 위한 기능을 명확하게 분리하는 것을 선호합니다.

types 모듈에 기능 추가: types 모듈은 타입과 관련된 기능을 위한 모듈인데, 어노테이션은 타입뿐만 아니라 함수와 모듈에도 존재할 수 있습니다.

서드파티 패키지로 이 기능 개발: 이 새 모듈의 기능은 순수 Python 코드로 작성될 것이므로, 인터프리터가 생성하는 __annotate__ 함수와 직접 상호작용하여 동일한 기능을 제공하는 서드파티 패키지를 구현하는 것도 가능합니다. 그러나 제안된 새 모듈의 기능은 표준 라이브러리 자체에서도 분명히 유용할 것이므로(예: dataclasses와 typing.NamedTuple 구현), 이를 표준 라이브러리에 포함하는 것이 타당합니다.

비공개 모듈에 이 기능 추가: 처음에는 비공개 표준 라이브러리 모듈(예: _annotations)에서 모듈을 개발하고, API에 대한 경험을 더 쌓은 뒤에 공개하는 것도 가능합니다. 그러나 저희는 이 모듈의 일부가 표준 라이브러리 자체에 필요하다는 것을 이미 알고 있습니다(예: dataclasses와 typing.NamedTuple 구현). 비공개로 만들더라도 이 모듈은 필연적으로 서드파티 사용자들이 사용하게 될 것입니다. 서드파티 사용자가 표준 라이브러리만큼 철저하게 PEP 649 의미를 지원할 수 있도록, 처음부터 명확하고 문서화된 API로 시작하는 것이 바람직합니다. 이 모듈은 표준 라이브러리의 다른 부분에서 즉시 사용될 것이므로, 합리적인 범위의 사용 사례를 다룰 수 있음이 보장됩니다.

REPL의 동작

PEP 649는 대화형 REPL의 동작을 다음과 같이 규정합니다:

단순성을 위해, 이 경우에는 지연 평가를 포기합니다. REPL 셸의 모듈 수준 어노테이션은 “기본 의미”에서와 똑같이 계속 동작하여, 즉시 평가되고 그 결과가 __annotations__ 딕셔너리 안에 직접 설정됩니다.

이 제안된 동작에는 몇 가지 문제가 있습니다. 이 동작은 REPL을 어노테이션이 여전히 즉시 평가되는 유일한 환경으로 만들며, 이는 사용자에게 혼란을 주고 언어를 복잡하게 만듭니다.

또한 출력을 표시할 필요가 없는 경우에도 모든 문장이 “interactive” 모드로 컴파일되도록 보장해야 하므로 REPL의 구현도 더 복잡해집니다. (이는 REPL이 평가하는 한 줄에 여러 문장이 있는 경우에 문제가 됩니다.)

가장 중요한 점은, 이 동작이 경험이 부족한 사용자가 마주칠 수 있는 그럴듯한 사용 사례 일부를 망가뜨린다는 것입니다. 사용자가 파일에 다음과 같이 작성할 수 있습니다:

a: X | None = None
class X: ...

PEP 649에서는 이 코드가 문제없이 동작합니다: a의 어노테이션에서 X를 사용할 때 X는 아직 정의되지 않았지만, 어노테이션은 지연 평가됩니다. 그러나 사용자가 이 동일한 코드를 REPL에 붙여 넣고 한 줄씩 실행하면, X라는 이름이 아직 정의되지 않았기 때문에 NameError가 발생합니다.

이 주제는 이전에 Discourse에서 논의된 바 있습니다.

명세

대화형 콘솔을 다른 모듈 수준 코드와 똑같이 취급하고, 어노테이션을 지연 평가하도록 할 것을 제안합니다. 이렇게 하면 언어의 일관성이 높아지고, 모듈과 REPL 사이의 미묘한 동작 차이를 피할 수 있습니다.

REPL은 한 줄씩 평가되므로, 전역 스코프에서 평가되는 문장 중 어노테이션을 포함하는 문장마다 새로운 __annotate__ 함수를 생성하게 됩니다. 어노테이션을 포함하는 줄이 평가될 때마다 이전의 __annotate__ 함수는 사라집니다:

>>> x: int
>>> __annotate__(1)
{'x': <class 'int'>}
>>> y: str
>>> __annotate__(1)
{'y': <class 'str'>}
>>> z: doesntexist
>>> __annotate__(1)
Traceback (most recent call last):
File "<python-input-5>", line 1, in <module>
    __annotate__(1)
    ~~~~~~~~~~~~^^^
File "<python-input-4>", line 1, in __annotate__
    z: doesntexist
       ^^^^^^^^^^^
NameError: name 'doesntexist' is not defined

REPL의 전역 네임스페이스에는 __annotations__ 키가 존재하지 않습니다. 모듈 네임스페이스에서는 모듈 객체의 __annotations__ 디스크립터에 접근할 때 이 키가 지연 생성되지만, REPL에는 그러한 모듈 객체가 없습니다.

REPL 안에서 정의된 클래스와 함수도 다른 클래스와 똑같이 동작하므로, 그 어노테이션의 평가는 지연됩니다. __annotations__ 및 __annotate__ 속성에 접근하거나 annotationlib 모듈을 사용하여 어노테이션을 인트로스펙션할 수 있습니다.

__annotations__를 제공하는 래퍼

표준 라이브러리를 비롯한 여러 곳의 몇몇 객체는 자신이 감싼 객체의 어노테이션을 제공합니다. PEP 649는 이러한 래퍼가 어떻게 동작해야 하는지 명시하지 않습니다.

명세

어노테이션을 제공하는 래퍼는 다음 목표를 염두에 두고 설계해야 합니다:

  • __annotations__의 평가는 내장 함수, 클래스, 모듈의 동작과 일관되게 가능한 한 오래 지연되어야 합니다.
  • PEP 649가 구현되기 이전 동작과의 하위 호환성이 유지되어야 합니다.
  • __annotate__ 속성과 __annotations__ 속성은 모두 감싼 객체의 의미와 일관된 의미로 제공되어야 합니다.

더 구체적으로는 다음과 같습니다:

  • functools.update_wrapper()(따라서 functools.wraps()도)는 감싼 객체에서 래퍼로 __annotate__ 속성만 복사합니다. 래퍼 함수의 __annotations__ 디스크립터는 복사된 __annotate__를 사용합니다.
  • 현재 classmethod()와 staticmethod()의 생성자는 감싼 객체에서 래퍼로 __annotations__ 속성을 복사합니다. 대신 이들은 __annotate__와 __annotations__에 대해 쓰기 가능한 속성을 갖게 됩니다. 이 속성을 읽으면 기반 호출 가능 객체에서 해당 속성을 가져와 래퍼의 __dict__에 캐시합니다. 이 속성에 쓰면 감싼 호출 가능 객체에는 영향을 주지 않고 __dict__를 직접 갱신합니다.

어노테이션과 메타클래스

이 PEP의 초기 구현을 테스트하는 과정에서 메타클래스와 클래스 어노테이션 간의 상호작용에 심각한 문제가 있다는 사실이 드러났습니다.

기존 버그

이 PEP에서 명시할 동작을 조사하던 중, 클래스에서 __annotations__가 동작하는 기존 방식에 여러 버그가 있다는 것을 발견했습니다. Python 3.13 이하에서 이 버그들을 수정하는 것은 이 PEP의 범위를 벗어나지만, 처리해야 할 코너 케이스를 설명하기 위해 여기에 기록해 둡니다.

참고로, Python 3.10부터 3.13까지는 클래스에 어노테이션이 하나라도 있으면 __annotations__ 딕셔너리가 클래스 네임스페이스에 들어갑니다. 어노테이션이 없으면 클래스가 생성될 때 클래스 딕셔너리에 __annotations__ 키가 없지만, cls.__annotations__에 접근하면 type에 정의된 디스크립터가 호출되어 빈 딕셔너리를 반환하고 그것을 클래스 딕셔너리에 저장합니다. 정적 타입은 예외입니다. 정적 타입에는 어노테이션이 전혀 없으며, .__annotations__에 접근하면 AttributeError가 발생합니다. Python 3.9 이하에서는 동작이 달랐습니다. gh-88067을 참조하십시오.

다음 코드는 Python 3.10부터 3.13까지 모두 똑같이 실패합니다:

class Meta(type): pass

class X(metaclass=Meta):
    a: str

class Y(X): pass

Meta.__annotations__  # important
assert Y.__annotations__ == {}, Y.__annotations__  # fails: {'a': <class 'str'>}

메타클래스 Meta의 어노테이션에 Y의 어노테이션보다 먼저 접근하면, 베이스 클래스 X의 어노테이션이 Y로 새어 나갑니다. 그러나 메타클래스의 어노테이션에 접근하지 않으면 (즉, 위의 Meta.__annotations__ 줄을 제거하면) Y의 어노테이션은 올바르게 비어 있습니다.

마찬가지로, 어노테이션이 있는 메타클래스의 어노테이션이 그 메타클래스의 인스턴스이면서 어노테이션이 없는 클래스로 새어 나갑니다:

class Meta(type):
    a: str

class X(metaclass=Meta):
    pass

assert X.__annotations__ == {}, X.__annotations__  # fails: {'a': <class 'str'>}

이러한 동작이 일어나는 이유는, 메타클래스의 클래스 딕셔너리에 __annotations__ 항목이 있으면 메타클래스의 인스턴스가 베이스 type 클래스의 __annotations__ 데이터 디스크립터를 사용하지 못하게 되기 때문입니다. 첫 번째 경우에는 Meta.__annotations__에 접근하면 부수 효과로 Meta.__dict__["__annotations__"] = {}가 설정됩니다. 그런 다음 Y에서 __annotations__ 속성을 조회하면 먼저 메타클래스 속성을 보게 되지만, 그것이 데이터 디스크립터이므로 건너뜁니다. 다음으로, 메서드 결정 순서(MRO)에 있는 클래스들의 클래스 딕셔너리를 살펴보다가 X.__annotations__를 찾아 반환합니다. 두 번째 예에서는 MRO 어디에도 어노테이션이 없으므로, type.__getattribute__는 대체로 메타클래스 속성을 반환합니다.

PEP 649에서의 메타클래스 동작

PEP 649에서는 메타클래스가 관여할 때 클래스의 .__annotations__ 속성에 접근하는 동작이 더욱 불규칙해지는데, 이제 어노테이션이 있는 클래스에서도 __annotations__가 클래스 딕셔너리에 지연 방식으로만 추가되기 때문입니다. 새로운 __annotate__ 속성도 어노테이션이 없는 클래스에서 지연 방식으로 생성되며, 이로 인해 메타클래스가 관여할 때 추가적인 오동작이 발생합니다.

이러한 문제의 원인은 __annotate__와 __annotations__ 클래스 딕셔너리 항목을 일부 상황에서만 설정하고, 설정되지 않은 경우에는 type에 정의된 디스크립터가 이를 채워 주는 데 의존하기 때문입니다. 일반적인 속성 조회를 사용하면, 메타클래스 자체의 클래스 딕셔너리에 있는 항목이 디스크립터를 보이지 않게 만들 수 있기 때문에 메타클래스가 있을 때 이 방식은 제대로 동작하지 않습니다.

여러 해결책을 검토한 끝에, __annotate__와 __annotations__ 객체를 클래스 딕셔너리에 저장하되 내부 전용의 다른 이름으로 저장하는 방식을 택했습니다. 따라서 클래스 딕셔너리 항목이 type에 정의된 디스크립터를 방해하지 않습니다.

이 방식을 사용하면 클래스 객체의 .__annotate__와 .__annotations__ 객체가 대체로 직관적으로 동작하지만, 몇 가지 단점이 있습니다.

첫째는 from __future__ import annotations 아래에서 정의된 클래스와의 상호 작용에 관한 것입니다. 이러한 클래스는 앞으로도 클래스 딕셔너리에 __annotations__ 항목을 가지므로, 몇 가지 버그성 동작도 계속 보이게 됩니다. 예를 들어, 어떤 메타클래스가 __future__ 임포트를 활성화한 상태로 정의되고 어노테이션을 가지는데, 그 메타클래스를 사용하는 클래스는 __future__ 임포트 없이 정의되면, 해당 클래스에서 .__annotations__에 접근할 때 잘못된 결과가 나옵니다. 그러나 이 버그는 이전 버전의 Python에도 이미 존재합니다. 이 경우에도 클래스 딕셔너리의 다른 키에 어노테이션을 설정하면 고칠 수 있지만, 그렇게 하면 클래스 딕셔너리에 직접 접근하는(예: 클래스 생성 중에) 사용자의 코드가 동작하지 않게 됩니다. 저희는 __future__ 임포트 아래에서의 동작을 가능한 한 바꾸지 않고 유지하는 편을 선호합니다.

둘째, 이전 버전의 Python에서는 어노테이션이 있는 사용자 정의 클래스의 인스턴스에서 __annotations__ 속성에 접근할 수 있었습니다. 그러나 이 동작은 문서화되지 않았고 inspect.get_annotations()에서도 지원되지 않았으며, 새로운 object.__annotations__ 디스크립터와 같은 더 큰 변경 없이는 PEP 649 프레임워크에서 유지할 수 없습니다. 이 동작 변경은 이식 가이드에 명시해야 합니다.

명세

클래스 객체의 .__annotate__ 속성과 .__annotations__ 속성은 사용자 정의 메타클래스가 있는 경우에도 각각 annotate 함수와 어노테이션 딕셔너리를 안정적으로 반환해야 합니다.

사용자는 어노테이션이나 annotate 함수에 접근하려고 클래스 딕셔너리에 직접 접근해서는 안 됩니다. 클래스 딕셔너리에 저장된 데이터는 구현 세부 사항이며 그 형식은 앞으로 바뀔 수 있습니다. 클래스 네임스페이스 딕셔너리만 사용할 수 있는 경우(예: 클래스가 생성되는 도중)에는 annotationlib.get_annotate_from_class_namespace를 사용하여 클래스 딕셔너리에서 annotate 함수를 가져올 수 있습니다.

기각된 대안

클래스의 __annotations__ 및 __annotate__ 항목의 동작을 처리하기 위해 크게 세 가지 접근 방식을 검토했습니다:

  • 항목이 비어 있거나 아직 평가되지 않았더라도 클래스 딕셔너리에 항상 존재하도록 보장합니다. 이는 필드를 채우기 위해 type에 정의된 디스크립터에 의존할 필요가 없으며, 따라서 메타클래스의 속성이 간섭하지 않는다는 것을 의미합니다. (gh-120719에 프로토타입이 있습니다.)
  • 사용자에게 __annotations__ 및 __annotate__ 속성을 직접 사용하지 말라고 경고하십시오. 대신 사용자는 type 디스크립터를 직접 호출하는 annotationlib의 함수를 호출해야 합니다. (gh-122074에서 구현되었습니다.)
  • 항목이 클래스 딕셔너리에 절대 존재하지 않도록, 또는 적어도 언어 코어의 로직이 항목을 추가하는 일은 절대 없도록 보장합니다. 이렇게 하면 메타클래스의 간섭 없이 항상 type의 디스크립터가 사용됩니다. (초기 프로토타입은 gh-120816에 있으며, 이후 gh-132345에서 구현되었습니다.)

Alex Waygood는 첫 번째 접근 방식을 사용하는 구현을 제안했습니다. 힙 타입(class 문으로 생성된 클래스 등)이 생성될 때 cls.__dict__["__annotations__"]는 특수한 디스크립터로 설정됩니다. __get__ 시 디스크립터는 __annotate__를 호출하여 어노테이션을 평가하고 그 결과를 반환합니다. 어노테이션 딕셔너리는 디스크립터 인스턴스 안에 캐시됩니다. 디스크립터는 매핑처럼 동작하기도 하므로 cls.__dict__["__annotations__"]를 사용하는 코드는 대개 여전히 작동합니다. 객체를 매핑으로 취급하면 어노테이션이 평가되고, 디스크립터 자체가 어노테이션 딕셔너리인 것처럼 동작합니다. (다만 cls.__dict__["__annotations__"]가 구체적으로 dict의 인스턴스라고 가정하는 코드는 깨질 수 있습니다.)

이 방식은 __annotate__에 대해서도 간단하게 구현할 수 있습니다. 이 속성은 어노테이션이 있는 클래스에서는 이미 항상 설정되며, 어노테이션이 없는 클래스에서는 명시적으로 None으로 설정할 수 있습니다.

이 접근 방식은 메타클래스와 관련해 알려진 예외적인 경우들을 해결하겠지만, 특이한 동작을 하는 새로운 내장 타입(어노테이션 디스크립터용)을 포함하여 모든 클래스에 상당한 복잡성을 더합니다.

두 번째 접근 방식은 구현이 간단하지만, cls.__annotations__에 직접 접근하면 여전히 예측할 수 없는 동작이 일어나기 쉽다는 단점이 있습니다.

VALUE_WITH_FAKE_GLOBALS 형식 추가

PEP 649는 다음과 같이 명시합니다:

이 PEP는 서드파티 라이브러리가 자체 __annotate__ 메서드를 구현할 수 있으며, 그러한 함수는 이 “가짜 전역” 환경에서 실행될 때 거의 확실히 잘못 동작할 것이라고 가정합니다. 그러한 이유로 이 PEP는 코드 객체에 플래그 하나를 할당합니다. 이 플래그는 co_flags의 사용되지 않는 비트 중 하나로, “이 코드 객체는 ‘가짜 전역’ 환경에서 실행될 수 있음”을 의미합니다. 이로써 “가짜 전역” 환경은 철저히 옵트인 방식이 되며, 이 플래그는 Python 컴파일러가 생성한 __annotate__ 메서드만 설정할 것으로 예상됩니다.

하지만 이 메커니즘은 구현을 코드 객체의 저수준 세부 사항과 결합시킵니다. 코드 객체 플래그는 CPython 전용이며, 문서는 이 값에 의존하지 말라고 명시적으로 경고합니다.

Larry Hastings는 코드 플래그에 의존하지 않는 대안적인 접근 방식으로 네 번째 형식인 VALUE_WITH_FAKE_GLOBALS를 제안했습니다. 컴파일러가 생성한 annotate 함수는 VALUE 형식과 VALUE_WITH_FAKE_GLOBALS 형식만 지원하며, 두 형식은 동일하게 구현됩니다. 표준 라이브러리는 특수한 “가짜 전역” 환경 중 하나에서 annotate 함수를 호출할 때 VALUE_WITH_FAKE_GLOBALS 형식을 사용합니다.

이 접근 방식은 앞으로 새로운 어노테이션 형식을 추가하기 위한 상위 호환 메커니즘으로 유용합니다. annotate 함수를 직접 작성하는 사용자는 VALUE_WITH_FAKE_GLOBALS 형식이 요청되면 NotImplementedError를 발생시켜야 합니다. 그러면 표준 라이브러리가 직접 작성된 annotate 함수를 “가짜 전역” 환경에서 호출하지 않으므로, 예측할 수 없는 결과를 피할 수 있습니다.

어노테이션 형식의 이름은 __annotate__ 함수가 어떤 종류의 객체를 반환해야 하는지를 나타냅니다. STRING 형식에서는 문자열을, FORWARDREF 형식에서는 전방 참조를, VALUE 형식에서는 반환값으로 값을 반환해야 합니다. VALUE_WITH_FAKE_GLOBALS라는 이름은 이 함수가 여전히 값을 반환값으로 돌려주어야 하지만, 일반적이지 않은 “가짜 전역(fake globals)” 환경에서 실행되고 있음을 나타냅니다.

명세

annotationlib 모듈의 Format enum에 값이 2인 추가 형식 VALUE_WITH_FAKE_GLOBALS가 추가됩니다. (그 결과 다른 형식의 값은 PEP 649와 비교해 바뀌게 되어, FORWARDREF는 3, SOURCE는 4가 됩니다.) 이 형식들의 정수 값은 C로 구현된 __annotate__ 함수처럼 enum을 바로 사용할 수 없는 곳에서 사용할 수 있도록 지정됩니다.

컴파일러가 생성한 annotate 함수는 이 형식을 지원하며, VALUE 형식에 대해 반환하는 것과 같은 값을 반환합니다. 표준 라이브러리는 FORWARDREF 형식과 SOURCE 형식을 구현하는 데 사용되는 “가짜 전역” 환경에서 __annotate__ 함수를 호출할 때 이 형식을 전달합니다. 형식 인자를 받는 annotationlib 모듈의 모든 공개 함수는 형식이 VALUE_WITH_FAKE_GLOBALS인 경우 NotImplementedError를 발생시킵니다.

__annotate__ 함수를 구현하는 서드파티 코드는 VALUE_WITH_FAKE_GLOBALS 형식이 전달되었으나 해당 함수가 “가짜 전역(fake globals)” 환경에서 실행될 준비가 되어 있지 않다면 NotImplementedError를 발생시켜야 합니다. 이 내용은 __annotate__에 대한 데이터 모델 문서에 언급되어야 합니다.

__annotations__ 삭제의 효과

PEP 649는 다음과 같이 규정합니다:

o.__annotations__를 유효한 값으로 설정하면 o.__annotate__가 자동으로 None으로 설정됩니다.

그러나 이 PEP는 (del을 사용하여) __annotations__ 속성을 삭제하면 어떻게 되는지는 명시하지 않습니다. 속성을 삭제하면 __annotate__도 함께 삭제되는 것이 가장 일관성 있어 보입니다.

명세

함수, 모듈, 클래스의 __annotations__ 속성을 삭제하면 __annotate__가 None으로 설정됩니다.

PEP 695 및 696 객체의 지연 평가

PEP 649가 작성된 이후, Python 3.12와 3.13에는 이 PEP가 어노테이션에 대해 제안하는 동작과 유사하게 지연 평가를 사용하는 여러 새로운 기능에 대한 지원이 추가되었습니다:

현재 이 객체들은 지연 평가를 사용하지만, 지연 평가에 사용되는 함수 객체에 직접 접근할 방법은 없습니다. 현재 어노테이션에 대해 가능한 것과 같은 종류의 인트로스펙션을 가능하게 하기 위해, 내부 함수 객체를 노출하여 사용자가 FORWARDREF 및 SOURCE 형식으로 이를 평가할 수 있도록 할 것을 제안합니다.

명세

다음과 같은 새 속성을 추가합니다:

evaluate_value를 제외한 이 속성들은 객체에 상한, 제약 조건 또는 기본값이 없으면 None일 수 있습니다. 그렇지 않으면 이 속성은 __annotate__ 함수와 유사하게 단일 정수 인자를 받아 평가된 값을 반환하는 호출 가능 객체입니다. __annotate__ 함수와 달리, 이 호출 가능 객체들은 어노테이션 딕셔너리가 아닌 단일 값을 반환합니다. 이 속성들은 읽기 전용입니다.

일반적으로 사용자는 이 속성들을 annotationlib.call_evaluate_function과 함께 사용하게 됩니다. 예를 들어, TypeVar의 상한을 SOURCE 형식으로 얻으려면 annotationlib.call_evaluate_function(T.evaluate_bound, annotationlib.Format.SOURCE)와 같이 작성할 수 있습니다.

데이터클래스 필드 타입의 동작

어노테이션 지연 평가의 결과 중 하나는 데이터클래스가 어노테이션에서 전방 참조를 사용할 수 있다는 것입니다:

>>> from dataclasses import dataclass
>>> @dataclass
... class D:
...     x: undefined
...

그러나 FORWARDREF 형식이 데이터클래스의 필드 타입으로 새어 나옵니다:

>>> fields(D)[0].type
ForwardRef('undefined')

필드 객체의 .type 속성에 접근하면 어노테이션이 평가되도록 하는 변경을 검토했습니다. 이렇게 하면 데이터클래스 자체가 생성된 후, 그러나 필드 타입에 접근하기 전에 정의된 전방 참조의 경우 필드 타입이 실제 값을 담을 수 있습니다. 그러나 이는 .type에 접근하는 것만으로 어노테이션 안의 임의의 코드가 실행될 수 있고, NameError 같은 오류가 발생할 수도 있다는 뜻이기도 합니다.

따라서 타입에 ForwardRef 객체를 그대로 두고, 전방 참조를 해석하려는 사용자는 ForwardRef.evaluate 메서드를 사용할 수 있다고 문서화하는 편이 사용자에게 더 친화적이라고 판단합니다.

향후 사용 사례가 생기면 어노테이션을 처음부터 다시 평가하는 새 메서드 같은 추가 기능을 더할 수 있습니다.

SOURCE를 STRING으로 이름 변경

SOURCE 형식은 원래 소스 코드에 가까운, 사람이 읽을 수 있는 형식을 보여 주어야 하는 도구를 위한 것입니다. 그러나 __annotate__ 함수 안에서는 원래 소스를 가져올 수 없으며, 경우에 따라서는 원래 코드에 접근할 수 없는 Python 코드 안에 __annotate__ 함수가 있기도 합니다. 예를 들어 dataclasses.make_dataclass()와 typing.TypedDict의 호출 기반 문법이 이에 해당합니다.

이 때문에 SOURCE라는 이름은 다소 부적절합니다. 이 형식의 목표는 분명 소스를 재현하는 것이어야 하지만, 실제로는 이 이름이 사용자를 오도할 가능성이 큽니다. 더 중립적인 이름이라면 이 형식이 문자열만으로 이루어진 어노테이션 딕셔너리를 반환한다는 점을 강조할 것입니다. STRING을 제안합니다.

명세

SOURCE 형식의 이름을 STRING으로 바꿉니다. 이 PEP의 변경 사항을 다시 정리하면, 이제 지원되는 네 가지 형식은 다음과 같습니다:

  • VALUE: 기본 형식으로, 어노테이션을 평가하여 그 결과 값을 반환합니다.
  • VALUE_WITH_FAKE_GLOBALS: 내부용입니다. 가짜 전역 변수로 실행하는 것을 지원하는 annotate 함수는 이 형식을 VALUE와 같이 처리해야 합니다.
  • FORWARDREF: 정의되지 않은 이름을 ForwardRef 객체로 대체합니다.
  • STRING: 문자열을 반환하며, 원래 소스에 가까운 코드를 재현하려고 시도합니다.

조건부로 정의된 어노테이션

PEP 649는 클래스나 모듈 본문에서 조건부로 정의된 어노테이션을 지원하지 않습니다:

현재는 if 문이나 try 문 안에서 어노테이션과 함께 모듈 및 클래스 속성을 설정할 수 있으며, 이는 예상대로 동작합니다. 이 PEP가 적용된 상태에서는 이 동작을 계속 지원할 수 없습니다.

그러나 널리 사용되는 SQLAlchemy 라이브러리의 유지 관리자는 이 패턴이 실제로 흔하고 중요하다고 보고했습니다:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from some_module import SpecialType

class MyClass:
    somevalue: str
    if TYPE_CHECKING:
        someothervalue: SpecialType

PEP 649에서 구상한 동작에 따르면 MyClass의 __annotations__에는 somevalue와 someothervalue 모두에 대한 키가 포함됩니다.

다행히 이 코드가 다시 예상대로 동작하게 만드는, 실현 가능한 구현 전략이 있습니다. 이 전략은 몇 가지 운 좋은 상황에 기반합니다:

  • 지역 스코프의 어노테이션은 무시되므로, 이 동작 변경은 모듈 및 클래스 어노테이션에만 해당합니다.
  • 모듈과 클래스 본문은 한 번만 실행됩니다.
  • 클래스의 어노테이션은 클래스 본문 실행이 완료될 때까지 외부에서 보이지 않습니다. 모듈의 경우에는 꼭 그렇지는 않습니다. 부분적으로 실행된 모듈이 임포트된 다른 모듈에서 보일 수 있기 때문입니다. 하지만 이 경우는 다른 이유로 문제가 됩니다(다음 절을 참조하십시오).

이를 통해 다음과 같은 구현 전략이 가능합니다:

  • 어노테이션이 있는 각 할당문에는 고유 식별자(예: 정수)가 부여됩니다.
  • 클래스나 모듈 본문이 실행되는 동안, 정의된 어노테이션의 식별자를 담기 위한 집합이 처음에는 빈 상태로 생성됩니다.
  • 어노테이션이 있는 할당문이 실행되면 그 식별자가 집합에 추가됩니다.
  • 생성된 __annotate__ 함수는 이 집합을 사용하여 클래스나 모듈 본문에서 어떤 어노테이션이 정의되었는지 판단하고, 해당 어노테이션만 반환합니다.

이는 python/cpython#130935에서 구현되었습니다.

명세

클래스와 모듈의 경우, __annotate__ 함수는 클래스나 모듈 본문이 실행될 때 실제로 실행된 할당문의 어노테이션만 반환합니다.

부분적으로 실행된 모듈에서의 어노테이션 캐싱

PEP 649는 클래스와 모듈의 __annotations__ 속성 값이 처음 접근할 때 __annotate__ 함수를 호출하여 결정되고, 이후 접근을 위해 캐시된다고 명시합니다. 이는 대부분의 경우 올바르며 호환성을 유지하지만, 예상치 못한 동작으로 이어질 수 있는 예외적인 경우가 하나 있습니다. 바로 부분적으로 실행된 모듈입니다.

다음 예를 생각해 보십시오:

# recmod/__main__.py
from . import a
print("in __main__:", a.__annotations__)

# recmod/a.py
v1: int
from . import b
v2: int

# recmod/b.py
from . import a
print("in b:", a.__annotations__)

recmod/b.py가 실행되는 동안 recmod.a 모듈은 정의되어 있지만 아직 실행이 끝나지 않았다는 점에 유의하십시오.

3.13에서는 다음과 같이 출력됩니다.

$ python3.13 -m recmod
in b: {'v1': <class 'int'>}
in __main__: {'v1': <class 'int'>, 'v2': <class 'int'>}

그러나 PEP 649가 원래 제안된 대로 구현되었다면, __annotate__ 함수가 모듈 실행이 완료된 뒤에야 설정되기 때문에 빈 딕셔너리가 두 번 출력될 것입니다. 이는 명백히 직관에 어긋납니다.

구현은 python/cpython#131550을 참조하십시오.

명세

부분적으로 실행된 모듈에서 __annotations__에 접근하면, 이전 버전의 Python에서의 동작과 마찬가지로 지금까지 실행된 어노테이션을 계속 반환합니다. 다만 이 경우에는 __annotations__ 딕셔너리가 캐시되지 않으므로, 이후 __annotations__ 속성에 접근하면 새로운 딕셔너리가 반환됩니다. 추가된 어노테이션을 반영하려면 __annotate__를 다시 호출해야 하므로 이렇게 할 필요가 있습니다.

기타 구현 세부 사항

PEP 649는 구현의 일부 측면을 상당히 자세하게 다룹니다. 혼란을 피하기 위해, 현재 구현이 PEP에 기술된 내용과 다른 몇 가지 측면을 설명합니다. 그러나 이러한 세부 사항은 언어 레퍼런스에 문서화되지 않는 한 앞으로도 유지된다고 보장되지 않으며, 향후 예고 없이 변경될 수 있습니다.

ForwardRef 객체에서 지원되는 연산

SOURCE 형식은 “stringizer” 기법으로 구현됩니다. 이 기법에서는 함수의 전역 딕셔너리를 확장하여 모든 조회가 특수한 객체를 반환하도록 하며, 이 객체는 자신에게 수행된 연산을 재구성하는 데 사용할 수 있습니다.

PEP 649는 다음과 같이 명시합니다.

실제로 “stringizer” 기능은 현재 typing 모듈에 정의된 ForwardRef 객체에 구현될 것입니다. ForwardRef는 모든 stringizer 기능을 구현하도록 확장될 것이며, 또한 자신이 담고 있는 문자열을 평가하여 실제 값을 산출하는 기능도 지원하도록 확장될 것입니다(참조된 모든 심볼이 정의되어 있다고 가정합니다).

그러나 이는 실제로는 혼란을 초래할 가능성이 높습니다. stringizer 기능을 구현하는 객체는 새로운 stringizer를 반환하기 위해 __getattr__과 __eq__를 포함한 거의 모든 특수 메서드를 구현해야 합니다. 이러한 객체는 다루기가 혼란스럽습니다. 모든 연산이 성공하지만, 사용자가 예상한 것과 다른 객체를 반환할 가능성이 높기 때문입니다.

대신 현재 구현은 ForwardRef 클래스에 유용한 메서드 몇 가지만 구현합니다. 어노테이션을 평가하는 동안에는 ForwardRef 대신 비공개 stringizer 클래스의 인스턴스를 사용합니다. 평가가 끝나면 FORWARDREF 형식 구현이 이러한 내부 객체를 ForwardRef 객체로 변환합니다.

__annotate__ 함수의 시그니처

PEP 649는 __annotate__ 함수의 시그니처를 다음과 같이 규정합니다:

__annotate__(format: int) -> dict

그러나 format을 매개변수 이름으로 사용하면 어노테이션에서 format이라는 이름의 심볼을 사용할 경우 충돌이 발생할 수 있습니다. 이 문제를 피하기 위해 현재 구현은 위치 전용 매개변수를 사용합니다. 이 매개변수는 함수 시그니처에서는 이름이 format이지만, 어노테이션 안에서 format이라는 이름을 사용하는 것을 가리지 않습니다.

하위 호환성

PEP 649는 기존 방식 또는 PEP 563 의미를 사용하는 기존 코드에 대한 하위 호환성 영향을 자세히 논의합니다.

그러나 또 다른 호환성 문제가 있습니다. PEP 649 의미를 전제로 작성했지만 어노테이션을 즉시 평가하는 기존 도구를 사용하는 새 코드입니다. 예를 들어, __annotations__에 직접 접근하거나 inspect.get_annotations()를 호출하여 자신이 데코레이트하는 클래스에서 어노테이션이 달린 필드를 가져오는, dataclass와 유사한 클래스 데코레이터 @annotator를 생각해 봅시다.

PEP 649가 구현되면 이와 같은 코드는 문제없이 동작합니다:

class X:
    y: Y

class Y: pass

하지만 @annotator가 새로운 FORWARDREF 형식을 사용하도록 변경되지 않는 한 이 코드는 동작하지 않습니다:

@annotator
class X:
    y: Y

class Y: pass

이전에 동작하던 코드가 깨지는 일은 없으므로 이것은 엄밀히 말해 하위 호환성 문제가 아닙니다. PEP 649 이전에는 이 코드가 런타임에 NameError를 발생시켰을 것입니다. 어떤 의미에서 이것은 서드파티 라이브러리가 지원해야 하는 다른 새로운 Python 기능과 다르지 않습니다. 그럼에도 불구하고 이는 인트로스펙션을 수행하는 라이브러리에는 심각한 문제이며, 라이브러리가 새로운 의미를 간단하고 사용자 친화적인 방식으로 최대한 쉽게 지원할 수 있도록 하는 것이 중요합니다.

dataclasses, typing.TypedDict, typing.NamedTuple을 비롯한 표준 라이브러리의 여러 기능이 이 문제의 영향을 받습니다. 이들은 새로운 annotationlib 모듈의 기능을 사용하여 이 패턴을 지원하도록 갱신되었습니다.

보안 영향

PEP 649의 결과 중 하나는 이제 객체의 어노테이션에 접근하면, 그 객체가 함수나 모듈이더라도, 임의의 코드가 실행될 수 있다는 점입니다. 이는 STRING 형식을 사용하는 경우에도 마찬가지입니다. 문자열화 메커니즘은 전역 네임스페이스만 재정의하며, 이것만으로는 Python 코드를 완전히 샌드박스화하기에 충분하지 않기 때문입니다.

이전 Python 버전에서는 함수나 모듈의 어노테이션에 접근해도 임의의 코드가 실행될 수 없었지만, 클래스와 기타 객체는 이미 __annotations__ 속성에 접근할 때 임의의 코드를 실행할 수 있었습니다. 마찬가지로 어노테이션에 대한 거의 모든 추가 인트로스펙션(예를 들어 isinstance()를 사용하거나, typing.get_origin 같은 함수를 호출하거나, 심지어 repr()로 어노테이션을 표시하는 것)도 이미 임의의 코드를 실행할 수 있었습니다. 그리고 물론, 신뢰할 수 없는 코드의 어노테이션에 접근한다는 것은 그 신뢰할 수 없는 코드가 이미 임포트되었다는 뜻입니다.

교육 방법

이 PEP에 의해 수정된 PEP 649의 의미는 코드에 어노테이션을 추가하는 사용자에게 대체로 직관적일 것입니다. 전방 참조가 필요한 어노테이션을 수동으로 따옴표로 감쌀 필요가 없어지므로, 사용자에게 큰 혼란을 주던 원인이 사라집니다.

어노테이션을 인트로스펙션해야 하는 고급 사용자에게는 상황이 더 복잡해집니다. 새로운 annotationlib 모듈의 문서는 어노테이션과 프로그래밍 방식으로 상호 작용해야 하는 사용자를 위한 참고 자료 역할을 할 것입니다.

참조 구현

이 PEP에서 제안한 변경 사항은 CPython 저장소의 main 브랜치에 구현되었습니다.

감사의 글

무엇보다 먼저, PEP 649를 작성한 Larry Hastings에게 감사드립니다. 이 PEP는 그가 처음 내린 결정 중 일부를 수정하지만, 전체 설계는 여전히 그의 것입니다.

이 PEP의 초기 초안에 피드백을 준 Carl Meyer와 Alex Waygood에게 감사드립니다. Alex Waygood, Alyssa Coghlan, David Ellis는 메타클래스와 __annotations__ 사이의 상호 작용에 관해 통찰력 있는 피드백과 제안을 제공했습니다. Larry Hastings도 이 PEP에 유용한 피드백을 제공했습니다. Nikita Sobolev는 PEP 649 기능을 활용하도록 표준 라이브러리를 여러 곳 변경했으며, 그의 경험은 설계를 개선하는 데 도움이 되었습니다.

부록

어떤 표현식을 문자열화할 수 있습니까?

PEP 649는 문자열화기(stringifier)가 모든 표현식을 처리할 수는 없다는 점을 인정합니다. 이제 초안 구현이 있으므로, 처리할 수 있는 표현식과 처리할 수 없는 표현식을 더 정확하게 말할 수 있습니다. 아래는 Python AST의 모든 표현식을 문자열화기가 복원할 수 있는 것과 복원할 수 없는 것으로 나누어 정리한 목록입니다. 전체 목록은 아마 문서에 추가하지 않는 편이 좋겠지만, 이 목록을 만들어 보는 것은 유용한 연습입니다.

첫째, 당연하게도 문자열화기는 컴파일된 코드에 존재하지 않는 정보를 복원할 수 없습니다. 여기에는 주석, 공백, 괄호, 그리고 AST 최적화기가 단순화하는 연산이 포함됩니다.

둘째, 문자열화기는 어떤 스코프에서 조회되는 이름이 관련된 연산은 거의 모두 가로챌 수 있지만, 전적으로 상수에만 작용하는 연산은 가로챌 수 없습니다. 이로부터 신뢰할 수 없는 코드에 SOURCE 형식을 요청하는 것도 안전하지 않다는 결론이 나옵니다. Python은 매우 강력해서 전역 변수나 내장 객체에 전혀 접근할 수 없어도 임의의 코드를 실행할 수 있습니다. 예를 들면 다음과 같습니다:

>>> def f(x: (1).__class__.__base__.__subclasses__()[-1].__init__.__builtins__["print"]("Hello world")): pass
...
>>> annotationlib.get_annotations(f, format=annotationlib.Format.SOURCE)
Hello world
{'x': 'None'}

(이 예제는 이 PEP 초안의 현재 구현에서 제 환경에서는 작동했습니다. 다만 정확히 같은 코드가 앞으로도 계속 작동한다는 보장은 없습니다.)

다음은 지원됩니다(경우에 따라 주의 사항이 있습니다):

  • BinOp
  • UnaryOp
    • Invert (~), UAdd (+), USub (-)는 지원됩니다
    • Not (not)은 지원되지 않습니다
  • Dict (** 언패킹을 사용하는 경우는 제외)
  • Set
  • Compare
    • Eq와 NotEq는 지원됩니다
    • Lt, LtE, Gt, GtE는 지원되지만, 피연산자의 순서가 뒤바뀔 수 있습니다
    • Is, IsNot, In, NotIn은 지원되지 않습니다
  • Call (** 언패킹을 사용하는 경우는 제외)
  • Constant (단, 상수의 정확한 표현까지 복원되지는 않습니다. 예를 들어 문자열의 이스케이프 시퀀스는 사라지고, 16진수는 10진수로 변환됩니다)
  • Attribute (값이 상수가 아니라고 가정할 때)
  • Subscript (값이 상수가 아닌 경우)
  • Starred (* 언패킹)
  • Name
  • List
  • Tuple
  • Slice

다음 항목은 지원되지 않지만, 문자열 변환기가 이를 만나면 원인을 알려 주는 오류를 발생시킵니다:

  • FormattedValue (f-문자열. !r 같은 변환 지정자를 사용하면 오류가 감지되지 않습니다)
  • JoinedStr (f-문자열)

다음 항목은 지원되지 않으며 잘못된 출력을 만들어 냅니다:

  • BoolOp (and와 or)
  • IfExp
  • Lambda
  • ListComp
  • SetComp
  • DictComp
  • GeneratorExp

다음 항목은 어노테이션 스코프에서 허용되지 않으므로 고려할 필요가 없습니다:

  • NamedExpr (:=)
  • Await
  • Yield
  • YieldFrom