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

동기

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

저는 CPython에서 이 PEP의 구현 작업을 시작했습니다. PEP에 일부 영역이 충분히 명시되어 있지 않으며, 일부 예외적인 경우에 대한 결정이 의문스럽다는 사실을 발견했습니다. 이 새로운 PEP는 이러한 문제를 해결하기 위해 사양에 대한 여러 변경 및 추가 사항을 제안합니다.

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

from __future__ import annotations의 미래

PEP 563은 이전에 모든 어노테이션을 문자열로 변경하는 미래 임포트인 from __future__ import annotations를 도입했습니다. PEP 649는 이 미래 임포트를 필요로 하지 않는 대안적 접근 방식을 제안하며 다음과 같이 명시합니다:

이 PEP가 채택되면 PEP 563은 사용 중단되고 결국 제거됩니다.

그러나 이 PEP는 이러한 사용 중단에 대한 상세한 계획을 제공하지 않습니다.

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

사양

다음과 같은 사용 중단 계획을 제안합니다:

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

거부된 대안

미래 임포트를 즉시 아무 작업도 하지 않도록 만들기: Python 3.14의 모든 코드에 PEP 649 의미론을 적용하여 미래 임포트를 아무 작업도 하지 않도록 만드는 방안을 검토했습니다. 그러나 이는 다음 조건을 만족하면서 3.13에서 작동하는 코드를 중단시킵니다.

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

이는 일반적인 패턴일 것으로 예상되므로, 3.13에서 3.14로 업그레이드하는 동안 이러한 코드가 중단되도록 둘 수는 없습니다.

이러한 코드는 결국 미래 임포트가 제거될 때 여전히 중단됩니다. 그러나 이는 수년 후의 일이므로 영향을 받는 라이브러리가 코드를 업데이트할 충분한 시간이 있습니다.

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

미래 임포트를 영원히 유지하기: 미래 임포트를 무기한 유지하기로 결정할 수도 있습니다. 그러나 이는 Python 언어의 동작을 영구적으로 양분하게 됩니다. 이는 바람직하지 않습니다. 언어에는 영구적으로 다른 두 가지 모드가 아니라 하나의 의미론 집합만 있어야 합니다.

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

새로운 annotationlib 모듈

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

새로운 표준 라이브러리 모듈은 이 기능을 위한 논리적인 위치를 제공하며, 어노테이션의 소비자에게 유용한 도구를 더 추가할 수 있게 합니다.

근거

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

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

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

사양

표준 라이브러리에 annotationlib라는 새 모듈을 추가합니다. 이 모듈의 목적은 어노테이션을 조사하고 래핑하기 위한 도구를 제공하는 것입니다.

이 모듈의 설계는 표준 라이브러리(예: dataclassestyping.TypedDict)를 PEP 649 의미론을 사용하도록 업데이트한 경험을 바탕으로 합니다.

이 모듈에는 다음 기능이 포함됩니다.

  • get_annotations(): 함수, 모듈 또는 클래스의 어노테이션을 반환하는 함수입니다. 이 함수는 inspect.get_annotations()를 대체합니다. 기존 함수는 새 함수에 작업을 위임합니다. 나중에 더 이상 사용되지 않는 것으로 지정될 수 있지만, 변경으로 인한 혼란을 최소화하기 위해 즉시 사용 중단을 제안하지는 않습니다.
  • get_annotate_from_class_namespace(namespace: Mapping[str, Any]): 클래스 네임스페이스 딕셔너리에서 __annotate__ 함수를 반환하거나, 해당 함수가 없으면 None을 반환하는 함수입니다. 이는 클래스 구성 중 메타클래스에서 유용합니다. 이는 __annotate__ 함수의 내부 저장 방식에 관한 구현 세부 정보를 노출하지 않기 위해 별도의 함수로 제공합니다(아래의 below 참조).
  • Format: 가능한 어노테이션 형식을 포함하는 열거형입니다. 이는 PEP 649VALUE, FORWARDREFSOURCE 형식을 대체합니다. PEP 649에서는 이러한 값을 inspect 모듈의 전역 멤버로 만들 것을 제안했지만, 여기에서는 이를 열거형 안에 배치하는 방식을 선호합니다. 네 번째 형식인 VALUE_WITH_FAKE_GLOBALS를 추가할 것을 제안합니다(아래 참조).
  • ForwardRef: 전방 참조를 나타내는 클래스이며, 형식이 FORWARDREF일 때 get_annotations()가 반환할 수 있습니다. 기존 typing.ForwardRef 클래스는 이 클래스의 별칭이 됩니다. 이 클래스의 멤버는 다음과 같습니다.
    • __forward_arg__: 전방 참조의 문자열 인자입니다.
    • evaluate(globals=None, locals=None, type_params=None, owner=None): 전방 참조의 평가를 시도하는 메서드입니다. ForwardRef 객체는 자신이 유래한 객체의 전역 및 기타 네임스페이스에 대한 참조를 보유할 수 있습니다. 그런 경우 이러한 네임스페이스를 사용하여 전방 참조를 평가할 수 있습니다. owner 인자는 클래스 객체나 모듈 객체처럼 원래 어노테이션을 보유한 객체일 수 있으며, 전역 및 지역 네임스페이스가 제공되지 않은 경우 이를 추출하는 데 사용됩니다.
    • 기존 ForwardRef._evaluate 메서드와 동일한 인터페이스를 갖는 _evaluate()입니다. 문서화되지 않으며 즉시 사용 중단될 예정입니다. 기존 typing.ForwardRef 사용자와의 하위 호환성을 위해 제공됩니다.
  • call_annotate_function(func: Callable, format: Format): 지정된 형식으로 __annotate__ 함수를 호출하기 위한 도우미입니다. 함수가 이 형식을 지원하지 않으면 call_annotate_function()PEP 649에 설명된 대로 “가짜 전역” 환경을 설정하고, 해당 환경을 사용하여 원하는 어노테이션 형식을 반환합니다.
  • call_evaluate_function(func: Callable | None, format: Format): call_annotate_function과 유사하지만 함수가 어노테이션 딕셔너리를 반환하는 데 의존하지 않습니다. 이는 PEP 695PEP 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()을 사용하지만, 타입에는 완전 수식 이름을 반환합니다. 또한 typingcollections.abc 모듈의 여러 객체에 대한 repr()을 위한 도우미로도 유용합니다.

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

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

