diff --git a/.gitignore b/.gitignore index 0658872..e8b6741 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +**/tmp +Pipfile + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5401b28..7e70bcc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,13 +2,19 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files + - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 24.4.2 hooks: - id: black + +- repo: https://github.com/asottile/reorder_python_imports + rev: "v3.12.0" + hooks: + - id: reorder-python-imports diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b337018 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "cSpell.words": [ + "reproschema" + ], + "autoDocstring.docstringFormat": "sphinx", + "esbonio.sphinx.confDir": "" +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..88e128b --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +.DEFAULT_GOAL := help + +define BROWSER_PYSCRIPT +import os, webbrowser, sys + +from urllib.request import pathname2url + +webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) +endef +export BROWSER_PYSCRIPT + +define PRINT_HELP_PYSCRIPT +import re, sys + +for line in sys.stdin: + match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) + if match: + target, help = match.groups() + print("%-20s %s" % (target, help)) +endef +export PRINT_HELP_PYSCRIPT + +BROWSER := python -c "$$BROWSER_PYSCRIPT" + +# Put it first so that "make" without argument is like "make help". +help: + @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) + +## DOC +.PHONY: docs + +docs: ## generate Sphinx HTML documentation, including API docs + rm -f docs/source/reproschema.rst + rm -f docs/source/modules.rst + sphinx-apidoc -o docs/source reproschema + $(MAKE) -C docs clean + $(MAKE) -C docs html + $(BROWSER) docs/build/html/index.html diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..dc1312a --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/architecture.md b/docs/source/architecture.md new file mode 100644 index 0000000..61d5d72 --- /dev/null +++ b/docs/source/architecture.md @@ -0,0 +1,20 @@ +# architecture + + + +```{mermaid} + classDiagram + SchemaUtils <|-- UI + SchemaBase *-- UI + SchemaBase <|-- Protocol + SchemaBase <|-- Activity + SchemaUtils <|-- Message + SchemaBase <|-- Item + Item *-- ResponseOption + SchemaUtils <|-- ResponseOption + SchemaUtils <|-- AdditionalNoteObj + SchemaUtils <|-- unitOption + SchemaUtils <|-- Choice + SchemaUtils <|-- AdditionalProperty + SchemaUtils <|-- SchemaBase +``` diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..001dd5a --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,55 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html +# -- Path setup -------------------------------------------------------------- +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys + +sys.path.insert(0, os.path.abspath("../..")) + + +# -- Project information ----------------------------------------------------- + +project = "reproschema-py" +copyright = "2022, Repronim developers" +author = "Repronim developers" + +# The full version, including alpha/beta/rc tags +release = "0.6.2" + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ["sphinx.ext.autodoc", "myst_parser", "sphinxcontrib.mermaid"] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..60fbd63 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,20 @@ +.. reproschema-py documentation master file, created by + sphinx-quickstart on Mon Aug 15 22:56:47 2022. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to reproschema-py's documentation! +========================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + architecture + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/modules.rst b/docs/source/modules.rst new file mode 100644 index 0000000..83ba206 --- /dev/null +++ b/docs/source/modules.rst @@ -0,0 +1,7 @@ +reproschema +=========== + +.. toctree:: + :maxdepth: 4 + + reproschema diff --git a/docs/source/reproschema.models.rst b/docs/source/reproschema.models.rst new file mode 100644 index 0000000..936288f --- /dev/null +++ b/docs/source/reproschema.models.rst @@ -0,0 +1,69 @@ +reproschema.models package +========================== + +Submodules +---------- + +reproschema.models.activity module +---------------------------------- + +.. automodule:: reproschema.models.activity + :members: + :undoc-members: + :show-inheritance: + +reproschema.models.base module +------------------------------ + +.. automodule:: reproschema.models.base + :members: + :undoc-members: + :show-inheritance: + +reproschema.models.item module +------------------------------ + +.. automodule:: reproschema.models.item + :members: + :undoc-members: + :show-inheritance: + +reproschema.models.protocol module +---------------------------------- + +.. automodule:: reproschema.models.protocol + :members: + :undoc-members: + :show-inheritance: + +reproschema.models.response\_options module +------------------------------------------- + +.. automodule:: reproschema.models.response_options + :members: + :undoc-members: + :show-inheritance: + +reproschema.models.ui module +---------------------------- + +.. automodule:: reproschema.models.ui + :members: + :undoc-members: + :show-inheritance: + +reproschema.models.utils module +------------------------------- + +.. automodule:: reproschema.models.utils + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: reproschema.models + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/reproschema.rst b/docs/source/reproschema.rst new file mode 100644 index 0000000..6932c41 --- /dev/null +++ b/docs/source/reproschema.rst @@ -0,0 +1,53 @@ +reproschema package +=================== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + reproschema.models + +Submodules +---------- + +reproschema.cli module +---------------------- + +.. automodule:: reproschema.cli + :members: + :undoc-members: + :show-inheritance: + +reproschema.jsonldutils module +------------------------------ + +.. automodule:: reproschema.jsonldutils + :members: + :undoc-members: + :show-inheritance: + +reproschema.utils module +------------------------ + +.. automodule:: reproschema.utils + :members: + :undoc-members: + :show-inheritance: + +reproschema.validate module +--------------------------- + +.. automodule:: reproschema.validate + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: reproschema + :members: + :undoc-members: + :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0d0aef7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +minversion = "4.4.0" +addopts = "-ra -vv" diff --git a/reproschema/_version.py b/reproschema/_version.py index da2b4c4..af50a41 100644 --- a/reproschema/_version.py +++ b/reproschema/_version.py @@ -3,12 +3,9 @@ # feature). Distribution tarballs (built by setup.py sdist) and build # directories (produced by setup.py build) will contain a much shorter file # that just contains the computed version number. - # This file is released into the public domain. Generated by # versioneer-0.18 (https://github.com/warner/python-versioneer) - """Git implementation of _version.py.""" - import errno import os import re diff --git a/reproschema/cli.py b/reproschema/cli.py index adbf509..2e49400 100644 --- a/reproschema/cli.py +++ b/reproschema/cli.py @@ -1,8 +1,10 @@ import os + import click -from . import get_logger, set_logger_level from . import __version__ +from . import get_logger +from . import set_logger_level lgr = get_logger() diff --git a/reproschema/jsonldutils.py b/reproschema/jsonldutils.py index f88f38f..a72496b 100644 --- a/reproschema/jsonldutils.py +++ b/reproschema/jsonldutils.py @@ -1,8 +1,12 @@ -from pyld import jsonld -from pyshacl import validate as shacl_validate import json import os -from .utils import start_server, stop_server, lgr + +from pyld import jsonld +from pyshacl import validate as shacl_validate + +from .utils import lgr +from .utils import start_server +from .utils import stop_server def load_file(path_or_url, started=False, http_kwargs={}): diff --git a/reproschema/models/README.md b/reproschema/models/README.md new file mode 100644 index 0000000..48d833f --- /dev/null +++ b/reproschema/models/README.md @@ -0,0 +1,8 @@ +# README + +## reproschema file creation + +The creation reproschema protocols, activities and items (or Field) are handled +by 3 python classes with the same names contained in their dedicated modules. + +They all inherit from the same base class. diff --git a/reproschema/models/__init__.py b/reproschema/models/__init__.py index 1c1a154..9a94610 100644 --- a/reproschema/models/__init__.py +++ b/reproschema/models/__init__.py @@ -1,3 +1,3 @@ -from .protocol import Protocol from .activity import Activity from .item import Item +from .protocol import Protocol diff --git a/reproschema/models/activity.py b/reproschema/models/activity.py index 25d27d2..aa43f0f 100644 --- a/reproschema/models/activity.py +++ b/reproschema/models/activity.py @@ -1,4 +1,12 @@ +from pathlib import Path +from typing import Dict +from typing import Optional +from typing import Union + +from .base import COMMON_SCHEMA_ORDER from .base import SchemaBase +from .item import Item +from .utils import DEFAULT_LANG class Activity(SchemaBase): @@ -6,62 +14,61 @@ class Activity(SchemaBase): class to deal with reproschema activities """ - schema_type = "reproschema:Activity" - - def __init__(self, version=None): - super().__init__(version) - self.schema["ui"] = {"shuffle": [], "order": [], "addProperties": []} - - def set_ui_shuffle(self, shuffle=False): - self.schema["ui"]["shuffle"] = shuffle - - def set_URI(self, URI): - self.URI = URI - - def get_URI(self): - return self.URI - - # TODO - # preamble - # compute - # citation - # image - - def set_defaults(self, name): - self._ReproschemaSchema__set_defaults(name) # this looks wrong - self.set_ui_shuffle(False) - - def update_activity(self, item_info): - - # TODO - # - remove the hard coding on visibility and valueRequired - - # update the content of the activity schema with new item - - item_info["URI"] = "items/" + item_info["name"] - - append_to_activity = { - "variableName": item_info["name"], - "isAbout": item_info["URI"], - "isVis": item_info["visibility"], - "valueRequired": False, - } - - self.schema["ui"]["order"].append(item_info["URI"]) - self.schema["ui"]["addProperties"].append(append_to_activity) - - def sort(self): - schema_order = [ - "@context", - "@type", - "@id", - "prefLabel", - "description", - "schemaVersion", - "version", - "ui", - ] - self.sort_schema(schema_order) - - ui_order = ["shuffle", "order", "addProperties"] - self.sort_ui(ui_order) + def __init__( + self, + name: Optional[str] = "activity", + schemaVersion: Optional[str] = None, + prefLabel: Optional[Union[str, Dict[str, str]]] = "activity", + altLabel: Optional[Union[str, Dict[str, str]]] = None, + description: Optional[str] = "", + preamble: Optional[str] = None, + citation: Optional[str] = None, + image: Optional[Union[str, Dict[str, str]]] = None, + audio: Optional[Union[str, Dict[str, str]]] = None, + video: Optional[Union[str, Dict[str, str]]] = None, + messages: Optional[Dict[str, str]] = None, + suffix: Optional[str] = "_schema", + visible: Optional[bool] = True, + required: Optional[bool] = False, + skippable: Optional[bool] = True, + limit: Optional[str] = None, + randomMaxDelay: Optional[str] = None, + schedule: Optional[str] = None, + ext: Optional[str] = ".jsonld", + output_dir: Optional[Union[str, Path]] = Path.cwd(), + lang: Optional[str] = DEFAULT_LANG(), + ): + + schema_order = COMMON_SCHEMA_ORDER() + ["citation", "compute", "messages"] + + super().__init__( + at_id=name, + schemaVersion=schemaVersion, + at_type="reproschema:Activity", + prefLabel={lang: prefLabel}, + altLabel=altLabel, + description=description, + preamble=preamble, + citation=citation, + messages=messages, + image=image, + audio=audio, + video=video, + schema_order=schema_order, + visible=visible, + required=required, + skippable=skippable, + limit=limit, + randomMaxDelay=randomMaxDelay, + schedule=schedule, + suffix=suffix, + ext=ext, + output_dir=output_dir, + lang=lang, + ) + super().set_defaults() + self.ui.shuffle = False + self.update() + + def append_item(self, item: Item): + self.ui.append(obj=item, variableName=item.get_basename()) diff --git a/reproschema/models/base.py b/reproschema/models/base.py index a2d59db..2a2116d 100644 --- a/reproschema/models/base.py +++ b/reproschema/models/base.py @@ -1,79 +1,488 @@ import json import os +from pathlib import Path +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Union +from attrs import define +from attrs import field +from attrs.converters import default_if_none +from attrs.validators import in_ +from attrs.validators import instance_of +from attrs.validators import optional -class SchemaBase: - """ - class to deal with reproschema schemas +from .ui import UI +from .utils import SchemaUtils + + +def DEFAULT_VERSION() -> str: + return "1.0.0-rc4" + + +def COMMON_SCHEMA_ORDER() -> list: + return [ + "@context", + "@type", + "@id", + "schemaVersion", + "version", + "prefLabel", + "altLabel", + "description", + "preamble", + "image", + "audio", + "video", + "ui", + ] + + +@define(kw_only=True) +class AdditionalNoteObj(SchemaUtils): + """A set of objects to define notes in a field. + + For example, most Redcap and NDA data dictionaries have notes + for each item which needs to be captured in reproschema. """ - schema_type = None + #: An element to define the column name where the note was taken from + column: Optional[str] = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + #: An element to define the source (eg. RedCap, NDA) where the note was taken from. + source: Optional[str] = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + #: The value for each option in choices or in additionalNotesObj + value: Any = field(default=None) + + def __attrs_post_init__(self) -> None: + + if self.schema_order in [None, []]: + self.schema_order = [ + "column", + "source", + "value", + ] - def __init__(self, version): + self.update().sort_schema() + +@define(kw_only=True) +class Message(SchemaUtils): + """An object to define messages in an activity or protocol.""" + + #: A JavaScript expression to compute a score from other variables. + jsExpression: Optional[str] = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + #: The message to be conditionally displayed for an item. + message: Optional[str] = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + + def __attrs_post_init__(self) -> None: + + if self.schema_order in [None, []]: + self.schema_order = [ + "message", + "jsExpression", + ] + + self.update().sort_schema() + + +@define(kw_only=True) +class SchemaBase(SchemaUtils): + """ + Schema based attributes: REQUIRED + """ + + at_type: Optional[str] = field( + factory=(str), + converter=default_if_none(default="reproschema:Field"), # type: ignore + validator=in_( + [ + "reproschema:Protocol", + "reproschema:Activity", + "reproschema:Field", + "reproschema:ResponseOption", + ] + ), + ) + at_id: Optional[str] = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=[instance_of(str)], + ) + schemaVersion: Optional[str] = field( + default=DEFAULT_VERSION(), + converter=default_if_none(default=DEFAULT_VERSION()), # type: ignore + validator=[instance_of(str)], + ) + version: Optional[str] = field( + default=None, + converter=default_if_none(default="0.0.1"), # type: ignore + validator=[instance_of(str)], + ) + at_context: str = field( + validator=[instance_of(str)], + ) + + @at_context.default + def _default_context(self) -> str: + """ + For now we assume that the github repo will be where schema will be read from. + """ URL = "https://raw.githubusercontent.com/ReproNim/reproschema/" - VERSION = version or "1.0.0-rc2" + VERSION = self.schemaVersion or DEFAULT_VERSION() + return URL + VERSION + "/contexts/generic" - self.schema = { - "@context": URL + VERSION + "/contexts/generic", - "@type": self.schema_type, - "schemaVersion": VERSION, - "version": "0.0.1", - } + """ + Schema based attributes: OPTIONAL - def set_filename(self, name): - self.schema_file = name + "_schema" - self.schema["@id"] = name + "_schema" + Can be found in the UI class + - order + - addProperties + - allow + - about + """ - def get_name(self): - return self.schema_file.replace("_schema", "") + """ + Protocol, Activity, Field + """ + # associatedMedia + prefLabel: Optional[Union[str, Dict[str, str]]] = field( + factory=(dict), + converter=default_if_none(default={}), # type: ignore + validator=optional(instance_of((str, dict))), + ) + altLabel: Optional[Union[str, Dict[str, str]]] = field( + factory=(dict), + converter=default_if_none(default={}), # type: ignore + validator=optional(instance_of((str, dict))), + ) + # TODO description is language specific? + description: str = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + image: Optional[Union[str, Dict[str, str]]] = field( + default=None, + validator=optional(instance_of((str, dict))), + ) + audio: Optional[Union[str, Dict[str, str]]] = field( + default=None, + validator=optional(instance_of((str, dict))), + ) + video: Optional[Union[str, Dict[str, str]]] = field( + default=None, + validator=optional(instance_of((str, dict))), + ) + preamble: Optional[dict] = field( + factory=(dict), + converter=default_if_none(default={}), # type: ignore + validator=optional(instance_of(dict)), + ) + + # Protocol only + # TODO landing_page is a dict or a list of dict? + landingPage: Optional[dict] = field( + factory=(dict), + converter=default_if_none(default={}), # type: ignore + validator=optional(instance_of(dict)), + ) - def get_filename(self): - return self.schema_file + """ + Protocol and Activity + """ + # TODO cronTable + citation: Optional[str] = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + compute: Optional[List[Dict[str, str]]] = field( + factory=(list), + converter=default_if_none(default=[]), # type: ignore + validator=optional(instance_of(list)), + ) + messages: Optional[list] = field( + factory=(list), + converter=default_if_none(default=[]), # type: ignore + validator=optional(instance_of(list)), + ) - def set_pref_label(self, pref_label): - self.schema["prefLabel"] = pref_label + """ + Activity only + """ + # TODO overrideProperties - def set_description(self, description): - self.schema["description"] = description + """ + Field only + """ + inputType: Optional[str] = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + readonlyValue: Optional[bool] = field( + factory=(bool), + validator=optional(instance_of(bool)), + ) + question: Optional[dict] = field( + factory=(dict), + converter=default_if_none(default={}), # type: ignore + validator=optional(instance_of(dict)), + ) + additionalNotesObj: Optional[list] = field( + factory=(list), + converter=default_if_none(default=[]), # type: ignore + validator=optional(instance_of(list)), + ) - def set_directory(self, output_directory): - self.dir = output_directory + """ + UI related + """ + ui: UI = field( + default=UI(at_type="reproschema:Field"), + validator=optional(instance_of(UI)), + ) + visible: bool | str | None = field( + factory=(bool), + converter=default_if_none(default=True), # type: ignore + validator=optional(instance_of((bool, str))), + ) + required: Optional[bool] = field( + factory=(bool), + validator=optional(instance_of(bool)), + ) + skippable: Optional[bool] = field( + factory=(bool), + converter=default_if_none(default=True), # type: ignore + validator=optional(instance_of(bool)), + ) + limit: Optional[str] = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + randomMaxDelay: Optional[str] = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + schedule: Optional[str] = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) - def __set_defaults(self, name): - self.set_filename(name) - self.set_directory(name) - self.set_pref_label(name.replace("_", " ")) - self.set_description(name.replace("_", " ")) + """ + Non schema based attributes: OPTIONAL - def sort_schema(self, schema_order): + Those attributes help with file management + and with printing json files with standardized key orders + """ - reordered_dict = {k: self.schema[k] for k in schema_order} - self.schema = reordered_dict + output_dir: Optional[Union[str, Path]] = field( + default=None, + converter=default_if_none(default=Path.cwd()), # type: ignore + validator=optional(instance_of((str, Path))), + ) + suffix: Optional[str] = field( + factory=(str), + converter=default_if_none(default="_schema"), # type: ignore + validator=optional(instance_of(str)), + ) + ext: Optional[str] = field( + factory=(str), + converter=default_if_none(default=".jsonld"), # type: ignore + validator=optional(instance_of(str)), + ) + URI: Optional[Path] = field( + default=None, + converter=default_if_none(default=Path("")), # type: ignore + validator=optional(instance_of((str, Path))), + ) - def sort_ui(self, ui_order): + def set_defaults(self): - reordered_dict = {k: self.schema["ui"][k] for k in ui_order} - self.schema["ui"] = reordered_dict + self.ui = UI(at_type=self.at_type, readonlyValue=self.readonlyValue) - def write(self, output_dir): - with open(os.path.join(output_dir, self.schema_file), "w") as ff: - json.dump(self.schema, ff, sort_keys=False, indent=4) + if self.description == "": + self.description = self.at_id.replace("_", " ") - @classmethod - def from_data(cls, data): - if cls.schema_type is None: - raise ValueError("SchemaBase cannot be used to instantiate class") - if cls.schema_type != data["@type"]: - raise ValueError(f"Mismatch in type {data['@type']} != {cls.schema_type}") - klass = cls() - klass.schema = data - return klass - - @classmethod - def from_file(cls, filepath): - with open(filepath) as fp: - data = json.load(fp) - if "@type" not in data: - raise ValueError("Missing @type key") - return cls.from_data(data) + if self.prefLabel == "": + self.prefLabel = self.at_id.replace("_", " ") + + if isinstance(self.prefLabel, str): + self.prefLabel = {self.lang: self.prefLabel} + + self.set_pref_label() + + self.set_filename() + + def update(self) -> None: + """Updates the schema content based on the attributes.""" + self.schema["@id"] = self.at_id + self.schema["@type"] = self.at_type + self.schema["@context"] = self.at_context + keys_to_update = [ + "schemaVersion", + "version", + "prefLabel", + "altLabel", + "description", + "citation", + "image", + "audio", + "video", + "preamble", + "landingPage", + "compute", + "question", + "messages", + "additionalNotesObj", + ] + for key in keys_to_update: + self.schema[key] = self.__getattribute__(key) + + self.update_ui() + + def update_ui(self) -> None: + self.ui.update() + self.schema["ui"] = self.ui.schema + + """SETTERS + + These are "complex" setters to help set fields that are dictionaries, + or that use other attributes + or set several attributes at once + + """ + + def set_preamble( + self, preamble: Optional[str] = None, lang: Optional[str] = None + ) -> None: + if preamble is None: + return + if lang is None: + lang = self.lang + if not self.preamble: + self.preamble = {} + self.preamble[lang] = preamble + self.update() + + # TODO move to protocol class? + def set_landing_page(self, page: Optional[str] = None, lang: Optional[str] = None): + if page is None: + return + if lang is None: + lang = self.lang + self.landingPage = {"@id": page, "inLanguage": lang} + self.update() + + def set_compute(self, variable, expression): + self.compute = [{"variableName": variable, "jsExpression": expression}] + self.update() + + def set_filename(self, name: str = None) -> None: + if name is None: + name = self.at_id + if name.endswith(self.ext): + name = name.replace(self.ext, "") + if name.endswith(self.suffix): + name = name.replace(self.suffix, "") + + name = name.replace(" ", "_") + + self.at_id = f"{name}{self.suffix}{self.ext}" + self.URI = os.path.join(self.output_dir, self.at_id) + self.update() + + def set_alt_label( + self, alt_label: Optional[str] = None, lang: Optional[str] = None + ) -> None: + if alt_label is None: + return + if lang is None: + lang = self.lang + self.alt_label[lang] = alt_label + self.update() + + def set_pref_label( + self, pref_label: Optional[str] = None, lang: Optional[str] = None + ) -> None: + if lang is None: + lang = self.lang + + if pref_label is None: + if isinstance(self.prefLabel, dict) and ( + self.prefLabel == {} + or self.prefLabel[lang] + in [ + "protocol", + "activity", + "item", + ] + ): + self.set_pref_label(pref_label=self.at_id.replace("_", " "), lang=lang) + # pref_label = self.at_id.replace("_", " ") + return + + self.prefLabel[lang] = pref_label + self.update() + + """GETTERS + """ + + def get_basename(self) -> str: + return Path(self.at_id).stem + + """ + writing, reading, sorting, unsetting + + Editing and appending things to the dictionary tends to give json output + that is not standardized. + For example: the `@context` can end up at the bottom for one file + and stay at the top for another. + So there are a couple of sorting methods to rearrange the keys of + the different dictionaries and those are called right before writing the output file. + + Those methods enforces a certain order or keys in the output and + also remove any empty or unknown keys. + """ + + def sort(self) -> None: + self.sort_schema() + self.update_ui() + + def write(self, output_dir: Optional[Union[str, Path]] = None) -> None: + + self.sort() + + self.drop_empty_values_from_schema() + + if output_dir is None: + output_dir = self.output_dir + + if isinstance(output_dir, str): + output_dir = Path(output_dir) + + output_dir.mkdir(parents=True, exist_ok=True) + + with open(output_dir.joinpath(self.at_id), "w") as ff: + json.dump(self.schema, ff, sort_keys=False, indent=4) diff --git a/reproschema/models/item.py b/reproschema/models/item.py index 595d67d..ecdc4b7 100644 --- a/reproschema/models/item.py +++ b/reproschema/models/item.py @@ -1,158 +1,241 @@ +from pathlib import Path +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Union + +from .base import COMMON_SCHEMA_ORDER from .base import SchemaBase +from .response_options import ResponseOption +from .utils import DEFAULT_LANG class Item(SchemaBase): """ - class to deal with reproschema activities + class to deal with reproschema items """ - schema_type = "reproschema:Field" - - def __init__(self, version=None): - super().__init__(version) - self.schema["ui"] = {"inputType": []} - self.schema["question"] = {} - self.schema["responseOptions"] = {} - # default input type is "char" - self.set_input_type_as_char() - - def set_URI(self, URI): - self.URI = URI - - # TODO - # image - # readonlyValue - - def set_defaults(self, name): - self._ReproschemaSchema__set_defaults(name) # this looks wrong - self.schema_file = name - self.schema["@id"] = name - self.set_input_type_as_char() - - def set_question(self, question, lang="en"): - self.schema["question"][lang] = question - - def set_input_type(self, input_type): - self.schema["ui"]["inputType"] = input_type - - def set_response_options(self, response_options): - self.schema["responseOptions"] = response_options - - """ - - input types with different response choices - - """ - - def set_input_type_as_radio(self, response_options): - self.set_input_type("radio") - self.set_response_options(response_options) - - def set_input_type_as_select(self, response_options): - self.set_input_type("select") - self.set_response_options(response_options) - - def set_input_type_as_slider(self): - self.set_input_type_as_char() # until the slide item of the ui is fixed - # self.set_input_type("slider") - # self.set_response_options({"valueType": "xsd:string"}) - - def set_input_type_as_language(self): - - URL = "https://raw.githubusercontent.com/ReproNim/reproschema/" - - self.set_input_type("selectLanguage") - - response_options = { - "valueType": "xsd:string", - "multipleChoice": True, - "choices": URL + "master/resources/languages.json", - } - self.set_response_options(response_options) - - """ - - input types with no response choice - - """ - - def set_input_type_as_char(self): - self.set_input_type("text") - self.set_response_options({"valueType": "xsd:string"}) - - def set_input_type_as_int(self): - self.set_input_type("number") - self.set_response_options({"valueType": "xsd:integer"}) - - def set_input_type_as_float(self): - self.set_input_type("float") - self.set_response_options({"valueType": "xsd:float"}) - - def set_input_type_as_time_range(self): - self.set_input_type("timeRange") - self.set_response_options({"valueType": "datetime"}) - - def set_input_type_as_date(self): - self.set_input_type("date") - self.set_response_options({"valueType": "xsd:date"}) - - """ - - input types with no response choice but with some parameters - - """ - - def set_input_type_as_multitext(self, max_length=300): - self.set_input_type("text") - self.set_response_options({"valueType": "xsd:string", "maxLength": max_length}) + def __init__( + self, + name: Optional[str] = "item", + input_type: Optional[str] = "text", + question: Optional[Union[dict, str]] = "", + schemaVersion: Optional[str] = None, + prefLabel: Optional[str] = "item", + altLabel: Optional[Dict[str, str]] = None, + description: Optional[str] = "", + image: Optional[Union[str, Dict[str, str]]] = None, + audio: Optional[Union[str, Dict[str, str]]] = None, + video: Optional[Union[str, Dict[str, str]]] = None, + preamble: Optional[str] = None, + additionalNotesObj: List[Dict[str, Any]] = None, + visible: bool | str = True, + required: Optional[bool] = False, + skippable: Optional[bool] = True, + read_only: Optional[bool] = None, + limit: Optional[str] = None, + randomMaxDelay: Optional[str] = None, + schedule: Optional[str] = None, + suffix: Optional[str] = "", + ext: Optional[str] = ".jsonld", + output_dir=Path.cwd(), + lang: Optional[str] = DEFAULT_LANG(), + ): + + schema_order = COMMON_SCHEMA_ORDER() + [ + "question", + "responseOptions", + "additionalNotesObj", + ] - # TODO - # email: EmailInput/EmailInput.vue + super().__init__( + at_id=name, + at_type="reproschema:Field", + inputType=input_type, + schemaVersion=schemaVersion, + prefLabel={lang: prefLabel}, + altLabel=altLabel, + description=description, + preamble=preamble, + image=image, + audio=audio, + video=video, + additionalNotesObj=additionalNotesObj, + schema_order=schema_order, + visible=visible, + required=required, + skippable=skippable, + readonlyValue=read_only, + schedule=schedule, + limit=limit, + randomMaxDelay=randomMaxDelay, + suffix=suffix, + ext=ext, + output_dir=output_dir, + lang=lang, + ) + + super().set_defaults() + + self.set_question(question=question) + + self.response_options: ResponseOption = ResponseOption() + self.set_response_options() + + self.set_input_type() + + self.update() + + def set_question( + self, question: Optional[Union[str, dict]] = None, lang: Optional[str] = None + ) -> None: + """_summary_ + + :param question: _description_, defaults to None + :type question: Optional[Union[str, dict]], optional + :param lang: _description_, defaults to None + :type lang: Optional[str], optional + """ + + if question is None: + question = self.question + + if lang is None: + lang = self.lang + + if question == {}: + return + + if isinstance(question, str): + self.question[lang] = question + elif isinstance(question, dict): + self.question = question + + self.update() + + # TODO: items not yet covered # audioCheck: AudioCheck/AudioCheck.vue # audioRecord: WebAudioRecord/Audio.vue # audioPassageRecord: WebAudioRecord/Audio.vue # audioImageRecord: WebAudioRecord/Audio.vue # audioRecordNumberTask: WebAudioRecord/Audio.vue # audioAutoRecord: AudioCheckRecord/AudioCheckRecord.vue - # year: YearInput/YearInput.vue - # selectCountry: SelectInput/SelectInput.vue - # selectState: SelectInput/SelectInput.vue # documentUpload: DocumentUpload/DocumentUpload.vue # save: SaveData/SaveData.vue # static: Static/Static.vue # StaticReadOnly: Static/Static.vue - def set_basic_response_type(self, response_type): - - # default (also valid for "char" input type) - self.set_input_type_as_char() - - if response_type == "int": - self.set_input_type_as_int() - - elif response_type == "float": - self.set_input_type_as_float() - - elif response_type == "date": - self.set_input_type_as_date() + def set_input_type(self, response_options: Optional[ResponseOption] = None) -> None: + """Set the input type of the item in the UI and the ResponseOptions objects. + + :param response_options: _description_, defaults to None + :type response_options: Optional[ResponseOption], optional + :raises ValueError: When the input type is not one of the supported values. + """ + + SUPPORTED_TYPES = ( + "text", + "multitext", + "integer", + "float", + "date", + "year", + "timeRange", + "selectLanguage", + "selectCountry", + "selectState", + "email", + "pid", + "select", + "radio", + "slider", + ) + + if self.inputType not in SUPPORTED_TYPES: + raise ValueError( + f""" + Input_type {self.inputType} not supported. + Supported input_types are: {SUPPORTED_TYPES} + """ + ) + + self.ui.inputType = self.inputType if self.inputType != "integer" else "number" + + if not self.inputType or self.inputType in [ + "text", + "multitext", + "selectLanguage", + "email", + "pid", + "selectLanguage", + "selectCountry", + "selectState", + ]: + self.response_options.set_valueType("string") + + if self.inputType in ["text", "multitext"]: + self.response_options.maxLength = 300 + self.response_options.update() + + elif self.inputType in ["integer", "float", "date"]: + self.response_options.set_valueType(self.inputType) + + elif self.inputType == "year": + self.response_options.set_valueType("date") + + elif self.inputType == "timeRange": + self.response_options.set_valueType("datetime") + + elif self.inputType == "selectLanguage": + URL = "https://raw.githubusercontent.com/ReproNim/reproschema-library/" + self.response_options.multipleChoice = True + self.response_options.choices = f"{URL}master/resources/languages.json" + + elif self.inputType == "selectCountry": + URL = "https://raw.githubusercontent.com/samayo/country-json/master/src/country-by-name.json" + self.response_options.maxLength = 50 + self.response_options.choices = URL + + elif self.inputType == "selectState": + URL = "https://gist.githubusercontent.com/mshafrir/2646763/raw/8b0dbb93521f5d6889502305335104218454c2bf/states_hash.json" + self.response_options.choices = URL + + elif self.inputType in ["radio", "select", "slider"]: + # TODO make it more general to be able to pass response_options for all input types + if response_options is not None: + if self.inputType in ["slider"]: + response_options.multipleChoice = False + self.response_options = response_options + self.response_options.set_valueType("integer") - elif response_type == "time range": - self.set_input_type_as_time_range() - - elif response_type == "language": - self.set_input_type_as_language() + """ + writing, reading, sorting, unsetting + """ - def sort(self): - schema_order = [ - "@context", - "@type", - "@id", - "prefLabel", - "description", - "schemaVersion", - "version", - "ui", - "question", - "responseOptions", - ] - self.sort_schema(schema_order) + def set_response_options(self) -> None: + """Pass the content of the response options object to the schema of the item. + + Also removes some "unnecessary" fields. + """ + self.response_options.update() + self.response_options.sort_schema() + self.response_options.drop_empty_values_from_schema() + self.response_options.schema.pop("@id") + self.response_options.schema.pop("@type") + self.response_options.schema.pop("@context") + self.schema["responseOptions"] = self.response_options.schema + + def unset(self, keys) -> None: + """Remove empty keys from the schema. + + Rarely used. + """ + for i in keys: + self.schema.pop(i, None) + + def write(self, output_dir=None) -> None: + if output_dir is None: + output_dir = self.output_dir + self.set_response_options() + super().write(output_dir) diff --git a/reproschema/models/protocol.py b/reproschema/models/protocol.py index 284f2c8..30c97cc 100644 --- a/reproschema/models/protocol.py +++ b/reproschema/models/protocol.py @@ -1,78 +1,77 @@ +from pathlib import Path +from typing import Dict +from typing import Optional +from typing import Union + +from attrs import define + +from .activity import Activity +from .base import COMMON_SCHEMA_ORDER from .base import SchemaBase +from .utils import DEFAULT_LANG +@define(kw_only=True) class Protocol(SchemaBase): """ class to deal with reproschema protocols """ - schema_type = "reproschema:Protocol" - - def __init__(self, version=None): - super().__init__(version) - self.schema["ui"] = { - "allow": [], - "shuffle": [], - "order": [], - "addProperties": [], - } - - def set_landing_page(self, landing_page_url, lang="en"): - self.schema["landingPage"] = {"@id": landing_page_url, "inLanguage": lang} - - # TODO - # def add_landing_page(self, landing_page_url, lang="en"): - # preamble - # compute - - def set_image(self, image_url): - self.schema["image"] = image_url - - def set_ui_allow(self): - self.schema["ui"]["allow"] = [ - "reproschema:AutoAdvance", - "reproschema:AllowExport", - ] - - def set_ui_shuffle(self, shuffle=False): - self.schema["ui"]["shuffle"] = shuffle - - def set_defaults(self, name): - self._ReproschemaSchema__set_defaults(name) # this looks wrong - self.set_landing_page("../../README-en.md") - self.set_ui_allow() - self.set_ui_shuffle(False) - - def append_activity(self, activity): - - # TODO - # - remove the hard coding on visibility and valueRequired - - # update the content of the protocol with this new activity - append_to_protocol = { - "variableName": activity.get_name(), - "isAbout": activity.get_URI(), - "prefLabel": {"en": activity.schema["prefLabel"]}, - "isVis": True, - "valueRequired": False, - } - - self.schema["ui"]["order"].append(activity.URI) - self.schema["ui"]["addProperties"].append(append_to_protocol) - - def sort(self): - schema_order = [ - "@context", - "@type", - "@id", - "prefLabel", - "description", - "schemaVersion", - "version", + def __init__( + self, + name: Optional[str] = "protocol", + schemaVersion: Optional[str] = None, + prefLabel: Optional[str] = "protocol", + altLabel: Optional[Union[str, Dict[str, str]]] = None, + description: Optional[str] = "", + landingPage: Optional[dict] = None, + preamble: Optional[str] = None, + citation: Optional[str] = None, + image: Optional[Union[str, Dict[str, str]]] = None, + audio: Optional[Union[str, Dict[str, str]]] = None, + video: Optional[Union[str, Dict[str, str]]] = None, + messages: Optional[Dict[str, str]] = None, + suffix: Optional[str] = "_schema", + ext: Optional[str] = ".jsonld", + output_dir: Optional[Union[str, Path]] = Path.cwd(), + lang: Optional[str] = DEFAULT_LANG(), + **kwargs + ): + + schema_order = COMMON_SCHEMA_ORDER() + [ "landingPage", - "ui", + "citation", + "compute", + "messages", ] - self.sort_schema(schema_order) - ui_order = ["allow", "shuffle", "order", "addProperties"] - self.sort_ui(ui_order) + super().__init__( + at_id=name, + at_type="reproschema:Protocol", + schemaVersion=schemaVersion, + prefLabel={lang: prefLabel}, + altLabel=altLabel, + description=description, + landingPage=landingPage, + preamble=preamble, + citation=citation, + messages=messages, + image=image, + audio=audio, + video=video, + schema_order=schema_order, + suffix=suffix, + ext=ext, + output_dir=output_dir, + lang=lang, + **kwargs + ) + super().set_defaults() + self.ui.shuffle = False + self.ui.update() + self.update() + + def append_activity(self, activity: Activity): + self.ui.append( + obj=activity, variableName=activity.get_basename().replace("_schema", "") + ) diff --git a/reproschema/models/response_options.py b/reproschema/models/response_options.py new file mode 100644 index 0000000..84604b8 --- /dev/null +++ b/reproschema/models/response_options.py @@ -0,0 +1,359 @@ +import json +import os +from pathlib import Path +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Union + +from attrs import define +from attrs import field +from attrs.converters import default_if_none +from attrs.validators import in_ +from attrs.validators import instance_of +from attrs.validators import optional + +from .base import DEFAULT_VERSION +from .utils import SchemaUtils + + +@define(kw_only=True) +class unitOption(SchemaUtils): + """ + An object to represent a human displayable name, + alongside the more formal value for units. + """ + + #: The value for each option in choices or in additionalNotesObj + value: Any = field(default=None) + #: The preferred label. + prefLabel: Optional[Union[str, Dict[str, str]]] = field( + factory=(dict), + converter=default_if_none(default={}), # type: ignore + validator=optional(instance_of((dict, str))), + ) + + def __attrs_post_init__(self) -> None: + + if self.schema_order in [None, []]: + self.schema_order = [ + "prefLabel", + "value", + ] + if isinstance(self.prefLabel, str): + self.prefLabel = {self.lang: self.prefLabel} + + self.update() + self.sort_schema() + + def set_pref_label( + self, pref_label: Optional[str] = None, lang: Optional[str] = None + ) -> None: + if pref_label is None: + return + if lang is None: + lang = self.lang + + self.prefLabel[lang] = pref_label + self.update() + + +@define(kw_only=True) +class Choice(SchemaUtils): + """An object to describe a response option.""" + + #: The name of the item. + name: Optional[Union[str, Dict[str, str]]] = field( + default=None, + converter=default_if_none(default=""), + validator=optional(instance_of((str, dict))), + ) + #: The value for each option in choices or in additionalNotesObj + value: Any = field(default=None) + #: An image of the item. This can be a URL or a fully described ImageObject. + image: Optional[Union[str, Dict[str, str]]] = field( + default=None, validator=optional(instance_of((str, dict))) + ) + + def __attrs_post_init__(self) -> None: + + if self.schema_order in [None, []]: + self.schema_order = [ + "name", + "value", + "image", + ] + + if isinstance(self.name, str): + self.name = {self.lang: self.name} + + self.update() + self.sort_schema() + self.drop_empty_values_from_schema() + + +@define(kw_only=True) +class ResponseOption(SchemaUtils): + + SUPPORTED_VALUE_TYPES = ( + "", + "xsd:string", + "xsd:integer", + "xsd:float", + "xsd:date", + "xsd:datetime", + "xsd:timeRange", + ) + + at_type: Optional[str] = field( + default=None, + converter=default_if_none(default="reproschema:ResponseOption"), # type: ignore + validator=in_( + [ + "reproschema:ResponseOption", + ] + ), + ) + at_id: Optional[str] = field( + default=None, + converter=default_if_none(default="valueConstraints"), # type: ignore + validator=[instance_of(str)], + ) + schemaVersion: Optional[str] = field( + default=DEFAULT_VERSION(), + converter=default_if_none(default=DEFAULT_VERSION()), # type: ignore + validator=[instance_of(str)], + ) + at_context: str = field( + validator=[instance_of(str)], + ) + + @at_context.default + def _default_context(self) -> str: + """ + For now we assume that the github repo will be where schema will be read from. + """ + URL = "https://raw.githubusercontent.com/ReproNim/reproschema/" + VERSION = self.schemaVersion or DEFAULT_VERSION() + return URL + VERSION + "/contexts/generic" + + #: The type of the response of an item. For example, string, integer, etc. + valueType: str = field( + factory=(str), + converter=default_if_none(default=""), # type: ignore + validator=optional(in_(SUPPORTED_VALUE_TYPES)), + ) + #: List the available options for response of the Field item. + choices: Optional[Union[str, List[Choice]]] = field( + factory=(list), + converter=default_if_none(default=[]), # type: ignore + validator=optional(instance_of((str, list))), + ) + #: Indicates if response for the Field item has one or more answer. + multipleChoice: Optional[bool] = field( + default=None, + validator=optional(instance_of(bool)), + ) + #: The lower value of some characteristic or property. + minValue: Optional[int] = field( + default=None, + validator=optional(instance_of(int)), + ) + #: The upper value of some characteristic or property. + maxValue: Optional[int] = field( + default=None, + validator=optional(instance_of(int)), + ) + #: A list to represent a human displayable name alongside the more formal value for units. + unitOptions: Optional[list] = field( + default=None, + converter=default_if_none(default=[]), # type: ignore + validator=optional(instance_of(list)), + ) + #: The unit of measurement given using the UN/CEFACT Common Code (3 characters) or a URL. + # Other codes than the UN/CEFACT Common Code may be used with a prefix followed by a colon. + unitCode: Optional[str] = field( + default=None, + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + #: Indicates what type of datum the response is + # (e.g. range,count,scalar etc.) for the Field item. + datumType: Optional[str] = field( + default=None, + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + # Technically not in the schema, but useful for the UI + maxLength: Optional[int] = field( + default=None, + validator=optional(instance_of(int)), + ) + + """ + + Non schema based attributes: OPTIONAL + + Those attributes help with file management + and with printing json files with standardized key orders + + """ + + output_dir: Optional[Union[str, Path]] = field( + default=None, + converter=default_if_none(default=Path.cwd()), # type: ignore + validator=optional(instance_of((str, Path))), + ) + suffix: Optional[str] = field( + default=None, + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + ext: Optional[str] = field( + default=None, + converter=default_if_none(default=".jsonld"), # type: ignore + validator=optional(instance_of(str)), + ) + URI: Optional[Path] = field( + default=None, + converter=default_if_none(default=Path("")), # type: ignore + validator=optional(instance_of((str, Path))), + ) + + def __attrs_post_init__(self) -> None: + + if self.schema_order in [None, []]: + self.schema_order = [ + "@type", + "@context", + "@id", + "valueType", + "choices", + "multipleChoice", + "minValue", + "maxValue", + "unitOptions", + "unitCode", + "datumType", + "maxLength", + ] + + """ + SETTERS: + """ + + def set_defaults(self) -> None: + self.schema["@type"] = self.at_type + self.set_filename() + + def set_valueType(self, value: str = None) -> None: + """_summary_ + + :param value: _description_, defaults to None + :type value: str, optional + """ + if value is not None: + self.valueType = f"xsd:{value}" + self.update() + + def set_min(self, value: int = None) -> None: + """_summary_ + + :param value: _description_, defaults to None + :type value: int, optional + """ + if value is not None: + self.minValue = value + elif len(self.choices) > 1: + self.minValue = 0 + all_values = self.values_all_options() + if all(isinstance(x, int) for x in all_values): + self.minValue = min(all_values) + + def set_max(self, value: int = None) -> None: + """_summary_ + + :param value: _description_, defaults to None + :type value: int, optional + """ + if value is not None: + self.maxValue = value + + elif len(self.choices) > 1: + all_values = self.values_all_options() + self.maxValue = len(all_values) - 1 + if all(isinstance(x, int) for x in all_values): + self.maxValue = max(all_values) + + self.update() + + def values_all_options(self) -> List[Any]: + return [i["value"] for i in self.choices if "value" in i] + + def add_choice( + self, + name: Optional[str] = None, + value: Any = None, + lang: Optional[str] = None, + ) -> None: + """Add a response choice. + + :param name: _description_, defaults to None + :type name: Optional[str], optional + :param value: _description_, defaults to None + :type value: Any, optional + :param lang: _description_, defaults to None + :type lang: Optional[str], optional + """ + if lang is None: + lang = self.lang + # TODO replace existing choice if already set + self.choices.append(Choice(name=name, value=value, lang=lang).schema) + self.set_max() + self.set_min() + + """ + MISC + """ + + def update(self) -> None: + self.schema["@id"] = self.at_id + self.schema["@type"] = self.at_type + self.schema["@context"] = self.at_context + + super().update() + + def set_filename(self, name: str = None) -> None: + if name is None: + name = self.at_id + if name.endswith(self.ext): + name = name.replace(self.ext, "") + if name.endswith(self.suffix): + name = name.replace(self.suffix, "") + + name = name.replace(" ", "_") + + self.at_id = f"{name}{self.suffix}{self.ext}" + self.URI = os.path.join(self.output_dir, self.at_id) + self.update() + + def get_basename(self) -> str: + return Path(self.at_id).stem + + def write(self, output_dir: Optional[Union[str, Path]] = None) -> None: + + self.update() + self.sort_schema() + self.drop_empty_values_from_schema() + + if output_dir is None: + output_dir = self.output_dir + + if isinstance(output_dir, str): + output_dir = Path(output_dir) + + output_dir.mkdir(parents=True, exist_ok=True) + + with open(output_dir.joinpath(self.at_id), "w") as ff: + json.dump(self.schema, ff, sort_keys=False, indent=4) diff --git a/reproschema/models/tests/.gitignore b/reproschema/models/tests/.gitignore new file mode 100644 index 0000000..2f9a1e0 --- /dev/null +++ b/reproschema/models/tests/.gitignore @@ -0,0 +1,4 @@ +items/*.jsonld +activities/*.jsonld +response_options/*.jsonld +protocols/*.jsonld diff --git a/reproschema/models/tests/README.md b/reproschema/models/tests/README.md new file mode 100644 index 0000000..73a8cda --- /dev/null +++ b/reproschema/models/tests/README.md @@ -0,0 +1,37 @@ +# Tests for reproschema-py "models" + +## Philosophy + +Most of the test are trying to be step by step "recipes" to create the different +files in a schema. + +Each test tries to create an item from 'scratch' by using the `Protocol`, +`Activity`, `Item` and `ResponseOptions` classes and writes the resulting +`.jsonld` to the disk. + +The file is then read and its content compared to the "expected file" in the +`data` folder. + +When testing the Protocol and Activity classes, the output tries to get very +very close to the `jsonld` found in: + +``` +reproschema/tests/data/activities/items/activity1_total_score +``` + +Ideally this would avoided having 2 sets of `.jsonld` to test against. + +## Running the tests + +Requires `pytest` you can `pip install`. + +If you are developping the code, also make sure you have installed the +reproschema package locally and not from pypi. + +Run this from the root folder of where you cloned the reproschema package: + +``` +pip install -e . +``` + +More [here](../../README.md) diff --git a/reproschema/models/tests/__init__.py b/reproschema/models/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/reproschema/models/tests/data/activities/activity1.jsonld b/reproschema/models/tests/data/activities/activity1.jsonld new file mode 100644 index 0000000..6f2100c --- /dev/null +++ b/reproschema/models/tests/data/activities/activity1.jsonld @@ -0,0 +1,59 @@ +{ + "@context": "../../contexts/generic", + "@type": "reproschema:Activity", + "@id": "activity1.jsonld", + "prefLabel": {"en": "Example 1"}, + "description": "Activity example 1", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "citation": "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1495268/", + "image": { + "@type": "AudioObject", + "contentUrl": "http://example.com/sample-image.png" + }, + "preamble": { + "en": "Over the last 2 weeks, how often have you been bothered by any of the following problems?", + "es": "Durante las últimas 2 semanas, ¿con qué frecuencia le han molestado los siguintes problemas?" + }, + "compute": [ + { + "variableName": "activity1_total_score", + "jsExpression": "item1 + item2" + } + ], + "messages": [ + { + "message": "Test message: Triggered when item1 value is greater than 1", + "jsExpression": "item1 > 1" + } + ], + "ui": { + "addProperties": [ + { "isAbout": "items/item1.jsonld", + "variableName": "item1", + "requiredValue": true, + "isVis": true, + "randomMaxDelay": "PT2H", + "limit": "P2D", + "schedule": "R/2020-08-01T08:00:00Z/P1D" + }, + { "isAbout": "items/item2.jsonld", + "variableName": "item2", + "requiredValue": true, + "isVis": true, + "allow": ["reproschema:Skipped"] + }, + { "isAbout": "items/activity1_total_score", + "variableName": "activity1_total_score", + "requiredValue": true, + "isVis": false + } + ], + "order": [ + "items/item1.jsonld", + "items/item2.jsonld", + "items/activity1_total_score" + ], + "shuffle": false + } +} diff --git a/reproschema/models/tests/data/activities/activity1_schema.jsonld b/reproschema/models/tests/data/activities/activity1_schema.jsonld new file mode 100644 index 0000000..0666968 --- /dev/null +++ b/reproschema/models/tests/data/activities/activity1_schema.jsonld @@ -0,0 +1,69 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Activity", + "@id": "activity1_schema.jsonld", + "prefLabel": { + "en": "Example 1" + }, + "description": "Activity example 1", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "preamble": { + "en": "Over the last 2 weeks, how often have you been bothered by any of the following problems?" + }, + "citation": "https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1495268/", + "image": { + "@type": "AudioObject", + "contentUrl": "http://example.com/sample-image.png" + }, + "compute": [ + { + "variableName": "activity1_total_score", + "jsExpression": "item1 + item2" + } + ], + "ui": { + "shuffle": false, + "order": [ + "items/item1.jsonld", + "../other_dir/item_two.jsonld", + "items/activity1_total_score" + ], + "addProperties": [ + { + "variableName": "item1", + "isAbout": "items/item1.jsonld", + "prefLabel": { + "en": "item1" + }, + "isVis": true, + "requiredValue": true + }, + { + "variableName": "item_two", + "isAbout": "../other_dir/item_two.jsonld", + "prefLabel": { + "en": "item2" + }, + "isVis": true, + "requiredValue": true, + "allow": [ + "reproschema:Skipped" + ] + }, + { + "variableName": "activity1_total_score", + "isAbout": "items/activity1_total_score", + "prefLabel": { + "en": "activity1 total score" + }, + "isVis": false, + "requiredValue": true + } + ], + "allow": [ + "reproschema:AutoAdvance", + "reproschema:AllowExport" + ] + } +} diff --git a/reproschema/models/tests/data/activities/default_schema.jsonld b/reproschema/models/tests/data/activities/default_schema.jsonld new file mode 100644 index 0000000..121e4c8 --- /dev/null +++ b/reproschema/models/tests/data/activities/default_schema.jsonld @@ -0,0 +1,18 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Activity", + "@id": "default_schema.jsonld", + "prefLabel": { + "en": "default" + }, + "description": "default", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "ui": { + "shuffle": false, + "allow": [ + "reproschema:AutoAdvance", + "reproschema:AllowExport" + ] + } +} diff --git a/reproschema/models/tests/data/items/country.jsonld b/reproschema/models/tests/data/items/country.jsonld new file mode 100644 index 0000000..15c0677 --- /dev/null +++ b/reproschema/models/tests/data/items/country.jsonld @@ -0,0 +1,22 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "country.jsonld", + "prefLabel": { + "en": "country" + }, + "description": "country", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "select a country" + }, + "ui": { + "inputType": "selectCountry" + }, + "responseOptions": { + "valueType": "xsd:string", + "maxLength": 50, + "choices": "https://raw.githubusercontent.com/samayo/country-json/master/src/country-by-name.json" + } +} diff --git a/reproschema/models/tests/data/items/date.jsonld b/reproschema/models/tests/data/items/date.jsonld new file mode 100644 index 0000000..85efd8f --- /dev/null +++ b/reproschema/models/tests/data/items/date.jsonld @@ -0,0 +1,20 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "date.jsonld", + "prefLabel": { + "en": "date" + }, + "description": "date", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "input a date" + }, + "ui": { + "inputType": "date" + }, + "responseOptions": { + "valueType": "xsd:date" + } +} diff --git a/reproschema/models/tests/data/items/default.jsonld b/reproschema/models/tests/data/items/default.jsonld new file mode 100644 index 0000000..944f630 --- /dev/null +++ b/reproschema/models/tests/data/items/default.jsonld @@ -0,0 +1,19 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "default.jsonld", + "prefLabel": { + "en": "default" + }, + "description": "default", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "ui": { + "inputType": "text" + }, + "question": {"en": ""}, + "responseOptions": { + "valueType": "xsd:string", + "maxLength": 300 + } +} diff --git a/reproschema/models/tests/data/items/email.jsonld b/reproschema/models/tests/data/items/email.jsonld new file mode 100644 index 0000000..1d4540e --- /dev/null +++ b/reproschema/models/tests/data/items/email.jsonld @@ -0,0 +1,20 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "email.jsonld", + "prefLabel": { + "en": "email" + }, + "description": "email", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "input email address" + }, + "ui": { + "inputType": "email" + }, + "responseOptions": { + "valueType": "xsd:string" + } +} diff --git a/reproschema/models/tests/data/items/float.jsonld b/reproschema/models/tests/data/items/float.jsonld new file mode 100644 index 0000000..9ef5d1e --- /dev/null +++ b/reproschema/models/tests/data/items/float.jsonld @@ -0,0 +1,20 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "float.jsonld", + "prefLabel": { + "en": "float" + }, + "description": "float", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "item to input a float" + }, + "ui": { + "inputType": "float" + }, + "responseOptions": { + "valueType": "xsd:float" + } +} diff --git a/reproschema/models/tests/data/items/integer.jsonld b/reproschema/models/tests/data/items/integer.jsonld new file mode 100644 index 0000000..fe57ec9 --- /dev/null +++ b/reproschema/models/tests/data/items/integer.jsonld @@ -0,0 +1,20 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "integer.jsonld", + "prefLabel": { + "en": "integer" + }, + "description": "integer", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "item to input a integer" + }, + "ui": { + "inputType": "number" + }, + "responseOptions": { + "valueType": "xsd:integer" + } +} diff --git a/reproschema/models/tests/data/items/item1.jsonld b/reproschema/models/tests/data/items/item1.jsonld new file mode 100644 index 0000000..05a61dc --- /dev/null +++ b/reproschema/models/tests/data/items/item1.jsonld @@ -0,0 +1,81 @@ +{ + "@context": "../../../contexts/generic", + "@type": "reproschema:Field", + "@id": "item1.jsonld", + "prefLabel": { + "en": "item1" + }, + "description": "Q1 of example 1", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "audio": { + "@type": "AudioObject", + "contentUrl": "http://media.freesound.org/sample-file.mp4" + }, + "image": { + "@type": "ImageObject", + "contentUrl": "http://example.com/sample-image.jpg" + }, + "question": { + "en": "Little interest or pleasure in doing things", + "es": "Poco interés o placer en hacer cosas" + }, + "ui": { + "inputType": "radio" + }, + "responseOptions": { + "valueType": "xsd:integer", + "minValue": 0, + "maxValue": 3, + "multipleChoice": false, + "choices": [ + { + "name": { + "en": "Not at all", + "es": "Para nada" + }, + "value": 0 + }, + { + "name": { + "en": "Several days", + "es": "Varios días" + }, + "value": "a" + }, + { + "name": { + "en": "More than half the days", + "es": "Más de la mitad de los días" + }, + "value": { + "@id": "http://example.com/choice3" + } + }, + { + "name": { + "en": "Nearly everyday", + "es": "Casi todos los días" + }, + "value": { + "@value": "choice-with-lang", + "@language": "en" + } + } + ] + }, + "additionalNotesObj": [ + { + "source": "redcap", + "column": "notes", + "value": "some extra note" + }, + { + "source": "redcap", + "column": "notes", + "value": { + "@id": "http://example.com/iri-example" + } + } + ] +} diff --git a/reproschema/models/tests/data/items/item2.jsonld b/reproschema/models/tests/data/items/item2.jsonld new file mode 100644 index 0000000..732903d --- /dev/null +++ b/reproschema/models/tests/data/items/item2.jsonld @@ -0,0 +1,41 @@ +{ + "@context": "../../../contexts/generic", + "@type": "reproschema:Field", + "@id": "item2.jsonld", + "prefLabel": { + "en": "item2" + }, + "description": "Q2 of example 1", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "Current temperature.", + "es": "Fiebre actual." + }, + "video": { + "@type": "VideoObject", + "contentUrl": "http://media.freesound.org/data/0/previews/719__elmomo__12oclock_girona_preview.mp4" + }, + "ui": { + "inputType": "float" + }, + "responseOptions": { + "valueType": "xsd:float", + "unitOptions": [ + { + "prefLabel": { + "en": "Fahrenheit", + "es": "Fahrenheit" + }, + "value": "°F" + }, + { + "prefLabel": { + "en": "Celsius", + "es": "Celsius" + }, + "value": "°C" + } + ] + } +} diff --git a/reproschema/models/tests/data/items/language.jsonld b/reproschema/models/tests/data/items/language.jsonld new file mode 100644 index 0000000..e4bac83 --- /dev/null +++ b/reproschema/models/tests/data/items/language.jsonld @@ -0,0 +1,22 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "language.jsonld", + "prefLabel": { + "en": "language" + }, + "description": "language", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "item to select several language" + }, + "ui": { + "inputType": "selectLanguage" + }, + "responseOptions": { + "valueType": "xsd:string", + "multipleChoice": true, + "choices": "https://raw.githubusercontent.com/ReproNim/reproschema-library/master/resources/languages.json" + } +} diff --git a/reproschema/models/tests/data/items/multitext.jsonld b/reproschema/models/tests/data/items/multitext.jsonld new file mode 100644 index 0000000..260426f --- /dev/null +++ b/reproschema/models/tests/data/items/multitext.jsonld @@ -0,0 +1,21 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "multitext.jsonld", + "prefLabel": { + "en": "multitext" + }, + "description": "multitext", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "item with several text field" + }, + "ui": { + "inputType": "multitext" + }, + "responseOptions": { + "valueType": "xsd:string", + "maxLength": 300 + } +} diff --git a/reproschema/models/tests/data/items/participant_id.jsonld b/reproschema/models/tests/data/items/participant_id.jsonld new file mode 100644 index 0000000..3d3ad12 --- /dev/null +++ b/reproschema/models/tests/data/items/participant_id.jsonld @@ -0,0 +1,20 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "participant_id.jsonld", + "prefLabel": { + "en": "participant id" + }, + "description": "participant id", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "input the participant id number" + }, + "ui": { + "inputType": "pid" + }, + "responseOptions": { + "valueType": "xsd:string" + } +} diff --git a/reproschema/models/tests/data/items/radio.jsonld b/reproschema/models/tests/data/items/radio.jsonld new file mode 100644 index 0000000..5ead18c --- /dev/null +++ b/reproschema/models/tests/data/items/radio.jsonld @@ -0,0 +1,37 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "radio.jsonld", + "prefLabel": { + "en": "radio" + }, + "description": "radio", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "question for radio item" + }, + "ui": { + "inputType": "radio" + }, + "responseOptions": { + "valueType": "xsd:integer", + "minValue": 0, + "maxValue": 1, + "multipleChoice": false, + "choices": [ + { + "name": { + "en": "Not at all" + }, + "value": 0 + }, + { + "name": { + "en": "Several days" + }, + "value": 1 + } + ] + } +} diff --git a/reproschema/models/tests/data/items/radio_multiple.jsonld b/reproschema/models/tests/data/items/radio_multiple.jsonld new file mode 100644 index 0000000..694e099 --- /dev/null +++ b/reproschema/models/tests/data/items/radio_multiple.jsonld @@ -0,0 +1,37 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "radio_multiple.jsonld", + "prefLabel": { + "en": "radio multiple" + }, + "description": "radio multiple", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "question for radio item with multiple responses" + }, + "ui": { + "inputType": "radio" + }, + "responseOptions": { + "valueType": "xsd:integer", + "minValue": 0, + "maxValue": 1, + "multipleChoice": true, + "choices": [ + { + "name": { + "en": "Not at all" + }, + "value": 0 + }, + { + "name": { + "en": "Several days" + }, + "value": 1 + } + ] + } +} diff --git a/reproschema/models/tests/data/items/select.jsonld b/reproschema/models/tests/data/items/select.jsonld new file mode 100644 index 0000000..b7e63f8 --- /dev/null +++ b/reproschema/models/tests/data/items/select.jsonld @@ -0,0 +1,43 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "select.jsonld", + "prefLabel": { + "en": "select" + }, + "description": "select", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "question for select item" + }, + "ui": { + "inputType": "select" + }, + "responseOptions": { + "valueType": "xsd:integer", + "minValue": 0, + "maxValue": 2, + "multipleChoice": false, + "choices": [ + { + "name": { + "en": "Response option 1" + }, + "value": 0 + }, + { + "name": { + "en": "Response option 2" + }, + "value": 1 + }, + { + "name": { + "en": "Response option 3" + }, + "value": 2 + } + ] + } +} diff --git a/reproschema/models/tests/data/items/select_multiple.jsonld b/reproschema/models/tests/data/items/select_multiple.jsonld new file mode 100644 index 0000000..ab855ee --- /dev/null +++ b/reproschema/models/tests/data/items/select_multiple.jsonld @@ -0,0 +1,43 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "select_multiple.jsonld", + "prefLabel": { + "en": "select multiple" + }, + "description": "select multiple", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "question for select item with multiple responses" + }, + "ui": { + "inputType": "select" + }, + "responseOptions": { + "valueType": "xsd:integer", + "minValue": 0, + "maxValue": 2, + "multipleChoice": true, + "choices": [ + { + "name": { + "en": "Response option 1" + }, + "value": 0 + }, + { + "name": { + "en": "Response option 2" + }, + "value": 1 + }, + { + "name": { + "en": "Response option 3" + }, + "value": 2 + } + ] + } +} diff --git a/reproschema/models/tests/data/items/slider.jsonld b/reproschema/models/tests/data/items/slider.jsonld new file mode 100644 index 0000000..80114dd --- /dev/null +++ b/reproschema/models/tests/data/items/slider.jsonld @@ -0,0 +1,55 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "slider.jsonld", + "prefLabel": { + "en": "slider" + }, + "description": "slider", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "question for slider item" + }, + "ui": { + "inputType": "slider" + }, + "responseOptions": { + "valueType": "xsd:integer", + "minValue": 0, + "maxValue": 4, + "multipleChoice": false, + "choices": [ + { + "name": { + "en": "not at all" + }, + "value": 0 + }, + { + "name": { + "en": "a bit" + }, + "value": 1 + }, + { + "name": { + "en": "so so" + }, + "value": 2 + }, + { + "name": { + "en": "a lot" + }, + "value": 3 + }, + { + "name": { + "en": "very much" + }, + "value": 4 + } + ] + } +} diff --git a/reproschema/models/tests/data/items/state.jsonld b/reproschema/models/tests/data/items/state.jsonld new file mode 100644 index 0000000..b09c421 --- /dev/null +++ b/reproschema/models/tests/data/items/state.jsonld @@ -0,0 +1,21 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "state.jsonld", + "prefLabel": { + "en": "state" + }, + "description": "state", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "select a USA state" + }, + "ui": { + "inputType": "selectState" + }, + "responseOptions": { + "valueType": "xsd:string", + "choices": "https://gist.githubusercontent.com/mshafrir/2646763/raw/8b0dbb93521f5d6889502305335104218454c2bf/states_hash.json" + } +} diff --git a/reproschema/models/tests/data/items/text.jsonld b/reproschema/models/tests/data/items/text.jsonld new file mode 100644 index 0000000..7e75772 --- /dev/null +++ b/reproschema/models/tests/data/items/text.jsonld @@ -0,0 +1,21 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "text.jsonld", + "prefLabel": { + "en": "text" + }, + "description": "text", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "ui": { + "inputType": "text" + }, + "question": { + "en": "question for text item" + }, + "responseOptions": { + "valueType": "xsd:string", + "maxLength": 300 + } +} diff --git a/reproschema/models/tests/data/items/time_range.jsonld b/reproschema/models/tests/data/items/time_range.jsonld new file mode 100644 index 0000000..14c1350 --- /dev/null +++ b/reproschema/models/tests/data/items/time_range.jsonld @@ -0,0 +1,20 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "time_range.jsonld", + "prefLabel": { + "en": "time range" + }, + "description": "time range", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "input a time range" + }, + "ui": { + "inputType": "timeRange" + }, + "responseOptions": { + "valueType": "xsd:datetime" + } +} diff --git a/reproschema/models/tests/data/items/year.jsonld b/reproschema/models/tests/data/items/year.jsonld new file mode 100644 index 0000000..e4e3039 --- /dev/null +++ b/reproschema/models/tests/data/items/year.jsonld @@ -0,0 +1,20 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Field", + "@id": "year.jsonld", + "prefLabel": { + "en": "year" + }, + "description": "year", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "question": { + "en": "input a year" + }, + "ui": { + "inputType": "year" + }, + "responseOptions": { + "valueType": "xsd:date" + } +} diff --git a/reproschema/models/tests/data/protocols/default_schema.jsonld b/reproschema/models/tests/data/protocols/default_schema.jsonld new file mode 100644 index 0000000..ee6fb51 --- /dev/null +++ b/reproschema/models/tests/data/protocols/default_schema.jsonld @@ -0,0 +1,18 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Protocol", + "@id": "default_schema.jsonld", + "prefLabel": { + "en": "default" + }, + "description": "default", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "ui": { + "allow": [ + "reproschema:AutoAdvance", + "reproschema:AllowExport" + ], + "shuffle": false + } +} diff --git a/reproschema/models/tests/data/protocols/protocol1.jsonld b/reproschema/models/tests/data/protocols/protocol1.jsonld new file mode 100644 index 0000000..580d099 --- /dev/null +++ b/reproschema/models/tests/data/protocols/protocol1.jsonld @@ -0,0 +1,45 @@ +{ + "@context": "../../contexts/generic", + "@type": "reproschema:Protocol", + "@id": "protocol1.jsonld", + "prefLabel": { + "en": "Protocol1", + "es": "Protocol1_es" + }, + "description": "example Protocol", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "landingPage": {"@id": "http://example.com/sample-readme.md", + "inLanguage": "en"}, + "messages": [ + { + "message": "Test message: Triggered when item1 value is greater than 0", + "jsExpression": "item1 > 0" + } + ], + "ui": { + "addProperties": [ + { + "isAbout": "../activities/activity1.jsonld", + "variableName": "activity1", + "prefLabel": { + "en": "Screening", + "es": "Screening_es" + }, + "isVis": true, + "schedule": "R5/2008-01-01T13:00:00Z/P1Y2M10DT2H30M", + "randomMaxDelay": "PT12H", + "limit": "P1W/2020-08-01T13:00:00Z" + } + ], + "order": [ + "../activities/activity1.jsonld" + ], + "shuffle": false, + "allow": [ + "reproschema:AutoAdvance", + "reproschema:AllowExport", + "reproschema:DisableBack" + ] + } +} diff --git a/reproschema/models/tests/data/protocols/protocol1_schema.jsonld b/reproschema/models/tests/data/protocols/protocol1_schema.jsonld new file mode 100644 index 0000000..2620c91 --- /dev/null +++ b/reproschema/models/tests/data/protocols/protocol1_schema.jsonld @@ -0,0 +1,43 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@type": "reproschema:Protocol", + "@id": "protocol1_schema.jsonld", + "schemaVersion": "1.0.0-rc4", + "version": "0.0.1", + "prefLabel": { + "en": "Protocol1" + }, + "description": "example Protocol", + "preamble": { + "en": "protocol1" + }, + "ui": { + "shuffle": false, + "order": [ + "../activities/activity1_schema.jsonld" + ], + "addProperties": [ + { + "variableName": "activity1", + "isAbout": "../activities/activity1_schema.jsonld", + "prefLabel": { + "en": "Screening" + }, + "isVis": true, + "requiredValue": false, + "allow": [ + "reproschema:Skipped" + ] + } + ], + "allow": [ + "reproschema:AutoAdvance", + "reproschema:AllowExport", + "reproschema:DisableBack" + ] + }, + "landingPage": { + "@id": "http://example.com/sample-readme.md", + "inLanguage": "en" + } +} diff --git a/reproschema/models/tests/data/response_options/example.jsonld b/reproschema/models/tests/data/response_options/example.jsonld new file mode 100644 index 0000000..7dfe3bc --- /dev/null +++ b/reproschema/models/tests/data/response_options/example.jsonld @@ -0,0 +1,46 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@id": "example.jsonld", + "@type": "reproschema:ResponseOption", + "valueType": "xsd:integer", + "minValue": 0, + "maxValue": 6, + "choices": [ + { + "name": { + "en": "Not at all" + }, + "value": 0 + }, + { + "name": { + "en": "" + }, + "value": 1 + }, + { + "name": { + "en": "" + }, + "value": 2 + }, + { + "name": { + "en": "" + }, + "value": 3 + }, + { + "name": { + "en": "" + }, + "value": 4 + }, + { + "name": { + "en": "Completely" + }, + "value": 6 + } + ] +} diff --git a/reproschema/models/tests/data/response_options/valueConstraints.jsonld b/reproschema/models/tests/data/response_options/valueConstraints.jsonld new file mode 100644 index 0000000..33ea751 --- /dev/null +++ b/reproschema/models/tests/data/response_options/valueConstraints.jsonld @@ -0,0 +1,5 @@ +{ + "@context": "https://raw.githubusercontent.com/ReproNim/reproschema/1.0.0-rc4/contexts/generic", + "@id": "valueConstraints.jsonld", + "@type": "reproschema:ResponseOption" +} diff --git a/reproschema/models/tests/test_activity.py b/reproschema/models/tests/test_activity.py new file mode 100644 index 0000000..6628b08 --- /dev/null +++ b/reproschema/models/tests/test_activity.py @@ -0,0 +1,150 @@ +import os + +from utils import clean_up +from utils import load_jsons +from utils import output_dir + +from reproschema.models.activity import Activity +from reproschema.models.base import Message +from reproschema.models.item import Item + +activity_dir = output_dir("activities") + + +def test_default(): + """ + FYI: The default activity does not conform to the schema + so `reproschema validate` will complain if you run it on this + """ + + activity = Activity(name="default", output_dir=activity_dir) + activity.write() + + activity_content, expected = load_jsons(activity_dir, activity) + assert activity_content == expected + + clean_up(activity_dir, activity) + + +def test_activity(): + + activity = Activity( + name="activity1", + prefLabel="Example 1", + description="Activity example 1", + citation="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1495268/", + output_dir=activity_dir, + ) + activity.set_preamble( + "Over the last 2 weeks, how often have you been bothered by any of the following problems?" + ) + activity.image = { + "@type": "AudioObject", + "contentUrl": "http://example.com/sample-image.png", + } + + activity.set_compute("activity1_total_score", "item1 + item2") + + item_1 = Item(name="item1", skippable=False, required=True, output_dir="items") + + item_2 = Item( + name="item2", required=True, output_dir=os.path.join("..", "other_dir") + ) + item_2.set_filename("item_two") + + """ + By default all files are save with a jsonld extension but this can be changed + """ + item_3 = Item( + name="activity1_total_score", + ext="", + skippable=False, + required=True, + visible=False, + output_dir="items", + ) + + activity.append_item(item_1) + activity.append_item(item_2) + activity.append_item(item_3) + + activity.write() + activity_content, expected = load_jsons(activity_dir, activity) + assert activity_content == expected + + clean_up(activity_dir, activity) + + +def test_activity_1(): + + messages = [ + Message( + jsExpression="item1 > 1", + message="Test message: Triggered when item1 value is greater than 1", + ).schema + ] + + activity = Activity( + name="activity1", + prefLabel="Example 1", + description="Activity example 1", + citation="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1495268/", + messages=messages, + output_dir=activity_dir, + suffix="", + ) + activity.set_preamble( + preamble="Over the last 2 weeks, how often have you been bothered by any of the following problems?" + ) + activity.set_preamble( + preamble="Durante las últimas 2 semanas, ¿con qué frecuencia le han molestado los siguintes problemas?", + lang="es", + ) + activity.image = { + "@type": "AudioObject", + "contentUrl": "http://example.com/sample-image.png", + } + activity.at_context = "../../contexts/generic" + activity.ui.AutoAdvance = False + activity.ui.AllowExport = False + activity.update() + + activity.set_compute(variable="activity1_total_score", expression="item1 + item2") + + item_1 = Item( + name="item1", + skippable=False, + required=True, + output_dir="items", + limit="P2D", + randomMaxDelay="PT2H", + schedule="R/2020-08-01T08:00:00Z/P1D", + ) + item_1.prefLabel = {} + activity.update() + + item_2 = Item(name="item2", skippable=True, required=True, output_dir="items") + item_2.set_filename("item2") + item_2.prefLabel = {} + activity.update() + + item_3 = Item( + name="activity1_total_score", + ext="", + skippable=False, + required=True, + visible=False, + output_dir="items", + ) + item_3.prefLabel = {} + activity.update() + + activity.append_item(item_1) + activity.append_item(item_2) + activity.append_item(item_3) + + activity.write() + activity_content, expected = load_jsons(activity_dir, activity) + assert activity_content == expected + + clean_up(activity_dir, activity) diff --git a/reproschema/models/tests/test_item.py b/reproschema/models/tests/test_item.py new file mode 100644 index 0000000..02ac6a6 --- /dev/null +++ b/reproschema/models/tests/test_item.py @@ -0,0 +1,301 @@ +import os +from pathlib import Path + +import pytest +from utils import clean_up +from utils import load_jsons +from utils import output_dir +from utils import read_json + +from reproschema.models.base import AdditionalNoteObj +from reproschema.models.item import Item +from reproschema.models.response_options import ResponseOption +from reproschema.models.response_options import unitOption + +item_dir = output_dir("items") + + +def test_default(): + + item = Item(name="default", output_dir=item_dir) + print(item.schema_order) + + item.write() + item_content, expected = load_jsons(item_dir, item) + assert item_content == expected + + clean_up(item_dir, item) + + +@pytest.mark.parametrize( + "name, question, input_type", + [ + ("text", "question for text item", "text"), + ("multitext", "item with several text field", "multitext"), + ("email", "input email address", "email"), + ("participant id", "input the participant id number", "pid"), + ("date", "input a date", "date"), + ("time range", "input a time range", "timeRange"), + ("year", "input a year", "year"), + ("language", "item to select several language", "selectLanguage"), + ("country", "select a country", "selectCountry"), + ("state", "select a USA state", "selectState"), + ("float", "item to input a float", "float"), + ("integer", "item to input a integer", "integer"), + ], +) +def test_items(name, question, input_type): + + item = Item( + name=name, question=question, input_type=input_type, output_dir=item_dir + ) + + item.write() + item_content, expected = load_jsons(item_dir, item) + assert item_content == expected + + clean_up(item_dir, item) + + +""" +NUMERICAL ITEMS +""" + + +""" +SELECTION ITEMS: radio and select +tested both with: +- only one response allowed +- multiple responses allowed +""" + + +def test_radio(): + + response_options = ResponseOption(multipleChoice=False) + response_options.add_choice(name="Not at all", value=0) + response_options.add_choice(name="Several days", value=1) + + item = Item( + name="radio", + input_type="radio", + question="question for radio item", + output_dir=item_dir, + ) + item.set_input_type(response_options=response_options) + + item.write() + item_content, expected = load_jsons(item_dir, item) + assert item_content == expected + + clean_up(item_dir, item) + + item.set_filename("radio multiple") + item.description = "radio multiple" + item.update() + item.set_pref_label("radio multiple") + item.set_question("question for radio item with multiple responses") + response_options.multipleChoice = True + item.set_input_type(response_options=response_options) + item.write() + + item_content, expected = load_jsons(item_dir, item) + assert item_content == expected + + clean_up(item_dir, item) + + +def test_select(): + + response_options = ResponseOption(multipleChoice=False) + response_options.add_choice(name="Response option 1", value=0) + response_options.add_choice(name="Response option 2", value=1) + response_options.add_choice(name="Response option 3", value=2) + + item = Item( + name="select", + input_type="select", + question="question for select item", + output_dir=item_dir, + ) + item.set_input_type(response_options=response_options) + + item.write() + item_content, expected = load_jsons(item_dir, item) + assert item_content == expected + + clean_up(item_dir, item) + + item.set_filename("select multiple") + item.description = "select multiple" + item.update() + item.set_pref_label("select multiple") + item.set_question("question for select item with multiple responses") + response_options.multipleChoice = True + item.set_input_type(response_options=response_options) + item.write() + + item_content, expected = load_jsons(item_dir, item) + assert item_content == expected + + clean_up(item_dir, item) + + +def test_slider(): + + response_options = ResponseOption() + response_options.add_choice(name="not at all", value=0) + response_options.add_choice(name="a bit", value=1) + response_options.add_choice(name="so so", value=2) + response_options.add_choice(name="a lot", value=3) + response_options.add_choice(name="very much", value=4) + + item = Item( + name="slider", + input_type="slider", + question="question for slider item", + output_dir=item_dir, + ) + item.set_input_type(response_options) + + item.write() + item_content, expected = load_jsons(item_dir, item) + assert item_content == expected + + clean_up(item_dir, item) + + +def test_item1(): + + additionalNotes = [ + AdditionalNoteObj( + column="notes", source="redcap", value="some extra note" + ).schema + ] + additionalNotes.append( + AdditionalNoteObj( + column="notes", + source="redcap", + value={"@id": "http://example.com/iri-example"}, + ).schema + ) + + response_options = ResponseOption(multipleChoice=False) + response_options.add_choice(name={"en": "Not at all", "es": "Para nada"}, value=0) + response_options.add_choice( + name={"en": "Several days", "es": "Varios días"}, value="a" + ) + response_options.add_choice( + name={"en": "More than half the days", "es": "Más de la mitad de los días"}, + value={"@id": "http://example.com/choice3"}, + ) + response_options.add_choice( + name={"en": "Nearly everyday", "es": "Casi todos los días"}, + value={"@value": "choice-with-lang", "@language": "en"}, + ) + + item = Item( + name="item1", + input_type="radio", + description="Q1 of example 1", + question="Little interest or pleasure in doing things", + image={ + "@type": "ImageObject", + "contentUrl": "http://example.com/sample-image.jpg", + }, + audio={ + "@type": "AudioObject", + "contentUrl": "http://media.freesound.org/sample-file.mp4", + }, + additionalNotesObj=additionalNotes, + read_only=None, + output_dir=item_dir, + ) + item.at_context = "../../../contexts/generic" + item.set_question(question="Poco interés o placer en hacer cosas", lang="es") + item.set_input_type(response_options=response_options) + + item.write() + + item_content, expected = load_jsons(item_dir, item) + assert item_content == expected + + clean_up(item_dir, item) + + +def test_item2(): + + item = Item( + name="item2", + input_type="float", + description="Q2 of example 1", + question="Current temperature.", + video={ + "@type": "VideoObject", + "contentUrl": "http://media.freesound.org/data/0/previews/719__elmomo__12oclock_girona_preview.mp4", + }, + read_only=None, + output_dir=item_dir, + ) + item.at_context = "../../../contexts/generic" + item.set_question(question="Fiebre actual.", lang="es") + + unitOption_0 = unitOption(value="°F", prefLabel="Fahrenheit") + unitOption_0.set_pref_label(pref_label="Fahrenheit", lang="es") + + unitOption_1 = unitOption(value="°C", prefLabel="Celsius") + unitOption_1.set_pref_label(pref_label="Celsius", lang="es") + + item.response_options.unitOptions = [unitOption_0.schema] + item.response_options.unitOptions.append(unitOption_1.schema) + item.set_response_options() + + item.write() + + item_content, expected = load_jsons(item_dir, item) + assert item_content == expected + + clean_up(item_dir, item) + + +""" +Just to check that item with read only values + +Tries to recreate the item from +reproschema/tests/data/activities/items/activity1_total_score +""" +my_path = Path(__file__).resolve().parent +reproschema_test_data = my_path.joinpath(my_path, "..", "..", "tests", "data") + + +def test_read_only(): + + item = Item( + name="activity1_total_score", + ext="", + input_type="integer", + read_only=True, + output_dir=item_dir, + ) + item.at_context = "../../../contexts/generic" + item.description = "Score item for Activity 1" + item.update() + item.set_filename("activity1_total_score") + item.set_pref_label("activity1_total_score") + item.response_options.set_max(3) + item.response_options.set_min(0) + item.unset(["question"]) + + item.write() + + output_file = os.path.join(item_dir, item.at_id) + item_content = read_json(output_file) + + # test against one of the pre existing files + data_file = os.path.join( + reproschema_test_data, "activities", "items", "activity1_total_score" + ) + expected = read_json(data_file) + assert item_content == expected + + clean_up(item_dir, item) diff --git a/reproschema/models/tests/test_protocol.py b/reproschema/models/tests/test_protocol.py new file mode 100644 index 0000000..40a69d9 --- /dev/null +++ b/reproschema/models/tests/test_protocol.py @@ -0,0 +1,114 @@ +import os +from pathlib import Path + +from reproschema.models.activity import Activity +from reproschema.models.base import Message +from reproschema.models.protocol import Protocol + +my_path = Path(__file__).resolve().parent + +from utils import load_jsons, clean_up, output_dir + +protocol_dir = output_dir("protocols") + + +def test_default(): + """ + FYI: The default protocol does not conform to the schema + so `reproschema validate` will complain if you run it in this + """ + + protocol = Protocol(name="default", output_dir=protocol_dir) + protocol.write() + + protocol_content, expected = load_jsons(protocol_dir, protocol) + assert protocol_content == expected + + clean_up(protocol_dir, protocol) + + +def test_protocol(): + + protocol = Protocol( + name="protocol1", + prefLabel="Protocol1", + lang="en", + description="example Protocol", + output_dir=protocol_dir, + ) + protocol.set_preamble(preamble="protocol1", lang="en") + protocol.set_landing_page(page="http://example.com/sample-readme.md") + protocol.ui.AutoAdvance = True + protocol.ui.AllowExport = True + protocol.ui.DisableBack = True + protocol.update() + + activity_1 = Activity( + name="activity1", + prefLabel="Screening", + lang="en", + output_dir=os.path.join("..", "activities"), + ) + + protocol.append_activity(activity_1) + + protocol.write() + + protocol_content, expected = load_jsons(protocol_dir, protocol) + assert protocol_content == expected + + clean_up(protocol_dir, protocol) + + +def test_protocol_1(): + + messages = [ + Message( + jsExpression="item1 > 0", + message="Test message: Triggered when item1 value is greater than 0", + ).schema + ] + + protocol = Protocol( + name="protocol1", + prefLabel="Protocol1", + lang="en", + description="example Protocol", + messages=messages, + output_dir=protocol_dir, + suffix="", + ) + protocol.set_pref_label(pref_label="Protocol1_es", lang="es") + protocol.set_landing_page(page="http://example.com/sample-readme.md", lang="en") + protocol.ui.AutoAdvance = True + protocol.ui.AllowExport = True + protocol.ui.DisableBack = True + protocol.at_context = "../../contexts/generic" + protocol.update() + + activity_1 = Activity( + name="activity1", + prefLabel="Screening", + limit="P1W/2020-08-01T13:00:00Z", + randomMaxDelay="PT12H", + schedule="R5/2008-01-01T13:00:00Z/P1Y2M10DT2H30M", + lang="en", + suffix="", + output_dir=os.path.join("..", "activities"), + visible=True, + skippable=False, + required=None, + ) + + activity_1.ui.shuffle = False + activity_1.update() + activity_1.set_pref_label(pref_label="Screening_es", lang="es") + + protocol.append_activity(activity_1) + + protocol.write() + + protocol_content, expected = load_jsons(protocol_dir, protocol) + assert protocol_content == expected + + clean_up(protocol_dir, protocol) diff --git a/reproschema/models/tests/test_response_options.py b/reproschema/models/tests/test_response_options.py new file mode 100644 index 0000000..574af14 --- /dev/null +++ b/reproschema/models/tests/test_response_options.py @@ -0,0 +1,62 @@ +from utils import clean_up +from utils import load_jsons +from utils import output_dir + +from reproschema.models.response_options import Choice +from reproschema.models.response_options import ResponseOption + + +response_options_dir = output_dir("response_options") + + +def test_choice(): + choice = Choice(name="Not at all", value=1) + assert choice.schema == { + "name": {"en": "Not at all"}, + "value": 1, + } + + +def test_example(): + + response_options = ResponseOption(output_dir=response_options_dir) + response_options.set_filename("example") + response_options.set_valueType("integer") + + response_options.add_choice(name="Not at all", value=0) + for i in range(1, 5): + response_options.add_choice(value=i) + response_options.add_choice(name="Completely", value=6) + + print(response_options.schema) + + response_options.write() + content, expected = load_jsons(response_options_dir, response_options) + assert content == expected + + clean_up(response_options_dir, response_options) + + +def test_default(): + + response_options = ResponseOption(output_dir=response_options_dir) + response_options.set_defaults() + + response_options.write() + content, expected = load_jsons(response_options_dir, response_options) + assert content == expected + + clean_up(response_options_dir, response_options) + + +def test_constructor_from_file(): + + response_options = ResponseOption.from_file( + response_options_dir.joinpath( + "..", "data", "response_options", "example.jsonld" + ) + ) + + assert response_options.at_id == "example.jsonld" + assert response_options.valueType == "xsd:integer" + assert response_options.choices[0]["name"] == {"en": "Not at all"} diff --git a/reproschema/models/tests/test_schema.py b/reproschema/models/tests/test_schema.py index a68e808..36059b6 100644 --- a/reproschema/models/tests/test_schema.py +++ b/reproschema/models/tests/test_schema.py @@ -1,4 +1,6 @@ -from .. import Protocol, Activity, Item +from reproschema.models import Activity +from reproschema.models import Item +from reproschema.models import Protocol def test_constructors(): @@ -6,11 +8,11 @@ def test_constructors(): Activity() Item() version = "1.0.0-rc2" - proto = Protocol(version=version) + proto = Protocol(schemaVersion=version) assert proto.schema["schemaVersion"] == version - act = Activity(version) + act = Activity(schemaVersion=version) assert act.schema["schemaVersion"] == version - item = Item(version) + item = Item(schemaVersion=version) assert item.schema["schemaVersion"] == version diff --git a/reproschema/models/tests/test_ui.py b/reproschema/models/tests/test_ui.py new file mode 100644 index 0000000..c195fc3 --- /dev/null +++ b/reproschema/models/tests/test_ui.py @@ -0,0 +1,20 @@ +from collections import OrderedDict + +from reproschema.models.ui import UI + + +def test_field(): + a = UI(at_type="reproschema:Field") + assert a.schema_order == ["inputType", "readonlyValue"] + print(a.schema) + assert a.schema == OrderedDict() + + +def test_protocol(): + a = UI(at_type="reproschema:Protocol", shuffle=False) + assert a.schema == OrderedDict( + { + "shuffle": False, + "allow": ["reproschema:AutoAdvance", "reproschema:AllowExport"], + } + ) diff --git a/reproschema/models/tests/utils.py b/reproschema/models/tests/utils.py new file mode 100644 index 0000000..550aec5 --- /dev/null +++ b/reproschema/models/tests/utils.py @@ -0,0 +1,32 @@ +import json +from pathlib import Path + +my_path = Path(__file__).resolve().parent + + +def load_jsons(dir, obj): + + output_file = Path(dir).joinpath(obj.at_id) + content = read_json(output_file) + + data_file = my_path.joinpath("data", Path(dir).name, obj.at_id) + expected = read_json(data_file) + + return content, expected + + +def read_json(file): + + with open(file, "r") as ff: + return json.load(ff) + + +def clean_up(dir, obj): + Path(dir).joinpath(obj.at_id).unlink() + + +def output_dir(dir): + value = my_path.joinpath(dir) + if not value.is_dir(): + value.mkdir(parents=True, exist_ok=True) + return value diff --git a/reproschema/models/ui.py b/reproschema/models/ui.py new file mode 100644 index 0000000..aba666c --- /dev/null +++ b/reproschema/models/ui.py @@ -0,0 +1,270 @@ +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Union + +from attrs import define +from attrs import field +from attrs.converters import default_if_none +from attrs.validators import in_ +from attrs.validators import instance_of +from attrs.validators import optional + +from .utils import SchemaUtils + + +@define( + kw_only=True, +) +class UI(SchemaUtils): + + #: this is more to help set up things on the UI side than purely schema related + SUPPORTED_INPUT_TYPES = ( + "text", + "multitext", + "number", + "float", + "date", + "time", + "timeRange", + "year", + "selectLanguage", + "selectCountry", + "selectState", + "email", + "pid", + "select", + "radio", + "slider", + ) + + at_type: str = field( + factory=str, + validator=in_( + [ + "reproschema:Protocol", + "reproschema:Activity", + "reproschema:Field", + "reproschema:ResponseOption", + ] + ), + ) + + shuffle: Optional[bool] = field( + default=None, + validator=optional(instance_of(bool)), + ) + + addProperties: Optional[List[Dict[str, Any]]] = field( + factory=(list), + validator=optional(instance_of(list)), + ) + + order: List[str] = field( + factory=(list), + validator=optional(instance_of(list)), + ) + + AutoAdvance: Optional[bool] = field( + default=None, + converter=default_if_none(default=True), # type: ignore + validator=optional(instance_of(bool)), + ) + + DisableBack: Optional[bool] = field( + default=None, + converter=default_if_none(default=False), # type: ignore + validator=optional(instance_of(bool)), + ) + + AllowExport: Optional[bool] = field( + default=None, + converter=default_if_none(default=True), # type: ignore + validator=optional(instance_of(bool)), + ) + + readonlyValue: Optional[bool] = field( + default=None, + validator=optional(instance_of(bool)), + ) + + inputType: Optional[str] = field( + default=None, validator=optional(in_(SUPPORTED_INPUT_TYPES)) + ) + + allow: Optional[List[str]] = field( + default=None, + converter=default_if_none(default=[]), # type: ignore + validator=optional(instance_of(list)), + ) + + def __attrs_post_init__(self) -> None: + + if self.schema_order in [None, []]: + self.schema_order = [ + "shuffle", + "order", + "addProperties", + "allow", + "limit", + "randomMaxDelay", + "schedule", + ] + if self.at_type == "reproschema:Field": + self.schema_order = ["inputType", "readonlyValue"] + + if self.at_type == "reproschema:ResponseOption": + self.AllowExport = False + self.AutoAdvance = False + + self.update() + + def append(self, obj, variableName: Optional[str] = None) -> None: + """Append Field or Activity to the UI schema. + + :param obj: _description_ + :type obj: _type_ + :param variableName: _description_, defaults to None + :type variableName: Optional[str], optional + """ + + this_property = AdditionalProperty( + variableName=variableName, + isAbout=obj.URI, + prefLabel=obj.prefLabel, + isVis=obj.visible, + requiredValue=obj.required, + skippable=obj.skippable, + limit=obj.limit, + randomMaxDelay=obj.randomMaxDelay, + schedule=obj.schedule, + ) + this_property.update() + this_property.sort_schema() + this_property.drop_empty_values_from_schema() + + self.order.append(obj.URI) + self.addProperties.append(this_property.schema) + self.update() + + def update(self) -> None: + + for attrib in ["DisableBack", "AutoAdvance", "AllowExport"]: + if ( + self.__getattribute__(attrib) + and f"reproschema:{attrib}" not in self.allow + ): + self.allow.append(f"reproschema:{attrib}") + elif ( + not self.__getattribute__(attrib) + and f"reproschema:{attrib}" in self.allow + ): + self.allow.remove(f"reproschema:{attrib}") + + if self.readonlyValue is not None: + self.schema["readonlyValue"] = self.readonlyValue + + self.schema["shuffle"] = self.shuffle + self.schema["order"] = self.order + self.schema["addProperties"] = self.addProperties + self.schema["allow"] = self.allow + + self.schema["inputType"] = self.inputType + + self.sort_schema() + + +@define( + kw_only=True, +) +class AdditionalProperty(SchemaUtils): + """An object to describe the various properties added to assessments and fields.""" + + #: The name used to represent an item. + variableName: Optional[str] = field( + default=None, + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + #: A pointer to the node describing the item. + isAbout: Optional[str] = field( + default=None, + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + #: The preferred label. + prefLabel: Optional[Union[str, Dict[str, str]]] = field( + default=None, + converter=default_if_none(default={}), # type: ignore + validator=optional(instance_of((str, dict))), + ) + #: An element to describe (by boolean or conditional statement) + # visibility conditions of items in an assessment. + isVis: Optional[Union[str, bool]] = field( + default=None, validator=optional(instance_of((bool, str))) + ) + #: Whether the property must be filled in to complete the action. + requiredValue: Optional[bool] = field( + default=None, + validator=optional(instance_of(bool)), + ) + #: List of items indicating properties allowed. + allow: Optional[List[str]] = field( + factory=list, + converter=default_if_none(default=[]), # type: ignore + validator=optional(instance_of(list)), + ) + #: An element to limit the duration (uses ISO 8601) + # this activity is allowed to be completed by once activity is available. + limit: Optional[str] = field( + default=None, + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + #: Defines number of times the item is allowed to be redone. + maxRetakes: Optional[str] = field( + default=None, + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + #: Present activity/item within some random offset of activity available time up + # to the maximum specified by this ISO 8601 duration + randomMaxDelay: Optional[str] = field( + default=None, + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + #: An element to set make activity available/repeat info using ISO 8601 repeating interval format. + schedule: Optional[str] = field( + default=None, + converter=default_if_none(default=""), # type: ignore + validator=optional(instance_of(str)), + ) + + skippable: Optional[bool] = field( + factory=(bool), + converter=default_if_none(default=True), # type: ignore + validator=optional(instance_of(bool)), + ) + + def __attrs_post_init__(self) -> None: + + if self.schema_order in [None, []]: + self.schema_order = [ + "variableName", + "isAbout", + "prefLabel", + "isVis", + "requiredValue", + "allow", + "limit", + "maxRetakes", + "randomMaxDelay", + "schedule", + ] + + def update(self) -> None: + if self.skippable is True: + self.allow = ["reproschema:Skipped"] + super().update() diff --git a/reproschema/models/utils.py b/reproschema/models/utils.py index 745ec4a..ba2b248 100644 --- a/reproschema/models/utils.py +++ b/reproschema/models/utils.py @@ -1,16 +1,112 @@ import json -from . import Protocol, Activity, Item - - -def load_schema(filepath): - with open(filepath) as fp: - data = json.load(fp) - if "@type" not in data: - raise ValueError("Missing @type key") - schema_type = data["@type"] - if schema_type == "reproschema:Protocol": - return Protocol.from_data(data) - if schema_type == "reproschema:Activity": - return Activity.from_data(data) - if schema_type == "reproschema:Item": - return Item.from_data(data) +from collections import OrderedDict +from pathlib import Path +from typing import Dict +from typing import List +from typing import Optional +from typing import Union + +from attrs import define +from attrs import field +from attrs.converters import default_if_none +from attrs.validators import instance_of +from attrs.validators import optional + + +def DEFAULT_LANG() -> str: + return "en" + + +@define(kw_only=True) +class SchemaUtils: + + #: specifies the order of keys in the output file + schema_order: Optional[list] = field( + factory=(list), + converter=default_if_none(default=[]), # type: ignore + validator=optional(instance_of(list)), + ) + #: contains the content that is read from or dumped in JSON + schema: Optional[dict] = field( + factory=(dict), + converter=default_if_none(default={}), # type: ignore + validator=optional(instance_of(dict)), + ) + #: default language for the schema + lang: Optional[str] = field( + default=None, + converter=default_if_none(default=DEFAULT_LANG()), # type: ignore + validator=optional(instance_of(str)), + ) + + def sort_schema(self) -> None: + if self.schema is None or self.schema_order is None: + return + reordered_dict = reorder_dict_skip_missing(self.schema, self.schema_order) + self.schema = reordered_dict + return reordered_dict + + def drop_empty_values_from_schema(self) -> None: + """Remove any empty keys from the schema to avoid cluttering output files.""" + tmp = dict(self.schema) + for key in tmp: + if self.schema[key] in [{}, [], "", None]: + self.schema.pop(key) + + def update(self): + """Updates the schema content based on the attributes.""" + + for key in self.schema_order: + if key.startswith("@"): + continue + self.schema[key] = self.__getattribute__(key) + + return self + + @classmethod + def from_data(cls, data: dict): + klass = cls() + if klass.at_type is None: + raise ValueError("Base class cannot be used to instantiate") + if klass.at_type != data["@type"]: + raise ValueError(f"Mismatch in type {data['@type']} != {klass.at_type}") + klass.schema = data + """Load values into instance""" + for key in klass.schema: + if key.startswith("@"): + klass.__setattr__(f"at_{key[1:]}", klass.schema[key]) + else: + klass.__setattr__(key, klass.schema[key]) + return klass + + @classmethod + def from_file(cls, filepath: Union[str, Path]): + with open(filepath) as fp: + data = json.load(fp) + if "@type" not in data: + raise ValueError("Missing @type key") + return cls.from_data(data) + + +def reorder_dict_skip_missing(old_dict: Dict, key_list: List) -> OrderedDict: + """Reorders dictionary according to ``key_list``. + + Removing any key with no associated value, or that is not in ``key_list`` + + This is useful to ensure that order of keys in output files is the same across files. + + :param old_dict: _description_ + :type old_dict: Dict + + :param key_list: _description_ + :type key_list: List + + :return: _description_ + :rtype: OrderedDict + """ + + return OrderedDict( + (k, old_dict[k]) + for k in key_list + if (k in old_dict and old_dict[k] not in ["", [], None]) + ) diff --git a/reproschema/tests/__init__.py b/reproschema/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/reproschema/tests/data/activities/activity1.jsonld b/reproschema/tests/data/activities/activity1.jsonld index 6a533ee..72d9e0e 100644 --- a/reproschema/tests/data/activities/activity1.jsonld +++ b/reproschema/tests/data/activities/activity1.jsonld @@ -17,8 +17,8 @@ }, "compute": [ { - "variableName": "activity1_total_score", - "jsExpression": "item1 + item2" + "variableName": "phq9_total_score", + "jsExpression": "phq9_1 + phq9_2 + phq9_3 + phq9_4 + phq9_5 + phq9_6 + phq9_7 + phq9_8 + phq9_9" } ], "messages": [ @@ -51,8 +51,7 @@ ], "order": [ "items/item1.jsonld", - "items/item2.jsonld", - "items/activity1_total_score" + "items/item2.jsonld" ], "shuffle": false } diff --git a/reproschema/tests/data/activities/items/activity1_total_score b/reproschema/tests/data/activities/items/activity1_total_score index 5ed7f84..150d9c0 100644 --- a/reproschema/tests/data/activities/items/activity1_total_score +++ b/reproschema/tests/data/activities/items/activity1_total_score @@ -2,7 +2,9 @@ "@context": "../../../contexts/generic", "@type": "reproschema:Field", "@id": "activity1_total_score", - "prefLabel": "activity1_total_score", + "prefLabel": { + "en": "activity1_total_score" + }, "description": "Score item for Activity 1", "schemaVersion": "1.0.0-rc4", "version": "0.0.1", diff --git a/reproschema/tests/data/activities/items/item1.jsonld b/reproschema/tests/data/activities/items/item1.jsonld index 50f73cc..9210612 100644 --- a/reproschema/tests/data/activities/items/item1.jsonld +++ b/reproschema/tests/data/activities/items/item1.jsonld @@ -69,5 +69,4 @@ "value": {"@id": "http://example.com/iri-example"} } ] - } diff --git a/reproschema/tests/test_convert.py b/reproschema/tests/test_convert.py index dd59f21..a3ee2e7 100644 --- a/reproschema/tests/test_convert.py +++ b/reproschema/tests/test_convert.py @@ -1,7 +1,9 @@ import os -from ..jsonldutils import to_newformat + import pytest +from reproschema.jsonldutils import to_newformat + @pytest.fixture def filename(): diff --git a/reproschema/tests/test_validate.py b/reproschema/tests/test_validate.py index 96e40db..e1e3576 100644 --- a/reproschema/tests/test_validate.py +++ b/reproschema/tests/test_validate.py @@ -1,7 +1,10 @@ import os -from ..validate import validate_dir, validate + import pytest +from reproschema.validate import validate +from reproschema.validate import validate_dir + def test_validate(): os.chdir(os.path.dirname(__file__)) diff --git a/reproschema/utils.py b/reproschema/utils.py index 2f85d1a..69ff859 100644 --- a/reproschema/utils.py +++ b/reproschema/utils.py @@ -1,7 +1,9 @@ import os import threading -from http.server import HTTPServer, SimpleHTTPRequestHandler +from http.server import HTTPServer +from http.server import SimpleHTTPRequestHandler from tempfile import mkdtemp + import requests import requests_cache diff --git a/reproschema/validate.py b/reproschema/validate.py index 64b612e..85a0e83 100644 --- a/reproschema/validate.py +++ b/reproschema/validate.py @@ -1,6 +1,10 @@ import os -from .utils import start_server, stop_server, lgr -from .jsonldutils import load_file, validate_data + +from .jsonldutils import load_file +from .jsonldutils import validate_data +from .utils import lgr +from .utils import start_server +from .utils import stop_server def validate_dir(directory, shape_file, started=False, http_kwargs={}): diff --git a/setup.cfg b/setup.cfg index ef22d32..c8a2980 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,6 +31,7 @@ install_requires = PyLD requests requests_cache + attrs >=22.0.0 test_requires = pytest >= 4.4.0 @@ -55,6 +56,8 @@ doc = sphinxcontrib-apidoc ~= 0.3.0 sphinxcontrib-napoleon sphinxcontrib-versioning + sphinxcontrib-mermaid + myst-parser docs = %(doc)s test = @@ -70,6 +73,7 @@ dev = %(test)s black pre-commit + mypy all = %(doc)s %(dev)s diff --git a/setup.py b/setup.py index 9a2b714..02bf7da 100755 --- a/setup.py +++ b/setup.py @@ -1,11 +1,13 @@ #!/usr/bin/env python # emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*- # vi: set ft=python sts=4 ts=4 sw=4 et: -"""Pydra: Dataflow Engine +"""Reproschema """ import sys + from setuptools import setup + import versioneer # Give setuptools a hint to complain if it's too old a version diff --git a/versioneer.py b/versioneer.py index 2b54540..b3a14fb 100644 --- a/versioneer.py +++ b/versioneer.py @@ -1,5 +1,4 @@ # Version: 0.18 - """The Versioneer - like a rocketeer, but for versions. The Versioneer @@ -274,7 +273,6 @@ https://creativecommons.org/publicdomain/zero/1.0/ . """ - from __future__ import print_function try: