Skip to content

Commit 97e5f11

Browse files
Fix NameError in get_type_hints by importing _Version unconditionally
The _Version class was only imported inside TYPE_CHECKING block, causing NameError at runtime when get_type_hints() tried to resolve annotations. Moving the import outside TYPE_CHECKING allows proper resolution of forward references at runtime while still avoiding circular imports thanks to 'from __future__ import annotations'.
1 parent bd3d484 commit 97e5f11

File tree

5 files changed

+292
-93
lines changed

5 files changed

+292
-93
lines changed

setuptools-scm/testing_scm/test_integration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ def test_pretend_metadata_invalid_fields_filtered(
237237
version = wd.get_version()
238238
assert version == "1.0.0"
239239

240-
assert "Invalid metadata fields in pretend metadata" in caplog.text
240+
assert "Invalid fields in TOML data" in caplog.text
241241
assert "invalid_field" in caplog.text
242242
assert "another_bad_field" in caplog.text
243243

vcs-versioning/src/vcs_versioning/_overrides.py

Lines changed: 109 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,79 @@
88

99
import dataclasses
1010
import logging
11+
import os
12+
import sys
1113
from collections.abc import Mapping
14+
from datetime import date, datetime
1215
from difflib import get_close_matches
13-
from typing import Any
16+
from re import Pattern
17+
from typing import TYPE_CHECKING, Any, TypedDict, get_type_hints
1418

1519
from packaging.utils import canonicalize_name
1620

1721
from . import _config
22+
from . import _types as _t
1823
from . import _version_schemes as version
19-
from ._toml import load_toml_or_inline_map
24+
from ._version_cls import Version as _Version
25+
26+
if TYPE_CHECKING:
27+
pass
28+
29+
if sys.version_info >= (3, 11):
30+
pass
31+
else:
32+
pass
2033

2134
log = logging.getLogger(__name__)
2235

36+
37+
# TypedDict schemas for TOML data validation and type hints
38+
39+
40+
class PretendMetadataDict(TypedDict, total=False):
41+
"""Schema for ScmVersion metadata fields that can be overridden via environment.
42+
43+
All fields are optional since partial overrides are allowed.
44+
"""
45+
46+
tag: str | _Version
47+
distance: int
48+
node: str | None
49+
dirty: bool
50+
preformatted: bool
51+
branch: str | None
52+
node_date: date | None
53+
time: datetime
54+
55+
56+
class ConfigOverridesDict(TypedDict, total=False):
57+
"""Schema for Configuration fields that can be overridden via environment.
58+
59+
All fields are optional since partial overrides are allowed.
60+
"""
61+
62+
# Configuration fields
63+
root: _t.PathT
64+
version_scheme: _t.VERSION_SCHEME
65+
local_scheme: _t.VERSION_SCHEME
66+
tag_regex: str | Pattern[str]
67+
parentdir_prefix_version: str | None
68+
fallback_version: str | None
69+
fallback_root: _t.PathT
70+
write_to: _t.PathT | None
71+
write_to_template: str | None
72+
version_file: _t.PathT | None
73+
version_file_template: str | None
74+
parse: Any # ParseFunction - avoid circular import
75+
git_describe_command: _t.CMD_TYPE | None # deprecated but still supported
76+
dist_name: str | None
77+
version_cls: Any # type[_Version] - avoid circular import
78+
normalize: bool # Used in from_data
79+
search_parent_directories: bool
80+
parent: _t.PathT | None
81+
scm: dict[str, Any] # Nested SCM configuration
82+
83+
2384
PRETEND_KEY = "SETUPTOOLS_SCM_PRETEND_VERSION"
2485
PRETEND_KEY_NAMED = PRETEND_KEY + "_FOR_{name}"
2586
PRETEND_METADATA_KEY = "SETUPTOOLS_SCM_PRETEND_METADATA"
@@ -90,7 +151,7 @@ def _find_close_env_var_matches(
90151

91152
def _read_pretended_metadata_for(
92153
config: _config.Configuration,
93-
) -> dict[str, Any] | None:
154+
) -> PretendMetadataDict | None:
94155
"""read overridden metadata from the environment
95156
96157
tries ``SETUPTOOLS_SCM_PRETEND_METADATA``
@@ -99,8 +160,6 @@ def _read_pretended_metadata_for(
99160
Returns a dictionary with metadata field overrides like:
100161
{"node": "g1337beef", "distance": 4}
101162
"""
102-
import os
103-
104163
from .overrides import EnvReader
105164

106165
log.debug("dist name: %s", config.dist_name)
@@ -110,39 +169,15 @@ def _read_pretended_metadata_for(
110169
env=os.environ,
111170
dist_name=config.dist_name,
112171
)
113-
pretended = reader.read("PRETEND_METADATA")
114172

115-
if pretended:
116-
try:
117-
metadata_overrides = load_toml_or_inline_map(pretended)
118-
# Validate that only known ScmVersion fields are provided
119-
valid_fields = {
120-
"tag",
121-
"distance",
122-
"node",
123-
"dirty",
124-
"preformatted",
125-
"branch",
126-
"node_date",
127-
"time",
128-
}
129-
invalid_fields = set(metadata_overrides.keys()) - valid_fields
130-
if invalid_fields:
131-
log.warning(
132-
"Invalid metadata fields in pretend metadata: %s. "
133-
"Valid fields are: %s",
134-
invalid_fields,
135-
valid_fields,
136-
)
137-
# Remove invalid fields but continue processing
138-
for field in invalid_fields:
139-
metadata_overrides.pop(field)
140-
141-
return metadata_overrides or None
142-
except Exception as e:
143-
log.error("Failed to parse pretend metadata: %s", e)
144-
return None
145-
else:
173+
try:
174+
# Use schema validation during TOML parsing
175+
metadata_overrides = reader.read_toml(
176+
"PRETEND_METADATA", schema=PretendMetadataDict
177+
)
178+
return metadata_overrides or None
179+
except Exception as e:
180+
log.error("Failed to parse pretend metadata: %s", e)
146181
return None
147182

148183

@@ -177,36 +212,41 @@ def _apply_metadata_overrides(
177212

178213
log.info("Applying metadata overrides: %s", metadata_overrides)
179214

180-
# Define type checks and field mappings
181-
from datetime import date, datetime
182-
183-
field_specs: dict[str, tuple[type | tuple[type, type], str]] = {
184-
"distance": (int, "int"),
185-
"dirty": (bool, "bool"),
186-
"preformatted": (bool, "bool"),
187-
"node_date": (date, "date"),
188-
"time": (datetime, "datetime"),
189-
"node": ((str, type(None)), "str or None"),
190-
"branch": ((str, type(None)), "str or None"),
191-
# tag is special - can be multiple types, handled separately
192-
}
193-
194-
# Apply each override individually using dataclasses.replace for type safety
215+
# Get type hints from PretendMetadataDict for validation
216+
field_types = get_type_hints(PretendMetadataDict)
217+
218+
# Apply each override individually using dataclasses.replace
195219
result = scm_version
196220

197221
for field, value in metadata_overrides.items():
198-
if field in field_specs:
199-
expected_type, type_name = field_specs[field]
200-
assert isinstance(value, expected_type), (
201-
f"{field} must be {type_name}, got {type(value).__name__}: {value!r}"
202-
)
203-
result = dataclasses.replace(result, **{field: value})
204-
elif field == "tag":
205-
# tag can be Version, NonNormalizedVersion, or str - we'll let the assignment handle validation
206-
result = dataclasses.replace(result, tag=value)
207-
else:
208-
# This shouldn't happen due to validation in _read_pretended_metadata_for
209-
log.warning("Unknown field '%s' in metadata overrides", field)
222+
# Validate field types using the TypedDict annotations
223+
if field in field_types:
224+
expected_type = field_types[field]
225+
# Handle Optional/Union types (e.g., str | None)
226+
if hasattr(expected_type, "__args__"):
227+
# Union type - check if value is instance of any of the types
228+
valid = any(
229+
isinstance(value, t) if t is not type(None) else value is None
230+
for t in expected_type.__args__
231+
)
232+
if not valid:
233+
type_names = " | ".join(
234+
t.__name__ if t is not type(None) else "None"
235+
for t in expected_type.__args__
236+
)
237+
raise TypeError(
238+
f"Field '{field}' must be {type_names}, "
239+
f"got {type(value).__name__}: {value!r}"
240+
)
241+
else:
242+
# Simple type
243+
if not isinstance(value, expected_type):
244+
raise TypeError(
245+
f"Field '{field}' must be {expected_type.__name__}, "
246+
f"got {type(value).__name__}: {value!r}"
247+
)
248+
249+
result = dataclasses.replace(result, **{field: value}) # type: ignore[arg-type]
210250

211251
# Ensure config is preserved (should not be overridden)
212252
assert result.config is config, "Config must be preserved during metadata overrides"
@@ -222,8 +262,6 @@ def _read_pretended_version_for(
222262
tries ``SETUPTOOLS_SCM_PRETEND_VERSION``
223263
and ``SETUPTOOLS_SCM_PRETEND_VERSION_FOR_$UPPERCASE_DIST_NAME``
224264
"""
225-
import os
226-
227265
from .overrides import EnvReader
228266

229267
log.debug("dist name: %s", config.dist_name)
@@ -241,15 +279,16 @@ def _read_pretended_version_for(
241279
return None
242280

243281

244-
def read_toml_overrides(dist_name: str | None) -> dict[str, Any]:
245-
"""Read TOML overrides from environment."""
246-
import os
282+
def read_toml_overrides(dist_name: str | None) -> ConfigOverridesDict:
283+
"""Read TOML overrides from environment.
247284
285+
Validates that only known Configuration fields are provided.
286+
"""
248287
from .overrides import EnvReader
249288

250289
reader = EnvReader(
251290
tools_names=("SETUPTOOLS_SCM", "VCS_VERSIONING"),
252291
env=os.environ,
253292
dist_name=dist_name,
254293
)
255-
return reader.read_toml("OVERRIDES")
294+
return reader.read_toml("OVERRIDES", schema=ConfigOverridesDict)

vcs-versioning/src/vcs_versioning/_toml.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import sys
55
from collections.abc import Callable
66
from pathlib import Path
7-
from typing import Any, TypeAlias, TypedDict, cast
7+
from typing import Any, TypeAlias, TypedDict, TypeVar, cast, get_type_hints
88

99
if sys.version_info >= (3, 11):
1010
from tomllib import loads as load_toml
@@ -17,11 +17,18 @@
1717
TOML_RESULT: TypeAlias = dict[str, Any]
1818
TOML_LOADER: TypeAlias = Callable[[str], TOML_RESULT]
1919

20+
# TypeVar for generic TypedDict support - the schema defines the return type
21+
TSchema = TypeVar("TSchema", bound=TypedDict) # type: ignore[valid-type]
22+
2023

2124
class InvalidTomlError(ValueError):
2225
"""Raised when TOML data cannot be parsed."""
2326

2427

28+
class InvalidTomlSchemaError(ValueError):
29+
"""Raised when TOML data does not conform to the expected schema."""
30+
31+
2532
def read_toml_content(path: Path, default: TOML_RESULT | None = None) -> TOML_RESULT:
2633
try:
2734
data = path.read_text(encoding="utf-8")
@@ -42,17 +49,78 @@ class _CheatTomlData(TypedDict):
4249
cheat: dict[str, Any]
4350

4451

45-
def load_toml_or_inline_map(data: str | None) -> dict[str, Any]:
52+
def _validate_against_schema(
53+
data: dict[str, Any],
54+
schema: type[TypedDict] | None, # type: ignore[valid-type]
55+
) -> dict[str, Any]:
56+
"""Validate parsed TOML data against a TypedDict schema.
57+
58+
Args:
59+
data: Parsed TOML data to validate
60+
schema: TypedDict class defining valid fields, or None to skip validation
61+
62+
Returns:
63+
The validated data with invalid fields removed
64+
65+
Raises:
66+
InvalidTomlSchemaError: If there are invalid fields (after logging warnings)
4667
"""
47-
load toml data - with a special hack if only a inline map is given
68+
if schema is None:
69+
return data
70+
71+
# Extract valid field names from the TypedDict
72+
try:
73+
valid_fields = frozenset(get_type_hints(schema).keys())
74+
except NameError as e:
75+
# If type hints can't be resolved, log warning and skip validation
76+
log.warning("Could not resolve type hints for schema validation: %s", e)
77+
return data
78+
79+
# If the schema has no fields (empty TypedDict), skip validation
80+
if not valid_fields:
81+
return data
82+
83+
invalid_fields = set(data.keys()) - valid_fields
84+
if invalid_fields:
85+
log.warning(
86+
"Invalid fields in TOML data: %s. Valid fields are: %s",
87+
sorted(invalid_fields),
88+
sorted(valid_fields),
89+
)
90+
# Remove invalid fields
91+
validated_data = {k: v for k, v in data.items() if k not in invalid_fields}
92+
return validated_data
93+
94+
return data
95+
96+
97+
def load_toml_or_inline_map(data: str | None, *, schema: type[TSchema]) -> TSchema:
98+
"""Load toml data - with a special hack if only a inline map is given.
99+
100+
Args:
101+
data: TOML string to parse, or None for empty dict
102+
schema: TypedDict class for schema validation.
103+
Invalid fields will be logged as warnings and removed.
104+
105+
Returns:
106+
Parsed TOML data as a dictionary conforming to the schema type
107+
108+
Raises:
109+
InvalidTomlError: If the TOML content is malformed
48110
"""
49111
if not data:
50-
return {}
112+
return {} # type: ignore[return-value]
51113
try:
52114
if data[0] == "{":
53115
data = "cheat=" + data
54116
loaded: _CheatTomlData = cast(_CheatTomlData, load_toml(data))
55-
return loaded["cheat"]
56-
return load_toml(data)
117+
result = loaded["cheat"]
118+
else:
119+
result = load_toml(data)
120+
121+
return _validate_against_schema(result, schema) # type: ignore[return-value]
57122
except Exception as e: # tomllib/tomli raise different decode errors
123+
# Don't re-wrap our own validation errors
124+
if isinstance(e, InvalidTomlSchemaError):
125+
raise
58126
raise InvalidTomlError("Invalid TOML content") from e

0 commit comments

Comments
 (0)