Skip to content

refactor: Implement modular schema generation #1176

Merged
nathan-stender merged 94 commits intomainfrom
schema-gen-v2
Apr 16, 2026
Merged

refactor: Implement modular schema generation #1176
nathan-stender merged 94 commits intomainfrom
schema-gen-v2

Conversation

@nathan-stender
Copy link
Copy Markdown
Collaborator

@nathan-stender nathan-stender commented Apr 10, 2026

Summary

The old model generation pipeline was built on datamodel-code-generator, a generic JSON Schema → Pydantic tool that was never designed for Allotrope's cross-schema $ref resolution, deeply nested allOf composition, or BENCHLING vendor extensions. To bridge the gap, we had accumulated ~2800 lines of pre-processing (flattener, schema cleaner, reference resolver) and post-processing (model editor, update_units) that massaged schemas before and after generation. Serialization relied on cattrs with 76 hand-maintained SPECIAL_KEYS entries mapping Python field names to JSON property names, plus custom hook registration for unions, datacubes, and dataclasses.

This PR replaces the entire pipeline with a purpose-built codegen that reads Allotrope JSON schemas directly and produces clean, modular Python dataclasses. The serializer is now 400 lines of straightforward dataclass ↔ dict code driven by json_name field metadata that the codegen itself attaches — no hand-maintained mapping table, no cattrs.

What improves

Models are now modular. The old pipeline produced one monolithic file per technique containing every type inlined. The new pipeline generates shared types (core.py, hierarchy.py, cube.py) per core schema version that technique modules import from, matching how the JSON schemas actually reference each other. Adding a new technique no longer duplicates every shared type.

Serialization is self-describing. Each generated field carries metadata={"json_name": "..."} when the JSON name isn't a trivial space↔underscore mapping of the Python name. The serializer reads this metadata. No more maintaining a parallel mapping table that silently breaks when names drift.

Schema composition actually works. The codegen handles allOf merging, anyOf/oneOf discriminated unions, BENCHLING schema forking, constraint-only overlays, and cross-file $ref resolution — all patterns that required fragile workarounds in the old pipeline.

BENCHLING isolation. BENCHLING technique schemas embed modified copies of REC shared schemas. The generator forks these into BENCHLING-versioned shared modules, keeping REC schemas pristine. Generating a single REC technique produces identical output whether or not BENCHLING schemas exist.

Generation is idempotent. Any schema can be regenerated independently or together with others. The output is deterministic and verified by a staleness test in CI.

Fewer dependencies. Removes cattrs, datamodel-code-generator, pydantic, and jsonschema-specifications from the dependency tree.

What the new pipeline looks like

schema URLs → fetch + cache (fetcher.py)
  → fork BENCHLING shared schemas (generate.py)
  → topological sort by dependency
  → generate Python modules (codegen.py)
  → lint (ruff + black)
  → write .py files

The codegen dispatches on schema structure: object → frozen dataclass, allOf → merged dataclass with inheritance, anyOf/oneOf → union type, enum → Enum or Literal. TQuantityValue{Unit} subclasses are detected from the allOf[tQuantityValue, unit_ref] pattern and generated once in a shared module.

Generated model structure

models/
├── shared/definitions/          # Manually maintained base types
│   ├── definitions.py           # TQuantityValue, TDatacube, JsonFloat
│   ├── quantity_values.py       # TQuantityValue{Unit} subclasses (generated, centralized)
│   └── units.py                 # HasUnit + unit subclasses (generated, centralized)
├── adm/core/{status}/{version}/ # Shared types per core schema version
│   ├── core.py                  # Field types, quantity value imports
│   ├── hierarchy.py             # Document hierarchy types
│   └── cube.py                  # Datacube types
└── adm/{technique}/{status}/{version}/
    └── {technique}.py           # Technique-specific types (imports from core)

Stats

  • 404 files changed, ~216k net lines removed
  • 78 commits
  • 1009 tests passing, 0 lint errors (ruff, black, mypy)

Test plan

  • All 1009 existing tests pass unchanged
  • Full lint suite clean
  • Serialization round-trip verified via existing converter tests
  • Schema validation verified via parser positive/negative test cases
  • Generation staleness test ensures models stay in sync with schemas
  • Single-schema idempotency test verifies order independence
  • 2280 lines of new codegen unit tests

🤖 Generated with Claude Code

nathan-stender and others added 5 commits April 10, 2026 14:23
Fresh implementation that generates modular Python dataclass models from
Allotrope JSON schemas. Given a GitLab URL to an ADM schema, the library
downloads the schema and all $ref dependencies, then generates one Python
module per schema file with proper cross-module imports.

