Skip to content

Commit cbb312d

Browse files
committed
Merge remote-tracking branch 'origin/main' into jleinas-biacore-insight-kinetics-affinity
# Conflicts: # src/allotropy/allotrope/schema_mappers/adm/binding_affinity_analyzer/benchling/_2024/_12/binding_affinity_analyzer.py # src/allotropy/parsers/cytiva_biacore_insight/cytiva_biacore_insight_data_creator.py
2 parents bbb9f7e + fa55d5d commit cbb312d

415 files changed

Lines changed: 113846 additions & 328124 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,12 @@ jobs:
3737
- '.github/**'
3838
schema-generation:
3939
- 'src/allotropy/allotrope/schemas/**'
40-
- 'src/allotropy/allotrope/schema_parser/**'
4140
- 'src/allotropy/allotrope/models/**'
4241
- 'src/allotropy/allotrope/schemas.py'
42+
- 'src/allotropy/schema_gen/**'
4343
- 'src/allotropy/exceptions.py'
44-
- 'scripts/generate_schemas.py'
4544
- 'scripts/download_schema.py'
46-
- 'tests/allotrope/schema_parser/**'
45+
- 'tests/schema_gen/**'
4746
- 'pyproject.toml'
4847
- name: Detect single parser changes
4948
id: detect-parser
@@ -109,8 +108,7 @@ jobs:
109108
run: |
110109
if [[ "${{ needs.detect-changes.outputs.run-all }}" == "true" ]]; then
111110
echo "Running all tests"
112-
hatch run test_all.py3.10:pytest -n 2 tests --ignore=tests/allotrope/schema_parser/generate_schemas_test.py
113-
else
111+
hatch run test_all.py3.10:pytest -n 2 tests else
114112
echo "Running tests for parser: ${{ needs.detect-changes.outputs.single-parser }}"
115113
hatch run test_all.py3.10:pytest -n 2 tests/parsers/${{ needs.detect-changes.outputs.single-parser }} tests/parser_factory_test.py::test_table_contents
116114
fi
@@ -137,8 +135,7 @@ jobs:
137135
run: |
138136
if [[ "${{ needs.detect-changes.outputs.run-all }}" == "true" ]]; then
139137
echo "Running all tests"
140-
hatch run test_all.py3.11:pytest -n 2 tests --ignore=tests/allotrope/schema_parser/generate_schemas_test.py
141-
else
138+
hatch run test_all.py3.11:pytest -n 2 tests else
142139
echo "Running tests for parser: ${{ needs.detect-changes.outputs.single-parser }}"
143140
hatch run test_all.py3.11:pytest -n 2 tests/parsers/${{ needs.detect-changes.outputs.single-parser }} tests/parser_factory_test.py::test_table_contents
144141
fi
@@ -164,8 +161,7 @@ jobs:
164161
run: |
165162
if [[ "${{ needs.detect-changes.outputs.run-all }}" == "true" ]]; then
166163
echo "Running all tests"
167-
hatch run test_all.py3.12:pytest -n 2 tests --ignore=tests/allotrope/schema_parser/generate_schemas_test.py
168-
else
164+
hatch run test_all.py3.12:pytest -n 2 tests else
169165
echo "Running tests for parser: ${{ needs.detect-changes.outputs.single-parser }}"
170166
hatch run test_all.py3.12:pytest -n 2 tests/parsers/${{ needs.detect-changes.outputs.single-parser }} tests/parser_factory_test.py::test_table_contents
171167
fi
@@ -194,7 +190,7 @@ jobs:
194190
- name: Install click
195191
run: pip install click!=8.3.0
196192
- name: Run Schema Generation Test
197-
run: hatch run test_all.py${{ matrix.python-version }}:pytest tests/allotrope/schema_parser/generate_schemas_test.py -v
193+
run: hatch run test_all.py${{ matrix.python-version }}:pytest tests/schema_gen/ -v
198194
timeout-minutes: 5
199195

200196
lint:

CLAUDE.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Allotropy
2+
3+
Lab instrument data parser library. Converts vendor file formats to Allotrope Simple Model (ASM) JSON format using generated Python dataclass models.
4+
5+
## Quick Reference
6+
7+
```bash
8+
# Run tests
9+
hatch run test_all.py3.10:pytest tests/ -x -q
10+
11+
# Run linting
12+
hatch run lint
13+
14+
# Auto-fix lint
15+
hatch run lint:fmt
16+
17+
# Generate models from schemas
18+
hatch run scripts:generate-schemas \
19+
"http://purl.allotrope.org/json-schemas/adm/cell-counting/REC/2024/09/cell-counting.schema"
20+
```
21+
22+
## Architecture Overview
23+
24+
```
25+
src/allotropy/
26+
├── allotrope/
27+
│ ├── schemas/ # Cached JSON schemas (fetched from upstream or manually placed)
28+
│ ├── models/ # Python dataclasses representing ASM schema types
29+
│ │ ├── adm/ # Generated technique + core models (NEVER manually edit)
30+
│ │ ├── qudt/ # Generated unit models (NEVER manually edit)
31+
│ │ └── shared/ # Shared base types, enums, and components (manually maintained)
32+
│ ├── schema_mappers/ # Map intermediate Data objects → Model instances
33+
│ ├── schema_gen/ # Code generation pipeline (see schema_gen/CLAUDE.md for details)
34+
│ │ ├── generate.py # Entry point / orchestrator
35+
│ │ ├── codegen/ # JSON schema → Python dataclass generator (package)
36+
│ │ ├── fetcher.py # Schema fetching + caching + dependency resolution
37+
│ │ ├── naming.py # URL ↔ Python identifier transformations
38+
│ │ └── serializer.py # Dataclass ↔ JSON dict round-tripping
39+
│ └── converter.py # Dispatches parser output through schema mapper to JSON
40+
├── parsers/ # Vendor-specific file parsers (one package per instrument)
41+
└── types.py # Shared type aliases
42+
```
43+
44+
## Schema Mapper Layer
45+
46+
Schema mappers sit between parsers and generated models:
47+
48+
```
49+
Parser (reads vendor file) → intermediate Data dataclass → Mapper.map_model() → Model instance
50+
```
51+
52+
Each mapper defines:
53+
- Intermediate dataclasses (`Data`, `Metadata`, `Measurement`, `MeasurementGroup`, etc.)
54+
- A `Mapper` class extending `SchemaMapper[Data, Model]` with `map_model()` method
55+
- The `MANIFEST` URL string for the `field_asm_manifest` field
56+
57+
## Field Naming Conventions
58+
59+
Generated models use these naming patterns:
60+
61+
| JSON Name | Python Field |
62+
|-----------|-------------|
63+
| `$asm.manifest` | `field_asm_manifest` |
64+
| `ASM file identifier` | `asm_file_identifier` |
65+
| `UNC path` | `unc_path` |
66+
| `pH` | `p_h` |
67+
| `pO2` | `p_o2` |
68+
| `pCO2` | `p_co2` |
69+
70+
Quantity value types use abbreviated unit names: `TQuantityValueDegC`, `TQuantityValueMAU`, `TQuantityValueMmHg`, `TQuantityValueMicroLPermin`, `TQuantityValueM`, `TQuantityValueNM`, `TQuantityValueM1s1`, `TQuantityValueS1`, `TQuantityValueRU`, `TQuantityValueS`.
71+
72+
## Code Rules
73+
74+
- **No runtime imports.** All imports must be at module level. Never put `import` or `from ... import` inside a function body to work around circular dependencies — fix the dependency instead.

mypy.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ disallow_untyped_decorators = False
88

99
[mypy-deepdiff.*]
1010
ignore_missing_imports = True
11+
12+
[mypy-allotropy.allotrope.models.*]
13+
disable_error_code = assignment, attr-defined

