Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

first draft conversion of unittest syntax to pytest syntax for schema… #328

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 26 additions & 15 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,53 +14,64 @@ jobs:
os: [ubuntu-latest, windows-latest]
python-version: ["3.8", "3.9", "3.10", "3.12"]
exclude:
- os: windows-latest
python-version: "3.8"
# Test on Windows with only the oldest and newest Python versions
- os: windows-latest
python-version: "3.8"

# See https://github.com/snok/install-poetry#running-on-windows
defaults:
run:
shell: bash

runs-on: ${{ matrix.os }}

steps:

#----------------------------------------------
# install poetry
#----------------------------------------------
- name: Install Poetry
run: pipx install poetry==1.4.0

#----------------------------------------------
# check-out repo and set-up python
#----------------------------------------------
- name: Install poetry
run: pipx install poetry==1.4.0

- name: Check out repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Install dynamic versioning plugin
run: poetry self add "poetry-dynamic-versioning[plugin]"

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
id: setup-python
with:
python-version: ${{ matrix.python-version }}
cache: 'poetry'

#----------------------------------------------
# install your root project, if required
# install dependencies
#----------------------------------------------
- name: Install library
run: poetry install --no-interaction
- name: Install dependencies
run: poetry install --no-interaction --all-extras

#----------------------------------------------
# coverage report
# coverage report
#----------------------------------------------
- name: Generate coverage results
run: |
poetry run coverage run -m pytest
poetry run coverage xml
poetry run coverage report -m
shell: bash

#----------------------------------------------
# upload coverage results
#----------------------------------------------
- name: Upload coverage report
if: github.repository == 'linkml/linkml-runtime'
uses: codecov/codecov-action@v3
with:
name: codecov-results-${{ matrix.os }}-${{ matrix.python-version }}
token: ${{ secrets.CODECOV_TOKEN }}
file: coverage.xml
fail_ci_if_error: false
fail_ci_if_error: false
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@

# TODO: make this mechanism more robust
MODEL_DIR = ../linkml-model/linkml_model/
RUN=poetry run

update_model:
cp -pr $(MODEL_DIR)/* linkml_runtime/linkml_model

test:
poetry run python -m unittest discover
$(RUN) pytest

# temporary measure until linkml-model is synced
linkml_runtime/processing/validation_datamodel.py: linkml_runtime/processing/validation_datamodel.yaml
Expand Down
94 changes: 91 additions & 3 deletions linkml_runtime/utils/schemaview.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pathlib import Path
from typing import Mapping, Tuple, TypeVar
import warnings
from pprint import pprint

from linkml_runtime.utils.namespaces import Namespaces
from deprecated.classic import deprecated
Expand All @@ -16,6 +17,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__)

Expand Down Expand Up @@ -117,6 +120,34 @@ 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):
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):
"""
Expand Down Expand Up @@ -1342,26 +1373,61 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo
for anc_sn in reversed(slot_anc_names):
anc_slot = self.get_slot(anc_sn, attributes=False)
for metaslot_name in SlotDefinition._inherited_slots:
# getattr(x, 'y') is equivalent to x.y. None here means raise an error if x.y is not found
if getattr(anc_slot, metaslot_name, None):
# setattr(x, 'y', v) is equivalent to ``x.y = v''
setattr(induced_slot, metaslot_name, copy(getattr(anc_slot, metaslot_name)))
COMBINE = {
'maximum_value': lambda x, y: min(x, y),
'minimum_value': lambda x, y: max(x, y),
}
# 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)
if not cls:
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)
# 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_class(ao.range)
if ao_range:
acd = get_anonymous_class_definition(to_dict(ao_range))
if induced_slot.range_expression is None:
induced_slot.range_expression = AnonymousClassExpression()
if induced_slot.range_expression.any_of is None:
induced_slot.range_expression.any_of = []
# Check for duplicates before appending
if acd not in induced_slot.range_expression.any_of:
induced_slot.range_expression.any_of.append(acd)
for eoo in anc_slot_usage.exactly_one_of:
if eoo.range is not None:
eoo_range = self.get_class(eoo.range)
acd = get_anonymous_class_definition(as_dict(eoo_range))
if induced_slot.range_expression is None:
induced_slot.range_expression = AnonymousClassExpression()
if induced_slot.range_expression.exactly_one_of is None:
induced_slot.range_expression.exactly_one_of = []
# Check for duplicates before appending
if acd not in induced_slot.range_expression.exactly_one_of:
induced_slot.range_expression.exactly_one_of.append(acd)
if v is None:
v = v2
else:
Expand All @@ -1371,7 +1437,6 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo
else:
if v2 is not None:
v = v2
logging.debug(f'{v} takes precedence over {v2} for {induced_slot.name}.{metaslot_name}')
if v is None:
if metaslot_name == 'range':
v = self.schema.default_range
Expand All @@ -1390,8 +1455,31 @@ def induced_slot(self, slot_name: SLOT_NAME, class_name: CLASS_NAME = None, impo
if induced_slot.name in c.slots or induced_slot.name in c.attributes:
if c.name not in induced_slot.domain_of:
induced_slot.domain_of.append(c.name)
if induced_slot.range is not None:
if induced_slot.range_expression is None:
induced_slot.range_expression = AnonymousClassExpression()
induced_slot.range_expression.any_of = []
induced_slot.range_expression.any_of.append(
get_anonymous_class_definition(to_dict(self.get_class(induced_slot.range)))
)
return induced_slot
else:
any_of_ancestors = []
if induced_slot.range_expression.any_of is not None:
for ao_range in induced_slot.range_expression.any_of:
ao_range_class = self.get_class(ao_range.name)
ao_anc = self.class_ancestors(ao_range_class.name)
for a in ao_anc:
if a not in any_of_ancestors:
any_of_ancestors.append(a)
if induced_slot.range in any_of_ancestors:
return induced_slot
else:
induced_slot.range_expression.any_of.append(
get_anonymous_class_definition(to_dict(self.get_class(induced_slot.range)))
)
return induced_slot
return induced_slot

@lru_cache(None)
def _metaslots_for_slot(self):
fake_slot = SlotDefinition('__FAKE')
Expand Down Expand Up @@ -1560,7 +1648,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:
Expand Down
18 changes: 9 additions & 9 deletions poetry.lock

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

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ pydantic = ">=1.10.2, <3.0.0"
coverage = "^6.2"
requests-cache = "^1.2.0"

[tool.poetry.group.tests.dependencies]
pytest = "^7.4.0"

[build-system]
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"]
build-backend = "poetry_dynamic_versioning.backend"
4 changes: 3 additions & 1 deletion tests/test_linkml_model/test_linkml_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def test_format_paths(fmt):
"""Every format should have an entry in _Path"""
assert fmt.name in _Path.items()


def test_no_unmapped_dirs():
"""
There should be no additional directories that don't have a mapping for Format.
Expand Down Expand Up @@ -109,7 +110,7 @@ def test_github_path_exists(source,fmt, release_type):
'source,fmt',
EXPECTED_FORMATS
)
def test_github_path_format(source,fmt, release_type):
def test_github_path_format(source, fmt, release_type):
if release_type == ReleaseTag.CURRENT:
pytest.skip("Need to cache network requests for this")

Expand All @@ -119,6 +120,7 @@ def test_github_path_format(source,fmt, release_type):
# for windows...
assert '\\' not in url


@pytest.mark.skip("github paths largely unused")
@pytest.mark.parametrize(
'source,fmt',
Expand Down
Loading
Loading