Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion modules/Clinical/src/htan_clinical/datamodel/clinical.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion modules/Imaging/src/htan_imaging/datamodel/imaging.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion modules/SpatialOmics/domains/spatial_panel.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ classes:
required: true
identifier: true
title: "HTAN Panel ID"
description: Unique identifier for the panel
description: Unique HTAN identifier for the panel. Follows the HTAN identifier format with a P-prefix segment (e.g., HTA201_1_P1), and carries the same requirements as the HTAN_PANEL_ID used in the corresponding ChannelMetadata RecordSet.
pattern: "^(?=.{1,50}$)(HTA2[0-2][0-9])_(0000|EXT[0-9]{1,18}|[0-9]{1,21})_(P[0-9]{1,20})$"
TARGET_TYPE:
range: TargetTypeEnum
Expand Down
2 changes: 1 addition & 1 deletion modules/SpatialOmics/src/htan_spatial/datamodel/spatial.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion modules/WES/src/htan_wes/datamodel/wes.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions scripts/linkml_to_flat_synapse_jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,58 @@ def backfill_descriptions_from_linkml(
return schema_data


def _base_attribute_description(sv, class_name, prop_name):
"""Return an attribute's canonical description, ignoring any slot_usage override.

Walks the class and its ancestors and returns the first description declared on the
attribute definition itself (class ``attributes``), not the induced/slot_usage value.
In this repo, slot_usage descriptions are conditional-requirement notes (e.g.
"Required when X is Y"); those belong to the ``rules`` constraints, not the property
description, so the attribute-level description is the canonical one.
"""
try:
ancestors = sv.class_ancestors(class_name, reflexive=True)
except Exception:
ancestors = [class_name]
for cname in ancestors:
cls = sv.get_class(cname)
attrs = getattr(cls, "attributes", None) or {}
attr = attrs.get(prop_name)
if attr is not None and getattr(attr, "description", None):
return attr.description
return None


def restore_base_attribute_descriptions(
schema_data: dict, linkml_yaml: str, class_name: str
) -> dict:
"""Override property descriptions clobbered by slot_usage with the attribute's own.

LinkML's JsonSchemaGenerator emits induced-slot descriptions, so a class ``slot_usage``
that carries a "Required when ..." note replaces the real attribute description in the
output (see issue #191). This restores the canonical attribute-level description. The
conditional requirement itself remains enforced by the generated ``rules`` (if/then)
constraints, so only the human-readable description changes.
"""
if not class_name:
return schema_data
sv = SchemaView(linkml_yaml)
props = schema_data.get("properties", {})
restored = 0
for prop_name, prop_val in props.items():
if not isinstance(prop_val, dict):
continue
base = _base_attribute_description(sv, class_name, prop_name)
if base and prop_val.get("description") != base:
prop_val["description"] = base
restored += 1
if restored:
print(
f"Restored {restored} attribute description(s) over slot_usage overrides"
)
return schema_data


def get_args():
"""Set up command-line interface and get arguments."""
parser = argparse.ArgumentParser(
Expand Down Expand Up @@ -457,6 +509,9 @@ def main():
schema_data = backfill_descriptions_from_linkml(
schema_data, args.linkml_yaml, args.class_name
)
schema_data = restore_base_attribute_descriptions(
schema_data, args.linkml_yaml, args.class_name
)
schema_data = fix_additional_properties(schema_data)
schema_data = clean_union_types(schema_data)
schema_data = fix_boolean_patterns(schema_data)
Expand Down
60 changes: 60 additions & 0 deletions tests/test_linkml_schema_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
remove_unsupported_fields,
fix_additional_properties,
fix_boolean_patterns,
restore_base_attribute_descriptions,
)


Expand Down Expand Up @@ -480,5 +481,64 @@ def test_string_pattern_under_anyof_left_alone(self):
] == {"pattern": "^Targeted$"}


class TestRestoreBaseAttributeDescriptions:
"""Issue #191/#190: slot_usage 'Required when ...' notes must not overwrite the
canonical attribute description in the generated JSON schema."""

LINKML = """\
id: https://example.org/test
name: test_slot_usage
prefixes:
linkml: https://w3id.org/linkml/
default_range: string
imports:
- linkml:types
classes:
Sample:
attributes:
THICKNESS:
description: Numeric thickness measured in microns
OTHER_SPECIFY:
description: A custom method
slot_usage:
THICKNESS:
description: Required when IS_SECTION is "Yes"
OTHER_SPECIFY:
description: Required when METHOD is "Other"
"""

def _write(self, tmp_path):
p = tmp_path / "sample.yaml"
p.write_text(self.LINKML)
return str(p)

def test_slot_usage_note_replaced_by_attribute_description(self, tmp_path):
yaml_path = self._write(tmp_path)
schema_data = {
"properties": {
"THICKNESS": {"description": 'Required when IS_SECTION is "Yes"'},
"OTHER_SPECIFY": {"description": 'Required when METHOD is "Other"'},
}
}
out = restore_base_attribute_descriptions(schema_data, yaml_path, "Sample")
assert (
out["properties"]["THICKNESS"]["description"]
== "Numeric thickness measured in microns"
)
assert out["properties"]["OTHER_SPECIFY"]["description"] == "A custom method"

def test_property_without_attribute_definition_is_untouched(self, tmp_path):
yaml_path = self._write(tmp_path)
schema_data = {"properties": {"UNKNOWN": {"description": "keep me"}}}
out = restore_base_attribute_descriptions(schema_data, yaml_path, "Sample")
assert out["properties"]["UNKNOWN"]["description"] == "keep me"

def test_empty_class_name_is_noop(self, tmp_path):
yaml_path = self._write(tmp_path)
schema_data = {"properties": {"THICKNESS": {"description": "Required when ..."}}}
out = restore_base_attribute_descriptions(schema_data, yaml_path, "")
assert out["properties"]["THICKNESS"]["description"] == "Required when ..."


if __name__ == "__main__":
pytest.main([__file__, "-v"])
10 changes: 7 additions & 3 deletions tests/test_none_types_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,18 @@ def test_none_types_analysis():
stats["total_none_values"] > 0
), "Should find at least some 'none' values in the data model"

# Check that the percentage is reasonable (not too high, not too low)
# 'none' values should not dominate the vocabulary. Only an upper bound is
# meaningful: the model now includes large external controlled vocabularies
# (ICD-O-3 morphology, ICD-10, antineoplastic agents) that are almost entirely
# non-'none', so any fixed lower percentage bound is invalid. The presence of
# 'none' handling is already asserted above via total_none_values > 0.
if stats["total_permissible_values"] > 0:
none_percentage = (
stats["total_none_values"] / stats["total_permissible_values"]
) * 100
assert (
0.1 <= none_percentage <= 10
), f"None types percentage ({none_percentage:.2f}%) should be between 0.1% and 10%"
none_percentage <= 10
), f"None types percentage ({none_percentage:.2f}%) should not exceed 10%"

print(f"\n✅ All assertions passed!")
print("=" * 80)
Expand Down