Key features:
- Modular output: shared sub-schemas (core, hierarchy, cube, units) become
  shared Python modules that ADM-specific modules import from
- Multi-version support: different ADM schemas can pin different core
  versions (e.g., REC/2024/06 vs REC/2024/09) independently
- Schema-conformant JSON serialization via field metadata that stores
  original JSON property names ($asm.manifest, @index, spaces, etc.)
- Full round-trip: to_dict(model) -> JSON dict -> from_dict(dict, Model)
- Tested with spectrophotometry (7 schemas) and qPCR (6 schemas)

Includes generated models for both schemas and cached schema JSON files
as working examples.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Use double quotes in generated code (Q000), single quotes for strings
  containing double quotes (Q003)
- Merge dataclass/typing imports into single lines (I001)
- Fix unused arguments with underscore prefix (ARG002)
- Use keyword-only bool parameter (FBT001)
- Remove dead code in fetcher topological sort
- Fix unit class name dedup to use numeric suffixes avoiding collisions
- Replace ambiguous Unicode minus sign with escape (RUF001)
- Add per-file-ignores for generated models_v2/ (A003, N801, RUF001)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Fix variable shadowing in ModuleCode.render (imp → stdlib_imp)
- Add type annotations for json.load/json.loads returns in fetcher
- Add type: ignore comments for generic dataclass return types in serializer
- Add mypy config to disable assignment checks in generated models_v2/
  (ADM subclasses intentionally narrow base class field types)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@nathan-stender nathan-stender requested review from a team and slopez-b as code owners April 10, 2026 18:46
Comment thread src/allotropy/schema_gen/naming.py Fixed
nathan-stender and others added 5 commits April 10, 2026 15:23
…s and skipping constraint overlays

Array type aliases (TNumberArray, TBooleanArray, etc.) now resolve element
types from the schema's items constraint instead of falling back to Any.
Constraint-only overlays (schemas with only validation keywords like
minItems/maxItems/prefixItems) are recognized and skipped, preventing
spurious empty classes and untyped fields.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Rewrite generate.py to use datamodel-codegen as the core engine with:
- Pre-processing: flatten external $ref#/$defs/X into local $defs (flattener.py)
- Post-processing: frozen/kw_only decorators, dedup imports, json_name metadata (post_processor.py)

Key fixes in this commit:
- Fix double `=` bug in _join_multiline_defaults (strip trailing `=` before `(`)
- Pass flattened schema to post_process for complete json_name coverage
- Add camelCase→snake_case conversion in property_name_to_python
- Add general @-prefix→field_ handling for JSON property names

All 7 spectrophotometry sub-schema modules generate cleanly with proper
cross-module imports and 100% json_name metadata coverage.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Remove unused `re` import from generate.py
- Remove unused `names_str` variable in post_processor.py
- Fix PLW2901 loop variable reassignment in add_json_name_metadata
- Apply black formatting to generate.py

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Add camelCase→snake_case conversion (minInclusive→min_inclusive)
- Add general @-prefix→field_ handling (@componentDatatype→field_component_datatype)
- Handle parentheses in property names to match datamodel-codegen
  (e.g. "setting (qPCR)" → setting__q_pcr_)
- Fix mypy errors in flattener.py and generate.py
- Regenerate all models with 100% json_name metadata coverage

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@nathan-stender nathan-stender changed the title feat: Add new modular schema-to-model generation library feat: Schema-gen v2 - Hybrid datamodel-codegen pipeline Apr 10, 2026
nathan-stender and others added 10 commits April 10, 2026 20:56
…ypoints

Swap the qPCR schema mapper from v1 hand-written models to v2 generated
models, validated across all 3 parsers (appbio_quantstudio, cfxmaestro,
appbio_quantstudio_designandanalysis) with zero test data changes.

Key changes:
- Add v2 serialization entrypoints (unstructure_v2, structure_v2) with
  identical signatures to v1 counterparts
- Auto-detect v2 models in serialize_and_validate_allotrope so existing
  public API works transparently
- Fix URL-encoding bug in flattener (_parse_def_ref) where %23-encoded
  unit refs (e.g., "#") were not resolved, causing missing default units
