python-introspect 0.1.7__tar.gz → 0.1.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {python_introspect-0.1.7/src/python_introspect.egg-info → python_introspect-0.1.9}/PKG-INFO +1 -1
  2. {python_introspect-0.1.7 → python_introspect-0.1.9}/pyproject.toml +1 -1
  3. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect/__init__.py +11 -1
  4. python_introspect-0.1.9/src/python_introspect/annotation_types.py +156 -0
  5. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect/signature_analyzer.py +1 -1
  6. {python_introspect-0.1.7 → python_introspect-0.1.9/src/python_introspect.egg-info}/PKG-INFO +1 -1
  7. {python_introspect-0.1.7 → python_introspect-0.1.9}/tests/test_annotation_types.py +33 -0
  8. {python_introspect-0.1.7 → python_introspect-0.1.9}/tests/test_init.py +1 -1
  9. {python_introspect-0.1.7 → python_introspect-0.1.9}/tests/test_signature_analyzer.py +13 -1
  10. python_introspect-0.1.7/src/python_introspect/annotation_types.py +0 -87
  11. {python_introspect-0.1.7 → python_introspect-0.1.9}/LICENSE +0 -0
  12. {python_introspect-0.1.7 → python_introspect-0.1.9}/README.md +0 -0
  13. {python_introspect-0.1.7 → python_introspect-0.1.9}/setup.cfg +0 -0
  14. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect/dataclass_projection.py +0 -0
  15. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect/enableable.py +0 -0
  16. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect/environment_projection.py +0 -0
  17. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect/exceptions.py +0 -0
  18. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect/unified_parameter_analyzer.py +0 -0
  19. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect/validation.py +0 -0
  20. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect.egg-info/SOURCES.txt +0 -0
  21. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect.egg-info/dependency_links.txt +0 -0
  22. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect.egg-info/requires.txt +0 -0
  23. {python_introspect-0.1.7 → python_introspect-0.1.9}/src/python_introspect.egg-info/top_level.txt +0 -0
  24. {python_introspect-0.1.7 → python_introspect-0.1.9}/tests/test_dataclass_projection.py +0 -0
  25. {python_introspect-0.1.7 → python_introspect-0.1.9}/tests/test_environment_projection.py +0 -0
  26. {python_introspect-0.1.7 → python_introspect-0.1.9}/tests/test_exceptions.py +0 -0
  27. {python_introspect-0.1.7 → python_introspect-0.1.9}/tests/test_unified_parameter_analyzer.py +0 -0
  28. {python_introspect-0.1.7 → python_introspect-0.1.9}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-introspect
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Pure Python introspection toolkit for function signatures, dataclasses, and type hints
5
5
  Author-email: Tristan Simas <tristan.simas@mail.mcgill.ca>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-introspect"
7
- version = "0.1.7"
7
+ version = "0.1.9"
8
8
  description = "Pure Python introspection toolkit for function signatures, dataclasses, and type hints"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -9,7 +9,7 @@ Extensibility:
9
9
  type resolution for framework-specific types (lazy configs, proxies, etc.)
