6868"""
6969
7070from __future__ import annotations
71- import datetime , uuid , os
71+ import datetime , uuid , os , re
7272from pathlib import Path
7373from typing import Any
7474
7575import click
76- import yaml
76+ from linkml_runtime . utils . schemaview import SchemaView
7777
7878from 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+
151190def 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 ,
0 commit comments