- Regenerate v2 qPCR model with correct unit defaults from schema
- Clean v2 swap of qPCR mapper — no v1 code remains
- Add get_data_cube_v2 helper for v2 datacube construction
- Fix allOf merge for multi-ref composition and $asm.manifest preservation
- Fix post-processor dedup to restrict to core schemas only
- Add json_name metadata and Enum/custom_information_document handling
  to v2 serializer

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Fix flattener to deeply merge allOf properties and run a second unwrap
pass for single-branch allOfs left by earlier passes. Fix post-processor
to handle bracket-based multiline type annotations. Generate V2 cell
counting model and swap the mapper to use it.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…attener/post_processor

Replace datamodel-codegen pipeline with custom SchemaCodeGenerator for V2 models.
Key changes:
- Fix dangling local refs when hierarchy schemas are deep-merged into technique
  context by absolutizing #/$defs refs at cross-schema merge sites
- Generate modular models: core types in core.py, hierarchy types in hierarchy.py,
  technique-specific types in per-schema modules
- TQuantityValue{Unit} thin subclasses generated once in core.py
- anyOf detector variants merged with required-field intersection
- Update mappers (cell_counting, qpcr, solution_analyzer) for new model imports
- Delete flattener.py (670 lines) and post_processor.py (418 lines)
- All 900 tests pass, lint clean

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Fix anyOf required field semantics: strip required arrays recursively
  from anyOf-merged properties so variant-specific fields are optional
  (e.g., dilution_factor_setting in solution_analyzer)
- Fix TQuantityValue.value type: use JsonFloat (float | InvalidJsonFloat)
  to support NaN/Infinity serialization, matching V1 behavior
- Narrow SchemaMapper.get_date_time return type from TDateTimeValue to
  str, fixing TDateTimeValueItem/TDateTimeStampValueItem mismatch
- Fix mypy errors in codegen.py (_resolve_ref_to_schema return types,
  _strip_required_recursive variable typing)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Switch 11 parser files from V1 Model imports to V2 Model imports
  to match the V2-based schema mappers
- Add DataCubeProtocolV2 for V2 datacube type compatibility
- Fix beckman_pharmspec test to handle optional V2 model fields
  with proper None guards

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…en pipeline

These files were generated by the previous datamodel-codegen pipeline
and are no longer used. All current V2 models use the _2024/_09 schema
versions generated by the custom codegen.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…tion, fix idempotency

- Migrate solution_analyzer/BENCHLING mapper and novabio_flex2 parser to V2 models
- Add BENCHLING schemas in linked-ref format (bga detector with kPa, solution-analyzer)
- Add codegen Pattern 1b for oneOf unit refs (e.g. mmHg | kPa)
- Add V2 schema validation using RefResolver with pre-loaded schema store
- Fix codegen idempotency: sort classes alphabetically within dependency levels
- Add _strip_embedded_defs safety net for BENCHLING embedded schema format
- Widen quantity_or_none TypeVar to accept V2 TQuantityValue types
- Regenerate all V2 models together so core.py has all quantity value types

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Fix codegen to extract oneOf variant properties from detector schemas,
enabling datacube fields (absorption_profile, luminescence_profile,
fluorescence_emission_profile) on MeasurementDocumentItem. Migrate
mapper and all 6 parsers to V2 imports.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Generate V2 models for plate-reader REC/2025/03 schema and migrate
mapper + 4 parsers to use them. Refactored spectrum datacube method
to use constructor arguments instead of mutable assignment (V2 frozen
dataclasses). No V2 equivalent for TQuantityValueKiloDalton or
TQuantityValueNanogramPerMicroliter — using TQuantityValue with
inline unit strings.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@nathan-stender nathan-stender marked this pull request as draft April 12, 2026 06:26
nathan-stender and others added 4 commits April 12, 2026 04:14
… technique types

The codegen had two bugs in allOf composition:

1. _resolve_all_of_property() didn't deep-merge overlapping properties from
   base $ref schemas when the ref became a base class. This caused types like
   DeviceSystemDocument to only get technique-specific fields (e.g., pump_model_number)
   while losing all hierarchy fields (asset_management_identifier, brand_name, etc.).

2. _resolve_all_of_array_items() only handled $ref variants in anyOf blocks,
   missing inline property variants. This caused PeakItem to lose ~30 optional
   peak metric fields (peak_width, asymmetry_factor, theoretical_plates, etc.)
   that are defined as inline anyOf variants in the schema.

Also added class deduplication in render() to prevent duplicate class definitions
when multiple detector schemas generate identical inline types.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Generate V2 models from the BENCHLING liquid chromatography schema with all
17 dependency schemas (core, hierarchy, cube, units, and detector sub-schemas).

Key types generated correctly with the allOf composition fix:
- DeviceSystemDocument: 11 fields (hierarchy base + technique-specific)
- PeakItem: 51 fields (base + 30 anyOf peak metrics)
- MeasurementDocumentItem: 15 fields (including all detector datacubes)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…odels

