From 5ca9a6e73a5021fb1605cf44365b50fccdd13bbe Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Wed, 2 Oct 2024 12:00:07 -0700 Subject: [PATCH 1/9] add induced_slot method changes --- linkml_runtime/utils/schemaview.py | 67 ++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 1bc97f1e..422f598c 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -1332,6 +1332,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: @@ -1375,6 +1376,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) @@ -1382,11 +1384,45 @@ 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) + for an in reversed(propagated_from): induced_slot.owner = an - a = self.get_class(an, imports) + a = self.get_element(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 ao in anc_slot_usage.any_of: + if ao.range is not None: + ao_range = self.get_element(ao.range) + if ao_range: + print(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_element(eoo.range) + print(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: @@ -1396,9 +1432,9 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo else: # can rewrite below as: # 1. if v2: - # 2. if v2 is not None and + # 2. if v2 is not None and # ( - # (isinstance(v2, (dict, list)) and v2) or + # (isinstance(v2, (dict, list)) and v2) or # (isinstance(v2, JsonObj) and as_dict(v2)) # ) if not is_empty(v2): @@ -1422,8 +1458,33 @@ 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: + 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_element(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_element(induced_slot.range))) + ) + return induced_slot return induced_slot + @lru_cache(None) def _metaslots_for_slot(self): fake_slot = SlotDefinition('__FAKE') From d858bf6ba49cb1e46bb83dfb00810dc37d062965 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Wed, 2 Oct 2024 12:01:17 -0700 Subject: [PATCH 2/9] add induced_slot method changes --- linkml_runtime/utils/schemaview.py | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 422f598c..49df0aee 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -118,6 +118,42 @@ class SchemaUsage(): inferred: bool = None +def to_dict(obj): + """ + 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. + """ + 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 + + +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(): + 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): """ From 8bec52ae6bc1a4c2055cd2c8b9b030073efb7438 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Wed, 2 Oct 2024 13:10:48 -0700 Subject: [PATCH 3/9] fix tests, convert schemaview to pytest --- linkml_runtime/utils/schemaview.py | 96 +- tests/test_utils/test_schemaview.py | 1826 +++++++++++++-------------- 2 files changed, 868 insertions(+), 1054 deletions(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 49df0aee..08c42ea5 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, Optional, Tuple, TypeVar import warnings +from pprint import pprint from linkml_runtime.utils.namespaces import Namespaces from deprecated.classic import deprecated @@ -17,6 +18,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__) @@ -36,9 +39,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] @@ -53,7 +56,6 @@ class OrderedBy(Enum): """ - def _closure(f, x, reflexive=True, depth_first=True, **kwargs): if reflexive: rv = [x] @@ -84,7 +86,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 @@ -146,6 +148,7 @@ def get_anonymous_class_definition(class_as_dict: ClassDefinition) -> AnonymousC :return: An AnonymousClassExpression. """ an_expr = AnonymousClassExpression() + print(class_as_dict) valid_fields = {field.name for field in fields(an_expr)} for k, v in class_as_dict.items(): if k in valid_fields: @@ -154,6 +157,7 @@ def get_anonymous_class_definition(class_as_dict: ClassDefinition) -> AnonymousC setattr(an_expr, k, v) return an_expr + @dataclass class SchemaView(object): """ @@ -265,7 +269,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 @@ -350,7 +355,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(): @@ -456,7 +461,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]: """ @@ -901,15 +905,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, @@ -1368,7 +1371,6 @@ 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: @@ -1412,7 +1414,6 @@ 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) @@ -1420,45 +1421,11 @@ 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) - for an in reversed(propagated_from): induced_slot.owner = an - a = self.get_element(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. + a = self.get_class(an, imports) 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 ao in anc_slot_usage.any_of: - if ao.range is not None: - ao_range = self.get_element(ao.range) - if ao_range: - print(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_element(eoo.range) - print(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: @@ -1494,33 +1461,8 @@ 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: - 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_element(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_element(induced_slot.range))) - ) - return induced_slot return induced_slot - @lru_cache(None) def _metaslots_for_slot(self): fake_slot = SlotDefinition('__FAKE') @@ -1645,7 +1587,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 @@ -1689,7 +1631,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: @@ -1701,9 +1643,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. diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py index 8056ba9e..3b2ff763 100644 --- a/tests/test_utils/test_schemaview.py +++ b/tests/test_utils/test_schemaview.py @@ -3,9 +3,7 @@ import logging from copy import copy from pathlib import Path -from typing import List -from unittest import TestCase - +import pytest from jsonasobj2 import JsonObj from linkml_runtime.dumpers import yaml_dumper @@ -34,998 +32,872 @@ RELATED_TO = 'related to' AGE_IN_YEARS = 'age in years' +@pytest.fixture +def schema_view_no_imports(): + return SchemaView(SCHEMA_NO_IMPORTS) + + +@pytest.fixture +def view(): + """Fixture for SchemaView with imports.""" + return SchemaView(SCHEMA_WITH_IMPORTS) -class SchemaViewTestCase(unittest.TestCase): - def test_children_method(self): +def test_children_method(): view = SchemaView(SCHEMA_NO_IMPORTS) children = view.get_children("Person") - self.assertEqual(children, ['Adult']) + assert children == ['Adult'] - def test_all_aliases(self): +def test_all_aliases(): """ 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"]) + assert "identifier" in aliases["id"] + assert "A" in aliases["subset A"] + assert "B" in aliases["subset B"] + assert "dad" in aliases["Adult"] + +def test_alias_slot(): + """ + Tests the alias slot. - 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. - 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)) - 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) - logger.debug(view.imports_closure()) - self.assertEqual(len(view.imports_closure()), 1) - all_cls = view.all_classes() - logger.debug(f'n_cls = {len(all_cls)}') - - self.assertEqual(list(view.annotation_dict(IS_CURRENT).values()), ['bar']) - logger.debug(view.annotation_dict(EMPLOYED_AT)) - e = view.get_element(EMPLOYED_AT) - logger.debug(e.annotations) - e = view.get_element('has employment history') - logger.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']) - logger.debug(view.annotation_dict(SlotDefinitionName(EMPLOYED_AT))) - element = view.get_element(SlotDefinitionName(EMPLOYED_AT)) - logger.debug(element.annotations) - element = view.get_element(SlotDefinitionName('has employment history')) - logger.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(): - logger.info(f'TN = {tn}') - self.assertEqual('https://w3id.org/linkml/tests/kitchen_sink', t.from_schema) - for sn, s in view.all_slots().items(): - logger.info(f'SN = {sn} RANGE={s.range}') - self.assertEqual('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 - 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) - logger.debug(f'{cn} PARENTS = {view.class_parents(cn)}') - logger.debug(f'{cn} ANCS = {view.class_ancestors(cn)}') - logger.debug(f'{cn} CHILDREN = {view.class_children(cn)}') - logger.debug(f'{cn} DESCS = {view.class_descendants(cn)}') - logger.debug(f'{cn} SCHEMA = {view.in_schema(cn)}') - logger.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) - logger.debug(f' SLOT {sn} R: {slot.range} U: {view.get_uri(sn)} ANCS: {view.slot_ancestors(sn)}') - induced_slot = view.induced_slot(sn, cn) - logger.debug(f' INDUCED {sn}={induced_slot}') - # range should always be populated: See https://github.com/linkml/linkml/issues/733 - self.assertIsNotNone(induced_slot.range) - - - logger.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') - logger.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) - logger.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']) - logger.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(): - logger.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', + """ + + 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 # Assert that alias is not None + + postal_code_slot = view.induced_slot('postal code', 'Address') + assert postal_code_slot.name == 'postal code' # Assert name is 'postal code' + assert postal_code_slot.alias == 'zip' # Assert alias is 'zip' + + +def test_schemaview_enums(): + view = SchemaView(SCHEMA_NO_IMPORTS) + + # Test for ValueError when passing incorrect parameters + 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 "TABBY" in view.permissible_value_children(pv, e.name) + assert "LION" in view.permissible_value_children(pv, e.name) + assert "EAGLE" not in view.permissible_value_descendants(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 view.permissible_value_descendants(pv, e.name) == ["ANGRY_LION"] + + for cn, c in view.all_classes().items(): + if c.name == "Adult": + assert view.class_ancestors(c.name) == ['Adult', 'Person', 'HasAliases', 'Thing'] + + +def test_schemaview(schema_view_no_imports): + view = schema_view_no_imports + logger.debug(view.imports_closure()) + assert len(view.imports_closure()) == 1 + + all_cls = view.all_classes() + logger.debug(f'n_cls = {len(all_cls)}') + + assert list(view.annotation_dict(IS_CURRENT).values()) == ['bar'] + logger.debug(view.annotation_dict(EMPLOYED_AT)) + e = view.get_element(EMPLOYED_AT) + logger.debug(e.annotations) + e = view.get_element('has employment history') + logger.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'] + logger.debug(view.annotation_dict(SlotDefinitionName(EMPLOYED_AT))) + element = view.get_element(SlotDefinitionName(EMPLOYED_AT)) + logger.debug(element.annotations) + element = view.get_element(SlotDefinitionName('has employment history')) + logger.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(): + logger.info(f'TN = {tn}') + assert t.from_schema == 'https://w3id.org/linkml/tests/kitchen_sink' + + for sn, s in view.all_slots().items(): + logger.info(f'SN = {sn} RANGE={s.range}') + assert s.from_schema == 'https://w3id.org/linkml/tests/kitchen_sink' + rng = view.induced_slot(sn).range + assert rng is not None + + for cn in all_cls.keys(): + c = view.get_class(cn) + assert c.from_schema == 'https://w3id.org/linkml/tests/kitchen_sink' + logger.debug(f'{cn} PARENTS = {view.class_parents(cn)}') + logger.debug(f'{cn} ANCS = {view.class_ancestors(cn)}') + logger.debug(f'{cn} CHILDREN = {view.class_children(cn)}') + logger.debug(f'{cn} DESCS = {view.class_descendants(cn)}') + logger.debug(f'{cn} SCHEMA = {view.in_schema(cn)}') + logger.debug(f' SLOTS = {view.class_slots(cn)}') + for sn in view.class_slots(cn): + slot = view.get_slot(sn) + assert slot.from_schema == 'https://w3id.org/linkml/tests/kitchen_sink' + logger.debug(f' SLOT {sn} R: {slot.range} U: {view.get_uri(sn)} ANCS: {view.slot_ancestors(sn)}') + induced_slot = view.induced_slot(sn, cn) + logger.debug(f' INDUCED {sn}={induced_slot}') + assert induced_slot.range is not None + + logger.debug(f'ALL = {view.all_elements().keys()}') + + # -- TEST ANCESTOR/DESCENDANTS FUNCTIONS -- + + assert set(view.class_ancestors(COMPANY)) == {'Company', 'Organization', 'HasAliases', 'Thing'} + assert set(view.class_ancestors(COMPANY, reflexive=False)) == {'Organization', 'HasAliases', 'Thing'} + assert set(view.class_descendants('Thing')) == {'Thing', 'Person', 'Organization', COMPANY, 'Adult'} + + # -- TEST CLASS SLOTS -- + + assert set(view.class_slots('Person')) == { + 'id', 'name', 'has employment history', 'has familial relationships', + 'has medical history', AGE_IN_YEARS, 'addresses', 'has birth event', + 'reason_for_happiness', 'aliases' + } + assert view.class_slots('Person') == view.class_slots('Adult') + assert set(view.class_slots(COMPANY)) == { + 'id', 'name', 'ceo', 'aliases' + } + + assert view.get_class(AGENT).class_uri == 'prov:Agent' + assert view.get_uri(AGENT) == 'prov:Agent' + logger.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 + 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 + 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 + assert view.induced_slot('name', c).owner == c + + for c in ['Event', 'EmploymentEvent', 'MedicalEvent']: + s = view.induced_slot('started at time', c) + logger.debug(f's={s.range} // c = {c}') + assert s.range == 'date' + assert s.slot_uri == 'prov:startedAtTime' + assert s.owner == c + + c_induced = view.induced_class(c) + 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' + assert set(view.induced_slot('name').domain_of) == {'Thing', 'Place'} + + a = view.get_class(ACTIVITY) + assert set(a.exact_mappings) == {'prov:Activity'} + logger.debug(view.get_mappings(ACTIVITY, expand=True)) + assert set(view.get_mappings(ACTIVITY)['exact']) == {'prov:Activity'} + assert set(view.get_mappings(ACTIVITY, expand=True)['exact']) == {'http://www.w3.org/ns/prov#Activity'} + + u = view.usage_index() + for k, v in u.items(): + logger.debug(f' {k} = {v}') + + assert SchemaUsage(used_by='FamilialRelationship', slot=RELATED_TO, + metaslot='range', used='Person', inferred=False) in u['Person'] + assert [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', + 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() - logger.debug(f'LEAVES={leaves}') - self.assertIn('MedicalEvent', leaves) - roots = view.class_roots() - logger.debug(f'ROOTS={roots}') - self.assertIn('Dataset', roots) - ds_slots = view.class_slots('Dataset') - logger.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') - logger.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): - logger.debug(slot) - induced_slot_names = [s.name for s in view.class_induced_slots(element_name)] - logger.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) - logger.debug(f'{element_name}') - logger.debug(f' {element_name} SLOTS(i) = {view.class_slots(element_name)}') - logger.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"}]: + )] == u['MarriageEvent'] + assert [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() + logger.debug(f'LEAVES={leaves}') + assert 'MedicalEvent' in leaves + roots = view.class_roots() + logger.debug(f'ROOTS={roots}') + assert 'Dataset' in roots + ds_slots = view.class_slots('Dataset') + logger.debug(ds_slots) + assert len(ds_slots) == 3 + assert len(['persons', 'companies', 'activities']) == len(ds_slots) + for sn in ds_slots: + s = view.induced_slot(sn, 'Dataset') + logger.debug(s) + +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): + logger.debug(slot) + induced_slot_names = [s.name for s in view.class_induced_slots(element_name)] + logger.debug(induced_slot_names) + assert len(['started at time', 'ended at time', IS_CURRENT, 'in location', EMPLOYED_AT, 'married to']) == len(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(): + logger.debug(f'{element_name}') + logger.debug(f' {element_name} SLOTS(i) = {view.class_slots(element_name)}') + logger.debug(f' {element_name} SLOTS(d) = {view.class_slots(element_name, direct=True)}') + assert len(view.class_slots(element_name)) == len(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() + +def test_caching(): + """ + Determine if cache is reset after modifications made to schema + """ + schema = SchemaDefinition(id='test', name='test') + view = SchemaView(schema) + assert len([]) == len(view.all_classes()) + view.add_class(ClassDefinition('X')) + assert len(['X']) == len(view.all_classes()) + view.add_class(ClassDefinition('Y')) + assert len(['X', 'Y']) == len(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 len(['X', 'Y']) == len(view.all_classes()) + # inform the view modifications have been made + view.set_modified() + # should be in sync + assert len(['X', 'Y', 'Z']) == len(view.all_classes()) + # recommended way to make updates + view.delete_class('X') + # cache will be up to date + assert len(['Y', 'Z']) == len(view.all_classes()) + view.add_class(ClassDefinition('W')) + assert len(['Y', 'Z', 'W']) == len(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) - logger.debug(view.imports_closure()) - self.assertCountEqual(['kitchen_sink', 'core', 'linkml:types'], view.imports_closure()) - for t in view.all_types().keys(): - logger.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') - logger.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') - logger.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_imports_relative(self): - """ - Relative imports from relative imports should evaluate relative to the *importing* schema, - not the *origin* schema. - - See - - input/imports_relative/README.md for an explanation of the test schema - """ - sv = SchemaView(SCHEMA_RELATIVE_IMPORT_TREE) - closure = sv.imports_closure(imports=True) - - assert len(closure) == len(sv.schema_map.keys()) - assert closure == [ - 'linkml:types', - '../neighborhood_parent', - 'neighbor', - '../parent', - '../L1_0_1/L2_0_1_0/grandchild', - '../../L0_1/L1_1_0/L2_1_0_0/apple', - '../../L0_1/L1_1_0/L2_1_0_0/index', - '../../L0_1/L1_1_0/L2_1_0_1/banana', - '../../L0_1/L1_1_0/L2_1_0_1/index', - '../../L0_1/L1_1_0/index', - '../../L0_1/cousin', - '../L1_0_1/dupe', - './L2_0_0_0/child', - './L2_0_0_1/child', - 'main' + for im in [None, {}, {"core": "core"}]: + view = SchemaView(SCHEMA_WITH_IMPORTS, importmap=im) + view.all_classes() + assert view.imports_closure().sort() == ['kitchen_sink', 'core', 'linkml:types'].sort() # Assert imports closure + assert ACTIVITY in view.all_classes() # Assert ACTIVITY is in all classes + assert ACTIVITY not in view.all_classes(imports=False) # Assert ACTIVITY is not in classes without imports + + +def test_imports(view): + """view should by default dynamically include imports chain""" + assert (view.schema.source_file is not None) + logger.debug(view.imports_closure()) + assert set(view.imports_closure()) == {'kitchen_sink', 'core', 'linkml:types'} + + for t in view.all_types().keys(): + logger.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 len(view.type_ancestors('SymbolString')) == len(['SymbolString', 'string']) + + 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 + 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 + 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' + logger.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' + logger.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 set(e.include[0].reachable_from.source_nodes) == {'GO:0007049', 'GO:0022403'} + + # 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 len(view.all_classes()) == len(view2.all_classes()) + assert len(view.all_classes(imports=False)) == len(view2.all_classes(imports=False)) + + +def test_imports_closure_order(): + """Imports should override in a python-like order.""" + 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.""" + 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_imports_relative(): + """Relative imports from relative imports should evaluate relative to the *importing* schema.""" + sv = SchemaView(SCHEMA_RELATIVE_IMPORT_TREE) + closure = sv.imports_closure(imports=True) + + assert len(closure) == len(sv.schema_map.keys()) + assert closure == [ + 'linkml:types', + '../neighborhood_parent', + 'neighbor', + '../parent', + '../L1_0_1/L2_0_1_0/grandchild', + '../../L0_1/L1_1_0/L2_1_0_0/apple', + '../../L0_1/L1_1_0/L2_1_0_0/index', + '../../L0_1/L1_1_0/L2_1_0_1/banana', + '../../L0_1/L1_1_0/L2_1_0_1/index', + '../../L0_1/L1_1_0/index', + '../../L0_1/cousin', + '../L1_0_1/dupe', + './L2_0_0_0/child', + './L2_0_0_1/child', + 'main' + ] + + # check that we can actually get the classes from the same-named schema + classes = sv.all_classes(imports=True) + assert 'L110Index' in classes + assert 'L2100Index' in classes + assert 'L2101Index' in classes + + +def test_direct_remote_imports(): + """Tests that building a SchemaView directly from a remote URL works.""" + 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 len(all_c) == len(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. + """ + 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 all_classes == 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, expected): + 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_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. + """ + 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 slot.range == 'string', f"in: {cn}.{sn}" + + +def test_ambiguous_attributes(): + 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})) + + 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 + 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 not in ['structured_alias', 'UnitOfMeasure', 'ValidationReport', 'ValidationResult']: + 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(actual_result) == sorted(expected_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 == r"\d+[\.\d+] (centimeter|meter|inch)" + assert weight_slot.pattern == r"\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 == r"\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 == r"\d+[\.\d+] (kg|g|lbs|stone)" + + +def test_mergeimports(): + sv = SchemaView(SCHEMA_WITH_IMPORTS, merge_imports=False) + classes_list = list(sv.schema.classes.keys()) + assert "activity" not in classes_list + + slots_list = list(sv.schema.slots.keys()) + assert "was generated by" not in slots_list + + prefixes_list = list(sv.schema.prefixes.keys()) + assert prefixes_list == ["pav", "dce", "lego", "linkml", "biolink", "ks", "RO", "BFO", "tax"] + + 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(prefixes_list) == sorted( + ["pav", "dce", "lego", "linkml", "biolink", "ks", "RO", "BFO", "tax", "core", "prov", "xsd", "schema", "shex"]) + + +def test_is_inlined(): + schema_path = os.path.join(INPUT_DIR, "schemaview_is_inlined.yaml") + sv = SchemaView(schema_path) + cases = [ + ("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) + assert sv.is_inlined(slot) == expected_result + + +def test_materialize_nonscalar_slot_usage(): + schema_path = os.path.join(INPUT_DIR, "DJ_controller_schema.yaml") + sv = SchemaView(schema_path) + cls = sv.induced_class("DJController") + + assert cls.attributes["jog_wheels"].range == "integer" + assert isinstance(cls.attributes["jog_wheels"].examples, list) + for example in cls.attributes["jog_wheels"].examples: + assert example.value == "2" + for example in cls.attributes["volume_faders"].examples: + assert example.value == "4" + for example in cls.attributes["crossfaders"].examples: + assert example.value == "1" - # check that we can actually get the classes from the same-named schema - classes = sv.all_classes(imports=True) - assert 'L110Index' in classes - assert 'L2100Index' in classes - assert 'L2101Index' in classes - - 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 \ - 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) - - def test_materialize_nonscalar_slot_usage(self): - """ - ``slot_usage`` overrides values in the base slot definition without - clobbering unrelated, nonscalar values. - - See: - - https://github.com/linkml/linkml/issues/2224 - - https://github.com/linkml/linkml-runtime/pull/335 - """ - schema_path = os.path.join(INPUT_DIR, "DJ_controller_schema.yaml") - sv = SchemaView(schema_path) - cls = sv.induced_class("DJController") - - # jog_wheels is a slot asserted at the schema level - # check that the range (scalar value) is being materialized properly - assert cls.attributes["jog_wheels"].range == "integer" - # check that the examples (list) is being materialized properly - assert isinstance(cls.attributes["jog_wheels"].examples, list) - for example in cls.attributes["jog_wheels"].examples: - assert example.value == "2" - for example in cls.attributes["volume_faders"].examples: - assert example.value == "4" - for example in cls.attributes["crossfaders"].examples: - assert example.value == "1" - # check that the annotations (dictionary) is being materialized properly - assert isinstance(cls.attributes["jog_wheels"].annotations, JsonObj) - assert cls.attributes["jog_wheels"].annotations.expected_value.value == "an integer between 0 and 4" - assert cls.attributes["volume_faders"].annotations.expected_value.value == "an integer between 0 and 8" - - # examples being overridden by slot_usage modification - assert cls.attributes["tempo"].examples == [Example(value='120.0'), Example(value='144.0'), Example(value='126.8'), Example(value='102.6')] - # annotations remain the same / propagated as is from schema-level - # definition of `tempo` slot - assert cls.attributes["tempo"].annotations.expected_value.value == "a number between 0 and 200" - assert cls.attributes["tempo"].annotations.preferred_unit.value == "BPM" - - assert cls.attributes["tempo"].domain_of == ["DJController"] - # ensure that domain_of is not being populated in slot_usage - # test for https://github.com/linkml/linkml/pull/2262 from upstream linkml - assert cls.slot_usage["tempo"].domain_of == [] - - -if __name__ == '__main__': - unittest.main() + assert isinstance(cls.attributes["jog_wheels"].annotations, JsonObj) + assert cls.attributes["jog_wheels"].annotations.expected_value.value == "an integer between 0 and 4" + assert cls.attributes["volume_faders"].annotations.expected_value.value == "an integer between 0 and 8" + + assert cls.attributes["tempo"].examples == [Example(value='120.0'), Example(value='144.0'), Example(value='126.8'), + Example(value='102.6')] + assert cls.attributes["tempo"].annotations.expected_value.value == "a number between 0 and 200" + assert cls.attributes["tempo"].annotations.preferred_unit.value == "BPM" + assert cls.attributes["tempo"].domain_of == ["DJController"] + assert cls.slot_usage["tempo"].domain_of == [] \ No newline at end of file From fe31808a7f6da8259d72adf9e9d8147cee04b76f Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Wed, 2 Oct 2024 13:16:23 -0700 Subject: [PATCH 4/9] add pytest to Makefile --- Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Makefile b/Makefile index 19a667f0..21233fbe 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,8 @@ update_model: test: poetry run python -m unittest discover + poetry run pytest + # temporary measure until linkml-model is synced linkml_runtime/processing/validation_datamodel.py: linkml_runtime/processing/validation_datamodel.yaml From 91330a899f3614fe99e426991e2db93d46fdf320 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Wed, 2 Oct 2024 13:23:49 -0700 Subject: [PATCH 5/9] remove unnecessary method resulting from branch split --- linkml_runtime/utils/schemaview.py | 22 ---------------------- tests/test_utils/test_schemaview.py | 1 - 2 files changed, 23 deletions(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 08c42ea5..d5c51bed 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -136,28 +136,6 @@ def to_dict(obj): else: return obj - -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() - print(class_as_dict) - 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): """ diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py index 3b2ff763..711c6778 100644 --- a/tests/test_utils/test_schemaview.py +++ b/tests/test_utils/test_schemaview.py @@ -1,5 +1,4 @@ import os -import unittest import logging from copy import copy from pathlib import Path From 71785b824ed29ba4d2c5d551426e0ba36554d3e8 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Wed, 2 Oct 2024 13:24:33 -0700 Subject: [PATCH 6/9] remove unnecessasry imports resulting from a branch split --- linkml_runtime/utils/schemaview.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index d5c51bed..45ad19e4 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -8,7 +8,6 @@ from pathlib import Path from typing import Mapping, Optional, Tuple, TypeVar import warnings -from pprint import pprint from linkml_runtime.utils.namespaces import Namespaces from deprecated.classic import deprecated @@ -18,8 +17,6 @@ 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__) From 9d0579ec49a63cdebb1a7452cdd014efb92e8865 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Wed, 2 Oct 2024 13:24:59 -0700 Subject: [PATCH 7/9] remove unnecessasry imports resulting from a branch split --- linkml_runtime/utils/schemaview.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/linkml_runtime/utils/schemaview.py b/linkml_runtime/utils/schemaview.py index 45ad19e4..b937964d 100644 --- a/linkml_runtime/utils/schemaview.py +++ b/linkml_runtime/utils/schemaview.py @@ -117,22 +117,6 @@ class SchemaUsage(): inferred: bool = None -def to_dict(obj): - """ - 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. - """ - 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): """ From 1d0fd149c19805043d584f81d0b694d644836be9 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Fri, 11 Oct 2024 10:44:13 -0700 Subject: [PATCH 8/9] adjust fixtures according to pr --- Makefile | 1 - tests/test_utils/test_schemaview.py | 63 +++++++++++++---------------- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/Makefile b/Makefile index 21233fbe..a7d4a8e2 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,6 @@ update_model: cp -pr $(MODEL_DIR)/* linkml_runtime/linkml_model test: - poetry run python -m unittest discover poetry run pytest diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py index 711c6778..3d4901f8 100644 --- a/tests/test_utils/test_schemaview.py +++ b/tests/test_utils/test_schemaview.py @@ -42,24 +42,25 @@ def view(): return SchemaView(SCHEMA_WITH_IMPORTS) -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"] - -def test_alias_slot(): +def test_children_method(schema_view_no_imports): + view = schema_view_no_imports + children = view.get_children("Person") + assert children == ['Adult'] + + +def test_all_aliases(vischema_view_no_imports): + """ + This tests the aliases slot (not: alias) + :return: + """ + view = schema_view_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"] + +def test_alias_slot(schema_view_no_imports): """ Tests the alias slot. @@ -67,9 +68,7 @@ def test_alias_slot(): name field if not present. """ - - view = SchemaView(SCHEMA_NO_IMPORTS) - + view = schema_view_no_imports for c in view.all_classes().values(): for s in view.class_induced_slots(c.name): assert s.alias is not None # Assert that alias is not None @@ -79,8 +78,8 @@ def test_alias_slot(): assert postal_code_slot.alias == 'zip' # Assert alias is 'zip' -def test_schemaview_enums(): - view = SchemaView(SCHEMA_NO_IMPORTS) +def test_schemaview_enums(schema_view_no_imports): + view = schema_view_no_imports # Test for ValueError when passing incorrect parameters with pytest.raises(ValueError): @@ -322,9 +321,9 @@ def test_schemaview(schema_view_no_imports): s = view.induced_slot(sn, 'Dataset') logger.debug(s) -def test_rollup_rolldown(): +def test_rollup_rolldown(schema_view_no_imports): # no import schema - view = SchemaView(SCHEMA_NO_IMPORTS) + view = schema_view_no_imports element_name = 'Event' roll_up(view, element_name) for slot in view.class_induced_slots(element_name): @@ -374,17 +373,15 @@ def test_caching(): assert len(['Y', 'Z', 'W']) == len(view.all_classes()) -def test_import_map(): +def test_import_map(view): """ 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() for im in [None, {}, {"core": "core"}]: - view = SchemaView(SCHEMA_WITH_IMPORTS, importmap=im) view.all_classes() assert view.imports_closure().sort() == ['kitchen_sink', 'core', 'linkml:types'].sort() # Assert imports closure assert ACTIVITY in view.all_classes() # Assert ACTIVITY is in all classes @@ -483,9 +480,8 @@ def test_imports(view): assert height.unit.ucum_code == "m" -def test_imports_from_schemaview(): +def test_imports_from_schemaview(view): """view should by default dynamically include imports chain""" - view = SchemaView(SCHEMA_WITH_IMPORTS) view2 = SchemaView(view.schema) assert len(view.all_classes()) == len(view2.all_classes()) assert len(view.all_classes(imports=False)) == len(view2.all_classes(imports=False)) @@ -575,11 +571,10 @@ def test_direct_remote_imports_additional(): class_count = len(view.all_classes()) assert class_count > 0 -def test_merge_imports(): +def test_merge_imports(view): """ 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) @@ -793,8 +788,7 @@ def test_metamodel_in_schemaview(): assert exp_slot_uri is not None -def test_get_classes_by_slot(): - sv = SchemaView(SCHEMA_WITH_IMPORTS) +def test_get_classes_by_slot(view): slot = sv.get_slot(AGE_IN_YEARS) actual_result = sv.get_classes_by_slot(slot) expected_result = ["Person"] @@ -833,6 +827,7 @@ def test_materialize_patterns_attribute(): def test_mergeimports(): + # note the change here to include an extra param not in the fixture sv = SchemaView(SCHEMA_WITH_IMPORTS, merge_imports=False) classes_list = list(sv.schema.classes.keys()) assert "activity" not in classes_list From 1b5e9f7bf2494bb71c102f89938a3093dd35e241 Mon Sep 17 00:00:00 2001 From: Sierra Taylor Moxon Date: Fri, 11 Oct 2024 10:49:52 -0700 Subject: [PATCH 9/9] adjust fixtures according to pr --- tests/test_utils/test_schemaview.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_utils/test_schemaview.py b/tests/test_utils/test_schemaview.py index 3d4901f8..c9f28ff8 100644 --- a/tests/test_utils/test_schemaview.py +++ b/tests/test_utils/test_schemaview.py @@ -48,7 +48,7 @@ def test_children_method(schema_view_no_imports): assert children == ['Adult'] -def test_all_aliases(vischema_view_no_imports): +def test_all_aliases(schema_view_no_imports): """ This tests the aliases slot (not: alias) :return: @@ -373,15 +373,17 @@ def test_caching(): assert len(['Y', 'Z', 'W']) == len(view.all_classes()) -def test_import_map(view): +def test_import_map(): """ Path to import file should be configurable """ for im in [{"core": "/no/such/file"}, {"linkml:": "/no/such/file"}]: + view = SchemaView(SCHEMA_WITH_IMPORTS, importmap=im) with pytest.raises(FileNotFoundError): view.all_classes() for im in [None, {}, {"core": "core"}]: + view = SchemaView(SCHEMA_WITH_IMPORTS, importmap=im) view.all_classes() assert view.imports_closure().sort() == ['kitchen_sink', 'core', 'linkml:types'].sort() # Assert imports closure assert ACTIVITY in view.all_classes() # Assert ACTIVITY is in all classes @@ -789,12 +791,12 @@ def test_metamodel_in_schemaview(): def test_get_classes_by_slot(view): - slot = sv.get_slot(AGE_IN_YEARS) - actual_result = sv.get_classes_by_slot(slot) + slot = view.get_slot(AGE_IN_YEARS) + actual_result = view.get_classes_by_slot(slot) expected_result = ["Person"] assert sorted(actual_result) == sorted(expected_result) - actual_result = sv.get_classes_by_slot(slot, include_induced=True) + actual_result = view.get_classes_by_slot(slot, include_induced=True) expected_result = ["Person", "Adult"] assert sorted(actual_result) == sorted(expected_result)