거부된 대안

다른 이름 사용하기: 이름 짓기는 어려운 일이며, 몇 가지 아이디어를 검토했습니다.

  • annotations: 가장 명확한 이름이지만, 기존 from __future__ import annotations와 혼동을 일으킬 수 있습니다. 동일한 모듈에 import annotationsfrom __future__ import annotations를 모두 사용하는 경우가 있을 수 있기 때문입니다. 일반적인 단어를 이름으로 사용하면 모듈을 검색하기가 더 어려워집니다. PyPI 패키지 annotations가 있지만, 2015년에 단 한 번 릴리스되었으며 현재는 방치된 것으로 보입니다.
  • annotation (단수형): 비슷하지만 future 임포트와 혼동을 일으키지는 않습니다. 방치된 PyPI 패키지 annotation이 있지만, 아마도 아티팩트를 전혀 릴리스하지 않은 것으로 보입니다.
  • annotools: itertoolsfunctools와 유사하지만, “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__ 함수와 직접 상호작용하여 동일한 기능을 제공하는 서드파티 패키지를 구현할 수 있습니다. 그러나 제안된 새 모듈의 기능은 표준 라이브러리 자체(예: dataclassestyping.NamedTuple 구현)에 확실히 유용하므로, 이를 표준 라이브러리에 포함하는 것이 타당합니다.

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

REPL의 동작

PEP 649은 대화형 REPL의 다음 동작을 명시합니다.

단순화를 위해 이 경우에는 지연 평가를 포기합니다. REPL 셸의 모듈 수준 어노테이션은 “stock semantics”에서와 정확히 동일하게 계속 작동하며, 즉시 평가하고 결과를 __annotations__ 딕셔너리 내부에 직접 설정합니다.

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

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

가장 중요한 점은, 경험이 부족한 사용자가 접할 수 있는 몇 가지 그럴듯한 사용 사례가 이로 인해 작동하지 않게 된다는 것입니다. 사용자가 파일에 다음과 같이 작성할 수 있습니다.:

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

관련 PEP 649에서는 이것이 문제없이 작동합니다. Xa의 어노테이션에서 사용될 때 아직 정의되지 않았지만, 어노테이션이 지연 평가되기 때문입니다. 그러나 사용자가 이 동일한 코드를 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에 정의된 디스크립터가 호출되어 빈 딕셔너리를 반환하고 이를 클래스 딕셔너리에 저장합니다. Static types은 예외로, 어노테이션을 절대 가지지 않으며 .__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__ 속성은 사용자 정의 메타클래스가 있는 경우에도 각각 어노테이트 함수와 어노테이션 딕셔너리를 안정적으로 반환해야 합니다.

사용자는 어노테이션이나 어노테이트 함수에 접근하기 위해 클래스 딕셔너리에 직접 접근해서는 안 됩니다. 클래스 딕셔너리에 저장된 데이터는 구현 세부 사항이며, 그 형식은 향후 변경될 수 있습니다. 클래스 네임스페이스 딕셔너리만 사용할 수 있는 경우(예: 클래스가 생성되는 동안) annotationlib.get_annotate_from_class_namespace를 사용하여 클래스 딕셔너리에서 어노테이트 함수를 가져올 수 있습니다.