10
10
  """
11
11
 
12
- __version__ = "0.1.7"
12
+ __version__ = "0.1.9"
13
13
 
14
14
  from .signature_analyzer import (
15
15
  SignatureAnalyzer,
@@ -56,6 +56,11 @@ from .environment_projection import (
56
56
  overlay_dataclass_from_environment,
57
57
  )
58
58
  from .annotation_types import (
59
+ coerce_enum_member,
60
+ declared_enum_type,
61
+ enum_import_path,
62
+ enum_input_values,
63
+ enum_member_names,
59
64
  enum_member_type,
60
65
  get_enum_from_list,
61
66
  is_enum_type,
@@ -108,6 +113,11 @@ __all__ = [
108
113
  "EnvironmentVariable",
109
114
  "overlay_dataclass_from_environment",
110
115
  # Annotation type operations
116
+ "coerce_enum_member",
117
+ "declared_enum_type",
118
+ "enum_import_path",
119
+ "enum_input_values",
120
+ "enum_member_names",
111
121
  "enum_member_type",
112
122
  "get_enum_from_list",
113
123
  "is_enum_type",
@@ -0,0 +1,156 @@
1
+ """Generic operations derived directly from Python type annotations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import types
6
+ from enum import Enum
7
+ from typing import Annotated, Union, get_args, get_origin
8
+
9
+
10
+ def is_union_type(annotation: object) -> bool:
11
+ """Return whether ``annotation`` is a typing or PEP 604 union."""
12
+
13
+ return get_origin(annotation) in {Union, types.UnionType}
14
+
15
+
16
+ def optional_member_type(annotation: object) -> object | None:
17
+ """Return ``T`` only for the simple optional declaration ``T | None``."""
18
+
19
+ if not is_union_type(annotation):
20
+ return None
21
+ members = get_args(annotation)
22
+ if len(members) != 2 or type(None) not in members:
23
+ return None
24
+ return next(member for member in members if member is not type(None))
25
+
26
+
27
+ def make_optional(annotation: object) -> object:
28
+ """Return the same declaration with ``None`` admitted exactly once."""
29
+
30
+ if is_union_type(annotation) and type(None) in get_args(annotation):
31
+ return annotation
32
+ return Union[annotation, type(None)]
33
+
34
+
35
+ def resolve_optional(annotation: object) -> object:
36
+ """Resolve an optional declaration to its non-None member declaration."""
37
+
38
+ member_type = optional_member_type(annotation)
39
+ return annotation if member_type is None else member_type
40
+
41
+
42
+ def resolve_annotated(annotation: object) -> object:
43
+ """Resolve an ``Annotated[T, ...]`` declaration to its owned type ``T``."""
44
+
45
+ if get_origin(annotation) is Annotated:
46
+ return get_args(annotation)[0]
47
+ return annotation
48
+
49
+
50
+ def is_enum_type(annotation: object) -> bool:
51
+ """Return whether the declaration is an enum type."""
52
+
53
+ return isinstance(annotation, type) and issubclass(annotation, Enum)
54
+
55
+
56
+ def enum_member_type(annotation: object) -> type[Enum] | None:
57
+ """Return the enum type declared directly or as a simple optional."""
58
+
59
+ if is_enum_type(annotation):
60
+ return annotation
61
+ if not is_union_type(annotation):
62
+ return None
63
+ members = tuple(member for member in get_args(annotation) if member is not type(None))
64
+ if len(members) != 1 or not is_enum_type(members[0]):
65
+ return None
66
+ return members[0]
67
+
68
+
69
+ def declared_enum_type(annotation: object) -> type[Enum] | None:
70
+ """Return the enum carried by transparent annotation wrappers.
71
+
72
+ Schema and transport projections need to recognise the same enum when it is
73
+ wrapped by ``Annotated``, ``Optional``, or a homogeneous container. Mixed
74
+ unions and heterogeneous containers admit values outside that enum, so they
75
+ deliberately have no single declared enum owner.
76
+ """
77
+
78
+ resolved = resolve_annotated(annotation)
79
+ if is_enum_type(resolved):
80
+ return resolved
81
+
82
+ optional_member = optional_member_type(resolved)
83
+ if optional_member is not None:
84
+ return declared_enum_type(optional_member)
85
+
86
+ origin = get_origin(resolved)
87
+ members = get_args(resolved)
88
+ if origin in {list, set, frozenset} and len(members) == 1:
89
+ return declared_enum_type(members[0])
90
+ if origin is tuple and len(members) == 2 and members[1] is Ellipsis:
91
+ return declared_enum_type(members[0])
92
+
93
+ return None
94
+
95
+
96
+ def enum_input_values(annotation: object) -> tuple[str, ...]:
97
+ """Return declaration-derived string inputs accepted for an enum.
98
+
99
+ String-valued members expose their value. Members whose values are not
100
+ strings expose their name, which keeps JSON and command-line projections
101
+ unambiguous, including enums whose concrete value is ``None``.
102
+ """
103
+
104
+ enum_type = declared_enum_type(annotation)
105
+ if enum_type is None:
106
+ return ()
107
+ return tuple(
108
+ member.value if isinstance(member.value, str) else member.name for member in enum_type
109
+ )
110
+
111
+
112
+ def enum_member_names(annotation: object) -> tuple[str, ...]:
113
+ """Return member names for the single enum carried by an annotation."""
114
+
115
+ enum_type = declared_enum_type(annotation)
116
+ if enum_type is None:
117
+ return ()
118
+ return tuple(member.name for member in enum_type)
119
+
120
+
121
+ def enum_import_path(annotation: object) -> str | None:
122
+ """Return the import path of the single enum carried by an annotation."""
123
+
124
+ enum_type = declared_enum_type(annotation)
125
+ if enum_type is None:
126
+ return None
127
+ return f"{enum_type.__module__}.{enum_type.__qualname__}"
128
+
129
+
130
+ def coerce_enum_member(annotation: object, value: object) -> Enum:
131
+ """Coerce an enum value or declared member name through its annotation."""
132
+
133
+ enum_type = declared_enum_type(annotation)
134
+ if enum_type is None:
135
+ raise TypeError(f"Annotation does not declare one enum type: {annotation!r}")
136
+ if isinstance(value, enum_type):
137
+ return value
138
+ for member in enum_type:
139
+ if value == member.value or value == member.name:
140
+ return member
141
+ raise ValueError(f"{value!r} is not a valid {enum_type.__module__}.{enum_type.__name__}")
142
+
143
+
144
+ def is_list_of_enums(annotation: object) -> bool:
145
+ """Return whether the declaration is ``list[SomeEnum]``."""
146
+
147
+ members = get_args(annotation)
148
+ return get_origin(annotation) is list and len(members) == 1 and is_enum_type(members[0])
149
+
150
+
151
+ def get_enum_from_list(annotation: object) -> type[Enum] | None:
152
+ """Return the enum type from ``list[SomeEnum]`` when declared."""
153
+
154
+ if not is_list_of_enums(annotation):
155
+ return None
156
+ return get_args(annotation)[0]
@@ -936,7 +936,7 @@ class SignatureAnalyzer:
936
936
  try:
937
937
  # Try to get type hints, fall back to __annotations__ if resolution fails
938
938
  try:
939
- type_hints = get_type_hints(dataclass_type)
939
+ type_hints = get_type_hints(dataclass_type, include_extras=True)
940
940
  except Exception:
941
941
  type_hints = inspect.get_annotations(dataclass_type, eval_str=False)
942
942
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-introspect
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Pure Python introspection toolkit for function signatures, dataclasses, and type hints
5
5
  Author-email: Tristan Simas <tristan.simas@mail.mcgill.ca>
6
6
  License: MIT
@@ -2,6 +2,11 @@ from enum import Enum
2
2
  from typing import Annotated, Optional
3
3
 
4
4
  from python_introspect import (
5
+ coerce_enum_member,
6
+ declared_enum_type,
7
+ enum_import_path,
8
+ enum_input_values,
9
+ enum_member_names,
5
10
  enum_member_type,
6
11
  get_enum_from_list,
7
12
  is_list_of_enums,
@@ -18,6 +23,15 @@ class Mode(Enum):
18
23
  SECOND = "second"
19
24
 
20
25
 
26
+ class OptionalMode(Enum):
27
+ ENABLED = "enabled"
28
+ INHERIT = None
29
+
30
+
31
+ class OtherMode(Enum):
32
+ THIRD = "third"
33
+
34
+
21
35
  def test_optional_operations_derive_from_the_annotation() -> None:
22
36
  annotated = Annotated[int, "units"]
23
37
  optional = make_optional(annotated)
@@ -44,3 +58,22 @@ def test_enum_operations_derive_from_the_annotation() -> None:
44
58
  assert is_list_of_enums(list[Mode])
45
59
  assert get_enum_from_list(list[Mode]) is Mode
46
60
  assert get_enum_from_list(list[str]) is None
61
+
62
+
63
+ def test_enum_schema_operations_follow_the_single_nested_declaration() -> None:
64
+ annotation = Annotated[list[Optional[Mode]], "modes"]
65
+
66
+ assert declared_enum_type(annotation) is Mode
67
+ assert enum_input_values(annotation) == ("first", "second")
68
+ assert enum_member_names(annotation) == ("FIRST", "SECOND")
69
+ assert enum_import_path(annotation) == f"{Mode.__module__}.{Mode.__qualname__}"
70
+ assert declared_enum_type(Mode | OtherMode) is None
71
+ assert declared_enum_type(Mode | str) is None
72
+ assert declared_enum_type(tuple[Mode, ...]) is Mode
73
+ assert declared_enum_type(tuple[Mode, str]) is None
74
+
75
+
76
+ def test_enum_input_coercion_accepts_values_and_non_string_member_names() -> None:
77
+ assert coerce_enum_member(OptionalMode, "enabled") is OptionalMode.ENABLED
78
+ assert coerce_enum_member(Optional[OptionalMode], "INHERIT") is OptionalMode.INHERIT
79
+ assert enum_input_values(OptionalMode) == ("enabled", "INHERIT")
@@ -11,7 +11,7 @@ class TestPackageImports:
11
11
  """Test that __version__ is available."""
12
12
  assert hasattr(python_introspect, "__version__")
13
13
  assert isinstance(python_introspect.__version__, str)
14
- assert python_introspect.__version__ == "0.1.7"
14
+ assert python_introspect.__version__ == "0.1.9"
15
15
 
16
16
  def test_signature_analyzer_import(self):
17
17
  """Test SignatureAnalyzer is importable."""
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
5
5
  from enum import Enum
6
6
  from functools import wraps
7
7
  from typing import get_args
8
- from typing import Optional, List, Dict, Any
8
+ from typing import Annotated, Optional, List, Dict, Any
9
9
  from python_introspect import (
10
10
  SignatureAnalyzer,
11
11
  ParameterInfo,
@@ -163,6 +163,18 @@ class TestSignatureAnalyzer:
163
163
  assert params["settings"].default_value == {}
164
164
  assert params["items"].is_required is False
165
165
 
166
+ def test_analyze_dataclass_preserves_annotated_metadata(self):
167
+ """Dataclass analysis retains metadata used by downstream projections."""
168
+ marker = object()
169
+
170
+ @dataclass
171
+ class Config:
172
+ shortcut: Annotated[str, marker] = "Ctrl+P"
173
+
174
+ params = SignatureAnalyzer.analyze(Config)
175
+
176
+ assert params["shortcut"].param_type == Annotated[str, marker]
177
+
166
178
  def test_field_type_docs_use_resolved_forward_annotations(self):
167
179
  docs = SignatureAnalyzer._extract_field_type_docs(
168
180
  ForwardFieldDocumentationOwner
@@ -1,87 +0,0 @@
1
- """Generic operations derived directly from Python type annotations."""
2
-
3
- from __future__ import annotations
4
-
5
- import types
6
- from enum import Enum
7
- from typing import Annotated, Union, get_args, get_origin
8
-
9
-
10
- def is_union_type(annotation: object) -> bool:
11
- """Return whether ``annotation`` is a typing or PEP 604 union."""
12
-
13
- return get_origin(annotation) in {Union, types.UnionType}
14
-
15
-
16
- def optional_member_type(annotation: object) -> object | None:
17
- """Return ``T`` only for the simple optional declaration ``T | None``."""
18
-
19
- if not is_union_type(annotation):
20
- return None
21
- members = get_args(annotation)
22
- if len(members) != 2 or type(None) not in members:
23
- return None
24
- return next(member for member in members if member is not type(None))
25
-
26
-
27
- def make_optional(annotation: object) -> object:
28
- """Return the same declaration with ``None`` admitted exactly once."""
29
-
30
- if is_union_type(annotation) and type(None) in get_args(annotation):
31
- return annotation
32
- return Union[annotation, type(None)]
33
-
34
-
35
- def resolve_optional(annotation: object) -> object:
36
- """Resolve an optional declaration to its non-None member declaration."""
37
-
38
- member_type = optional_member_type(annotation)
39
- return annotation if member_type is None else member_type
40
-
41
-
42
- def resolve_annotated(annotation: object) -> object:
43
- """Resolve an ``Annotated[T, ...]`` declaration to its owned type ``T``."""
44
-
45
- if get_origin(annotation) is Annotated:
46
- return get_args(annotation)[0]
47
- return annotation
48
-
49
-
50
- def is_enum_type(annotation: object) -> bool:
51
- """Return whether the declaration is an enum type."""
52
-
53
- return isinstance(annotation, type) and issubclass(annotation, Enum)
54
-
55
-
56
- def enum_member_type(annotation: object) -> type[Enum] | None:
57
- """Return the enum type declared directly or as a simple optional."""
58
-
59
- if is_enum_type(annotation):
60
- return annotation
61
- if not is_union_type(annotation):
62
- return None
63
- members = tuple(
64
- member for member in get_args(annotation) if member is not type(None)
65
- )
66
- if len(members) != 1 or not is_enum_type(members[0]):
67
- return None
68
- return members[0]
69
-
70
-
71
- def is_list_of_enums(annotation: object) -> bool:
72
- """Return whether the declaration is ``list[SomeEnum]``."""
73
-
74
- members = get_args(annotation)
75
- return (
76
- get_origin(annotation) is list
77
- and len(members) == 1
78
- and is_enum_type(members[0])
79
- )
80
-
81
-
82
- def get_enum_from_list(annotation: object) -> type[Enum] | None:
83
- """Return the enum type from ``list[SomeEnum]`` when declared."""
84
-
85
- if not is_list_of_enums(annotation):
86
- return None
87
- return get_args(annotation)[0]