Skip to content

Commit e4bcca9

Browse files
committed
Merge remote-tracking branch 'upstream/maint/1.10.0'
2 parents 880ab2d + 6547186 commit e4bcca9

File tree

8 files changed

+421
-7
lines changed

8 files changed

+421
-7
lines changed

.github/workflows/schemacode_ci.yml

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,18 @@ jobs:
5757
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
5858
include:
5959
- os: macos-latest
60-
python-version: 3
60+
python-version: 3.13
6161
- os: windows-latest
62-
python-version: 3
62+
python-version: 3.13
6363
name: ${{ matrix.os }} with Python ${{ matrix.python-version }}
6464
steps:
6565
- uses: actions/checkout@v4
6666

67-
- name: "Set up Python"
68-
uses: actions/setup-python@v5
67+
- name: Set up Python ${{ matrix.python-version }} (uv)
68+
uses: astral-sh/setup-uv@v6
6969
with:
7070
python-version: ${{ matrix.python-version }}
71-
allow-prereleases: true
71+
activate-environment: true
7272

7373
- name: "Display Python version"
7474
run: python -c "import sys; print(sys.version)"
@@ -81,14 +81,19 @@ jobs:
8181

8282
- name: "Install package"
8383
run: |
84-
pip install $( ls dist/*.whl )[all]
84+
uv pip install $( ls dist/*.whl )[all]
8585
8686
- name: "Run tests"
8787
run: |
8888
python -m pytest -vs --doctest-modules -m "not validate_schema" \
8989
--cov-append --cov-report=xml --cov-report=term --cov=src/bidsschematools
9090
working-directory: tools/schemacode
9191

92+
- name: "Validate generated types"
93+
run: |
94+
uvx --with=. mypy tests
95+
working-directory: tools/schemacode
96+
9297
- name: Upload artifacts
9398
uses: actions/upload-artifact@v4
9499
with:
@@ -140,7 +145,7 @@ jobs:
140145
141146
- name: Run schema validation tests
142147
run: |
143-
python -m pytest -vs --doctest-modules -m "not validate_schema" \
148+
python -m pytest -vs --doctest-modules -m "validate_schema" \
144149
--cov-append --cov-report=xml --cov-report=term --cov=src/bidsschematools
145150
working-directory: tools/schemacode
146151

tools/schemacode/docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"pytest",
5656
# Mock internal modules to avoid building docs
5757
"bidsschematools.conftest",
58+
"bidsschematools.tests",
5859
]
5960

6061
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}

tools/schemacode/pdm_build.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
sys.path.insert(0, "src")
44

55
import bidsschematools.schema
6+
from bidsschematools.types._generator import generate_module
67

78

89
def pdm_build_initialize(context):
@@ -23,7 +24,22 @@ def pdm_build_initialize(context):
2324
schema_json.parent.mkdir(parents=True, exist_ok=True)
2425
schema_json.write_text(schema.to_json())
2526

27+
# Write generated code for types
28+
# Limit to wheel to avoid duplication while allowing building
29+
# the wheel directly from source
30+
if context.target == "wheel":
31+
context_py = base_dir / "bidsschematools/types/context.py"
32+
context_py.parent.mkdir(parents=True, exist_ok=True)
33+
context_py.write_text(generate_module(schema, "dataclasses"))
34+
35+
protocols_py = base_dir / "bidsschematools/types/protocols.py"
36+
protocols_py.parent.mkdir(parents=True, exist_ok=True)
37+
protocols_py.write_text(generate_module(schema, "protocol"))
38+
2639

2740
def pdm_build_update_files(context, files):
2841
# Dereference symlinks
2942
files.update({relpath: path.resolve() for relpath, path in files.items()})
43+
# Remove code generator, which is not used once installed
44+
if context.target == "wheel":
45+
del files["bidsschematools/types/_generator.py"]
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
"""BIDS validation context code generator
2+
3+
This module produces source code for BIDS validation context classes.
4+
The source code can be executed at import time to ensure that classes
5+
are consistent with the current state of the BIDS schema.
6+
7+
When packaging, calling modules should be replaced with the source code
8+
they execute, ensuring that the classes are available for static analysis
9+
and can be compiled to bytecode.
10+
11+
This module can be removed during packaging.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
from textwrap import dedent, indent
18+
19+
TYPE_CHECKING = False
20+
if TYPE_CHECKING:
21+
from typing import Any, Callable, Protocol
22+
23+
class Spec(Protocol):
24+
prelude: str
25+
class_def: str
26+
attr_def: str
27+
proto_prefix: str
28+
29+
30+
def with_indent(spaces: int, /) -> Callable[[str], str]:
31+
def decorator(attr: str) -> str:
32+
return indent(dedent(attr), " " * spaces)
33+
34+
return decorator
35+
36+
37+
class ProtocolSpec:
38+
prelude = dedent('''\
39+
"""BIDS validation context definitions
40+
41+
The classes in this module are used to define the context for BIDS validation.
42+
The context is a namespace that contains relevant information about the dataset
43+
as a whole and an individual file to be validated.
44+
45+
These classes are used to define the structure of the context,
46+
but they cannot be instantiated directly.
47+
Conforming subtypes need only match the structure of these classes,
48+
and do not need to inherit from them.
49+
It is recommended to import this module in an ``if TYPE_CHECKING`` block
50+
to avoid import costs.
51+
52+
The classes use ``@property`` decorators to indicate that subtypes need only
53+
provide read access to the attributes, and may restrict writing, for example,
54+
when calculating attributes dynamically based on other attributes.
55+
56+
Note that some type checkers will not match classes that use
57+
:class:`functools.cached_property`.
58+
To permit this, add the following to your module::
59+
60+
if TYPE_CHECKING:
61+
cached_property = property
62+
else:
63+
from functools import cached_property
64+
65+
This module has been auto-generated from the BIDS schema version {version}.
66+
"""
67+
68+
from __future__ import annotations
69+
70+
from collections.abc import Mapping, Sequence
71+
from typing import Any, Literal, Protocol
72+
73+
__all__ = {__all__}
74+
75+
76+
''')
77+
78+
class_def = dedent('''\
79+
class {name}(Protocol):
80+
"""{docstring}"""
81+
''')
82+
83+
attr_def = with_indent(4)('''\
84+
@property
85+
def {name}(self) -> {type}:
86+
"""{docstring}"""
87+
''')
88+
89+
proto_prefix = ""
90+
91+
92+
class DataclassSpec:
93+
prelude = dedent('''\
94+
"""BIDS validation context dataclasses
95+
96+
The classes in this module may be used to populate the context for BIDS validation.
97+
98+
This module has been auto-generated from the BIDS schema version {version}.
99+
"""
100+
101+
from __future__ import annotations
102+
103+
import sys
104+
from dataclasses import dataclass
105+
106+
TYPE_CHECKING = False
107+
if TYPE_CHECKING or "sphinx.ext.autodoc" in sys.modules:
108+
from collections.abc import Mapping, Sequence
109+
from typing import Any, Literal
110+
111+
from . import protocols
112+
113+
if sys.version_info >= (3, 10):
114+
dc_kwargs = {{"slots": True, "frozen": True}}
115+
else: # PY39
116+
dc_kwargs = {{"frozen": True}}
117+
118+
__all__ = {__all__}
119+
120+
121+
''')
122+
123+
class_def = dedent('''\
124+
@dataclass(**dc_kwargs)
125+
class {name}:
126+
"""{docstring}"""
127+
''')
128+
129+
attr_def = with_indent(4)("""\
130+
{name}: {type}{default}
131+
#: {docstring}
132+
""")
133+
134+
proto_prefix = "protocols."
135+
136+
137+
def snake_to_pascal(name: str) -> str:
138+
return "".join(word.capitalize() for word in name.split("_"))
139+
140+
141+
def create_protocol_source(
142+
class_name: str,
143+
properties: dict[str, Any],
144+
metadata: dict[str, Any],
145+
template: Spec,
146+
classes: dict[str, str],
147+
) -> str:
148+
class_name = snake_to_pascal(class_name)
149+
docstring = metadata.get("description", "").strip()
150+
if "@property" not in template.attr_def:
151+
docstring += with_indent(4)("""
152+
153+
Attributes
154+
----------
155+
""")
156+
157+
required = metadata.get("required", {})
158+
optional = [prop for prop in properties if prop not in required]
159+
160+
lines = []
161+
for prop_name in (*required, *optional):
162+
prop_info = properties[prop_name]
163+
type_, md = typespec_to_source(prop_name, prop_info, template, classes)
164+
default = ""
165+
if prop_name not in required:
166+
type_ = f"{type_} | None"
167+
default = " = None"
168+
if not type_.startswith(("int", "float", "str", "bool", "Literal", "Sequence", "Mapping")):
169+
type_ = f"{template.proto_prefix}{type_}"
170+
description = md.get("description", "").strip()
171+
lines.append(
172+
template.attr_def.format(
173+
name=prop_name, type=type_, docstring=description, default=default
174+
)
175+
)
176+
# Avoid double-documenting properties
177+
if "@property" not in template.attr_def:
178+
docstring += with_indent(4)(f"""\
179+
{prop_name}: {type_}
180+
{description}
181+
182+
""")
183+
184+
lines.insert(0, template.class_def.format(name=class_name, docstring=docstring))
185+
186+
classes[class_name] = "\n".join(lines)
187+
return class_name
188+
189+
190+
def typespec_to_source(
191+
name: str,
192+
typespec: dict[str, Any],
193+
template: Spec,
194+
classes: dict[str, str],
195+
) -> tuple[str, dict[str, Any]]:
196+
"""Convert JSON-schema style specification to type and metadata dictionary."""
197+
tp = typespec.get("type")
198+
if not tp:
199+
raise ValueError(f"Invalid typespec: {json.dumps(typespec)}")
200+
metadata = {
201+
key: typespec[key] for key in ("name", "description", "required") if key in typespec
202+
}
203+
if tp == "object":
204+
properties = typespec.get("properties")
205+
if properties:
206+
type_ = create_protocol_source(
207+
name, properties=properties, metadata=metadata, template=template, classes=classes
208+
)
209+
else:
210+
type_ = "Mapping[str, Any]"
211+
elif tp == "array":
212+
if "items" in typespec:
213+
subtype, md = typespec_to_source(name, typespec["items"], template, classes=classes)
214+
else:
215+
subtype = "Any"
216+
type_ = f"Sequence[{subtype}]"
217+
else:
218+
type_ = {
219+
"number": "float",
220+
"string": "str",
221+
"integer": "int",
222+
}[tp]
223+
if type_ == "str" and "enum" in typespec:
224+
type_ = f"Literal[{', '.join(f'{v!r}' for v in typespec['enum'])}]"
225+
return type_, metadata
226+
227+
228+
def generate_protocols(
229+
typespec: dict[str, Any],
230+
root_class_name: str,
231+
template: Spec,
232+
) -> dict[str, str]:
233+
"""Generate protocol definitions from a JSON schema typespec."""
234+
protocols: dict[str, str] = {}
235+
typespec_to_source(
236+
root_class_name,
237+
typespec,
238+
template=template,
239+
classes=protocols,
240+
)
241+
return protocols
242+
243+
244+
def generate_module(schema: dict[str, Any], class_type: str) -> str:
245+
"""Generate a context module source code from a BIDS schema.
246+
247+
Returns a tuple containing the module source code and a list of protocol names.
248+
"""
249+
template: Spec = ProtocolSpec if class_type == "protocol" else DataclassSpec
250+
protocols = generate_protocols(schema["meta"]["context"], "context", template)
251+
prelude = template.prelude.format(version=schema["schema_version"], __all__=list(protocols))
252+
return prelude + "\n\n".join(protocols.values())
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Loader for dynamically-generated context.
2+
3+
This module is accessed when the package is installed in editable mode.
4+
This source code should not be found in the installed package.
5+
"""
6+
7+
from ..schema import load_schema
8+
from ._generator import generate_module
9+
10+
schema = load_schema()
11+
exec(generate_module(schema, "dataclasses"), globals())
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Loader for dynamically-generated context.
2+
3+
This module is accessed when the package is installed in editable mode.
4+
This source code should not be found in the installed package.
5+
"""
6+
7+
from ..schema import load_schema
8+
from ._generator import generate_module
9+
10+
schema = load_schema()
11+
exec(generate_module(schema, "protocol"), globals())

tools/schemacode/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)