거부된 대안

클래스에서 __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 함수는 VALUEVALUE_WITH_FAKE_GLOBALS 형식만 지원하며, 두 형식은 동일하게 구현됩니다. 표준 라이브러리는 특수한 “가짜 전역 변수” 환경 중 하나에서 annotate 함수를 호출할 때 VALUE_WITH_FAKE_GLOBALS 형식을 사용합니다.

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

어노테이션 형식의 이름은 __annotate__ 함수가 반환해야 하는 객체의 종류를 나타냅니다. STRING형식에서는 문자열을 반환해야 하고, FORWARDREF형식에서는 전방 참조를 반환해야 하며, VALUE형식에서는 값을 반환해야 합니다. VALUE_WITH_FAKE_GLOBALS라는 이름은 함수가 여전히 값을 반환하지만 특이한 “가짜 전역 변수” 환경에서 실행되고 있음을 나타냅니다.

사양입니다.

VALUE_WITH_FAKE_GLOBALS 형식이 annotationlib 모듈의 Format 열거형에 추가되며, 값은 2입니다. (그 결과 다른 형식의 값은 PEP 649에 비해 변경되어 FORWARDREF는 3이 되고 SOURCE는 4가 됩니다.) 이러한 형식의 정수 값은 열거형을 쉽게 사용할 수 없는 곳, 예를 들어 C로 구현된 __annotate__ 함수에서 사용하도록 지정됩니다.

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

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

__annotations__ 삭제의 영향

PEP 649는 다음을 지정합니다.

o.__annotations__를 유효한 값으로 설정하면 자동으로 o.__annotate__None으로 설정합니다.

그러나 PEP에서는 __annotations__ 속성이 (del을 사용하여) 삭제될 때 어떤 일이 발생하는지 설명하지 않습니다. 속성을 삭제하면 __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과 함께 사용합니다. 예를 들어 SOURCE 형식으로 TypeVar의 바운드를 가져오려면 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__에는 somevaluesomeothervalue 모두에 대한 키가 포함됩니다.

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

  • 이 동작 변경은 모듈 및 클래스 어노테이션에만 관련됩니다. 지역 스코프의 어노테이션은 무시되기 때문입니다.
  • 모듈 및 클래스 본문은 한 번만 실행됩니다.
  • 클래스의 어노테이션은 클래스 본문의 실행이 완료될 때까지 외부에 표시되지 않습니다. 모듈의 경우에는 이것이 완전히 참은 아닙니다. 부분적으로 실행된 모듈이 다른 가져온 모듈에 표시될 수 있기 때문이지만, 이 경우는 다른 이유로 문제가 됩니다(다음 절을 참조하십시오).

따라서 다음과 같은 구현 전략을 사용할 수 있습니다.

  • 어노테이션이 지정된 각 할당문에는 고유 식별자(예: 정수)가 할당됩니다.
  • 클래스 또는 모듈 본문을 실행하는 동안, 처음에는 비어 있는 집합이 생성되어 정의된 어노테이션의 식별자를 보관합니다.
  • 어노테이션이 지정된 할당문이 실행되면 해당 식별자가 집합에 추가됩니다.
  • 생성된 __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대신 비공개 문자열화 클래스의 인스턴스를 사용합니다. 평가가 완료되면 FORWARDREF 형식의 구현이 이러한 내부 객체를 ForwardRef객체로 변환합니다.

__annotate__함수의 시그니처

PEP 649에서는 __annotate__함수의 시그니처를 다음과 같이 지정합니다.

__annotate__(format: int) -> dict

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

하위 호환성

PEP 649에서는 기본 의미론 또는 PEP 563의미론을 사용하는 기존 코드에 미치는 하위 호환성 영향을 철저히 논의합니다.

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

관련 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.TypedDicttyping.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는 문자열 변환기가 모든 표현식을 처리할 수는 없음을 인정합니다. 이제 초안 구현이 마련되었으므로 처리할 수 있는 표현식과 처리할 수 없는 표현식을 더 정확하게 설명할 수 있습니다. 아래에는 문자열 변환기로 복원할 수 있는 표현식과 복원할 수 없는 표현식을 모두 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
    • EqNotEq는 지원됩니다.
    • 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 (andor)
  • IfExp
  • Lambda
  • ListComp
  • SetComp
  • DictComp
  • GeneratorExp

다음 항목은 어노테이션 스코프에서 허용되지 않으므로 관련이 없습니다:

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