pyproject.toml

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ classifiers = [
4848
]
4949
dependencies = [
5050
"babel >= 2.9.0",
51-
"cattrs >= 23.2.0",
5251
"chardet >= 5.2.0",
5352
"defusedxml >= 0.7.1",
5453
# NOTE: jsonschema 4.18.0 introduces a serious performance regression, seemingly due to use of new
@@ -117,16 +116,11 @@ path = "src/allotropy/__about__.py"
117116
# 3.11. Use hatch run test_all to tests against 3.10-3.12
118117
# Note: if your hatch env is not picking up this setting, run hatch env prune.
119118
python = "3.11.9"
120-
# We define dependencies for linting and datamodel codegen in the default environment because we lint in
119+
# We define dependencies for linting and codegen in the default environment because we lint in
121120
# the generation script, and we test generation in tests, so we need them everywhere except the actual project.
122121
dependencies = [
123122
"autoflake == 2.2.0",
124-
# black pinned for compatability with datamodel-code-generator
125123
"black == 22.3.0",
126-
"datamodel-code-generator == 0.25.2",
127-
# Without jsonschema-spec and pydantic, pydantic spews a bunch of errors when parsing schemas
128-
"jsonschema-specifications == 2023.7.1",
129-
"pydantic == 1.10.17",
130124
"ruff == 0.0.289",
131125
"semantic-version",
132126
]
@@ -170,8 +164,6 @@ run = "scripts/visualization.py {args:}"
170164

171165
[tool.hatch.envs.lint]
172166
extra-dependencies = [
173-
# mypy sometimes can't find cattrs even though it's a project dependency, so add explicitly here.
174-
"cattrs >= 23.2.0",
175167
"more-itertools >= 10.1.0",
176168
"mypy == 1.8.0",
177169
"pandas-stubs == 2.1.4.231227",
@@ -290,11 +282,15 @@ ban-relative-imports = "all"
290282
"N815",
291283
]
292284
# Generated models can have class fields that shadow python builtins
293-
"src/allotropy/allotrope/models/**/*" = ["A003"]
285+
# PLC0414: "import X as X" used for explicit mypy re-exports of shared types
286+
"src/allotropy/allotrope/models/**/*" = ["A003", "N801", "PLC0414", "RUF001"]
294287
# Scripts can print
295288
"scripts/**/*" = ["T201"]
296289
# update_version can use subprocess
297290
"scripts/update_version.py" = ["S603", "S607"]
291+
# schema_gen is a script/CLI library — print() is the intended output mechanism
292+
# S603/S607: subprocess calls to ruff/black in _lint_file are safe known tools
293+
"src/allotropy/schema_gen/**/*" = ["T201", "S603", "S607"]
298294

299295
[tool.ruff.pyupgrade]
300296
# Preserve types, even if a file imports `from __future__ import annotations`.

scripts/create_parser.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33

44
import click
55

6-
from allotropy.allotrope.schema_parser.path_util import (
6+
from allotropy.allotrope.path_util import (
77
get_import_path_from_path,
88
get_manifest_from_schema_path,
99
get_model_path_from_schema_path,
1010
SCHEMA_DIR_PATH,
1111
)
12-
from allotropy.allotrope.schema_parser.schema_model import snake_to_upper_camel
12+
13+
14+
def snake_to_upper_camel(name: str) -> str:
15+
return "".join(word.capitalize() for word in name.split("_"))
16+
1317

1418
ALLOTROPY_DIR = Path(Path(__file__).parent.parent, "src/allotropy")
1519
TEMPLATE_DIR = Path(Path(__file__).parent, "templates")

scripts/download_schema.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,41 @@
11
#!/usr/bin/env python3
22
import click
33

4-
from allotropy.allotrope.schema_parser.reference_resolver import download_schema
4+
from allotropy.schema_gen.fetcher import SchemaFetcher
5+
from allotropy.schema_gen.technique_resolver import (
6+
is_shorthand,
7+
resolve_shorthand_to_urls,
8+
)
59

610

711
@click.command()
8-
@click.argument("schema_url")
9-
def _download_schema(schema_url: str) -> None:
10-
print(f"Downloading schema at {schema_url}...")
11-
schema_path = download_schema(schema_url)
12-
print(f"Downloaded to {schema_path}")
12+
@click.argument("schema_input")
13+
def _download_schema(schema_input: str) -> None:
14+
"""Download an Allotrope schema and its dependencies.
15+
16+
SCHEMA_INPUT can be a full purl URL or a shorthand like "plate-reader 2026/03".
17+
18+
\b
19+
Examples:
20+
plate-reader 2026/03
21+
pcr WD/2025/06
22+
http://purl.allotrope.org/json-schemas/adm/pcr/REC/2024/09/qpcr.schema
23+
"""
24+
if is_shorthand(schema_input):
25+
urls = resolve_shorthand_to_urls(schema_input)
26+
click.echo(f"Resolved to {len(urls)} schema(s):")
27+
for url in urls:
28+
click.echo(f" {url}")
29+
else:
30+
urls = [schema_input]
31+
32+
fetcher = SchemaFetcher()
33+
total = 0
34+
for url in urls:
35+
click.echo(f"Downloading schema at {url}...")
36+
schemas = fetcher.fetch_with_dependencies(url)
37+
total += len(schemas)
38+
click.echo(f"Downloaded {total} schema(s) to cache")
1339

1440

1541
if __name__ == "__main__":

scripts/generate_schemas.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,57 @@
11
#!/usr/bin/env python3
22
import click
33

4-
from allotropy.allotrope.schema_parser.generate_schemas import generate_schemas
4+
from allotropy.schema_gen.generate import (
5+
_discover_cached_technique_urls,
6+
generate_models,
7+
)
8+
from allotropy.schema_gen.technique_resolver import (
9+
is_shorthand,
10+
resolve_shorthand_to_urls,
11+
)
512

613

714
@click.command()
8-
@click.option("-r", "--regex", help="Regex to determine which schemas to generate.")
9-
def _generate_schemas(regex: str | None = None) -> None:
10-
generate_schemas(schema_regex=regex)
15+
@click.argument("schema_urls", nargs=-1, required=False)
16+
@click.option(
17+
"--all",
18+
"regenerate_all",
19+
is_flag=True,
20+
help="Regenerate all cached technique schemas.",
21+
)
22+
def _generate_schemas(
23+
schema_urls: tuple[str, ...], *, regenerate_all: bool = False
24+
) -> None:
25+
"""Generate Python models from one or more Allotrope schema URLs.
26+
27+
Each argument can be a full purl URL or a shorthand like "plate-reader 2026/03".
28+
Use --all to regenerate every cached technique schema.
29+
30+
\b
31+
Examples:
32+
plate-reader 2026/03
33+
pcr WD/2025/06
34+
http://purl.allotrope.org/json-schemas/adm/pcr/REC/2024/09/qpcr.schema
35+
"""
36+
urls: list[str]
37+
if regenerate_all:
38+
urls = _discover_cached_technique_urls()
39+
click.echo(f"Discovered {len(urls)} cached technique schema(s)")
40+
elif schema_urls:
41+
urls = []
42+
for arg in schema_urls:
43+
if is_shorthand(arg):
44+
resolved = resolve_shorthand_to_urls(arg)
45+
click.echo(f"Resolved '{arg}' to {len(resolved)} schema(s):")
46+
for u in resolved:
47+
click.echo(f" {u}")
48+
urls.extend(resolved)
49+
else:
50+
urls.append(arg)
51+
else:
52+
msg = "Provide schema URLs/shorthands or use --all."
53+
raise click.UsageError(msg)
54+
generate_models(urls)
1155

1256

1357
if __name__ == "__main__":

scripts/update_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import semantic_version # type: ignore
1111

1212
from allotropy.__about__ import __version__
13-
from allotropy.allotrope.schema_parser.path_util import ALLOTROPY_DIR, ROOT_DIR
13+
from allotropy.allotrope.path_util import ALLOTROPY_DIR, ROOT_DIR
1414

1515
SECTION_TO_PREFIX = {
1616
"feat": "Added",

src/allotropy/allotrope/allotrope.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
def serialize_and_validate_allotrope(model: Any) -> dict[str, Any]:
1111
try:
12-
allotrope_dict = unstructure(model)
12+
allotrope_dict: dict[str, Any] = unstructure(model)
1313
except Exception as e:
1414
msg = f"Failed to serialize allotrope model: {e}"
1515
raise AllotropeSerializationError(msg) from e

0 commit comments

Comments
 (0)