From 639e8639d51cc9ac790848353004d0e2d71dbd65 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Fri, 12 Jul 2024 12:49:18 -0700 Subject: [PATCH 01/14] first draft conversion of unittest syntax to pytest syntax for schemaview tests. --- .github/workflows/main.yaml | 30 +- linkml_runtime/utils/schemaview.py | 1 + tests/test_utils/test_schemaview.py | 1802 +++++++++++++-------------- 3 files changed, 908 insertions(+), 925 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 973acf9c..5cd6ff33 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -21,44 +21,48 @@ jobs: steps: - #---------------------------------------------- - # install poetry - #---------------------------------------------- - - name: Install Poetry - run: pipx install poetry==1.4.0 - #---------------------------------------------- # check-out repo and set-up python #---------------------------------------------- - name: Check out repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install poetry + run: pipx install poetry + + - name: Install dynamic versioning plugin + run: poetry self add "poetry-dynamic-versioning[plugin]" - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 + id: setup-python with: python-version: ${{ matrix.python-version }} cache: 'poetry' #---------------------------------------------- - # install your root project, if required + # install dependencies #---------------------------------------------- - - name: Install library - run: poetry install --no-interaction + - name: Install dependencies + run: poetry install --no-interaction --all-extras #---------------------------------------------- # coverage report #---------------------------------------------- - name: Generate coverage results run: | - poetry run coverage run -m pytest + poetry run coverage run -m pytest --with-slow poetry run coverage xml poetry run coverage report -m + shell: bash #---------------------------------------------- # upload coverage results #---------------------------------------------- - name: Upload coverage report - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: name: codecov-results-${{ matrix.os }}-${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 1511ca06..0b85af41 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -1374,6 +1374,7 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo logging.debug(f'{v} takes precedence over {v2} for {induced_slot.name}.{metaslot_name}') if v is None: if metaslot_name == 'range': + v = self.schema.default_range if v is not None: setattr(induced_slot, metaslot_name, v) diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py index 367fb842..b1d9f826 100644 --- a/tests/test_utils/test_schemaview.py +++ b/tests/test_utils/test_schemaview.py @@ -4,6 +4,7 @@ from copy import copy from pathlib import Path from typing import List +import pytest from unittest import TestCase from linkml_runtime.dumpers import yaml_dumper @@ -30,921 +31,898 @@ AGE_IN_YEARS = 'age in years' -class SchemaViewTestCase(unittest.TestCase): - - def test_children_method(self): - view = SchemaView(SCHEMA_NO_IMPORTS) - children = view.get_children("Person") - self.assertEqual(children, ['Adult']) - - def test_all_aliases(self): - """ - This tests the aliases slot (not: alias) - :return: - """ - view = SchemaView(SCHEMA_NO_IMPORTS) - aliases = view.all_aliases() - self.assertIn("identifier", aliases["id"]) - self.assertIn("A", aliases["subset A"]) - self.assertIn("B", aliases["subset B"]) - self.assertIn("dad", aliases["Adult"]) - self.assertNotIn("test", aliases["Adult"]) - - def test_alias_slot(self): - """ - Tests the alias slot. - - The induced slot alias should always be populated. For induced slots, it should default to the - name field if not present. - """ - view = SchemaView(SCHEMA_NO_IMPORTS) - for c in view.all_classes().values(): - for s in view.class_induced_slots(c.name): - self.assertIsNotNone(s.alias) - postal_code_slot = view.induced_slot('postal code', 'Address') - self.assertEqual(postal_code_slot.name, 'postal code') - self.assertEqual(postal_code_slot.alias, 'zip') - - def test_schemaview_enums(self): - view = SchemaView(SCHEMA_NO_IMPORTS) - with self.assertRaises(ValueError): - view.permissible_value_parent("not_a_pv", "not_an_enum") - for en, e in view.all_enums().items(): - if e.name == "Animals": - for pv, v in e.permissible_values.items(): - if pv == "CAT": - self.assertEqual(view.permissible_value_parent(pv, e.name), None) - self.assertEqual(view.permissible_value_ancestors(pv, e.name), ['CAT']) - self.assertIn("LION", view.permissible_value_descendants(pv, e.name)) - self.assertIn("ANGRY_LION", view.permissible_value_descendants(pv, e.name)) - self.assertIn("TABBY", view.permissible_value_descendants(pv, e.name)) - self.assertIn("TABBY", view.permissible_value_children(pv, e.name)) - self.assertIn("LION", view.permissible_value_children(pv, e.name)) - self.assertNotIn("EAGLE", view.permissible_value_descendants(pv, e.name)) - if pv == "LION": - self.assertIn("ANGRY_LION", view.permissible_value_children(pv, e.name)) - if pv == "ANGRY_LION": - self.assertEqual(view.permissible_value_parent(pv, e.name), ['LION']) - self.assertEqual(view.permissible_value_ancestors(pv, e.name), ['ANGRY_LION', 'LION', 'CAT']) - self.assertEqual(["ANGRY_LION"], view.permissible_value_descendants(pv, e.name)) +def test_children_method(): + view = SchemaView(SCHEMA_NO_IMPORTS) + children = view.get_children("Person") + assert children == ['Adult'] + + +def test_all_aliases(): + """ + This tests the aliases slot (not: alias) + :return: + """ + view = SchemaView(SCHEMA_NO_IMPORTS) + aliases = view.all_aliases() + assert "identifier" in aliases["id"] + assert "A" in aliases["subset A"] + assert "B" in aliases["subset B"] + assert "dad" in aliases["Adult"] + assert "test" not in aliases["Adult"] + + +def test_alias_slot(): + """ + Tests the alias slot. + + The induced slot alias should always be populated. For induced slots, it should default to the + name field if not present. + """ + view = SchemaView(SCHEMA_NO_IMPORTS) + for c in view.all_classes().values(): + for s in view.class_induced_slots(c.name): + assert s.alias is not None + postal_code_slot = view.induced_slot('postal code', 'Address') + assert postal_code_slot.name == 'postal code' + assert postal_code_slot.alias == 'zip' + + +def test_schemaview_enums(): + view = SchemaView(SCHEMA_NO_IMPORTS) + with pytest.raises(ValueError): + view.permissible_value_parent("not_a_pv", "not_an_enum") + for en, e in view.all_enums().items(): + if e.name == "Animals": + for pv, v in e.permissible_values.items(): + if pv == "CAT": + assert view.permissible_value_parent(pv, e.name) is None + assert view.permissible_value_ancestors(pv, e.name) == ['CAT'] + assert "LION" in view.permissible_value_descendants(pv, e.name) + assert "ANGRY_LION" in view.permissible_value_descendants(pv, e.name) + assert "TABBY" in view.permissible_value_descendants(pv, e.name) + assert "EAGLE" not in view.permissible_value_descendants(pv, e.name) + + assert "TABBY" in view.permissible_value_children(pv, e.name) + assert "LION" in view.permissible_value_children(pv, e.name) + + if pv == "LION": + assert "ANGRY_LION" in view.permissible_value_children(pv, e.name) + if pv == "ANGRY_LION": + assert view.permissible_value_parent(pv, e.name) == ['LION'] + assert view.permissible_value_ancestors(pv, e.name) == ['ANGRY_LION', 'LION', 'CAT'] + assert ["ANGRY_LION"] == view.permissible_value_descendants(pv, e.name) for cn, c in view.all_classes().items(): if c.name == "Adult": - self.assertEqual(view.class_ancestors(c.name), ['Adult', 'Person', 'HasAliases', 'Thing']) - - def test_schemaview(self): - # no import schema - view = SchemaView(SCHEMA_NO_IMPORTS) - logging.debug(view.imports_closure()) - self.assertEqual(len(view.imports_closure()), 1) - all_cls = view.all_classes() - logging.debug(f'n_cls = {len(all_cls)}') - - self.assertEqual(list(view.annotation_dict(IS_CURRENT).values()), ['bar']) - logging.debug(view.annotation_dict(EMPLOYED_AT)) - e = view.get_element(EMPLOYED_AT) - logging.debug(e.annotations) - e = view.get_element('has employment history') - logging.debug(e.annotations) - - elements = view.get_elements_applicable_by_identifier("ORCID:1234") - self.assertIn("Person", elements) - elements = view.get_elements_applicable_by_identifier("PMID:1234") - self.assertIn("Organization", elements) - elements = view.get_elements_applicable_by_identifier("http://www.ncbi.nlm.nih.gov/pubmed/1234") - self.assertIn("Organization", elements) - elements = view.get_elements_applicable_by_identifier("TEST:1234") - self.assertNotIn("anatomical entity", elements) - self.assertEqual(list(view.annotation_dict(SlotDefinitionName(IS_CURRENT)).values()), ['bar']) - logging.debug(view.annotation_dict(SlotDefinitionName(EMPLOYED_AT))) - element = view.get_element(SlotDefinitionName(EMPLOYED_AT)) - logging.debug(element.annotations) - element = view.get_element(SlotDefinitionName('has employment history')) - logging.debug(element.annotations) - - self.assertTrue(view.is_mixin('WithLocation')) - self.assertFalse(view.is_mixin('BirthEvent')) - - self.assertTrue(view.inverse('employment history of'), 'has employment history') - self.assertTrue(view.inverse('has employment history'), 'employment history of') - - mapping = view.get_mapping_index() - self.assertTrue(mapping is not None) - - category_mapping = view.get_element_by_mapping("GO:0005198") - self.assertTrue(category_mapping, [ACTIVITY]) - - self.assertTrue(view.is_multivalued('aliases')) - self.assertFalse(view.is_multivalued('id')) - self.assertTrue(view.is_multivalued('dog addresses')) - - self.assertTrue(view.slot_is_true_for_metadata_property('aliases', 'multivalued')) - self.assertTrue(view.slot_is_true_for_metadata_property('id', 'identifier')) - with self.assertRaises(ValueError): - view.slot_is_true_for_metadata_property('aliases', 'aliases') - - for tn, t in view.all_types().items(): - logging.info(f'TN = {tn}') - self.assertEqual('https://w3id.org/linkml/tests/kitchen_sink', t.from_schema) - for sn, s in view.all_slots().items(): - logging.info(f'SN = {sn} RANGE={s.range}') - self.assertEqual('https://w3id.org/linkml/tests/kitchen_sink', s.from_schema) + assert view.class_ancestors(c.name) == ['Adult', 'Person', 'HasAliases', 'Thing'] + + +def test_schemaview(): + # no import schema + view = SchemaView(SCHEMA_NO_IMPORTS) + logging.debug(view.imports_closure()) + assert len(view.imports_closure()) == 1 + all_cls = view.all_classes() + logging.debug(f'n_cls = {len(all_cls)}') + + assert list(view.annotation_dict(IS_CURRENT).values()) == ['bar'] + logging.debug(view.annotation_dict(EMPLOYED_AT)) + e = view.get_element(EMPLOYED_AT) + logging.debug(e.annotations) + e = view.get_element('has employment history') + logging.debug(e.annotations) + + elements = view.get_elements_applicable_by_identifier("ORCID:1234") + assert "Person" in elements + elements = view.get_elements_applicable_by_identifier("PMID:1234") + assert "Organization" in elements + elements = view.get_elements_applicable_by_identifier("http://www.ncbi.nlm.nih.gov/pubmed/1234") + assert "Organization" in elements + elements = view.get_elements_applicable_by_identifier("TEST:1234") + assert "anatomical entity" not in elements + assert list(view.annotation_dict(SlotDefinitionName(IS_CURRENT)).values()) == ['bar'] + logging.debug(view.annotation_dict(SlotDefinitionName(EMPLOYED_AT))) + element = view.get_element(SlotDefinitionName(EMPLOYED_AT)) + logging.debug(element.annotations) + element = view.get_element(SlotDefinitionName('has employment history')) + logging.debug(element.annotations) + + assert view.is_mixin('WithLocation') + assert not view.is_mixin('BirthEvent') + + assert view.inverse('employment history of') == 'has employment history' + assert view.inverse('has employment history') == 'employment history of' + + mapping = view.get_mapping_index() + assert mapping is not None + + category_mapping = view.get_element_by_mapping("GO:0005198") + assert category_mapping == [ACTIVITY] + + assert view.is_multivalued('aliases') + assert not view.is_multivalued('id') + assert view.is_multivalued('dog addresses') + + assert view.slot_is_true_for_metadata_property('aliases', 'multivalued') + assert view.slot_is_true_for_metadata_property('id', 'identifier') + with pytest.raises(ValueError): + view.slot_is_true_for_metadata_property('aliases', 'aliases') + + for tn, t in view.all_types().items(): + logging.info(f'TN = {tn}') + assert 'https://w3id.org/linkml/tests/kitchen_sink' == t.from_schema + for sn, s in view.all_slots().items(): + logging.info(f'SN = {sn} RANGE={s.range}') + assert 'https://w3id.org/linkml/tests/kitchen_sink' == s.from_schema + # range should always be populated: See https://github.com/linkml/linkml/issues/733 + rng = view.induced_slot(sn).range + assert rng is not None + # this section is mostly for debugging + for cn in all_cls.keys(): + c = view.get_class(cn) + assert 'https://w3id.org/linkml/tests/kitchen_sink' == c.from_schema + logging.debug(f'{cn} PARENTS = {view.class_parents(cn)}') + logging.debug(f'{cn} ANCS = {view.class_ancestors(cn)}') + logging.debug(f'{cn} CHILDREN = {view.class_children(cn)}') + logging.debug(f'{cn} DESCS = {view.class_descendants(cn)}') + logging.debug(f'{cn} SCHEMA = {view.in_schema(cn)}') + logging.debug(f' SLOTS = {view.class_slots(cn)}') + for sn in view.class_slots(cn): + slot = view.get_slot(sn) + assert 'https://w3id.org/linkml/tests/kitchen_sink' == slot.from_schema + logging.debug(f' SLOT {sn} R: {slot.range} U: {view.get_uri(sn)} ANCS: {view.slot_ancestors(sn)}') + induced_slot = view.induced_slot(sn, cn) + logging.debug(f' INDUCED {sn}={induced_slot}') # range should always be populated: See https://github.com/linkml/linkml/issues/733 - rng = view.induced_slot(sn).range - self.assertIsNotNone(rng) - # this section is mostly for debugging - for cn in all_cls.keys(): - c = view.get_class(cn) - self.assertEqual('https://w3id.org/linkml/tests/kitchen_sink', c.from_schema) - logging.debug(f'{cn} PARENTS = {view.class_parents(cn)}') - logging.debug(f'{cn} ANCS = {view.class_ancestors(cn)}') - logging.debug(f'{cn} CHILDREN = {view.class_children(cn)}') - logging.debug(f'{cn} DESCS = {view.class_descendants(cn)}') - logging.debug(f'{cn} SCHEMA = {view.in_schema(cn)}') - logging.debug(f' SLOTS = {view.class_slots(cn)}') - for sn in view.class_slots(cn): - slot = view.get_slot(sn) - self.assertEqual('https://w3id.org/linkml/tests/kitchen_sink', slot.from_schema) - logging.debug(f' SLOT {sn} R: {slot.range} U: {view.get_uri(sn)} ANCS: {view.slot_ancestors(sn)}') - induced_slot = view.induced_slot(sn, cn) - logging.debug(f' INDUCED {sn}={induced_slot}') - # range should always be populated: See https://github.com/linkml/linkml/issues/733 - self.assertIsNotNone(induced_slot.range) - - - logging.debug(f'ALL = {view.all_elements().keys()}') - - # -- TEST ANCESTOR/DESCENDANTS FUNCTIONS -- - - self.assertCountEqual(['Company', 'Organization', 'HasAliases', 'Thing'], - view.class_ancestors(COMPANY)) - self.assertCountEqual(['Organization', 'HasAliases', 'Thing'], - view.class_ancestors(COMPANY, reflexive=False)) - self.assertCountEqual(['Thing', 'Person', 'Organization', COMPANY, 'Adult'], - view.class_descendants('Thing')) - - # -- TEST CLASS SLOTS -- - - self.assertCountEqual(['id', 'name', ## From Thing - 'has employment history', 'has familial relationships', 'has medical history', - AGE_IN_YEARS, 'addresses', 'has birth event', 'reason_for_happiness', ## From Person - 'aliases' ## From HasAliases - ], - view.class_slots('Person')) - self.assertCountEqual(view.class_slots('Person'), view.class_slots('Adult')) - self.assertCountEqual(['id', 'name', ## From Thing - 'ceo', ## From COMPANY - 'aliases' ## From HasAliases - ], - view.class_slots(COMPANY)) - - self.assertEqual(view.get_class(AGENT).class_uri, 'prov:Agent') - self.assertEqual(view.get_uri(AGENT), 'prov:Agent') - logging.debug(view.get_class(COMPANY).class_uri) - - self.assertEqual(view.get_uri(COMPANY), 'ks:Company') - - # test induced slots - - for c in [COMPANY, 'Person', 'Organization',]: - islot = view.induced_slot('aliases', c) - assert islot.multivalued is True - self.assertEqual(islot.owner, c, 'owner does not match') - self.assertEqual(view.get_uri(islot, expand=True), 'https://w3id.org/linkml/tests/kitchen_sink/aliases') - - self.assertEqual(view.get_identifier_slot('Company').name, 'id') - self.assertEqual(view.get_identifier_slot('Thing').name, 'id') - self.assertTrue(view.get_identifier_slot('FamilialRelationship') is None) - for c in [COMPANY, 'Person', 'Organization', 'Thing']: - self.assertTrue(view.induced_slot('id', c).identifier) - self.assertFalse(view.induced_slot('name', c).identifier) - self.assertFalse(view.induced_slot('name', c).required) - self.assertEqual(view.induced_slot('name', c).range, 'string') - self.assertEqual(view.induced_slot('id', c).owner, c, 'owner does not match') - self.assertEqual(view.induced_slot('name', c).owner, c, 'owner does not match') - for c in ['Event', 'EmploymentEvent', 'MedicalEvent']: - s = view.induced_slot('started at time', c) - logging.debug(f's={s.range} // c = {c}') - self.assertEqual(s.range, 'date') - self.assertEqual(s.slot_uri, 'prov:startedAtTime') - self.assertEqual(s.owner, c, 'owner does not match') - c_induced = view.induced_class(c) - # an induced class should have no slots - self.assertEqual(c_induced.slots, []) - self.assertNotEqual(c_induced.attributes, []) - s2 = c_induced.attributes['started at time'] - self.assertEqual(s2.range, 'date') - self.assertEqual(s2.slot_uri, 'prov:startedAtTime') - # test slot_usage - self.assertEqual(view.induced_slot(AGE_IN_YEARS, 'Person').minimum_value, 0) - self.assertEqual(view.induced_slot(AGE_IN_YEARS, 'Adult').minimum_value, 16) - self.assertTrue(view.induced_slot('name', 'Person').pattern is not None) - self.assertEqual(view.induced_slot('type', 'FamilialRelationship').range, 'FamilialRelationshipType') - self.assertEqual(view.induced_slot(RELATED_TO, 'FamilialRelationship').range, 'Person') - self.assertEqual(view.get_slot(RELATED_TO).range, 'Thing') - self.assertEqual(view.induced_slot(RELATED_TO, 'Relationship').range, 'Thing') - # https://github.com/linkml/linkml/issues/875 - self.assertCountEqual(['Thing', 'Place'], view.induced_slot('name').domain_of) - - a = view.get_class(ACTIVITY) - self.assertCountEqual(a.exact_mappings, ['prov:Activity']) - logging.debug(view.get_mappings(ACTIVITY, expand=True)) - self.assertCountEqual(view.get_mappings(ACTIVITY)['exact'], ['prov:Activity']) - self.assertCountEqual(view.get_mappings(ACTIVITY, expand=True)['exact'], ['http://www.w3.org/ns/prov#Activity']) - - u = view.usage_index() - for k, v in u.items(): - logging.debug(f' {k} = {v}') - self.assertIn(SchemaUsage(used_by='FamilialRelationship', slot=RELATED_TO, - metaslot='range', used='Person', inferred=False), u['Person']) - self.assertListEqual( - [SchemaUsage(used_by='Person', - slot='reason_for_happiness', - metaslot='any_of[range]', - used='MarriageEvent', - inferred=True - ), - SchemaUsage(used_by='Adult', - slot='reason_for_happiness', - metaslot='any_of[range]', - used='MarriageEvent', - inferred=False - )], - u['MarriageEvent']) - self.assertListEqual( - [SchemaUsage(used_by='Person', - slot='has employment history', - metaslot='range', - used='EmploymentEvent', - inferred=True), - SchemaUsage(used_by='Person', - slot='reason_for_happiness', - metaslot='any_of[range]', - used='EmploymentEvent', - inferred=True), - SchemaUsage(used_by='Adult', - slot='has employment history', - metaslot='range', - used='EmploymentEvent', - inferred=False), - SchemaUsage(used_by='Adult', - slot='reason_for_happiness', - metaslot='any_of[range]', - used='EmploymentEvent', - inferred=False)], - u['EmploymentEvent']) - - # test methods also work for attributes - leaves = view.class_leaves() - logging.debug(f'LEAVES={leaves}') - self.assertIn('MedicalEvent', leaves) - roots = view.class_roots() - logging.debug(f'ROOTS={roots}') - self.assertIn('Dataset', roots) - ds_slots = view.class_slots('Dataset') - logging.debug(ds_slots) - self.assertEqual(len(ds_slots), 3) - self.assertCountEqual(['persons', 'companies', 'activities'], ds_slots) - for sn in ds_slots: - s = view.induced_slot(sn, 'Dataset') - logging.debug(s) - - def test_all_classes_ordered_lexical(self): - view = SchemaView(SCHEMA_NO_IMPORTS) - classes = view.all_classes(ordered_by=OrderedBy.LEXICAL) - - ordered_c = [] - for c in classes.values(): - ordered_c.append(c.name) - self.assertEqual(ordered_c, sorted(ordered_c)) - - def test_all_classes_ordered_rank(self): - view = SchemaView(SCHEMA_NO_IMPORTS) - classes = view.all_classes(ordered_by=OrderedBy.RANK) - ordered_c = [] - for c in classes.values(): - ordered_c.append(c.name) - first_in_line = [] - second_in_line = [] - for name, definition in classes.items(): - if definition.rank == 1: - first_in_line.append(name) - elif definition.rank == 2: - second_in_line.append(name) - self.assertIn(ordered_c[0], first_in_line) - self.assertNotIn(ordered_c[10], second_in_line) - - def test_all_classes_ordered_no_ordered_by(self): - view = SchemaView(SCHEMA_NO_IMPORTS) - classes = view.all_classes() - ordered_c = [] - for c in classes.values(): - ordered_c.append(c.name) - self.assertEqual("HasAliases", ordered_c[0]) - self.assertEqual("EmptyClass", ordered_c[-1]) - self.assertEqual("agent", ordered_c[-2]) - - def test_all_slots_ordered_lexical(self): - view = SchemaView(SCHEMA_NO_IMPORTS) - slots = view.all_slots(ordered_by=OrderedBy.LEXICAL) - ordered_s = [] - for s in slots.values(): - ordered_s.append(s.name) - self.assertEqual(ordered_s, sorted(ordered_s)) - - def test_all_slots_ordered_rank(self): - view = SchemaView(SCHEMA_NO_IMPORTS) - slots = view.all_slots(ordered_by=OrderedBy.RANK) - ordered_s = [] - for s in slots.values(): - ordered_s.append(s.name) - first_in_line = [] - second_in_line = [] - for name, definition in slots.items(): - if definition.rank == 1: - first_in_line.append(name) - elif definition.rank == 2: - second_in_line.append(name) - self.assertIn(ordered_s[0], first_in_line) - self.assertNotIn(ordered_s[10], second_in_line) - - def test_rollup_rolldown(self): - # no import schema - view = SchemaView(SCHEMA_NO_IMPORTS) - element_name = 'Event' - roll_up(view, element_name) - for slot in view.class_induced_slots(element_name): - logging.debug(slot) - induced_slot_names = [s.name for s in view.class_induced_slots(element_name)] - logging.debug(induced_slot_names) - self.assertCountEqual(['started at time', 'ended at time', IS_CURRENT, 'in location', EMPLOYED_AT, 'married to'], - induced_slot_names) - # check to make sure rolled-up classes are deleted - self.assertEqual(view.class_descendants(element_name, reflexive=False), []) - roll_down(view, view.class_leaves()) - - for element_name in view.all_classes(): - c = view.get_class(element_name) - logging.debug(f'{element_name}') - logging.debug(f' {element_name} SLOTS(i) = {view.class_slots(element_name)}') - logging.debug(f' {element_name} SLOTS(d) = {view.class_slots(element_name, direct=True)}') - self.assertCountEqual(view.class_slots(element_name), view.class_slots(element_name, direct=True)) - self.assertNotIn('Thing', view.all_classes()) - self.assertNotIn('Person', view.all_classes()) - self.assertIn('Adult', view.all_classes()) - - def test_caching(self): - """ - Determine if cache is reset after modifications made to schema - """ - schema = SchemaDefinition(id='test', name='test') - view = SchemaView(schema) - self.assertCountEqual([], view.all_classes()) - view.add_class(ClassDefinition('X')) - self.assertCountEqual(['X'], view.all_classes()) - view.add_class(ClassDefinition('Y')) - self.assertCountEqual(['X', 'Y'], view.all_classes()) - # bypass view method and add directly to schema; - # in general this is not recommended as the cache will - # not be updated - view.schema.classes['Z'] = ClassDefinition('Z') - # as expected, the view doesn't know about Z - self.assertCountEqual(['X', 'Y'], view.all_classes()) - # inform the view modifications have been made - view.set_modified() - # should be in sync - self.assertCountEqual(['X', 'Y', 'Z'], view.all_classes()) - # recommended way to make updates - view.delete_class('X') - # cache will be up to date - self.assertCountEqual(['Y', 'Z'], view.all_classes()) - view.add_class(ClassDefinition('W')) - self.assertCountEqual(['Y', 'Z', 'W'], view.all_classes()) - - def test_import_map(self): - """ - Path to import file should be configurable - """ - for im in [{"core": "/no/such/file"}, {"linkml:": "/no/such/file"}]: - with self.assertRaises(FileNotFoundError): - view = SchemaView(SCHEMA_WITH_IMPORTS, importmap=im) - view.all_classes() - for im in [None, {}, {"core": "core"}]: + assert induced_slot.range is not None + + logging.debug(f'ALL = {view.all_elements().keys()}') + + # -- TEST ANCESTOR/DESCENDANTS FUNCTIONS -- + assert sorted(['Company', 'Organization', 'HasAliases', 'Thing']) == sorted(view.class_ancestors(COMPANY)) + assert sorted(['Organization', 'HasAliases', 'Thing']) == sorted(view.class_ancestors(COMPANY, reflexive=False)) + assert sorted(['Thing', 'Person', 'Organization', COMPANY, 'Adult']) == sorted(view.class_descendants('Thing')) + + # -- TEST CLASS SLOTS -- + + assert sorted(['id', 'name', # From Thing + 'has employment history', 'has familial relationships', 'has medical history', + AGE_IN_YEARS, 'addresses', 'has birth event', 'reason_for_happiness', # From Person + 'aliases' # From HasAliases + ]) == sorted(view.class_slots('Person')) + assert sorted(view.class_slots('Person')) == sorted(view.class_slots('Adult')) + assert sorted(['id', 'name', # From Thing + 'ceo', # From COMPANY + 'aliases' # From HasAliases + ]) == sorted(view.class_slots(COMPANY)) + + assert view.get_class(AGENT).class_uri == 'prov:Agent' + assert view.get_uri(AGENT) == 'prov:Agent' + logging.debug(view.get_class(COMPANY).class_uri) + + assert view.get_uri(COMPANY) == 'ks:Company' + # test induced slots + + for c in [COMPANY, 'Person', 'Organization']: + islot = view.induced_slot('aliases', c) + assert islot.multivalued is True + assert islot.owner == c, 'owner does not match' + assert view.get_uri(islot, expand=True) == 'https://w3id.org/linkml/tests/kitchen_sink/aliases' + + assert view.get_identifier_slot('Company').name == 'id' + assert view.get_identifier_slot('Thing').name == 'id' + assert view.get_identifier_slot('FamilialRelationship') is None + for c in [COMPANY, 'Person', 'Organization', 'Thing']: + assert view.induced_slot('id', c).identifier is True + assert not view.induced_slot('name', c).identifier + assert not view.induced_slot('name', c).required + assert view.induced_slot('name', c).range == 'string' + assert view.induced_slot('id', c).owner == c, 'owner does not match' + assert view.induced_slot('name', c).owner == c, 'owner does not match' + for c in ['Event', 'EmploymentEvent', 'MedicalEvent']: + s = view.induced_slot('started at time', c) + logging.debug(f's={s.range} // c = {c}') + assert s.range == 'date' + assert s.slot_uri == 'prov:startedAtTime' + assert s.owner == c, 'owner does not match' + c_induced = view.induced_class(c) + # an induced class should have no slots + assert c_induced.slots == [] + assert c_induced.attributes != [] + s2 = c_induced.attributes['started at time'] + assert s2.range == 'date' + assert s2.slot_uri == 'prov:startedAtTime' + + # test slot_usage + assert view.induced_slot(AGE_IN_YEARS, 'Person').minimum_value == 0 + assert view.induced_slot(AGE_IN_YEARS, 'Adult').minimum_value == 16 + assert view.induced_slot('name', 'Person').pattern is not None + assert view.induced_slot('type', 'FamilialRelationship').range == 'FamilialRelationshipType' + assert view.induced_slot(RELATED_TO, 'FamilialRelationship').range == 'Person' + assert view.get_slot(RELATED_TO).range == 'Thing' + assert view.induced_slot(RELATED_TO, 'Relationship').range == 'Thing' + # https://github.com/linkml/linkml/issues/875 + assert sorted(['Thing', 'Place']) == sorted(view.induced_slot('name').domain_of) + + a = view.get_class(ACTIVITY) + assert sorted(a.exact_mappings) == sorted(['prov:Activity']) + logging.debug(view.get_mappings(ACTIVITY, expand=True)) + assert sorted(view.get_mappings(ACTIVITY)['exact']) == sorted(['prov:Activity']) + assert sorted(view.get_mappings(ACTIVITY, expand=True)['exact']) == sorted(['http://www.w3.org/ns/prov#Activity']) + + u = view.usage_index() + for k, v in u.items(): + logging.debug(f' {k} = {v}') + assert SchemaUsage(used_by='FamilialRelationship', slot=RELATED_TO, metaslot='range', used='Person', + inferred=False) in u['Person'] + + u = view.usage_index() + + expected_marriage_event = [ + ('Person', 'reason_for_happiness', 'any_of[range]', 'MarriageEvent', True), + ('Adult', 'reason_for_happiness', 'any_of[range]', 'MarriageEvent', False) + ] + + actual_marriage_event = [ + (su.used_by, su.slot, su.metaslot, su.used, su.inferred) + for su in u['MarriageEvent'] + ] + + assert sorted(actual_marriage_event) == sorted(expected_marriage_event) + + expected_employment_event = [ + ('Person', 'has employment history', 'range', 'EmploymentEvent', True), + ('Person', 'reason_for_happiness', 'any_of[range]', 'EmploymentEvent', True), + ('Adult', 'has employment history', 'range', 'EmploymentEvent', False), + ('Adult', 'reason_for_happiness', 'any_of[range]', 'EmploymentEvent', False) + ] + + actual_employment_event = [ + (su.used_by, su.slot, su.metaslot, su.used, su.inferred) + for su in u['EmploymentEvent'] + ] + + assert sorted(actual_employment_event) == sorted(expected_employment_event) + + # test methods also work for attributes + leaves = view.class_leaves() + logging.debug(f'LEAVES={leaves}') + assert 'MedicalEvent' in leaves + roots = view.class_roots() + logging.debug(f'ROOTS={roots}') + assert 'Dataset' in roots + ds_slots = view.class_slots('Dataset') + logging.debug(ds_slots) + assert len(ds_slots) == 3 + assert sorted(['persons', 'companies', 'activities']) == sorted(ds_slots) + for sn in ds_slots: + s = view.induced_slot(sn, 'Dataset') + logging.debug(s) + + +def test_all_classes_ordered_lexical(): + view = SchemaView(SCHEMA_NO_IMPORTS) + classes = view.all_classes(ordered_by=OrderedBy.LEXICAL) + + ordered_c = [] + for c in classes.values(): + ordered_c.append(c.name) + assert ordered_c == sorted(ordered_c) + + +def test_all_classes_ordered_rank(): + view = SchemaView(SCHEMA_NO_IMPORTS) + classes = view.all_classes(ordered_by=OrderedBy.RANK) + ordered_c = [] + for c in classes.values(): + ordered_c.append(c.name) + first_in_line = [] + second_in_line = [] + for name, definition in classes.items(): + if definition.rank == 1: + first_in_line.append(name) + elif definition.rank == 2: + second_in_line.append(name) + assert ordered_c[0] in first_in_line + assert ordered_c[10] not in second_in_line + + +def test_all_classes_ordered_no_ordered_by(): + view = SchemaView(SCHEMA_NO_IMPORTS) + classes = view.all_classes() + ordered_c = [c.name for c in classes.values()] + assert ordered_c[0] == "HasAliases" + assert ordered_c[-1] == "EmptyClass" + assert ordered_c[-2] == "agent" + + +def test_all_slots_ordered_lexical(): + view = SchemaView(SCHEMA_NO_IMPORTS) + slots = view.all_slots(ordered_by=OrderedBy.LEXICAL) + ordered_s = [] + for s in slots.values(): + ordered_s.append(s.name) + assert ordered_s == sorted(ordered_s) + + +def test_all_slots_ordered_rank(): + view = SchemaView(SCHEMA_NO_IMPORTS) + slots = view.all_slots(ordered_by=OrderedBy.RANK) + ordered_s = [] + for s in slots.values(): + ordered_s.append(s.name) + first_in_line = [] + second_in_line = [] + for name, definition in slots.items(): + if definition.rank == 1: + first_in_line.append(name) + elif definition.rank == 2: + second_in_line.append(name) + assert ordered_s[0] in first_in_line + assert ordered_s[10] not in second_in_line + + +def test_rollup_rolldown(): + # no import schema + view = SchemaView(SCHEMA_NO_IMPORTS) + element_name = 'Event' + roll_up(view, element_name) + for slot in view.class_induced_slots(element_name): + logging.debug(slot) + induced_slot_names = [s.name for s in view.class_induced_slots(element_name)] + logging.debug(induced_slot_names) + assert sorted(['started at time', 'ended at time', IS_CURRENT, 'in location', EMPLOYED_AT, 'married to']) == sorted( + induced_slot_names) + + # check to make sure rolled-up classes are deleted + assert view.class_descendants(element_name, reflexive=False) == [] + roll_down(view, view.class_leaves()) + + for element_name in view.all_classes(): + c = view.get_class(element_name) + logging.debug(f'{element_name}') + logging.debug(f' {element_name} SLOTS(i) = {view.class_slots(element_name)}') + logging.debug(f' {element_name} SLOTS(d) = {view.class_slots(element_name, direct=True)}') + assert sorted(view.class_slots(element_name)) == sorted(view.class_slots(element_name, direct=True)) + + assert 'Thing' not in view.all_classes() + assert 'Person' not in view.all_classes() + assert 'Adult' in view.all_classes() + +import pytest + +def test_caching(): + """ + Determine if cache is reset after modifications made to schema + """ + schema = SchemaDefinition(id='test', name='test') + view = SchemaView(schema) + assert sorted([]) == sorted(view.all_classes()) + view.add_class(ClassDefinition('X')) + assert sorted(['X']) == sorted(view.all_classes()) + view.add_class(ClassDefinition('Y')) + assert sorted(['X', 'Y']) == sorted(view.all_classes()) + # bypass view method and add directly to schema; + # in general this is not recommended as the cache will + # not be updated + view.schema.classes['Z'] = ClassDefinition('Z') + # as expected, the view doesn't know about Z + assert sorted(['X', 'Y']) == sorted(view.all_classes()) + # inform the view modifications have been made + view.set_modified() + # should be in sync + assert sorted(['X', 'Y', 'Z']) == sorted(view.all_classes()) + # recommended way to make updates + view.delete_class('X') + # cache will be up to date + assert sorted(['Y', 'Z']) == sorted(view.all_classes()) + view.add_class(ClassDefinition('W')) + assert sorted(['Y', 'Z', 'W']) == sorted(view.all_classes()) + + +def test_import_map(): + """ + Path to import file should be configurable + """ + for im in [{"core": "/no/such/file"}, {"linkml:": "/no/such/file"}]: + with pytest.raises(FileNotFoundError): view = SchemaView(SCHEMA_WITH_IMPORTS, importmap=im) view.all_classes() - self.assertCountEqual(['kitchen_sink', 'core', 'linkml:types'], view.imports_closure()) - self.assertIn(ACTIVITY, view.all_classes()) - self.assertNotIn(ACTIVITY, view.all_classes(imports=False)) - - def test_imports(self): - """ - view should by default dynamically include imports chain - """ - view = SchemaView(SCHEMA_WITH_IMPORTS) - self.assertIsNotNone(view.schema.source_file) - logging.debug(view.imports_closure()) - self.assertCountEqual(['kitchen_sink', 'core', 'linkml:types'], view.imports_closure()) - for t in view.all_types().keys(): - logging.debug(f'T={t} in={view.in_schema(t)}') - self.assertEqual(view.in_schema(ClassDefinitionName('Person')), 'kitchen_sink') - self.assertEqual(view.in_schema(SlotDefinitionName('id')), 'core') - self.assertEqual(view.in_schema(SlotDefinitionName('name')), 'core') - self.assertEqual(view.in_schema(SlotDefinitionName(ACTIVITY)), 'core') - self.assertEqual(view.in_schema(SlotDefinitionName('string')), 'types') - self.assertIn(ACTIVITY, view.all_classes()) - self.assertNotIn(ACTIVITY, view.all_classes(imports=False)) - self.assertIn('string', view.all_types()) - self.assertNotIn('string', view.all_types(imports=False)) - self.assertCountEqual(['SymbolString', 'string'], view.type_ancestors('SymbolString')) - - for tn, t in view.all_types().items(): - self.assertEqual(tn, t.name) - induced_t = view.induced_type(tn) - self.assertIsNotNone(induced_t.uri) - #self.assertIsNotNone(induced_t.repr) - self.assertIsNotNone(induced_t.base) - if t in view.all_types(imports=False).values(): - self.assertEqual('https://w3id.org/linkml/tests/kitchen_sink', t.from_schema) - else: - self.assertIn(t.from_schema, ['https://w3id.org/linkml/tests/core', 'https://w3id.org/linkml/types']) - for en, e in view.all_enums().items(): - self.assertEqual(en, e.name) - if e in view.all_enums(imports=False).values(): - self.assertEqual('https://w3id.org/linkml/tests/kitchen_sink', e.from_schema) - else: - self.assertEqual('https://w3id.org/linkml/tests/core', e.from_schema) - for sn, s in view.all_slots().items(): - self.assertEqual(sn, s.name) - s_induced = view.induced_slot(sn) - self.assertIsNotNone(s_induced.range) - if s in view.all_slots(imports=False).values(): - self.assertEqual('https://w3id.org/linkml/tests/kitchen_sink', s.from_schema) - else: - self.assertEqual('https://w3id.org/linkml/tests/core', s.from_schema) - for cn, c in view.all_classes().items(): - self.assertEqual(cn, c.name) - if c in view.all_classes(imports=False).values(): - self.assertEqual('https://w3id.org/linkml/tests/kitchen_sink', c.from_schema) - else: - self.assertEqual('https://w3id.org/linkml/tests/core', c.from_schema) - for s in view.class_induced_slots(cn): - if s in view.all_classes(imports=False).values(): - self.assertIsNotNone(s.slot_uri) - self.assertEqual('https://w3id.org/linkml/tests/kitchen_sink', s.from_schema) - - for c in ['Company', 'Person', 'Organization', 'Thing']: - self.assertTrue(view.induced_slot('id', c).identifier) - self.assertFalse(view.induced_slot('name', c).identifier) - self.assertFalse(view.induced_slot('name', c).required) - self.assertEqual(view.induced_slot('name', c).range, 'string') - for c in ['Event', 'EmploymentEvent', 'MedicalEvent']: - s = view.induced_slot('started at time', c) - self.assertEqual(s.range, 'date') - self.assertEqual(s.slot_uri, 'prov:startedAtTime') - self.assertEqual(view.induced_slot(AGE_IN_YEARS, 'Person').minimum_value, 0) - self.assertEqual(view.induced_slot(AGE_IN_YEARS, 'Adult').minimum_value, 16) - - self.assertEqual(view.get_class('agent').class_uri, 'prov:Agent') - self.assertEqual(view.get_uri(AGENT), 'prov:Agent') - logging.debug(view.get_class('Company').class_uri) - - self.assertEqual(view.get_uri(COMPANY), 'ks:Company') - self.assertEqual(view.get_uri(COMPANY, expand=True), 'https://w3id.org/linkml/tests/kitchen_sink/Company') - logging.debug(view.get_uri('TestClass')) - self.assertEqual(view.get_uri('TestClass'), 'core:TestClass') - self.assertEqual(view.get_uri('TestClass', expand=True), 'https://w3id.org/linkml/tests/core/TestClass') - - self.assertEqual(view.get_uri('string'), 'xsd:string') - - # dynamic enums - e = view.get_enum('HCAExample') - self.assertCountEqual(['GO:0007049', - 'GO:0022403'], - e.include[0].reachable_from.source_nodes) - - # units - height = view.get_slot('height_in_m') - self.assertEqual("m", height.unit.ucum_code) - - def test_imports_from_schemaview(self): - """ - view should by default dynamically include imports chain - """ - view = SchemaView(SCHEMA_WITH_IMPORTS) - view2 = SchemaView(view.schema) - self.assertCountEqual(view.all_classes(), view2.all_classes()) - self.assertCountEqual(view.all_classes(imports=False), view2.all_classes(imports=False)) - - def test_imports_closure_order(self): - """ - Imports should override in a python-like order. - - See - - https://github.com/linkml/linkml/issues/1839 for initial discussion - - input/imports/README.md for explanation of the test schema - """ - sv = SchemaView(SCHEMA_IMPORT_TREE) - closure = sv.imports_closure(imports=True) - target = [ - 'linkml:types', - 's1_1', - 's1_2_1_1_1', 's1_2_1_1_2', - 's1_2_1_1', 's1_2_1', 's1_2', - 's1', - 's2_1', 's2_2', 's2', - 's3_1', 's3_2', 's3', - 'main' - ] - self.assertEqual(closure, target) - - def test_imports_overrides(self): - """ - Classes defined in the importing module should override same-named classes in - imported modules. - - Tests recursively across an import tree. Each class defines all classes lower - in the tree with a `value` attribute with an `ifabsent` value matching the - current schema. Lower (closer to the importing schema) schemas should override - each class at that level or lower, keeping the rest. - - See `input/imports/README.md` for further explanation. - """ - sv = SchemaView(SCHEMA_IMPORT_TREE) - defaults = {} - target = {} - for name, cls in sv.all_classes(imports=True).items(): - target[name] = name - defaults[name] = cls.attributes['value'].ifabsent - - self.assertEqual(defaults, target) - - - def test_direct_remote_imports(self): - """ - Tests that building a SchemaView directly from a remote URL works. - - Note: this should be the only test in this suite that fails if there is - no network connection. - """ - view = SchemaView("https://w3id.org/linkml/meta.yaml") - main_classes = ["class_definition", "prefix"] - imported_classes = ["annotation"] - for c in main_classes: - self.assertIn(c, view.all_classes(imports=True)) - self.assertIn(c, view.all_classes(imports=False)) - for c in imported_classes: - self.assertIn(c, view.all_classes(imports=True)) - self.assertNotIn(c, view.all_classes(imports=False)) - - @unittest.skip("Skipped as fragile: will break if the remote schema changes") - def test_direct_remote_imports_additional(self): - """ - Alternative test to: https://github.com/linkml/linkml/pull/1379 - """ - url = "https://raw.githubusercontent.com/GenomicsStandardsConsortium/mixs/main/model/schema/mixs.yaml" - view = SchemaView(url) - self.assertEqual(view.schema.name, "MIxS") - class_count = len(view.all_classes()) - self.assertGreater(class_count, 0) - - - def test_merge_imports(self): - """ - ensure merging and merging imports closure works - """ - view = SchemaView(SCHEMA_WITH_IMPORTS) - all_c = copy(view.all_classes()) - all_c_noi = copy(view.all_classes(imports=False)) - self.assertLess(len(all_c_noi), len(all_c)) - view.merge_imports() - all_c2 = copy(view.all_classes()) - self.assertCountEqual(all_c, all_c2) - all_c2_noi = copy(view.all_classes(imports=False)) - self.assertEqual(len(all_c2_noi), len(all_c2)) - - def test_metamodel_imports(self): - """ - Tests imports of the metamodel. - - Note: this test and others should be able to run without network connectivity. - SchemaView should make use of the version of the metamodel distributed with the package - over the network available version. - - TODO: use mock testing framework to emulate no access to network. - - - ``_ - :return: - """ - schema = SchemaDefinition(id='test', name='metamodel-imports-test', - imports=["linkml:meta"]) - sv = SchemaView(schema) - all_classes = sv.all_classes() - self.assertGreater(len(all_classes), 20) - schema_str = yaml_dumper.dumps(schema) - sv = SchemaView(schema_str) - self.assertGreater(len(sv.all_classes()), 20) - self.assertCountEqual(all_classes, sv.all_classes()) - - def test_non_linkml_remote_import(self): - """ - Test that a remote import _not_ using the linkml prefix works - - See: https://github.com/linkml/linkml/issues/1627 - """ - schema = SchemaDefinition( - id='test_non_linkml_remote_import', - name='test_non_linkml_remote_import', - prefixes=[ - Prefix( - prefix_prefix="foo", - prefix_reference="https://w3id.org/linkml/" - ) - ], - imports=[ - "foo:types" - ], - slots=[ - SlotDefinition( - name="an_int", - range="integer" - ) - ], - classes=[ - ClassDefinition( - name="AClass", - slots=["an_int"] - ) - ] - ) - sv = SchemaView(schema) - slots = sv.class_induced_slots("AClass", imports=True) - self.assertEqual(len(slots), 1) - - - def test_traversal(self): - schema = SchemaDefinition(id='test', name='traversal-test') - view = SchemaView(schema) - view.add_class(ClassDefinition('Root', mixins=['RootMixin'])) - view.add_class(ClassDefinition('A', is_a='Root', mixins=['Am1', 'Am2', 'AZ'])) - view.add_class(ClassDefinition('B', is_a='A', mixins=['Bm1', 'Bm2', 'BY'])) - view.add_class(ClassDefinition('C', is_a='B', mixins=['Cm1', 'Cm2', 'CX'])) - view.add_class(ClassDefinition('RootMixin', mixin=True)) - view.add_class(ClassDefinition('Am1', is_a='RootMixin', mixin=True)) - view.add_class(ClassDefinition('Am2', is_a='RootMixin', mixin=True)) - view.add_class(ClassDefinition('Bm1', is_a='Am1', mixin=True)) - view.add_class(ClassDefinition('Bm2', is_a='Am2', mixin=True)) - view.add_class(ClassDefinition('Cm1', is_a='Bm1', mixin=True)) - view.add_class(ClassDefinition('Cm2', is_a='Bm2', mixin=True)) - view.add_class(ClassDefinition('AZ', is_a='RootMixin', mixin=True)) - view.add_class(ClassDefinition('BY', is_a='RootMixin', mixin=True)) - view.add_class(ClassDefinition('CX', is_a='RootMixin', mixin=True)) - - def check(ancs: List, expected: List): - self.assertEqual(ancs, expected) - check(view.class_ancestors('C', depth_first=True), - ['C', 'Cm1', 'Cm2', 'CX', 'B', 'Bm1', 'Bm2', 'BY', 'A', 'Am1', 'Am2', 'AZ', 'Root', 'RootMixin']) - check(view.class_ancestors('C', depth_first=False), - ['C', 'Cm1', 'Cm2', 'CX', 'B', 'Bm1', 'Bm2', 'RootMixin', 'BY', 'A', 'Am1', 'Am2', 'AZ', 'Root']) - check(view.class_ancestors('C', mixins=False), - ['C', 'B', 'A', 'Root']) - check(view.class_ancestors('C', is_a=False), - ['C', 'Cm1', 'Cm2', 'CX']) - - def test_slot_inheritance(self): - schema = SchemaDefinition(id='test', name='test') - view = SchemaView(schema) - view.add_class(ClassDefinition('C', slots=['s1', 's2'])) - view.add_class(ClassDefinition('D')) - view.add_class(ClassDefinition('Z')) - view.add_class(ClassDefinition('W')) - #view.add_class(ClassDefinition('C2', - # is_a='C') - # # slot_usage=[SlotDefinition(s1, range='C2')]) - view.add_slot(SlotDefinition('s1', multivalued=True, range='D')) - view.add_slot(SlotDefinition('s2', is_a='s1')) - view.add_slot(SlotDefinition('s3', is_a='s2', mixins=['m1'])) - view.add_slot(SlotDefinition('s4', is_a='s2', mixins=['m1'], range='W')) - view.add_slot(SlotDefinition('m1', mixin=True, multivalued=False, range='Z')) - slot1 = view.induced_slot('s1', 'C') - self.assertEqual(slot1.is_a, None) - self.assertEqual('D', slot1.range) - self.assertIsNotNone(slot1.multivalued) - slot2 = view.induced_slot('s2', 'C') - self.assertEqual(slot2.is_a, 's1') - self.assertEqual('D', slot2.range) - self.assertIsNotNone(slot2.multivalued) - slot3 = view.induced_slot('s3', 'C') - self.assertIsNotNone(slot3.multivalued) - self.assertEqual('Z', slot3.range) - slot4 = view.induced_slot('s4', 'C') - self.assertIsNotNone(slot4.multivalued) - self.assertEqual('W', slot4.range) - # test dangling - view.add_slot(SlotDefinition('s5', is_a='does-not-exist')) - with self.assertRaises(ValueError): - view.slot_ancestors('s5') - - def test_attribute_inheritance(self): - """ - Tests attribute inheritance edge cases - :return: - """ - view = SchemaView(os.path.join(INPUT_DIR, 'attribute_edge_cases.yaml')) - expected = [ - ('Root', 'a1', None, "a1"), - ('Root', 'a2', None, "a2"), - ('Root', 'a3', None, "a3"), - ('C1', 'a1', True, "a1m1"), - ('C1', 'a2', True, "a2c1"), - ('C1', 'a3', None, "a3"), - ('C1', 'a4', None, "a4"), - ('C2', 'a1', False, "a1m2"), - ('C2', 'a2', True, "a2c2"), - ('C2', 'a3', None, "a3"), - ('C2', 'a4', True, "a4m2"), - ('C1x', 'a1', True, "a1m1"), - ('C1x', 'a2', True, "a2c1x"), - ('C1x', 'a3', None, "a3"), - ('C1x', 'a4', None, "a4"), - ] - for cn, sn, req, desc in expected: - slot = view.induced_slot(sn, cn) - self.assertEqual(req, slot.required, f"in: {cn}.{sn}") - self.assertEqual(desc, slot.description, f"in: {cn}.{sn}") - self.assertEqual('string', slot.range, f"in: {cn}.{sn}") - - def test_ambiguous_attributes(self): - """ - Tests behavior where multiple attributes share the same name - """ - schema = SchemaDefinition(id='test', name='test') - view = SchemaView(schema) - a1 = SlotDefinition('a1', range='string') - a2 = SlotDefinition('a2', range='FooEnum') - a3 = SlotDefinition('a3', range='C3') - view.add_class(ClassDefinition('C1', attributes={a1.name: a1, a2.name: a2, a3.name: a3})) - a1x = SlotDefinition('a1', range='integer') - a2x = SlotDefinition('a2', range='BarEnum') - view.add_class(ClassDefinition('C2', attributes={a1x.name: a1x, a2x.name: a2x})) - # a1 and a2 are ambiguous: only stub information available - # without class context - self.assertIsNone(view.get_slot(a1.name).range) - self.assertIsNone(view.get_slot(a2.name).range) - self.assertIsNotNone(view.get_slot(a3.name).range) - self.assertEqual(3, len(view.all_slots(attributes=True))) - self.assertEqual(0, len(view.all_slots(attributes=False))) - # default is to include attributes - self.assertEqual(3, len(view.all_slots())) - self.assertEqual(a3.range, view.induced_slot(a3.name).range) - self.assertEqual(a1.range, view.induced_slot(a1.name, 'C1').range) - self.assertEqual(a2.range, view.induced_slot(a2.name, 'C1').range) - self.assertEqual(a1x.range, view.induced_slot(a1x.name, 'C2').range) - self.assertEqual(a2x.range, view.induced_slot(a2x.name, 'C2').range) - - def test_metamodel_in_schemaview(self): - view = package_schemaview('linkml_runtime.linkml_model.meta') - self.assertIn('meta', view.imports_closure()) - self.assertIn('linkml:types', view.imports_closure()) - self.assertIn('meta', view.imports_closure(imports=False)) - self.assertNotIn('linkml:types', view.imports_closure(imports=False)) - self.assertEqual(1, len(view.imports_closure(imports=False))) - all_classes = list(view.all_classes().keys()) - all_classes_no_imports = list(view.all_classes(imports=False).keys()) - for cn in ['class_definition', 'type_definition', 'slot_definition']: - self.assertIn(cn, all_classes) - self.assertIn(cn, all_classes_no_imports) - self.assertEqual(view.get_identifier_slot(cn).name, 'name') - for cn in ['annotation', 'extension']: - self.assertIn(cn, all_classes, "imports should be included by default") - self.assertNotIn(cn, all_classes_no_imports, "imported class unexpectedly included") - for sn in ['id', 'name', 'description']: - self.assertIn(sn, view.all_slots()) - for tn in ['uriorcurie', 'string', 'float']: - self.assertIn(tn, view.all_types()) - for tn in ['uriorcurie', 'string', 'float']: - self.assertNotIn(tn, view.all_types(imports=False)) - for cn, c in view.all_classes().items(): - uri = view.get_uri(cn, expand=True) - self.assertIsNotNone(uri) - if cn != 'structured_alias' and cn != 'UnitOfMeasure' and cn != 'ValidationReport' and \ + for im in [None, {}, {"core": "core"}]: + view = SchemaView(SCHEMA_WITH_IMPORTS, importmap=im) + view.all_classes() + assert sorted(['kitchen_sink', 'core', 'linkml:types']) == sorted(view.imports_closure()) + assert ACTIVITY in view.all_classes() + assert ACTIVITY not in view.all_classes(imports=False) + + +def test_imports(): + """ + view should by default dynamically include imports chain + """ + view = SchemaView(SCHEMA_WITH_IMPORTS) + assert view.schema.source_file is not None + logging.debug(view.imports_closure()) + assert sorted(['kitchen_sink', 'core', 'linkml:types']) == sorted(view.imports_closure()) + for t in view.all_types().keys(): + logging.debug(f'T={t} in={view.in_schema(t)}') + assert view.in_schema(ClassDefinitionName('Person')) == 'kitchen_sink' + assert view.in_schema(SlotDefinitionName('id')) == 'core' + assert view.in_schema(SlotDefinitionName('name')) == 'core' + assert view.in_schema(SlotDefinitionName(ACTIVITY)) == 'core' + assert view.in_schema(SlotDefinitionName('string')) == 'types' + assert ACTIVITY in view.all_classes() + assert ACTIVITY not in view.all_classes(imports=False) + assert 'string' in view.all_types() + assert 'string' not in view.all_types(imports=False) + assert sorted(['SymbolString', 'string']) == sorted(view.type_ancestors('SymbolString')) + + for tn, t in view.all_types().items(): + assert tn == t.name + induced_t = view.induced_type(tn) + assert induced_t.uri is not None + # assertIsNotNone(induced_t.repr) + assert induced_t.base is not None + if t in view.all_types(imports=False).values(): + assert t.from_schema == 'https://w3id.org/linkml/tests/kitchen_sink' + else: + assert t.from_schema in ['https://w3id.org/linkml/tests/core', 'https://w3id.org/linkml/types'] + for en, e in view.all_enums().items(): + assert en == e.name + if e in view.all_enums(imports=False).values(): + assert e.from_schema == 'https://w3id.org/linkml/tests/kitchen_sink' + else: + assert e.from_schema == 'https://w3id.org/linkml/tests/core' + for sn, s in view.all_slots().items(): + assert sn == s.name + s_induced = view.induced_slot(sn) + assert s_induced.range is not None + if s in view.all_slots(imports=False).values(): + assert s.from_schema == 'https://w3id.org/linkml/tests/kitchen_sink' + else: + assert s.from_schema == 'https://w3id.org/linkml/tests/core' + for cn, c in view.all_classes().items(): + assert cn == c.name + if c in view.all_classes(imports=False).values(): + assert c.from_schema == 'https://w3id.org/linkml/tests/kitchen_sink' + else: + assert c.from_schema == 'https://w3id.org/linkml/tests/core' + for s in view.class_induced_slots(cn): + if s in view.all_classes(imports=False).values(): + assert s.slot_uri is not None + assert s.from_schema == 'https://w3id.org/linkml/tests/kitchen_sink' + + for c in ['Company', 'Person', 'Organization', 'Thing']: + assert view.induced_slot('id', c).identifier is True + assert not view.induced_slot('name', c).identifier + assert not view.induced_slot('name', c).required + assert view.induced_slot('name', c).range == 'string' + for c in ['Event', 'EmploymentEvent', 'MedicalEvent']: + s = view.induced_slot('started at time', c) + assert s.range == 'date' + assert s.slot_uri == 'prov:startedAtTime' + assert view.induced_slot(AGE_IN_YEARS, 'Person').minimum_value == 0 + assert view.induced_slot(AGE_IN_YEARS, 'Adult').minimum_value == 16 + + assert view.get_class('agent').class_uri == 'prov:Agent' + assert view.get_uri(AGENT) == 'prov:Agent' + logging.debug(view.get_class('Company').class_uri) + + assert view.get_uri(COMPANY) == 'ks:Company' + assert view.get_uri(COMPANY, expand=True) == 'https://w3id.org/linkml/tests/kitchen_sink/Company' + logging.debug(view.get_uri('TestClass')) + assert view.get_uri('TestClass') == 'core:TestClass' + assert view.get_uri('TestClass', expand=True) == 'https://w3id.org/linkml/tests/core/TestClass' + + assert view.get_uri('string') == 'xsd:string' + + # dynamic enums + e = view.get_enum('HCAExample') + assert sorted(['GO:0007049', 'GO:0022403']) == sorted(e.include[0].reachable_from.source_nodes) + + # units + height = view.get_slot('height_in_m') + assert height.unit.ucum_code == "m" + + +def test_imports_from_schemaview(): + """ + view should by default dynamically include imports chain + """ + view = SchemaView(SCHEMA_WITH_IMPORTS) + view2 = SchemaView(view.schema) + assert sorted(view.all_classes()) == sorted(view2.all_classes()) + assert sorted(view.all_classes(imports=False)) == sorted(view2.all_classes(imports=False)) + + +def test_imports_closure_order(): + """ + Imports should override in a python-like order. + + See + - https://github.com/linkml/linkml/issues/1839 for initial discussion + - input/imports/README.md for explanation of the test schema + """ + sv = SchemaView(SCHEMA_IMPORT_TREE) + closure = sv.imports_closure(imports=True) + target = [ + 'linkml:types', + 's1_1', + 's1_2_1_1_1', 's1_2_1_1_2', + 's1_2_1_1', 's1_2_1', 's1_2', + 's1', + 's2_1', 's2_2', 's2', + 's3_1', 's3_2', 's3', + 'main' + ] + assert closure == target + + +def test_imports_overrides(): + """ + Classes defined in the importing module should override same-named classes in + imported modules. + + Tests recursively across an import tree. Each class defines all classes lower + in the tree with a `value` attribute with an `ifabsent` value matching the + current schema. Lower (closer to the importing schema) schemas should override + each class at that level or lower, keeping the rest. + + See `input/imports/README.md` for further explanation. + """ + sv = SchemaView(SCHEMA_IMPORT_TREE) + defaults = {} + target = {} + for name, cls in sv.all_classes(imports=True).items(): + target[name] = name + defaults[name] = cls.attributes['value'].ifabsent + + assert defaults == target + + +def test_direct_remote_imports(): + """ + Tests that building a SchemaView directly from a remote URL works. + + Note: this should be the only test in this suite that fails if there is + no network connection. + """ + view = SchemaView("https://w3id.org/linkml/meta.yaml") + main_classes = ["class_definition", "prefix"] + imported_classes = ["annotation"] + for c in main_classes: + assert c in view.all_classes(imports=True) + assert c in view.all_classes(imports=False) + for c in imported_classes: + assert c in view.all_classes(imports=True) + assert c not in view.all_classes(imports=False) + + +@pytest.mark.skip("Skipped as fragile: will break if the remote schema changes") +def test_direct_remote_imports_additional(): + """ + Alternative test to: https://github.com/linkml/linkml/pull/1379 + """ + url = "https://raw.githubusercontent.com/GenomicsStandardsConsortium/mixs/main/model/schema/mixs.yaml" + view = SchemaView(url) + assert view.schema.name == "MIxS" + class_count = len(view.all_classes()) + assert class_count > 0 + + +def test_merge_imports(): + """ + ensure merging and merging imports closure works + """ + view = SchemaView(SCHEMA_WITH_IMPORTS) + all_c = copy(view.all_classes()) + all_c_noi = copy(view.all_classes(imports=False)) + assert len(all_c_noi) < len(all_c) + view.merge_imports() + all_c2 = copy(view.all_classes()) + assert sorted(all_c) == sorted(all_c2) + all_c2_noi = copy(view.all_classes(imports=False)) + assert len(all_c2_noi) == len(all_c2) + + +def test_metamodel_imports(): + """ + Tests imports of the metamodel. + + Note: this test and others should be able to run without network connectivity. + SchemaView should make use of the version of the metamodel distributed with the package + over the network available version. + + TODO: use mock testing framework to emulate no access to network. + + - ``_ + :return: + """ + schema = SchemaDefinition(id='test', name='metamodel-imports-test', imports=["linkml:meta"]) + sv = SchemaView(schema) + all_classes = sv.all_classes() + assert len(all_classes) > 20 + schema_str = yaml_dumper.dumps(schema) + sv = SchemaView(schema_str) + assert len(sv.all_classes()) > 20 + assert sorted(all_classes) == sorted(sv.all_classes()) + + +def test_non_linkml_remote_import(): + """ + Test that a remote import _not_ using the linkml prefix works + + See: https://github.com/linkml/linkml/issues/1627 + """ + schema = SchemaDefinition( + id='test_non_linkml_remote_import', + name='test_non_linkml_remote_import', + prefixes=[ + Prefix( + prefix_prefix="foo", + prefix_reference="https://w3id.org/linkml/" + ) + ], + imports=["foo:types"], + slots=[SlotDefinition(name="an_int", range="integer")], + classes=[ClassDefinition(name="AClass", slots=["an_int"])] + ) + sv = SchemaView(schema) + slots = sv.class_induced_slots("AClass", imports=True) + assert len(slots) == 1 + + +def test_traversal(): + schema = SchemaDefinition(id='test', name='traversal-test') + view = SchemaView(schema) + view.add_class(ClassDefinition('Root', mixins=['RootMixin'])) + view.add_class(ClassDefinition('A', is_a='Root', mixins=['Am1', 'Am2', 'AZ'])) + view.add_class(ClassDefinition('B', is_a='A', mixins=['Bm1', 'Bm2', 'BY'])) + view.add_class(ClassDefinition('C', is_a='B', mixins=['Cm1', 'Cm2', 'CX'])) + view.add_class(ClassDefinition('RootMixin', mixin=True)) + view.add_class(ClassDefinition('Am1', is_a='RootMixin', mixin=True)) + view.add_class(ClassDefinition('Am2', is_a='RootMixin', mixin=True)) + view.add_class(ClassDefinition('Bm1', is_a='Am1', mixin=True)) + view.add_class(ClassDefinition('Bm2', is_a='Am2', mixin=True)) + view.add_class(ClassDefinition('Cm1', is_a='Bm1', mixin=True)) + view.add_class(ClassDefinition('Cm2', is_a='Bm2', mixin=True)) + view.add_class(ClassDefinition('AZ', is_a='RootMixin', mixin=True)) + view.add_class(ClassDefinition('BY', is_a='RootMixin', mixin=True)) + view.add_class(ClassDefinition('CX', is_a='RootMixin', mixin=True)) + + def check(ancs: list, expected: list): + assert ancs == expected + + check(view.class_ancestors('C', depth_first=True), + ['C', 'Cm1', 'Cm2', 'CX', 'B', 'Bm1', 'Bm2', 'BY', 'A', 'Am1', 'Am2', 'AZ', 'Root', 'RootMixin']) + check(view.class_ancestors('C', depth_first=False), + ['C', 'Cm1', 'Cm2', 'CX', 'B', 'Bm1', 'Bm2', 'RootMixin', 'BY', 'A', 'Am1', 'Am2', 'AZ', 'Root']) + check(view.class_ancestors('C', mixins=False), + ['C', 'B', 'A', 'Root']) + check(view.class_ancestors('C', is_a=False), + ['C', 'Cm1', 'Cm2', 'CX']) + + +def test_slot_inheritance(): + schema = SchemaDefinition(id='test', name='test') + view = SchemaView(schema) + view.add_class(ClassDefinition('C', slots=['s1', 's2'])) + view.add_class(ClassDefinition('D')) + view.add_class(ClassDefinition('Z')) + view.add_class(ClassDefinition('W')) + # view.add_class(ClassDefinition('C2', + # is_a='C') + # # slot_usage=[SlotDefinition(s1, range='C2')]) + view.add_slot(SlotDefinition('s1', multivalued=True, range='D')) + view.add_slot(SlotDefinition('s2', is_a='s1')) + view.add_slot(SlotDefinition('s3', is_a='s2', mixins=['m1'])) + view.add_slot(SlotDefinition('s4', is_a='s2', mixins=['m1'], range='W')) + view.add_slot(SlotDefinition('m1', mixin=True, multivalued=False, range='Z')) + slot1 = view.induced_slot('s1', 'C') + assert slot1.is_a is None + assert slot1.range == 'D' + assert slot1.multivalued is not None + slot2 = view.induced_slot('s2', 'C') + assert slot2.is_a == 's1' + assert slot2.range == 'D' + assert slot2.multivalued is not None + slot3 = view.induced_slot('s3', 'C') + assert slot3.multivalued is not None + assert slot3.range == 'Z' + slot4 = view.induced_slot('s4', 'C') + assert slot4.multivalued is not None + assert slot4.range == 'W' + # test dangling + view.add_slot(SlotDefinition('s5', is_a='does-not-exist')) + with pytest.raises(ValueError): + view.slot_ancestors('s5') + + +def test_attribute_inheritance(): + """ + Tests attribute inheritance edge cases + :return: + """ + view = SchemaView(os.path.join(INPUT_DIR, 'attribute_edge_cases.yaml')) + expected = [ + ('Root', 'a1', None, "a1"), + ('Root', 'a2', None, "a2"), + ('Root', 'a3', None, "a3"), + ('C1', 'a1', True, "a1m1"), + ('C1', 'a2', True, "a2c1"), + ('C1', 'a3', None, "a3"), + ('C1', 'a4', None, "a4"), + ('C2', 'a1', False, "a1m2"), + ('C2', 'a2', True, "a2c2"), + ('C2', 'a3', None, "a3"), + ('C2', 'a4', True, "a4m2"), + ('C1x', 'a1', True, "a1m1"), + ('C1x', 'a2', True, "a2c1x"), + ('C1x', 'a3', None, "a3"), + ('C1x', 'a4', None, "a4"), + ] + for cn, sn, req, desc in expected: + slot = view.induced_slot(sn, cn) + assert req == slot.required, f"in: {cn}.{sn}" + assert desc == slot.description, f"in: {cn}.{sn}" + assert 'string' == slot.range, f"in: {cn}.{sn}" + + +def test_ambiguous_attributes(): + """ + Tests behavior where multiple attributes share the same name + """ + schema = SchemaDefinition(id='test', name='test') + view = SchemaView(schema) + a1 = SlotDefinition('a1', range='string') + a2 = SlotDefinition('a2', range='FooEnum') + a3 = SlotDefinition('a3', range='C3') + view.add_class(ClassDefinition('C1', attributes={a1.name: a1, a2.name: a2, a3.name: a3})) + a1x = SlotDefinition('a1', range='integer') + a2x = SlotDefinition('a2', range='BarEnum') + view.add_class(ClassDefinition('C2', attributes={a1x.name: a1x, a2x.name: a2x})) + # a1 and a2 are ambiguous: only stub information available + # without class context + assert view.get_slot(a1.name).range is None + assert view.get_slot(a2.name).range is None + assert view.get_slot(a3.name).range is not None + assert len(view.all_slots(attributes=True)) == 3 + assert len(view.all_slots(attributes=False)) == 0 + # default is to include attributes + assert len(view.all_slots()) == 3 + assert view.induced_slot(a3.name).range == a3.range + assert view.induced_slot(a1.name, 'C1').range == a1.range + assert view.induced_slot(a2.name, 'C1').range == a2.range + assert view.induced_slot(a1x.name, 'C2').range == a1x.range + assert view.induced_slot(a2x.name, 'C2').range == a2x.range + + +def test_metamodel_in_schemaview(): + view = package_schemaview('linkml_runtime.linkml_model.meta') + assert 'meta' in view.imports_closure() + assert 'linkml:types' in view.imports_closure() + assert 'meta' in view.imports_closure(imports=False) + assert 'linkml:types' not in view.imports_closure(imports=False) + assert len(view.imports_closure(imports=False)) == 1 + all_classes = list(view.all_classes().keys()) + all_classes_no_imports = list(view.all_classes(imports=False).keys()) + for cn in ['class_definition', 'type_definition', 'slot_definition']: + assert cn in all_classes + assert cn in all_classes_no_imports + assert view.get_identifier_slot(cn).name == 'name' + for cn in ['annotation', 'extension']: + assert cn in all_classes, "imports should be included by default" + assert cn not in all_classes_no_imports, "imported class unexpectedly included" + for sn in ['id', 'name', 'description']: + assert sn in view.all_slots() + for tn in ['uriorcurie', 'string', 'float']: + assert tn in view.all_types() + for tn in ['uriorcurie', 'string', 'float']: + assert tn not in view.all_types(imports=False) + for cn, c in view.all_classes().items(): + uri = view.get_uri(cn, expand=True) + assert uri is not None + if cn != 'structured_alias' and cn != 'UnitOfMeasure' and cn != 'ValidationReport' and \ cn != 'ValidationResult': - self.assertIn('https://w3id.org/linkml/', uri) - induced_slots = view.class_induced_slots(cn) - for s in induced_slots: - exp_slot_uri = view.get_uri(s, expand=True) - self.assertIsNotNone(exp_slot_uri) - - def test_get_classes_by_slot(self): - sv = SchemaView(SCHEMA_WITH_IMPORTS) - - slot = sv.get_slot(AGE_IN_YEARS) - - actual_result = sv.get_classes_by_slot(slot) - expected_result = ["Person"] - - self.assertListEqual(sorted(expected_result), sorted(actual_result)) - - actual_result = sv.get_classes_by_slot(slot, include_induced=True) - expected_result = ["Person", "Adult"] - - self.assertListEqual(sorted(actual_result), sorted(expected_result)) - - def test_materialize_patterns(self): - sv = SchemaView(SCHEMA_WITH_STRUCTURED_PATTERNS) - - sv.materialize_patterns() - - height_slot = sv.get_slot("height") - weight_slot = sv.get_slot("weight") - - self.assertEqual(height_slot.pattern, "\d+[\.\d+] (centimeter|meter|inch)") - self.assertEqual(weight_slot.pattern, "\d+[\.\d+] (kg|g|lbs|stone)") - - def test_materialize_patterns_slot_usage(self): - sv = SchemaView(SCHEMA_WITH_STRUCTURED_PATTERNS) - - sv.materialize_patterns() - - name_slot_usage = sv.get_class("FancyPersonInfo").slot_usage['name'] - - self.assertEqual(name_slot_usage.pattern, "\\S+ \\S+-\\S+") - - def test_materialize_patterns_attribute(self): - sv = SchemaView(SCHEMA_WITH_STRUCTURED_PATTERNS) - - sv.materialize_patterns() - - weight_attribute = sv.get_class('ClassWithAttributes').attributes['weight'] - - self.assertEqual(weight_attribute.pattern, "\d+[\.\d+] (kg|g|lbs|stone)") - - def test_mergeimports(self): - sv = SchemaView(SCHEMA_WITH_IMPORTS, merge_imports=False) - # activity class is in core, but not in kitchen_sink - classes_list = list(sv.schema.classes.keys()) - self.assertNotIn("activity", classes_list) - - # was generated by slot is in core, but not in kitchen_sink - slots_list = list(sv.schema.slots.keys()) - self.assertNotIn("was generated by", slots_list) - - # list of prefixes only in kitchen_sink - prefixes_list = list(sv.schema.prefixes.keys()) - self.assertListEqual( - ["pav", "dce", "lego", "linkml", "biolink", "ks", "RO", "BFO", "tax"], - prefixes_list - ) - - # merge_imports=True, so activity class should be present - sv = SchemaView(SCHEMA_WITH_IMPORTS, merge_imports=True) - classes_list = list(sv.schema.classes.keys()) - self.assertIn("activity", classes_list) - - slots_list = list(sv.schema.slots.keys()) - self.assertIn("was generated by", slots_list) - - prefixes_list = list(sv.schema.prefixes.keys()) - if 'schema' not in prefixes_list: - prefixes_list.append('schema') - self.assertCountEqual( - ["pav", - "dce", - "lego", - "linkml", - "biolink", - "ks", - "RO", - "BFO", - "tax", - "core", - "prov", - "xsd", - "schema", - "shex", - ], - prefixes_list - ) - - def test_is_inlined(self): - schema_path = os.path.join(INPUT_DIR, "schemaview_is_inlined.yaml") - sv = SchemaView(schema_path) - cases = [ - # slot name, expected is_inline - ("a_thing_with_id", False), - ("inlined_thing_with_id", True), - ("inlined_as_list_thing_with_id", True), - ("a_thing_without_id", True), - ("inlined_thing_without_id", True), - ("inlined_as_list_thing_without_id", True), - ("an_integer", False), - ("inlined_integer", False), - ("inlined_as_list_integer", False) - ] - for slot_name, expected_result in cases: - with self.subTest(slot_name=slot_name): - slot = sv.get_slot(slot_name) - actual_result = sv.is_inlined(slot) - self.assertEqual(actual_result, expected_result) - - -if __name__ == '__main__': - unittest.main() + assert 'https://w3id.org/linkml/' in uri + induced_slots = view.class_induced_slots(cn) + for s in induced_slots: + exp_slot_uri = view.get_uri(s, expand=True) + assert exp_slot_uri is not None + + +def test_get_classes_by_slot(): + sv = SchemaView(SCHEMA_WITH_IMPORTS) + + slot = sv.get_slot(AGE_IN_YEARS) + + actual_result = sv.get_classes_by_slot(slot) + expected_result = ["Person"] + + assert sorted(expected_result) == sorted(actual_result) + + actual_result = sv.get_classes_by_slot(slot, include_induced=True) + expected_result = ["Person", "Adult"] + + assert sorted(actual_result) == sorted(expected_result) + + +def test_materialize_patterns(): + sv = SchemaView(SCHEMA_WITH_STRUCTURED_PATTERNS) + + sv.materialize_patterns() + + height_slot = sv.get_slot("height") + weight_slot = sv.get_slot("weight") + + assert height_slot.pattern == "\d+[\.\d+] (centimeter|meter|inch)" + assert weight_slot.pattern == "\d+[\.\d+] (kg|g|lbs|stone)" + + +def test_materialize_patterns_slot_usage(): + sv = SchemaView(SCHEMA_WITH_STRUCTURED_PATTERNS) + + sv.materialize_patterns() + + name_slot_usage = sv.get_class("FancyPersonInfo").slot_usage['name'] + + assert name_slot_usage.pattern == "\\S+ \\S+-\\S+" + + +def test_materialize_patterns_attribute(): + sv = SchemaView(SCHEMA_WITH_STRUCTURED_PATTERNS) + + sv.materialize_patterns() + + weight_attribute = sv.get_class('ClassWithAttributes').attributes['weight'] + + assert weight_attribute.pattern == "\d+[\.\d+] (kg|g|lbs|stone)" + + +def test_mergeimports(): + sv = SchemaView(SCHEMA_WITH_IMPORTS, merge_imports=False) + # activity class is in core, but not in kitchen_sink + classes_list = list(sv.schema.classes.keys()) + assert "activity" not in classes_list + + # was generated by slot is in core, but not in kitchen_sink + slots_list = list(sv.schema.slots.keys()) + assert "was generated by" not in slots_list + + # list of prefixes only in kitchen_sink + prefixes_list = list(sv.schema.prefixes.keys()) + assert sorted(["pav", "dce", "lego", "linkml", "biolink", "ks", "RO", "BFO", "tax"]) == sorted(prefixes_list) + + # merge_imports=True, so activity class should be present + sv = SchemaView(SCHEMA_WITH_IMPORTS, merge_imports=True) + classes_list = list(sv.schema.classes.keys()) + assert "activity" in classes_list + + slots_list = list(sv.schema.slots.keys()) + assert "was generated by" in slots_list + + prefixes_list = list(sv.schema.prefixes.keys()) + if 'schema' not in prefixes_list: + prefixes_list.append('schema') + assert sorted(["pav", "dce", "lego", "linkml", "biolink", "ks", "RO", "BFO", "tax", "core", "prov", "xsd", "schema", "shex"]) == sorted(prefixes_list) + + +def test_is_inlined(): + schema_path = os.path.join(INPUT_DIR, "schemaview_is_inlined.yaml") + sv = SchemaView(schema_path) + cases = [ + # slot name, expected is_inline + ("a_thing_with_id", False), + ("inlined_thing_with_id", True), + ("inlined_as_list_thing_with_id", True), + ("a_thing_without_id", True), + ("inlined_thing_without_id", True), + ("inlined_as_list_thing_without_id", True), + ("an_integer", False), + ("inlined_integer", False), + ("inlined_as_list_integer", False) + ] + for slot_name, expected_result in cases: + slot = sv.get_slot(slot_name) + actual_result = sv.is_inlined(slot) + assert actual_result == expected_result + From ff48929d42aac94b5d58e06993e36f7880905621 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Fri, 12 Jul 2024 13:00:10 -0700 Subject: [PATCH 02/14] add pytest runner to makefile, include pytest as a development/test dependency --- Makefile | 3 +- poetry.lock | 124 +++++++++++++++++++++++++++++++++++++++++++++---- pyproject.toml | 9 ++++ 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 19a667f0..65536211 100644 --- a/Makefile +++ b/Makefile @@ -5,12 +5,13 @@ # TODO: make this mechanism more robust MODEL_DIR = ../linkml-model/linkml_model/ +RUN=poetry run update_model: cp -pr $(MODEL_DIR)/* linkml_runtime/linkml_model test: - poetry run python -m unittest discover + $(RUN) pytest # temporary measure until linkml-model is synced linkml_runtime/processing/validation_datamodel.py: linkml_runtime/processing/validation_datamodel.yaml diff --git a/poetry.lock b/poetry.lock index afd28039..86a65127 100644 --- a/poetry.lock +++ b/poetry.lock @@ -442,6 +442,97 @@ files = [ importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} referencing = ">=0.31.0" +[[package]] +name = "numpy" +version = "1.24.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "numpy-1.24.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0bfb52d2169d58c1cdb8cc1f16989101639b34c7d3ce60ed70b19c63eba0b64"}, + {file = "numpy-1.24.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed094d4f0c177b1b8e7aa9cba7d6ceed51c0e569a5318ac0ca9a090680a6a1b1"}, + {file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79fc682a374c4a8ed08b331bef9c5f582585d1048fa6d80bc6c35bc384eee9b4"}, + {file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ffe43c74893dbf38c2b0a1f5428760a1a9c98285553c89e12d70a96a7f3a4d6"}, + {file = "numpy-1.24.4-cp310-cp310-win32.whl", hash = "sha256:4c21decb6ea94057331e111a5bed9a79d335658c27ce2adb580fb4d54f2ad9bc"}, + {file = "numpy-1.24.4-cp310-cp310-win_amd64.whl", hash = "sha256:b4bea75e47d9586d31e892a7401f76e909712a0fd510f58f5337bea9572c571e"}, + {file = "numpy-1.24.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f136bab9c2cfd8da131132c2cf6cc27331dd6fae65f95f69dcd4ae3c3639c810"}, + {file = "numpy-1.24.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2926dac25b313635e4d6cf4dc4e51c8c0ebfed60b801c799ffc4c32bf3d1254"}, + {file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:222e40d0e2548690405b0b3c7b21d1169117391c2e82c378467ef9ab4c8f0da7"}, + {file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7215847ce88a85ce39baf9e89070cb860c98fdddacbaa6c0da3ffb31b3350bd5"}, + {file = "numpy-1.24.4-cp311-cp311-win32.whl", hash = "sha256:4979217d7de511a8d57f4b4b5b2b965f707768440c17cb70fbf254c4b225238d"}, + {file = "numpy-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b7b1fc9864d7d39e28f41d089bfd6353cb5f27ecd9905348c24187a768c79694"}, + {file = "numpy-1.24.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1452241c290f3e2a312c137a9999cdbf63f78864d63c79039bda65ee86943f61"}, + {file = "numpy-1.24.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:04640dab83f7c6c85abf9cd729c5b65f1ebd0ccf9de90b270cd61935eef0197f"}, + {file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5425b114831d1e77e4b5d812b69d11d962e104095a5b9c3b641a218abcc050e"}, + {file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd80e219fd4c71fc3699fc1dadac5dcf4fd882bfc6f7ec53d30fa197b8ee22dc"}, + {file = "numpy-1.24.4-cp38-cp38-win32.whl", hash = "sha256:4602244f345453db537be5314d3983dbf5834a9701b7723ec28923e2889e0bb2"}, + {file = "numpy-1.24.4-cp38-cp38-win_amd64.whl", hash = "sha256:692f2e0f55794943c5bfff12b3f56f99af76f902fc47487bdfe97856de51a706"}, + {file = "numpy-1.24.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2541312fbf09977f3b3ad449c4e5f4bb55d0dbf79226d7724211acc905049400"}, + {file = "numpy-1.24.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9667575fb6d13c95f1b36aca12c5ee3356bf001b714fc354eb5465ce1609e62f"}, + {file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a86ed21e4f87050382c7bc96571755193c4c1392490744ac73d660e8f564a9"}, + {file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d11efb4dbecbdf22508d55e48d9c8384db795e1b7b51ea735289ff96613ff74d"}, + {file = "numpy-1.24.4-cp39-cp39-win32.whl", hash = "sha256:6620c0acd41dbcb368610bb2f4d83145674040025e5536954782467100aa8835"}, + {file = "numpy-1.24.4-cp39-cp39-win_amd64.whl", hash = "sha256:befe2bf740fd8373cf56149a5c23a0f601e82869598d41f8e188a0e9869926f8"}, + {file = "numpy-1.24.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31f13e25b4e304632a4619d0e0777662c2ffea99fcae2029556b17d8ff958aef"}, + {file = "numpy-1.24.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95f7ac6540e95bc440ad77f56e520da5bf877f87dca58bd095288dce8940532a"}, + {file = "numpy-1.24.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e98f220aa76ca2a977fe435f5b04d7b3470c0a2e6312907b37ba6068f26787f2"}, + {file = "numpy-1.24.4.tar.gz", hash = "sha256:80f5e3a4e498641401868df4208b74581206afbee7cf7b8329daae82676d9463"}, +] + +[[package]] +name = "numpy" +version = "2.0.0" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "numpy-2.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:04494f6ec467ccb5369d1808570ae55f6ed9b5809d7f035059000a37b8d7e86f"}, + {file = "numpy-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2635dbd200c2d6faf2ef9a0d04f0ecc6b13b3cad54f7c67c61155138835515d2"}, + {file = "numpy-2.0.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0a43f0974d501842866cc83471bdb0116ba0dffdbaac33ec05e6afed5b615238"}, + {file = "numpy-2.0.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:8d83bb187fb647643bd56e1ae43f273c7f4dbcdf94550d7938cfc32566756514"}, + {file = "numpy-2.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79e843d186c8fb1b102bef3e2bc35ef81160ffef3194646a7fdd6a73c6b97196"}, + {file = "numpy-2.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d7696c615765091cc5093f76fd1fa069870304beaccfd58b5dcc69e55ef49c1"}, + {file = "numpy-2.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b4c76e3d4c56f145d41b7b6751255feefae92edbc9a61e1758a98204200f30fc"}, + {file = "numpy-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acd3a644e4807e73b4e1867b769fbf1ce8c5d80e7caaef0d90dcdc640dfc9787"}, + {file = "numpy-2.0.0-cp310-cp310-win32.whl", hash = "sha256:cee6cc0584f71adefe2c908856ccc98702baf95ff80092e4ca46061538a2ba98"}, + {file = "numpy-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:ed08d2703b5972ec736451b818c2eb9da80d66c3e84aed1deeb0c345fefe461b"}, + {file = "numpy-2.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad0c86f3455fbd0de6c31a3056eb822fc939f81b1618f10ff3406971893b62a5"}, + {file = "numpy-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7f387600d424f91576af20518334df3d97bc76a300a755f9a8d6e4f5cadd289"}, + {file = "numpy-2.0.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:34f003cb88b1ba38cb9a9a4a3161c1604973d7f9d5552c38bc2f04f829536609"}, + {file = "numpy-2.0.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:b6f6a8f45d0313db07d6d1d37bd0b112f887e1369758a5419c0370ba915b3871"}, + {file = "numpy-2.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f64641b42b2429f56ee08b4f427a4d2daf916ec59686061de751a55aafa22e4"}, + {file = "numpy-2.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7039a136017eaa92c1848152827e1424701532ca8e8967fe480fe1569dae581"}, + {file = "numpy-2.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:46e161722e0f619749d1cd892167039015b2c2817296104487cd03ed4a955995"}, + {file = "numpy-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0e50842b2295ba8414c8c1d9d957083d5dfe9e16828b37de883f51fc53c4016f"}, + {file = "numpy-2.0.0-cp311-cp311-win32.whl", hash = "sha256:2ce46fd0b8a0c947ae047d222f7136fc4d55538741373107574271bc00e20e8f"}, + {file = "numpy-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:fbd6acc766814ea6443628f4e6751d0da6593dae29c08c0b2606164db026970c"}, + {file = "numpy-2.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:354f373279768fa5a584bac997de6a6c9bc535c482592d7a813bb0c09be6c76f"}, + {file = "numpy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d2f62e55a4cd9c58c1d9a1c9edaedcd857a73cb6fda875bf79093f9d9086f85"}, + {file = "numpy-2.0.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:1e72728e7501a450288fc8e1f9ebc73d90cfd4671ebbd631f3e7857c39bd16f2"}, + {file = "numpy-2.0.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:84554fc53daa8f6abf8e8a66e076aff6ece62de68523d9f665f32d2fc50fd66e"}, + {file = "numpy-2.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c73aafd1afca80afecb22718f8700b40ac7cab927b8abab3c3e337d70e10e5a2"}, + {file = "numpy-2.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49d9f7d256fbc804391a7f72d4a617302b1afac1112fac19b6c6cec63fe7fe8a"}, + {file = "numpy-2.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0ec84b9ba0654f3b962802edc91424331f423dcf5d5f926676e0150789cb3d95"}, + {file = "numpy-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:feff59f27338135776f6d4e2ec7aeeac5d5f7a08a83e80869121ef8164b74af9"}, + {file = "numpy-2.0.0-cp312-cp312-win32.whl", hash = "sha256:c5a59996dc61835133b56a32ebe4ef3740ea5bc19b3983ac60cc32be5a665d54"}, + {file = "numpy-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a356364941fb0593bb899a1076b92dfa2029f6f5b8ba88a14fd0984aaf76d0df"}, + {file = "numpy-2.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e61155fae27570692ad1d327e81c6cf27d535a5d7ef97648a17d922224b216de"}, + {file = "numpy-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4554eb96f0fd263041baf16cf0881b3f5dafae7a59b1049acb9540c4d57bc8cb"}, + {file = "numpy-2.0.0-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:903703372d46bce88b6920a0cd86c3ad82dae2dbef157b5fc01b70ea1cfc430f"}, + {file = "numpy-2.0.0-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:3e8e01233d57639b2e30966c63d36fcea099d17c53bf424d77f088b0f4babd86"}, + {file = "numpy-2.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cde1753efe513705a0c6d28f5884e22bdc30438bf0085c5c486cdaff40cd67a"}, + {file = "numpy-2.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821eedb7165ead9eebdb569986968b541f9908979c2da8a4967ecac4439bae3d"}, + {file = "numpy-2.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9a1712c015831da583b21c5bfe15e8684137097969c6d22e8316ba66b5baabe4"}, + {file = "numpy-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9c27f0946a3536403efb0e1c28def1ae6730a72cd0d5878db38824855e3afc44"}, + {file = "numpy-2.0.0-cp39-cp39-win32.whl", hash = "sha256:63b92c512d9dbcc37f9d81b123dec99fdb318ba38c8059afc78086fe73820275"}, + {file = "numpy-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:3f6bed7f840d44c08ebdb73b1825282b801799e325bcbdfa6bc5c370e5aecc65"}, + {file = "numpy-2.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9416a5c2e92ace094e9f0082c5fd473502c91651fb896bc17690d6fc475128d6"}, + {file = "numpy-2.0.0-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:17067d097ed036636fa79f6a869ac26df7db1ba22039d962422506640314933a"}, + {file = "numpy-2.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38ecb5b0582cd125f67a629072fed6f83562d9dd04d7e03256c9829bdec027ad"}, + {file = "numpy-2.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cef04d068f5fb0518a77857953193b6bb94809a806bd0a14983a8f12ada060c9"}, + {file = "numpy-2.0.0.tar.gz", hash = "sha256:cf5d1c9e6837f8af9f92b6bd3e86d513cdc11f60fd62185cc49ec7d1aba34864"}, +] + [[package]] name = "packaging" version = "23.2" @@ -481,13 +572,13 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest- [[package]] name = "pluggy" -version = "1.4.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, - {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] @@ -652,13 +743,13 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "8.0.1" +version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "pytest-8.0.1-py3-none-any.whl", hash = "sha256:3e4f16fe1c0a9dc9d9389161c127c3edc5d810c38d6793042fb81d9f48a59fca"}, - {file = "pytest-8.0.1.tar.gz", hash = "sha256:267f6563751877d772019b13aacbe4e860d73fe8f651f28112e9ac37de7513ae"}, + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, ] [package.dependencies] @@ -666,7 +757,7 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.3.0,<2.0" +pluggy = ">=0.12,<2.0" tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] @@ -685,6 +776,21 @@ files = [ [package.dependencies] pytest = ">=2.8.1" +[[package]] +name = "pytest-subtests" +version = "0.11.0" +description = "unittest subTest() support and subtests fixture" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-subtests-0.11.0.tar.gz", hash = "sha256:51865c88457545f51fb72011942f0a3c6901ee9e24cbfb6d1b9dc1348bafbe37"}, + {file = "pytest_subtests-0.11.0-py3-none-any.whl", hash = "sha256:453389984952eec85ab0ce0c4f026337153df79587048271c7fd0f49119c07e4"}, +] + +[package.dependencies] +attrs = ">=19.2.0" +pytest = ">=7.0" + [[package]] name = "pytrie" version = "0.4.0" @@ -1126,4 +1232,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "fddd54f2e38fdce32a4236c283d5f4e141f897e6d7ed4aac4ce5dbb40c37e428" +content-hash = "bd78493854ff84fcab6b02bfc14ef1136647e7e65a47bdbcb5d2043a8d2d2aa7" diff --git a/pyproject.toml b/pyproject.toml index b50cba65..0d09c408 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,15 @@ pydantic = ">=1.10.2, <3.0.0" coverage = "^6.2" requests-cache = "^1.2.0" +[tool.poetry.group.tests.dependencies] +pytest = "^7.4.0" +pytest-subtests = "^0.11.0" +numpy = [ + { "version" = ">=1.24.3", "python" = "<3.12" }, + { "version" = ">=1.25.2", "python" = ">=3.12" } +] +requests-cache = "^1.2.0" + [build-system] requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] build-backend = "poetry_dynamic_versioning.backend" From 2a02fe2429ef5ce5a8eeab1f0ceca61d39786991 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Fri, 12 Jul 2024 14:30:58 -0700 Subject: [PATCH 03/14] do not fail CI if coverage upload fails -reverting to current standard for runtime --- .github/workflows/main.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 5cd6ff33..adeb7e27 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -49,7 +49,7 @@ jobs: run: poetry install --no-interaction --all-extras #---------------------------------------------- - # coverage report + # coverage report #---------------------------------------------- - name: Generate coverage results run: | @@ -62,9 +62,10 @@ jobs: # upload coverage results #---------------------------------------------- - name: Upload coverage report + if: github.repository == 'linkml/linkml-runtime' uses: codecov/codecov-action@v4 with: name: codecov-results-${{ matrix.os }}-${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} file: coverage.xml - fail_ci_if_error: false + fail_ci_if_error: false \ No newline at end of file From 47f42b7349fc7a2cbd5d5d4c854201a97f525d88 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Fri, 12 Jul 2024 14:49:31 -0700 Subject: [PATCH 04/14] remove command line argument that is only available in linkml --- .github/workflows/main.yaml | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index adeb7e27..2b912ed4 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -14,8 +14,18 @@ jobs: os: [ubuntu-latest, windows-latest] python-version: ["3.8", "3.9", "3.10", "3.12"] exclude: - - os: windows-latest - python-version: "3.8" + # Test on Windows with only the oldest and newest Python versions + - os: windows-latest + python-version: "3.9" + - os: windows-latest + python-version: "3.10" + - os: windows-latest + python-version: "3.11" + + # See https://github.com/snok/install-poetry#running-on-windows + defaults: + run: + shell: bash runs-on: ${{ matrix.os }} @@ -24,14 +34,14 @@ jobs: #---------------------------------------------- # check-out repo and set-up python #---------------------------------------------- + - name: Install poetry + run: pipx install poetry==1.4.0 + - name: Check out repository uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Install poetry - run: pipx install poetry - - name: Install dynamic versioning plugin run: poetry self add "poetry-dynamic-versioning[plugin]" @@ -53,7 +63,7 @@ jobs: #---------------------------------------------- - name: Generate coverage results run: | - poetry run coverage run -m pytest --with-slow + poetry run coverage run -m pytest poetry run coverage xml poetry run coverage report -m shell: bash @@ -63,7 +73,7 @@ jobs: #---------------------------------------------- - name: Upload coverage report if: github.repository == 'linkml/linkml-runtime' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v3 with: name: codecov-results-${{ matrix.os }}-${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} From 0c43ba6a6091e7eaecb4ed3caa9b5d4f50b12212 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Fri, 12 Jul 2024 14:51:51 -0700 Subject: [PATCH 05/14] remove unused dependencies --- poetry.lock | 108 +------------------------------------------------ pyproject.toml | 6 --- 2 files changed, 1 insertion(+), 113 deletions(-) diff --git a/poetry.lock b/poetry.lock index 86a65127..8072e7ff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -442,97 +442,6 @@ files = [ importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} referencing = ">=0.31.0" -[[package]] -name = "numpy" -version = "1.24.4" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "numpy-1.24.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0bfb52d2169d58c1cdb8cc1f16989101639b34c7d3ce60ed70b19c63eba0b64"}, - {file = "numpy-1.24.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed094d4f0c177b1b8e7aa9cba7d6ceed51c0e569a5318ac0ca9a090680a6a1b1"}, - {file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79fc682a374c4a8ed08b331bef9c5f582585d1048fa6d80bc6c35bc384eee9b4"}, - {file = "numpy-1.24.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ffe43c74893dbf38c2b0a1f5428760a1a9c98285553c89e12d70a96a7f3a4d6"}, - {file = "numpy-1.24.4-cp310-cp310-win32.whl", hash = "sha256:4c21decb6ea94057331e111a5bed9a79d335658c27ce2adb580fb4d54f2ad9bc"}, - {file = "numpy-1.24.4-cp310-cp310-win_amd64.whl", hash = "sha256:b4bea75e47d9586d31e892a7401f76e909712a0fd510f58f5337bea9572c571e"}, - {file = "numpy-1.24.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f136bab9c2cfd8da131132c2cf6cc27331dd6fae65f95f69dcd4ae3c3639c810"}, - {file = "numpy-1.24.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2926dac25b313635e4d6cf4dc4e51c8c0ebfed60b801c799ffc4c32bf3d1254"}, - {file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:222e40d0e2548690405b0b3c7b21d1169117391c2e82c378467ef9ab4c8f0da7"}, - {file = "numpy-1.24.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7215847ce88a85ce39baf9e89070cb860c98fdddacbaa6c0da3ffb31b3350bd5"}, - {file = "numpy-1.24.4-cp311-cp311-win32.whl", hash = "sha256:4979217d7de511a8d57f4b4b5b2b965f707768440c17cb70fbf254c4b225238d"}, - {file = "numpy-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b7b1fc9864d7d39e28f41d089bfd6353cb5f27ecd9905348c24187a768c79694"}, - {file = "numpy-1.24.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1452241c290f3e2a312c137a9999cdbf63f78864d63c79039bda65ee86943f61"}, - {file = "numpy-1.24.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:04640dab83f7c6c85abf9cd729c5b65f1ebd0ccf9de90b270cd61935eef0197f"}, - {file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5425b114831d1e77e4b5d812b69d11d962e104095a5b9c3b641a218abcc050e"}, - {file = "numpy-1.24.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd80e219fd4c71fc3699fc1dadac5dcf4fd882bfc6f7ec53d30fa197b8ee22dc"}, - {file = "numpy-1.24.4-cp38-cp38-win32.whl", hash = "sha256:4602244f345453db537be5314d3983dbf5834a9701b7723ec28923e2889e0bb2"}, - {file = "numpy-1.24.4-cp38-cp38-win_amd64.whl", hash = "sha256:692f2e0f55794943c5bfff12b3f56f99af76f902fc47487bdfe97856de51a706"}, - {file = "numpy-1.24.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2541312fbf09977f3b3ad449c4e5f4bb55d0dbf79226d7724211acc905049400"}, - {file = "numpy-1.24.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9667575fb6d13c95f1b36aca12c5ee3356bf001b714fc354eb5465ce1609e62f"}, - {file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a86ed21e4f87050382c7bc96571755193c4c1392490744ac73d660e8f564a9"}, - {file = "numpy-1.24.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d11efb4dbecbdf22508d55e48d9c8384db795e1b7b51ea735289ff96613ff74d"}, - {file = "numpy-1.24.4-cp39-cp39-win32.whl", hash = "sha256:6620c0acd41dbcb368610bb2f4d83145674040025e5536954782467100aa8835"}, - {file = "numpy-1.24.4-cp39-cp39-win_amd64.whl", hash = "sha256:befe2bf740fd8373cf56149a5c23a0f601e82869598d41f8e188a0e9869926f8"}, - {file = "numpy-1.24.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:31f13e25b4e304632a4619d0e0777662c2ffea99fcae2029556b17d8ff958aef"}, - {file = "numpy-1.24.4-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95f7ac6540e95bc440ad77f56e520da5bf877f87dca58bd095288dce8940532a"}, - {file = "numpy-1.24.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e98f220aa76ca2a977fe435f5b04d7b3470c0a2e6312907b37ba6068f26787f2"}, - {file = "numpy-1.24.4.tar.gz", hash = "sha256:80f5e3a4e498641401868df4208b74581206afbee7cf7b8329daae82676d9463"}, -] - -[[package]] -name = "numpy" -version = "2.0.0" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.9" -files = [ - {file = "numpy-2.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:04494f6ec467ccb5369d1808570ae55f6ed9b5809d7f035059000a37b8d7e86f"}, - {file = "numpy-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2635dbd200c2d6faf2ef9a0d04f0ecc6b13b3cad54f7c67c61155138835515d2"}, - {file = "numpy-2.0.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0a43f0974d501842866cc83471bdb0116ba0dffdbaac33ec05e6afed5b615238"}, - {file = "numpy-2.0.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:8d83bb187fb647643bd56e1ae43f273c7f4dbcdf94550d7938cfc32566756514"}, - {file = "numpy-2.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79e843d186c8fb1b102bef3e2bc35ef81160ffef3194646a7fdd6a73c6b97196"}, - {file = "numpy-2.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d7696c615765091cc5093f76fd1fa069870304beaccfd58b5dcc69e55ef49c1"}, - {file = "numpy-2.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b4c76e3d4c56f145d41b7b6751255feefae92edbc9a61e1758a98204200f30fc"}, - {file = "numpy-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acd3a644e4807e73b4e1867b769fbf1ce8c5d80e7caaef0d90dcdc640dfc9787"}, - {file = "numpy-2.0.0-cp310-cp310-win32.whl", hash = "sha256:cee6cc0584f71adefe2c908856ccc98702baf95ff80092e4ca46061538a2ba98"}, - {file = "numpy-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:ed08d2703b5972ec736451b818c2eb9da80d66c3e84aed1deeb0c345fefe461b"}, - {file = "numpy-2.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad0c86f3455fbd0de6c31a3056eb822fc939f81b1618f10ff3406971893b62a5"}, - {file = "numpy-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7f387600d424f91576af20518334df3d97bc76a300a755f9a8d6e4f5cadd289"}, - {file = "numpy-2.0.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:34f003cb88b1ba38cb9a9a4a3161c1604973d7f9d5552c38bc2f04f829536609"}, - {file = "numpy-2.0.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:b6f6a8f45d0313db07d6d1d37bd0b112f887e1369758a5419c0370ba915b3871"}, - {file = "numpy-2.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f64641b42b2429f56ee08b4f427a4d2daf916ec59686061de751a55aafa22e4"}, - {file = "numpy-2.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7039a136017eaa92c1848152827e1424701532ca8e8967fe480fe1569dae581"}, - {file = "numpy-2.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:46e161722e0f619749d1cd892167039015b2c2817296104487cd03ed4a955995"}, - {file = "numpy-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0e50842b2295ba8414c8c1d9d957083d5dfe9e16828b37de883f51fc53c4016f"}, - {file = "numpy-2.0.0-cp311-cp311-win32.whl", hash = "sha256:2ce46fd0b8a0c947ae047d222f7136fc4d55538741373107574271bc00e20e8f"}, - {file = "numpy-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:fbd6acc766814ea6443628f4e6751d0da6593dae29c08c0b2606164db026970c"}, - {file = "numpy-2.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:354f373279768fa5a584bac997de6a6c9bc535c482592d7a813bb0c09be6c76f"}, - {file = "numpy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d2f62e55a4cd9c58c1d9a1c9edaedcd857a73cb6fda875bf79093f9d9086f85"}, - {file = "numpy-2.0.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:1e72728e7501a450288fc8e1f9ebc73d90cfd4671ebbd631f3e7857c39bd16f2"}, - {file = "numpy-2.0.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:84554fc53daa8f6abf8e8a66e076aff6ece62de68523d9f665f32d2fc50fd66e"}, - {file = "numpy-2.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c73aafd1afca80afecb22718f8700b40ac7cab927b8abab3c3e337d70e10e5a2"}, - {file = "numpy-2.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49d9f7d256fbc804391a7f72d4a617302b1afac1112fac19b6c6cec63fe7fe8a"}, - {file = "numpy-2.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0ec84b9ba0654f3b962802edc91424331f423dcf5d5f926676e0150789cb3d95"}, - {file = "numpy-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:feff59f27338135776f6d4e2ec7aeeac5d5f7a08a83e80869121ef8164b74af9"}, - {file = "numpy-2.0.0-cp312-cp312-win32.whl", hash = "sha256:c5a59996dc61835133b56a32ebe4ef3740ea5bc19b3983ac60cc32be5a665d54"}, - {file = "numpy-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a356364941fb0593bb899a1076b92dfa2029f6f5b8ba88a14fd0984aaf76d0df"}, - {file = "numpy-2.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e61155fae27570692ad1d327e81c6cf27d535a5d7ef97648a17d922224b216de"}, - {file = "numpy-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4554eb96f0fd263041baf16cf0881b3f5dafae7a59b1049acb9540c4d57bc8cb"}, - {file = "numpy-2.0.0-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:903703372d46bce88b6920a0cd86c3ad82dae2dbef157b5fc01b70ea1cfc430f"}, - {file = "numpy-2.0.0-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:3e8e01233d57639b2e30966c63d36fcea099d17c53bf424d77f088b0f4babd86"}, - {file = "numpy-2.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cde1753efe513705a0c6d28f5884e22bdc30438bf0085c5c486cdaff40cd67a"}, - {file = "numpy-2.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821eedb7165ead9eebdb569986968b541f9908979c2da8a4967ecac4439bae3d"}, - {file = "numpy-2.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9a1712c015831da583b21c5bfe15e8684137097969c6d22e8316ba66b5baabe4"}, - {file = "numpy-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9c27f0946a3536403efb0e1c28def1ae6730a72cd0d5878db38824855e3afc44"}, - {file = "numpy-2.0.0-cp39-cp39-win32.whl", hash = "sha256:63b92c512d9dbcc37f9d81b123dec99fdb318ba38c8059afc78086fe73820275"}, - {file = "numpy-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:3f6bed7f840d44c08ebdb73b1825282b801799e325bcbdfa6bc5c370e5aecc65"}, - {file = "numpy-2.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9416a5c2e92ace094e9f0082c5fd473502c91651fb896bc17690d6fc475128d6"}, - {file = "numpy-2.0.0-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:17067d097ed036636fa79f6a869ac26df7db1ba22039d962422506640314933a"}, - {file = "numpy-2.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38ecb5b0582cd125f67a629072fed6f83562d9dd04d7e03256c9829bdec027ad"}, - {file = "numpy-2.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cef04d068f5fb0518a77857953193b6bb94809a806bd0a14983a8f12ada060c9"}, - {file = "numpy-2.0.0.tar.gz", hash = "sha256:cf5d1c9e6837f8af9f92b6bd3e86d513cdc11f60fd62185cc49ec7d1aba34864"}, -] - [[package]] name = "packaging" version = "23.2" @@ -776,21 +685,6 @@ files = [ [package.dependencies] pytest = ">=2.8.1" -[[package]] -name = "pytest-subtests" -version = "0.11.0" -description = "unittest subTest() support and subtests fixture" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-subtests-0.11.0.tar.gz", hash = "sha256:51865c88457545f51fb72011942f0a3c6901ee9e24cbfb6d1b9dc1348bafbe37"}, - {file = "pytest_subtests-0.11.0-py3-none-any.whl", hash = "sha256:453389984952eec85ab0ce0c4f026337153df79587048271c7fd0f49119c07e4"}, -] - -[package.dependencies] -attrs = ">=19.2.0" -pytest = ">=7.0" - [[package]] name = "pytrie" version = "0.4.0" @@ -1232,4 +1126,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "bd78493854ff84fcab6b02bfc14ef1136647e7e65a47bdbcb5d2043a8d2d2aa7" +content-hash = "48fbd12412504923c94e27a8e0a67e37fa003b758cd05b11a94aeec521d06eeb" diff --git a/pyproject.toml b/pyproject.toml index 0d09c408..f39ec07e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,12 +68,6 @@ requests-cache = "^1.2.0" [tool.poetry.group.tests.dependencies] pytest = "^7.4.0" -pytest-subtests = "^0.11.0" -numpy = [ - { "version" = ">=1.24.3", "python" = "<3.12" }, - { "version" = ">=1.25.2", "python" = ">=3.12" } -] -requests-cache = "^1.2.0" [build-system] requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"] From 2287fbcabc4eb69c8653e07d47e770e3dbfaf872 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Fri, 12 Jul 2024 14:52:30 -0700 Subject: [PATCH 06/14] remove non-related style changes --- linkml_runtime/utils/schemaview.py | 1 - 1 file changed, 1 deletion(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 0b85af41..1511ca06 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -1374,7 +1374,6 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo logging.debug(f'{v} takes precedence over {v2} for {induced_slot.name}.{metaslot_name}') if v is None: if metaslot_name == 'range': - v = self.schema.default_range if v is not None: setattr(induced_slot, metaslot_name, v) From fa27f4f72e0b67a96ea801b1c75d1d1cce18e534 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Fri, 12 Jul 2024 15:09:42 -0700 Subject: [PATCH 07/14] revert extra windows testing --- .github/workflows/main.yaml | 8 ++------ tests/test_linkml_model/test_linkml_files.py | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 2b912ed4..b35f4217 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -16,12 +16,8 @@ jobs: exclude: # Test on Windows with only the oldest and newest Python versions - os: windows-latest - python-version: "3.9" - - os: windows-latest - python-version: "3.10" - - os: windows-latest - python-version: "3.11" - + python-version: "3.8" + # See https://github.com/snok/install-poetry#running-on-windows defaults: run: diff --git a/tests/test_linkml_model/test_linkml_files.py b/tests/test_linkml_model/test_linkml_files.py index 5899950d..303631a5 100644 --- a/tests/test_linkml_model/test_linkml_files.py +++ b/tests/test_linkml_model/test_linkml_files.py @@ -65,6 +65,7 @@ def test_format_paths(fmt): """Every format should have an entry in _Path""" assert fmt.name in _Path.items() + def test_no_unmapped_dirs(): """ There should be no additional directories that don't have a mapping for Format. @@ -109,7 +110,7 @@ def test_github_path_exists(source,fmt, release_type): 'source,fmt', EXPECTED_FORMATS ) -def test_github_path_format(source,fmt, release_type): +def test_github_path_format(source, fmt, release_type): if release_type == ReleaseTag.CURRENT: pytest.skip("Need to cache network requests for this") @@ -119,6 +120,7 @@ def test_github_path_format(source,fmt, release_type): # for windows... assert '\\' not in url + @pytest.mark.skip("github paths largely unused") @pytest.mark.parametrize( 'source,fmt', From 2c2883fbe95d33856a5a436f1d4da3310fa3de22 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Tue, 16 Jul 2024 15:02:30 -0700 Subject: [PATCH 08/14] add range_expression calculation to induced_slot method --- linkml_runtime/utils/schemaview.py | 21 +++++++++++++++-- tests/test_utils/input/kitchen_sink.yaml | 19 ++++++++++++++++ tests/test_utils/test_schemaview.py | 29 ++++++++++++++++++++---- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 1511ca06..05372ec0 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -1342,7 +1342,9 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo for anc_sn in reversed(slot_anc_names): anc_slot = self.get_slot(anc_sn, attributes=False) for metaslot_name in SlotDefinition._inherited_slots: + # getattr(x, 'y') is equivalent to x.y. None here means raise an error if x.y is not found if getattr(anc_slot, metaslot_name, None): + # setattr(x, 'y', v) is equivalent to ``x.y = v'' setattr(induced_slot, metaslot_name, copy(getattr(anc_slot, metaslot_name))) COMBINE = { 'maximum_value': lambda x, y: min(x, y), @@ -1350,6 +1352,7 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo } # iterate through all metaslots, and potentially populate metaslot value for induced slot for metaslot_name in self._metaslots_for_slot(): + # inheritance of slots; priority order # slot-level assignment < ancestor slot_usage < self slot_usage v = getattr(induced_slot, metaslot_name, None) @@ -1357,11 +1360,24 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo propagated_from = [] else: propagated_from = self.class_ancestors(class_name, reflexive=True, mixins=True) + union_range = [] for an in reversed(propagated_from): induced_slot.owner = an a = self.get_class(an, imports) + # slot usage of the slot in the ancestor class, last ancestor iterated through here is "self" + # so that self.slot_usage overrides ancestor slot_usage at the conclusion of the loop. anc_slot_usage = a.slot_usage.get(slot_name, {}) + # slot name in the ancestor class + # getattr(x, 'y') is equivalent to x.y. None here means raise an error if x.y is not found v2 = getattr(anc_slot_usage, metaslot_name, None) + # v2 is the value of the metaslot in slot_usage in the ancestor class, which in the loop, means that + # the class itself is the last slot_usage to be considered and applied. + if metaslot_name in ["any_of", "exactly_one_of"]: + if anc_slot_usage != {}: + for x in anc_slot_usage.exactly_one_of + anc_slot_usage.any_of: + if x.range: + if self.get_class(x.range) not in union_range: + union_range.append(self.get_class(x.range)) if v is None: v = v2 else: @@ -1371,12 +1387,13 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo else: if v2 is not None: v = v2 - logging.debug(f'{v} takes precedence over {v2} for {induced_slot.name}.{metaslot_name}') if v is None: if metaslot_name == 'range': v = self.schema.default_range if v is not None: setattr(induced_slot, metaslot_name, v) + if union_range: + setattr(induced_slot, 'range_expression', union_range) if slot.inlined_as_list: slot.inlined = True if slot.identifier or slot.key: @@ -1560,7 +1577,7 @@ def slot_range_as_union(self, slot: SlotDefinition) -> List[ElementName]: """ Returns all applicable ranges for a slot - Typically any given slot has exactly one range, and one metamodel element type, + Typically, any given slot has exactly one range, and one metamodel element type, but a proposed feature in LinkML 1.2 is range expressions, where ranges can be defined as unions :param slot: diff --git a/tests/test_utils/input/kitchen_sink.yaml b/tests/test_utils/input/kitchen_sink.yaml index c3971533..7e971e6f 100644 --- a/tests/test_utils/input/kitchen_sink.yaml +++ b/tests/test_utils/input/kitchen_sink.yaml @@ -63,6 +63,22 @@ classes: - id - name + Alien: + is_a: Thing + slots: + - height_in_m + - type + - related to + slot_usage: + type: + range: OrganismType + required: true + related to: + any_of: + - range: Person + - range: Alien + id_prefixes: [ ks ] + Person: is_a: Thing in_subset: @@ -223,7 +239,10 @@ slots: in_subset: - subset A - subset B + associated with: + range: Thing related to: + is_a: associated with range: Thing type: range: string diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py index b1d9f826..5c195817 100644 --- a/tests/test_utils/test_schemaview.py +++ b/tests/test_utils/test_schemaview.py @@ -31,6 +31,29 @@ AGE_IN_YEARS = 'age in years' +def test_induced_range(): + view = SchemaView(SCHEMA_WITH_IMPORTS) + rangers = view.induced_slot('related to', 'Alien').range_expression + print("class name: Alien") + if rangers: + for ranger in rangers: + print(ranger.name) + rangers = view.induced_slot('related to', 'Person').range_expression + print("class name: Person") + if rangers: + for ranger in rangers: + print(ranger.name) + else: + print(view.induced_slot('related to', 'Person').range) + print("class name: FamilialRelationship") + rangers = view.induced_slot('related to', 'FamilialRelationship').range_expression + if rangers: + for ranger in rangers: + print(ranger.name) + else: + print(view.induced_slot('related to', 'FamilialRelationship').range) + + def test_children_method(): view = SchemaView(SCHEMA_NO_IMPORTS) children = view.get_children("Person") @@ -367,9 +390,9 @@ def test_rollup_rolldown(): element_name = 'Event' roll_up(view, element_name) for slot in view.class_induced_slots(element_name): - logging.debug(slot) + logging.error(slot) induced_slot_names = [s.name for s in view.class_induced_slots(element_name)] - logging.debug(induced_slot_names) + logging.error(induced_slot_names) assert sorted(['started at time', 'ended at time', IS_CURRENT, 'in location', EMPLOYED_AT, 'married to']) == sorted( induced_slot_names) @@ -388,8 +411,6 @@ def test_rollup_rolldown(): assert 'Person' not in view.all_classes() assert 'Adult' in view.all_classes() -import pytest - def test_caching(): """ Determine if cache is reset after modifications made to schema From 52f20d64b0f6de206d5a0ad6131a00786a45b12e Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Tue, 16 Jul 2024 15:49:01 -0700 Subject: [PATCH 09/14] convert ClassDefinition to AnonymousClassExpression to comply with schema --- linkml_runtime/utils/schemaview.py | 41 ++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 05372ec0..8903e790 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -16,6 +16,8 @@ from linkml_runtime.linkml_model.meta import * from linkml_runtime.exceptions import OrderingError from enum import Enum +from linkml_runtime.linkml_model.meta import ClassDefinition, SlotDefinition, ClassDefinitionName +from dataclasses import asdict, is_dataclass, fields logger = logging.getLogger(__name__) @@ -117,6 +119,23 @@ class SchemaUsage(): inferred: bool = None +def to_dict(obj): + """ + Convert a LinkML class (such as ClassDefinition) to a dictionary. + + :param obj: The LinkML class instance to convert. + :return: A dictionary representation of the class. + """ + if is_dataclass(obj): + return asdict(obj) + elif isinstance(obj, list): + return [to_dict(item) for item in obj] + elif isinstance(obj, dict): + return {key: to_dict(value) for key, value in obj.items()} + else: + return obj + + @dataclass class SchemaView(object): """ @@ -580,6 +599,16 @@ def element_by_schema_map(self) -> Dict[ElementName, SchemaDefinitionName]: ix[aname] = schema.name return ix + @lru_cache(None) + def get_anonymous_class_expression(self, class_name: ElementName) -> AnonymousClassExpression: + """ + :param class_name: name of the class to be retrieved + :return: class definition + """ + class_element = self.get_class(class_name, imports=True, strict=True) + + return AnonymousClassExpression(class_element) + @lru_cache(None) def get_class(self, class_name: CLASS_NAME, imports=True, strict=False) -> ClassDefinition: """ @@ -1376,8 +1405,16 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo if anc_slot_usage != {}: for x in anc_slot_usage.exactly_one_of + anc_slot_usage.any_of: if x.range: - if self.get_class(x.range) not in union_range: - union_range.append(self.get_class(x.range)) + class_as_dict = to_dict(self.get_class(x.range)) + an_expr = AnonymousClassExpression() + valid_fields = {field.name for field in fields(an_expr)} + for k, v in class_as_dict.items(): + if k in valid_fields: + setattr(an_expr, k, v) + for k, v in class_as_dict.items(): + setattr(an_expr, k, v) + if an_expr not in union_range: + union_range.append(an_expr) if v is None: v = v2 else: From 0c1242d32166abf4c02c952ecfca5a633031afba Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Tue, 16 Jul 2024 16:05:50 -0700 Subject: [PATCH 10/14] rnages --- linkml_runtime/utils/schemaview.py | 34 ++++++++++++------------ tests/test_utils/input/kitchen_sink.yaml | 5 +--- tests/test_utils/test_schemaview.py | 24 +++++------------ 3 files changed, 25 insertions(+), 38 deletions(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 8903e790..f1da6ca9 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -136,6 +136,17 @@ def to_dict(obj): return obj +def get_anonymous_class_definition(class_as_dict): + an_expr = AnonymousClassExpression() + valid_fields = {field.name for field in fields(an_expr)} + for k, v in class_as_dict.items(): + if k in valid_fields: + setattr(an_expr, k, v) + for k, v in class_as_dict.items(): + setattr(an_expr, k, v) + return an_expr + + @dataclass class SchemaView(object): """ @@ -599,16 +610,6 @@ def element_by_schema_map(self) -> Dict[ElementName, SchemaDefinitionName]: ix[aname] = schema.name return ix - @lru_cache(None) - def get_anonymous_class_expression(self, class_name: ElementName) -> AnonymousClassExpression: - """ - :param class_name: name of the class to be retrieved - :return: class definition - """ - class_element = self.get_class(class_name, imports=True, strict=True) - - return AnonymousClassExpression(class_element) - @lru_cache(None) def get_class(self, class_name: CLASS_NAME, imports=True, strict=False) -> ClassDefinition: """ @@ -1406,13 +1407,7 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo for x in anc_slot_usage.exactly_one_of + anc_slot_usage.any_of: if x.range: class_as_dict = to_dict(self.get_class(x.range)) - an_expr = AnonymousClassExpression() - valid_fields = {field.name for field in fields(an_expr)} - for k, v in class_as_dict.items(): - if k in valid_fields: - setattr(an_expr, k, v) - for k, v in class_as_dict.items(): - setattr(an_expr, k, v) + an_expr = get_anonymous_class_definition(class_as_dict) if an_expr not in union_range: union_range.append(an_expr) if v is None: @@ -1444,6 +1439,11 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo if induced_slot.name in c.slots or induced_slot.name in c.attributes: if c.name not in induced_slot.domain_of: induced_slot.domain_of.append(c.name) + if induced_slot.range is not None and induced_slot.range_expression is None: + class_as_dict = to_dict(self.get_class(induced_slot.range)) + an_expr = get_anonymous_class_definition(class_as_dict) + induced_slot.range_expression = [an_expr] + return induced_slot @lru_cache(None) diff --git a/tests/test_utils/input/kitchen_sink.yaml b/tests/test_utils/input/kitchen_sink.yaml index 7e971e6f..9cc46d1c 100644 --- a/tests/test_utils/input/kitchen_sink.yaml +++ b/tests/test_utils/input/kitchen_sink.yaml @@ -77,6 +77,7 @@ classes: any_of: - range: Person - range: Alien + range: FamilialRelationship id_prefixes: [ ks ] Person: @@ -255,10 +256,6 @@ slots: unit: ucum_code: m - - - - enums: FamilialRelationshipType: permissible_values: diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py index 5c195817..cfa8a616 100644 --- a/tests/test_utils/test_schemaview.py +++ b/tests/test_utils/test_schemaview.py @@ -34,24 +34,14 @@ def test_induced_range(): view = SchemaView(SCHEMA_WITH_IMPORTS) rangers = view.induced_slot('related to', 'Alien').range_expression - print("class name: Alien") - if rangers: - for ranger in rangers: - print(ranger.name) + assert rangers is not None + assert len(rangers) == 2 + ranger_names = [] + for ranger in rangers: + ranger_names.append(ranger.name) + assert ranger_names == ['Person', 'Alien'] rangers = view.induced_slot('related to', 'Person').range_expression - print("class name: Person") - if rangers: - for ranger in rangers: - print(ranger.name) - else: - print(view.induced_slot('related to', 'Person').range) - print("class name: FamilialRelationship") - rangers = view.induced_slot('related to', 'FamilialRelationship').range_expression - if rangers: - for ranger in rangers: - print(ranger.name) - else: - print(view.induced_slot('related to', 'FamilialRelationship').range) + assert rangers is not None def test_children_method(): From b40b6ac3e20a648e504ec7dae7c75a2382944831 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Tue, 16 Jul 2024 17:09:59 -0700 Subject: [PATCH 11/14] initialize empty collections of objects correctly --- linkml_runtime/utils/schemaview.py | 47 +++++++++++++++++++++-------- tests/test_utils/test_schemaview.py | 5 +-- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index f1da6ca9..03926f04 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Mapping, Tuple, TypeVar import warnings +from pprint import pprint from linkml_runtime.utils.namespaces import Namespaces from deprecated.classic import deprecated @@ -1390,7 +1391,8 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo propagated_from = [] else: propagated_from = self.class_ancestors(class_name, reflexive=True, mixins=True) - union_range = [] + + an_expr = AnonymousClassExpression() for an in reversed(propagated_from): induced_slot.owner = an a = self.get_class(an, imports) @@ -1404,12 +1406,30 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo # the class itself is the last slot_usage to be considered and applied. if metaslot_name in ["any_of", "exactly_one_of"]: if anc_slot_usage != {}: - for x in anc_slot_usage.exactly_one_of + anc_slot_usage.any_of: - if x.range: - class_as_dict = to_dict(self.get_class(x.range)) - an_expr = get_anonymous_class_definition(class_as_dict) - if an_expr not in union_range: - union_range.append(an_expr) + for ao in anc_slot_usage.any_of: + if ao.range is not None: + ao_range = self.get_class(ao.range) + if ao_range: + acd = get_anonymous_class_definition(to_dict(ao_range)) + if induced_slot.range_expression is None: + induced_slot.range_expression = AnonymousClassExpression() + if induced_slot.range_expression.any_of is None: + induced_slot.range_expression.any_of = [] + # Check for duplicates before appending + if acd not in induced_slot.range_expression.any_of: + induced_slot.range_expression.any_of.append(acd) + + for eoo in anc_slot_usage.exactly_one_of: + if eoo.range is not None: + eoo_range = self.get_class(eoo.range) + acd = get_anonymous_class_definition(as_dict(eoo_range)) + if induced_slot.range_expression is None: + induced_slot.range_expression = AnonymousClassExpression() + if induced_slot.range_expression.exactly_one_of is None: + induced_slot.range_expression.exactly_one_of = [] + # Check for duplicates before appending + if acd not in induced_slot.range_expression.exactly_one_of: + induced_slot.range_expression.exactly_one_of.append(acd) if v is None: v = v2 else: @@ -1424,8 +1444,6 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo v = self.schema.default_range if v is not None: setattr(induced_slot, metaslot_name, v) - if union_range: - setattr(induced_slot, 'range_expression', union_range) if slot.inlined_as_list: slot.inlined = True if slot.identifier or slot.key: @@ -1439,11 +1457,14 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo if induced_slot.name in c.slots or induced_slot.name in c.attributes: if c.name not in induced_slot.domain_of: induced_slot.domain_of.append(c.name) + # not sure what the logic should be here; should range_expression hold standard ranges in the anyOf slot + # to make usage easier? or is this an exercise for the user to unionize slot.range and + # slot.range_expression.anyOf? if induced_slot.range is not None and induced_slot.range_expression is None: - class_as_dict = to_dict(self.get_class(induced_slot.range)) - an_expr = get_anonymous_class_definition(class_as_dict) - induced_slot.range_expression = [an_expr] - + induced_slot.range_expression = AnonymousClassExpression() + induced_slot.range_expression.any_of = [ + get_anonymous_class_definition(to_dict(self.get_class(induced_slot.range))) + ] return induced_slot @lru_cache(None) diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py index cfa8a616..cc0fbf82 100644 --- a/tests/test_utils/test_schemaview.py +++ b/tests/test_utils/test_schemaview.py @@ -1,5 +1,6 @@ import os import unittest +from pprint import pprint import logging from copy import copy from pathlib import Path @@ -33,14 +34,14 @@ def test_induced_range(): view = SchemaView(SCHEMA_WITH_IMPORTS) - rangers = view.induced_slot('related to', 'Alien').range_expression + rangers = view.induced_slot('related to', 'Alien').range_expression.any_of assert rangers is not None assert len(rangers) == 2 ranger_names = [] for ranger in rangers: ranger_names.append(ranger.name) assert ranger_names == ['Person', 'Alien'] - rangers = view.induced_slot('related to', 'Person').range_expression + rangers = view.induced_slot('related to', 'Person').range_expression.any_of assert rangers is not None From 6c89399d78ca44cfe611b8374a4877c88e295e95 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Tue, 16 Jul 2024 17:56:50 -0700 Subject: [PATCH 12/14] fix up tests - if the range is an ancestor of the any_of construct, don't include it in the range_expression, else do. --- linkml_runtime/utils/schemaview.py | 35 +++++++++++++++-------- tests/test_utils/input/kitchen_sink.yaml | 28 ++++++++++++++++++ tests/test_utils/test_schemaview.py | 36 +++++++++++++++++------- 3 files changed, 78 insertions(+), 21 deletions(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 03926f04..d4ef344f 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -1392,7 +1392,6 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo else: propagated_from = self.class_ancestors(class_name, reflexive=True, mixins=True) - an_expr = AnonymousClassExpression() for an in reversed(propagated_from): induced_slot.owner = an a = self.get_class(an, imports) @@ -1418,7 +1417,6 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo # Check for duplicates before appending if acd not in induced_slot.range_expression.any_of: induced_slot.range_expression.any_of.append(acd) - for eoo in anc_slot_usage.exactly_one_of: if eoo.range is not None: eoo_range = self.get_class(eoo.range) @@ -1457,16 +1455,31 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo if induced_slot.name in c.slots or induced_slot.name in c.attributes: if c.name not in induced_slot.domain_of: induced_slot.domain_of.append(c.name) - # not sure what the logic should be here; should range_expression hold standard ranges in the anyOf slot - # to make usage easier? or is this an exercise for the user to unionize slot.range and - # slot.range_expression.anyOf? - if induced_slot.range is not None and induced_slot.range_expression is None: - induced_slot.range_expression = AnonymousClassExpression() - induced_slot.range_expression.any_of = [ - get_anonymous_class_definition(to_dict(self.get_class(induced_slot.range))) - ] + if induced_slot.range is not None: + if induced_slot.range_expression is None: + induced_slot.range_expression = AnonymousClassExpression() + induced_slot.range_expression.any_of = [] + induced_slot.range_expression.any_of.append( + get_anonymous_class_definition(to_dict(self.get_class(induced_slot.range))) + ) + return induced_slot + else: + any_of_ancestors = [] + if induced_slot.range_expression.any_of is not None: + for ao_range in induced_slot.range_expression.any_of: + ao_range_class = self.get_class(ao_range.name) + ao_anc = self.class_ancestors(ao_range_class.name) + for a in ao_anc: + if a not in any_of_ancestors: + any_of_ancestors.append(a) + if induced_slot.range in any_of_ancestors: + return induced_slot + else: + induced_slot.range_expression.any_of.append( + get_anonymous_class_definition(to_dict(self.get_class(induced_slot.range))) + ) + return induced_slot return induced_slot - @lru_cache(None) def _metaslots_for_slot(self): fake_slot = SlotDefinition('__FAKE') diff --git a/tests/test_utils/input/kitchen_sink.yaml b/tests/test_utils/input/kitchen_sink.yaml index 9cc46d1c..e3d13f2e 100644 --- a/tests/test_utils/input/kitchen_sink.yaml +++ b/tests/test_utils/input/kitchen_sink.yaml @@ -80,6 +80,34 @@ classes: range: FamilialRelationship id_prefixes: [ ks ] + Martian: + is_a: Thing + slots: + - height_in_m + - type + - related to + slot_usage: + type: + range: OrganismType + required: true + related to: + range: Alien + required: true + + Venetian: + is_a: Thing + slots: + - height_in_m + - type + - related to + slot_usage: + related to: + any_of: + - range: Person + - range: Alien + + + Person: is_a: Thing in_subset: diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py index cc0fbf82..393be28a 100644 --- a/tests/test_utils/test_schemaview.py +++ b/tests/test_utils/test_schemaview.py @@ -1,12 +1,10 @@ import os -import unittest -from pprint import pprint + import logging from copy import copy from pathlib import Path -from typing import List import pytest -from unittest import TestCase + from linkml_runtime.dumpers import yaml_dumper from linkml_runtime.linkml_model.meta import SchemaDefinition, ClassDefinition, SlotDefinitionName, SlotDefinition, \ @@ -32,17 +30,35 @@ AGE_IN_YEARS = 'age in years' -def test_induced_range(): +def test_induced_range_expression_with_range(): view = SchemaView(SCHEMA_WITH_IMPORTS) rangers = view.induced_slot('related to', 'Alien').range_expression.any_of assert rangers is not None - assert len(rangers) == 2 + assert len(rangers) == 3 + ranger_names = [ranger.name for ranger in rangers] + expected_names = ['Alien', 'Person', 'FamilialRelationship'] + assert sorted(ranger_names) == sorted(expected_names) + + +def test_induced_range_only_range(): + view = SchemaView(SCHEMA_WITH_IMPORTS) + rangers = view.induced_slot('related to', 'Martian').range_expression.any_of + assert rangers is not None + assert len(rangers) == 1 ranger_names = [] - for ranger in rangers: - ranger_names.append(ranger.name) - assert ranger_names == ['Person', 'Alien'] - rangers = view.induced_slot('related to', 'Person').range_expression.any_of + ranger_names = [ranger.name for ranger in rangers] + expected_names = ['Alien'] + assert sorted(ranger_names) == sorted(expected_names) + + +def test_induced_range_only_range_expression(): + view = SchemaView(SCHEMA_WITH_IMPORTS) + rangers = view.induced_slot('related to', 'Venetian').range_expression.any_of assert rangers is not None + ranger_names = [ranger.name for ranger in rangers] + expected_names = ['Alien', 'Person'] + assert sorted(ranger_names) == sorted(expected_names) + assert len(rangers) == 2 def test_children_method(): From 5cc7f46de88f895d0c361d4030a242d44a0e1d56 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Tue, 16 Jul 2024 17:59:18 -0700 Subject: [PATCH 13/14] fix docstring --- linkml_runtime/utils/schemaview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index d4ef344f..77cbc22a 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -122,7 +122,7 @@ class SchemaUsage(): def to_dict(obj): """ - Convert a LinkML class (such as ClassDefinition) to a dictionary. + Convert a LinkML element (such as ClassDefinition) to a dictionary. :param obj: The LinkML class instance to convert. :return: A dictionary representation of the class. From 91e0a76e1046e5e09c9d17adaf3c194e61c7eb9e Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Wed, 24 Jul 2024 08:26:20 -0700 Subject: [PATCH 14/14] stashing test fixes --- linkml_runtime/utils/schemaview.py | 44 ++++++++++++++++++----------- tests/test_utils/test_schemaview.py | 3 ++ 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 77cbc22a..eface3a7 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -38,9 +38,9 @@ ENUM_NAME = Union[EnumDefinitionName, str] ElementType = TypeVar("ElementType", bound=Element) -ElementNameType = TypeVar("ElementNameType", bound=Union[ElementName,str]) +ElementNameType = TypeVar("ElementNameType", bound=Union[ElementName, str]) DefinitionType = TypeVar("DefinitionType", bound=Definition) -DefinitionNameType = TypeVar("DefinitionNameType", bound=Union[DefinitionName,str]) +DefinitionNameType = TypeVar("DefinitionNameType", bound=Union[DefinitionName, str]) ElementDict = Dict[ElementNameType, ElementType] DefDict = Dict[DefinitionNameType, DefinitionType] @@ -55,7 +55,6 @@ class OrderedBy(Enum): """ - def _closure(f, x, reflexive=True, depth_first=True, **kwargs): if reflexive: rv = [x] @@ -86,7 +85,7 @@ def load_schema_wrap(path: str, **kwargs): schema: SchemaDefinition schema = yaml_loader.load(path, target_class=SchemaDefinition, **kwargs) if "\n" not in path: - # if "\n" not in path and "://" not in path: + # if "\n" not in path and "://" not in path: # only set path if the input is not a yaml string or URL. # Setting the source path is necessary for relative imports; # while initializing a schema with a yaml string is possible, there @@ -137,7 +136,16 @@ def to_dict(obj): return obj -def get_anonymous_class_definition(class_as_dict): +def get_anonymous_class_definition(class_as_dict: ClassDefinition) -> AnonymousClassExpression: + """ + Convert a ClassDefinition to an AnonymousClassExpression, typically for use in defining an Expression object + (e.g. SlotDefinition.range_expression). This method only fills out the fields that are present in the + AnonymousClassExpression class. #TODO: We should consider whether an Expression should share a common ancestor with + the Definition classes. + + :param class_as_dict: The ClassDefinition to convert. + :return: An AnonymousClassExpression. + """ an_expr = AnonymousClassExpression() valid_fields = {field.name for field in fields(an_expr)} for k, v in class_as_dict.items(): @@ -252,7 +260,8 @@ def load_import(self, imp: str, from_schema: SchemaDefinition = None): return schema @lru_cache(None) - def imports_closure(self, imports: bool = True, traverse: Optional[bool] = None, inject_metadata=True) -> List[SchemaDefinitionName]: + def imports_closure(self, imports: bool = True, traverse: Optional[bool] = None, inject_metadata=True) -> List[ + SchemaDefinitionName]: """ Return all imports @@ -320,7 +329,7 @@ def imports_closure(self, imports: bool = True, traverse: Optional[bool] = None, visited.add(sn) # filter duplicates, keeping first entry - closure = list({k:None for k in closure}.keys()) + closure = list({k: None for k in closure}.keys()) if inject_metadata: for s in self.schema_map.values(): @@ -426,7 +435,6 @@ def _order_inheritance(self, elements: DefDict) -> DefDict: return {s.name: s for s in slist} - @lru_cache(None) def all_classes(self, ordered_by=OrderedBy.PRESERVE, imports=True) -> Dict[ClassDefinitionName, ClassDefinition]: """ @@ -871,15 +879,14 @@ def permissible_value_ancestors(self, permissible_value_text: str, @lru_cache(None) def permissible_value_descendants(self, permissible_value_text: str, - enum_name: ENUM_NAME, - reflexive=True, - depth_first=True) -> List[str]: + enum_name: ENUM_NAME, + reflexive=True, + depth_first=True) -> List[str]: """ Closure of permissible_value_children method :enum """ - return _closure(lambda x: self.permissible_value_children(x, enum_name), permissible_value_text, reflexive=reflexive, @@ -1025,7 +1032,7 @@ def is_multivalued(self, slot_name: SlotDefinition) -> bool: :param slot_name: slot to test for multivalued :return boolean: """ - induced_slot = self.induced_slot(slot_name) + induced_slot = self.induced_slot(slot_name.name) return True if induced_slot.multivalued else False @lru_cache(None) @@ -1325,6 +1332,7 @@ def class_slots(self, class_name: CLASS_NAME, imports=True, direct=False, attrib slots_nr.append(s) return slots_nr + @lru_cache(None) def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, imports=True, mangle_name=False) -> SlotDefinition: @@ -1338,6 +1346,7 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo :param slot_name: slot to be queries :param class_name: class used as context :param imports: include imports closure + :param mangle_name: if True, the slot name will be mangled to include the class name :return: dynamic slot constructed by inference """ if class_name: @@ -1480,6 +1489,7 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo ) return induced_slot return induced_slot + @lru_cache(None) def _metaslots_for_slot(self): fake_slot = SlotDefinition('__FAKE') @@ -1604,7 +1614,7 @@ def is_inlined(self, slot: SlotDefinition, imports=True) -> bool: return True elif slot.inlined_as_list: return True - + id_slot = self.get_identifier_slot(range, imports=imports) if id_slot is None: # must be inlined as has no identifier @@ -1660,9 +1670,9 @@ def slot_range_as_union(self, slot: SlotDefinition) -> List[ElementName]: if x.range: range_union_of.append(x.range) return range_union_of - + def get_classes_by_slot( - self, slot: SlotDefinition, include_induced: bool = False + self, slot: SlotDefinition, include_induced: bool = False ) -> List[ClassDefinitionName]: """Get all classes that use a given slot, either as a direct or induced slot. @@ -1979,4 +1989,4 @@ def materialize_derived_schema(self) -> SchemaDefinition: derived_schema.subsets[subset.name] = subset for enum in [deepcopy(e) for e in self.all_enums().values()]: derived_schema.enums[enum.name] = enum - return derived_schema \ No newline at end of file + return derived_schema diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py index 393be28a..d82efe87 100644 --- a/tests/test_utils/test_schemaview.py +++ b/tests/test_utils/test_schemaview.py @@ -39,6 +39,9 @@ def test_induced_range_expression_with_range(): expected_names = ['Alien', 'Person', 'FamilialRelationship'] assert sorted(ranger_names) == sorted(expected_names) + # slot.range still is the default specified in the SlotDefinition....... + assert view.get_slot('related to').range == "Thing" + def test_induced_range_only_range(): view = SchemaView(SCHEMA_WITH_IMPORTS)