diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..ff2a019 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (if applicable, please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..1882c69 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,36 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: feature +assignees: '' + +--- + +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: feature +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**How important is this feature?** Select from the options below: +• Low - it's an enhancement but not crucial for work +• Medium - can do work without it; but it's important (e.g. to save time or for convenience) +• Important - it's a blocker and can't do work without it + +**When will use cases depending on this become relevant?** Select from the options below: +• Short-term - 2-4 weeks +• Mid-term - 2-4 months +• Long-term - 6 months - 1 year + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..1334286 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,81 @@ +name: Build + + +on: + push: + branches: [ main ] + +jobs: + unittests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pipenv + pipenv install --dev + + - name: Test with unittest + run: | + pipenv run python -m unittest discover -p 'test_*.py' + + commits: + needs: unittests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Update requirements + run: | + python -m pip install --upgrade pip + pip install pipenv-to-requirements + pipenv_to_requirements + git add requirements*.txt + + if [[ ! -z $(git status -s requirements*.txt) ]] + then + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git commit -m 'Automatically generated requirements.txt and requirements-dev.txt' requirements*.txt + git push + fi + + - name: Check in test outputs + run: | + pip install pipenv + pipenv install --dev + pipenv run python -m unittest discover -p 'test_*.py' + find tests -name output -exec git add --force {} \; + if [[ ! -z $(git status -s tests) ]] + then + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git commit -m 'Automated adding outputs from tests' tests + git push + fi + + - name: Check in test updated notebooks + run: | + if [[ ! -z $(git status -s notebooks) ]] + then + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git commit -m 'Automated adding updated notebooks' notebooks + git push + fi diff --git a/.github/workflows/pr-test.yaml b/.github/workflows/pr-test.yaml new file mode 100644 index 0000000..7a51dcc --- /dev/null +++ b/.github/workflows/pr-test.yaml @@ -0,0 +1,33 @@ +name: Pull request unit tests + +on: + pull_request: + branches: [ main ] + +jobs: + + build-pipenv: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.7.1, 3.8, 3.9] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install pipenv + uses: dschep/install-pipenv-action@v1 + + - name: Install dependencies + run: | + pipenv install --dev + + - name: Test with unittest + run: | + pipenv run python -m unittest discover -p 'test_*.py' diff --git a/.github/workflows/pypi-publish.yaml b/.github/workflows/pypi-publish.yaml new file mode 100644 index 0000000..eb47fa6 --- /dev/null +++ b/.github/workflows/pypi-publish.yaml @@ -0,0 +1,34 @@ +name: Publish Python Package + +on: + release: + types: [created] + +jobs: + build-n-publish: + name: Build and publish Python 🐍 distributions 📦 to PyPI + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install wheel + + - name: build a binary wheel dist + run: | + rm -fr dist + python setup.py bdist_wheel sdist + + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@v1.2.2 + with: + user: __token__ + password: ${{ secrets.pypi_password }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1324cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,145 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Specific to this project +temp/ +output/ + +# Test config +tests/test_config.ini + +# Don't lock +Pipfile.lock + +# No Pycharm +.idea/ + +# generated +tests/model/kitchen_sink_api_test.yaml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3719771 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +test: + pipenv run python -m unittest discover -p 'test_*.py' + +prep_tests: tests/model/kitchen_sink_api.yaml + +%_api.yaml: %.yaml + pipenv run gen-api --container-class Dataset $< > $@.tmp && mv $@.tmp $@ diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..46f9e70 --- /dev/null +++ b/Pipfile @@ -0,0 +1,16 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +linkml-runtime = "*" +jsonpath-ng = "*" +"ruamel.yaml" = "*" +jsonpatch = "*" +jinja2 = "*" + +[dev-packages] + +[pipenv] +allow_prereleases = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8b05e8 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# linkml-runtime-api + +An extension to linkml-runtime to provide an API over runtime data objects. + +Documentation will be added in the main [linkml](https://linkml.io/linkml/) repo + +* The ability to *query* objects that instantiate linkml classes +* The ability to *change* (apply changes to) objects that instantiate linkml classes + +See tests for examples + +## Changes API + +See linkml_runtime_api.changes + +Example: + +```python +# your datamodel here: +from kitchen_sink import Dataset, Person, FamilialRelationship + +# setup - load schema +schemaview = SchemaView('kitchen_sink.yaml') + +dataset = yaml_loader.load('my_dataset.yaml', target_class=Dataset) + +# first create classes using your normal LinkML datamodel +person = Person(id='P:222', + name='foo', + has_familial_relationships=[FamilialRelationship(related_to='P:001', + type='SIBLING_OF')]) + +changer = ObjectChanger(schemaview=schemaview) +change = AddPerson(value=person) +changer.apply(change, dataset) +``` + +Currently there are two changer implementations: + +* ObjectChanger +* JsonPatchChanger + +Both operate on in-memory object trees. JsonPatchChanger uses JsonPatch objects as intermediates: these can be exposed and used on your source data documents in JSON + +In future there will be other datastores + +## Query API + +See linkml_runtime_api.query + +```python +# your datamodel here: +from kitchen_sink import Dataset, Person, FamilialRelationship + +# setup - load schema +schemaview = SchemaView('kitchen_sink.yaml') + +dataset = yaml_loader.load('my_dataset.yaml', target_class=Dataset) + + +q = FetchQuery(target_class=Person.class_name, + constraints=[MatchConstraint(op='=', + left='has_medical_history/*/diagnosis/name', + right='headache')]) +qe = ObjectQueryEngine(schemaview=schemaview) +persons = qe.fetch(q, dataset) +for person in persons: + print(f'{person.id} {person.name}') +``` + +Currently there is only one Query api implemntation + +* ObjectQuery + +This operates on in-memory object trees. + +In future there will be other datastores implemented (SQL, SPARQL, ...) + +## Generating APIs + +The above examples use generic APIs that can be used with any data models. You can also generate specific APIs for your datamodel. + +``` +gen-python-api kitchen_sink.yaml > kitchen_sink_api.py +``` + +This will generate an API: + +```python + # -- + # Person methods + # -- + def fetch_Person(self, id_value: str) -> Person: + """ + Retrieves an instance of `Person` via a primary key + + :param id_value: + :return: Person with matching ID + """ + ... + + def query_Person(self, + has_employment_history: Union[str, MatchExpression] = None, + has_familial_relationships: Union[str, MatchExpression] = None, + has_medical_history: Union[str, MatchExpression] = None, + age_in_years: Union[str, MatchExpression] = None, + addresses: Union[str, MatchExpression] = None, + has_birth_event: Union[str, MatchExpression] = None, + metadata: Union[str, MatchExpression] = None, + aliases: Union[str, MatchExpression] = None, + id: Union[str, MatchExpression] = None, + name: Union[str, MatchExpression] = None, + + _extra: Any = None) -> List[Person]: + """ + ... + """ + ... +``` + +The API is neutral with respect to the underlying datastore - each method is a wrapper for the generic runtime API diff --git a/linkml_runtime_api/__init__.py b/linkml_runtime_api/__init__.py new file mode 100644 index 0000000..bd8417c --- /dev/null +++ b/linkml_runtime_api/__init__.py @@ -0,0 +1,2 @@ +from linkml_runtime_api.query import * +from linkml_runtime_api.changer import * diff --git a/linkml_runtime_api/apiroot.py b/linkml_runtime_api/apiroot.py new file mode 100644 index 0000000..f9146fa --- /dev/null +++ b/linkml_runtime_api/apiroot.py @@ -0,0 +1,109 @@ +from abc import ABC +from dataclasses import dataclass + +from linkml_runtime.utils.schemaview import SchemaView +from linkml_runtime.utils.yamlutils import YAMLRoot + + +@dataclass +class ApiRoot(ABC): + """ + Base class for runtime API + + This class only contains base methods and cannot be used directly. Instead use: + + * Patcher -- for update operations on instances of a LinkML model + * QueryEngine -- for query operations on instances of a LinkML model + """ + + + schemaview: SchemaView = None + + def select_path(self, path: str, element: YAMLRoot) -> YAMLRoot: + """ + Return the sub-element matching the path expression + + :param path: query path + :param element: object to be queried + :return: matching sub-element + """ + parts = path.split('/') + if parts[0] == '': + parts = parts[1:] + if parts == []: + return element + #nxt = parts[0] + new_path = '/'.join(parts[1:]) + selector = parts[0] + new_element = None + if isinstance(element, dict): + new_element = element[selector] + elif isinstance(element, list): + for x in element: + x_id = self._get_primary_key_value_for_element(x) + if selector == x_id: + new_element = x + break + if new_element is None: + raise Exception(f'Could not find {selector} in list {element}') + else: + new_element = getattr(element, selector) + return self.select_path(new_path, new_element) + + def _yield_path(self, path: str, element: YAMLRoot) -> YAMLRoot: + """ + As `select_path`, but accepts '*' in paths and may yield multiple objects + + :param path: + :param element: + :return: + """ + parts = path.split('/') + if parts[0] == '': + parts = parts[1:] + if len(parts) == 0: + yield element + return + new_path = '/'.join(parts[1:]) + selector = parts[0] + new_element = None + if isinstance(element, dict): + new_element = element[selector] + elif isinstance(element, list): + if selector == '*': + for x in element: + for p in self._yield_path(new_path, x): + yield p + return + for x in element: + x_id = self._get_primary_key_value_for_element(x) + if selector == x_id: + new_element = x + break + if new_element is None: + raise Exception(f'Could not find {selector} in list {element}') + else: + new_element = getattr(element, selector) + for p in self._yield_path(new_path, new_element): + yield p + + def _get_primary_key_value_for_element(self, element: YAMLRoot) -> str: + """ + value of the slot that is assigned as identifier for the class that element instantiates + + :param element: + :return: + """ + cn = type(element).class_name + if cn is None: + raise Exception(f'Could not determine LinkML class name from {change.value}') + pk = self.schemaview.get_identifier_slot(cn) + return getattr(element, pk.name) + + def _get_top_level_classes(self, container_class=None): + sv = self.schemaview + cns = [] + for slot in sv.class_induced_slots(container_class): + if slot.range in sv.all_class(): + cns.append(slot.range) + return cns diff --git a/linkml_runtime_api/changer/__init__.py b/linkml_runtime_api/changer/__init__.py new file mode 100644 index 0000000..678e04f --- /dev/null +++ b/linkml_runtime_api/changer/__init__.py @@ -0,0 +1,2 @@ +from linkml_runtime_api.changer.object_changer import ObjectChanger +from linkml_runtime_api.changer.jsonpatch_changer import JsonPatchChanger diff --git a/linkml_runtime_api/changer/changer.py b/linkml_runtime_api/changer/changer.py new file mode 100644 index 0000000..8e4ded3 --- /dev/null +++ b/linkml_runtime_api/changer/changer.py @@ -0,0 +1,124 @@ +from dataclasses import dataclass, field + +from linkml_runtime.utils.formatutils import underscore + +from linkml_runtime_api.apiroot import ApiRoot +from linkml_runtime_api.changer.changes_model import Change, AddObject, RemoveObject +from linkml_runtime.utils.yamlutils import YAMLRoot + +@dataclass +class ChangeResult: + object: YAMLRoot + modified: bool = field(default_factory=lambda: True) + +@dataclass +class Changer(ApiRoot): + """ + Base class for engines that perform changes on elements + + Currently the most useful subclasses: + + * :class:`ObjectChanger` - operate directly on objects + * :class:`JsonChanger` - operate via generating JSON Patches + """ + + def apply(self, change: Change, element: YAMLRoot) -> ChangeResult: + raise Exception(f'Base class') + + def _map_change_object(self, change: YAMLRoot) -> Change: + if isinstance(change, Change): + return change + cn = type(change).class_name + for prefix, direct_cn in [('Add', AddObject), + ('Remove', RemoveObject)]: + if cn.startswith(prefix): + new_change_obj = direct_cn() + new_change_obj.path = change.path + new_change_obj.value = change.value + return new_change_obj + return None + + + def _path_to_jsonpath(self, path: str, element: YAMLRoot) -> str: + toks = path.split('/') + nu = [] + curr_el = element + for selector in toks: + nxt = selector + if selector == '': + None + else: + new_element = None + if isinstance(curr_el, dict): + new_element = curr_el[selector] + nxt = selector + elif isinstance(curr_el, list): + i = 0 + for x in curr_el: + x_id = self._get_primary_key_value_for_element(x) + if selector == x_id: + new_element = x + nxt = str(i) + break + i += 1 + if new_element is None: + raise Exception(f'Could not find {selector} in list {element}') + else: + new_element = getattr(curr_el, selector) + curr_el = new_element + nu.append(nxt) + return '/'.join(nu) + + def _get_jsonpath(self, change: Change, element: YAMLRoot) -> str: + if change.path is not None: + return self._path_to_jsonpath(change.path, element) + else: + return self._path_to_jsonpath(self._get_path(change, element), element) + + def _get_path(self, change: Change, element: YAMLRoot, strict=True) -> str: + if change.path is not None: + return change.path + else: + target_cn = type(change.value).class_name + sv = self.schemaview + if sv is None: + raise Exception(f'Must pass path OR schemaview') + paths = [] + for cn, c in sv.all_class().items(): + for slot in sv.class_induced_slots(cn): + if slot.inlined and slot.range == target_cn: + k = underscore(slot.name) + paths.append(f'/{k}') + if len(paths) > 1: + if strict: + raise Exception(f'Multiple possible paths: {paths}') + sorted(paths, key=lambda p: len(p.split('/'))) + return paths[0] + elif len(paths) == 1: + return paths[0] + else: + raise Exception(f'No matching top level slot') + + def _locate_object(self, change: Change, element: YAMLRoot) -> YAMLRoot: + if change.parent is not None: + return change.parent + else: + path = self._get_path(change, element) + return self.select_path(path, element) + + def _get_primary_key_value(self, change: Change) -> str: + pk_slot = self._get_primary_key(change) + return getattr(change.value, pk_slot) + + def _get_primary_key(self, change: Change) -> str: + if change.primary_key_slot is None: + sv = self.schemaview + if sv is None: + raise Exception(f'Must pass EITHER primary_key_slot in change object OR set schemaview') + cn = type(change.value).class_name + if cn is None: + raise Exception(f'Could not determine LinkML class name from {change.value}') + pk = sv.get_identifier_slot(cn) + return pk.name + else: + return change.primary_key_slot diff --git a/linkml_runtime_api/changer/changes.yaml b/linkml_runtime_api/changer/changes.yaml new file mode 100644 index 0000000..f1bd1b0 --- /dev/null +++ b/linkml_runtime_api/changer/changes.yaml @@ -0,0 +1,100 @@ +id: https://w3id.org/linkml/changes +title: LinkML Changes Datamodel +name: changes +description: |- + A generic datamodel for representing changes + +license: https://creativecommons.org/publicdomain/zero/1.0/ +see_also: + - https://datatracker.ietf.org/doc/html/rfc6902 + +prefixes: + linkml: https://w3id.org/linkml/ + jsonpatch: https://w3id.org/linkml/jsonpatch + skos: http://www.w3.org/2004/02/skos/core# + schema: http://schema.org/ + +default_prefix: jsonpatch +default_range: string + +default_curi_maps: + - semweb_context + +emit_prefixes: + - linkml + - jsonpatch + +imports: + - linkml:types + +#================================== +# Types # +#================================== +types: + PathExpression: + base: str + uri: xsd:string + description: |- + A path expression conformant to [rfc6901](https://datatracker.ietf.org/doc/html/rfc6901) + see_also: + - https://datatracker.ietf.org/doc/html/rfc6901 + +#================================== +# Slots # +#================================== +slots: + path: + range: PathExpression + value: + range: ChangeTarget + parent: + range: ChangeTarget + primary_key_slot: + range: string + +#================================== +# Classes # +#================================== +classes: + ChangeTarget: + class_uri: linkml:Any + + Change: + aliases: + - changer + slots: + - path + - parent + - value + - primary_key_slot + + AddObject: + is_a: Change + + RemoveObject: + is_a: Change + + Rename: + is_a: Change + attributes: + old_value: + target_class: + + SetValue: + is_a: Change + abstract: true + + SetAtomicValue: + is_a: SetValue + + SetComplexValue: + is_a: SetValue + + Append: + is_a: Change + + + + + + diff --git a/linkml_runtime_api/changer/changes_model.py b/linkml_runtime_api/changer/changes_model.py new file mode 100644 index 0000000..fb576a6 --- /dev/null +++ b/linkml_runtime_api/changer/changes_model.py @@ -0,0 +1,160 @@ +# Auto generated from changes.yaml by pythongen.py version: 0.9.0 +# Generation date: 2021-09-19 00:49 +# Schema: changes +# +# id: https://w3id.org/linkml/changes +# description: A generic datamodel for representing changes +# license: https://creativecommons.org/publicdomain/zero/1.0/ + +import dataclasses +import sys +import re +from jsonasobj2 import JsonObj, as_dict +from typing import Optional, List, Union, Dict, ClassVar, Any +from dataclasses import dataclass +from linkml_runtime.linkml_model.meta import EnumDefinition, PermissibleValue, PvFormulaOptions + +from linkml_runtime.utils.slot import Slot +from linkml_runtime.utils.metamodelcore import empty_list, empty_dict, bnode +from linkml_runtime.utils.yamlutils import YAMLRoot, extended_str, extended_float, extended_int +from linkml_runtime.utils.dataclass_extensions_376 import dataclasses_init_fn_with_kwargs +from linkml_runtime.utils.formatutils import camelcase, underscore, sfx +from linkml_runtime.utils.enumerations import EnumDefinitionImpl +from rdflib import Namespace, URIRef +from linkml_runtime.utils.curienamespace import CurieNamespace +from linkml_runtime.linkml_model.types import String + +metamodel_version = "1.7.0" + +# Overwrite dataclasses _init_fn to add **kwargs in __init__ +dataclasses._init_fn = dataclasses_init_fn_with_kwargs + +# Namespaces +JSONPATCH = CurieNamespace('jsonpatch', 'https://w3id.org/linkml/jsonpatch') +LINKML = CurieNamespace('linkml', 'https://w3id.org/linkml/') +SCHEMA = CurieNamespace('schema', 'http://schema.org/') +SKOS = CurieNamespace('skos', 'http://www.w3.org/2004/02/skos/core#') +XSD = CurieNamespace('xsd', 'http://www.w3.org/2001/XMLSchema#') +DEFAULT_ = JSONPATCH + + +# Types +class PathExpression(str): + """ A path expression conformant to [rfc6901](https://datatracker.ietf.org/doc/html/rfc6901) """ + type_class_uri = XSD.string + type_class_curie = "xsd:string" + type_name = "PathExpression" + type_model_uri = JSONPATCH.PathExpression + + +# Class references + + + +ChangeTarget = Any + +@dataclass +class Change(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = JSONPATCH.Change + class_class_curie: ClassVar[str] = "jsonpatch:Change" + class_name: ClassVar[str] = "Change" + class_model_uri: ClassVar[URIRef] = JSONPATCH.Change + + path: Optional[str] = None + parent: Optional[Union[dict, ChangeTarget]] = None + value: Optional[Union[dict, ChangeTarget]] = None + primary_key_slot: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + if self.primary_key_slot is not None and not isinstance(self.primary_key_slot, str): + self.primary_key_slot = str(self.primary_key_slot) + + super().__post_init__(**kwargs) + + +class AddObject(Change): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = JSONPATCH.AddObject + class_class_curie: ClassVar[str] = "jsonpatch:AddObject" + class_name: ClassVar[str] = "AddObject" + class_model_uri: ClassVar[URIRef] = JSONPATCH.AddObject + + +class RemoveObject(Change): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = JSONPATCH.RemoveObject + class_class_curie: ClassVar[str] = "jsonpatch:RemoveObject" + class_name: ClassVar[str] = "RemoveObject" + class_model_uri: ClassVar[URIRef] = JSONPATCH.RemoveObject + + +@dataclass +class Rename(Change): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = JSONPATCH.Rename + class_class_curie: ClassVar[str] = "jsonpatch:Rename" + class_name: ClassVar[str] = "Rename" + class_model_uri: ClassVar[URIRef] = JSONPATCH.Rename + + old_value: Optional[str] = None + target_class: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.old_value is not None and not isinstance(self.old_value, str): + self.old_value = str(self.old_value) + + if self.target_class is not None and not isinstance(self.target_class, str): + self.target_class = str(self.target_class) + + super().__post_init__(**kwargs) + + +class SetValue(Change): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = JSONPATCH.SetValue + class_class_curie: ClassVar[str] = "jsonpatch:SetValue" + class_name: ClassVar[str] = "SetValue" + class_model_uri: ClassVar[URIRef] = JSONPATCH.SetValue + + +class SetAtomicValue(SetValue): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = JSONPATCH.SetAtomicValue + class_class_curie: ClassVar[str] = "jsonpatch:SetAtomicValue" + class_name: ClassVar[str] = "SetAtomicValue" + class_model_uri: ClassVar[URIRef] = JSONPATCH.SetAtomicValue + + +class SetComplexValue(SetValue): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = JSONPATCH.SetComplexValue + class_class_curie: ClassVar[str] = "jsonpatch:SetComplexValue" + class_name: ClassVar[str] = "SetComplexValue" + class_model_uri: ClassVar[URIRef] = JSONPATCH.SetComplexValue + + +class Append(Change): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = JSONPATCH.Append + class_class_curie: ClassVar[str] = "jsonpatch:Append" + class_name: ClassVar[str] = "Append" + class_model_uri: ClassVar[URIRef] = JSONPATCH.Append + + +# Enumerations + + +# Slots + diff --git a/linkml_runtime_api/changer/jsonpatch_changer.py b/linkml_runtime_api/changer/jsonpatch_changer.py new file mode 100644 index 0000000..d82ba2c --- /dev/null +++ b/linkml_runtime_api/changer/jsonpatch_changer.py @@ -0,0 +1,197 @@ +import logging +from typing import List, Dict, Any +import json + +from jsonpatch import JsonPatch + +from linkml_runtime.dumpers import json_dumper +from linkml_runtime.loaders import json_loader +from linkml_runtime_api.changer.changer import Changer, ChangeResult +from linkml_runtime_api.changer.changes_model import Change, AddObject, RemoveObject, Append, Rename +from linkml_runtime.utils.formatutils import underscore +from linkml_runtime.utils.yamlutils import YAMLRoot + +OPDICT = Dict[str, Any] +OPS = List[OPDICT] + +def _element_to_dict(element: YAMLRoot) -> dict: + jsonstr = json_dumper.dumps(element, inject_type=False) + return json.loads(jsonstr) + +class JsonPatchChanger(Changer): + """ + A :class:`Patcher` that works by first creating :class:`JsonPath` objects, then applying them + """ + + def apply(self, change: Change, element: YAMLRoot, in_place=True) -> ChangeResult: + """ + Generates a JsonPatch and then applies it + + See :meth:`.make_patch` + + :param change: + :param element: + :param in_place: + :return: + """ + change = self._map_change_object(change) + patch_ops = self.make_patch(change, element) + patch = JsonPatch(patch_ops) + logging.info(f'Patch = {patch_ops}') + element_dict = _element_to_dict(element) + result = patch.apply(element_dict) + typ = type(element) + jsonstr = json_dumper.dumps(result, inject_type=False) + result = json_loader.loads(jsonstr, target_class=typ) + if in_place: + new_obj = result + if isinstance(element, dict): + for k, v in element.items(): + element[k] = new_obj[k] + else: + for k in element.__dict__: + setattr(element, k, getattr(new_obj, k)) + return ChangeResult(result) + + def _value(self, change: Change): + # TODO: move this functionality into json_dumper + return json.loads(json_dumper.dumps(change.value, inject_type=False)) + + def make_patch(self, change: Change, element: YAMLRoot) -> OPS: + """ + Generates a list of JsonPatch objects from a Change + + These can then be directly applied, or applied later out of band, + e.g. using :meth:`JsonPath.apply` + + :param change: + :param element: + :return: + """ + change = self._map_change_object(change) + if isinstance(change, AddObject): + return self.make_add_object_patch(change, element) + elif isinstance(change, RemoveObject): + return self.make_remove_object_patch(change, element) + elif isinstance(change, Append): + return self.make_append_patch(change, element) + elif isinstance(change, Rename): + return self.make_rename_patch(change, element) + else: + raise Exception(f'Unknown type {type(change)} for {change}') + + def make_add_object_patch(self, change: AddObject, element: YAMLRoot) -> OPS: + path = self._get_jsonpath(change, element) + place = self._locate_object(change, element) + pk_slot = self._get_primary_key(change) + pk_val = getattr(change.value, pk_slot) + v = self._value(change) + op = dict(op='add', value=v) + if isinstance(place, dict): + op['path'] = f'{path}/pk_val' + elif isinstance(place, list): + op['path'] = f'{path}/{len(place)}' + else: + raise Exception(f'place {place} cannot be added to') + return [op] + + def make_remove_object_patch(self, change: RemoveObject, element: YAMLRoot) -> OPS: + place = self._locate_object(change, element) + path = self._get_jsonpath(change, element) + op = dict(op='remove') + if isinstance(change.value, str): + v = change.value + else: + v = self._get_primary_key_value(change) + if isinstance(place, list): + if change.value not in place: + raise Exception(f'value {v} not in list: {place}') + op['path'] = f'{path}/{place.index(change.value)}' + else: + op['path'] = f'{path}/{v}' + return [op] + + def make_append_patch(self, change: Append, element: YAMLRoot) -> OPS: + """ + Apply an :class:`Append` change + + :param change: + :param element: + :return: + """ + path = self._get_jsonpath(change, element) + place = self._locate_object(change, element) + if not isinstance(place, list): + raise Exception(f'Expected list got {place}') + v = self._value(change) + n = len(place) + if n == 0: + op = dict(op='add', value=[v], path=path) + else: + op = dict(op='add', value=v, path=f'{path}/{n}') + return [op] + + def make_rename_patch(self, change: Rename, element: YAMLRoot) -> OPS: + """ + Apply a Rename change + + :param change: + :param element: + :return: + """ + path = self._get_path(change, element) + ops = self._rename(change, element, path) + return ops + + + def _rename(self, change: Rename, element: YAMLRoot, path: str) -> OPS: + ops = [] + def add_op(op, path_ext): + op = dict(op=op, path=f'{path}/{path_ext}', value=change.value) + ops.append(op) + sv = self.schemaview + if not isinstance(element, YAMLRoot): + return [] + cn = type(element).class_name + if cn == change.target_class: + pk = sv.get_identifier_slot(change.target_class) + if pk is not None: + pk_val = getattr(element, pk.name) + if pk_val == change.old_value: + add_op('replace', pk.name) + slots = sv.class_induced_slots(cn) + for k, v in element.__dict__.items(): + next_path = f'{path}/{k}' + range_matches_target = False + for s in slots: + if underscore(k) == underscore(s.name): + if s.range == change.target_class: + range_matches_target = True + break + if isinstance(v, list): + if range_matches_target: + i = 0 + for v1 in v: + if v1 == change.old_value: + add_op('replace', k) + i += 1 + i = 0 + for v1 in v: + ops += self._rename(change, v1, f'{next_path}/{i}') + i += 1 + elif isinstance(v, dict): + if range_matches_target: + if change.old_value in v: + op = {"op": 'move', + "from": f'{path}/{k}/{change.old_value}', + "path": f'{path}/{k}/{change.value}'} + ops.append(op) + for k, v1 in v.items(): + ops += self._rename(change, v1, next_path) + elif isinstance(v, str): + if range_matches_target and v == change.old_value: + add_op('replace', k) + else: + ops += self._rename(change, v, next_path) + return ops + diff --git a/linkml_runtime_api/changer/object_changer.py b/linkml_runtime_api/changer/object_changer.py new file mode 100644 index 0000000..c94d02a --- /dev/null +++ b/linkml_runtime_api/changer/object_changer.py @@ -0,0 +1,129 @@ +from copy import copy, deepcopy + +from linkml_runtime_api.changer.changer import Changer, ChangeResult +from linkml_runtime_api.changer.changes_model import Change, AddObject, RemoveObject, Append, Rename +from linkml_runtime.utils.formatutils import underscore +from linkml_runtime.utils.yamlutils import YAMLRoot + + + +class ObjectChanger(Changer): + """ + A :class:`Changer` that operates over an in-memory object tree + """ + + def apply(self, change: Change, element: YAMLRoot, in_place=True) -> ChangeResult: + """ + Apply a change directly to an in-memory object tree + + :param change: + :param element: + :param in_place: + :return: + """ + change = self._map_change_object(change) + if not in_place: + element = deepcopy(element) + if isinstance(change, AddObject): + return self.add_object(change, element) + elif isinstance(change, RemoveObject): + return self.remove_object(change, element) + elif isinstance(change, Append): + return self.append_value(change, element) + elif isinstance(change, Rename): + return self.rename(change, element) + else: + raise Exception(f'Unknown type {type(change)} for {change}') + + # NOTE: changes in place + def add_object(self, change: AddObject, element: YAMLRoot) -> ChangeResult: + place = self._locate_object(change, element) + pk_slot = self._get_primary_key(change) + pk_val = getattr(change.value, pk_slot) + if isinstance(place, dict): + place[pk_val] = change.value + elif isinstance(place, list): + place.append(change.value) + else: + raise Exception(f'place {place} cannot be added to') + return ChangeResult(object=element) + + + # NOTE: changes in place + def remove_object(self, change: RemoveObject, element: YAMLRoot) -> ChangeResult: + place = self._locate_object(change, element) + if isinstance(change.value, str): + v = change.value + else: + v = self._get_primary_key_value(change) + if isinstance(place, list): + if change.value not in place: + raise Exception(f'value {v} not in list: {place}') + place.remove(change.value) + else: + del place[v] + return ChangeResult(object=element) + + # NOTE: changes in place + def append_value(self, change: Append, element: YAMLRoot) -> ChangeResult: + place = self._locate_object(change, element) + place.append(change.value) + return ChangeResult(object=element) + + # NOTE: changes in place + def rename(self, change: Rename, element: YAMLRoot) -> ChangeResult: + element = self._rename(change, element) + return ChangeResult(object=element) + + def _rename(self, change: Rename, element: YAMLRoot) -> YAMLRoot: + sv = self.schemaview + if not isinstance(element, YAMLRoot): + return element + cn = type(element).class_name + #print(f'CN={cn}') + if cn == change.target_class: + pk = sv.get_identifier_slot(change.target_class) + if pk is not None: + pk_val = getattr(element, pk.name) + if pk_val == change.old_value: + setattr(element, pk.name, change.value) + slots = sv.class_induced_slots(cn) + for k, v in element.__dict__.items(): + is_replace = False + for s in slots: + #print(f'Testing slot {s.name} -- {k} == {s.name}') + if underscore(k) == underscore(s.name): + #print(f' xxTesting slot {s.name} -- {change.target_class} == {s.range} // {cn}') + if s.range == change.target_class: + is_replace = True + #print(f' REPLACE!!') + break + def replace(v): + if v == change.old_value: + return change.value + else: + return v + if isinstance(v, list): + if is_replace: + v = [replace(v1) for v1 in v] + v = [self._rename(change, v1) for v1 in v] + elif isinstance(v, dict): + if is_replace: + if change.old_value in v: + v[change.value] = v[change.old_value] + del v[change.old_value] + v = {k: self._rename(change, v1) for k, v1 in v.items()} + elif isinstance(v, str): + if is_replace: + v = replace(v) + else: + v = self._rename(change, v) + setattr(element, k, v) + return element + + + + + + + diff --git a/linkml_runtime_api/diffs/__init__.py b/linkml_runtime_api/diffs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/linkml_runtime_api/diffs/differ.py b/linkml_runtime_api/diffs/differ.py new file mode 100644 index 0000000..f8b0d9e --- /dev/null +++ b/linkml_runtime_api/diffs/differ.py @@ -0,0 +1,31 @@ +import logging +from typing import List, Dict, Any +import json + +from jsonpatch import JsonPatch + +from linkml_runtime.dumpers import json_dumper + +from linkml_runtime_api.apiroot import ApiRoot +from linkml_runtime.utils.yamlutils import YAMLRoot + +def _as_dict(obj: YAMLRoot) -> dict: + return json.loads(json_dumper.dumps(obj)) + +class DiffEngine(ApiRoot): + """ + Engine for performing diffs over databases + """ + + def diff(self, src: YAMLRoot, dst: YAMLRoot, **kwargs) -> JsonPatch: + """ + Apply a diff to two object trees + :param src: + :param dst: + :param kwargs: + :return: + """ + srco = _as_dict(src) + dsto = _as_dict(dst) + patch = JsonPatch.from_diff(srco, dsto, **kwargs) + return patch \ No newline at end of file diff --git a/linkml_runtime_api/generators/__init__.py b/linkml_runtime_api/generators/__init__.py new file mode 100644 index 0000000..ddcfd64 --- /dev/null +++ b/linkml_runtime_api/generators/__init__.py @@ -0,0 +1,2 @@ +from linkml_runtime_api.generators.apigenerator import ApiGenerator +from linkml_runtime_api.generators.pyapigenerator import PythonApiGenerator diff --git a/linkml_runtime_api/generators/apigenerator.py b/linkml_runtime_api/generators/apigenerator.py new file mode 100644 index 0000000..73267e1 --- /dev/null +++ b/linkml_runtime_api/generators/apigenerator.py @@ -0,0 +1,130 @@ +import re +import operator as op +from copy import copy +from dataclasses import dataclass +from typing import Any + +import click +import yaml +from linkml_runtime.utils.formatutils import camelcase, underscore +from linkml_runtime.utils.schemaview import SchemaView + +from linkml_runtime_api.apiroot import ApiRoot + +@dataclass +class ApiGenerator(ApiRoot): + """ + Generates an API schema given a data schema + """ + + def serialize(self, container_class=None): + sv = self.schemaview + cns = sv.all_class(imports=False).keys() + if container_class != None: + cns = self._get_top_level_classes(container_class) + + src = sv.schema + name = f'{src.name}_api' + classes = { + "LocalChange" : { + "slots" : [ + "value", + "path" + ], + "mixin" : True + }, + "LocalQuery" : { + "mixin" : True, + "slots" : [ + "target_class", + "path" + ] + } + } + schema = { + + "id" : "https://w3id.org/linkml/tests/kitchen_sink_api", + "name" : name, + "description" : f"API for querying and manipulating objects from the {src.name} schema", + "prefixes" : { + name : "https://w3id.org/linkml/tests/kitchen_sink_api/", + "linkml" : "https://w3id.org/linkml/" + }, + "default_prefix" : name, + "imports" : [ + "linkml:types", + src.name + ], + "default_range" : "string", + "slots" : { + "value" : { + "inlined" : True + }, + "path" : {}, + "constraints" : { + "range" : "Any" + }, + "id_value" : {}, + "target_class" : {} + }, + "classes": classes + } + + cmts = ["This is an autogenerated class"] + + for cn in cns: + c = sv.get_class(cn) + cn_camel = camelcase(cn) + if not c.abstract and not c.mixin: + classes[f'Add{cn_camel}'] = { + "description": f'A change action that adds a {cn_camel} to a database', + "comments": copy(cmts), + "mixins" : "LocalChange", + "slot_usage" : { + "value" : { + "range" : cn, + "inlined" : True + } + } + } + classes[f'Remove{cn_camel}'] = { + "description": f'A change action that remoaves a {cn_camel} to a database', + "comments": copy(cmts), + "mixins" : "LocalChange", + "slot_usage" : { + "value" : { + "range" : cn, + "inlined" : True + } + } + } + classes[f'{cn_camel}Query'] = { + "description": f'A query object for instances of {cn_camel} from a database', + "comments": copy(cmts), + "mixins" : "LocalChange", + "slots" : [ + "constraints" + ], + } + classes[f'{cn_camel}FetchById'] = { + "description": f'A query object for fetching an instance of {cn_camel} from a database by id', + "comments": copy(cmts), + "mixins" : "LocalChange", + "slots" : [ + "id_value" + ], + } + return yaml.safe_dump(schema, sort_keys=False) + +@click.command() +@click.option('-R', '--container-class', help="name of class that contains top level objects") +@click.argument('schema') +def cli(schema, **args): + """ Generate API """ + sv = SchemaView(schema) + gen = ApiGenerator(schemaview=sv) + print(gen.serialize(**args)) + + +if __name__ == '__main__': + cli() diff --git a/linkml_runtime_api/generators/pyapigenerator.py b/linkml_runtime_api/generators/pyapigenerator.py new file mode 100644 index 0000000..80122c7 --- /dev/null +++ b/linkml_runtime_api/generators/pyapigenerator.py @@ -0,0 +1,132 @@ +import logging +import re +import operator as op +from collections import defaultdict +from copy import copy +from dataclasses import dataclass +from typing import Any + +import click +import yaml +from jinja2 import Template + +from linkml_runtime.utils.formatutils import camelcase, underscore +from linkml_runtime.utils.schemaview import SchemaView + +from linkml_runtime_api.apiroot import ApiRoot + +jinja2_template = """ +import logging +from dataclasses import dataclass +from linkml_runtime_api.query.queryengine import QueryEngine +from linkml_runtime_api.query.query_model import FetchQuery, Constraint, MatchConstraint, OrConstraint, AbstractQuery, \ + FetchById +from linkml_runtime_api.query.queryengine import MatchExpression + +from {{ datamodel_package_full}} import * + +@dataclass +class {{ api_name }}: + + # attributes + query_engine: QueryEngine = None + + {% for cn in classes %} + # -- + # {{cn}} methods + # -- + def fetch_{{cn}}(self, id_value: str) -> {{cn}}: + \""" + Retrieves an instance of `{{cn}}` via a primary key + + :param id_value: + :return: {{cn}} with matching ID + \""" + q = FetchById(id=id_value, target_class={{cn}}.class_name) + results = self.query_engine.fetch_by_id(q) + return results[0] if results else None + + def query_{{cn}}(self, + {% for s in slots[cn] -%} + {{s.name}}: Union[{{s.range}}, MatchExpression] = None, + {% endfor %} + _extra: Any = None) -> List[{{cn}}]: + \""" + Queries for instances of `{{cn}}` + + {% for s in slots[cn] -%} + :param {{s.name}}: {{s.description}} + {% endfor %} + :return: Person list matching constraints + \""" + results = self.query_engine.simple_query({{cn}}.class_name, + {% for s in slots[cn] %} + {{s.name}}={{s.name}}, + {% endfor %} + _extra=_extra) + return results + {% endfor %} + +""" + +@dataclass +class PythonApiGenerator(ApiRoot): + """ + Generates source for a Python API + + Implements the `Gateway `_ + pattern. + + For example, given a schema KitchenSink, which includes a class Person, + the generated API will be of the form + + .. code block:: python + + class KitchenSinkAPI: + + def fetch_Person(id_value: str) -> Person: + ... + def query_Person(id: str, name: str, ...) -> List[Person]: + ... + """ + + def serialize(self, container_class=None, python_path=''): + sv = self.schemaview + cns = sv.all_class(imports=False).keys() + if container_class != None: + cns = self._get_top_level_classes(container_class) + template_obj = Template(jinja2_template) + datamodel_package_full = f'{python_path}.{sv.schema.name}' + slots = defaultdict(list) + classes = [] + for cn in cns: + cn_cc = camelcase(cn) + classes.append(cn_cc) + islots = sv.class_induced_slots(cn) + for s in islots: + s.range = 'str' # TODO + slots[cn_cc].append(s) + s.name = underscore(s.name) + logging.info(f'# Slot: {cn}.{s.name}') + + logging.info(f'# Classes: {list(classes)}') + + code = template_obj.render(datamodel_package_full=datamodel_package_full, + api_name=f'{camelcase(sv.schema.name)}API', + classes=classes, + slots=slots + ) + return str(code) + +@click.command() +@click.option('-R', '--container-class', help="name of class that contains top level objects") +@click.argument('schema') +def cli(schema, **args): + """ Generate API """ + sv = SchemaView(schema) + gen = PythonApiGenerator(schemaview=sv) + print(gen.serialize(**args)) + + +if __name__ == '__main__': + cli() diff --git a/linkml_runtime_api/query/__init__.py b/linkml_runtime_api/query/__init__.py new file mode 100644 index 0000000..5dd9841 --- /dev/null +++ b/linkml_runtime_api/query/__init__.py @@ -0,0 +1 @@ +from linkml_runtime_api.query.object_queryengine import ObjectQueryEngine \ No newline at end of file diff --git a/linkml_runtime_api/query/object_queryengine.py b/linkml_runtime_api/query/object_queryengine.py new file mode 100644 index 0000000..d2f15f1 --- /dev/null +++ b/linkml_runtime_api/query/object_queryengine.py @@ -0,0 +1,163 @@ +import logging +import re +import operator as op +from dataclasses import dataclass +from typing import Any + +from linkml_runtime.utils.formatutils import camelcase, underscore + +from linkml_runtime_api.query.query_model import FetchQuery, Constraint, MatchConstraint, OrConstraint, AbstractQuery, \ + FetchById +from linkml_runtime.utils.yamlutils import YAMLRoot + +from linkml_runtime_api.query.queryengine import QueryEngine, create_match_constraint + + +def like(x: Any, y: Any) -> bool: + y = str(y).replace('%', '.*') + return re.match(f'^{y}$', str(x)) + +OPMAP = {'<': op.lt, + '<=': op.le, + '==': op.eq, + '=': op.eq, + '>=': op.ge, + '>': op.gt, + 'like': like} + + +@dataclass +class ObjectQueryEngine(QueryEngine): + """ + Engine for executing queries over an object tree + + """ + + + def fetch(self, query: AbstractQuery, element: YAMLRoot): + if not isinstance(query, AbstractQuery): + cn = type(query).class_name + if cn.endswith('Query'): + target_class = cn.replace('Query', '') + query = FetchQuery(target_class=target_class, + path=query.path, + constraints=query.constraints) + elif cn.endswith('FetchById'): + target_class = cn.replace('FetchById', '') + query = FetchById(target_class=target_class, + path=query.path, + id=query.id_value) + else: + raise Exception(f'Not supported: {cn}') + if isinstance(query, FetchQuery): + return self.fetch_by_query(query, element) + elif isinstance(query, FetchById): + return self.fetch_by_id(query, element) + else: + raise Exception(f'Unknown query type: {type(query)} for {query}') + + def fetch_by_id(self, query: FetchById, element: YAMLRoot = None): + if element is None: + element = self.database.document + pk = None + tc = query.target_class + for cn, c in self.schemaview.all_class().items(): + if camelcase(cn) == camelcase(tc): + pk = self.schemaview.get_identifier_slot(cn) + break + if pk is None: + raise Exception(f'No primary key {cn}') + c = MatchConstraint(op='=', left=pk.name, right=query.id) + return self.fetch_by_query(FetchQuery(target_class=query.target_class, + constraints=[c]), + element) + + def fetch_by_query(self, query: FetchQuery, element: YAMLRoot = None): + if element is None: + element = self.database.document + path = self._get_path(query) + place = self.select_path(path, element) + if isinstance(place, list): + elts = place + else: + elts = place.values() + results = [] + for e in elts: + if self._satisfies(e, query): + results.append(e) + return results + + def simple_query(self, target_class: str, element: YAMLRoot = None, **kwargs): + """ + Wrapper for `fetch_by_query` + + :param target_class: + :param kwargs: + :return: + """ + constraints = [create_match_constraint(k, v) for k, v in kwargs.items()] + q = FetchQuery(target_class=target_class, constraints=constraints) + logging.info(f'Q={q}') + return self.fetch_by_query(q, element=element) + + def _satisfies(self, element: YAMLRoot, query: FetchQuery): + for c in query.constraints: + if not self._satisfies_constraint(element, c): + return False + return True + + def _satisfies_constraint(self, element: YAMLRoot, constraint: Constraint): + sat = self._satisfies_constraint_pos(element, constraint) + if constraint.negated: + return not sat + else: + return sat + + def _satisfies_constraint_pos(self, element: YAMLRoot, constraint: Constraint): + if isinstance(constraint, MatchConstraint): + constraint: MatchConstraint + if constraint.right is None: + # unspecified always matches + return True + for v in self._yield_path(constraint.left, element): + py_op = OPMAP[constraint.op] + sat = py_op(v, constraint.right) + if sat: + return True + return False + elif isinstance(constraint, OrConstraint): + constraint: OrConstraint + for subc in constraint.subconstraints: + if self._satisfies_constraint(element, subc): + return True + return False + else: + raise Exception(f'Cannot handle {type(constraint)} yet') + + # TODO: avoid repetion with same method + def _get_path(self, query: AbstractQuery, strict=True) -> str: + if query.path is not None: + return query.path + else: + target_cn = query.target_class + sv = self.schemaview + if sv is None: + raise Exception(f'Must pass path OR schemaview') + paths = [] + for cn, c in sv.all_class().items(): + for slot in sv.class_induced_slots(cn): + if slot.inlined and slot.range == target_cn: + k = underscore(slot.name) + paths.append(f'/{k}') + if len(paths) > 1: + if strict: + raise Exception(f'Multiple possible paths: {paths}') + sorted(paths, key=lambda p: len(p.split('/'))) + return paths[0] + elif len(paths) == 1: + return paths[0] + else: + raise Exception(f'No matching top level slot') + + + diff --git a/linkml_runtime_api/query/query.yaml b/linkml_runtime_api/query/query.yaml new file mode 100644 index 0000000..27cf289 --- /dev/null +++ b/linkml_runtime_api/query/query.yaml @@ -0,0 +1,108 @@ +id: https://w3id.org/linkml/query +title: LinkML Query Datamodel +name: query +description: |- + A generic datamodel for representing query + +license: https://creativecommons.org/publicdomain/zero/1.0/ +see_also: + - https://datatracker.ietf.org/doc/html/rfc6902 + +prefixes: + linkml: https://w3id.org/linkml/ + query: https://w3id.org/linkml/query + skos: http://www.w3.org/2004/02/skos/core# + schema: http://schema.org/ + +default_prefix: query +default_range: string + +default_curi_maps: + - semweb_context + +emit_prefixes: + - linkml + - query + +imports: + - linkml:types + +#================================== +# Types # +#================================== +types: + PathExpression: + base: str + uri: xsd:string + description: |- + A path expression conformant to [rfc6901](https://datatracker.ietf.org/doc/html/rfc6901) + see_also: + - https://datatracker.ietf.org/doc/html/rfc6901 + +#================================== +# Slots # +#================================== +slots: + path: + range: PathExpression + +#================================== +# Classes # +#================================== +classes: + MyAny: + class_uri: linkml:Any + + AbstractQuery: + abstract: true + + FetchById: + is_a: AbstractQuery + attributes: + id: string + path: + range: PathExpression + target_class: + range: string + + FetchQuery: + is_a: AbstractQuery + attributes: + constraints: + multivalued: true + range: Constraint + inlined: true + path: + range: PathExpression + target_class: + range: string + + Constraint: + attributes: + negated: + range: boolean + + MatchConstraint: + is_a: Constraint + attributes: + op: + left: + right: + range: MyAny + + OrConstraint: + is_a: Constraint + attributes: + subconstraints: + multivalued: true + range: Constraint + inlined: true + + + + + + + + + diff --git a/linkml_runtime_api/query/query_model.py b/linkml_runtime_api/query/query_model.py new file mode 100644 index 0000000..f3c3eb0 --- /dev/null +++ b/linkml_runtime_api/query/query_model.py @@ -0,0 +1,184 @@ +# Auto generated from query.yaml by pythongen.py version: 0.9.0 +# Generation date: 2021-09-19 01:06 +# Schema: query +# +# id: https://w3id.org/linkml/query +# description: A generic datamodel for representing query +# license: https://creativecommons.org/publicdomain/zero/1.0/ + +import dataclasses +import sys +import re +from jsonasobj2 import JsonObj, as_dict +from typing import Optional, List, Union, Dict, ClassVar, Any +from dataclasses import dataclass +from linkml_runtime.linkml_model.meta import EnumDefinition, PermissibleValue, PvFormulaOptions + +from linkml_runtime.utils.slot import Slot +from linkml_runtime.utils.metamodelcore import empty_list, empty_dict, bnode +from linkml_runtime.utils.yamlutils import YAMLRoot, extended_str, extended_float, extended_int +from linkml_runtime.utils.dataclass_extensions_376 import dataclasses_init_fn_with_kwargs +from linkml_runtime.utils.formatutils import camelcase, underscore, sfx +from linkml_runtime.utils.enumerations import EnumDefinitionImpl +from rdflib import Namespace, URIRef +from linkml_runtime.utils.curienamespace import CurieNamespace +from linkml_runtime.linkml_model.types import Boolean, String +from linkml_runtime.utils.metamodelcore import Bool + +metamodel_version = "1.7.0" + +# Overwrite dataclasses _init_fn to add **kwargs in __init__ +dataclasses._init_fn = dataclasses_init_fn_with_kwargs + +# Namespaces +LINKML = CurieNamespace('linkml', 'https://w3id.org/linkml/') +QUERY = CurieNamespace('query', 'https://w3id.org/linkml/query') +SCHEMA = CurieNamespace('schema', 'http://schema.org/') +SKOS = CurieNamespace('skos', 'http://www.w3.org/2004/02/skos/core#') +STRING = CurieNamespace('string', 'http://example.org/UNKNOWN/string/') +XSD = CurieNamespace('xsd', 'http://www.w3.org/2001/XMLSchema#') +DEFAULT_ = QUERY + + +# Types +class PathExpression(str): + """ A path expression conformant to [rfc6901](https://datatracker.ietf.org/doc/html/rfc6901) """ + type_class_uri = XSD.string + type_class_curie = "xsd:string" + type_name = "PathExpression" + type_model_uri = QUERY.PathExpression + + +# Class references + + + +MyAny = Any + +class AbstractQuery(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = QUERY.AbstractQuery + class_class_curie: ClassVar[str] = "query:AbstractQuery" + class_name: ClassVar[str] = "AbstractQuery" + class_model_uri: ClassVar[URIRef] = QUERY.AbstractQuery + + +@dataclass +class FetchById(AbstractQuery): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = QUERY.FetchById + class_class_curie: ClassVar[str] = "query:FetchById" + class_name: ClassVar[str] = "FetchById" + class_model_uri: ClassVar[URIRef] = QUERY.FetchById + + id: Optional[str] = None + path: Optional[str] = None + target_class: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.id is not None and not isinstance(self.id, str): + self.id = str(self.id) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + if self.target_class is not None and not isinstance(self.target_class, str): + self.target_class = str(self.target_class) + + super().__post_init__(**kwargs) + + +@dataclass +class FetchQuery(AbstractQuery): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = QUERY.FetchQuery + class_class_curie: ClassVar[str] = "query:FetchQuery" + class_name: ClassVar[str] = "FetchQuery" + class_model_uri: ClassVar[URIRef] = QUERY.FetchQuery + + constraints: Optional[Union[Union[dict, "Constraint"], List[Union[dict, "Constraint"]]]] = empty_list() + path: Optional[str] = None + target_class: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if not isinstance(self.constraints, list): + self.constraints = [self.constraints] if self.constraints is not None else [] + self.constraints = [v if isinstance(v, Constraint) else Constraint(**as_dict(v)) for v in self.constraints] + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + if self.target_class is not None and not isinstance(self.target_class, str): + self.target_class = str(self.target_class) + + super().__post_init__(**kwargs) + + +@dataclass +class Constraint(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = QUERY.Constraint + class_class_curie: ClassVar[str] = "query:Constraint" + class_name: ClassVar[str] = "Constraint" + class_model_uri: ClassVar[URIRef] = QUERY.Constraint + + negated: Optional[Union[bool, Bool]] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.negated is not None and not isinstance(self.negated, Bool): + self.negated = Bool(self.negated) + + super().__post_init__(**kwargs) + + +@dataclass +class MatchConstraint(Constraint): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = QUERY.MatchConstraint + class_class_curie: ClassVar[str] = "query:MatchConstraint" + class_name: ClassVar[str] = "MatchConstraint" + class_model_uri: ClassVar[URIRef] = QUERY.MatchConstraint + + op: Optional[str] = None + left: Optional[str] = None + right: Optional[Union[dict, MyAny]] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.op is not None and not isinstance(self.op, str): + self.op = str(self.op) + + if self.left is not None and not isinstance(self.left, str): + self.left = str(self.left) + + super().__post_init__(**kwargs) + + +@dataclass +class OrConstraint(Constraint): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = QUERY.OrConstraint + class_class_curie: ClassVar[str] = "query:OrConstraint" + class_name: ClassVar[str] = "OrConstraint" + class_model_uri: ClassVar[URIRef] = QUERY.OrConstraint + + subconstraints: Optional[Union[Union[dict, Constraint], List[Union[dict, Constraint]]]] = empty_list() + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if not isinstance(self.subconstraints, list): + self.subconstraints = [self.subconstraints] if self.subconstraints is not None else [] + self.subconstraints = [v if isinstance(v, Constraint) else Constraint(**as_dict(v)) for v in self.subconstraints] + + super().__post_init__(**kwargs) + + +# Enumerations + + +# Slots + diff --git a/linkml_runtime_api/query/queryengine.py b/linkml_runtime_api/query/queryengine.py new file mode 100644 index 0000000..e23ebf8 --- /dev/null +++ b/linkml_runtime_api/query/queryengine.py @@ -0,0 +1,83 @@ +import re +import operator as op +from dataclasses import dataclass +from typing import Any + +from linkml_runtime.utils.formatutils import camelcase, underscore + +from linkml_runtime_api.apiroot import ApiRoot +from linkml_runtime_api.query.query_model import FetchQuery, Constraint, MatchConstraint, OrConstraint, AbstractQuery, \ + FetchById +from linkml_runtime.utils.yamlutils import YAMLRoot + + +@dataclass +class MatchExpression: + """ + A simple expression to be used in a constaint, e.g + + * `== 5` + * `like "foo%"` + """ + op: str + right: Any + +def create_match_constraint(left: str, right: Any, op: str = "==") -> MatchConstraint: + if isinstance(right, MatchExpression): + return MatchConstraint(left=left, op=right.op, right=right.right) + else: + return MatchConstraint(op=op, left=left, right=right) + +@dataclass +class Database: + """ + Abstraction over different datastores + + Currently only one supported + """ + document: YAMLRoot = None + +@dataclass +class QueryEngine(ApiRoot): + """ + Abstract base class for QueryEngine objects for querying over a database + + Here a ref:`Database` can refer to: + * in-memory object tree or JSON document + * external database (SQL, Solr, Triplestore) -- to be implemented in future + + Currently one one implementation: + * ref:`ObjectQueryEngine` -- query over in-memory objects + + future versions may include other implementations + """ + + database: Database = None + + # TODO: avoid repetion with same method + def _get_path(self, query: AbstractQuery, strict=True) -> str: + if query.path is not None: + return query.path + else: + target_cn = query.target_class + sv = self.schemaview + if sv is None: + raise Exception(f'Must pass path OR schemaview') + paths = [] + for cn, c in sv.all_class().items(): + for slot in sv.class_induced_slots(cn): + if slot.inlined and slot.range == target_cn: + k = underscore(slot.name) + paths.append(f'/{k}') + if len(paths) > 1: + if strict: + raise Exception(f'Multiple possible paths: {paths}') + sorted(paths, key=lambda p: len(p.split('/'))) + return paths[0] + elif len(paths) == 1: + return paths[0] + else: + raise Exception(f'No matching top level slot') + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b0471b7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta:__legacy__" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..07f0180 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,38 @@ +[metadata] +name = linkml_runtime_api +url = https://github.com/biolink/linkml-runtime-api +author = Harold Solbrig +author-email = solbrig@jhu.edu +summary = LinkML Runtime Environment API +description = Runtime Environment API for the Linked Open Data Modeling Language +home-page = http://linkml.github.io/linkml-runtime-api +license = CC0 1.0 Universal +python-requires = >=3.7 +classifiers = + Development Status :: 4 - Beta + Environment :: Console + Intended Audience :: Developers + Intended Audience :: Science/Research + Intended Audience :: Healthcare Industry + License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 +keywords = + linkml + lod + rdf + owl + yaml + model + metamodel + +[files] +packages = + linkml_runtime_api + +[entry_points] +console_scripts = + gen-api-datamodel = linkml_runtime_api.generators.apigenerator:cli + gen-python-api = linkml_runtime_api.generators.pyapigenerator:cli diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..aa6b10f --- /dev/null +++ b/setup.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +import sys +from setuptools import setup +from warnings import warn + +if sys.version_info < (3, 7, 0): + warn(f"Some URL processing will fail with python 3.7.5 or earlier. Current version: {sys.version_info}") + +setup( + setup_requires=['pbr'], + pbr=True, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..43cc667 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,10 @@ +import os + +METAMODEL_CONTEXT_URI = "https://w3id.org/linkml/meta.context.jsonld" +META_BASE_URI = "https://w3id.org/linkml/" + +TESTING_DIR = os.path.abspath(os.path.dirname(__file__)) + +INPUT_DIR = os.path.join(TESTING_DIR, 'input') +MODEL_DIR = os.path.join(TESTING_DIR, 'model') +OUTPUT_DIR = os.path.join(TESTING_DIR, 'output') diff --git a/tests/input/kitchen_sink_inst_01.yaml b/tests/input/kitchen_sink_inst_01.yaml new file mode 100644 index 0000000..b1137c6 --- /dev/null +++ b/tests/input/kitchen_sink_inst_01.yaml @@ -0,0 +1,32 @@ +persons: + - id: P:001 + name: fred bloggs + age_in_years: 33 + - id: P:002 + name: joe schmoe + has_employment_history: + - employed_at: ROR:1 + started_at_time: 2019-01-01 + is_current: true + has_familial_relationships: + - related_to: P:001 + type: SIBLING_OF + has_medical_history: + - started_at_time: 2019-01-01 + in_location: GEO:1234 + diagnosis: + id: CODE:D0001 + name: headache + procedure: + id: CODE:P0001 + name: trepanation + addresses: + - street: 1 foo street + city: foo city +companies: + - id: ROR:1 + name: foo +activities: + - id: A:1 + started_at_time: 2019-01-01 + was_associated_with: Agent:987 diff --git a/tests/model/__init__.py b/tests/model/__init__.py new file mode 100644 index 0000000..84d0ca9 --- /dev/null +++ b/tests/model/__init__.py @@ -0,0 +1 @@ +from tests.model.kitchen_sink import * \ No newline at end of file diff --git a/tests/model/kitchen_sink.py b/tests/model/kitchen_sink.py new file mode 100644 index 0000000..963a04e --- /dev/null +++ b/tests/model/kitchen_sink.py @@ -0,0 +1,806 @@ +# Auto generated from kitchen_sink.yaml by pythongen.py version: 0.9.0 +# Generation date: 2021-09-18 18:50 +# Schema: kitchen_sink +# +# id: https://w3id.org/linkml/tests/kitchen_sink +# description: Kitchen Sink Schema (no imports version) This schema does not do anything useful. It exists to test +# all features of linkml. This particular text field exists to demonstrate markdown within a text +# field: Lists: * a * b * c And links, e.g to [Person](Person.md) +# license: https://creativecommons.org/publicdomain/zero/1.0/ + +import dataclasses +import sys +import re +from jsonasobj2 import JsonObj, as_dict +from typing import Optional, List, Union, Dict, ClassVar, Any +from dataclasses import dataclass +from linkml_runtime.linkml_model.meta import EnumDefinition, PermissibleValue, PvFormulaOptions + +from linkml_runtime.utils.slot import Slot +from linkml_runtime.utils.metamodelcore import empty_list, empty_dict, bnode +from linkml_runtime.utils.yamlutils import YAMLRoot, extended_str, extended_float, extended_int +from linkml_runtime.utils.dataclass_extensions_376 import dataclasses_init_fn_with_kwargs +from linkml_runtime.utils.formatutils import camelcase, underscore, sfx +from linkml_runtime.utils.enumerations import EnumDefinitionImpl +from rdflib import Namespace, URIRef +from linkml_runtime.utils.curienamespace import CurieNamespace +from linkml_runtime.linkml_model.types import Boolean, Date, Integer, String +from linkml_runtime.utils.metamodelcore import Bool, XSDDate + +metamodel_version = "1.7.0" + +# Overwrite dataclasses _init_fn to add **kwargs in __init__ +dataclasses._init_fn = dataclasses_init_fn_with_kwargs + +# Namespaces +BFO = CurieNamespace('BFO', 'http://purl.obolibrary.org/obo/BFO_') +RO = CurieNamespace('RO', 'http://purl.obolibrary.org/obo/RO_') +BIOLINK = CurieNamespace('biolink', 'https://w3id.org/biolink/') +DCE = CurieNamespace('dce', 'http://purl.org/dc/elements/1.1/') +KS = CurieNamespace('ks', 'https://w3id.org/linkml/tests/kitchen_sink/') +LEGO = CurieNamespace('lego', 'http://geneontology.org/lego/') +LINKML = CurieNamespace('linkml', 'https://w3id.org/linkml/') +PAV = CurieNamespace('pav', 'http://purl.org/pav/') +PROV = CurieNamespace('prov', 'http://www.w3.org/ns/prov#') +DEFAULT_ = KS + + +# Types + +# Class references +class ThingId(extended_str): + pass + + +class PersonId(ThingId): + pass + + +class AdultId(PersonId): + pass + + +class OrganizationId(ThingId): + pass + + +class PlaceId(extended_str): + pass + + +class ConceptId(extended_str): + pass + + +class DiagnosisConceptId(ConceptId): + pass + + +class ProcedureConceptId(ConceptId): + pass + + +class CompanyId(OrganizationId): + pass + + +class ActivityId(extended_str): + pass + + +class AgentId(extended_str): + pass + + +Any = Any + +@dataclass +class HasAliases(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.HasAliases + class_class_curie: ClassVar[str] = "ks:HasAliases" + class_name: ClassVar[str] = "HasAliases" + class_model_uri: ClassVar[URIRef] = KS.HasAliases + + aliases: Optional[Union[str, List[str]]] = empty_list() + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if not isinstance(self.aliases, list): + self.aliases = [self.aliases] if self.aliases is not None else [] + self.aliases = [v if isinstance(v, str) else str(v) for v in self.aliases] + + super().__post_init__(**kwargs) + + +@dataclass +class Thing(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Thing + class_class_curie: ClassVar[str] = "ks:Thing" + class_name: ClassVar[str] = "Thing" + class_model_uri: ClassVar[URIRef] = KS.Thing + + id: Union[str, ThingId] = None + name: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.id): + self.MissingRequiredField("id") + if not isinstance(self.id, ThingId): + self.id = ThingId(self.id) + + if self.name is not None and not isinstance(self.name, str): + self.name = str(self.name) + + super().__post_init__(**kwargs) + + +@dataclass +class Person(Thing): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Person + class_class_curie: ClassVar[str] = "ks:Person" + class_name: ClassVar[str] = "Person" + class_model_uri: ClassVar[URIRef] = KS.Person + + id: Union[str, PersonId] = None + has_employment_history: Optional[Union[Union[dict, "EmploymentEvent"], List[Union[dict, "EmploymentEvent"]]]] = empty_list() + has_familial_relationships: Optional[Union[Union[dict, "FamilialRelationship"], List[Union[dict, "FamilialRelationship"]]]] = empty_list() + has_medical_history: Optional[Union[Union[dict, "MedicalEvent"], List[Union[dict, "MedicalEvent"]]]] = empty_list() + age_in_years: Optional[int] = None + addresses: Optional[Union[Union[dict, "Address"], List[Union[dict, "Address"]]]] = empty_list() + has_birth_event: Optional[Union[dict, "BirthEvent"]] = None + metadata: Optional[Union[dict, Any]] = None + name: Optional[str] = None + aliases: Optional[Union[str, List[str]]] = empty_list() + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.id): + self.MissingRequiredField("id") + if not isinstance(self.id, PersonId): + self.id = PersonId(self.id) + + if not isinstance(self.has_employment_history, list): + self.has_employment_history = [self.has_employment_history] if self.has_employment_history is not None else [] + self.has_employment_history = [v if isinstance(v, EmploymentEvent) else EmploymentEvent(**as_dict(v)) for v in self.has_employment_history] + + self._normalize_inlined_as_list(slot_name="has_familial_relationships", slot_type=FamilialRelationship, key_name="type", keyed=False) + + if not isinstance(self.has_medical_history, list): + self.has_medical_history = [self.has_medical_history] if self.has_medical_history is not None else [] + self.has_medical_history = [v if isinstance(v, MedicalEvent) else MedicalEvent(**as_dict(v)) for v in self.has_medical_history] + + if self.age_in_years is not None and not isinstance(self.age_in_years, int): + self.age_in_years = int(self.age_in_years) + + if not isinstance(self.addresses, list): + self.addresses = [self.addresses] if self.addresses is not None else [] + self.addresses = [v if isinstance(v, Address) else Address(**as_dict(v)) for v in self.addresses] + + if self.has_birth_event is not None and not isinstance(self.has_birth_event, BirthEvent): + self.has_birth_event = BirthEvent(**as_dict(self.has_birth_event)) + + if self.name is not None and not isinstance(self.name, str): + self.name = str(self.name) + + if not isinstance(self.aliases, list): + self.aliases = [self.aliases] if self.aliases is not None else [] + self.aliases = [v if isinstance(v, str) else str(v) for v in self.aliases] + + super().__post_init__(**kwargs) + + +@dataclass +class Adult(Person): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Adult + class_class_curie: ClassVar[str] = "ks:Adult" + class_name: ClassVar[str] = "Adult" + class_model_uri: ClassVar[URIRef] = KS.Adult + + id: Union[str, AdultId] = None + age_in_years: Optional[int] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.id): + self.MissingRequiredField("id") + if not isinstance(self.id, AdultId): + self.id = AdultId(self.id) + + if self.age_in_years is not None and not isinstance(self.age_in_years, int): + self.age_in_years = int(self.age_in_years) + + super().__post_init__(**kwargs) + + +@dataclass +class Organization(Thing): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Organization + class_class_curie: ClassVar[str] = "ks:Organization" + class_name: ClassVar[str] = "Organization" + class_model_uri: ClassVar[URIRef] = KS.Organization + + id: Union[str, OrganizationId] = None + aliases: Optional[Union[str, List[str]]] = empty_list() + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.id): + self.MissingRequiredField("id") + if not isinstance(self.id, OrganizationId): + self.id = OrganizationId(self.id) + + if not isinstance(self.aliases, list): + self.aliases = [self.aliases] if self.aliases is not None else [] + self.aliases = [v if isinstance(v, str) else str(v) for v in self.aliases] + + super().__post_init__(**kwargs) + + +@dataclass +class Place(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Place + class_class_curie: ClassVar[str] = "ks:Place" + class_name: ClassVar[str] = "Place" + class_model_uri: ClassVar[URIRef] = KS.Place + + id: Union[str, PlaceId] = None + name: Optional[str] = None + aliases: Optional[Union[str, List[str]]] = empty_list() + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.id): + self.MissingRequiredField("id") + if not isinstance(self.id, PlaceId): + self.id = PlaceId(self.id) + + if self.name is not None and not isinstance(self.name, str): + self.name = str(self.name) + + if not isinstance(self.aliases, list): + self.aliases = [self.aliases] if self.aliases is not None else [] + self.aliases = [v if isinstance(v, str) else str(v) for v in self.aliases] + + super().__post_init__(**kwargs) + + +@dataclass +class Address(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Address + class_class_curie: ClassVar[str] = "ks:Address" + class_name: ClassVar[str] = "Address" + class_model_uri: ClassVar[URIRef] = KS.Address + + street: Optional[str] = None + city: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.street is not None and not isinstance(self.street, str): + self.street = str(self.street) + + if self.city is not None and not isinstance(self.city, str): + self.city = str(self.city) + + super().__post_init__(**kwargs) + + +@dataclass +class Concept(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Concept + class_class_curie: ClassVar[str] = "ks:Concept" + class_name: ClassVar[str] = "Concept" + class_model_uri: ClassVar[URIRef] = KS.Concept + + id: Union[str, ConceptId] = None + name: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.id): + self.MissingRequiredField("id") + if not isinstance(self.id, ConceptId): + self.id = ConceptId(self.id) + + if self.name is not None and not isinstance(self.name, str): + self.name = str(self.name) + + super().__post_init__(**kwargs) + + +@dataclass +class DiagnosisConcept(Concept): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.DiagnosisConcept + class_class_curie: ClassVar[str] = "ks:DiagnosisConcept" + class_name: ClassVar[str] = "DiagnosisConcept" + class_model_uri: ClassVar[URIRef] = KS.DiagnosisConcept + + id: Union[str, DiagnosisConceptId] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.id): + self.MissingRequiredField("id") + if not isinstance(self.id, DiagnosisConceptId): + self.id = DiagnosisConceptId(self.id) + + super().__post_init__(**kwargs) + + +@dataclass +class ProcedureConcept(Concept): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.ProcedureConcept + class_class_curie: ClassVar[str] = "ks:ProcedureConcept" + class_name: ClassVar[str] = "ProcedureConcept" + class_model_uri: ClassVar[URIRef] = KS.ProcedureConcept + + id: Union[str, ProcedureConceptId] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.id): + self.MissingRequiredField("id") + if not isinstance(self.id, ProcedureConceptId): + self.id = ProcedureConceptId(self.id) + + super().__post_init__(**kwargs) + + +@dataclass +class Event(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Event + class_class_curie: ClassVar[str] = "ks:Event" + class_name: ClassVar[str] = "Event" + class_model_uri: ClassVar[URIRef] = KS.Event + + started_at_time: Optional[Union[str, XSDDate]] = None + ended_at_time: Optional[Union[str, XSDDate]] = None + is_current: Optional[Union[bool, Bool]] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.started_at_time is not None and not isinstance(self.started_at_time, XSDDate): + self.started_at_time = XSDDate(self.started_at_time) + + if self.ended_at_time is not None and not isinstance(self.ended_at_time, XSDDate): + self.ended_at_time = XSDDate(self.ended_at_time) + + if self.is_current is not None and not isinstance(self.is_current, Bool): + self.is_current = Bool(self.is_current) + + super().__post_init__(**kwargs) + + +@dataclass +class Relationship(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Relationship + class_class_curie: ClassVar[str] = "ks:Relationship" + class_name: ClassVar[str] = "Relationship" + class_model_uri: ClassVar[URIRef] = KS.Relationship + + started_at_time: Optional[Union[str, XSDDate]] = None + ended_at_time: Optional[Union[str, XSDDate]] = None + related_to: Optional[Union[str, ThingId]] = None + type: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.started_at_time is not None and not isinstance(self.started_at_time, XSDDate): + self.started_at_time = XSDDate(self.started_at_time) + + if self.ended_at_time is not None and not isinstance(self.ended_at_time, XSDDate): + self.ended_at_time = XSDDate(self.ended_at_time) + + if self.related_to is not None and not isinstance(self.related_to, ThingId): + self.related_to = ThingId(self.related_to) + + if self.type is not None and not isinstance(self.type, str): + self.type = str(self.type) + + super().__post_init__(**kwargs) + + +@dataclass +class FamilialRelationship(Relationship): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.FamilialRelationship + class_class_curie: ClassVar[str] = "ks:FamilialRelationship" + class_name: ClassVar[str] = "FamilialRelationship" + class_model_uri: ClassVar[URIRef] = KS.FamilialRelationship + + type: Union[str, "FamilialRelationshipType"] = None + related_to: Union[str, PersonId] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.type): + self.MissingRequiredField("type") + if not isinstance(self.type, FamilialRelationshipType): + self.type = FamilialRelationshipType(self.type) + + if self._is_empty(self.related_to): + self.MissingRequiredField("related_to") + if not isinstance(self.related_to, PersonId): + self.related_to = PersonId(self.related_to) + + super().__post_init__(**kwargs) + + +@dataclass +class BirthEvent(Event): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.BirthEvent + class_class_curie: ClassVar[str] = "ks:BirthEvent" + class_name: ClassVar[str] = "BirthEvent" + class_model_uri: ClassVar[URIRef] = KS.BirthEvent + + in_location: Optional[Union[str, PlaceId]] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.in_location is not None and not isinstance(self.in_location, PlaceId): + self.in_location = PlaceId(self.in_location) + + super().__post_init__(**kwargs) + + +@dataclass +class EmploymentEvent(Event): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.EmploymentEvent + class_class_curie: ClassVar[str] = "ks:EmploymentEvent" + class_name: ClassVar[str] = "EmploymentEvent" + class_model_uri: ClassVar[URIRef] = KS.EmploymentEvent + + employed_at: Optional[Union[str, CompanyId]] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.employed_at is not None and not isinstance(self.employed_at, CompanyId): + self.employed_at = CompanyId(self.employed_at) + + super().__post_init__(**kwargs) + + +@dataclass +class MedicalEvent(Event): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.MedicalEvent + class_class_curie: ClassVar[str] = "ks:MedicalEvent" + class_name: ClassVar[str] = "MedicalEvent" + class_model_uri: ClassVar[URIRef] = KS.MedicalEvent + + in_location: Optional[Union[str, PlaceId]] = None + diagnosis: Optional[Union[dict, DiagnosisConcept]] = None + procedure: Optional[Union[dict, ProcedureConcept]] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.in_location is not None and not isinstance(self.in_location, PlaceId): + self.in_location = PlaceId(self.in_location) + + if self.diagnosis is not None and not isinstance(self.diagnosis, DiagnosisConcept): + self.diagnosis = DiagnosisConcept(**as_dict(self.diagnosis)) + + if self.procedure is not None and not isinstance(self.procedure, ProcedureConcept): + self.procedure = ProcedureConcept(**as_dict(self.procedure)) + + super().__post_init__(**kwargs) + + +@dataclass +class WithLocation(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.WithLocation + class_class_curie: ClassVar[str] = "ks:WithLocation" + class_name: ClassVar[str] = "WithLocation" + class_model_uri: ClassVar[URIRef] = KS.WithLocation + + in_location: Optional[Union[str, PlaceId]] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.in_location is not None and not isinstance(self.in_location, PlaceId): + self.in_location = PlaceId(self.in_location) + + super().__post_init__(**kwargs) + + +@dataclass +class MarriageEvent(Event): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.MarriageEvent + class_class_curie: ClassVar[str] = "ks:MarriageEvent" + class_name: ClassVar[str] = "MarriageEvent" + class_model_uri: ClassVar[URIRef] = KS.MarriageEvent + + married_to: Optional[Union[str, PersonId]] = None + in_location: Optional[Union[str, PlaceId]] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.married_to is not None and not isinstance(self.married_to, PersonId): + self.married_to = PersonId(self.married_to) + + if self.in_location is not None and not isinstance(self.in_location, PlaceId): + self.in_location = PlaceId(self.in_location) + + super().__post_init__(**kwargs) + + +@dataclass +class Company(Organization): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Company + class_class_curie: ClassVar[str] = "ks:Company" + class_name: ClassVar[str] = "Company" + class_model_uri: ClassVar[URIRef] = KS.Company + + id: Union[str, CompanyId] = None + ceo: Optional[Union[str, PersonId]] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.id): + self.MissingRequiredField("id") + if not isinstance(self.id, CompanyId): + self.id = CompanyId(self.id) + + if self.ceo is not None and not isinstance(self.ceo, PersonId): + self.ceo = PersonId(self.ceo) + + super().__post_init__(**kwargs) + + +@dataclass +class Dataset(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Dataset + class_class_curie: ClassVar[str] = "ks:Dataset" + class_name: ClassVar[str] = "Dataset" + class_model_uri: ClassVar[URIRef] = KS.Dataset + + persons: Optional[Union[Dict[Union[str, PersonId], Union[dict, Person]], List[Union[dict, Person]]]] = empty_dict() + companies: Optional[Union[Dict[Union[str, CompanyId], Union[dict, Company]], List[Union[dict, Company]]]] = empty_dict() + activities: Optional[Union[Dict[Union[str, ActivityId], Union[dict, "Activity"]], List[Union[dict, "Activity"]]]] = empty_dict() + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + self._normalize_inlined_as_list(slot_name="persons", slot_type=Person, key_name="id", keyed=True) + + self._normalize_inlined_as_list(slot_name="companies", slot_type=Company, key_name="id", keyed=True) + + self._normalize_inlined_as_list(slot_name="activities", slot_type=Activity, key_name="id", keyed=True) + + super().__post_init__(**kwargs) + + +@dataclass +class Activity(YAMLRoot): + """ + a provence-generating activity + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KS.Activity + class_class_curie: ClassVar[str] = "ks:Activity" + class_name: ClassVar[str] = "activity" + class_model_uri: ClassVar[URIRef] = KS.Activity + + id: Union[str, ActivityId] = None + started_at_time: Optional[Union[str, XSDDate]] = None + ended_at_time: Optional[Union[str, XSDDate]] = None + was_informed_by: Optional[Union[str, ActivityId]] = None + was_associated_with: Optional[Union[str, AgentId]] = None + used: Optional[str] = None + description: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.id): + self.MissingRequiredField("id") + if not isinstance(self.id, ActivityId): + self.id = ActivityId(self.id) + + if self.started_at_time is not None and not isinstance(self.started_at_time, XSDDate): + self.started_at_time = XSDDate(self.started_at_time) + + if self.ended_at_time is not None and not isinstance(self.ended_at_time, XSDDate): + self.ended_at_time = XSDDate(self.ended_at_time) + + if self.was_informed_by is not None and not isinstance(self.was_informed_by, ActivityId): + self.was_informed_by = ActivityId(self.was_informed_by) + + if self.was_associated_with is not None and not isinstance(self.was_associated_with, AgentId): + self.was_associated_with = AgentId(self.was_associated_with) + + if self.used is not None and not isinstance(self.used, str): + self.used = str(self.used) + + if self.description is not None and not isinstance(self.description, str): + self.description = str(self.description) + + super().__post_init__(**kwargs) + + +@dataclass +class Agent(YAMLRoot): + """ + a provence-generating agent + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = PROV.Agent + class_class_curie: ClassVar[str] = "prov:Agent" + class_name: ClassVar[str] = "agent" + class_model_uri: ClassVar[URIRef] = KS.Agent + + id: Union[str, AgentId] = None + acted_on_behalf_of: Optional[Union[str, AgentId]] = None + was_informed_by: Optional[Union[str, ActivityId]] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self._is_empty(self.id): + self.MissingRequiredField("id") + if not isinstance(self.id, AgentId): + self.id = AgentId(self.id) + + if self.acted_on_behalf_of is not None and not isinstance(self.acted_on_behalf_of, AgentId): + self.acted_on_behalf_of = AgentId(self.acted_on_behalf_of) + + if self.was_informed_by is not None and not isinstance(self.was_informed_by, ActivityId): + self.was_informed_by = ActivityId(self.was_informed_by) + + super().__post_init__(**kwargs) + + +# Enumerations +class FamilialRelationshipType(EnumDefinitionImpl): + + SIBLING_OF = PermissibleValue(text="SIBLING_OF") + PARENT_OF = PermissibleValue(text="PARENT_OF") + CHILD_OF = PermissibleValue(text="CHILD_OF") + + _defn = EnumDefinition( + name="FamilialRelationshipType", + ) + +class DiagnosisType(EnumDefinitionImpl): + + _defn = EnumDefinition( + name="DiagnosisType", + ) + +# Slots +class slots: + pass + +slots.employed_at = Slot(uri=KS.employed_at, name="employed at", curie=KS.curie('employed_at'), + model_uri=KS.employed_at, domain=None, range=Optional[Union[str, CompanyId]]) + +slots.is_current = Slot(uri=KS.is_current, name="is current", curie=KS.curie('is_current'), + model_uri=KS.is_current, domain=None, range=Optional[Union[bool, Bool]]) + +slots.has_employment_history = Slot(uri=KS.has_employment_history, name="has employment history", curie=KS.curie('has_employment_history'), + model_uri=KS.has_employment_history, domain=None, range=Optional[Union[Union[dict, EmploymentEvent], List[Union[dict, EmploymentEvent]]]]) + +slots.has_marriage_history = Slot(uri=KS.has_marriage_history, name="has marriage history", curie=KS.curie('has_marriage_history'), + model_uri=KS.has_marriage_history, domain=None, range=Optional[Union[Union[dict, MarriageEvent], List[Union[dict, MarriageEvent]]]]) + +slots.has_medical_history = Slot(uri=KS.has_medical_history, name="has medical history", curie=KS.curie('has_medical_history'), + model_uri=KS.has_medical_history, domain=None, range=Optional[Union[Union[dict, MedicalEvent], List[Union[dict, MedicalEvent]]]]) + +slots.has_familial_relationships = Slot(uri=KS.has_familial_relationships, name="has familial relationships", curie=KS.curie('has_familial_relationships'), + model_uri=KS.has_familial_relationships, domain=None, range=Optional[Union[Union[dict, FamilialRelationship], List[Union[dict, FamilialRelationship]]]]) + +slots.married_to = Slot(uri=KS.married_to, name="married to", curie=KS.curie('married_to'), + model_uri=KS.married_to, domain=None, range=Optional[Union[str, PersonId]]) + +slots.in_location = Slot(uri=KS.in_location, name="in location", curie=KS.curie('in_location'), + model_uri=KS.in_location, domain=None, range=Optional[Union[str, PlaceId]]) + +slots.diagnosis = Slot(uri=KS.diagnosis, name="diagnosis", curie=KS.curie('diagnosis'), + model_uri=KS.diagnosis, domain=None, range=Optional[Union[dict, DiagnosisConcept]]) + +slots.procedure = Slot(uri=KS.procedure, name="procedure", curie=KS.curie('procedure'), + model_uri=KS.procedure, domain=None, range=Optional[Union[dict, ProcedureConcept]]) + +slots.addresses = Slot(uri=KS.addresses, name="addresses", curie=KS.curie('addresses'), + model_uri=KS.addresses, domain=None, range=Optional[Union[Union[dict, Address], List[Union[dict, Address]]]]) + +slots.age_in_years = Slot(uri=KS.age_in_years, name="age in years", curie=KS.curie('age_in_years'), + model_uri=KS.age_in_years, domain=None, range=Optional[int]) + +slots.related_to = Slot(uri=KS.related_to, name="related to", curie=KS.curie('related_to'), + model_uri=KS.related_to, domain=None, range=Optional[Union[str, ThingId]]) + +slots.type = Slot(uri=KS.type, name="type", curie=KS.curie('type'), + model_uri=KS.type, domain=None, range=Optional[str]) + +slots.street = Slot(uri=KS.street, name="street", curie=KS.curie('street'), + model_uri=KS.street, domain=None, range=Optional[str]) + +slots.city = Slot(uri=KS.city, name="city", curie=KS.curie('city'), + model_uri=KS.city, domain=None, range=Optional[str]) + +slots.has_birth_event = Slot(uri=KS.has_birth_event, name="has birth event", curie=KS.curie('has_birth_event'), + model_uri=KS.has_birth_event, domain=None, range=Optional[Union[dict, BirthEvent]]) + +slots.id = Slot(uri=KS.id, name="id", curie=KS.curie('id'), + model_uri=KS.id, domain=None, range=URIRef) + +slots.name = Slot(uri=KS.name, name="name", curie=KS.curie('name'), + model_uri=KS.name, domain=None, range=Optional[str]) + +slots.description = Slot(uri=KS.description, name="description", curie=KS.curie('description'), + model_uri=KS.description, domain=None, range=Optional[str]) + +slots.started_at_time = Slot(uri=PROV.startedAtTime, name="started at time", curie=PROV.curie('startedAtTime'), + model_uri=KS.started_at_time, domain=None, range=Optional[Union[str, XSDDate]]) + +slots.ended_at_time = Slot(uri=PROV.endedAtTime, name="ended at time", curie=PROV.curie('endedAtTime'), + model_uri=KS.ended_at_time, domain=None, range=Optional[Union[str, XSDDate]]) + +slots.was_informed_by = Slot(uri=PROV.wasInformedBy, name="was informed by", curie=PROV.curie('wasInformedBy'), + model_uri=KS.was_informed_by, domain=None, range=Optional[Union[str, ActivityId]]) + +slots.was_associated_with = Slot(uri=PROV.wasAssociatedWith, name="was associated with", curie=PROV.curie('wasAssociatedWith'), + model_uri=KS.was_associated_with, domain=None, range=Optional[Union[str, AgentId]]) + +slots.acted_on_behalf_of = Slot(uri=PROV.actedOnBehalfOf, name="acted on behalf of", curie=PROV.curie('actedOnBehalfOf'), + model_uri=KS.acted_on_behalf_of, domain=None, range=Optional[Union[str, AgentId]]) + +slots.was_generated_by = Slot(uri=PROV.wasGeneratedBy, name="was generated by", curie=PROV.curie('wasGeneratedBy'), + model_uri=KS.was_generated_by, domain=None, range=Optional[Union[str, ActivityId]]) + +slots.used = Slot(uri=PROV.used, name="used", curie=PROV.curie('used'), + model_uri=KS.used, domain=Activity, range=Optional[str]) + +slots.activity_set = Slot(uri=KS.activity_set, name="activity set", curie=KS.curie('activity_set'), + model_uri=KS.activity_set, domain=None, range=Optional[Union[Dict[Union[str, ActivityId], Union[dict, Activity]], List[Union[dict, Activity]]]]) + +slots.agent_set = Slot(uri=KS.agent_set, name="agent set", curie=KS.curie('agent_set'), + model_uri=KS.agent_set, domain=None, range=Optional[Union[Dict[Union[str, AgentId], Union[dict, Agent]], List[Union[dict, Agent]]]]) + +slots.metadata = Slot(uri=KS.metadata, name="metadata", curie=KS.curie('metadata'), + model_uri=KS.metadata, domain=None, range=Optional[Union[dict, Any]]) + +slots.hasAliases__aliases = Slot(uri=KS.aliases, name="hasAliases__aliases", curie=KS.curie('aliases'), + model_uri=KS.hasAliases__aliases, domain=None, range=Optional[Union[str, List[str]]]) + +slots.company__ceo = Slot(uri=KS.ceo, name="company__ceo", curie=KS.curie('ceo'), + model_uri=KS.company__ceo, domain=None, range=Optional[Union[str, PersonId]]) + +slots.dataset__persons = Slot(uri=KS.persons, name="dataset__persons", curie=KS.curie('persons'), + model_uri=KS.dataset__persons, domain=None, range=Optional[Union[Dict[Union[str, PersonId], Union[dict, Person]], List[Union[dict, Person]]]]) + +slots.dataset__companies = Slot(uri=KS.companies, name="dataset__companies", curie=KS.curie('companies'), + model_uri=KS.dataset__companies, domain=None, range=Optional[Union[Dict[Union[str, CompanyId], Union[dict, Company]], List[Union[dict, Company]]]]) + +slots.dataset__activities = Slot(uri=KS.activities, name="dataset__activities", curie=KS.curie('activities'), + model_uri=KS.dataset__activities, domain=None, range=Optional[Union[Dict[Union[str, ActivityId], Union[dict, Activity]], List[Union[dict, Activity]]]]) + +slots.Person_name = Slot(uri=KS.name, name="Person_name", curie=KS.curie('name'), + model_uri=KS.Person_name, domain=Person, range=Optional[str], + pattern=re.compile(r'^\S+ \S+')) + +slots.Adult_age_in_years = Slot(uri=KS.age_in_years, name="Adult_age in years", curie=KS.curie('age_in_years'), + model_uri=KS.Adult_age_in_years, domain=Adult, range=Optional[int]) + +slots.FamilialRelationship_type = Slot(uri=KS.type, name="FamilialRelationship_type", curie=KS.curie('type'), + model_uri=KS.FamilialRelationship_type, domain=FamilialRelationship, range=Union[str, "FamilialRelationshipType"]) + +slots.FamilialRelationship_related_to = Slot(uri=KS.related_to, name="FamilialRelationship_related to", curie=KS.curie('related_to'), + model_uri=KS.FamilialRelationship_related_to, domain=FamilialRelationship, range=Union[str, PersonId]) diff --git a/tests/model/kitchen_sink.yaml b/tests/model/kitchen_sink.yaml new file mode 100644 index 0000000..e5c08e2 --- /dev/null +++ b/tests/model/kitchen_sink.yaml @@ -0,0 +1,356 @@ +id: https://w3id.org/linkml/tests/kitchen_sink +name: kitchen_sink +description: |- + Kitchen Sink Schema (no imports version) + + This schema does not do anything useful. It exists to test all features of linkml. + + This particular text field exists to demonstrate markdown within a text field: + + Lists: + + * a + * b + * c + + And links, e.g to [Person](Person.md) + +default_curi_maps: + - semweb_context +prefixes: + pav: http://purl.org/pav/ + dce: http://purl.org/dc/elements/1.1/ + lego: http://geneontology.org/lego/ + linkml: https://w3id.org/linkml/ + biolink: https://w3id.org/biolink/ + ks: https://w3id.org/linkml/tests/kitchen_sink/ + RO: http://purl.obolibrary.org/obo/RO_ + BFO: http://purl.obolibrary.org/obo/BFO_ +default_prefix: ks +default_range: string +see_also: + - https://example.org/ + +imports: + - linkml:types + +subsets: + + subset A: + description: >- + test subset A + comments: + - this subset is meaningless, it is just here for testing + aliases: + - A + subset B: + description: >- + test subset B + aliases: + - B + +# -- +# Classes +# -- +classes: + + Any: + class_uri: linkml:Any + abstract: true + + # -- + # Mixins + # -- + HasAliases: + mixin: true + attributes: + aliases: + multivalued: true + + # -- + # Main Classes + # -- + Thing: + abstract: true + slots: + - id + - name + + Person: + is_a: Thing + in_subset: + - subset A + mixins: + - HasAliases + slots: + - has employment history + - has familial relationships + - has medical history + - age in years + - addresses + - has birth event + - metadata + slot_usage: + name: + pattern: "^\\S+ \\S+" ## do not do this in a real schema, people have all kinds of names + + Adult: + is_a: Person + slot_usage: + age in years: + minimum_value: 16 + + Organization: + is_a: Thing + mixins: + - HasAliases + + Place: + mixins: + - HasAliases + slots: + - id + - name + Address: + slots: + - street + - city + + Concept: + slots: + - id + - name + + DiagnosisConcept: + is_a: Concept + + ProcedureConcept: + is_a: Concept + + Event: + slots: + - started at time + - ended at time + - is current + + Relationship: + slots: + - started at time + - ended at time + - related to + - type + + FamilialRelationship: + is_a: Relationship + slot_usage: + type: + range: FamilialRelationshipType + required: true + related to: + range: Person + required: true + + BirthEvent: + is_a: Event + slots: + - in location + + EmploymentEvent: + is_a: Event + slots: + - employed at + + MedicalEvent: + is_a: Event + slots: + - in location + - diagnosis + - procedure + + WithLocation: + mixin: true + slots: + - in location + + MarriageEvent: + is_a: Event + mixins: + - WithLocation + slots: + - married to + + Company: + is_a: Organization + attributes: + ceo: + range: Person + + Dataset: + attributes: + persons: + range: Person + inlined: true + inlined_as_list: true + multivalued: true + companies: + range: Company + inlined_as_list: true + inlined: true + multivalued: true + activities: + range: activity + inlined_as_list: true + inlined: true + multivalued: true + + activity: + description: "a provence-generating activity" + slots: + - id + - started at time + - ended at time + - was informed by + - was associated with + - used + - description + exact_mappings: + - prov:Activity + + agent: + description: "a provence-generating agent" + slots: + - id + - acted on behalf of + - was informed by + class_uri: prov:Agent + +slots: + employed at: + range: Company + in_subset: + - subset A + annotations: + - tag: "ks:a1" + value: [1,2,3] + - tag: "ks:a2" + value: ["v1", "v2", "v3"] + - tag: "ks:a3" + value: 'a3.1' + - tag: "ks:a3" + value: 'v3.2' + is current: + range: boolean + annotations: + "ks:foo": bar + has employment history: + range: EmploymentEvent + multivalued: true + inlined_as_list: true + in_subset: + - subset B + annotations: + "ks:mv": 1 + has marriage history: + range: MarriageEvent + multivalued: true + inlined_as_list: true + in_subset: + - subset B + has medical history: + range: MedicalEvent + multivalued: true + inlined_as_list: true + in_subset: + - subset B + has familial relationships: + range: FamilialRelationship + multivalued: true + inlined_as_list: true + in_subset: + - subset B + married to: + range: Person + in location: + range: Place + diagnosis: + range: DiagnosisConcept + inlined: true + procedure: + range: ProcedureConcept + inlined: true + addresses: + range: Address + multivalued: True + age in years: + range: integer + minimum_value: 0 + maximum_value: 999 + in_subset: + - subset A + - subset B + related to: + range: Thing + type: + range: string + street: + city: + has birth event: + range: BirthEvent + + id: + identifier: true + + name: + required: false + + description: + + started at time: + slot_uri: prov:startedAtTime + range: date + + ended at time: + slot_uri: prov:endedAtTime + range: date + + was informed by: + range: activity + slot_uri: prov:wasInformedBy + + was associated with: + range: agent + slot_uri: prov:wasAssociatedWith + inlined: false + + acted on behalf of: + range: agent + slot_uri: prov:actedOnBehalfOf + + was generated by: + range: activity + slot_uri: prov:wasGeneratedBy + + used: + domain: activity + slot_uri: prov:used + + activity set: + range: activity + multivalued: true + inlined_as_list: true + + agent set: + range: agent + multivalued: true + inlined_as_list: true + + metadata: + range: Any + +enums: + FamilialRelationshipType: + permissible_values: + SIBLING_OF: + PARENT_OF: + CHILD_OF: + DiagnosisType: diff --git a/tests/model/kitchen_sink_api.py b/tests/model/kitchen_sink_api.py new file mode 100644 index 0000000..4a2a0ed --- /dev/null +++ b/tests/model/kitchen_sink_api.py @@ -0,0 +1,408 @@ +# Auto generated from kitchen_sink_api.yaml by pythongen.py version: 0.9.0 +# Generation date: 2021-09-19 04:09 +# Schema: kitchen_sink_api +# +# id: https://w3id.org/linkml/tests/kitchen_sink_api +# description: API for querying and manipulating objects from the kitchen_sink schema +# license: https://creativecommons.org/publicdomain/zero/1.0/ + +import dataclasses +import sys +import re +from jsonasobj2 import JsonObj, as_dict +from typing import Optional, List, Union, Dict, ClassVar, Any +from dataclasses import dataclass +from linkml_runtime.linkml_model.meta import EnumDefinition, PermissibleValue, PvFormulaOptions + +from linkml_runtime.utils.slot import Slot +from linkml_runtime.utils.metamodelcore import empty_list, empty_dict, bnode +from linkml_runtime.utils.yamlutils import YAMLRoot, extended_str, extended_float, extended_int +from linkml_runtime.utils.dataclass_extensions_376 import dataclasses_init_fn_with_kwargs +from linkml_runtime.utils.formatutils import camelcase, underscore, sfx +from linkml_runtime.utils.enumerations import EnumDefinitionImpl +from rdflib import Namespace, URIRef +from linkml_runtime.utils.curienamespace import CurieNamespace +from . kitchen_sink import Activity, ActivityId, Any, Company, CompanyId, Person, PersonId +from linkml_runtime.linkml_model.types import String + +metamodel_version = "1.7.0" + +# Overwrite dataclasses _init_fn to add **kwargs in __init__ +dataclasses._init_fn = dataclasses_init_fn_with_kwargs + +# Namespaces +KITCHEN_SINK_API = CurieNamespace('kitchen_sink_api', 'https://w3id.org/linkml/tests/kitchen_sink_api/') +LINKML = CurieNamespace('linkml', 'https://w3id.org/linkml/') +DEFAULT_ = KITCHEN_SINK_API + + +# Types + +# Class references + + + +@dataclass +class LocalChange(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.LocalChange + class_class_curie: ClassVar[str] = "kitchen_sink_api:LocalChange" + class_name: ClassVar[str] = "LocalChange" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.LocalChange + + value: Optional[str] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.value is not None and not isinstance(self.value, str): + self.value = str(self.value) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class LocalQuery(YAMLRoot): + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.LocalQuery + class_class_curie: ClassVar[str] = "kitchen_sink_api:LocalQuery" + class_name: ClassVar[str] = "LocalQuery" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.LocalQuery + + target_class: Optional[str] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.target_class is not None and not isinstance(self.target_class, str): + self.target_class = str(self.target_class) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class AddPerson(YAMLRoot): + """ + A change action that adds a Person to a database + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.AddPerson + class_class_curie: ClassVar[str] = "kitchen_sink_api:AddPerson" + class_name: ClassVar[str] = "AddPerson" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.AddPerson + + value: Optional[Union[dict, Person]] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.value is not None and not isinstance(self.value, Person): + self.value = Person(**as_dict(self.value)) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class RemovePerson(YAMLRoot): + """ + A change action that remoaves a Person to a database + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.RemovePerson + class_class_curie: ClassVar[str] = "kitchen_sink_api:RemovePerson" + class_name: ClassVar[str] = "RemovePerson" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.RemovePerson + + value: Optional[Union[dict, Person]] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.value is not None and not isinstance(self.value, Person): + self.value = Person(**as_dict(self.value)) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class PersonQuery(YAMLRoot): + """ + A query object for instances of Person from a database + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.PersonQuery + class_class_curie: ClassVar[str] = "kitchen_sink_api:PersonQuery" + class_name: ClassVar[str] = "PersonQuery" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.PersonQuery + + constraints: Optional[Union[dict, Any]] = None + value: Optional[str] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.value is not None and not isinstance(self.value, str): + self.value = str(self.value) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class PersonFetchById(YAMLRoot): + """ + A query object for fetching an instance of Person from a database by id + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.PersonFetchById + class_class_curie: ClassVar[str] = "kitchen_sink_api:PersonFetchById" + class_name: ClassVar[str] = "PersonFetchById" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.PersonFetchById + + id_value: Optional[str] = None + value: Optional[str] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.id_value is not None and not isinstance(self.id_value, str): + self.id_value = str(self.id_value) + + if self.value is not None and not isinstance(self.value, str): + self.value = str(self.value) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class AddCompany(YAMLRoot): + """ + A change action that adds a Company to a database + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.AddCompany + class_class_curie: ClassVar[str] = "kitchen_sink_api:AddCompany" + class_name: ClassVar[str] = "AddCompany" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.AddCompany + + value: Optional[Union[dict, Company]] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.value is not None and not isinstance(self.value, Company): + self.value = Company(**as_dict(self.value)) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class RemoveCompany(YAMLRoot): + """ + A change action that remoaves a Company to a database + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.RemoveCompany + class_class_curie: ClassVar[str] = "kitchen_sink_api:RemoveCompany" + class_name: ClassVar[str] = "RemoveCompany" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.RemoveCompany + + value: Optional[Union[dict, Company]] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.value is not None and not isinstance(self.value, Company): + self.value = Company(**as_dict(self.value)) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class CompanyQuery(YAMLRoot): + """ + A query object for instances of Company from a database + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.CompanyQuery + class_class_curie: ClassVar[str] = "kitchen_sink_api:CompanyQuery" + class_name: ClassVar[str] = "CompanyQuery" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.CompanyQuery + + constraints: Optional[Union[dict, Any]] = None + value: Optional[str] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.value is not None and not isinstance(self.value, str): + self.value = str(self.value) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class CompanyFetchById(YAMLRoot): + """ + A query object for fetching an instance of Company from a database by id + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.CompanyFetchById + class_class_curie: ClassVar[str] = "kitchen_sink_api:CompanyFetchById" + class_name: ClassVar[str] = "CompanyFetchById" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.CompanyFetchById + + id_value: Optional[str] = None + value: Optional[str] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.id_value is not None and not isinstance(self.id_value, str): + self.id_value = str(self.id_value) + + if self.value is not None and not isinstance(self.value, str): + self.value = str(self.value) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class AddActivity(YAMLRoot): + """ + A change action that adds a Activity to a database + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.AddActivity + class_class_curie: ClassVar[str] = "kitchen_sink_api:AddActivity" + class_name: ClassVar[str] = "AddActivity" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.AddActivity + + value: Optional[Union[dict, Activity]] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.value is not None and not isinstance(self.value, Activity): + self.value = Activity(**as_dict(self.value)) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class RemoveActivity(YAMLRoot): + """ + A change action that remoaves a Activity to a database + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.RemoveActivity + class_class_curie: ClassVar[str] = "kitchen_sink_api:RemoveActivity" + class_name: ClassVar[str] = "RemoveActivity" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.RemoveActivity + + value: Optional[Union[dict, Activity]] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.value is not None and not isinstance(self.value, Activity): + self.value = Activity(**as_dict(self.value)) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class ActivityQuery(YAMLRoot): + """ + A query object for instances of Activity from a database + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.ActivityQuery + class_class_curie: ClassVar[str] = "kitchen_sink_api:ActivityQuery" + class_name: ClassVar[str] = "ActivityQuery" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.ActivityQuery + + constraints: Optional[Union[dict, Any]] = None + value: Optional[str] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.value is not None and not isinstance(self.value, str): + self.value = str(self.value) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +@dataclass +class ActivityFetchById(YAMLRoot): + """ + A query object for fetching an instance of Activity from a database by id + """ + _inherited_slots: ClassVar[List[str]] = [] + + class_class_uri: ClassVar[URIRef] = KITCHEN_SINK_API.ActivityFetchById + class_class_curie: ClassVar[str] = "kitchen_sink_api:ActivityFetchById" + class_name: ClassVar[str] = "ActivityFetchById" + class_model_uri: ClassVar[URIRef] = KITCHEN_SINK_API.ActivityFetchById + + id_value: Optional[str] = None + value: Optional[str] = None + path: Optional[str] = None + + def __post_init__(self, *_: List[str], **kwargs: Dict[str, Any]): + if self.id_value is not None and not isinstance(self.id_value, str): + self.id_value = str(self.id_value) + + if self.value is not None and not isinstance(self.value, str): + self.value = str(self.value) + + if self.path is not None and not isinstance(self.path, str): + self.path = str(self.path) + + super().__post_init__(**kwargs) + + +# Enumerations + + +# Slots + diff --git a/tests/model/kitchen_sink_api.yaml b/tests/model/kitchen_sink_api.yaml new file mode 100644 index 0000000..6c3568a --- /dev/null +++ b/tests/model/kitchen_sink_api.yaml @@ -0,0 +1,130 @@ +id: https://w3id.org/linkml/tests/kitchen_sink_api +name: kitchen_sink_api +description: API for querying and manipulating objects from the kitchen_sink schema +prefixes: + kitchen_sink_api: https://w3id.org/linkml/tests/kitchen_sink_api/ + linkml: https://w3id.org/linkml/ +default_prefix: kitchen_sink_api +imports: +- linkml:types +- kitchen_sink +default_range: string +slots: + value: + inlined: true + path: {} + constraints: + range: Any + id_value: {} + target_class: {} +classes: + LocalChange: + slots: + - value + - path + mixin: true + LocalQuery: + mixin: true + slots: + - target_class + - path + AddPerson: + description: A change action that adds a Person to a database + comments: + - This is an autogenerated class + mixins: LocalChange + slot_usage: + value: + range: Person + inlined: true + RemovePerson: + description: A change action that remoaves a Person to a database + comments: + - This is an autogenerated class + mixins: LocalChange + slot_usage: + value: + range: Person + inlined: true + PersonQuery: + description: A query object for instances of Person from a database + comments: + - This is an autogenerated class + mixins: LocalChange + slots: + - constraints + PersonFetchById: + description: A query object for fetching an instance of Person from a database + by id + comments: + - This is an autogenerated class + mixins: LocalChange + slots: + - id_value + AddCompany: + description: A change action that adds a Company to a database + comments: + - This is an autogenerated class + mixins: LocalChange + slot_usage: + value: + range: Company + inlined: true + RemoveCompany: + description: A change action that remoaves a Company to a database + comments: + - This is an autogenerated class + mixins: LocalChange + slot_usage: + value: + range: Company + inlined: true + CompanyQuery: + description: A query object for instances of Company from a database + comments: + - This is an autogenerated class + mixins: LocalChange + slots: + - constraints + CompanyFetchById: + description: A query object for fetching an instance of Company from a database + by id + comments: + - This is an autogenerated class + mixins: LocalChange + slots: + - id_value + AddActivity: + description: A change action that adds a Activity to a database + comments: + - This is an autogenerated class + mixins: LocalChange + slot_usage: + value: + range: activity + inlined: true + RemoveActivity: + description: A change action that remoaves a Activity to a database + comments: + - This is an autogenerated class + mixins: LocalChange + slot_usage: + value: + range: activity + inlined: true + ActivityQuery: + description: A query object for instances of Activity from a database + comments: + - This is an autogenerated class + mixins: LocalChange + slots: + - constraints + ActivityFetchById: + description: A query object for fetching an instance of Activity from a database + by id + comments: + - This is an autogenerated class + mixins: LocalChange + slots: + - id_value + diff --git a/tests/model/kitchen_sink_api_bespoke.py b/tests/model/kitchen_sink_api_bespoke.py new file mode 100644 index 0000000..7f14e92 --- /dev/null +++ b/tests/model/kitchen_sink_api_bespoke.py @@ -0,0 +1,41 @@ +import logging +from dataclasses import dataclass +from linkml_runtime_api.query.query_model import FetchQuery, Constraint, MatchConstraint, OrConstraint, AbstractQuery, \ + FetchById +from linkml_runtime_api.query.queryengine import MatchExpression, QueryEngine + +from tests.model.kitchen_sink import * + +@dataclass +class KitchenSinkAPI: + + query_engine: QueryEngine = None + + def fetch_Person(self, id_value: str) -> Person: + """ + Retrieves an instance of `Person` via a primary key + + :param id_value: + :return: Person with matching ID + """ + q = FetchById(id=id_value, target_class=Person.class_name) + results = self.query_engine.fetch_by_id(q) + return results[0] if results else None + + def query_Person(self, + id: Union[str, MatchExpression] = None, + name: Union[str, MatchExpression] = None, + age_in_years: Union[int, MatchExpression] = None) -> List[Person]: + """ + Queries for instances of `Person` + + :param id: + :param name: + :return: Person list matching constraints + """ + results = self.query_engine.simple_query(Person.class_name, + id=id, + name=name, + age_in_years=age_in_years) + return results + diff --git a/tests/output/README.md b/tests/output/README.md new file mode 100644 index 0000000..13da172 --- /dev/null +++ b/tests/output/README.md @@ -0,0 +1 @@ +test output here diff --git a/tests/test_apigenerator.py b/tests/test_apigenerator.py new file mode 100644 index 0000000..704981e --- /dev/null +++ b/tests/test_apigenerator.py @@ -0,0 +1,41 @@ +import logging +import os +import unittest + +from linkml_runtime.dumpers import yaml_dumper +from linkml_runtime_api.generators.apigenerator import ApiGenerator +from linkml_runtime_api.changer.changes_model import AddObject, RemoveObject, Append, Rename +from linkml_runtime.loaders import yaml_loader +from linkml_runtime.utils.schemaview import SchemaView +from tests.model.kitchen_sink import Person, Dataset, FamilialRelationship +from tests.model.kitchen_sink_api import AddPerson +from tests import MODEL_DIR, INPUT_DIR + +SCHEMA = os.path.join(MODEL_DIR, 'kitchen_sink.yaml') +API_SCHEMA = os.path.join(MODEL_DIR, 'kitchen_sink_api_test.yaml') + + + +class ApiGeneratorTestCase(unittest.TestCase): + """ + Tests :class:`ApiGenerator` + """ + + def test_apigen(self): + """ + Tests :class:`ApiGenerator.serialize` + :return: + """ + view = SchemaView(SCHEMA) + gen = ApiGenerator(schemaview=view) + logging.info(gen.serialize()) + with open(API_SCHEMA, 'w') as stream: + stream.write(gen.serialize()) + api_view = SchemaView(API_SCHEMA) + for cn, c in api_view.all_class().items(): + logging.info(f'C={cn}') + assert 'AddPerson' in api_view.all_class() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_differ.py b/tests/test_differ.py new file mode 100644 index 0000000..3ba4759 --- /dev/null +++ b/tests/test_differ.py @@ -0,0 +1,40 @@ +import os +import unittest +import logging + +from linkml_runtime_api.changer.object_changer import ObjectChanger +from linkml_runtime_api.changer.changes_model import Rename +from linkml_runtime_api.diffs.differ import DiffEngine +from linkml_runtime.loaders import yaml_loader +from linkml_runtime.utils.schemaview import SchemaView +from tests.model.kitchen_sink import Dataset +from tests import MODEL_DIR, INPUT_DIR + +SCHEMA = os.path.join(MODEL_DIR, 'kitchen_sink.yaml') +DATA = os.path.join(INPUT_DIR, 'kitchen_sink_inst_01.yaml') + +class ObjectPatcherTestCase(unittest.TestCase): + + def test_rename(self): + view = SchemaView(SCHEMA) + patcher = ObjectChanger(schemaview=view) + dataset = yaml_loader.load(DATA, target_class=Dataset) + dataset: Dataset + change = Rename(value='P:999', old_value='P:001', target_class='Person') + result = patcher.apply(change, dataset, in_place=False) + new_dataset = result.object + assert dataset.persons[0].id == 'P:001' + assert new_dataset.persons[0].id == 'P:999' + assert new_dataset.persons[1].has_familial_relationships[0].related_to == 'P:999' + de = DiffEngine() + patch = de.diff(dataset, new_dataset) + for p in patch: + logging.info(p) + assert len(list(patch)) > 0 + + + + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_jsonpatch_changer.py b/tests/test_jsonpatch_changer.py new file mode 100644 index 0000000..043433d --- /dev/null +++ b/tests/test_jsonpatch_changer.py @@ -0,0 +1,95 @@ +import logging +import os +import unittest + +from linkml_runtime.loaders import yaml_loader, json_loader +from linkml_runtime.dumpers import yaml_dumper, json_dumper +from linkml_runtime.utils.schemaview import SchemaView +from linkml_runtime.utils.yamlutils import YAMLRoot + +from linkml_runtime_api.changer.jsonpatch_changer import JsonPatchChanger +from linkml_runtime_api.changer.changes_model import AddObject, RemoveObject, Append, Rename + +from tests.model.kitchen_sink import Person, Dataset, FamilialRelationship +from tests import MODEL_DIR, INPUT_DIR +from tests.model.kitchen_sink_api import AddPerson + +SCHEMA = os.path.join(MODEL_DIR, 'kitchen_sink.yaml') +DATA = os.path.join(INPUT_DIR, 'kitchen_sink_inst_01.yaml') + +def _roundtrip(element: YAMLRoot) -> dict: + typ = type(element) + jsonstr = json_dumper.dumps(element, inject_type=False) + return json_loader.loads(jsonstr, target_class=typ) + +class JsonPatchMakerTestCase(unittest.TestCase): + + def test_make_jsonpatch(self): + view = SchemaView(SCHEMA) + patcher = JsonPatchChanger(schemaview=view) + d = Dataset(persons=[Person('foo', name='foo')]) + new_person = Person(id='P1', name='P1') + # ADD OBJECT + obj = AddObject(value=new_person) + result = patcher.apply(obj, d, in_place=True) + #d = result.object + logging.info(d) + logging.info(yaml_dumper.dumps(d)) + assert new_person.id in [p.id for p in d.persons] + assert new_person in d.persons + + obj = RemoveObject(value=new_person) + rs = patcher.apply(obj, d) + logging.info(yaml_dumper.dumps(d)) + assert new_person.id not in [p.id for p in d.persons] + assert new_person not in d.persons + + # add back + patcher.apply(AddObject(value=new_person), d) + + # add to list + obj = Append(value='fred', path='/persons/P1/aliases') + #person = next(p for p in d.persons if p.id == 'P1') + #print(person) + #print(f'ALIASES={person.aliases}') + #obj = Append(value='fred', parent=person.aliases) + rs = patcher.apply(obj, d) + logging.info(yaml_dumper.dumps(d)) + assert next(p for p in d.persons if p.id == 'P1').aliases == ['fred'] + + def test_rename(self): + view = SchemaView(SCHEMA) + patcher = JsonPatchChanger(schemaview=view) + dataset = yaml_loader.load(DATA, target_class=Dataset) + dataset: Dataset + change = Rename(value='P:999', old_value='P:001', target_class='Person', path='') + d2 = _roundtrip(dataset) + logging.info(f'CHANGE = {change}') + r = patcher.apply(change, dataset) + dataset = r.object + logging.info(dataset) + logging.info(f'RESULTS:') + logging.info(yaml_dumper.dumps(dataset)) + assert dataset.persons[0].id == 'P:999' + assert dataset.persons[1].has_familial_relationships[0].related_to == 'P:999' + + def test_generated_api(self): + view = SchemaView(SCHEMA) + patcher = JsonPatchChanger(schemaview=view) + dataset = yaml_loader.load(DATA, target_class=Dataset) + dataset: Dataset + frel = FamilialRelationship(related_to='P:001', type='SIBLING_OF') + person = Person(id='P:222', name='foo', + has_familial_relationships=[frel]) + change = AddPerson(value=person) + logging.info(change) + patcher.apply(change, dataset) + logging.info(dataset) + logging.info(yaml_dumper.dumps(dataset)) + assert len(dataset.persons) == 3 + assert dataset.persons[2].id == 'P:222' + assert dataset.persons[2].has_familial_relationships[0].related_to == 'P:001' + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_object_changer.py b/tests/test_object_changer.py new file mode 100644 index 0000000..02b77e2 --- /dev/null +++ b/tests/test_object_changer.py @@ -0,0 +1,111 @@ +import logging +import os +import unittest + +from linkml_runtime.dumpers import yaml_dumper +from linkml_runtime_api.changer.object_changer import ObjectChanger +from linkml_runtime_api.changer.changes_model import AddObject, RemoveObject, Append, Rename +from linkml_runtime.loaders import yaml_loader +from linkml_runtime.utils.schemaview import SchemaView +from tests.model.kitchen_sink import Person, Dataset, FamilialRelationship +from tests.model.kitchen_sink_api import AddPerson +from tests import MODEL_DIR, INPUT_DIR + +SCHEMA = os.path.join(MODEL_DIR, 'kitchen_sink.yaml') +DATA = os.path.join(INPUT_DIR, 'kitchen_sink_inst_01.yaml') + +ADD_PERSON = """ +path: /persons +value: + id: P:500 + name: foo bag + age_in_years: 44 + has_employment_history: + - employed_at: ROR:1 + started_at_time: 2019-01-01 + is_current: true +""" + +class ObjectPatcherTestCase(unittest.TestCase): + + def test_object_patcher(self): + view = SchemaView(SCHEMA) + patcher = ObjectChanger(schemaview=view) + d = Dataset(persons=[Person('foo')]) + new_person = Person(id='P1') + # ADD OBJECT + #obj = AddObject(value=new_person, path='/persons') + obj = AddObject(value=new_person) + rs = patcher.apply(obj, d) + logging.info(yaml_dumper.dumps(d)) + assert new_person.id in [p.id for p in d.persons] + assert new_person in d.persons + + obj = RemoveObject(value=new_person) + rs = patcher.apply(obj, d) + logging.info(yaml_dumper.dumps(d)) + assert new_person.id not in [p.id for p in d.persons] + assert new_person not in d.persons + + # add back + patcher.apply(AddObject(value=new_person), d) + + # add to list + #obj = Append(value='fred', path='/persons/P1/aliases') + person = next(p for p in d.persons if p.id == 'P1') + logging.info(person) + obj = Append(value='fred', parent=person.aliases) + rs = patcher.apply(obj, d) + logging.info(yaml_dumper.dumps(d)) + assert next(p for p in d.persons if p.id == 'P1').aliases == ['fred'] + + def test_rename(self): + view = SchemaView(SCHEMA) + patcher = ObjectChanger(schemaview=view) + dataset = yaml_loader.load(DATA, target_class=Dataset) + dataset: Dataset + change = Rename(value='P:999', old_value='P:001', target_class='Person') + patcher.apply(change, dataset) + logging.info(dataset) + logging.info(yaml_dumper.dumps(dataset)) + assert dataset.persons[0].id == 'P:999' + assert dataset.persons[1].has_familial_relationships[0].related_to == 'P:999' + + def test_generated_api(self): + view = SchemaView(SCHEMA) + patcher = ObjectChanger(schemaview=view) + dataset = yaml_loader.load(DATA, target_class=Dataset) + dataset: Dataset + frel = FamilialRelationship(related_to='P:001', type='SIBLING_OF') + person = Person(id='P:222', name='foo', + has_familial_relationships=[frel]) + change = AddPerson(value=person) + logging.info(change) + patcher.apply(change, dataset) + logging.info(dataset) + logging.info(yaml_dumper.dumps(dataset)) + assert len(dataset.persons) == 3 + assert dataset.persons[2].id == 'P:222' + assert dataset.persons[2].has_familial_relationships[0].related_to == 'P:001' + + @unittest.skip + def test_from_json(self): + view = SchemaView(SCHEMA) + patcher = ObjectChanger(schemaview=view) + dataset = yaml_loader.load(DATA, target_class=Dataset) + dataset: Dataset + change = yaml_loader.loads(ADD_PERSON, target_class=AddObject) + logging.info(change) + patcher.apply(change, dataset) + logging.info(dataset) + logger.info(yaml_dumper.dumps(dataset)) + assert len(dataset.persons) == 3 + #assert dataset.persons[0].id == 'P:999' + #assert dataset.persons[1].has_familial_relationships[0].related_to == 'P:999' + + + + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_pythonapigenerator.py b/tests/test_pythonapigenerator.py new file mode 100644 index 0000000..817a1da --- /dev/null +++ b/tests/test_pythonapigenerator.py @@ -0,0 +1,54 @@ +import logging +import os +import unittest + +from linkml_runtime.dumpers import yaml_dumper +from linkml_runtime.utils.compile_python import compile_python + +from linkml_runtime_api import ObjectQueryEngine +from linkml_runtime_api.generators import PythonApiGenerator +from linkml_runtime.loaders import yaml_loader +from linkml_runtime.utils.schemaview import SchemaView + +from linkml_runtime_api.query.queryengine import Database, MatchExpression +from tests.model.kitchen_sink import Person, Dataset, FamilialRelationship +from tests import MODEL_DIR, INPUT_DIR, OUTPUT_DIR + +SCHEMA = os.path.join(MODEL_DIR, 'kitchen_sink.yaml') +API_CODE = os.path.join(OUTPUT_DIR, 'kitchen_sink_api_generated_test.py') +DATA = os.path.join(INPUT_DIR, 'kitchen_sink_inst_01.yaml') + + + +class PythonApiGeneratorTestCase(unittest.TestCase): + + def test_pyapigen(self): + view = SchemaView(SCHEMA) + gen = PythonApiGenerator(schemaview=view) + code = gen.serialize(python_path='tests.model') + with open(API_CODE, 'w') as stream: + stream.write(code) + mod = compile_python(code) + + view = SchemaView(SCHEMA) + qe = ObjectQueryEngine(schemaview=view) + api = mod.KitchenSinkAPI(query_engine=qe) + dataset = yaml_loader.load(DATA, target_class=Dataset) + qe.database = Database(document=dataset) + person = api.fetch_Person('P:001') + #print(f'PERSON={person}') + self.assertEqual(person.id, 'P:001') + + results = api.query_Person(name='fred bloggs') + self.assertEqual(len(results), 1) + person = results[0] + self.assertEqual(person.id, 'P:001') + + results = api.query_Person(name=MatchExpression('like', 'fred%')) + self.assertEqual(len(results), 1) + person = results[0] + self.assertEqual(person.id, 'P:001') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_query.py b/tests/test_query.py new file mode 100644 index 0000000..a705341 --- /dev/null +++ b/tests/test_query.py @@ -0,0 +1,128 @@ +import logging +import os +import unittest +from typing import List + +from linkml_runtime.loaders import yaml_loader +from linkml_runtime_api.query.object_queryengine import ObjectQueryEngine +from linkml_runtime_api.query.queryengine import Database, MatchExpression +from linkml_runtime_api.query.query_model import FetchQuery, MatchConstraint, OrConstraint, FetchById +from linkml_runtime.utils.schemaview import SchemaView +from tests.model.kitchen_sink import Person, Dataset +from tests.model.kitchen_sink_api import PersonQuery, PersonFetchById +from tests.model.kitchen_sink_api_bespoke import KitchenSinkAPI + +from tests import MODEL_DIR, INPUT_DIR + +SCHEMA = os.path.join(MODEL_DIR, 'kitchen_sink.yaml') +DATA = os.path.join(INPUT_DIR, 'kitchen_sink_inst_01.yaml') + +class QueryTestCase(unittest.TestCase): + + def test_query(self): + view = SchemaView(SCHEMA) + qe = ObjectQueryEngine(schemaview=view) + dataset = yaml_loader.load(DATA, target_class=Dataset) + q = FetchQuery(target_class=Person.class_name, + constraints=[MatchConstraint(op='=', left='id', right='P:001')]) + logging.info(q) + results = qe.fetch(q, dataset) + results: List[Person] + assert len(results) == 1 + #for r in results: + # print(r) + self.assertEqual(results[0].id, 'P:001') + + # negated queries + q = FetchQuery(target_class=Person.class_name, + constraints=[MatchConstraint(negated=True, op='=', left='id', right='P:001')]) + results = qe.fetch(q, dataset) + self.assertEqual(results[0].id, 'P:002') + + # path queries + q = FetchQuery(target_class=Person.class_name, + constraints=[MatchConstraint(op='=', + left='has_medical_history/*/diagnosis/name', + right='headache')]) + results = qe.fetch(q, dataset) + self.assertEqual(results[0].id, 'P:002') + + # union queries + def id_constraint(v): + return MatchConstraint(op='=', left='id', right=v) + q = FetchQuery(target_class=Person.class_name, + constraints=[OrConstraint(subconstraints=[id_constraint('P:001'), + id_constraint('P:002')])]) + results = qe.fetch(q, dataset) + assert len(results) == 2 + + # Like queries + q = FetchQuery(target_class=Person.class_name, + constraints=[MatchConstraint(op='like', left='name', right='joe%')]) + results = qe.fetch(q, dataset) + assert len(results) == 1 + + q = FetchQuery(target_class=Person.class_name, + constraints=[MatchConstraint(op='like', left='name', right='joe')]) + results = qe.fetch(q, dataset) + assert len(results) == 0 + + q = FetchQuery(target_class=Person.class_name, + constraints=[MatchConstraint(op='like', left='name', right='%')]) + results = qe.fetch(q, dataset) + assert len(results) == 2 + + # by ID + q = FetchById(id='P:001', + target_class=Person.class_name) + results = qe.fetch(q, dataset) + assert len(results) == 1 + self.assertEqual(results[0].id, 'P:001') + + def test_generated_api(self): + view = SchemaView(SCHEMA) + qe = ObjectQueryEngine(schemaview=view) + dataset = yaml_loader.load(DATA, target_class=Dataset) + q = PersonQuery(constraints=[MatchConstraint(op='=', left='id', right='P:001')]) + logging.info(q) + results = qe.fetch(q, dataset) + results: List[Person] + assert len(results) == 1 + #for r in results: + # print(r) + self.assertEqual(results[0].id, 'P:001') + + q = PersonFetchById(id_value='P:001') + logging.info(q) + results = qe.fetch(q, dataset) + results: List[Person] + assert len(results) == 1 + self.assertEqual(results[0].id, 'P:001') + + def test_bespoke_api(self): + view = SchemaView(SCHEMA) + qe = ObjectQueryEngine(schemaview=view) + api = KitchenSinkAPI(query_engine=qe) + dataset = yaml_loader.load(DATA, target_class=Dataset) + qe.database = Database(document=dataset) + person = api.fetch_Person('P:001') + logging.info(f'PERSON={person}') + self.assertEqual(person.id, 'P:001') + + results = api.query_Person(name='fred bloggs') + self.assertEqual(len(results), 1) + person = results[0] + self.assertEqual(person.id, 'P:001') + + results = api.query_Person(name=MatchExpression('like', 'fred%')) + self.assertEqual(len(results), 1) + person = results[0] + self.assertEqual(person.id, 'P:001') + + + + + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_schema_manipulation.py b/tests/test_schema_manipulation.py new file mode 100644 index 0000000..524dfd7 --- /dev/null +++ b/tests/test_schema_manipulation.py @@ -0,0 +1,67 @@ +import logging +import unittest + +from linkml_runtime.linkml_model import SchemaDefinition, ClassDefinition, SlotDefinition +from linkml_runtime.dumpers import yaml_dumper +from linkml_runtime_api.changer.object_changer import ObjectChanger +from linkml_runtime_api.changer.changes_model import AddObject, RemoveObject, Append + +class ObjectPatcherTestCase(unittest.TestCase): + + def test_object_patcher(self): + patcher = ObjectChanger() + + s = SchemaDefinition(id='test', name='test') + new_class = ClassDefinition('c1', + slot_usage={'s1': SlotDefinition('s1', range='d')}, + slots=['s1', 's2']) + + # ADD OBJECT + obj = AddObject(value=new_class, path='/classes', primary_key_slot='name') + rs = patcher.apply(obj, s) + logging.info(rs) + logging.info(yaml_dumper.dumps(s)) + assert new_class.name in s.classes + + # ADD SUB-OBJECT + sd = SlotDefinition('s2', range='e') + obj = AddObject(value=sd, path='/classes/c1/slot_usage', primary_key_slot='name') + patcher.apply(obj, s) + assert new_class.slot_usage['s2'].range == 'e' + + # REMOVE OBJECT (inlined) + obj = RemoveObject(value=new_class, path='/classes', primary_key_slot='name') + patcher.apply(obj, s) + assert new_class.name not in s.classes + logging.info(yaml_dumper.dumps(s)) + + # place class back + patcher.apply(AddObject(value=new_class, path='/classes', primary_key_slot='name'), s) + + # REMOVE OBJECT (by key) + obj = RemoveObject(value=new_class.name, path='/classes') + patcher.apply(obj, s) + assert new_class.name not in s.classes + logging.info(yaml_dumper.dumps(s)) + + # place class back + patcher.apply(AddObject(value=new_class, path='/classes', primary_key_slot='name'), s) + + # APPEND + obj = Append(value='new_slot', path='/classes/c1/slots') + patcher.apply(obj, s) + assert 'new_slot' in new_class.slots + logging.info(yaml_dumper.dumps(s)) + + # REMOVE ATOMIC FROM LIST + obj = RemoveObject(value='new_slot', path='/classes/c1/slots') + patcher.apply(obj, s) + assert 'new_slot' not in s.classes['c1'].slots + logging.info(yaml_dumper.dumps(s)) + + + + + +if __name__ == '__main__': + unittest.main()