From 2e22afd10495916fe8da45212faac28ccb3f8fee Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Mon, 2 Jan 2023 15:01:02 +0100 Subject: [PATCH 01/11] Update Sphinx configuration Update the requirements files, especially for the documentation. Fix up the conf.py file for configuring the Sphinx build. Add explicitly the AutoAPI templates. Update the "homepage" index file to include a nicer ToC. --- pydoc/_static/logo.jpg | Bin 147202 -> 0 bytes pydoc/_templates/autoapi/base/base.rst | 7 + pydoc/_templates/autoapi/index.rst | 15 ++ pydoc/_templates/autoapi/python/attribute.rst | 1 + pydoc/_templates/autoapi/python/class.rst | 58 +++++++ pydoc/_templates/autoapi/python/data.rst | 32 ++++ pydoc/_templates/autoapi/python/exception.rst | 1 + pydoc/_templates/autoapi/python/function.rst | 15 ++ pydoc/_templates/autoapi/python/method.rst | 19 +++ pydoc/_templates/autoapi/python/module.rst | 114 +++++++++++++ pydoc/_templates/autoapi/python/package.rst | 1 + pydoc/_templates/autoapi/python/property.rst | 15 ++ pydoc/conf.py | 156 ++++++++++++++---- pydoc/index.md | 60 +++++++ pydoc/index.rst | 20 --- requirements_dev.txt | 8 +- requirements_doc.txt | 26 +-- 17 files changed, 471 insertions(+), 77 deletions(-) delete mode 100644 pydoc/_static/logo.jpg create mode 100644 pydoc/_templates/autoapi/base/base.rst create mode 100644 pydoc/_templates/autoapi/index.rst create mode 100644 pydoc/_templates/autoapi/python/attribute.rst create mode 100644 pydoc/_templates/autoapi/python/class.rst create mode 100644 pydoc/_templates/autoapi/python/data.rst create mode 100644 pydoc/_templates/autoapi/python/exception.rst create mode 100644 pydoc/_templates/autoapi/python/function.rst create mode 100644 pydoc/_templates/autoapi/python/method.rst create mode 100644 pydoc/_templates/autoapi/python/module.rst create mode 100644 pydoc/_templates/autoapi/python/package.rst create mode 100644 pydoc/_templates/autoapi/python/property.rst create mode 100644 pydoc/index.md delete mode 100644 pydoc/index.rst diff --git a/pydoc/_static/logo.jpg b/pydoc/_static/logo.jpg deleted file mode 100644 index a99fda71bf1c0c42d3f11ea14157f6b566022a04..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 147202 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 diff --git a/pydoc/_templates/autoapi/python/attribute.rst b/pydoc/_templates/autoapi/python/attribute.rst new file mode 100644 index 000000000..ebaba555a --- /dev/null +++ b/pydoc/_templates/autoapi/python/attribute.rst @@ -0,0 +1 @@ +{% extends "python/data.rst" %} diff --git a/pydoc/_templates/autoapi/python/class.rst b/pydoc/_templates/autoapi/python/class.rst new file mode 100644 index 000000000..df5edffb6 --- /dev/null +++ b/pydoc/_templates/autoapi/python/class.rst @@ -0,0 +1,58 @@ +{% if obj.display %} +.. py:{{ obj.type }}:: {{ obj.short_name }}{% if obj.args %}({{ obj.args }}){% endif %} +{% for (args, return_annotation) in obj.overloads %} + {{ " " * (obj.type | length) }} {{ obj.short_name }}{% if args %}({{ args }}){% endif %} +{% endfor %} + + + {% if obj.bases %} + {% if "show-inheritance" in autoapi_options %} + Bases: {% for base in obj.bases %}{{ base|link_objs }}{% if not loop.last %}, {% endif %}{% endfor %} + {% endif %} + + + {% if "show-inheritance-diagram" in autoapi_options and obj.bases != ["object"] %} + .. autoapi-inheritance-diagram:: {{ obj.obj["full_name"] }} + :parts: 1 + {% if "private-members" in autoapi_options %} + :private-bases: + {% endif %} + + {% endif %} + {% endif %} + {% if obj.docstring %} + {{ obj.docstring|indent(3) }} + {% endif %} + {% if "inherited-members" in autoapi_options %} + {% set visible_classes = obj.classes|selectattr("display")|list %} + {% else %} + {% set visible_classes = obj.classes|rejectattr("inherited")|selectattr("display")|list %} + {% endif %} + {% for klass in visible_classes %} + {{ klass.render()|indent(3) }} + {% endfor %} + {% if "inherited-members" in autoapi_options %} + {% set visible_properties = obj.properties|selectattr("display")|list %} + {% else %} + {% set visible_properties = obj.properties|rejectattr("inherited")|selectattr("display")|list %} + {% endif %} + {% for property in visible_properties %} + {{ property.render()|indent(3) }} + {% endfor %} + {% if "inherited-members" in autoapi_options %} + {% set visible_attributes = obj.attributes|selectattr("display")|list %} + {% else %} + {% set visible_attributes = obj.attributes|rejectattr("inherited")|selectattr("display")|list %} + {% endif %} + {% for attribute in visible_attributes %} + {{ attribute.render()|indent(3) }} + {% endfor %} + {% if "inherited-members" in autoapi_options %} + {% set visible_methods = obj.methods|selectattr("display")|list %} + {% else %} + {% set visible_methods = obj.methods|rejectattr("inherited")|selectattr("display")|list %} + {% endif %} + {% for method in visible_methods %} + {{ method.render()|indent(3) }} + {% endfor %} +{% endif %} diff --git a/pydoc/_templates/autoapi/python/data.rst b/pydoc/_templates/autoapi/python/data.rst new file mode 100644 index 000000000..89417f1e1 --- /dev/null +++ b/pydoc/_templates/autoapi/python/data.rst @@ -0,0 +1,32 @@ +{% if obj.display %} +.. py:{{ obj.type }}:: {{ obj.name }} + {%+ if obj.value is not none or obj.annotation is not none -%} + :annotation: + {%- if obj.annotation %} :{{ obj.annotation }} + {%- endif %} + {%- if obj.value is not none %} = {% + if obj.value is string and obj.value.splitlines()|count > 1 -%} + Multiline-String + + .. raw:: html + +
Show Value + + .. code-block:: text + :linenos: + + {{ obj.value|indent(width=8) }} + + .. raw:: html + +
+ + {%- else -%} + {{ obj.value|string|truncate(100) }} + {%- endif %} + {%- endif %} + {% endif %} + + + {{ obj.docstring|indent(3) }} +{% endif %} diff --git a/pydoc/_templates/autoapi/python/exception.rst b/pydoc/_templates/autoapi/python/exception.rst new file mode 100644 index 000000000..92f3d38fd --- /dev/null +++ b/pydoc/_templates/autoapi/python/exception.rst @@ -0,0 +1 @@ +{% extends "python/class.rst" %} diff --git a/pydoc/_templates/autoapi/python/function.rst b/pydoc/_templates/autoapi/python/function.rst new file mode 100644 index 000000000..b00d5c244 --- /dev/null +++ b/pydoc/_templates/autoapi/python/function.rst @@ -0,0 +1,15 @@ +{% if obj.display %} +.. py:function:: {{ obj.short_name }}({{ obj.args }}){% if obj.return_annotation is not none %} -> {{ obj.return_annotation }}{% endif %} + +{% for (args, return_annotation) in obj.overloads %} + {{ obj.short_name }}({{ args }}){% if return_annotation is not none %} -> {{ return_annotation }}{% endif %} + +{% endfor %} + {% for property in obj.properties %} + :{{ property }}: + {% endfor %} + + {% if obj.docstring %} + {{ obj.docstring|indent(3) }} + {% endif %} +{% endif %} diff --git a/pydoc/_templates/autoapi/python/method.rst b/pydoc/_templates/autoapi/python/method.rst new file mode 100644 index 000000000..723cb7bbe --- /dev/null +++ b/pydoc/_templates/autoapi/python/method.rst @@ -0,0 +1,19 @@ +{%- if obj.display %} +.. py:method:: {{ obj.short_name }}({{ obj.args }}){% if obj.return_annotation is not none %} -> {{ obj.return_annotation }}{% endif %} + +{% for (args, return_annotation) in obj.overloads %} + {{ obj.short_name }}({{ args }}){% if return_annotation is not none %} -> {{ return_annotation }}{% endif %} + +{% endfor %} + {% if obj.properties %} + {% for property in obj.properties %} + :{{ property }}: + {% endfor %} + + {% else %} + + {% endif %} + {% if obj.docstring %} + {{ obj.docstring|indent(3) }} + {% endif %} +{% endif %} diff --git a/pydoc/_templates/autoapi/python/module.rst b/pydoc/_templates/autoapi/python/module.rst new file mode 100644 index 000000000..1000e2c3e --- /dev/null +++ b/pydoc/_templates/autoapi/python/module.rst @@ -0,0 +1,114 @@ +{% if not obj.display %} +:orphan: + +{% endif %} +:py:mod:`{{ obj.name|remove_plugin_path }}` +=========={{ "=" * obj.name|remove_plugin_path|length }} + +.. py:module:: {{ obj.name|remove_plugin_path }} + +{% if obj.docstring %} +.. autoapi-nested-parse:: + + {{ obj.docstring|indent(3) }} + +{% endif %} + +{% block subpackages %} +{% set visible_subpackages = obj.subpackages|selectattr("display")|list %} +{% if visible_subpackages %} +Subpackages +----------- +.. toctree:: + :titlesonly: + :maxdepth: 3 + +{% for subpackage in visible_subpackages %} + {{ subpackage.short_name }}/index.rst +{% endfor %} + + +{% endif %} +{% endblock %} +{% block submodules %} +{% set visible_submodules = obj.submodules|selectattr("display")|list %} +{% if visible_submodules %} +Submodules +---------- +.. toctree:: + :titlesonly: + :maxdepth: 1 + +{% for submodule in visible_submodules %} + {{ submodule.short_name }}/index.rst +{% endfor %} + + +{% endif %} +{% endblock %} +{% block content %} +{% if obj.all is not none %} +{% set visible_children = obj.children|selectattr("short_name", "in", obj.all)|list %} +{% elif obj.type is equalto("package") %} +{% set visible_children = obj.children|selectattr("display")|list %} +{% else %} +{% set visible_children = obj.children|selectattr("display")|rejectattr("imported")|list %} +{% endif %} +{% if visible_children %} +{{ obj.type|title }} Contents +{{ "-" * obj.type|length }}--------- + +{% set visible_classes = visible_children|selectattr("type", "equalto", "class")|list %} +{% set visible_functions = visible_children|selectattr("type", "equalto", "function")|list %} +{% set visible_attributes = visible_children|selectattr("type", "equalto", "data")|list %} +{% if "show-module-summary" in autoapi_options and (visible_classes or visible_functions) %} +{% block classes scoped %} +{% if visible_classes %} +Classes +~~~~~~~ + +.. autoapisummary:: + +{% for klass in visible_classes %} + {{ klass.id }} +{% endfor %} + + +{% endif %} +{% endblock %} + +{% block functions scoped %} +{% if visible_functions %} +Functions +~~~~~~~~~ + +.. autoapisummary:: + +{% for function in visible_functions %} + {{ function.id }} +{% endfor %} + + +{% endif %} +{% endblock %} + +{% block attributes scoped %} +{% if visible_attributes %} +Attributes +~~~~~~~~~~ + +.. autoapisummary:: + +{% for attribute in visible_attributes %} + {{ attribute.id }} +{% endfor %} + + +{% endif %} +{% endblock %} +{% endif %} +{% for obj_item in visible_children %} +{{ obj_item.render()|indent(0) }} +{% endfor %} +{% endif %} +{% endblock %} diff --git a/pydoc/_templates/autoapi/python/package.rst b/pydoc/_templates/autoapi/python/package.rst new file mode 100644 index 000000000..fb9a64965 --- /dev/null +++ b/pydoc/_templates/autoapi/python/package.rst @@ -0,0 +1 @@ +{% extends "python/module.rst" %} diff --git a/pydoc/_templates/autoapi/python/property.rst b/pydoc/_templates/autoapi/python/property.rst new file mode 100644 index 000000000..70af24236 --- /dev/null +++ b/pydoc/_templates/autoapi/python/property.rst @@ -0,0 +1,15 @@ +{%- if obj.display %} +.. py:property:: {{ obj.short_name }} + {% if obj.annotation %} + :type: {{ obj.annotation }} + {% endif %} + {% if obj.properties %} + {% for property in obj.properties %} + :{{ property }}: + {% endfor %} + {% endif %} + + {% if obj.docstring %} + {{ obj.docstring|indent(3) }} + {% endif %} +{% endif %} diff --git a/pydoc/conf.py b/pydoc/conf.py index 823980835..783f86b8b 100644 --- a/pydoc/conf.py +++ b/pydoc/conf.py @@ -1,65 +1,153 @@ -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html +"""Configuration file for the Sphinx documentation builder. + +For the full list of built-in configuration values, see the documentation: +https://www.sphinx-doc.org/en/master/usage/configuration.html +""" +import os +from pathlib import Path +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from jinja2 import Environment + +dlite_version_file = Path(__file__).resolve().parent.parent / "CMakeLists.txt" +if not dlite_version_file.exists(): + raise FileNotFoundError( + f"Could not find {dlite_version_file} necessary to read DLite version." + ) +for line in dlite_version_file.read_text(encoding="utf8").splitlines(): + match = re.match( + r"^\s+VERSION\s+(?P[0-9]+(\.[0-9]+){2})$", + line, + ) + if match: + dlite_version = match.group("version") + break +else: + raise ValueError(f"Could not determine DLite version from {dlite_version_file}") + +dlite_share_plugins = [ + plugin_dir.name + for plugin_dir in ( + Path(__file__).resolve().parent.parent + / "build" / "bindings" / "python" / "dlite" / "share" / "dlite" + ).iterdir() + if plugin_dir.is_dir() +] # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = 'DLite' -copyright = '© SINTEF 2022' -author = 'SINTEF' -release = '0.3' +project = "DLite" +copyright = "© SINTEF 2022" +author = "SINTEF" +version = ".".join(dlite_version.split(".")[2:]) +release = dlite_version + # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - # General configuration +exclude_patterns = [ + "_build", + "Thumbs.db", + ".DS_Store", + "**.ipynb_checkpoints", + "_templates", +] + extensions = [ "breathe", # Doxygen bridge - "myst_parser", # markdown source support + "myst_nb", # markdown source support & support for Jupyter notebooks "autoapi.extension", - "sphinx.ext.napoleon", # API ref Google and NumPy style "sphinx.ext.graphviz", # Graphviz + "sphinx.ext.napoleon", # API ref Google and NumPy style + "sphinx.ext.viewcode", "sphinxcontrib.plantuml", # PlantUml "sphinx_copybutton", # Copy button for codeblocks - "nbsphinx", # Jupyter - "IPython.sphinxext.ipython_console_highlighting", # nb syntax highlight - "sphinx.ext.autosectionlabel", # Auto-generate section labels. - "sphinx_panels", # Create panels in a grid layout or as drop-downs - "sphinx_markdown_tables", + "sphinx_design", # Create panels in a grid layout or as drop-downs and more + # "sphinx.ext.autosectionlabel", # Auto-generate section labels. ] -autoapi_dirs = ['../build/bindings/python/dlite'] -autoapi_type = 'python' -autoapi_file_patterns=['*.py', '*.pyi'] -autoapi_add_toctree_entry = False - -master_doc = "index" +root_doc = "index" -myst_heading_anchors = 5 +suppress_warnings = ["myst.mathjax"] -plantuml = "java -jar lib/plantuml.jar" -plantuml_output_format = "svg_img" +# Extension configuration +autoapi_dirs = [ + "../build/bindings/python/dlite" +] + [ + f"../build/bindings/python/dlite/share/dlite/{plugin_dir}" + for plugin_dir in dlite_share_plugins +] +autoapi_type = "python" +autoapi_file_patterns = ["*.py", "*.pyi"] +autoapi_template_dir = "_templates/autoapi" +autoapi_add_toctree_entry = True # Toggle the most top-level index file +autoapi_options = [ + "members", + "undoc-members", + "private-members", + "show-inheritance", + "show-module-summary", + "special-members", + "imported-members", + # "show-inheritance-diagram", + # "inherited-members", +] +autoapi_keep_files = False # Should be False in production +autoapi_python_use_implicit_namespaces = True # True to avoid namespace being `python.dlite` -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**.ipynb_checkpoints"] +autodoc_typehints = "description" +autodoc_typehints_format = "short" +autodoc_inherit_docstrings = True # HTML output html_theme = "sphinx_book_theme" html_logo = "_static/logo.svg" html_favicon = "_static/favicon.ico" html_theme_options = { - "repository_url": "https://github.com/sintef/dlite", + "path_to_docs": "pydoc", + "repository_url": "https://github.com/SINTEF/dlite", + "repository_branch": "master", + "use_issues_button": True, + "use_fullscreen_button": True, "use_repository_button": True, - "repository_branch": "main", - "path_to_docs": "docs", "logo_only": True, - "show_navbar_depth": 2, + "show_navbar_depth": 1, + "announcement": "This documentation is under development!", } - html_static_path = ["_static"] -html_css_files = ["custom.css"] +# html_css_files = ["custom.css"] -nbsphinx_allow_errors = True +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} -suppress_warnings = ["myst.mathjax"] +myst_heading_anchors = 5 +myst_enable_extensions = ["colon_fence"] + +napoleon_use_admonition_for_examples = True +napoleon_use_admonition_for_notes = True +napoleon_use_admonition_for_references = True +napoleon_preprocess_types = True +napoleon_attr_annotations = True + +nb_execution_allow_errors = False +nb_execution_mode = "cache" +if os.getenv("CI"): + nb_kernel_rgx_aliases = {".*": "python"} + +plantuml = "java -jar lib/plantuml.jar" +plantuml_output_format = "svg_img" + +# Jinja2 custom filters +def autoapi_prepare_jinja_env(jinja_env: "Environment") -> None: + """Add custom Jinja2 filters for use with AutoAPI templates.""" + + # Remove plugin path, removes the initial part of the module name if it is equal to + # any of the folder names under `dlite/share/dlite`. + jinja_env.filters["remove_plugin_path"] = lambda name: ".".join( + name.split(".")[1:] + ) if name.split(".")[0] in dlite_share_plugins else name diff --git a/pydoc/index.md b/pydoc/index.md new file mode 100644 index 000000000..18aef1b08 --- /dev/null +++ b/pydoc/index.md @@ -0,0 +1,60 @@ + +:::{toctree} +:maxdepth: 3 +:caption: API Reference +:glob: +:hidden: + +Python +::: + +:::{toctree} +:maxdepth: 3 +:caption: Mapping Plugins +:glob: +:hidden: + +autoapi/mappingplugins/**/index +::: + +:::{toctree} +:maxdepth: 3 +:caption: Storage Plugins +:glob: +:hidden: + +autoapi/storageplugins/**/index +::: + +:::{toctree} +:maxdepth: 3 +:caption: Python Mapping Plugins +:glob: +:hidden: + +autoapi/pythonmappingplugins/**/index +::: + +:::{toctree} +:maxdepth: 3 +:caption: Python Storage Plugins +:glob: +:hidden: + +autoapi/pythonstorageplugins/**/index +::: + +:::{toctree} +:maxdepth: 3 +:caption: Storages +:glob: +:hidden: + +autoapi/storages/**/index +::: + +## Indices and tables + +* [](genindex) +* [](modindex) +* [](py-modindex) diff --git a/pydoc/index.rst b/pydoc/index.rst deleted file mode 100644 index ec204bede..000000000 --- a/pydoc/index.rst +++ /dev/null @@ -1,20 +0,0 @@ -DLite Python API -================ - -.. toctree:: - :maxdepth: 3 - :caption: Contents: - :glob: - - - autoapi/python/dlite/index - autoapi/yaml/index - autoapi/postgresql/index - autoapi/blob/index - autoapi/mongodb/index - autoapi/pyrdf/index - autoapi/bson/index - - -* :ref:`genindex` - diff --git a/requirements_dev.txt b/requirements_dev.txt index 2679950f9..aa6a60a20 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,4 @@ -ipython==8.6.0 -ipykernel==6.0.1 -ipython_genutils==0.2.0 -mongomock==4.1.2 +ipython>=8.6.0,<9 +ipykernel>=6.0.1,<7 +ipython_genutils~=0.2.0 +mongomock>=4.1.2,<5 diff --git a/requirements_doc.txt b/requirements_doc.txt index 6415f608b..990a24c20 100644 --- a/requirements_doc.txt +++ b/requirements_doc.txt @@ -1,22 +1,10 @@ -markupsafe==2.0.1 breathe==4.34.0 -docutils==0.17.1 -ipython==8.6.0 -myst-parser==0.18.0 -nbsphinx==0.8.9 -Jinja2==3.1.2 -Sphinx==4.5.0 -sphinx-autoapi==2.0.0 +importlib-metadata==4.13.0; python_version<'3.8' +myst-nb~=0.17.1 +Sphinx>=4.5.0,<6 +sphinx-autoapi~=2.0 sphinx-autobuild==2021.3.14 -sphinx-book-theme==0.3.3 -sphinx-copybutton==0.5.0 -sphinx-markdown-tables==0.0.17 -sphinx-panels==0.6.0 -sphinxawesome-theme==3.3.5 -sphinxcontrib-applehelp==1.0.2 -sphinxcontrib-devhelp==1.0.2 -sphinxcontrib-htmlhelp==2.0.0 -sphinxcontrib-jsmath==1.0.1 +sphinx-book-theme~=0.3.3 +sphinx-copybutton~=0.5.1 +sphinx-design~=0.3.0 sphinxcontrib-plantuml==0.24 -sphinxcontrib-qthelp==1.0.3 -sphinxcontrib-serializinghtml==1.1.5 From ea569e80ecb602c95ac3c63fde970cc32379aed8 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Mon, 2 Jan 2023 15:05:45 +0100 Subject: [PATCH 02/11] New min. ipython version for Py3.7 support --- requirements_dev.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements_dev.txt b/requirements_dev.txt index aa6a60a20..11bb96574 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,4 +1,5 @@ -ipython>=8.6.0,<9 +# Keep ipython at min. v7.34.0 for Python 3.7 support. +ipython>=7.34.0,<9 ipykernel>=6.0.1,<7 ipython_genutils~=0.2.0 mongomock>=4.1.2,<5 From dc9259883691745e5ba8656b8ad3d8618286392c Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Mon, 2 Jan 2023 17:45:20 +0100 Subject: [PATCH 03/11] Fix API ref for postgresql py storage plugin --- pydoc/conf.py | 4 +- pydoc/index.md | 16 +- .../python-storage-plugins/postgresql.py | 359 ++++++++++-------- 3 files changed, 221 insertions(+), 158 deletions(-) diff --git a/pydoc/conf.py b/pydoc/conf.py index 783f86b8b..5a602754c 100644 --- a/pydoc/conf.py +++ b/pydoc/conf.py @@ -58,9 +58,9 @@ ] extensions = [ + "autoapi.extension", "breathe", # Doxygen bridge "myst_nb", # markdown source support & support for Jupyter notebooks - "autoapi.extension", "sphinx.ext.graphviz", # Graphviz "sphinx.ext.napoleon", # API ref Google and NumPy style "sphinx.ext.viewcode", @@ -96,7 +96,7 @@ # "show-inheritance-diagram", # "inherited-members", ] -autoapi_keep_files = False # Should be False in production +autoapi_keep_files = True # Should be False in production autoapi_python_use_implicit_namespaces = True # True to avoid namespace being `python.dlite` autodoc_typehints = "description" diff --git a/pydoc/index.md b/pydoc/index.md index 18aef1b08..313a18264 100644 --- a/pydoc/index.md +++ b/pydoc/index.md @@ -1,4 +1,6 @@ - +# DLite + + :::{toctree} :maxdepth: 3 :caption: API Reference @@ -17,23 +19,23 @@ Python autoapi/mappingplugins/**/index ::: -:::{toctree} + -:::{toctree} + :::{toctree} :maxdepth: 3 @@ -44,14 +46,14 @@ autoapi/pythonmappingplugins/**/index autoapi/pythonstorageplugins/**/index ::: -:::{toctree} + ## Indices and tables diff --git a/storages/python/python-storage-plugins/postgresql.py b/storages/python/python-storage-plugins/postgresql.py index a6e96efaa..f683cc436 100644 --- a/storages/python/python-storage-plugins/postgresql.py +++ b/storages/python/python-storage-plugins/postgresql.py @@ -1,7 +1,6 @@ -import os -import sys -import warnings +"""PostgreSQL storage""" import fnmatch +from typing import TYPE_CHECKING import psycopg2 from psycopg2 import sql @@ -10,100 +9,134 @@ from dlite.options import Options from dlite.utils import instance_from_dict +if TYPE_CHECKING: # pragma: no cover + from typing import Generator, Optional -# Translation table from dlite types to postgresql types + +# Mapping from DLite types to PostgreSQL types pgtypes = { - 'blob': 'bytea', - 'bool': 'bool', - 'int': 'integer', - 'int8': 'bytea', - 'int16': 'smallint', - 'int32': 'integer', - 'int64': 'bigint', - 'uint16': 'integer', - 'uint32': 'bigint', - 'float': 'real', - 'double': 'float8', - 'float32': 'real', - 'float64': 'float8', - 'string': 'varchar', - 'dimension': 'varchar[2]', - 'property': 'varchar[5]', - 'relation': 'varchar[3]', + "blob": "bytea", + "bool": "bool", + "int": "integer", + "int8": "bytea", + "int16": "smallint", + "int32": "integer", + "int64": "bigint", + "uint16": "integer", + "uint32": "bigint", + "float": "real", + "double": "float8", + "float32": "real", + "float64": "float8", + "string": "varchar", + "dimension": "varchar[2]", + "property": "varchar[5]", + "relation": "varchar[3]", } -def to_pgtype(typename): - """Returns PostGreSQL type corresponding to dlite typename.""" - if typename in pgtypes: - return pgtypes[typename] - else: - t = typename.rstrip('0123456789') - return pgtypes[t] +def to_pgtype(typename: str) -> str: + """Returns PostgreSQL type corresponding to dlite typename.""" + return pgtypes.get( + typename, + pgtypes[typename.rstrip("0123456789")] + ) class postgresql(dlite.DLiteStorageBase): """DLite storage plugin for PostgreSQL.""" - def open(self, uri, options=None): + + def open(self, uri: str, options: "Optional[str]" = None) -> None: """Opens `uri`. - The `options` argument provies additional input to the driver. - Which options that are supported varies between the plugins. It - should be a valid URL query string of the form: + After the options are passed, this method may set attribute + `writable` to `True` if it is writable and to `False` otherwise. + If `writable` is not set, it is assumed to be true. - key1=value1;key2=value2... + Parameters: + uri: A fully resolved URI to the PostgreSQL database. + options: This Argument provides additional input to the driver. + Which options that are supported varies between the plugins. It + should be a valid URL query string of the form: - An ampersand (&) may be used instead of the semicolon (;). + ```python + options = "key1=value1;key2=value2;..." + ``` - Typical options supported by most drivers include: - - database : Name of database to connect to (default: dlite) - - user : User name. - - password : Password. - - mode : append | r - Valid values are: - - append Append to existing file or create new file (default) - - r Open existing file for read-only + An ampersand (`&`) may be used instead of the semicolon (`;`) as a separator + between the key/value-pairs. + + Typical options supported by most drivers include: + + - `database`: Name of database to connect to (default: dlite). + - `user`: User name. + - `password`: User password. + - `mode`: Mode for opening. + Valid values are: + + - `append`: Append to existing file or create new file (default). + - `r`: Open existing file for read-only. - After the options are passed, this method may set attribute - `writable` to true if it is writable and to false otherwise. - If `writable` is not set, it is assumed to be true. """ - self.options = Options(options, defaults='database=dlite;mode=append') - opts = self.options - opts.setdefault('password', None) - self.writable = False if opts.mode == 'r' else True + self.options = Options(options, defaults="database=dlite;mode=append") + self.options.setdefault("password", None) + self.writable = self.options.mode != "r" # Connect to existing database - print(' host:', uri) - print(' user:', opts.user) - print(' database:', opts.database) - #print(' password:', opts.password) - self.conn = psycopg2.connect(host=uri, database=opts.database, - user=opts.user, password=opts.password) + self.connection = psycopg2.connect( + host=uri, + database=self.options.database, + user=self.options.user, + password=self.options.password, + ) # Open a cursor to perform database operations - self.cur = self.conn.cursor() + self.cursor = self.connection.cursor() - def close(self): + def close(self) -> None: """Closes this storage.""" - self.cur.close() - self.conn.close() + self.cursor.close() + self.connection.close() + + def load(self, uuid: str) -> dlite.Instance: + """Loads `uuid` from current storage and returns it as a new instance. + + Parameters: + uuid: The DLite Instance UUID. + + Returns: + The DLite Instance corresponding to the UUID given as it exists in the + PostgreSQL database. - def load(self, uuid): - """Loads `uuid` from current storage and return it as a new instance.""" + """ uuid = dlite.get_uuid(uuid) - q = sql.SQL('SELECT meta FROM uuidtable WHERE uuid = %s') - self.cur.execute(q, [uuid]) - metaid, = self.cur.fetchone() - q = sql.SQL('SELECT * FROM {} WHERE uuid = %s').format( - sql.Identifier(metaid)) - self.cur.execute(q, [uuid]) - tokens = self.cur.fetchone() + + sql_query = sql.SQL("SELECT meta FROM uuidtable WHERE uuid = %s") + self.cursor.execute(sql_query, [uuid]) + metaid = self.cursor.fetchone() + + if metaid is None or len(metaid) != 1: + raise RuntimeError(f"Could not retrieve meta ID for UUID {uuid}") + + sql_query = sql.SQL("SELECT * FROM {} WHERE uuid = %s").format( + sql.Identifier(metaid[0]) + ) + self.cursor.execute(sql_query, [uuid]) + tokens = self.cursor.fetchone() uuid_, uri, metaid_, dims = tokens[:4] values = tokens[4:] - assert uuid_ == uuid - assert metaid_ == metaid - # Make sure we have metadata object correcponding to metaid + if uuid_ != uuid: + raise RuntimeError( + f"Fetching {uuid} from PostgreSQL database results in a different " + f"UUID to be returned: {uuid_}" + ) + if metaid_ != metaid: + raise RuntimeError( + f"Fetching {metaid} from PostgreSQL database results in a different " + f"meta ID to be returned: {metaid_}" + ) + + # Make sure we have a metadata object corresponding to metaid try: with dlite.err(): meta = dlite.get_instance(metaid) @@ -111,103 +144,131 @@ def load(self, uuid): dlite.errclr() meta = self.load(metaid) - inst = dlite.Instance.from_metaid(metaid, dims, uri) + inst: dlite.Instance = dlite.Instance.from_metaid(metaid, dims, uri) - for i, p in enumerate(inst.meta['properties']): - inst.set_property(p.name, values[i]) + for index, meta_property in enumerate(inst.meta["properties"]): + inst.set_property(meta_property.name, values[index]) - # The uuid will be wrong for data instances, so override it + # The UUID will be wrong for data instances, so override it if not inst.is_metameta: - d = inst.asdict() - d['uuid'] = uuid - inst = instance_from_dict(d) + inst_dict = inst.asdict() + inst_dict["uuid"] = uuid + inst = instance_from_dict(inst_dict) + return inst - def save(self, inst): - """Stores `inst` in current storage.""" + def save(self, inst: dlite.Instance) -> None: + """Stores `inst` in current storage. + Parameters: + inst: The DLite Instance to store in the PostgreSQL database. + + """ # Save to metadata table - if not self.table_exists(inst.meta.uri): - self.table_create(inst.meta, inst.dimensions.values()) - colnames = ['uuid', 'uri', 'meta', 'dims'] + [ - p.name for p in inst.meta['properties']] - q = sql.SQL('INSERT INTO {0} ({1}) VALUES ({2});').format( + if not self._table_exists(inst.meta.uri): + self._table_create(inst.meta) + colnames = ["uuid", "uri", "meta", "dims"] + [ + meta_property.name for meta_property in inst.meta["properties"] + ] + sql_query = sql.SQL("INSERT INTO {0} ({1}) VALUES ({2});").format( sql.Identifier(inst.meta.uri), - sql.SQL(', ').join(map(sql.Identifier, colnames)), - (sql.Placeholder() * len(colnames)).join(', ')) - values = [inst.uuid, - inst.uri, - inst.meta.uri, - list(inst.dimensions.values()), - ] + [dlite.standardise(v, inst.get_property_descr(k), asdict=False) - for k, v in inst.properties.items()] + sql.SQL(", ").join(map(sql.Identifier, colnames)), + (sql.Placeholder() * len(colnames)).join(", ") + ) + values = [ + inst.uuid, + inst.uri, + inst.meta.uri, + list(inst.dimensions.values()), + ] + [ + dlite.standardise(value, inst.get_property_descr(key), asdict=False) + for key, value in inst.properties.items() + ] try: - self.cur.execute(q, values) + self.cursor.execute(sql_query, values) except psycopg2.IntegrityError: - self.conn.rollback() # Instance already in database + self.connection.rollback() # Instance already in database return # Save to uuidtable - if not self.table_exists('uuidtable'): - self.uuidtable_create() - q = sql.SQL('INSERT INTO uuidtable (uuid, meta) VALUES (%s, %s);') - self.cur.execute(q, [inst.uuid, inst.meta.uri]) - self.conn.commit() + if not self._table_exists("uuidtable"): + self._uuidtable_create() + sql_query = sql.SQL("INSERT INTO uuidtable (uuid, meta) VALUES (%s, %s);") + self.cursor.execute(sql_query, [inst.uuid, inst.meta.uri]) + self.connection.commit() - def table_exists(self, table_name): - """Returns true if a table named `table_name` exists.""" - self.cur.execute( - 'SELECT EXISTS(SELECT * FROM information_schema.tables ' - 'WHERE table_name=%s);', (table_name, )) - return self.cur.fetchone()[0] + def instances(self, pattern: str) -> "Generator[str, None, None]": + """Generator method that iterates over all UUIDs in the storage + whose metadata URI matches glob pattern `pattern`. - def table_create(self, meta, dims=None): - """Creates a table for storing instances of `meta`.""" - table_name = meta.uri - if self.table_exists(table_name): - raise ValueError('Table already exists: %r' % table_name) - if dims: - dims = list(dims) - cols = [ - 'uuid char(36) PRIMARY KEY', - 'uri varchar', - 'meta varchar', - 'dims integer[%d]' % meta.ndimensions - ] - for p in meta['properties']: - decl = f'"{p.name}" {to_pgtype(p.type)}' - if len(p.dims): - decl += '[]' * len(p.dims) - cols.append(decl) - q = sql.SQL('CREATE TABLE {} (%s);' % - ', '.join(cols)).format(sql.Identifier(meta.uri)) - self.cur.execute(q) - self.conn.commit() + Parameters: + pattern: Regular expression to identify DLite Instance UUIDs. - def uuidtable_create(self): - """Creates the uuidtable - a table mapping all uuid's to their - metadata uri.""" - q = sql.SQL('CREATE TABLE uuidtable (' - 'uuid char(36) PRIMARY KEY, ' - 'meta varchar' - ');') - self.cur.execute(q) - self.conn.commit() - - def queue(self, pattern): - """Generator method that iterates over all UUIDs in the storage - who's metadata URI matches glob pattern `pattern`.""" + Yields: + DLite Instance UUIDs matching the regular expression UUID pattern or all + instance UUIDs in the storage if no pattern is given. + + """ if pattern: # Convert glob patter to PostgreSQL regular expression - regex = '^{}'.format(fnmatch.translate(globex).replace( - '\\Z(?ms)', '$')) - q = sql.SQL('SELECT uuid from uuidtable WHERE uuid ~ %s;') - self.cur.execute(q, (regex, )) + regex = "^{}".format( + fnmatch.translate(pattern).replace("\\Z(?ms)", "$") + ) + sql_query = sql.SQL("SELECT uuid from uuidtable WHERE uuid ~ %s;") + self.cursor.execute(sql_query, [regex]) else: - q = sql.SQL('SELECT uuid from uuidtable;') - self.cur.execute(q) - tokens = self.cur.fetchone() + sql_query = sql.SQL("SELECT uuid from uuidtable;") + self.cursor.execute(sql_query) + + tokens = self.cursor.fetchone() + while tokens: uuid, = tokens yield uuid - tokens = self.cur.fetchone() + tokens = self.cursor.fetchone() + + def _table_exists(self, table_name: str) -> str: + """Returns true if a table named `table_name` exists.""" + self.cursor.execute( + "SELECT EXISTS(SELECT * FROM information_schema.tables " + "WHERE table_name=%s);", + [table_name], + ) + return self.cursor.fetchone()[0] + + def _table_create(self, meta: dlite.Metadata) -> None: + """Creates a table for storing instances of `meta`. + + Parameters: + meta: Metadata around which to create an SQL table. + + """ + table_name = meta.uri + if self._table_exists(table_name): + raise ValueError(f"Table already exists: {table_name!r}") + cols = [ + "uuid char(36) PRIMARY KEY", + "uri varchar", + "meta varchar", + f"dims integer[{meta.ndimensions}]" + ] + for meta_property in meta["properties"]: + decl = f"{meta_property.name} {to_pgtype(meta_property.type)}" + if len(meta_property.dims): + decl += "[]" * len(meta_property.dims) + cols.append(decl) + + sql_query = sql.SQL( + f"CREATE TABLE {{}} (%s);" % + ", ".join(cols)).format(sql.Identifier(meta.uri)) + self.cursor.execute(sql_query) + self.connection.commit() + + def _uuidtable_create(self) -> None: + """Creates the uuidtable - a table mapping all uuid"s to their + metadata uri.""" + sql_query = sql.SQL( + "CREATE TABLE uuidtable (uuid char(36) PRIMARY KEY, meta varchar);" + ) + self.cursor.execute(sql_query) + self.connection.commit() From 0c7031eeac161e55009315e4f6067d8436f3a2ad Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 3 Jan 2023 12:07:06 +0100 Subject: [PATCH 04/11] Update RDF Python storage doc strings --- .gitignore | 5 +- .../python-storage-plugins/postgresql.py | 2 +- .../python/python-storage-plugins/pyrdf.py | 100 +++++++++++------- 3 files changed, 69 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index 9f29d056a..516729642 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,7 @@ Dockerfile-*linux*[^.template] # Units (pint) files units.cpython-*.pyc -bindings/python/triplestore/__pycache__ \ No newline at end of file +bindings/python/triplestore/__pycache__ + +# Documentation +pydoc/autoapi/ diff --git a/storages/python/python-storage-plugins/postgresql.py b/storages/python/python-storage-plugins/postgresql.py index f683cc436..1d2e9861e 100644 --- a/storages/python/python-storage-plugins/postgresql.py +++ b/storages/python/python-storage-plugins/postgresql.py @@ -54,7 +54,7 @@ def open(self, uri: str, options: "Optional[str]" = None) -> None: Parameters: uri: A fully resolved URI to the PostgreSQL database. - options: This Argument provides additional input to the driver. + options: This argument provides additional input to the driver. Which options that are supported varies between the plugins. It should be a valid URL query string of the form: diff --git a/storages/python/python-storage-plugins/pyrdf.py b/storages/python/python-storage-plugins/pyrdf.py index 681319e4a..bd15caf9b 100644 --- a/storages/python/python-storage-plugins/pyrdf.py +++ b/storages/python/python-storage-plugins/pyrdf.py @@ -1,6 +1,5 @@ """A simple demonstrage of a DLite storage plugin written in Python.""" -import os -import sys +from typing import TYPE_CHECKING import rdflib from rdflib.util import guess_format @@ -9,68 +8,97 @@ from dlite.options import Options from dlite.rdf import DM, PUBLIC_ID, from_graph, to_graph +if TYPE_CHECKING: # pragma: no cover + from typing import Generator, Optional + class pyrdf(dlite.DLiteStorageBase): """DLite storage plugin for RDF serialisation.""" - def open(self, uri, options=None): + def open(self, uri: str, options: "Optional[str]" = None) -> None: """Opens `uri`. - Supported options: - - mode : "a" | "r" | "w" - Valid values are: - - a Append to existing file or create new file (default) - - r Open existing file for read-only - - w Truncate existing file or create new file - - format : "turtle" | "xml" | "n3" | "nt" | "json-ld" | "nquads"... - File format. For a complete list of valid formats, see - https://rdflib.readthedocs.io/en/stable/intro_to_parsing.html - - base_uri : str - Base URI that is prepended to the instance UUID or URI - (if it is not already a valid URI). - - base_prefix: str - Optional namespace prefix to use for `base_uri`. - - include_meta: bool - Whether to also serialise metadata. The default - is to only include metadata if `inst` is a data object. + Parameters: + uri: A fully resolve URI to the RDF. + options: Supported options: + + - `mode`: Mode for opening. + Valid values are: + + - `a`: Append to existing file or create new file (default) + - `r`: Open existing file for read-only + - `w`: Truncate existing file or create new file + - `format`: File format. For a complete list of valid formats, see + https://rdflib.readthedocs.io/en/stable/intro_to_parsing.html + A sample list of valid format values: "turtle", "xml", "n3", "nt", + "json-ld", "nquads". + - `base_uri`: Base URI that is prepended to the instance UUID or URI + (if it is not already a valid URI). + - `base_prefix`: Optional namespace prefix to use for `base_uri`. + - `include_meta`: Whether to also serialise metadata. The default is to + only include metadata if `inst` is a data object. + """ - self.options = Options(options, defaults='mode=a') - self.writable = False if 'r' in self.options.mode else True + self.options = Options(options, defaults="mode=a") + self.writable = "r" not in self.options.mode self.uri = uri self.format = ( - self.options.format if 'format' in self.options else guess_format( - uri) + self.options.format + if "format" in self.options else guess_format(uri) ) self.graph = rdflib.Graph() - if self.options.mode in 'ra': + if self.options.mode in "ra": self.graph.parse(uri, format=self.format, publicID=PUBLIC_ID) - def close(self): + def close(self) -> None: """Closes this storage.""" if self.writable: self.graph.serialize(self.uri, format=self.format) - def load(self, id): - """Loads `uuid` from current storage and return it as a new instance.""" + def load(self, id: str) -> dlite.Instance: + """Loads `uuid` from current storage and returns it as a new instance. + + Parameters: + id: A UUID representing a DLite Instance to return from the RDF storage. + + Returns: + A DLite Instance corresponding to the given `id` (UUID). + + """ return from_graph(self.graph, id) - def save(self, inst): - """Stores `inst` in current storage.""" + def save(self, inst: dlite.Instance) -> None: + """Stores `inst` in current storage. + + Parameters: + inst: A DLite Instance to store in the RDF storage. + + """ to_graph( inst, self.graph, - base_uri=self.options.get('base_uri'), - base_prefix=self.options.get('base_prefix'), + base_uri=self.options.get("base_uri"), + base_prefix=self.options.get("base_prefix"), include_meta=( - dlite.asbool(self.options) if 'include_meta' in self.options + dlite.asbool(self.options) if "include_meta" in self.options else None ), ) - def queue(self, pattern=None): + def queue(self, pattern: "Optional[str]" = None) -> "Generator[str, None, None]": """Generator method that iterates over all UUIDs in the storage - who's metadata URI matches glob pattern `pattern`.""" - for s, p, o in self.graph.triples((None, DM.hasUUID, None)): + who"s metadata URI matches glob pattern `pattern`. + + Parameters: + pattern: A regular expression to filter the yielded UUIDs. + + Yields: + DLite Instance UUIDs based on the `pattern` regular expression. + If no `pattern` is given, all UUIDs are yielded from within the RDF + storage. + + """ + for s, _, o in self.graph.triples((None, DM.hasUUID, None)): metaid = str(list(self.graph.objects(s, DM.instanceOf))[0]) if pattern and dlite.globmatch(pattern, metaid): continue From a1b478e5a069fe088772e78fe168d2dc9c7158e1 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 3 Jan 2023 15:18:00 +0100 Subject: [PATCH 05/11] Further doc-string fixes --- bindings/python/dlite-entity-python.i | 11 +- bindings/python/dlite-entity.i | 46 ++- bindings/python/dlite-misc-python.i | 14 +- bindings/python/dlite-misc.i | 43 +-- bindings/python/dlite-storage.i | 24 +- bindings/python/factory.py | 297 +++++++++++------- pydoc/_templates/autoapi/python/data.rst | 2 +- .../python/python-storage-plugins/pyrdf.py | 2 +- .../python/python-storage-plugins/yaml.py | 125 +++++--- 9 files changed, 354 insertions(+), 210 deletions(-) diff --git a/bindings/python/dlite-entity-python.i b/bindings/python/dlite-entity-python.i index f8f559069..7448a3f8d 100644 --- a/bindings/python/dlite-entity-python.i +++ b/bindings/python/dlite-entity-python.i @@ -1,6 +1,6 @@ /* -*- Python -*- (not really, but good for syntax highlighting) */ -/* Python-spesific extensions to dlite-entity.i */ +/* Python-specific extensions to dlite-entity.i */ %pythoncode %{ import sys import json @@ -115,18 +115,19 @@ def standardise(v, prop, asdict=False): return conv(v) -def get_instance(id: "str", metaid: "str"=None, check_storages: "bool"=True) -> "Instance": +def get_instance(id: "str", metaid: "str" = None, check_storages: "bool" = True) -> "Instance": """Return instance with given id. Arguments: - id: Id of instance to return. + id: ID of instance to return. metaid: If given, dlite will try to convert the instance to a new - instance of `metaid`. + instance of ``metaid``. check_storages: Whether to check for the instance in storages listed in dlite.storage_path if the instance is not already in memory. Returns: - DLite instance. + DLite Instance. + """ if isinstance(id, dlite.Instance): inst = id diff --git a/bindings/python/dlite-entity.i b/bindings/python/dlite-entity.i index f35b3024a..d20acf8d0 100644 --- a/bindings/python/dlite-entity.i +++ b/bindings/python/dlite-entity.i @@ -122,16 +122,23 @@ struct _DLiteDimension { %feature("docstring", "\ Creates a new property. +```python Property(name, type, dims=None, unit=None, description=None) - Creates a new property with the provided attributes. +``` +Creates a new property with the provided attributes. + +```python Property(seq) - Creates a new property from sequence of 6 strings, corresponding to - `name`, `type`, `dims`, `unit` and `description`. Valid - values for `dims` are: - - '' or '[]': no dimensions - - ', ': list of dimension names - - '[, ]': list of dimension names +``` + +Creates a new property from sequence of 6 strings, corresponding to +``name``, ``type``, ``dims``, ``unit`` and ``description``. +Valid values for ``dims`` are: + +- ``''`` or ``'[]'``: No dimensions. +- ``', '``: List of dimension names. +- ``'[, ]'``: List of dimension names. ") _DLiteProperty; %rename(Property) _DLiteProperty; @@ -608,15 +615,22 @@ Call signatures: dlite_swig_set_property_by_index($self, i, obj); } - %feature("docstring", - "Return property `name` as a string.\n" - "\n" - "`width` Minimum field width. Unused if 0, auto if -1.\n" - "`prec` Precision. Auto if -1, unused if -2.\n" - "`flags` Or'ed sum of formatting flags:\n" - " 0 default (json)\n" - " 1 raw unquoted output\n" - " 2 quoted output") + %feature("docstring", "\ +Return property ``name`` as a string. + +Parameters: + width: Minimum field width. Unused if 0, auto if -1. + prec: Precision. Auto if -1, unused if -2. + flags: Or'ed sum of formatting flags: + + - ``0``: Default (json). + - ``1``: Raw unquoted output. + - ``2``: Quoted output. + +Returns: + Property as a string. + +") get_property_as_string; %newobject get_property_as_string; char *get_property_as_string(const char *name, diff --git a/bindings/python/dlite-misc-python.i b/bindings/python/dlite-misc-python.i index a10f67f9e..1b38da4ba 100644 --- a/bindings/python/dlite-misc-python.i +++ b/bindings/python/dlite-misc-python.i @@ -8,12 +8,14 @@ import dlite class err(): """Context manager for temporary turning off or redirecting errors. - By default errors are skipped within the err context. But if - `filename` is provided, the error messages are written to that file. - Special file names includes - - None or empty: no output is written - - : write errors to stderr (default) - - : write errors to stdout + By default errors are skipped within the err context. But if + ``filename`` is provided, the error messages are written to that file. + Special file names includes: + + - ``None`` or empty: No output is written. + - ````: Write errors to stderr (default). + - ````: Write errors to stdout. + """ def __init__(self, filename=None): self.filename = filename diff --git a/bindings/python/dlite-misc.i b/bindings/python/dlite-misc.i index e82018885..50b77c6af 100644 --- a/bindings/python/dlite-misc.i +++ b/bindings/python/dlite-misc.i @@ -41,22 +41,24 @@ const char *dlite_get_license(void); %feature("docstring", "\ Returns an UUID, depending on: - - If `id` is NULL or empty, generates a new random version 4 UUID. - - If `id` is not a valid UUID string, generates a new version 5 sha1-based - UUID from `id` using the DNS namespace. - - Otherwise return `id` (which already must be a valid UUID). + +- If ``id`` is NULL or empty, generates a new random version 4 UUID. +- If ``id`` is not a valid UUID string, generates a new version 5 sha1-based + UUID from ``id`` using the DNS namespace. + +Otherwise return ``id`` (which already must be a valid UUID). ") dlite_get_uuid; %cstring_bounded_output(char *buff36, DLITE_UUID_LENGTH+1); void dlite_get_uuid(char *buff36, const char *id=NULL); %feature("docstring", "\ -Returns the generated UUID version number if `id` had been passed to -get_uuid() or zero if `id` is already a valid UUID. +Returns the generated UUID version number if ``id`` had been passed to +get_uuid() or zero if ``id`` is already a valid UUID. ") get_uuid_version; posstatus_t get_uuid_version(const char *id=NULL); %feature("docstring", "\ -Returns a (metadata) uri by combining `name`, `version` and `namespace` as: +Returns a (metadata) uri by combining ``name``, ``version`` and ``namespace`` as: namespace/version/name ") dlite_join_meta_uri; @@ -66,7 +68,7 @@ char *dlite_join_meta_uri(const char *name, const char *version, %feature("docstring", "\ -Returns (name, version, namespace)-tuplet from valid metadata `uri`. +Returns (name, version, namespace)-tuplet from valid metadata ``uri``. ") dlite_split_meta_uri; %cstring_output_allocate(char **name, if (*$1) free(*$1)); %cstring_output_allocate(char **version, if (*$1) free(*$1)); @@ -81,7 +83,7 @@ Returns an url constructed from the arguments of the form: driver://location?options#fragment -The `driver`, `options` and `fragment` arguments may be None. +The ``driver``, ``options`` and ``fragment`` arguments may be None. ") dlite_join_url; %newobject dlite_join_url; char *dlite_join_url(const char *driver, const char *location, @@ -89,7 +91,7 @@ char *dlite_join_url(const char *driver, const char *location, %feature("docstring", "\ Returns a (driver, location, options, fragment)-tuplet by splitting -`url` of the form +``url`` of the form driver://location?options#fragment @@ -108,11 +110,12 @@ Match string 's' against glob pattern 'pattern' and return zero on match. Understands the following patterns: - * any number of characters - ? any single character - [a-z] any single character in the range a-z - [^a-z] any single character not in the range a-z - \x match x + +- ``*``: Any number of characters. +- ``?``: Any single character. +- ``[a-z]``: Any single character in the range a-z. +- ``[^a-z]``: Any single character not in the range a-z. +- ``\\x``: Match x. ") globmatch; int globmatch(const char *pattern, const char *s); @@ -145,10 +148,12 @@ Set error stream. void dlite_err_set_stream(FILE *); %feature("docstring", "\ -Set error log file. Special values includes: - - None | "": turn off error output - - : standard error - - : standard output +Set error log file. Special values includes: + +- ``None`` | ``""``: Turn off error output. +- ````: Standard error. +- ````: Standard output. + All other values are treated as a filename that will be opened in append mode. ") dlite_err_set_file; void dlite_err_set_file(const char *filename); diff --git a/bindings/python/dlite-storage.i b/bindings/python/dlite-storage.i index b421371b6..a2418feb9 100644 --- a/bindings/python/dlite-storage.i +++ b/bindings/python/dlite-storage.i @@ -9,8 +9,8 @@ /* Storage iterator */ %feature("docstring", "\ -Iterates over instances in storage `s`. If `pattern` is given, only -instances whos metadata URI matches `pattern` are returned. +Iterates over instances in storage ``s``. If ``pattern`` is given, only +instances whos metadata URI matches ``pattern`` are returned. ") StorageIterator; %inline %{ struct StorageIterator { @@ -57,7 +57,7 @@ Returns next instance or None if exhausted.") next; enum _DLiteIDFlag { dliteIDTranslateToUUID=0, /*!< Translate id's that are not a valid UUID to a (version 5) UUID (default). */ - dliteIDRequireUUID=1, /*!< Require that `id` is a valid UUID. */ + dliteIDRequireUUID=1, /*!< Require that ``id`` is a valid UUID. */ dliteIDKeepID=2 /*!< Store data under the given id, even if it is not a valid UUID. Not SOFT compatible, but may be useful for input files. */ @@ -71,21 +71,23 @@ Represents a data storage. Parameters ---------- driver_or_url : string - Name of driver used to connect to the storage or, if `location` is not + Name of driver used to connect to the storage or, if ``location`` is not given, the URL to the storage: driver://location?options location : string - The location to the storage. For file storages, this is the file name. + The location to the storage. For file storages, this is the file name. options : string Additional options passed to the driver as a list of semicolon-separated - ``key=value`` pairs. Each driver may have their own options. Some + ``key=value`` pairs. Each driver may have their own options. Some common options are: - - mode={'append','r','w'}: 'append': append to existing storage or - create a new one (hdf5,json). - - compact={'yes','no'}: Whether to store in a compact format (json). - - meta={'yes','no'}: Whether to format output as metadata (json). + + - ``mode={'append','r','w'}``: + - 'append': Append to existing storage or create a new one (hdf5,json). + - ``compact={'yes','no'}``: Whether to store in a compact format (json). + - ``meta={'yes','no'}``: Whether to format output as metadata (json). + ") _DLiteStorage; %rename(Storage) _DLiteStorage; @@ -120,7 +122,7 @@ Returns name of driver for this storage.") get_driver; %feature("docstring", "\ Returns a list of UUIDs of all instances in the storage whos metadata -matches `pattern`. If `pattern` is None, all UUIDs will be returned. +matches ``pattern``. If ``pattern`` is None, all UUIDs will be returned. ") get_uuids; char **get_uuids(const char *pattern=NULL) { return dlite_storage_uuids($self, pattern); diff --git a/bindings/python/factory.py b/bindings/python/factory.py index 3d24848f2..09453c4f7 100644 --- a/bindings/python/factory.py +++ b/bindings/python/factory.py @@ -1,117 +1,149 @@ -#!/usr/bin/env python # -*- coding: utf-8 -*- """A module that makes it easy to add dlite functionality to existing classes. -Class customisations --------------------- -In order to handle special cases, the following methods may be -defined/overridden in the class that is to be extended: - _dlite_get_(self, name) - _dlite_set_(self, name, value) - _dlite__new__(cls, inst) +## Class customisations + +In order to handle special cases, the following methods may be defined/overridden in +the class that is to be extended: + +```python +_dlite_get_(self, name) +_dlite_set_(self, name, value) +_dlite__new__(cls, inst) +``` + """ import copy +from typing import TYPE_CHECKING import numpy as np from .dlite import Instance, Storage +if TYPE_CHECKING: # pragma: no cover + from typing import Any, Callable, List, Optional, Tuple, Union + + from dlite import Metadata + class FactoryError(Exception): """Base exception for factory errors.""" - pass class IncompatibleClassError(FactoryError): """Raised if an extended class is not compatible with its dlite metadata.""" - pass class MetaExtension(type): """Metaclass for BaseExtension.""" - def __init__(cls, name, bases, attr): + + def __init__(cls, name, bases, attr) -> None: super().__init__(name, bases, attr) -class BaseExtension(object, metaclass=MetaExtension): - """Base class for extension. Except for `dlite_id`, all - arguments are passed further to the __init__() function of the - class we are inheriting from. - If `instanceid` is given, the id of the underlying dlite instance - will be set to it. +class BaseExtension(metaclass=MetaExtension): + """Base class for extension. + + Except for ``dlite_id``, all arguments are passed further to the ``__init__()`` + function of the class we are inheriting from. If ``instanceid`` is given, the id of + the underlying dlite instance will be set to it. + """ - def __init__(self, *args, instanceid=None, **kw): - self._theclass.__init__(self, *args, **kw) + + def __init__(self, *args, instanceid: str = None, **kwargs) -> None: + self._theclass.__init__(self, *args, **kwargs) self._dlite_init(instanceid=instanceid) - def _dlite_init(self, instanceid=None): - """Initialise the underlying dlite instance. If `id` is given, - the id of the underlying dlite instance will be set to it.""" + def _dlite_init(self, instanceid: str = None) -> None: + """Initialise the underlying DLite Instance. + + If ``id`` is given, the id of the underlying DLite Instance will be set to it. + + Parameters: + instanceid: A DLite ID (UUID) to use for the underlying DLite Instance. + + """ dims = self._dlite_infer_dimensions() self.dlite_inst = Instance.from_metaid( - self.dlite_meta.uri, dims, instanceid) + self.dlite_meta.uri, dims, instanceid + ) self._dlite_assign_properties() - def _dlite_get(self, name): - """Returns the value of property `name` from the wrapped opject.""" - if hasattr(self, '_dlite_get_' + name): - getter = getattr(self, '_dlite_get_' + name) - value = getter() - elif hasattr(self, 'get_' + name): - getter = getattr(self, 'get_' + name) - value = getter() - else: - value = getattr(self, name) - return value - - def _dlite_set(self, name, value): - """Sets value of property `name` in the wrapped opject.""" - if hasattr(self, '_dlite_set_' + name): - setter = getattr(self, '_dlite_set_' + name) - setter(value) - elif hasattr(self, 'set_' + name): - setter = getattr(self, 'set_' + name) - setter(value) + def _dlite_get(self, name: str) -> "Any": + """Returns the value of property `name` from the wrapped object. + + Parameters: + name: The property to retrieve. + + Returns: + The property value. + + """ + if hasattr(self, f"_dlite_get_{name}"): + return getattr(self, f"_dlite_get_{name}")() + + if hasattr(self, f"get_{name}"): + return getattr(self, f"get_{name}")() + + return getattr(self, name) + + def _dlite_set(self, name: str, value: "Any") -> None: + """Sets value of property ``name`` in the wrapped object. + + Parameters: + name: The property to set a value for. + value: The value to set for the property. + + """ + if hasattr(self, f"_dlite_set_{name}"): + getattr(self, f"_dlite_set_{name}")(value) + elif hasattr(self, f"set_{name}"): + getattr(self, f"set_{name}")(value) else: setattr(self, name, value) - def _dlite_infer_dimensions(self, meta=None, getter=None): + def _dlite_infer_dimensions( + self, + meta: "Optional[Metadata]" = None, + getter: "Optional[Callable[[str], Any]]" = None, + ) -> "List[int]": """Returns inferred property dimensions from __dict__.""" - if not meta: - meta = self.dlite_meta - if not getter: - getter = self._dlite_get - dims = [-1] * len(meta.properties['dimensions']) - dimnames = [d.name for d in meta.properties['dimensions']] - for prop in meta.properties['properties']: + meta = meta if meta is not None else self.dlite_meta + getter = getter if getter is not None else self._dlite_get + + dims = [-1] * len(meta.properties["dimensions"]) + dimnames = [dim.name for dim in meta.properties["dimensions"]] + for prop in meta.properties["properties"]: if prop.ndims: value = getter(prop.name) - if len(value) > 0: - arr = np.array(value, copy=False) - else: - arr = np.zeros([0] * prop.ndims) - if arr.ndim < prop.ndims: - raise ValueError('expected %d dimensions for array ' - 'property "%s"; got %d' % ( - prop.ndims, prop.name, arr.ndim)) + array = ( + np.array(value, copy=False) + if value else np.zeros([0] * prop.ndims) + ) + if array.ndim < prop.ndims: + raise ValueError( + f"Expected {prop.ndims} dimensions for array property " + f"{prop.name!r}; got {array.ndim}" + ) for i, pdim in enumerate(prop.dims): if pdim in dimnames: n = dimnames.index(pdim) if dims[n] == -1: - dims[n] = arr.shape[i] - elif arr.shape[i] and dims[n] != arr.shape[i]: + dims[n] = array.shape[i] + elif array.shape[i] and dims[n] != array.shape[i]: raise ValueError( - 'inconsistent length of dimension "%s"; was ' - '%d but got %d for property %r' % ( - meta.properties['dimensions'][n].name, dims[n], - arr.shape[i], prop.name)) + "Inconsistent length of dimension " + f"{meta.properties['dimensions'][n].name!r}; was " + f"{dims[n]} but got {array.shape[i]} for property " + f"{prop.name!r}" + ) if min(dims) < 0: - raise ValueError('cannot infer all dimensions') + raise ValueError("Cannot infer all dimensions") return dims - def _dlite_assign_properties(self): + def _dlite_assign_properties(self) -> None: """Assigns all dlite properties from extended object.""" - for prop in self.dlite_meta.properties['properties']: + for prop in self.dlite_meta.properties["properties"]: name = prop.name self.dlite_inst[name] = self._dlite_get(name) @@ -119,31 +151,41 @@ def _dlite_assign_properties(self): def _dlite__new__(cls, inst=None): """Class method returning a new uninitialised instance of the class that is extended. - This method simply returns ``cls.__new__(cls)``. Override + This method simply returns ``cls.__new__(cls)``. Override this method if the extended class already defines a __new__() method. """ return cls.__new__(cls) - def dlite_assign(self, inst): + def dlite_assign(self, inst: Instance) -> None: """Assigns self from dlite instance `inst`.""" if self.dlite_meta.uri != inst.meta.uri: - raise TypeError('Expected instance of metadata "%s", got "%s"' % ( - self.dlite_meta.uri, inst.meta.uri)) - for prop in self.dlite_meta.properties['properties']: + raise TypeError( + f"Expected instance of metadata {self.dlite_meta.uri!r}, got " + f"{inst.meta.uri!r}" + ) + for prop in self.dlite_meta.properties["properties"]: name = prop.name self._dlite_set(name, inst[name]) - def dlite_load(self, *args): + def dlite_load(self, *args) -> None: """Loads dlite instance from storage and assign self from it. The arguments `args` are passed to dlite.Instance.from_storage().""" inst = Instance.from_storage(*args) self._dlite_assign(inst) -def instancefactory(theclass, inst): - """Returns an extended instance of `theclass` initiated from dlite - instance `inst`. +def instancefactory(theclass: type, inst: Instance) -> "Any": + """Returns an extended instance of ``theclass`` initiated from dlite + instance ``inst``. + + Parameters: + theclass: The class to instantiate an object from using ``inst``. + inst: A DLite Instance to use as source for a ``theclass`` instance object. + + Returns: + A ``theclass`` instance object based on the DLite Instance ``inst``. + """ cls = classfactory(theclass, meta=inst.meta) obj = cls._dlite__new__(inst) @@ -153,40 +195,76 @@ def instancefactory(theclass, inst): return obj -def objectfactory(obj, meta=None, deepcopy=False, cls=None, - url=None, storage=None, id=None, instanceid=None): - """Returns an extended copy of `obj`. If `deepcopy` is true, a deep - copy is returned, otherwise a shallow copy is returned. - By default, the returned object will have the same class as `obj`. If - `cls` is provided, the class of the returned object will be set to `cls` +def objectfactory( + obj: "Any", + meta: "Optional[Metadata]" = None, + deepcopy: bool = False, + cls: "Optional[type]" = None, + url: "Optional[str]" = None, + storage: "Optional[Union[Storage, Tuple[str, str, str]]]" = None, + id: "Optional[str]" = None, + instanceid: "Optional[str]" = None, +) -> "Any": + """Returns an extended copy of ``obj``. + + If ``deepcopy`` is ``True``, a deep copy is returned, otherwise a shallow copy is + returned. By default, the returned object will have the same class as ``obj``. + If ``cls`` is provided, the class of the returned object will be set to ``cls`` (typically a subclass of ``obj.__class__``). - The `url`, `storage` and `id` arguments are passed to classfactory(). + + The ``url``, ``storage``, and ``id`` arguments are passed to ``classfactory()``. + + Parameters: + obj: A Python object. + meta: A DLite Metadata Instance. + deepcopy: Whether or not to perform a deep or shallow copy of ``obj``. + cls: A class to use for the new object. If this is not supplied, the new object + will be the same class as the original. + url: URL referring to the metadata. Should be of the form + ``driver://location?options#id``. Only used if ``cls`` is not specified. + storage: Storage from which to load meta data. Only used if ``cls`` is not + specified. + id: A unique ID referring to the meta data if ``storage`` is provided. + instanceid: A DLite ID (UUID) to use for the underlying DLite Instance. + + Returns: + A new, extended copy of the Python object ``obj``. + """ - if cls is None: - cls = classfactory(obj.__class__, meta=meta, url=url, - storage=storage, id=id) + cls = cls if cls is not None else classfactory( + obj.__class__, + meta=meta, + url=url, + storage=storage, + id=id, + ) new = copy.deepcopy(obj) if deepcopy else copy.copy(obj) new.__class__ = cls new._dlite_init(instanceid=instanceid) return new -def classfactory(theclass, meta=None, url=None, storage=None, id=None): +def classfactory( + theclass: type, + meta: "Optional[Metadata]" = None, + url: "Optional[str]" = None, + storage: "Optional[Union[Storage, Tuple[str, str, str]]]" = None, + id: "Optional[str]" = None, +) -> type: """Factory function that returns a new class that inherits from both - `theclass` and BaseInstance. - Parameters - ---------- - theclass : class instance - The class to extend. - meta : Instance - Metadata instance. - url : string - URL referring to the metadata. Should be of the form - ``driver://location?options#id`` - storage : Storage | (driver, location, options) tuple - Storage to load meta from. - id : string - A unique id referring to the metadata if `storage` is provided. + ``theclass`` and ``BaseExtension``. + + Parameters: + theclass: The class to extend. + meta: Metadata instance. + url: URL referring to the metadata. Should be of the form + ``driver://location?options#id``. + storage: Storage to load meta from. + id: A unique ID referring to the metadata if ``storage`` is provided. + + Returns: + A new class based on ``theclass`` and ``BaseExtensions``. + """ if meta is None: if url is not None: @@ -197,16 +275,15 @@ def classfactory(theclass, meta=None, url=None, storage=None, id=None): else: meta = Instance.from_driver(*storage, id=id) else: - raise TypeError('`meta`, `url` or `storage` must be provided') + raise TypeError("`meta`, `url`, or `storage` must be provided.") if not meta.is_meta: - raise TypeError('`meta` must refer to metadata') - - attr = dict( - dlite_meta=meta, - _theclass=theclass, - __init__=BaseExtension.__init__ - ) + raise TypeError("`meta` must refer to metadata.") + attr = { + "dlite_meta": meta, + "_theclass": theclass, + "__init__": BaseExtension.__init__, + } return type(meta.name, (theclass, BaseExtension), attr) diff --git a/pydoc/_templates/autoapi/python/data.rst b/pydoc/_templates/autoapi/python/data.rst index 89417f1e1..4c7da4ae8 100644 --- a/pydoc/_templates/autoapi/python/data.rst +++ b/pydoc/_templates/autoapi/python/data.rst @@ -1,4 +1,4 @@ -{% if obj.display %} +{% if obj.display and obj.name not in ("__repr__",) %} .. py:{{ obj.type }}:: {{ obj.name }} {%+ if obj.value is not none or obj.annotation is not none -%} :annotation: diff --git a/storages/python/python-storage-plugins/pyrdf.py b/storages/python/python-storage-plugins/pyrdf.py index bd15caf9b..b84c0f2de 100644 --- a/storages/python/python-storage-plugins/pyrdf.py +++ b/storages/python/python-storage-plugins/pyrdf.py @@ -88,7 +88,7 @@ def save(self, inst: dlite.Instance) -> None: def queue(self, pattern: "Optional[str]" = None) -> "Generator[str, None, None]": """Generator method that iterates over all UUIDs in the storage who"s metadata URI matches glob pattern `pattern`. - + Parameters: pattern: A regular expression to filter the yielded UUIDs. diff --git a/storages/python/python-storage-plugins/yaml.py b/storages/python/python-storage-plugins/yaml.py index 6e00782e9..5198caa7f 100644 --- a/storages/python/python-storage-plugins/yaml.py +++ b/storages/python/python-storage-plugins/yaml.py @@ -1,66 +1,109 @@ """A simple demonstrage of a DLite storage plugin written in Python.""" import os -import sys +from typing import TYPE_CHECKING -import yaml as pyyaml +import yaml as pyyaml # To not clash with the current file name. import dlite from dlite.options import Options from dlite.utils import instance_from_dict +if TYPE_CHECKING: # pragma: no cover + from typing import Generator, Optional + class yaml(dlite.DLiteStorageBase): """DLite storage plugin for YAML.""" - def open(self, uri, options=None): + def open(self, uri: str, options: "Optional[str]" = None) -> None: """Opens `uri`. - Supported options: - - mode : "a" | "r" | "w" - Valid values are: - - a Append to existing file or create new file (default) - - r Open existing file for read-only - - w Truncate existing file or create new file - - soft7 : bool - Whether to save using SOFT7 format. - - single : bool | "auto" - Whether the input is assumed to be in single-entity form. - The default (auto) will try to infer it automatically. + Parameters: + uri: A fully resolved URI to the PostgreSQL database. + options: Supported options: + + - `mode`: Mode for opening. + Valid values are: + + - `a`: Append to existing file or create new file (default). + - `r`: Open existing file for read-only. + - `w`: Truncate existing file or create new file. + + - `soft7`: Whether to save using SOFT7 format. + - `single`: Whether the input is assumed to be in single-entity form. + The default (`"auto"`) will try to infer it automatically. + """ - self.options = Options(options, defaults='mode=a;soft7=true;single=auto') - self.mode = dict(r='r', w='w', a='r+', append='r+')[self.options.mode] - self.readable = True if 'r' in self.mode else False - self.writable = False if 'r' == self.mode else True + self.options = Options(options, defaults="mode=a;soft7=true;single=auto") + self.mode = {"r": "r", "w": "w", "a": "r+", "append": "r+"}[self.options.mode] + self.readable = "r" in self.mode + self.writable = "r" != self.mode self.generic = True self.uri = uri - self.d = {} - if self.mode in ('r', 'r+'): - with open(uri, self.mode) as f: - d = pyyaml.safe_load(f) - if d: - self.d = d - - def close(self): + self._data = {} + + if self.mode in ("r", "r+"): + with open(uri, self.mode) as handle: + data = pyyaml.safe_load(handle) + if data: + self._data = data + + def close(self) -> None: """Closes this storage.""" if self.writable: - mode = ('w' if self.mode == 'r+' and not os.path.exists(self.uri) - else self.mode) - with open(self.uri, mode) as f: - pyyaml.dump(self.d, f, default_flow_style=False, sort_keys=False) + mode = ( + "w" + if self.mode == "r+" and not os.path.exists(self.uri) + else self.mode + ) + with open(self.uri, mode) as handle: + pyyaml.dump( + self._data, + handle, + default_flow_style=False, + sort_keys=False, + ) + + def load(self, id: str) -> dlite.Instance: + """Loads `uuid` from current storage and return it as a new instance. + + Parameters: + id: A UUID representing a DLite Instance to return from the RDF storage. - def load(self, id): - """Loads `uuid` from current storage and return it as a new instance.""" - return instance_from_dict(self.d, id, single=self.options.single, - check_storages=False) + Returns: + A DLite Instance corresponding to the given `id` (UUID). - def save(self, inst): - """Stores `inst` in current storage.""" - self.d[inst.uuid] = inst.asdict(soft7=dlite.asbool(self.options.soft7)) + """ + return instance_from_dict( + self._data, + id, + single=self.options.single, + check_storages=False, + ) + + def save(self, inst: dlite.Instance) -> None: + """Stores `inst` in current storage. + + Parameters: + inst: A DLite Instance to store in the RDF storage. + + """ + self._data[inst.uuid] = inst.asdict(soft7=dlite.asbool(self.options.soft7)) - def queue(self, pattern=None): + def queue(self, pattern: "Optional[str]" = None) -> "Generator[str, None, None]": """Generator method that iterates over all UUIDs in the storage - who's metadata URI matches glob pattern `pattern`.""" - for uuid, d in self.d.items(): - if pattern and dlite.globmatch(pattern, d['meta']): + who"s metadata URI matches glob pattern `pattern`. + + Parameters: + pattern: A regular expression to filter the yielded UUIDs. + + Yields: + DLite Instance UUIDs based on the `pattern` regular expression. + If no `pattern` is given, all UUIDs are yielded from within the RDF + storage. + + """ + for uuid, inst_as_dict in self._data.items(): + if pattern and dlite.globmatch(pattern, inst_as_dict["meta"]): continue yield uuid From 1877ff875f8524e99c8a3fb8a6302d5991ebbbe3 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 3 Jan 2023 17:20:39 +0100 Subject: [PATCH 06/11] Update docstrings for dlite.mappings --- bindings/python/dlite-misc.i | 2 +- bindings/python/factory.py | 2 + bindings/python/mappings.py | 644 +++++++++++++++++++++-------------- pydoc/conf.py | 8 + requirements.txt | 1 - 5 files changed, 408 insertions(+), 249 deletions(-) diff --git a/bindings/python/dlite-misc.i b/bindings/python/dlite-misc.i index 50b77c6af..25da852ea 100644 --- a/bindings/python/dlite-misc.i +++ b/bindings/python/dlite-misc.i @@ -150,7 +150,7 @@ void dlite_err_set_stream(FILE *); %feature("docstring", "\ Set error log file. Special values includes: -- ``None`` | ``""``: Turn off error output. +- ``None`` or empty: Turn off error output. - ````: Standard error. - ````: Standard output. diff --git a/bindings/python/factory.py b/bindings/python/factory.py index 09453c4f7..cc4c242e7 100644 --- a/bindings/python/factory.py +++ b/bindings/python/factory.py @@ -13,6 +13,8 @@ ``` """ +from __future__ import annotations + import copy from typing import TYPE_CHECKING diff --git a/bindings/python/mappings.py b/bindings/python/mappings.py index 4c9062bdf..aed5d2509 100644 --- a/bindings/python/mappings.py +++ b/bindings/python/mappings.py @@ -11,21 +11,23 @@ """ from __future__ import annotations -import itertools -import types import warnings from collections import defaultdict -from collections.abc import Sequence from enum import Enum -from typing import Any, Callable, Dict, List, Optional, Union +from typing import TYPE_CHECKING import numpy as np from pint import Quantity -from tripper import Triplestore, DM, FNO, MAP, RDF, RDFS +from tripper import DM, FNO, MAP, RDF, RDFS import dlite from dlite.utils import infer_dimensions +if TYPE_CHECKING: # pragma: no cover + from typing import Any, Callable, Generator, Optional, Sequence, Type + + from tripper.triplestore import Triplestore + class MappingError(dlite.DLiteError): """Base class for mapping errors.""" @@ -72,61 +74,78 @@ class Value: property_iri: IRI of datamodel property that this value is an instance of. cost: Cost of accessing this value. + """ - def __init__(self, value, unit=None, iri=None, property_iri=None, cost=0.0): + def __init__( + self, + value: "Any", + unit: "Optional[str]" =None, + iri: "Optional[str]" = None, + property_iri: "Optional[str]" = None, + cost: "Any | Callable" = 0.0, + ) -> None: self.value = value self.unit = unit self.iri = iri self.property_iri = property_iri self.cost = cost - def show(self, routeno=None, name=None, indent=0): + def show( + self, + routeno: "Optional[Any]" = None, + name: "Optional[str]" = None, + indent: int = 0, + ) -> str: """Returns a string representation of the Value. Arguments: - routeno: Unused. The argument exists for consistency with + routeno: Unused. The argument exists for consistency with the corresponding method in Step. name: Name of value. indent: Indentation level. + + Returns: + String representation of the value. + """ - s = [] - ind = ' '*indent - s.append(ind + f'{name if name else "Value"}:') - s.append(ind + f' iri: {self.iri}') - s.append(ind + f' property_iri: {self.property_iri}') - s.append(ind + f' unit: {self.unit}') - s.append(ind + f' cost: {self.cost}') - s.append(ind + f' value: {self.value}') - return '\n'.join(s) + res = [] + ind = " " * indent + res.append(ind + f"{name if name else 'Value'}:") + res.append(ind + f" iri: {self.iri}") + res.append(ind + f" property_iri: {self.property_iri}") + res.append(ind + f" unit: {self.unit}") + res.append(ind + f" cost: {self.cost}") + res.append(ind + f" value: {self.value}") + return "\n".join(res) class MappingStep: """A step in a mapping route from a target to one or more sources. - A mapping step corresponds to one or more RDF triples. In the + A mapping step corresponds to one or more RDF triples. In the simple case of a `mo:mapsTo` or `rdfs:isSubclassOf` relation, it is - only one triple. For transformations that has several input and + only one triple. For transformations that has several input and output, a set of triples are expected. Arguments: output_iri: IRI of the output concept. steptype: One of the step types from the StepType enum. function: Callable that evaluates the output from the input. - cost: The cost related to this mapping step. Should be either a - float or a callable taking the same arguments as `function` as + cost: The cost related to this mapping step. Should be either a + float or a callable taking the same arguments as ``function`` as input returning the cost as a float. output_unit: Output unit. The arguments can also be assigned as attributes. """ def __init__( - self, - output_iri: Optional[str] = None, - steptype: Optional[StepType] = None, - function: Optional[Callable] = None, - cost: Union[Any, Callable] = 1.0, - output_unit: Optional[str] = None, - ): + self, + output_iri: "Optional[str]" = None, + steptype: "Optional[StepType]" = None, + function: "Optional[Callable]" = None, + cost: "Any | Callable" = 1.0, + output_unit: "Optional[str]" = None, + ) -> None: self.output_iri = output_iri self.steptype = steptype self.function = function @@ -136,71 +155,99 @@ def __init__( self.join_mode = False # whether to join upcoming input self.joined_input = {} - def add_inputs(self, inputs): + def add_inputs(self, inputs: dict) -> None: """Add input dict for an input route.""" - assert isinstance(inputs, dict) + if not isinstance(inputs, dict): + raise TypeError("inputs must be a dict.") self.input_routes.append(inputs) - def add_input(self, input, name=None): - """Add an input (MappingStep or Value), where `name` is the name + def add_input( + self, input: "MappingStep | Value", name: "Optional[str]" = None + ) -> None: + """Add an input (MappingStep or Value), where ``name`` is the name assigned to the argument. - If the `join_mode` attribute is false, a new route is created with + If the ``join_mode`` attribute is ``False``, a new route is created with only one input. - If the `join_mode` attribute is true, the input is remembered, but - first added when join_input() is called. + If the ``join_mode`` attribute is ``True``, the input is remembered, but + first added when ``join_input()`` is called. + + Arguments: + input: A mapping step or a value. + name: Name assigned to the argument. + """ - assert isinstance(input, (MappingStep, Value)) - argname = name if name else f'arg{len(self.joined_input)+1}' + if not isinstance(input, (MappingStep, Value)): + raise TypeError("input must be either a MappingStep or a Value.") + + argname = name if name else f"arg{len(self.joined_input)+1}" if self.join_mode: self.joined_input[argname] = input else: self.add_inputs({argname: input}) - def join_input(self): + def join_input(self) -> None: """Join all input added with add_input() since `join_mode` was set true. Resets `join_mode` to false.""" if not self.join_mode: - raise MappingError('Calling join_input() when join_mode is false.') + raise MappingError("Calling join_input() when join_mode is false.") self.join_mode = False self.add_inputs(self.joined_input) self.joined_input = {} - def eval(self, routeno=None, unit=None, magnitude=False, quantity=Quantity): + def eval( + self, + routeno: "Optional[int]" = None, + unit: "Optional[str]" = None, + magnitude: bool = False, + quantity: "Type[Quantity]" = Quantity, + ) -> "Any": """Returns the evaluated value of given input route number. - Args: - routeno: The route number to evaluate. If None (default) + Arguments: + routeno: The route number to evaluate. If ``None`` (default) the route with the lowest cost is evalueated. - unit: return the result in the given unit. - Implies `magnitude=True`. - magnitude: Whether to only return the magitude of the evaluated + unit: return the result in the given unit. Implies `magnitude=True`. + magnitude: Whether to only return the magnitude of the evaluated value (with no unit). - quantity: Quantity class to use for evaluation. Defaults to pint. + quantity: Quantity class to use for evaluation. Defaults to pint. + """ if routeno is None: (_, routeno), = self.lowest_costs(nresults=1) inputs, idx = self.get_inputs(routeno) values = get_values(inputs, idx, quantity=quantity) + if self.function: value = self.function(**values) elif len(values) == 1: value, = values.values() else: raise TypeError( - f"Expected inputs to be a single argument: {values}") + f"Expected inputs to be a single argument: {values}" + ) if isinstance(value, quantity) and unit: return value.m_as(unit) - elif isinstance(value, quantity) and magnitude: + + if isinstance(value, quantity) and magnitude: return value.m - else: - return value - def get_inputs(self, routeno): - """Returns input and input index `(inputs, idx)` for route number - `routeno`.""" + return value + + def get_inputs(self, routeno: int) -> tuple[dict, int]: + """Returns input and input index ``(inputs, idx)`` for route number + ``routeno``. + + Arguments: + routeno: The route number to return inputs for. + + Returns: + Inputs and difference between route number and number of routes for an + input dictioary. + + """ n = 0 for inputs in self.input_routes: n0 = n @@ -209,23 +256,46 @@ def get_inputs(self, routeno): return inputs, routeno - n0 raise ValueError(f"routeno={routeno} exceeds number of routes") - def get_input_iris(self, routeno): + def get_input_iris(self, routeno: int) -> dict[str, str]: """Returns a dict mapping input names to iris for the given route - number.""" - inputs, idx = self.get_inputs(routeno) - return {k: v.output_iri if isinstance(v, MappingStep) else v.iri - for k, v in inputs.items()} + number. + + Arguments: + routeno: The route number to return a mapping for. + + Returns: + Mapping of input names to IRIs. + + """ + inputs, _ = self.get_inputs(routeno) + return { + key: value.output_iri if isinstance(value, MappingStep) else value.iri + for key, value in inputs.items() + } + + def number_of_routes(self) -> int: + """Total number of routes to this mapping step. + + Returns: + Total number of routes to this mapping step. - def number_of_routes(self): - """Returns total number of routes to this mapping step.""" + """ n = 0 for inputs in self.input_routes: n += get_nroutes(inputs) return n - def lowest_costs(self, nresults=5): - """Returns a list of `(cost, routeno)` tuples with up to the `nresult` - lowest costs and their corresponding route numbers.""" + def lowest_costs(self, nresults: int = 5) -> list: + """Returns a list of ``(cost, routeno)`` tuples with up to the ``nresults`` + lowest costs and their corresponding route numbers. + + Arguments: + nresults: Number of results to return. + + Returns: + A list of ``(cost, routeno)`` tuples. + + """ result = [] n = 0 # total number of routes @@ -289,81 +359,129 @@ def lowest_costs(self, nresults=5): # `nresults` rows with lowest cost. return sorted(result)[:nresults] - def show(self, routeno=None, name=None, indent=0): + def show( + self, + routeno: "Optional[int]" = None, + name: "Optional[str]" = None, + indent: int = 0, + ) -> str: """Returns a string representation of the mapping routes to this step. Arguments: - routeno: show given route. The default is to show all routes. + routeno: show given route. The default is to show all routes. name: Name of the last mapping step (mainly for internal use). - indent: How of blanks to prepend each line with (mainly for - internal use). + indent: How of blanks to prepend each line with (mainly for internal use). + + Returns: + String representation of the mapping routes. + """ - s = [] - ind = ' '*indent - s.append(ind + f'{name if name else "Step"}:') - s.append(ind + f' steptype: ' - f'{self.steptype.name if self.steptype else None}') - s.append(ind + f' output_iri: {self.output_iri}') - s.append(ind + f' output_unit: {self.output_unit}') - s.append(ind + f' cost: {self.cost}') + res = [] + ind = " " * indent + res.append(ind + f"{name if name else 'Step'}:") + res.append(ind + f" steptype: {self.steptype.name if self.steptype else None}") + res.append(ind + f" output_iri: {self.output_iri}") + res.append(ind + f" output_unit: {self.output_unit}") + res.append(ind + f" cost: {self.cost}") if routeno is None: - s.append(ind + f' routes:') + res.append(ind + " routes:") for inputs in self.input_routes: - t = '\n'.join([input_.show(name=name_, indent=indent+6) - for name_, input_ in inputs.items()]) - s.append(ind + ' - ' + t[indent+6:]) + input_repr = "\n".join( + [ + input_.show(name=name_, indent=indent + 6) + for name_, input_ in inputs.items() + ] + ) + res.append(ind + f" - {input_repr[indent+6:]}") else: - s.append(ind + f' inputs:') + res.append(ind + " inputs:") inputs, idx = self.get_inputs(routeno) - t = '\n'.join([input_.show(routeno=idx, name=name_, indent=indent+6) - for name_, input_ in inputs.items()]) - s.append(ind + ' - ' + t[indent+6:]) - return '\n'.join(s) + input_repr = "\n".join( + [ + input_.show(routeno=idx, name=name_, indent=indent + 6) + for name_, input_ in inputs.items() + ] + ) + res.append(ind + f" - {input_repr[indent+6:]}") + return "\n".join(res) + +def get_nroutes(inputs: "dict[str, Any]") -> int: + """Help function returning the number of routes for an input dict. -def get_nroutes(inputs): - """Help function returning the number of routes for an input dict.""" - m = 1 + Arguments: + inputs: Input dictionary. + + Returns: + Number of routes in the ``inputs`` input dictionary. + + """ + nroutes = 1 for input in inputs.values(): if isinstance(input, MappingStep): - m *= input.number_of_routes() - return m + nroutes *= input.number_of_routes() + return nroutes -def get_values(inputs, routeno, quantity=Quantity, magnitudes=False): +def get_values( + inputs: "dict[str, Any]", + routeno: int, + quantity: "Type[Quantity]" = Quantity, + magnitudes: bool = False, +) -> "dict[str, Any]": """Help function returning a dict mapping the input names to actual value of expected input unit. - There exists `get_nroutes(inputs)` routes to populate `inputs`. - `routeno` is the index of the specific route we will use to obtain the - values.""" + There exists ``get_nroutes(inputs)`` routes to populate ``inputs``. + ``routeno`` is the index of the specific route we will use to obtain the + values. + + Arguments: + inputs: Input dictionary. + routeno: Route number index. + quantity: A unit quantity class. + magnitudes: Whether to only return the magnitude of the evaluated + value (with no unit). + + Returns: + A mapping between input names and values of expected input unit. + + """ values = {} - for k, v in inputs.items(): - if isinstance(v, MappingStep): - value = v.eval(routeno=routeno, quantity=quantity) - values[k] = ( - value.to(v.output_unit) - if v.output_unit and isinstance(v, quantity) else value + for key, input_value in inputs.items(): + if isinstance(input_value, MappingStep): + value = input_value.eval(routeno=routeno, quantity=quantity) + values[key] = ( + value.to(input_value.output_unit) + if input_value.output_unit and isinstance(input_value, quantity) else value ) + elif isinstance(input_value, Value): + values[key] = quantity(input_value.value, input_value.unit) else: - values[k] = quantity(v.value, v.unit) + raise TypeError( + "Expected values in inputs to be either MappingStep or Value objects." + ) if magnitudes: - values = {k: v.m if isinstance(v, quantity) else v - for k, v in values.items()} + values = { + key: value.m if isinstance(value, quantity) else value + for key, value in values.items() + } return values -def fno_mapper(triplestore): +def fno_mapper(triplestore: "Triplestore") -> defaultdict: """Finds all function definitions in `triplestore` based on the function ontololy (FNO). - Return a dict mapping output IRIs to a list of + Arguments: + triplestore: The triplestore to investigate. - (function_iri, [input_iris, ...]) + Returns: + A mapping of output IRIs to a list of ``(function_iri, [input_iris, ...])`` + tuples. - tuples. """ # Temporary dicts for fast lookup Dfirst = {s: o for s, o in triplestore.subject_objects(RDF.first)} @@ -375,7 +493,7 @@ def fno_mapper(triplestore): for s, o in triplestore.subject_objects(FNO.returns): Dreturns[s].append(o) - d = defaultdict(list) + res = defaultdict(list) for func, lst in Dreturns.items(): input_iris = [] for exp in Dexpects.get(func, ()): @@ -393,34 +511,39 @@ def fno_mapper(triplestore): for ret in lst: if ret in Dfirst: while ret in Dfirst: - d[Dfirst[ret]].append((func, input_iris)) + res[Dfirst[ret]].append((func, input_iris)) if ret not in Drest: break ret = Drest[ret] else: # Support also misuse of FNO, where fno:returns refers # directly to the returned individual - d[ret].append((func, input_iris)) + res[ret].append((func, input_iris)) - return d + return res def mapping_route( - target, - sources, - triplestore, - function_repo=None, - function_mappers=(fno_mapper, ), - default_costs={'function': 10.0, 'mapsTo': 2.0, 'instanceOf': 1.0, - 'subClassOf': 1.0, 'value': 0.0}, - mapsTo=MAP.mapsTo, - instanceOf=DM.instanceOf, - subClassOf=RDFS.subClassOf, - #description=DCTERMS.description, - label=RDFS.label, - hasUnit=DM.hasUnit, - hasCost=DM.hasCost, # TODO - add hasCost to the DM ontology -): + target: str, + sources: dict, + triplestore: "Triplestore", + function_repo: "Optional[dict[str, Callable]]" = None, + function_mappers: "Sequence[Callable]" = (fno_mapper, ), + default_costs: dict[str, float] = { + "function": 10.0, + "mapsTo": 2.0, + "instanceOf": 1.0, + "subClassOf": 1.0, + "value": 0.0, + }, + mapsTo: str = MAP.mapsTo, + instanceOf: str = DM.instanceOf, + subClassOf: str = RDFS.subClassOf, + #description: str = DCTERMS.description, + label: str = RDFS.label, + hasUnit: str = DM.hasUnit, + hasCost: str = DM.hasCost, # TODO - add hasCost to the DM ontology +) -> MappingStep: """Find routes of mappings from any source in `sources` to `target`. This implementation supports functions (using FnO) and subclass @@ -432,8 +555,6 @@ def mapping_route( sources: Dict mapping source IRIs to source values. triplestore: Triplestore instance. It is safe to pass a generator expression too. - - Additional arguments for fine-grained tuning: function_repo: Dict mapping function IRIs to corresponding Python function. Default is to use `triplestore.function_repo`. function_mappers: Sequence of mapping functions that takes @@ -456,20 +577,21 @@ def mapping_route( property. Returns: - A MappingStep instance. This is a root of a nested tree of + A MappingStep instance. This is a root of a nested tree of MappingStep instances providing an (efficient) internal description of all possible mapping routes from `sources` to `target`. + """ if function_repo is None: function_repo = triplestore.function_repo # Create lookup tables for fast access to properties - # This only transverse `tiples` once - soMaps = defaultdict(list) # (s, mapsTo, o) ==> soMaps[s] -> [o, ..] - osMaps = defaultdict(list) # (o, mapsTo, s) ==> osMaps[o] -> [s, ..] + # This only transverse `triples` once + soMaps = defaultdict(list) # (s, mapsTo, o) ==> soMaps[s] -> [o, ..] + osMaps = defaultdict(list) # (o, mapsTo, s) ==> osMaps[o] -> [s, ..] osSubcl = defaultdict(list) # (o, subClassOf, s) ==> osSubcl[o] -> [s, ..] - soInst = dict() # (s, instanceOf, o) ==> soInst[s] -> o - osInst = defaultdict(list) # (o, instanceOf, s) ==> osInst[o] -> [s, ..] + soInst = {} # (s, instanceOf, o) ==> soInst[s] -> o + osInst = defaultdict(list) # (o, instanceOf, s) ==> osInst[o] -> [s, ..] for s, o in triplestore.subject_objects(mapsTo): soMaps[s].append(o) osMaps[o].append(s) @@ -478,8 +600,9 @@ def mapping_route( for s, o in triplestore.subject_objects(instanceOf): if s in soInst: raise InconsistentTriplesError( - f'The same individual can only relate to one datamodel ' - f'property via {instanceOf} relations.') + "The same individual can only relate to one datamodel " + f"property via {instanceOf} relations." + ) soInst[s] = o osInst[o].append(s) soName = {s: o for s, o in triplestore.subject_objects(label)} @@ -560,13 +683,19 @@ def addnode(node, steptype, stepname): return step -def instance_routes(meta, instances, triplestore, allow_incomplete=False, - quantity=Quantity, **kwargs): +def instance_routes( + meta: str | dlite.Metadata, + instances: "dlite.Instance | Sequence[dlite.Instance]", + triplestore: "Triplestore", + allow_incomplete: bool = False, + quantity: "Type[Quantity]" = Quantity, + **kwargs, +) -> dict[str, MappingStep]: """Find all mapping routes for populating an instance of `meta`. Arguments: meta: Metadata for the instance we will create. - instances: sequence of instances that the new intance will be + instances: sequence of instances that the new instance will be populated from. triplestore: Triplestore containing the mappings. allow_incomplete: Whether to allow not populating all properties @@ -577,6 +706,7 @@ def instance_routes(meta, instances, triplestore, allow_incomplete=False, Returns: A dict mapping property names to a MappingStep instance. + """ if isinstance(meta, str): meta = dlite.get_instance(meta) @@ -585,9 +715,9 @@ def instance_routes(meta, instances, triplestore, allow_incomplete=False, sources = {} for inst in instances: - props = {p.name: p for p in inst.meta['properties']} - for k, v in inst.properties.items(): - sources[f'{inst.meta.uri}#{k}'] = quantity(v, props[k].unit) + props = {prop.name: prop for prop in inst.meta['properties']} + for key, value in inst.properties.items(): + sources[f'{inst.meta.uri}#{key}'] = quantity(value, props[key].unit) routes = {} for prop in meta['properties']: @@ -599,14 +729,19 @@ def instance_routes(meta, instances, triplestore, allow_incomplete=False, continue raise if not allow_incomplete and not route.number_of_routes(): - raise InsufficientMappingError(f'no mappings for {target}') + raise InsufficientMappingError(f'No mappings for {target}') routes[prop.name] = route return routes -def instantiate_from_routes(meta, routes, routedict=None, id=None, - quantity=Quantity): +def instantiate_from_routes( + meta: str | dlite.Metadata, + routes: dict[str, MappingStep], + routedict: dict[str, int] = None, + id: "Optional[str]" = None, + quantity: "Type[Quantity]" = Quantity, +) -> dlite.Instance: """Create a new instance of `meta` from selected mapping route returned by instance_routes(). @@ -623,31 +758,41 @@ def instantiate_from_routes(meta, routes, routedict=None, id=None, Returns: New instance. + """ if isinstance(meta, str): meta = dlite.get_instance(meta) - if routedict is None: - routedict = {} + routedict = routedict or {} values = {} for prop in meta['properties']: if prop.name in routes: step = routes[prop.name] - values[prop.name] = step.eval(routeno=routedict.get(prop.name), - unit=prop.unit, - quantity=quantity) + values[prop.name] = step.eval( + routeno=routedict.get(prop.name), + unit=prop.unit, + quantity=quantity, + ) dims = infer_dimensions(meta, values) inst = meta(dims=dims, id=id) - for k, v in routes.items(): - inst[k] = v.eval(magnitude=True, unit=meta.getprop(k).unit) + for key, value in routes.items(): + inst[key] = value.eval(magnitude=True, unit=meta.getprop(key).unit) return inst -def instantiate(meta, instances, triplestore, routedict=None, id=None, - allow_incomplete=False, quantity=Quantity, **kwargs): +def instantiate( + meta: str | dlite.Metadata, + instances: "dlite.Instance | Sequence[dlite.Instance]", + triplestore: "Triplestore", + routedict: "Optional[dict[str, int]]" = None, + id: "Optional[str]" = None, + allow_incomplete: bool = False, + quantity: "Type[Quantity]" = Quantity, + **kwargs, +) -> dlite.Instance: """Create a new instance of `meta` populated with the selected mapping routes. @@ -670,25 +815,12 @@ def instantiate(meta, instances, triplestore, routedict=None, id=None, quantity: Class implementing quantities with units. Defaults to pint.Quantity. - Keyword arguments (passed to mapping_route()): - function_repo: Dict mapping function IRIs to corresponding Python - function. Default is to use `triplestore.function_repo`. - function_mappers: Sequence of mapping functions that takes - `triplestore` as argument and return a dict mapping output IRIs - to a list of `(function_iri, [input_iris, ...])` tuples. - mapsTo: IRI of 'mapsTo' in `triplestore`. - instanceOf: IRI of 'instanceOf' in `triplestore`. - subClassOf: IRI of 'subClassOf' in `triplestore`. Set it to None if - subclasses should not be considered. - label: IRI of 'label' in `triplestore`. Used for naming function - input parameters. The default is to use rdfs:label. - hasUnit: IRI of 'hasUnit' in `triplestore`. - hasCost: IRI of 'hasCost' in `triplestore`. - Returns: New instance. + """ - meta = dlite.get_instance(meta) + if isinstance(meta, str): + meta = dlite.get_instance(meta) routes = instance_routes( meta=meta, @@ -696,19 +828,26 @@ def instantiate(meta, instances, triplestore, routedict=None, id=None, triplestore=triplestore, allow_incomplete=allow_incomplete, quantity=quantity, - **kwargs + **kwargs, ) return instantiate_from_routes( meta=meta, routes=routes, routedict=routedict, id=id, - quantity=quantity + quantity=quantity, ) -def instantiate_all(meta, instances, triplestore, routedict=None, - allow_incomplete=False, quantity=Quantity, **kwargs): +def instantiate_all( + meta: str | dlite.Metadata, + instances: "dlite.Instance | Sequence[dlite.Instance]", + triplestore: "Triplestore", + routedict: "Optional[dict[str, int]]" = None, + allow_incomplete: bool = False, + quantity: "Type[Quantity]" = Quantity, + **kwargs, +) -> "Generator[dlite.Instance, None, None]": """Like instantiate(), but returns a generator over all possible instances. The number of instances iterated over is the product of the number @@ -729,25 +868,12 @@ def instantiate_all(meta, instances, triplestore, routedict=None, quantity: Class implementing quantities with units. Defaults to pint.Quantity. - Keyword arguments (passed to mapping_route()): - function_repo: Dict mapping function IRIs to corresponding Python - function. Default is to use `triplestore.function_repo`. - function_mappers: Sequence of mapping functions that takes - `triplestore` as argument and return a dict mapping output IRIs - to a list of `(function_iri, [input_iris, ...])` tuples. - mapsTo: IRI of 'mapsTo' in `triplestore`. - instanceOf: IRI of 'instanceOf' in `triplestore`. - subClassOf: IRI of 'subClassOf' in `triplestore`. Set it to None if - subclasses should not be considered. - label: IRI of 'label' in `triplestore`. Used for naming function - input parameters. The default is to use rdfs:label. - hasUnit: IRI of 'hasUnit' in `triplestore`. - hasCost: IRI of 'hasCost' in `triplestore`. + Yields: + New instances. - Returns: - Generator over new instances. """ - meta = dlite.get_instance(meta) + if isinstance(meta, str): + meta = dlite.get_instance(meta) routes = instance_routes( meta=meta, @@ -758,10 +884,9 @@ def instantiate_all(meta, instances, triplestore, routedict=None, **kwargs ) - property_names = [p.name for p in meta.properties['properties']] - nprops = len(property_names) + property_names = [prop.name for prop in meta.properties['properties']] - def routedicts(n): + def routedicts(n: int) -> "Generator[dict[str, int], None, None]": """Recursive help function returning an iterator over all possible routedicts. `n` is the index of the current property.""" if n < 0: @@ -778,20 +903,18 @@ def routedicts(n): outer[name] = inner yield outer - for rd in routedicts(len(property_names) - 1): + for route_dict in routedicts(len(property_names) - 1): yield instantiate_from_routes( meta=meta, routes=routes, - routedict=rd, + routedict=route_dict, quantity=quantity ) - - # ------------- Old implementation ----------------- -def unitconvert_pint(dest_unit, value, unit): +def unitconvert_pint(dest_unit: "Any", value: "Any", unit: "Any") -> "Any": """Returns `value` converted to `dest_unit`. A unitconvert function based on Pint. Alternative functions @@ -801,6 +924,7 @@ def unitconvert_pint(dest_unit, value, unit): dest_unit: Destination unit that `value` should be converted to. value: Source value. unit: The unit of the source value. + """ import pint ureg = pint.UnitRegistry() @@ -812,7 +936,9 @@ def unitconvert_pint(dest_unit, value, unit): unitconvert = unitconvert_pint -def match_factory(triples, match_first=False): +def match_factory( + triples: "Sequence[tuple[str, str, str]]", match_first: bool = False +) -> "Callable[[Optional[str], Optional[str], Optional[str]], Generator[tuple[str, str, str], None, None]]": """A factory function that returns a match functions for `triples`. If `match_first` is false, the returned match function will return @@ -825,39 +951,47 @@ def match_factory(triples, match_first=False): only the first match. Example: - >>> triples = [ - ... (':mamal', 'rdfs:subClassOf', ':animal'), - ... (':cat', 'rdfs:subClassOf', ':mamal'), - ... (':mouse', 'rdfs:subClassOf', ':mamal'), - ... (':cat', ':eats', ':mouse'), - ... ] - >>> match = match_factory(triples) - >>> match_first = match_factory(triples, only_first=True) - >>> list(match(':cat')) - [(':cat', 'rdfs:subClassOf', ':mamal'), - (':cat', ':eats', ':mouse')] - >>> match_first(':cat', None, None) - (':cat', 'rdfs:subClassOf', ':mamal') + >>> triples = [ + ... (':mamal', 'rdfs:subClassOf', ':animal'), + ... (':cat', 'rdfs:subClassOf', ':mamal'), + ... (':mouse', 'rdfs:subClassOf', ':mamal'), + ... (':cat', ':eats', ':mouse'), + ... ] + >>> match = match_factory(triples) + >>> match_first = match_factory(triples, only_first=True) + >>> list(match(':cat')) + [(':cat', 'rdfs:subClassOf', ':mamal'), + (':cat', ':eats', ':mouse')] + >>> match_first(':cat', None, None) + (':cat', 'rdfs:subClassOf', ':mamal') + """ - def match(s=None, p=None, o=None): + + def match( + s: "Optional[str]" = None, p: "Optional[str]" = None, o: "Optional[str]" = None + ) -> "Generator[tuple[str, str, str], None, None]": """Returns generator over all triples that matches (s, p, o).""" - return (t for t in triples if - (s is None or t[0] == s) and - (p is None or t[1] == p) and - (o is None or t[2] == o)) + return ( + triple for triple in triples + if ( + (s is None or triple[0] == s) and + (p is None or triple[1] == p) and + (o is None or triple[2] == o) + ) + ) + if match_first: return lambda s=None, p=None, o=None: next( iter(match(s, p, o) or ()), (None, None, None)) - else: - return match - + return match - -def assign_dimensions(dims: Dict, - inst: dlite.Instance, - propname: str): +def assign_dimensions( + dims: dict[str, int], + inst: dlite.Instance, + propname: str, +) -> None: """Assign dimensions from property assignment. Args: @@ -865,26 +999,36 @@ def assign_dimensions(dims: Dict, should be assigned. Only values that are None will be assigned. inst: Source instance. propname: Source property name. + """ - lst = [p.dims for p in inst.meta['properties'] if p.name == propname] + lst = [prop.dims for prop in inst.meta['properties'] if prop.name == propname] if not lst: - raise MappingError('Unexpected property name: {src_propname}') + raise MappingError(f"Unexpected property name: {propname}") + src_dims, = lst for dim in src_dims: if dim not in dims: - raise InconsistentDimensionError('Unexpected dimension: {dim}') + raise InconsistentDimensionError(f"Unexpected dimension: {dim}") + if dims[dim] is None: dims[dim] = inst.dimensions[dim] elif dims[dim] != inst.dimensions[dim]: raise InconsistentDimensionError( - f'Trying to assign dimension {dim} of {src_inst.meta.uri} ' - f'to {src_inst.dimensions[dim]}, but it is already assigned ' - f'to {dims[dim]}') + f"Trying to assign dimension {dim} of {inst.meta.uri} " + f"to {inst.dimensions[dim]}, but it is already assigned " + f"to {dims[dim]}" + ) -def make_instance(meta, instances, mappings=(), strict=True, - allow_incomplete=False, unitconvert=unitconvert_pint, - mapsTo=':mapsTo'): +def make_instance( + meta: dlite.Metadata, + instances: "dlite.Instance | Sequence[dlite.Instance]", + mappings: "Sequence[tuple[str, str, str]]" = (), + strict: bool = True, + allow_incomplete: bool = False, + unitconvert: "Callable[[Any, Any, Any], Any]" = unitconvert_pint, + mapsTo: str = ':mapsTo', +) -> dlite.Instance: """Create an instance of `meta` using data found in `*instances`. Args: @@ -917,11 +1061,12 @@ def make_instance(meta, instances, mappings=(), strict=True, returned instance is assigned to more than one value. Todo: + - Consider that mapsTo is a transitive relation. - Use EMMOntoPy to also account for rdfs:subClassOf relations. - Consider the possibility to assign values via the `mappings` - triples. Do we really want that? May be useful, but will add - a lot of complexity. Should we have different relations for + triples. Do we really want that? May be useful, but will add + a lot of complexity. Should we have different relations for default values and values that will overwrite what is provided from a matching input instance? - Add `mapsToPythonExpression` subproperty of `mapsTo` to allow @@ -930,6 +1075,7 @@ def make_instance(meta, instances, mappings=(), strict=True, Use the ast module for safe evaluation to ensure that this feature cannot be misused for code injection. - Add a function that visualise the possible mapping paths. + """ warnings.warn( "make_instance() is deprecated. Use instantiate() instead.", @@ -941,7 +1087,7 @@ def make_instance(meta, instances, mappings=(), strict=True, if isinstance(instances, dlite.Instance): instances = [instances] - dims = {d.name: None for d in meta['dimensions']} + dims = {dim.name: None for dim in meta['dimensions']} props = {} for prop in meta['properties']: @@ -950,15 +1096,16 @@ def make_instance(meta, instances, mappings=(), strict=True, for inst in instances: for prop2 in inst.meta['properties']: prop2_uri = f'{inst.meta.uri}#{prop2.name}' - for _, _, o2 in match(prop2_uri, mapsTo, o): + for _ in match(prop2_uri, mapsTo, o): value = inst[prop2.name] if prop.name not in props: assign_dimensions(dims, inst, prop2.name) props[prop.name] = value elif props[prop.name] != value: raise AmbiguousMappingError( - f'"{prop.name}" maps to both ' - f'"{props[prop.name]}" and "{value}"') + f"{prop.name!r} maps to both " + f"{props[prop.name]!r} and {value!r}" + ) if prop.name not in props and not strict: for inst in instances: @@ -969,20 +1116,23 @@ def make_instance(meta, instances, mappings=(), strict=True, props[prop.name] = value elif props[prop.name] != value: raise AmbiguousMappingError( - f'"{prop.name}" assigned to both ' - f'"{props[prop.name]}" and "{value}"') + f"{prop.name!r} assigned to both " + f"{props[prop.name]!r} and {value!r}" + ) if not allow_incomplete and prop.name not in props: raise InsufficientMappingError( - f'no mapping for assigning property "{prop.name}" ' - f'in {meta.uri}') + f"no mapping for assigning property {prop.name!r} " + f"in {meta.uri}" + ) if None in dims: - dimname = [k for k, v in dims.items() if v is None][0] + dimname = [name for name, dim in dims.items() if dim is None][0] raise InsufficientMappingError( - f'dimension "{dimname}" is not assigned') + f"dimension {dimname!r} is not assigned" + ) inst = meta(list(dims.values())) - for k, v in props.items(): - inst[k] = v + for key, value in props.items(): + inst[key] = value return inst diff --git a/pydoc/conf.py b/pydoc/conf.py index 5a602754c..904c6b8df 100644 --- a/pydoc/conf.py +++ b/pydoc/conf.py @@ -62,6 +62,7 @@ "breathe", # Doxygen bridge "myst_nb", # markdown source support & support for Jupyter notebooks "sphinx.ext.graphviz", # Graphviz + "sphinx.ext.intersphinx", # Connect to external (Sphinx) API documentation "sphinx.ext.napoleon", # API ref Google and NumPy style "sphinx.ext.viewcode", "sphinxcontrib.plantuml", # PlantUml @@ -122,7 +123,14 @@ # html_css_files = ["custom.css"] intersphinx_mapping = { + "numpy": ("https://numpy.org/doc/stable/", None), + "openpyxl": ("https://openpyxl.readthedocs.io/en/stable/", None), + "pandas": ("https://pandas.pydata.org/docs/", None), + "pint": ("https://pint.readthedocs.io/en/stable/", None), + "pymongo": ("https://pymongo.readthedocs.io/en/stable/", None), "python": ("https://docs.python.org/3", None), + "rdflib": ("https://rdflib.readthedocs.io/en/stable/", None), + "tripper": ("https://emmc-asbl.github.io/tripper/latest/", None), } myst_heading_anchors = 5 diff --git a/requirements.txt b/requirements.txt index a229913a8..cbeda11fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ numpy PyYAML psycopg2-binary pandas -pymongo rdflib pint openpyxl From b179404515eb64ec7de99db9639a0ad5c44bb55c Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 3 Jan 2023 20:11:43 +0100 Subject: [PATCH 07/11] Fix testing PostgreSQL storage These tests are absolutely HORRIBLE!!!! --- .../python-storage-plugins/postgresql.py | 6 +-- .../test_postgresql_storage_python.py | 45 ++++++++++--------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/storages/python/python-storage-plugins/postgresql.py b/storages/python/python-storage-plugins/postgresql.py index 1d2e9861e..41f30e7a0 100644 --- a/storages/python/python-storage-plugins/postgresql.py +++ b/storages/python/python-storage-plugins/postgresql.py @@ -139,10 +139,10 @@ def load(self, uuid: str) -> dlite.Instance: # Make sure we have a metadata object corresponding to metaid try: with dlite.err(): - meta = dlite.get_instance(metaid) + dlite.get_instance(metaid) except RuntimeError: dlite.errclr() - meta = self.load(metaid) + self.load(metaid) inst: dlite.Instance = dlite.Instance.from_metaid(metaid, dims, uri) @@ -265,7 +265,7 @@ def _table_create(self, meta: dlite.Metadata) -> None: self.connection.commit() def _uuidtable_create(self) -> None: - """Creates the uuidtable - a table mapping all uuid"s to their + """Creates the uuidtable - a table mapping all uuid's to their metadata uri.""" sql_query = sql.SQL( "CREATE TABLE uuidtable (uuid char(36) PRIMARY KEY, meta varchar);" diff --git a/storages/python/tests-python/test_postgresql_storage_python.py b/storages/python/tests-python/test_postgresql_storage_python.py index 03ebf84ad..72d2c8915 100644 --- a/storages/python/tests-python/test_postgresql_storage_python.py +++ b/storages/python/tests-python/test_postgresql_storage_python.py @@ -5,10 +5,7 @@ from pathlib import Path sys.dont_write_bytecode = True -import psycopg2 -from psycopg2 import sql -import dlite from dlite.utils import instance_from_dict from run_python_storage_tests import print_test_exception @@ -42,51 +39,57 @@ df = ' def ' ind8 = ' ' # Indent of 8 spaces - open_start = lines.index(df + 'open(self, uri, options=None):\n') - close_start = lines.index(df + 'close(self):\n') - load_start = lines.index(df + 'load(self, uuid):\n') - save_start = lines.index(df + 'save(self, inst):\n') + open_start = lines.index(df + 'open(self, uri: str, options: "Optional[str]" = None) -> None:\n') + close_start = lines.index(df + 'close(self) -> None:\n') + load_start = lines.index(df + 'load(self, uuid: str) -> dlite.Instance:\n') + save_start = lines.index(df + 'save(self, inst: dlite.Instance) -> None:\n') # open(): Don't connect to server - read 'uri' instead lines[open_start + 1] = ind8 + 'self.data = open_pgsql(uri)\n' lines[open_start + 2] = ind8 + 'self.d = {}\n' + lines[open_start + 3] = ind8 + "return None\n" # close(): Don't disconnect from server - just pass - lines[close_start + 1] = ind8 + 'pass\n' + lines[close_start + 2] = ind8 + 'pass\n' # load(): Don't access server - read from self.data - lines[load_start + 3] = ind8 + 'self.d[uuid] = ' \ + lines[load_start + 11] = ind8 + 'self.d[uuid] = ' \ + 'load_pgsql(self.data, uuid, ["L", "M", "N"])\n' - lines[load_start + 4] = ind8 \ + lines[load_start + 12] = ind8 \ + 'return instance_from_dict(self.d[uuid])\n' # save(): Don't write to database, but compare the writing # commands to the commands in the database dump file - n = save_start + 2 + n = save_start + 8 lines[n - 1] = ind8 + 'ret = {"uuid": inst.uuid}\n' - while not lines[n].startswith(df + 'table_exists'): - lines[n] = lines[n].replace('self.conn', '#') - lines[n] = lines[n].replace('self.cur.execute(', \ - 'ret = extract_exec_args(ret, ') - if lines[n].startswith(ind8 + 'if not self.table'): + while not lines[n].startswith(df + 'instances'): + lines[n] = lines[n].replace('self.connection', '#') + lines[n] = lines[n].replace( + 'self.cursor.execute(', + 'ret = extract_exec_args(ret, ', + ) + if lines[n].startswith(ind8 + 'if not self._table'): lines[n] = '\n' lines[n + 1] = '\n' n += 1 lines[n - 1] = ind8 + 'return ret\n' + lines = lines[:n] - del lines[(load_start + 5):(save_start - 1)] + # del lines[(load_start + 5):(save_start - 1)] del lines[(close_start + 2):(load_start - 1)] - del lines[(open_start + 3):(close_start - 1)] + del lines[(open_start + 4):(close_start - 1)] + # uuidtable_create_start = lines.index(df + "_uuidtable_create(self) -> None:\n") + # del lines[(uuidtable_create_start + 1):(uuidtable_create_start + 3)] s = 'from test_postgresql_storage_python import open_pgsql, ' \ - + 'load_pgsql, extract_exec_args\n' + str().join(lines) + + 'load_pgsql, extract_exec_args\n' + "".join(lines) s = s.replace('class postgresql', 'class dlite_postgresql') exec(s) - + # Load JSON metadata with open(dlite_path / 'src/tests/test-entity.json', 'r') as f: json_dict1 = json.load(f) json_dict1 = instance_from_dict(json_dict1).asdict() - + # Test loading PostgreSQL metadata postgresql_inst1 = dlite_postgresql() postgresql_inst1.open(input_path / 'test_meta.pgsql') From 2ed719833b6f56c6490aee3cc57419e3de26b71d Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Tue, 3 Jan 2023 21:01:41 +0100 Subject: [PATCH 08/11] Remove triplestore - moved to tripper --- bindings/python/tests/CMakeLists.txt | 1 - bindings/python/tests/test_triplestore.py | 224 ----- bindings/python/triplestore/__init__.py | 33 - .../python/triplestore/backends/__init__.py | 0 .../python/triplestore/backends/ontopy.py | 208 ----- .../python/triplestore/backends/rdflib.py | 130 --- bindings/python/triplestore/interface.py | 102 --- bindings/python/triplestore/test_units.py | 1 - bindings/python/triplestore/triplestore.py | 776 ------------------ bindings/python/triplestore/units.py | 3 +- bindings/python/utils.py | 2 - examples/mappings/mappingfunc.py | 3 +- examples/mappings/simple.py | 2 +- 13 files changed, 3 insertions(+), 1482 deletions(-) delete mode 100644 bindings/python/tests/test_triplestore.py delete mode 100644 bindings/python/triplestore/backends/__init__.py delete mode 100644 bindings/python/triplestore/backends/ontopy.py delete mode 100644 bindings/python/triplestore/backends/rdflib.py delete mode 100644 bindings/python/triplestore/interface.py delete mode 100644 bindings/python/triplestore/triplestore.py diff --git a/bindings/python/tests/CMakeLists.txt b/bindings/python/tests/CMakeLists.txt index dedc26fa1..0420351fc 100644 --- a/bindings/python/tests/CMakeLists.txt +++ b/bindings/python/tests/CMakeLists.txt @@ -17,7 +17,6 @@ set(tests test_transaction test_mapping test_rdf - test_triplestore test_postgresql_write test_postgresql_read ) diff --git a/bindings/python/tests/test_triplestore.py b/bindings/python/tests/test_triplestore.py deleted file mode 100644 index 3251a7231..000000000 --- a/bindings/python/tests/test_triplestore.py +++ /dev/null @@ -1,224 +0,0 @@ -# Skip test if rdflib is not available -import textwrap -from pathlib import Path - -from check_import import check_import - -from dlite.triplestore import ( - en, Literal, Namespace, Triplestore, RDF, RDFS, XSD, OWL, SKOS -) -from dlite.triplestore.triplestore import function_id, NoSuchIRIError - -rdflib = check_import("rdflib", skip=True) -pytest = check_import("pytest") -ontopy = check_import("ontopy") - - -thisdir = Path(__file__).absolute().parent -ontopath_family = thisdir / "ontologies" / "family.ttl" -ontopath_food = thisdir / "ontologies" / "food.ttl" - - -# Test namespaces -# --------------- -assert str(RDF) == "http://www.w3.org/1999/02/22-rdf-syntax-ns#" -assert RDF.type == "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" - -FAM = Namespace( - "http://onto-ns.com/ontologies/examples/family#", - check=True, - triplestore_url=ontopath_family, -) -FOOD = Namespace( - "http://onto-ns.com/ontologies/examples/food#", - label_annotations=True, - check=True, - triplestore_url=ontopath_food, -) -FOOD2 = Namespace( - "http://onto-ns.com/ontologies/examples/food#", - label_annotations=True, - check=False, - triplestore_url=ontopath_food, -) -assert FAM.Son == "http://onto-ns.com/ontologies/examples/family#Son" -assert FAM["Son"] == "http://onto-ns.com/ontologies/examples/family#Son" -assert FAM + "Son" == "http://onto-ns.com/ontologies/examples/family#Son" - -name = "FOOD_345ecde3_3cac_41d2_aad6_cb6835a27b41" -assert FOOD[name] == FOOD + name -assert FOOD.Vegetable == FOOD + name - -assert FOOD2[name] == FOOD2 + name -assert FOOD2.Vegetable == FOOD2 + name - -if pytest: - with pytest.raises(NoSuchIRIError): - FAM.NonExisting - - with pytest.raises(NoSuchIRIError): - FOOD.NonExisting - -assert FOOD2.NonExisting == FOOD2 + "NonExisting" - - -# Test RDF literals -# ----------------- -l1 = Literal("Hello world!") -assert l1 == "Hello world!" -assert isinstance(l1, str) -assert l1.lang is None -assert l1.datatype is None -assert l1.to_python() == "Hello world!" -assert l1.value == "Hello world!" -assert l1.n3() == '"Hello world!"' - -l2 = Literal("Hello world!", lang="en") -assert l2.lang == "en" -assert l2.datatype == None -assert l2.value == "Hello world!" -assert l2.n3() == '"Hello world!"@en' - -l3 = en("Hello world!") -assert l3.n3() == '"Hello world!"@en' - -l4 = Literal(42) -assert l4.lang == None -assert l4.datatype == XSD.integer -assert l4.value == 42 -assert l4.n3() == f'"42"^^{XSD.integer}' - -l5 = Literal(42, datatype=float) -assert l5.lang == None -assert l5.datatype == XSD.double -assert l5.value == 42.0 -assert l5.n3() == f'"42"^^{XSD.double}' - - -# Test rdflib triplestore backend -# ------------------------------- -if rdflib: - ts = Triplestore("rdflib") - assert ts.expand_iri("xsd:integer") == XSD.integer - assert ts.prefix_iri(RDF.type) == 'rdf:type' - EX = ts.bind("ex", "http://example.com/onto#") - assert str(EX) == "http://example.com/onto#" - ts.add_mapsTo( - EX.MyConcept, "http://onto-ns.com/meta/0.1/MyEntity", "myprop") - ts.add((EX.MyConcept, RDFS.subClassOf, OWL.Thing)) - ts.add((EX.AnotherConcept, RDFS.subClassOf, OWL.Thing)) - ts.add((EX.Sum, RDFS.subClassOf, OWL.Thing)) - assert ts.has(EX.Sum) == True - assert ts.has(EX.Sum, RDFS.subClassOf, OWL.Thing) == True - assert ts.has(object=EX.NotInOntology) == False - - - def sum(a, b): - """Returns the sum of `a` and `b`.""" - return a + b - - ts.add_function(sum, expects=(EX.MyConcept, EX.AnotherConcept), - returns=EX.Sum, base_iri=EX) - - s = ts.serialize(format="turtle") - fid = function_id(sum) - expected = textwrap.dedent(f"""\ - @prefix dcterms: . - @prefix ex: . - @prefix fno: . - @prefix map: . - @prefix owl: . - @prefix rdf: . - @prefix rdfs: . - - ex:sum_{fid} a fno:Function ; - dcterms:description "Returns the sum of `a` and `b`."@en ; - fno:expects ( ex:sum_{fid}_parameter1_a ex:sum_{fid}_parameter2_b ) ; - fno:returns ( ex:sum_{fid}_output1 ) . - - map:mapsTo ex:MyConcept . - - ex:AnotherConcept rdfs:subClassOf owl:Thing . - - ex:Sum rdfs:subClassOf owl:Thing . - - ex:sum_{fid}_output1 a fno:Output ; - map:mapsTo ex:Sum . - - ex:sum_{fid}_parameter1_a a fno:Parameter ; - rdfs:label "a"@en ; - map:mapsTo ex:MyConcept . - - ex:sum_{fid}_parameter2_b a fno:Parameter ; - rdfs:label "b"@en ; - map:mapsTo ex:AnotherConcept . - - ex:MyConcept rdfs:subClassOf owl:Thing . - - """) - assert s == expected - - - - # Test SPARQL query - rows = ts.query("SELECT ?s ?o WHERE { ?s rdfs:subClassOf ?o }") - assert len(rows) == 3 - rows.sort() # ensure consistent ordering of rows - assert rows[0] == ('http://example.com/onto#AnotherConcept', - 'http://www.w3.org/2002/07/owl#Thing') - assert rows[1] == ('http://example.com/onto#MyConcept', - 'http://www.w3.org/2002/07/owl#Thing') - assert rows[2] == ('http://example.com/onto#Sum', - 'http://www.w3.org/2002/07/owl#Thing') - - - # Test adding mappings and functions - ts2 = Triplestore("rdflib") - ts2.parse(format="turtle", data=s) - assert ts2.serialize(format="turtle") == s - ts2.set((EX.AnotherConcept, RDFS.subClassOf, EX.MyConcept)) - - def cost(x): - return 2*x - - ts2.add_mapsTo(EX.Sum, "http://onto-ns.com/meta/0.1/MyEntity#sum", - cost=cost) - assert list(ts2.function_repo.values())[0] == cost - - def func(x): - return x+1 - - ts2.add_function(func, expects=EX.Sum, returns=EX.OneMore, cost=cost) - assert list(ts2.function_repo.values())[1] == func - assert len(ts2.function_repo) == 2 # cost is only added once - - def func2(x): - return x+2 - - def cost2(x): - return 2*x+1 - - ts2.add_function(func2, expects=EX.Sum, returns=EX.EvenMore, cost=cost2) - assert len(ts2.function_repo) == 4 - - #print(ts2.serialize(format="turtle")) - - -# Test ontopy triplestore backend -# ------------------------------- -if ontopy: - ts3 = Triplestore("ontopy", base_iri="emmo", load=True) - onto = ts3.backend.onto - triples = list(ts3.triples((None, None, None))) - - ts4 = Triplestore( - "ontopy", base_iri="http://onto-ns.com/ontologies/examples/food", - ) - ts4.parse(ontopath_food) - - ts5 = Triplestore( - "ontopy", base_iri="http://onto-ns.com/ontologies/examples/food", - ) - ts5.bind('food', FOOD) - with open(ontopath_food, 'rt') as f: - ts5.parse(data=f.read()) diff --git a/bindings/python/triplestore/__init__.py b/bindings/python/triplestore/__init__.py index 334acf5be..cb99656fe 100644 --- a/bindings/python/triplestore/__init__.py +++ b/bindings/python/triplestore/__init__.py @@ -4,39 +4,6 @@ See the README.md file for a description for how to use this package. """ import warnings -from typing import TYPE_CHECKING - -from .triplestore import ( - Literal, Namespace, Triplestore, - en, - XML, RDF, RDFS, XSD, OWL, SKOS, DC, DCTERMS, FOAF, DOAP, FNO, EMMO, MAP, DM, -) - -if TYPE_CHECKING: # pragma: no cover - from .triplestore import Triple - - -__all__ = ( - "Literal", - "Namespace", - "Triplestore", - "en", - "XML", - "RDF", - "RDFS", - "XSD", - "OWL", - "SKOS", - "DC", - "DCTERMS", - "FOAF", - "DOAP", - "FNO", - "EMMO", - "MAP", - "DM", -) - warnings.warn( "dlite.triplestore is deprecated.\n" diff --git a/bindings/python/triplestore/backends/__init__.py b/bindings/python/triplestore/backends/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/bindings/python/triplestore/backends/ontopy.py b/bindings/python/triplestore/backends/ontopy.py deleted file mode 100644 index 60e8b1855..000000000 --- a/bindings/python/triplestore/backends/ontopy.py +++ /dev/null @@ -1,208 +0,0 @@ -import io -import os -import tempfile -import warnings -from typing import TYPE_CHECKING - -from ontopy.ontology import get_ontology, Ontology, _unabbreviate - - -from dlite.triplestore import Literal - -if TYPE_CHECKING: # pragma: no cover - from collections.abc import Sequence - from typing import Generator - - from dlite.triplestore import Triple - - -class OntopyStrategy: - """Triplestore strategy for EMMOntoPy. - - Arguments: - base_iri: The base iri of the ontology. - onto: Ontology to initiate the triplestore from. Defaults to an new - ontology with the given `base_iri`. - load: Whether to load the ontology. - kwargs: Keyword arguments passed to the ontology load() method. - - Either the `base_iri` or `onto` argument must be provided. - """ - def __init__( - self, - base_iri: str = None, - onto: Ontology = None, - load: bool = False, - kwargs: dict = {} - ): - if onto is None: - if base_iri is None: - raise TypeError("either `base_iri` or `onto` must be provided") - self.onto = get_ontology(base_iri) - elif isinstance(onto, Ontology): - self.onto = onto - else: - raise TypeError("`onto` must be either an ontology or None") - - if load: - self.onto.load(**kwargs) - - def triples(self, triple: "Triple") -> "Generator": - """Returns a generator over matching triples.""" - - def to_literal(o, d): - """Returns a literal from (o, d).""" - if isinstance(d, str) and d.startswith("@"): - lang, datatype = d[1:], None - else: - lang, datatype = None, d - return Literal(o, lang=lang, datatype=datatype) - - s, p, o = triple - abb = ( - None if s is None else self.onto._abbreviate(s), - None if p is None else self.onto._abbreviate(p), - None if o is None else self.onto._abbreviate(o), - ) - for s, p, o in self.onto._get_obj_triples_spo_spo(*abb): - yield ( - _unabbreviate(self.onto, s), - _unabbreviate(self.onto, p), - _unabbreviate(self.onto, o), - ) - for s, p, o, d in self.onto._get_data_triples_spod_spod(*abb, d=''): - yield ( - _unabbreviate(self.onto, s), - _unabbreviate(self.onto, p), - to_literal(o, d), - ) - - def add_triples(self, triples: "Sequence[Triple]"): - """Add a sequence of triples.""" - for s, p, o in triples: - if isinstance(o, Literal): - if o.lang: - d = f"@{o.lang}" - elif o.datatype: - d = f"^^{o.datatype}" - else: - d = 0 - self.onto._add_data_triple_spod( - self.onto._abbreviate(s), - self.onto._abbreviate(p), - self.onto._abbreviate(o), - d, - ) - else: - self.onto._add_obj_triple_spo( - self.onto._abbreviate(s), - self.onto._abbreviate(p), - self.onto._abbreviate(o), - ) - - def remove(self, triple: "Triple"): - """Remove all matching triples from the backend.""" - s, p, o = triple - to_remove = list(self.onto._get_triples_spod_spod( - self.onto._abbreviate(s) if s is not None else None, - self.onto._abbreviate(p) if s is not None else None, - self.onto._abbreviate(o) if s is not None else None, - )) - for s, p, o, d in to_remove: - if d: - self.onto._del_data_triple_spod(s, p, o, d) - else: - self.onto._del_obj_triple_spo(s, p, o) - - # Optional methods - def parse(self, source=None, location=None, data=None, format=None, - encoding=None, **kwargs): - """Parse source and add the resulting triples to triplestore. - - The source is specified using one of `source`, `location` or `data`. - - Parameters: - source: File-like object or file name. - location: String with relative or absolute URL to source. - data: String containing the data to be parsed. - format: Needed if format can not be inferred from source. - encoding: Encoding argument to io.open(). - kwargs: Additional keyword arguments passed to Ontology.load(). - """ - if sum(arg is not None for arg in (source, location, data)) != 1: - raise ValueError( - "one (and only one) of `source`, `location` and `data` " - "should be provided") - - if source: - self.onto.load(filename=source, format=format, **kwargs) - elif location: - self.onto.load(filename=location, format=format, **kwargs) - elif data: - #s = io.StringIO(data) - #self.onto.load(filename=s, format=format, **kwargs) - - # Could have been done much nicer if it hasn't been for Windows - filename = None - try: - kw = {"delete": False} - if isinstance(data, str): - kw.update(mode="w+t", encoding=encoding) - with tempfile.NamedTemporaryFile(**kw) as f: - f.write(data) - filename = f.name - self.onto.load(filename=filename, format=format, **kwargs) - finally: - if filename: - os.remove(filename) - - else: - raise ValueError( - "either `source`, `location` or `data` must be given" - ) - - def serialize(self, destination=None, format='turtle', **kwargs): - """Serialise to destination. - - Parameters: - destination: File name or object to write to. If None, the - serialisation is returned. - format: Format to serialise as. Supported formats, depends on - the backend. - kwargs: Passed to the Ontology.save() method. - - Returns: - Serialised string if `destination` is None. - """ - if destination: - self.onto.save(destination, format=format, **kwargs) - else: - # Clumsy implementation due to Windows file locking... - filename = None - try: - with tempfile.NamedTemporaryFile(delete=False) as f: - filename = f.name - self.onto.save(filename, format=format, **kwargs) - with open(filename, 'rt') as f: - return f.read() - finally: - if filename: - os.remove(filename) - - def query(self, query_object, native=True, **kwargs): - """SPARQL query.""" - if native: - res = self.onto.world.sparql(query_object) - else: - graph = self.onto.world.as_rdflib_graph() - res = graph.query(query_object, **kwargs) - # TODO: Convert result to expected type - return res - - def update(self, update_object, native=True, **kwargs): - """Update triplestore with SPARQL.""" - if native: - self.onto.world.sparql(update_object) - else: - graph = self.onto.world.as_rdflib_graph() - graph.update(update_object, **kwargs) diff --git a/bindings/python/triplestore/backends/rdflib.py b/bindings/python/triplestore/backends/rdflib.py deleted file mode 100644 index 5dffc67f8..000000000 --- a/bindings/python/triplestore/backends/rdflib.py +++ /dev/null @@ -1,130 +0,0 @@ -import warnings -from typing import TYPE_CHECKING - -import rdflib -from rdflib import BNode, Graph, URIRef - -from dlite.triplestore import Literal - -if TYPE_CHECKING: # pragma: no cover - from collections.abc import Sequence - from typing import Generator - - from dlite.triplestore import Triple - - -def asuri(v): - """Help function converting a spo-value to proper rdflib type.""" - if v is None: - return None - if isinstance(v, Literal): - return rdflib.Literal(v.value, lang=v.lang, datatype=v.datatype) - if v.startswith("_:"): - return BNode(v) - return URIRef(v) - - -def astriple(t): - """Help function converting a triple to rdflib triple.""" - s, p, o = t - return asuri(s), asuri(p), asuri(o) - - -class RdflibStrategy: - """Triplestore strategy for rdflib.""" - - def __init__(self, base_iri): - self.graph = Graph() - - def triples(self, triple: "Triple") -> "Generator": - """Returns a generator over matching triples.""" - for s, p, o in self.graph.triples(astriple(triple)): - yield (str(s), str(p), - Literal(o.value, lang=o.language, datatype=o.datatype) - if isinstance(o, rdflib.Literal) else str(o)) - - def add_triples(self, triples: "Sequence[Triple]"): - """Add a sequence of triples.""" - for t in triples: - self.graph.add(astriple(t)) - - def remove(self, triple: "Triple"): - """Remove all matching triples from the backend.""" - self.graph.remove(astriple(triple)) - - # Optional methods - def parse(self, source=None, location=None, data=None, format=None, - **kwargs): - """Parse source and add the resulting triples to triplestore. - - The source is specified using one of `source`, `location` or `data`. - - Parameters: - source: File-like object or file name. - location: String with relative or absolute URL to source. - data: String containing the data to be parsed. - format: Needed if format can not be inferred from source. - kwargs: Additional less used keyword arguments. - See https://rdflib.readthedocs.io/en/stable/apidocs/rdflib.html#rdflib.Graph.parse - """ - self.graph.parse(source=source, location=location, data=data, - format=format, **kwargs) - - def serialize(self, destination=None, format='turtle', **kwargs): - """Serialise to destination. - - Parameters: - destination: File name or object to write to. If None, the - serialisation is returned. - format: Format to serialise as. Supported formats, depends on - the backend. - kwargs: Passed to the rdflib.Graph.serialize() method. - See https://rdflib.readthedocs.io/en/stable/apidocs/rdflib.html#rdflib.Graph.serialize - - Returns: - Serialised string if `destination` is None. - """ - s = self.graph.serialize(destination=destination, format=format, - **kwargs) - if destination is None: - # Depending on the version of rdflib the return value of - # graph.serialize() man either be a string or a bytes object... - return s if isinstance(s, str) else s.decode() - - def query(self, query_object, **kwargs): - """SPARQL query. - - Parameters: - query_object: String with the SPARQL query. - kwargs: Keyword arguments passed to rdflib.Graph.query(). - - Returns: - List of tuples of IRIs for each matching row. - """ - rows = self.graph.query(query_object=query_object, **kwargs) - return [tuple(str(v) for v in row) for row in rows] - - def update(self, update_object, **kwargs): - """Update triplestore with SPARQL.""" - return self.graph.update(update_object=update_object, **kwargs) - - def bind(self, prefix: str, namespace: str): - """Bind prefix to namespace. - - Should only be defined if the backend supports namespaces. - Called by triplestore.bind(). - """ - if namespace: - self.graph.bind(prefix, namespace, replace=True) - else: - warnings.warn( - "rdflib does not support removing namespace prefixes") - - def namespaces(self) -> dict: - """Returns a dict mapping prefixes to namespaces. - - Should only be defined if the backend supports namespaces. - Used by triplestore.parse() to get prefixes after reading - triples from an external source. - """ - return {prefix: str(ns) for prefix, ns in self.graph.namespaces()} diff --git a/bindings/python/triplestore/interface.py b/bindings/python/triplestore/interface.py deleted file mode 100644 index c21f3973f..000000000 --- a/bindings/python/triplestore/interface.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Provides the ITriplestore protocol class, that documents the interface -of the triplestore backends.""" -from typing import TYPE_CHECKING, Protocol - -if TYPE_CHECKING: # pragma: no cover - from collections.abc import Sequence - from typing import Generator - - from dlite.triplestore import Triple - - -class ITriplestore(Protocol): - '''Interface for triplestore backends. - - In addition to the methods specified by this interface, a backend - may also implement the following optional methods: - - ```python - - def __init__(self, base_iri=None, **kwargs) - - def parse(self, source=None, location=None, data=None, format=None, - **kwargs): - """Parse source and add the resulting triples to triplestore. - - The source is specified using one of `source`, `location` or `data`. - - Parameters: - source: File-like object or file name. - location: String with relative or absolute URL to source. - data: String containing the data to be parsed. - format: Needed if format can not be inferred from source. - kwargs: Additional backend-specific parameters controlling - the parsing. - """ - - def serialize(self, destination=None, format='xml', **kwargs) - """Serialise to destination. - - Parameters: - destination: File name or object to write to. If None, the - serialisation is returned. - format: Format to serialise as. Supported formats, depends on - the backend. - kwargs: Additional backend-specific parameters controlling - the serialisation. - - Returns: - Serialised string if `destination` is None. - """ - - def query(self, query_object, **kwargs) - """SPARQL query. - - Parameters: - query_object: String with the SPARQL query. - kwargs: Keyword arguments passed to rdflib.Graph.query(). - - Returns: - List of tuples of IRIs for each matching row. - - Note: - This method is intended for SELECT queries. Use - the update() method for INSERT and DELETE queries. - """ - - def update(self, update_object, **kwargs) - """Update triplestore with SPARQL. - - Parameters: - query_object: String with the SPARQL query. - kwargs: Keyword arguments passed to rdflib.Graph.query(). - - Note: - This method is intended for INSERT and DELETE queries. Use - the query() method for SELECT queries. - """ - - def bind(self, prefix: str, namespace: str) - """Bind prefix to namespace. - - Should only be defined if the backend supports namespaces. - """ - - def namespaces(self) -> dict - """Returns a dict mapping prefixes to namespaces. - - Should only be defined if the backend supports namespaces. - Used by triplestore.parse() to get prefixes after reading - triples from an external source. - """ - ``` - ''' - - def triples(self, triple: "Triple") -> "Generator": - """Returns a generator over matching triples.""" - - def add_triples(self, triples: "Sequence[Triple]"): - """Add a sequence of triples.""" - - def remove(self, triple: "Triple"): - """Remove all matching triples from the backend.""" diff --git a/bindings/python/triplestore/test_units.py b/bindings/python/triplestore/test_units.py index 33f0d77e8..59857446f 100644 --- a/bindings/python/triplestore/test_units.py +++ b/bindings/python/triplestore/test_units.py @@ -1,5 +1,4 @@ from units import get_pint_registry -from pint import UnitRegistry, Quantity ureg = get_pint_registry(force_recreate=True) diff --git a/bindings/python/triplestore/triplestore.py b/bindings/python/triplestore/triplestore.py deleted file mode 100644 index d925b6c7b..000000000 --- a/bindings/python/triplestore/triplestore.py +++ /dev/null @@ -1,776 +0,0 @@ -'''A module encapsulating different triplestores using the strategy design -pattern. - -See https://github.com/SINTEF/dlite/tree/master/bindings/python/triplestore -for an introduction. - -This module has no dependencies outside the standard library, but the -triplestore backends may have. -''' -from __future__ import annotations # Support Python 3.7 (PEP 585) - -import hashlib -import inspect -import re -import warnings -from collections.abc import Sequence -from datetime import datetime -from importlib import import_module -from typing import TYPE_CHECKING - -if TYPE_CHECKING: # pragma: no cover - from collections.abc import Mapping - from typing import Callable, Generator, Tuple, Union - - Triple = Tuple[Union[str, None], Union[str, None], Union[str, None]] - - -# Regular expression matching a prefixed IRI -_MATCH_PREFIXED_IRI = re.compile(r"^([a-z]+):([^/]{2}.*)$") - - -class TriplestoreError(Exception): - """Base exception for triplestore errors.""" - -class UniquenessError(TriplestoreError): - """More than one matching triple.""" - -class NamespaceError(TriplestoreError): - """Namespace error.""" - -class NoSuchIRIError(NamespaceError): - """Namespace has no such IRI.""" - - -class Namespace: - """Represent a namespace. - - Arguments: - iri: IRI of namespace to represent. - label_annotations: Sequence of label annotations. If given, check - the underlying ontology during attribute access if the name - correspond to a label. The label annotations should be ordered - from highest to lowest precedense. - If True is provided, `label_annotations` is set to - ``(SKOS.prefLabel, RDF.label, SKOS.altLabel)``. - check: Whether to check underlying ontology if the IRI exists during - attribute access. If true, NoSuchIRIError will be raised if the - IRI does not exist in this namespace. - cachemode: Should be one of: - - Namespace.NO_CACHE: Turn off caching. - - Namespace.USE_CACHE: Cache attributes as they are looked up. - - Namespace.ONLY_CACHE: Cache all names at initialisation time. - Do not access the triplestore after that. - Default is `NO_CACHE` if neither `label_annotations` or `check` - is given, otherwise `USE_CACHE`. - triplestore: Use this triplestore for label lookup and checking. - If not given, and either `label_annotations` or `check` are - enabled, a new rdflib triplestore will be created. - triplestore_url: Alternative URL to use for loading the underlying - ontology if `triplestore` is not given. Defaults to `iri`. - """ - NO_CACHE = 0 - USE_CACHE = 1 - ONLY_CACHE = 2 - - __slots__ = ( - "_iri", "_label_annotations", "_check", "_cache", "_triplestore", - ) - - def __init__(self, iri, label_annotations=(), check=False, cachemode=-1, - triplestore=None, triplestore_url=None): - if label_annotations is True: - label_annotations = (SKOS.prefLabel, RDF.label, SKOS.altLabel) - - self._iri = str(iri) - self._label_annotations = tuple(label_annotations) - self._check = bool(check) - - need_triplestore = True if check or label_annotations else False - if cachemode == -1: - cachemode = ( - Namespace.ONLY_CACHE if need_triplestore else Namespace.NO_CACHE - ) - - if need_triplestore and triplestore is None: - url = triplestore_url if triplestore_url else iri - triplestore = Triplestore("rdflib", base_iri=iri) - triplestore.parse(url) - - self._cache = {} if cachemode != Namespace.NO_CACHE else None - # - # FIXME: - # Change this to only assigning the triplestore if cachemode is - # ONLY_CACHE when we figure out a good way to pre-populate the - # cache with IRIs from the triplestore. - # - #self._triplestore = ( - # triplestore if cachemode != Namespace.ONLY_CACHE else None - #) - self._triplestore = triplestore if need_triplestore else None - - if cachemode != Namespace.NO_CACHE: - self._update_cache(triplestore) - - def _update_cache(self, triplestore=None): - """Update the internal cache from `triplestore`.""" - if not triplestore: - triplestore = self._triplestore - if not triplestore: - raise NamespaceError( - "`triplestore` argument needed for updating the cache" - ) - if self._cache is None: - self._cache = {} - - # Add (label, full_iri) pairs to cache - for la in reversed(self._label_annotations): - self._cache.update( - (o, s) for s, o in triplestore.subject_objects(la) - if s.startswith(self._iri) - ) - - # Add (name, full_iri) pairs to cache - # Currently we only check concepts that defines RDFS.isDefinedBy - # relations. - # Is there an efficient way to loop over all IRIs in this namespace? - n = len(self._iri) - self._cache.update( - (s[n:], s) for s in triplestore.subjects( - RDFS.isDefinedBy, self._iri) - if s.startswith(self._iri) - ) - - def __getattr__(self, name): - if self._cache is not None and name in self._cache: - return self._cache[name] - - if self._triplestore: - - # Check if ``iri = self._iri + name`` is in the triplestore. - # If so, add it to the cache. - # We only need to check that generator returned by - # `self._triplestore.predicate_objects(iri)` is non-empty. - iri = self._iri + name - g = self._triplestore.predicate_objects(iri) - try: - g.__next__() - except StopIteration: - pass - else: - if self._cache is not None: - self._cache[name] = iri - return iri - - # Check for label annotations matching `name`. - for la in self._label_annotations: - for s, o in self._triplestore.subject_objects(la): - if o == name and s.startswith(self._iri): - if self._cache is not None: - self._cache[name] = s - return s - - if self._check: - raise NoSuchIRIError(self._iri + name) - else: - return self._iri + name - - def __getitem__(self, key): - return self.__getattr__(key) - - def __repr__(self): - return f"Namespace({self._iri})" - - def __str__(self): - return self._iri - - def __add__(self, other): - return self._iri + str(other) - - -# Pre-defined namespaces -XML = Namespace("http://www.w3.org/XML/1998/namespace") -RDF = Namespace("http://www.w3.org/1999/02/22-rdf-syntax-ns#") -RDFS = Namespace("http://www.w3.org/2000/01/rdf-schema#") -XSD = Namespace("http://www.w3.org/2001/XMLSchema#") -OWL = Namespace("http://www.w3.org/2002/07/owl#") -SKOS = Namespace("http://www.w3.org/2004/02/skos/core#") -DC = Namespace("http://purl.org/dc/elements/1.1/") -DCTERMS = Namespace("http://purl.org/dc/terms/") -FOAF = Namespace("http://xmlns.com/foaf/0.1/") -DOAP = Namespace("http://usefulinc.com/ns/doap#") -PROV = Namespace("http://www.w3.org/ns/prov#") -DCAT = Namespace("http://www.w3.org/ns/dcat#") -TIME = Namespace("http://www.w3.org/2006/time#") -FNO = Namespace("https://w3id.org/function/ontology#") -QUDTU = Namespace("http://qudt.org/vocab/unit/") -OM = Namespace("http://www.ontology-of-units-of-measure.org/resource/om-2/") - -EMMO = Namespace("http://emmo.info/emmo#") -MAP = Namespace("http://emmo.info/domain-mappings#") -DM = Namespace("http://emmo.info/datamodel#") - - -class Literal(str): - """A literal RDF value.""" - def __new__(cls, value, lang=None, datatype=None): - string = super().__new__(cls, value) - if lang: - if datatype: - raise TypeError("A literal can only have one of `lang` or " - "`datatype`.") - string.lang = str(lang) - string.datatype = None - else: - string.lang = None - if datatype: - d = { - str: XSD.string, - bool: XSD.boolean, - int: XSD.integer, - float: XSD.double, - bytes: XSD.hexBinary, - bytearray: XSD.hexBinary, - datetime: XSD.dateTime, - } - string.datatype = d.get(datatype, datatype) - elif isinstance(value, str): - string.datatype = None - elif isinstance(value, bool): - string.datatype = XSD.boolean - elif isinstance(value, int): - string.datatype = XSD.integer - elif isinstance(value, float): - string.datatype = XSD.double - elif isinstance(value, (bytes, bytearray)): - string = value.hex() - string.datatype = XSD.hexBinary - elif isinstance(value, datetime): - string.datatype = XSD.dateTime - # TODO: - # - XSD.base64Binary - # - XSD.byte, XSD.unsignedByte - else: - string.datatype = None - return string - - def __repr__(self): - lang = f", lang='{self.lang}'" if self.lang else "" - datatype = f", datatype='{self.datatype}'" if self.datatype else "" - return f"Literal('{self}'{lang}{datatype})" - - value = property( - fget=lambda self: self.to_python(), - doc="Appropriate python datatype derived from this RDF literal.", - ) - - def to_python(self): - """Returns an appropriate python datatype derived from this RDF - literal.""" - v = str(self) - - if self.datatype == XSD.boolean: - v = bool(self) - elif self.datatype in (XSD.integer, XSD.int, XSD.short, XSD.long, - XSD.nonPositiveInteger, - XSD.negativeInteger, XSD.nonNegativeInteger, - XSD.unsignedInt, XSD.unsignedShort, - XSD.unsignedLong, - XSD.byte, XSD.unsignedByte, - ): - v = int(self) - elif self.datatype in (XSD.double, XSD.decimal, XSD.dataTimeStamp, - OWL.real, OWL.rational): - v = float(self) - elif self.datatype == XSD.hexBinary: - v = self.encode() - elif self.datatype == XSD.dateTime: - v = datetime.fromisoformat(self) - elif self.datatype and self.datatype not in ( - RDF.PlainLiteral, RDF.XMLLiteral, RDFS.Literal, - XSD.anyURI, XSD.language, XSD.Name, XSD.NMName, - XSD.normalizedString, XSD.string, XSD.token, XSD.NMTOKEN, - ): - warnings.warn( - f"unknown datatype: {self.datatype} - assuming string" - ) - return v - - def n3(self): - """Returns a representation in n3 format.""" - if self.lang: - return f'"{self}"@{self.lang}' - elif self.datatype: - return f'"{self}"^^{self.datatype}' - else: - return f'"{self}"' - - -def en(value): - """Convenience function that returns value as a plain english literal. - - Equivalent to``Literal(value, lang="en")``. - """ - return Literal(value, lang="en") - - -class Triplestore: - """Provides a common frontend to a range of triplestore backends.""" - - default_namespaces = { - "xml": XML, - "rdf": RDF, - "rdfs": RDFS, - "xsd": XSD, - "owl": OWL, - # "skos": SKOS, - # "dc": DC, - # "dcterms": DCTERMS, - # "foaf": FOAF, - # "doap": DOAP, - # "fno": FNO, - # "emmo": EMMO, - # "map": MAP, - # "dm": DM, - } - - def __init__(self, backend: str, base_iri: str = None, **kwargs): - """Initialise triplestore using the backend with the given name. - - Parameters: - backend: Name of the backend module. - base_iri: Base IRI used by the add_function() method when adding - new triples. - kwargs: Keyword arguments passed to the backend's __init__() - method. - """ - module = import_module(backend if "." in backend - else "dlite.triplestore.backends." + backend) - cls = getattr(module, backend.title() + "Strategy") - self.base_iri = base_iri - self.namespaces = {} - self.backend_name = backend - self.backend = cls(base_iri=base_iri, **kwargs) - # Keep functions in the triplestore for convienence even though - # they usually do not belong to the triplestore per se. - self.function_repo = {} - for prefix, ns in self.default_namespaces.items(): - self.bind(prefix, ns) - - # Methods implemented by backend - # ------------------------------ - def triples(self, triple: "Triple") -> "Generator": - """Returns a generator over matching triples.""" - return self.backend.triples(triple) - - def add_triples(self, triples: "Sequence[Triple]"): - """Add a sequence of triples.""" - self.backend.add_triples(triples) - - def remove(self, triple: "Triple"): - """Remove all matching triples from the backend.""" - self.backend.remove(triple) - - # Methods optionally implemented by backend - # ----------------------------------------- - def parse(self, source=None, format=None, **kwargs): - """Parse source and add the resulting triples to triplestore. - - Parameters: - source: File-like object or file name. - format: Needed if format can not be inferred from source. - kwargs: Keyword arguments passed to the backend. - The rdflib and ontopy backends support e.g. `location` - (absolute or relative URL) and `data` (string - containing the data to be parsed) arguments. - """ - self._check_method("parse") - self.backend.parse(source=source, format=format, **kwargs) - - if hasattr(self.backend, "namespaces"): - for prefix, ns in self.backend.namespaces().items(): - if prefix and prefix not in self.namespaces: - self.namespaces[prefix] = Namespace(ns) - - def serialize(self, destination=None, format="turtle", **kwargs): - """Serialise triplestore. - - Parameters: - destination: File name or object to write to. If None, the - serialisation is returned. - format: Format to serialise as. Supported formats, depends on - the backend. - kwargs: Passed to the backend serialize() method. - - Returns: - Serialized string if `destination` is None. - """ - self._check_method("serialize") - return self.backend.serialize(destination=destination, - format=format, **kwargs) - - def query(self, query_object, **kwargs): - """SPARQL query. - - Parameters: - query_object: String with the SPARQL query. - kwargs: Keyword arguments passed to rdflib.Graph.query(). - - Returns: - List of tuples of IRIs for each matching row. - - Note: - This method is intended for SELECT queries. Use - the update() method for INSERT and DELETE queries. - - """ - self._check_method("query") - return self.backend.query(query_object=query_object, **kwargs) - - def update(self, update_object, **kwargs): - """Update triplestore with SPARQL. - - Parameters: - query_object: String with the SPARQL query. - kwargs: Keyword arguments passed to rdflib.Graph.query(). - - Note: - This method is intended for INSERT and DELETE queries. Use - the query() method for SELECT queries. - """ - self._check_method("update") - return self.backend.update(update_object=update_object, **kwargs) - - def bind(self, prefix: str, namespace: "Union[str, Namespace]", **kwargs): - """Bind prefix to namespace and return the new Namespace object. - - The new Namespace is created with `namespace` as IRI. - Keyword arguments are passed to the Namespace() constructor. - - If `namespace` is None, the corresponding prefix is removed. - """ - if namespace is None: - del self.namespaces[prefix] - ns = None - else: - ns = namespace if isinstance(namespace, Namespace) else Namespace( - namespace, **kwargs) - self.namespaces[prefix] = ns - - if hasattr(self.backend, "bind"): - self.backend.bind(prefix, namespace) - - return ns - - # Convenient methods - # ------------------ - # These methods are modelled after rdflib and provide some convinient - # interfaces to the triples(), add_triples() and remove() methods - # implemented by all backends. - def _check_method(self, name): - """Check that backend implements the given method.""" - if not hasattr(self.backend, name): - raise NotImplementedError( - f'Triplestore backend "{self.backend_name}" do not ' - f'implement a "{name}()" method.') - - def add(self, triple: "Triple"): - """Add `triple` to triplestore.""" - self.add_triples([triple]) - - def value(self, subject=None, predicate=None, object=None, default=None, - any=False): - """Return the value for a pair of two criteria. - - Useful if one knows that there may only be one value. - - Parameters: - subject, predicate, object: Triple to match. - default: Value to return if no matches are found. - any: If true, return any matching value, otherwise raise - UniquenessError. - """ - g = self.triples((subject, predicate, object)) - try: - value = next(g) - except StopIteration: - return default - - if any: - return value - - try: - next(g) - except StopIteration: - return value - else: - raise UniquenessError("More than one match") - - def subjects(self, predicate=None, object=None): - """Returns a generator of subjects for given predicate and object.""" - for s, _, _ in self.triples((None, predicate, object)): - yield s - - def predicates(self, subject=None, object=None): - """Returns a generator of predicates for given subject and object.""" - for _, p, _ in self.triples((subject, None, object)): - yield p - - def objects(self, subject=None, predicate=None): - """Returns a generator of objects for given subject and predicate.""" - for _, _, o in self.triples((subject, predicate, None)): - yield o - - def subject_predicates(self, object=None): - """Returns a generator of (subject, predicate) tuples for given - object.""" - for s, p, _ in self.triples((None, None, object)): - yield s, p - - def subject_objects(self, predicate=None): - """Returns a generator of (subject, object) tuples for given - predicate.""" - for s, _, o in self.triples((None, predicate, None)): - yield s, o - - def predicate_objects(self, subject=None): - """Returns a generator of (predicate, object) tuples for given - subject.""" - for _, p, o in self.triples((subject, None, None)): - yield p, o - - def set(self, triple): - """Convenience method to update the value of object. - - Removes any existing triples for subject and predicate before adding - the given `triple`. - """ - s, p, _ = triple - self.remove((s, p, None)) - self.add(triple) - - def has(self, subject=None, predicate=None, object=None): - """Returns true if the triplestore has any triple matching - the give subject, predicate and/or object.""" - g = self.triples((subject, predicate, object)) - try: - g.__next__() - except StopIteration: - return False - return True - - - # Methods providing additional functionality - # ------------------------------------------ - def expand_iri(self, iri: str): - """Return the full IRI if `iri` is prefixed. Otherwise `iri` is - returned.""" - match = re.match(_MATCH_PREFIXED_IRI, iri) - if match: - prefix, name = match.groups() - if prefix not in self.namespaces: - raise NamespaceError(f"unknown namespace: {prefix}") - return f"{self.namespaces[prefix]}{name}" - return iri - - def prefix_iri(self, iri: str, require_prefixed: bool = False): - """Return prefixed IRI. - - This is the reverse of expand_iri(). - - If `require_prefixed` is true, a NamespaceError exception is raised - if no prefix can be found. - """ - if not re.match(_MATCH_PREFIXED_IRI, iri): - for prefix, namespace in self.namespaces.items(): - if iri.startswith(str(namespace)): - return f"{prefix}:{iri[len(str(namespace)):]}" - if require_prefixed: - raise NamespaceError(f"No prefix defined for IRI: {iri}") - return iri - - def add_mapsTo( - self, - target: str, - source: "Union[str, dlite.Instance, dataclass]", - property_name: str = None, - cost: "Union[float, Callable]" = None, - target_cost: bool = True, - ): - """Add 'mapsTo' relation to triplestore. - - Parameters: - target: IRI of target ontological concept. - source: Source IRI or entity object. - property_name: Name of property if `source` is an entity or - an entity IRI. - cost: User-defined cost of following this mapping relation - represented as a float. It may be given either as a - float or as a callable taking the value of the mapped - quantity as input and returning the cost as a float. - target_cost: Whether the cost is assigned to mapping steps - that have `target` as output. - """ - self.bind("map", MAP) - - if not property_name and not isinstance(source, str): - raise TriplestoreError( - "`property_name` is required when `target` is not a string.") - - target = self.expand_iri(target) - source = self.expand_iri(infer_iri(source)) - if property_name: - source = f"{source}#{property_name}" - self.add((source, MAP.mapsTo, target)) - if cost is not None: - dest = target if target_cost else source - self._add_cost(cost, dest) - - def add_function( - self, - func: Callable, - expects: "Union[str, Sequence, Mapping]" = (), - returns: "Union[str, Sequence]" = (), - base_iri: str = None, - standard: str = 'fno', - cost: "Union[float, Callable]" = None, - ): - """Inspect function and add triples describing it to the triplestore. - - Parameters: - func: Function to describe. - expects: Sequence of IRIs to ontological concepts corresponding - to positional arguments of `func`. May also be given as a - dict mapping argument names to corresponding ontological IRIs. - returns: IRI of return value. May also be given as a sequence - of IRIs, if multiple values are returned. - base_iri: Base of the IRI representing the function in the - knowledge base. Defaults to the base IRI of the triplestore. - standard: Name of ontology to use when describing the function. - Defaults to the Function Ontology (FnO). - cost: User-defined cost of following this mapping relation - represented as a float. It may be given either as a - float or as a callable taking the same arguments as `func` - returning the cost as a float. - - Returns: - func_iri: IRI of the added function. - """ - if isinstance(expects, str): - expects = [expects] - if isinstance(returns, str): - returns = [returns] - - method = getattr(self, f"_add_function_{standard}") - func_iri = method(func, expects, returns, base_iri) - self.function_repo[func_iri] = func - - if cost is not None: - for dest_iri in returns: - self._add_cost(cost, dest_iri) - - return func_iri - - def _add_cost(self, cost, dest_iri): - """Help function that adds `cost` to destination IRI `dest_iri`. - - `cost` should be either a float or a Callable returning a float. - - If `cost` is a callable it is just referred to with a literal - id and is not ontologically described as a function. The - expected input arguments depends on the context, which is why - this function is not part of the public API. Use the add_mapsTo() - and add_function() methods instead. - """ - if self.has(dest_iri, DM.hasCost): - warnings.warn(f"A cost is already assigned to IRI: {dest_iri}") - elif callable(cost): - cost_id = f"cost_function{function_id(cost)}" - self.add((dest_iri, DM.hasCost, Literal(cost_id))) - self.function_repo[cost_id] = cost - else: - self.add((dest_iri, DM.hasCost, Literal(cost))) - - def _add_function_fno(self, func, expects, returns, base_iri): - """Implementing add_function() for FnO.""" - self.bind("fno", FNO) - self.bind("dcterms", DCTERMS) - self.bind("map", MAP) - - if base_iri is None: - base_iri = self.base_iri if self.base_iri else ":" - fid = function_id(func) # Function id - doc = inspect.getdoc(func) - name = func.__name__ - signature = inspect.signature(func) - func_iri = f"{base_iri}{name}_{fid}" - parlist = f"_:{name}{fid}parlist" - outlist = f"_:{name}{fid}outlist" - self.add((func_iri, RDF.type, FNO.Function)) - self.add((func_iri, FNO.expects, parlist)) - self.add((func_iri, FNO.returns, outlist)) - if doc: - self.add((func_iri, DCTERMS.description, en(doc))) - - if isinstance(expects, Sequence): - items = list(zip(expects, signature.parameters)) - else: - items = [(expects[par], par) - for par in signature.parameters.keys()] - lst = parlist - for i, (iri, parname) in enumerate(items): - lst_next = f"{parlist}{i+2}" if i < len(items) - 1 else RDF.nil - par = f"{func_iri}_parameter{i+1}_{parname}" - self.add((par, RDF.type, FNO.Parameter)) - self.add((par, RDFS.label, en(parname))) - self.add((par, MAP.mapsTo, iri)) - self.add((lst, RDF.first, par)) - self.add((lst, RDF.rest, lst_next)) - lst = lst_next - - lst = outlist - for i, iri in enumerate(returns): - lst_next = f"{outlist}{i+2}" if i < len(returns) - 1 else RDF.nil - val = f"{func_iri}_output{i+1}" - self.add((val, RDF.type, FNO.Output)) - self.add((val, MAP.mapsTo, iri)) - self.add((lst, RDF.first, val)) - self.add((lst, RDF.rest, lst_next)) - lst = lst_next - - return func_iri - - -def infer_iri(obj): - """Return IRI of the individual that stands for object `obj`.""" - if isinstance(obj, str): - return obj - if hasattr(obj, "uri") and obj.uri: - # dlite.Metadata or dataclass (or instance with uri) - return obj.uri - if hasattr(obj, "uuid") and obj.uuid: - # dlite.Instance or dataclass - return obj.uuid - if hasattr(obj, "schema") and callable(obj.schema): - # pydantic.BaseModel - schema = obj.schema() - properties = schema['properties'] - if "uri" in properties and properties["uri"]: - return properties["uri"] - if "uuid" in properties and properties["uuid"]: - return properties["uuid"] - raise TypeError("cannot infer IRI from object {obj!r}") - - -def function_id(func, length=4): - """Return a checksum for function `func`. - - The returned object is a string of hexadecimal digits. - - `length` is the number of bytes in the returned checksum. Since - the current implementation is based on the shake_128 algorithm, - it make no sense to set `length` larger than 32 bytes. - """ - #return hex(crc32(inspect.getsource(func).encode())).lstrip('0x') - return hashlib.shake_128( - inspect.getsource(func).encode()).hexdigest(length) diff --git a/bindings/python/triplestore/units.py b/bindings/python/triplestore/units.py index 9c9ced9fc..92b262b24 100644 --- a/bindings/python/triplestore/units.py +++ b/bindings/python/triplestore/units.py @@ -3,8 +3,7 @@ import os import re import logging -import warnings -from pint import UnitRegistry, Quantity +from pint import UnitRegistry from tripper import Triplestore, RDFS from appdirs import user_cache_dir diff --git a/bindings/python/utils.py b/bindings/python/utils.py index 01a0bc52c..583bc5336 100644 --- a/bindings/python/utils.py +++ b/bindings/python/utils.py @@ -1,5 +1,3 @@ -import binascii -import os from typing import Sequence, Mapping import json from typing import Dict, List, Optional diff --git a/examples/mappings/mappingfunc.py b/examples/mappings/mappingfunc.py index 5efdc0e7c..c3ea18f4a 100644 --- a/examples/mappings/mappingfunc.py +++ b/examples/mappings/mappingfunc.py @@ -3,10 +3,9 @@ import numpy as np -from tripper import DM, EMMO, RDFS, Triplestore +from tripper import EMMO, RDFS, Triplestore import dlite -from dlite.mappings import instantiate # Paths diff --git a/examples/mappings/simple.py b/examples/mappings/simple.py index 9f1e17965..3bd35a550 100644 --- a/examples/mappings/simple.py +++ b/examples/mappings/simple.py @@ -1,7 +1,7 @@ """Mapping example using a collection - without mapping functions.""" from pathlib import Path -from tripper import EMMO, MAP, RDFS, Triplestore +from tripper import EMMO, Triplestore import dlite From ef354b6746d67074272148eaaa796f0706a76414 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 4 Jan 2023 12:12:39 +0100 Subject: [PATCH 09/11] Update doc strings in BSON & CSV plugins --- bindings/python/mappings.py | 8 - .../python/python-storage-plugins/bson.py | 136 +++++---- storages/python/python-storage-plugins/csv.py | 275 +++++++++++------- .../python/python-storage-plugins/pyrdf.py | 9 +- 4 files changed, 261 insertions(+), 167 deletions(-) diff --git a/bindings/python/mappings.py b/bindings/python/mappings.py index aed5d2509..6c5575305 100644 --- a/bindings/python/mappings.py +++ b/bindings/python/mappings.py @@ -1026,7 +1026,6 @@ def make_instance( mappings: "Sequence[tuple[str, str, str]]" = (), strict: bool = True, allow_incomplete: bool = False, - unitconvert: "Callable[[Any, Any, Any], Any]" = unitconvert_pint, mapsTo: str = ':mapsTo', ) -> dlite.Instance: """Create an instance of `meta` using data found in `*instances`. @@ -1040,13 +1039,6 @@ def make_instance( with the same name. allow_incomplete: Whether to allow not populating all properties of the returned instance. - unitconvert: A callable that converts between units. It has - prototype - - unitconvert(dest_unit, value, unit) - - and should return `value` (in units `unit`) converted to - `dest_unit`. mapsTo: How the 'mapsTo' predicate is written in `mappings`. Returns: diff --git a/storages/python/python-storage-plugins/bson.py b/storages/python/python-storage-plugins/bson.py index f7016f475..88ba031b7 100644 --- a/storages/python/python-storage-plugins/bson.py +++ b/storages/python/python-storage-plugins/bson.py @@ -1,5 +1,6 @@ """A DLite storage plugin for BSON written in Python.""" import os +from typing import TYPE_CHECKING import bson as pybson # Must be pymongo.bson @@ -7,86 +8,115 @@ from dlite.options import Options from dlite.utils import instance_from_dict +if TYPE_CHECKING: # pragma: no cover + from typing import Generator, Optional + class bson(dlite.DLiteStorageBase): """DLite storage plugin for BSON.""" - def open(self, uri, options=None): + def open(self, uri: str, options: "Optional[str]" = None) -> None: """Open `uri`. - Supported options: - - mode : a | r | w - Valid values are: - - a Append to existing file or create new file (default) - - r Open existing file for read-only - - w Truncate existing file or create new file - - soft7 : bool - Whether to save using SOFT7 format. + Parameters: + uri: A fully resolved URI to the BSON file. + options: Supported options: + + - `mode`: Mode for opening. + Valid values are: + + - `a`: Append to existing file or create new file (default). + - `r`: Open existing file for read-only. + - `w`: Truncate existing file or create new file. + + - `soft7`: Whether to save using the SOFT7 format. After the options are passed, this method may set attribute - `writable` to True if it is writable and to False otherwise. - If `writable` is not set, it is assumed to be True. + ``writable`` to ``True`` if it is writable and to ``False`` otherwise. + If ``writable`` is not set, it is assumed to be ``True``. The BSON data is translated to JSON. """ - self.options = Options(options, defaults='mode=a;soft7=true') - self.mode = dict(r='rb', w='wb', a='rb+', append='rb+')[self.options.mode] - if self.mode == 'rb' and not os.path.exists(uri): - raise FileNotFoundError(f"Did not find URI '{uri}'") - self.readable = True if 'rb' in self.mode else False - self.writable = False if 'rb' == self.mode else True + self.options = Options(options, defaults="mode=a;soft7=true") + self.mode = dict(r="rb", w="wb", a="rb+", append="rb+")[self.options.mode] + if self.mode == "rb" and not os.path.exists(uri): + raise FileNotFoundError(f"Did not find URI {uri!r}") + + self.readable = "rb" in self.mode + self.writable = "rb" != self.mode self.generic = True self.uri = uri - self.d = {} - if self.mode in ('rb', 'rb+'): - with open(uri, self.mode) as f: - bson_data = f.read() + self._data = {} + if self.mode in ("rb", "rb+"): + with open(uri, self.mode) as handle: + bson_data = handle.read() + if pybson.is_valid(bson_data): - self.d = pybson.decode(bson_data) - if not self.d: - raise EOFError(f"Failed to read BSON data from '{uri}'") + self._data = pybson.decode(bson_data) + if not self._data: + raise EOFError(f"Failed to read BSON data from {uri!r}") else: - raise EOFError(f"Invalid BSON data in source '{uri}'") + raise EOFError(f"Invalid BSON data in source {uri!r}") - def close(self): + def close(self) -> None: """Close this storage and write the data to file. Assumes the data to store is in JSON format. """ if self.writable: - if self.mode == 'rb+' and not os.path.exists(self.uri): - mode = 'wb' + if self.mode == "rb+" and not os.path.exists(self.uri): + mode = "wb" else: mode = self.mode + for uuid in self.queue(): - props = self.d[uuid]['properties'] - if type(props) == dict: # Metadata props is list - for key in props.keys(): - if type(props[key]) in (bytearray, bytes): + props = self._data[uuid]["properties"] + if isinstance(props, dict): # Metadata props is list + for key in props: + if isinstance(props[key], (bytearray, bytes)): props[key] = props[key].hex() - self.d[uuid]['properties'] = props - with open(self.uri, mode) as f: - f.write(pybson.encode(self.d)) + self._data[uuid]["properties"] = props + + with open(self.uri, mode) as handle: + handle.write(pybson.encode(self._data)) + + def load(self, uuid: str) -> dlite.Instance: + """Load `uuid` from current storage and return it as a new instance. + + Parameters: + uuid: A UUID representing a DLite Instance to return from the RDF storage. + + Returns: + A DLite Instance corresponding to the given UUID. + + """ + if uuid in self._data: + return instance_from_dict(self._data[uuid]) + raise KeyError(f"Instance with ID {uuid!r} not found") + + def save(self, inst: dlite.Instance) -> None: + """Store `inst` in the current storage. + + Parameters: + inst: A DLite Instance to store in the BSON storage. - def load(self, uuid): - """Load `uuid` from current storage and return it - as a new instance. """ - if uuid in self.d.keys(): - return instance_from_dict(self.d[uuid]) - else: - raise KeyError(f"Instance with id '{uuid}' not found") - - def save(self, inst): - """Store `inst` in the current storage.""" - self.d[inst.uuid] = inst.asdict(soft7=dlite.asbool(self.options.soft7)) - - def queue(self, pattern=None): - """Generator method that iterates over all UUIDs in - the storage who's metadata URI matches global pattern - `pattern`. + self._data[inst.uuid] = inst.asdict(soft7=dlite.asbool(self.options.soft7)) + + def queue(self, pattern: "Optional[str]" = None) -> "Generator[str, None, None]": + """Generator method that iterates over all UUIDs in the storage whose metadata + URI matches global pattern. + + Parameters: + pattern: A regular expression to filter the yielded UUIDs. + + Yields: + DLite Instance UUIDs based on the `pattern` regular expression. + If no `pattern` is given, all UUIDs are yielded from within the RDF + storage. + """ - for uuid, d in self.d.items(): - if pattern and dlite.globmatch(pattern, d['meta']): + for uuid, data in self._data.items(): + if pattern and dlite.globmatch(pattern, data["meta"]): continue yield uuid diff --git a/storages/python/python-storage-plugins/csv.py b/storages/python/python-storage-plugins/csv.py index c2a6fcfa2..63f87db32 100644 --- a/storages/python/python-storage-plugins/csv.py +++ b/storages/python/python-storage-plugins/csv.py @@ -1,166 +1,237 @@ """Storage plugin that reading/writing CSV files.""" -import sys -import re +from __future__ import annotations + import warnings import hashlib import ast from pathlib import Path +from typing import TYPE_CHECKING import pandas as pd import dlite from dlite.options import Options +if TYPE_CHECKING: # pragma: no cover + from typing import Any, Optional + -class csv(dlite.DLiteStorageBase): # noqa: F821 +class csv(dlite.DLiteStorageBase): """DLite storage plugin for CSV files.""" - def open(self, uri, options=None): + def open(self, uri: str, options: "Optional[str]" = None) -> None: """Opens `uri`, which should be a valid path to a local file. - Options - ------- - mode: "r" | "w" - Whether to read or write data. Required - meta: URI - URI to metadata describing the table to read. Required if - `infer` is false. - infer: bool - Whether to infer metadata from data source. Defaults to True - path: directories - Additional search directories to add to the search path for - `meta`. Optional - pandas_opts: string - Comma-separated string of "key"=value options sent to pandas - read_ or save_ function. String values should - be quoted. - format: "csv" | "excel" | "json" | "clipboard", ... - Any format supported by pandas. The default is inferred from - the extension of `uri`. - id: string - Explicit id of returned instance if reading. Optional + Parameters: + uri: A fully resolved URI to the CSV. + options: Supported options: + + - mode: Whether to read or write data. Required. + Valid values are: + + - `r`: Read data. + - `w`: Write data. + + - meta: URI to metadata describing the table to read. + Required if `infer` is ``False``. + - infer: Whether to infer metadata from data source. + Defaults to ``True``. + - path: Additional search directories to add to the search path for + `meta`. Optional. + - pandas_opts: Comma-separated string of "key"=value options sent to + pandas read_ or save_ function. String values should + be quoted. + - format: Any format supported by pandas. The default is inferred from + the extension of `uri`. + - id: Explicit id of returned instance if reading. Optional. + """ - self.options = Options(options, defaults='mode=r') - self.mode = dict(r='rt', w='wt')[self.options.mode] - self.readable = True if 'rt' in self.mode else False - self.writable = False if 'rt' == self.mode else True + self.options = Options(options, defaults="mode=r") + self.mode = {"r": "rt", "w": "wt"}[self.options.mode] + self.readable = "rt" in self.mode + self.writable = "rt" != self.mode self.generic = False self.uri = uri - self.format = get_pandas_format_name(uri, self.options.get('format')) + self.format = get_pandas_format_name(uri, self.options.get("format")) - def close(self): + def close(self) -> None: """Closes this storage.""" - pass - def load(self, id=None): - """Loads `id` from current storage and return it as a new - instance.""" + def load(self, id: "Optional[str]" = None) -> dlite.Instance: + """Loads `id` from current storage and return it as a new instance. + + Parameters: + id: A UUID representing a DLite Instance to return from the CSV storage. + + Returns: + A DLite Instance corresponding to the given `id` (UUID). + + """ # This will break recursive search for metadata using this plugin if id: - raise dlite.DLiteError('csv plugin does support loading an ' - 'instance with a given id') + raise dlite.DLiteError( + "csv plugin does support loading an instance with a given id" + ) - reader = getattr(pd, 'read_' + self.format) - pdopts = optstring2keywords(self.options.get('pandas_opts', '')) - metaid = self.options.meta if 'meta' in self.options else None + reader = getattr(pd, f"read_{self.format}") + pdopts = optstring2keywords(self.options.get("pandas_opts", "")) + metaid = self.options.meta if "meta" in self.options else None data = reader(self.uri, **pdopts) - rows, columns = data.shape + rows, _ = data.shape - if 'infer' not in self.options or dlite.asbool(self.options.infer): + if "infer" not in self.options or dlite.asbool(self.options.infer): Meta = infer_meta(data, metaid, self.uri) elif metaid: Meta = dlite.get_instance(metaid) else: raise ValueError( - 'csv option `meta` must be provided if `infer` if false') + "csv option `meta` must be provided if `infer` if false" + ) - inst = Meta(dims=(rows, ), id=self.options.get('id')) - for i, name in enumerate(inst.properties): + inst = Meta(dims=(rows,), id=self.options.get("id")) + for i in range(len(inst.properties)): inst[i] = data.iloc[:, i] return inst - def save(self, inst): - """Stores `inst` in current storage.""" - d = inst.asdict() - data = pd.DataFrame(d['properties']) + def save(self, inst: dlite.Instance) -> None: + """Stores `inst` in current storage. + + Parameters: + inst: A DLite Instance to store in the CSV storage. - writer = getattr(data, 'to_' + self.format) - pdopts = optstring2keywords(self.options.get('pandas_opts', '')) + """ + inst_as_dict = inst.asdict() + data = pd.DataFrame(inst_as_dict["properties"]) + + writer = getattr(data, f"to_{self.format}") + pdopts = optstring2keywords(self.options.get("pandas_opts", "")) writer(self.uri, **pdopts) -def get_pandas_format_name(uri, format): - """Return Pandas format name corresponding to `format`. If `format` - is None, the name is inferred from the extension of `uri`. +def get_pandas_format_name(uri: str, format: str) -> str: + """Return Pandas format name corresponding to `format`. + + If `format` is ``None``, the name is inferred from the extension of `uri`. + + Parameters: + uri: A fully resolved URI to the CSV. + format: A format to be mapped to Pandas format. + + Returns: + A Pandas format matching `format`. + + """ + fmt = format.lower() if format else Path(uri).suffix.lstrip(".").lower() + return { + "xls": "excel", + "xlsx": "excel", + "h5": "hdf", + "hdf5": "hdf", + }.get(fmt, fmt) + + +def infer_prop_name(name: str) -> str: + """Return inferred property name from pandas column name. + + Parameters: + name: Pandas column name. + + Returns: + Inferred property name. + """ - fmt = format.lower() if format else Path(uri).suffix.lstrip('.').lower() - d = { - 'xls': 'excel', - 'xlsx': 'excel', - 'h5': 'hdf', - 'hdf5': 'hdf', - } - return d.get(fmt, fmt) + return name.strip(' "').rsplit("(", 1)[0].rsplit("[", 1)[0].strip().replace( + " ", "_" + ) -def infer_prop_name(name): - """Return inferred property name from pandas column name.""" - return name.strip(' "').rsplit('(', 1)[0].rsplit( - '[', 1)[0].strip().replace(' ', '_') +def infer_prop_unit(name: str) -> "Optional[str]": + """Return inferred property unit from pandas column name. + + Parameters: + name: Pandas column name. + + Returns: + Inferred property unit. + + """ + if "(" in name: + return name.strip(' "').rsplit("(", 1)[1].strip().rstrip(")").rstrip() + if "[" in name: + return name.strip(' "').rsplit("[", 1)[1].strip().rstrip("]").rstrip() -def infer_prop_unit(name): - """Return inferred property unit from pandas column name.""" - if '(' in name: - return name.strip(' "').rsplit('(', 1)[1].strip().rstrip(')').rstrip() - elif '[' in name: - return name.strip(' "').rsplit('[', 1)[1].strip().rstrip(']').rstrip() - else: - return None + return None -def infer_meta(data, metauri, uri): +def infer_meta(data: pd.DataFrame, metauri: str, uri: str) -> dlite.Metadata: """Infer dlite metadata from Pandas dataframe `data`. - `metauri` is a namespace/version/name URI for the metadata. - `uri` is the location of the input storage. + + Parameters: + data: A Pandas DataFrame. + metauri: A namespace/version/name URI for the metadata. + uri: The location of the input storage. + + Returns: + A DLite Metadata based on the Pandas DataFrame. + """ if not metauri: - ext = Path(uri).suffix.lstrip('.') - fmt = ext if ext else 'csv' - with open(uri, 'rb') as f: - hash = hashlib.sha256(f.read()).hexdigest() - metauri = f'http://onto-ns.com/meta/1.0/generated_from_{fmt}_{hash}' + ext = Path(uri).suffix.lstrip(".") + fmt = ext if ext else "csv" + + with open(uri, "rb") as handle: + hash = hashlib.sha256(handle.read()).hexdigest() + + metauri = f"http://onto-ns.com/meta/1.0/generated_from_{fmt}_{hash}" elif dlite.has_instance(metauri): - warnings.warn(f'csv option infer is true, but explicit instance id ' - f'"{metauri}" already exists') + warnings.warn( + f"csv option infer is true, but explicit instance id {metauri!r} already " + "exists" + ) - dims_ = [dlite.Dimension('rows', 'Number of rows.')] + dims = [dlite.Dimension("rows", "Number of rows.")] props = [] for i, col in enumerate(data.columns): name = infer_prop_name(col) type = data.dtypes[i].name - if type == 'object': - type = 'string' - dims = ['rows'] + if type == "object": + type = "string" + col_dims = ["rows"] unit = infer_prop_unit(col) - props.append(dlite.Property(name=name, type=type, dims=dims, - unit=unit, description=None)) - descr = f'Inferred metadata for {uri}' - - return dlite.Instance.create_metadata(metauri, dims_, props, descr) + props.append( + dlite.Property( + name=name, + type=type, + dims=col_dims, + unit=unit, + description=None, + ) + ) + + return dlite.Instance.create_metadata( + metauri, dims, props, f"Inferred metadata for {uri}" + ) + + +def optstring2keywords(optstring: str) -> "dict[str, Any]": + """Converts comma-separated ``"key":value`` options string `optstring` + to a keyword dict and returns it. + + The values should be valid Python expressions. They are parsed with + ast.literal_eval(). + Parameters: + optstring: Commma-separated options string. -def optstring2keywords(optstring): - """Converts comma-separated ``"key":value`` option string `optstring` - to a keyword dict and return it. + Returns: + Parsed options as a dictionary. - The values should be valid Python expressions. They are parsed with - ast.literal_eval(). """ - s = '{%s}' % (optstring, ) try: - return ast.literal_eval(s) - except Exception as e: + return ast.literal_eval("{%s}" % (optstring,)) + except Exception as exc: raise ValueError( - f'invalid in option string ({e.__name__}): {optstring!r}') + f"invalid in option string ({exc.__name__}): {optstring!r}" + ) from exc diff --git a/storages/python/python-storage-plugins/pyrdf.py b/storages/python/python-storage-plugins/pyrdf.py index b84c0f2de..ad0a9a769 100644 --- a/storages/python/python-storage-plugins/pyrdf.py +++ b/storages/python/python-storage-plugins/pyrdf.py @@ -19,15 +19,16 @@ def open(self, uri: str, options: "Optional[str]" = None) -> None: """Opens `uri`. Parameters: - uri: A fully resolve URI to the RDF. + uri: A fully resolved URI to the RDF. options: Supported options: - `mode`: Mode for opening. Valid values are: - - `a`: Append to existing file or create new file (default) - - `r`: Open existing file for read-only - - `w`: Truncate existing file or create new file + - `a`: Append to existing file or create new file (default). + - `r`: Open existing file for read-only. + - `w`: Truncate existing file or create new file. + - `format`: File format. For a complete list of valid formats, see https://rdflib.readthedocs.io/en/stable/intro_to_parsing.html A sample list of valid format values: "turtle", "xml", "n3", "nt", From 790347cb5f53e3e0c137a857cf725b405d7d3b66 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 4 Jan 2023 14:04:21 +0100 Subject: [PATCH 10/11] Convert index to RST --- pydoc/dlite.rst | 2 +- pydoc/index.md | 62 ------------------------------------------------- pydoc/index.rst | 61 ++++++++++++++++++++++++++++++++++++++++++++++++ pydoc/utils.rst | 5 ++-- 4 files changed, 64 insertions(+), 66 deletions(-) delete mode 100644 pydoc/index.md create mode 100644 pydoc/index.rst diff --git a/pydoc/dlite.rst b/pydoc/dlite.rst index 1a0535f29..8de758984 100644 --- a/pydoc/dlite.rst +++ b/pydoc/dlite.rst @@ -1,7 +1,7 @@ DLite Core ========== -Core DLite C-API modules +Core DLite C-API modules. .. toctree:: :caption: Modules: diff --git a/pydoc/index.md b/pydoc/index.md deleted file mode 100644 index 313a18264..000000000 --- a/pydoc/index.md +++ /dev/null @@ -1,62 +0,0 @@ -# DLite - - -:::{toctree} -:maxdepth: 3 -:caption: API Reference -:glob: -:hidden: - -Python -::: - -:::{toctree} -:maxdepth: 3 -:caption: Mapping Plugins -:glob: -:hidden: - -autoapi/mappingplugins/**/index -::: - - - - - -:::{toctree} -:maxdepth: 3 -:caption: Python Storage Plugins -:glob: -:hidden: - -autoapi/pythonstorageplugins/**/index -::: - - - -## Indices and tables - -* [](genindex) -* [](modindex) -* [](py-modindex) diff --git a/pydoc/index.rst b/pydoc/index.rst new file mode 100644 index 000000000..d88e5c6ac --- /dev/null +++ b/pydoc/index.rst @@ -0,0 +1,61 @@ +DLite +===== + +.. Comment back in the toctrees below if ever Python files are generated in these folders. +.. toctree:: + :maxdepth: 3 + :caption: API Reference + :glob: + :hidden: + + Python + C + Extras + Utils + +.. toctree:: + :maxdepth: 3 + :caption: Mapping Plugins + :glob: + :hidden: + + autoapi/mappingplugins/**/index + +.. .. toctree:: +.. :maxdepth: 3 +.. :caption: Storage Plugins +.. :glob: +.. :hidden: + +.. autoapi/storageplugins/**/index + +.. .. toctree:: +.. :maxdepth: 3 +.. :caption: Python Mapping Plugins +.. :glob: +.. :hidden: + +.. autoapi/pythonmappingplugins/**/index + +.. toctree:: + :maxdepth: 3 + :caption: Python Storage Plugins + :glob: + :hidden: + + autoapi/pythonstorageplugins/**/index + +.. .. toctree:: +.. :maxdepth: 3 +.. :caption: Storages +.. :glob: +.. :hidden: + +.. autoapi/storages/**/index + +Indices and tables +------------------ + +* ``_ +* ``_ +* ``_ diff --git a/pydoc/utils.rst b/pydoc/utils.rst index 38949cc5a..bc810d4d1 100644 --- a/pydoc/utils.rst +++ b/pydoc/utils.rst @@ -1,8 +1,7 @@ Utils ===== -A collection of utility functions used by DLite, but generic enough -to be separate. +A collection of utility functions used by DLite, but generic enough to be separate. .. toctree:: :caption: Modules: @@ -12,7 +11,7 @@ to be separate. utils/bson utils/execprocess - utils/infixcalc + .. utils/infixcalc utils/map utils/sha1 utils/tgen From 72a15203a391b9870ede0639bd8c30f93f8a4ab0 Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen Date: Wed, 4 Jan 2023 14:16:06 +0100 Subject: [PATCH 11/11] Fix references --- pydoc/index.rst | 5 ++--- pydoc/utils.rst | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pydoc/index.rst b/pydoc/index.rst index d88e5c6ac..fc25366bf 100644 --- a/pydoc/index.rst +++ b/pydoc/index.rst @@ -56,6 +56,5 @@ DLite Indices and tables ------------------ -* ``_ -* ``_ -* ``_ +* :ref:`genindex` +* :ref:`modindex` diff --git a/pydoc/utils.rst b/pydoc/utils.rst index bc810d4d1..c10a65945 100644 --- a/pydoc/utils.rst +++ b/pydoc/utils.rst @@ -11,7 +11,6 @@ A collection of utility functions used by DLite, but generic enough to be separa utils/bson utils/execprocess - .. utils/infixcalc utils/map utils/sha1 utils/tgen