Migrate 4 LC parsers (cytiva_unicorn, agilent_openlab_cds, benchling_empower,
benchling_chromeleon) from V1 to V2 models. Fix codegen multi-unit union type
handling so peak_height correctly generates as a union of 5 quantity types.
Fix mAu→mAU unit casing in cytiva unicorn absorbance parser.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Remove cell_counting and light_obscuration BENCHLING schemas, mappers,
and V1 models that have zero parser imports — no parsers depend on them.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
nathan-stender and others added 20 commits April 15, 2026 02:27
Core modules no longer re-export all ~780 TQuantityValue types from
shared. Technique/hierarchy models now import QV types directly from
shared/definitions/quantity_values. Added _is_quantity_value_variant()
to route BENCHLING schema refs to pre-composed QV defs (e.g.
core.schema#/$defs/tQuantityValueUnitless) through shared instead of
core.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
These were artifacts of the removed _add_core_quantity_value_reexports
post-pass. Manifest schemas have zero $defs, so the codegen produces
empty modules and correctly skips writing them, but never cleaned up
the old files on disk.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
TNullableQuantityValue allowed value=None in quantity fields, but no
parser ever constructed one with a null value. The quantity_or_none()
helper returns None for the whole field instead. Replaced all nullable
variants with their regular TQuantityValue equivalents. JSON output is
unchanged since the nullable path was never exercised at runtime.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
These 6 units (OD, M-1cm-1, mm^2, RU^2, TODO, U/L) never appeared in
any schema file. 3 were dead code (OD, TODO, U/L). The remaining 3
were used only in custom_information_document dicts (untyped), where a
bare TQuantityValue(value=x, unit="...") produces identical JSON. Also
fixed _update_shared_units to detect removals, not just additions.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Replace bare TQuantityValue(value=x, unit=y) with quantity_or_none_from_unit
for model fields that accept specific TQuantityValue subclasses (electrophoresis
peak/data_region positions, plate reader peak est. MW). Custom info document
fields remain TQuantityValue since they are untyped dicts.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Extract shared helpers to eliminate repeated patterns in codegen.py:
- _merge_props_into(): replaces 8 copies of the 5-line property-merge loop
- _collect_all_of_parts(): shared collection logic for allOf merged class
  and allOf array items, eliminating ~12 duplicated lines
- _is_units_ref(): replaces 2 copies of try/except/normalize URL check
- _resolve_variant_types(): eliminates duplicated anyOf/oneOf loop in
  array item type resolution
- _resolve_variant(), _merge_variant_props(), _warn_deep_nesting():
  flatten merge_variant_properties from 4+ nesting levels to 2

Also: remove dead _extract_unit_const wrapper, remove redundant manual
import dedup, replace shallow .update() with deep-merge for consistency.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Reuse _resolve_variant_types in _resolve_any_of_property (was duplicated)
- Move _collect_all_of_parts to module level (consistent with other
  stateless helpers like _partition_all_of and _merge_props_into)
- Remove no-op branch in _generate_one_of (constraint-only variants
  are implicitly skipped)
- Flatten nested if in _resolve_array_type for readability

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The list comprehension returned `value` (the outer list) instead of `v`
(the current item) when an item was itself a list. This replaced inner
lists with copies of the entire outer list — a data corruption bug for
custom information documents containing nested arrays (e.g. data cubes).

Pre-existing on main; added regression test for the nested list path.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
- Add timeout=30 to urlopen() to prevent indefinite hangs
- Catch HTTPError and URLError separately instead of bare Exception,
  giving distinct messages for "schema not found" vs "network error"
- Add SchemaFetcher tests for cache hit, cache miss, and recursive
  dependency resolution from cache
- Make circular dependency fallback deterministic with sorted()
- Add test verifying circular dep ordering is reproducible

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Verifies the implicit contract between codegen's json_name metadata and
converter's default_json_name() fallback across all 93 importable
generated model modules (~4800 fields):

- Fields with json_name metadata must actually need it (non-trivial mapping)
- No field produces an empty JSON key

Catches silent serialization breakage if the two paths diverge.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The set-subset check `prop_schema.keys() <= _CONSTRAINT_ONLY_KEYS` was
clever but non-obvious. Extracted to a named function with a docstring
explaining what constraint-only overlays are and why they're skipped.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Break up the two main coupling points in codegen.py:

- Extract class deduplication logic from ModuleCode.render() into a
  standalone _deduplicate_classes() function with a clear docstring
  covering the three strategies (identical merge, variant split, widening).
  render() is now a clean pipeline: deduplicate → sort → emit.

- Add ModuleCode.add_import() convenience method and replace all 5
  scattered module.imports.append(ImportEntry(...)) calls. Centralizes
  import management into a single entry point.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Split the 115-line generate_models() into focused phase functions:

- _fetch_all_schemas(): Fetch and merge all schemas via SchemaFetcher
- _prepare_benchling_schemas(): Load cached BENCHLING + fork shared deps
- _build_generation_order(): Topological sort with progress output
- _generate_and_write(): Code generation, file writing, formatting

Also extract _write_and_lint() to replace the repeated _write_module() +
_lint_file() pattern (was used in 3 places).

generate_models() is now 12 lines of orchestration calling these phases.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
The pattern `ref.split("#")[0]` appeared in 5 places across codegen.py.
Extracted to a named helper for clarity and single point of change.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Extract 9 inline regex compilations to module-level constants. These
patterns are called per-property/per-class during generation of hundreds
of schemas, so pre-compilation avoids redundant work and makes the
patterns easier to find and maintain.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Adds 25 parameterized tests (one per technique) that each generate a
single schema into a temp directory and compare against committed output.
Covers both REC and BENCHLING schemas, preferring BENCHLING where
available since they exercise more codegen paths (forking, embedded $defs).

This replaces the coverage gap from deleting the V1 generate_schemas_test.py
which parameterized over all schemas. Each technique test runs in ~3s.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Replace 7 occurrences of string literals with named constants:
- _BENCHLING_MARKER = "/BENCHLING/"
- _CORE_SCHEMA_MARKER = "/core/"
- _SHARED_SCHEMA_NAMES = ("core", "qudt")

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Split the 1000+ line SchemaCodeGenerator God class into two focused classes:
- TypeResolver (22 methods): all type dispatch, property resolution, $ref
  resolution, quantity value generation, and JSON type mapping
- SchemaCodeGenerator (10 methods): module-level orchestration — iterating
  schemas in dependency order, shared definition imports, ADM root flattening

The separation is clean because the mutual recursion (generate_dataclass →
resolve_property_type → generate_dataclass) is entirely within the type
resolution layer. The orchestrator only enters the type layer at two points:
generate_type() and generate_dataclass().

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Update codegen class descriptions (TypeResolver extraction, SchemaMerger,
QuantityValueManager), fix pipeline function names, correct QV type flow,
add dedup strategies and fetcher debugging tips.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…e state

- Split codegen.py (2185 lines) into codegen/ package with 6 focused modules:
  ir.py, merger.py, quantity_values.py, type_resolver.py, generator.py, __init__.py
- Eliminate shared mutable state: TypeResolver now uses a pre-computed static
  export_map instead of reading from the mutable _modules dict, making name
  resolution independent of generation order
- Rename _dquote → quote_python_literal, _all_classes_identical → _all_classes_compatible
- Hoist _json_type_to_python inline dict to _JSON_TYPE_MAP module constant
- Fix bare except Exception in converter.py → except (NameError, AttributeError)
- Whitelist T201/S603/S607 for schema_gen directory, remove noqa comments
- Fix _lint_file to warn when ruff/black not found instead of silently swallowing
- Remove 7 tests that accessed private APIs through gen._type_resolver._method
- Add 40 unit tests for generate.py pure functions (BENCHLING forking, ref
  rewriting, deep merge, URL extraction, descriptive name resolution)
- Update CLAUDE.md docs to reflect new package structure and export_map

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@nathan-stender nathan-stender marked this pull request as ready for review April 16, 2026 01:14
nathan-stender and others added 2 commits April 15, 2026 21:14
The plate_reader schema mappers (rec/2024/06 and rec/2025/03) had
quantity_or_none(TQuantityValueNanogramPerMicroliter, ...) replaced
with inline TQuantityValue(value=..., unit="ng/μL") during an earlier
regression. Since TQuantityValueNanogramPerMicroliter still exists in
the shared quantity_values module, restore the idiomatic pattern.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
stephenworlow
stephenworlow previously approved these changes Apr 16, 2026
Drop the re-export __init__.py and backward-compat aliases entirely.
Rename underscore-prefixed functions to public names where they are
used across module boundaries, and update all imports to point directly
at submodules.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@nathan-stender nathan-stender merged commit 71f4f80 into main Apr 16, 2026
9 checks passed
@nathan-stender nathan-stender deleted the schema-gen-v2 branch April 16, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants