Skip to content

Commit 1e794be

Browse files
authored
Merge pull request #7 from djarecka/fix_linkmlvalidation
Use the linkml library for schema validation and ingestion
2 parents c342050 + 3fe4e53 commit 1e794be

11 files changed

Lines changed: 372 additions & 103 deletions

.github/workflows/schema_submission.yml

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -96,37 +96,9 @@ jobs:
9696
sys.exit(1)
9797
PYEOF
9898
99-
- name: Validate with linkml-runtime
99+
- name: Validate LinkML schema
100100
run: |
101-
python3 - << 'PYEOF'
102-
import sys
103-
import yaml
104-
105-
name = "${{ steps.meta.outputs.schema_name }}"
106-
path = f"schemas/{name}.yml"
107-
108-
# Use linkml-runtime's SchemaView for authoritative LinkML validation.
109-
# If the schema is structurally invalid, SchemaView raises on load.
110-
try:
111-
from linkml_runtime.utils.schemaview import SchemaView
112-
sv = SchemaView(path)
113-
all_classes = sv.all_classes()
114-
all_slots = sv.all_slots()
115-
except Exception as e:
116-
print(f"ERROR: Invalid LinkML schema — {e}")
117-
sys.exit(1)
118-
119-
if not all_classes:
120-
print("ERROR: Schema defines no classes.")
121-
sys.exit(1)
122-
123-
n_attrs = sum(
124-
len(cls.attributes or {})
125-
for cls in all_classes.values()
126-
)
127-
print(f"Valid LinkML schema: {len(all_classes)} classes, "
128-
f"{len(all_slots)} slots, {n_attrs} inline attributes")
129-
PYEOF
101+
python neuro_ghost/validate_schema.py schemas/${{ steps.meta.outputs.schema_name }}.yml
130102
131103
- name: Seed schema.org (if DB not cached)
132104
run: |
@@ -206,7 +178,8 @@ jobs:
206178
body: [
207179
`❌ **Ingestion failed.**`,
208180
``,
209-
`Check that your YAML is valid LinkML with \`classes:\` and \`slots:\` keys.`,
181+
`Check the run log for the specific LinkML validation error (e.g. an undefined slot, ` +
182+
`\`is_a\` parent, or range reference).`,
210183
``,
211184
`[View run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`,
212185
].join("\n"),

.github/workflows/tests.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
pytest:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
16+
- name: Set up Python
17+
uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.12"
20+
21+
- name: Install dependencies
22+
run: pip install -r requirements.txt
23+
24+
- name: Run pytest
25+
run: pytest tests/ -v

neuro_ghost/ingest_linkml.py

Lines changed: 78 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,12 @@
6868
"""
6969

7070
from __future__ import annotations
71-
import datetime, uuid, os
71+
import datetime, uuid, os, re
7272
from pathlib import Path
7373
from typing import Any
7474

7575
import click
76-
import yaml
76+
from linkml_runtime.utils.schemaview import SchemaView
7777

7878
from db import (
7979
get_connection, make_base, make_uid, make_iri, now_iso,
@@ -148,20 +148,64 @@ def resolve_prefix(curie: str, prefixes: dict[str, str]) -> str:
148148
# LinkML parser
149149
# ---------------------------------------------------------------------------
150150

151+
def _slot_to_dict(slot, prefixes: dict[str, str]) -> dict:
152+
"""
153+
Convert a SchemaView-induced SlotDefinition into our internal slot dict.
154+
155+
"Induced" means inheritance (is_a), mixins, and schema-level default_range
156+
have already been resolved onto this slot by SchemaView — so a slot
157+
inherited from a mixin or parent class arrives here fully formed, exactly
158+
as if it had been declared directly.
159+
"""
160+
slot_uri = slot.slot_uri or ""
161+
resolved_iri = resolve_prefix(slot_uri, prefixes) if slot_uri else ""
162+
163+
raw_range = slot.range if isinstance(slot.range, str) and slot.range else "string"
164+
if raw_range in LINKML_PRIMITIVES:
165+
value_range = LINKML_PRIMITIVES[raw_range]
166+
else:
167+
# It's a reference to another class/type/enum — store as a resolved IRI
168+
value_range = (resolve_prefix(raw_range, prefixes)
169+
if ":" in raw_range else make_iri(raw_range))
170+
171+
# Extract units from description if present (common in neuro schemas)
172+
desc = str(slot.description or "")
173+
units = ""
174+
if desc and "(units:" in desc.lower():
175+
m = re.search(r'\(units?:\s*([^)]+)\)', desc, re.IGNORECASE)
176+
if m:
177+
units = m.group(1).strip()
178+
179+
return {
180+
"iri": resolved_iri,
181+
"definition": desc,
182+
"value_range": value_range,
183+
"units": units,
184+
"multivalued": bool(slot.multivalued),
185+
"required": bool(slot.required),
186+
"pattern": slot.pattern or "",
187+
}
188+
189+
151190
def parse_linkml(path: Path) -> dict[str, Any]:
152191
"""
153-
Read a LinkML YAML file and return a clean, normalised Python dict.
154-
155-
Input (LinkML YAML):
156-
classes:
157-
Person:
158-
description: A person
159-
class_uri: schema:Person
160-
slots: [name, email]
161-
slots:
162-
name:
163-
range: string
164-
slot_uri: schema:name
192+
Load a LinkML YAML file via SchemaView and return a clean, normalised dict.
193+
194+
Using SchemaView instead of a hand-rolled YAML walk means classes get
195+
their real, induced slot set: slots inherited via is_a or mixins, slots
196+
declared inline as `attributes:`, and ranges defaulted from the schema's
197+
`default_range` all resolve exactly as LinkML defines them. A class that
198+
lists no slots of its own but has `is_a: Device` still gets Device's
199+
slots attached — the hand-rolled version silently dropped those.
200+
201+
IRI resolution intentionally does NOT use SchemaView's own get_uri():
202+
imports (e.g. linkml:types) can declare their own "schema" prefix and,
203+
depending on import-merge order, shadow a schema's own `prefixes:`
204+
declaration — which would silently flip schema.org IRIs from
205+
https:// to http://, breaking identity matching against the
206+
https://schema.org/ IRIs seed.py uses. Resolving CURIEs ourselves from
207+
the schema's own top-level `prefixes:` block (schema's own declarations
208+
always win over KNOWN_PREFIXES) avoids that.
165209
166210
Output (our internal format):
167211
{
@@ -186,77 +230,40 @@ def parse_linkml(path: Path) -> dict[str, Any]:
186230
}
187231
}
188232
}
189-
190-
The key normalisation step is value_range:
191-
- If range is a LinkML primitive (string, integer, etc.) → XSD CURIE
192-
- If range is a class name or CURIE → resolved IRI
193233
"""
194-
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
234+
sv = SchemaView(str(path))
195235

196-
prefixes = {k: v for k, v in (raw.get("prefixes") or {}).items()}
197-
version = str(raw.get("version", "1.0.0"))
236+
prefixes = {k: v.prefix_reference for k, v in (sv.schema.prefixes or {}).items()}
198237

199238
meta = {
200-
"id": raw.get("id", ""),
201-
"name": raw.get("name", path.stem),
202-
"version": version,
203-
"description": raw.get("description", ""),
239+
"id": sv.schema.id or "",
240+
"name": sv.schema.name or path.stem,
241+
"version": str(sv.schema.version or "1.0.0"),
242+
"description": sv.schema.description or "",
204243
}
205244

206-
# Parse slots first — classes reference them by name
245+
classes: dict[str, dict] = {}
207246
slots: dict[str, dict] = {}
208-
for slot_name, slot_def in (raw.get("slots") or {}).items():
209-
slot_def = slot_def or {}
210-
raw_range = slot_def.get("range", "string")
211-
slot_uri = slot_def.get("slot_uri", "")
212-
resolved_iri = resolve_prefix(slot_uri, prefixes) if slot_uri else ""
213-
214-
# Normalise raw_range — can be None if the YAML slot has no range defined
215-
if not raw_range or not isinstance(raw_range, str):
216-
raw_range = "string"
217-
218-
if raw_range in LINKML_PRIMITIVES:
219-
value_range = LINKML_PRIMITIVES[raw_range]
220-
else:
221-
# It's a reference to another class — store as a resolved IRI
222-
value_range = (resolve_prefix(raw_range, prefixes)
223-
if ":" in raw_range else make_iri(raw_range))
224-
225-
# Extract units from description if present (common in neuro schemas)
226-
desc = slot_def.get("description") or ""
227-
desc = str(desc) if desc is not None else ""
228-
units = ""
229-
if desc and "(units:" in desc.lower():
230-
import re
231-
m = re.search(r'\(units?:\s*([^)]+)\)', desc, re.IGNORECASE)
232-
if m:
233-
units = m.group(1).strip()
234-
235-
slots[slot_name] = {
236-
"iri": resolved_iri,
237-
"definition": desc,
238-
"value_range": value_range,
239-
"units": units,
240-
"multivalued": bool(slot_def.get("multivalued", False)),
241-
"required": bool(slot_def.get("required", False)),
242-
"pattern": slot_def.get("pattern", ""),
243-
}
244247

245-
# Parse classes
246-
classes: dict[str, dict] = {}
247-
for cls_name, cls_def in (raw.get("classes") or {}).items():
248-
cls_def = cls_def or {}
249-
class_uri = cls_def.get("class_uri", "")
248+
for cls_name in sv.all_classes():
249+
cls_def = sv.get_class(cls_name)
250+
induced_slots = sv.class_induced_slots(cls_name)
251+
252+
class_uri = cls_def.class_uri or ""
250253
resolved_iri = resolve_prefix(class_uri, prefixes) if class_uri else ""
251254

252255
classes[cls_name] = {
253256
"iri": resolved_iri,
254-
"definition": cls_def.get("description", ""),
255-
"is_a": cls_def.get("is_a", None),
256-
"is_abstract": bool(cls_def.get("abstract", False)),
257-
"slots": cls_def.get("slots") or [],
257+
"definition": cls_def.description or "",
258+
"is_a": cls_def.is_a,
259+
"is_abstract": bool(cls_def.abstract),
260+
"slots": [slot.name for slot in induced_slots],
258261
}
259262

263+
for slot in induced_slots:
264+
if slot.name not in slots:
265+
slots[slot.name] = _slot_to_dict(slot, prefixes)
266+
260267
return {
261268
"meta": meta,
262269
"prefixes": prefixes,

neuro_ghost/validate_schema.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""
2+
validate_schema.py — Validate a submitted file is a well-formed LinkML schema
3+
==============================================================================
4+
5+
WHY THIS FILE EXISTS
6+
--------------------
7+
The schema-submission GitHub Action used to "validate" incoming YAML by
8+
checking that the string "classes:" appeared in the text and that the
9+
parsed dict had "classes"/"slots" keys. That accepts almost anything —
10+
a schema with a class that lists a slot which is never defined, or an
11+
is_a parent that doesn't exist, sails straight through and only breaks
12+
later, deep inside ingest_linkml.py.
13+
14+
This script does real LinkML validation, in two passes:
15+
16+
1. Metamodel validation — is this YAML shaped like a LinkML schema at
17+
all? (required fields like id/name present, types match the LinkML
18+
metamodel's JSON Schema). Uses linkml's own Linter.validate_schema(),
19+
the same check `linkml validate schema.yml` runs.
20+
21+
2. Reference resolution — do all the internal references actually
22+
resolve? A schema can satisfy the metamodel shape while still
23+
pointing a class's slots at an undefined slot name, or is_a at an
24+
undefined parent class, or a slot's range at an undefined
25+
class/type/enum. SchemaView resolves every class's induced slots,
26+
which forces exactly those lookups and raises a clear ValueError
27+
if something doesn't resolve.
28+
29+
USAGE
30+
-----
31+
python validate_schema.py schemas/bbqs.yml
32+
python validate_schema.py schemas/bbqs.yml --strict # also fail on lint warnings
33+
"""
34+
35+
from __future__ import annotations
36+
37+
import sys
38+
39+
import click
40+
from linkml.linter.linter import Linter
41+
from linkml_runtime.utils.schemaview import SchemaView
42+
43+
44+
def validate_metamodel(schema_path: str) -> list[str]:
45+
"""Return a list of human-readable errors from metamodel validation."""
46+
return [
47+
f"{problem.rule_name}: {problem.message}"
48+
for problem in Linter.validate_schema(schema_path)
49+
]
50+
51+
52+
def validate_references(schema_path: str) -> list[str]:
53+
"""
54+
Force resolution of every class's slots (and thereby their is_a
55+
parents and ranges) and collect any dangling references.
56+
57+
Returns an empty list if the schema loads and every reference resolves.
58+
"""
59+
errors: list[str] = []
60+
try:
61+
sv = SchemaView(schema_path)
62+
except Exception as e: # malformed enough that SchemaView itself chokes
63+
return [f"could not load schema: {e}"]
64+
65+
for class_name in sv.all_classes():
66+
try:
67+
sv.class_induced_slots(class_name)
68+
except ValueError as e:
69+
errors.append(f"class '{class_name}': {e}")
70+
71+
return errors
72+
73+
74+
@click.command()
75+
@click.argument("schema_path", type=click.Path(exists=True))
76+
def cli(schema_path: str) -> None:
77+
"""Validate SCHEMA_PATH is a well-formed, internally-consistent LinkML schema."""
78+
metamodel_errors = validate_metamodel(schema_path)
79+
if metamodel_errors:
80+
click.echo(f"FAILED metamodel validation for {schema_path}:")
81+
for err in metamodel_errors:
82+
click.echo(f" - {err}")
83+
sys.exit(1)
84+
85+
reference_errors = validate_references(schema_path)
86+
if reference_errors:
87+
click.echo(f"FAILED reference validation for {schema_path}:")
88+
for err in reference_errors:
89+
click.echo(f" - {err}")
90+
sys.exit(1)
91+
92+
sv = SchemaView(schema_path)
93+
click.echo(
94+
f"Valid LinkML schema: {schema_path} "
95+
f"({len(sv.all_classes())} classes, {len(sv.all_slots())} slots)"
96+
)
97+
98+
99+
if __name__ == "__main__":
100+
cli()

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ httpx>=0.27.0
77
pydantic>=2.0.0
88
click>=8.0.0
99
pyyaml>=6.0
10-
linkml-runtime>=1.7.0
10+
linkml>=1.8.0
1111
# Pinned <3.0.0 to avoid torchcodec + FFmpeg system dependency
1212
sentence-transformers>=2.7.0,<3.0.0
13+
pytest>=8.0.0

tests/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import sys
2+
from pathlib import Path
3+
4+
REPO_ROOT = Path(__file__).parent.parent
5+
NEURO_GHOST_DIR = REPO_ROOT / "neuro_ghost"
6+
7+
# neuro_ghost/*.py use flat imports (e.g. `from db import ...`), the same way
8+
# they resolve when run directly as `python neuro_ghost/ingest_linkml.py` —
9+
# so neuro_ghost/ itself, not just the repo root, must be on sys.path.
10+
for p in (str(REPO_ROOT), str(NEURO_GHOST_DIR)):
11+
if p not in sys.path:
12+
sys.path.insert(0, p)

0 commit comments

Comments
 (0)