diff --git a/.github/workflows/realapps.yml b/.github/workflows/realapps.yml index 2de63d7..4dfaad6 100644 --- a/.github/workflows/realapps.yml +++ b/.github/workflows/realapps.yml @@ -71,8 +71,14 @@ jobs: done - name: Validate the generated crate + # rocrate-validator needs rich<14, which current Flower cannot share, so it + # gets its own virtual environment. The federation has finished by now. + # offline=True only reads the validator's cache, so fill it first. run: | - python tests/e2e/validate_crate.py \ + python -m venv /tmp/validator + /tmp/validator/bin/pip install "roc-validator>=0.13" + /tmp/validator/bin/rocrate-validator cache warm -p ro-crate-1.2 -p process-run-crate-0.5 + /tmp/validator/bin/python tests/e2e/validate_crate.py \ /tmp/flcrate_out/ro-crate/ro-crate-metadata.json \ "${{ matrix.strategy }}" "${{ matrix.framework }}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f950efa..807ce61 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,8 +28,13 @@ jobs: # works without a Flower install. run: | python -m pip install --upgrade pip - pip install pytest pytest-cov "rocrate>=0.13" "tomli>=2.0; python_version < '3.11'" + pip install pytest pytest-cov "rocrate>=0.15" "roc-validator>=0.13" "tomli>=2.0; python_version < '3.11'" pip install -e . --no-deps + + - name: Cache the validator's profile resources + # The tests validate with offline=True, which only reads rocrate-validator's + # cache. A fresh runner's cache is empty, so fill it first. + run: rocrate-validator cache warm -p ro-crate-1.2 -p process-run-crate-0.5 - name: Run tests with coverage run: pytest -q --cov=flwrcrate --cov-report=term-missing --cov-fail-under=85 diff --git a/README.md b/README.md index 6b85616..d479c1b 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ grep -rn "flwr.serverapp\|strategy.start\|server_fn\|ServerAppComponents" / - `flwr.serverapp` **and** `strategy.start` → ✅ compatible - `server_fn` or `ServerAppComponents` → ❌ classic API, not supported (yet) -Other requirements: Python ≥ 3.11, `rocrate ≥ 0.13`. +Other requirements: Python ≥ 3.11, `rocrate ≥ 0.15`. ## Install @@ -243,11 +243,15 @@ series. ### Validation -Crates validate against the official -[`rocrate-validator`](https://github.com/crs4/rocrate-validator). Note: -`ro-crate-py` ≥ 0.15 writes RO-Crate **1.2**, while `rocrate-validator` 0.10 -ships only a **1.1** profile, producing a single false-positive `MUST 5.3` -(`conformsTo` version). All other REQUIRED checks pass. +Crates declare and validate against **RO-Crate 1.2**: flwrCrate sets the +version explicitly, so the output does not depend on which `ro-crate-py` is +installed. The test suite and the end-to-end workflow check generated crates +with [`rocrate-validator`](https://github.com/crs4/rocrate-validator) at the +REQUIRED level, against the `ro-crate-1.2` and `process-run-crate-0.5` +profiles. Process Run Crate 0.5 is based on RO-Crate 1.1, so its inherited +checks are not reported; the RO-Crate 1.2 check covers the base rules. +The validator's opt-in extra checks are enabled too; they flag likely mistakes, +such as a plain string where a link to another entity was meant. ## Tested with @@ -294,7 +298,8 @@ it produced. ## Development ```bash -pip install -e ".[dev]" # installs pytest + pytest-cov +pip install -e ".[dev]" # installs pytest + pytest-cov and roc-validator +rocrate-validator cache warm -p ro-crate-1.2 -p process-run-crate-0.5 # once, for the validation tests pytest # run the full unit + integration suite pytest --cov=flwrcrate --cov-report=term-missing # with a coverage report ``` diff --git a/flwrcrate/crate_builder.py b/flwrcrate/crate_builder.py index f01322d..9c0ffc0 100644 --- a/flwrcrate/crate_builder.py +++ b/flwrcrate/crate_builder.py @@ -56,7 +56,7 @@ def build_crate(captured: dict, crate_dir, metrics_log_path=None, model_path=Non """Assemble and write an RO-Crate. Returns the crate directory path.""" uri_map = uri_map or {} crate_dir = Path(crate_dir) - crate = ROCrate() + crate = ROCrate(version="1.2") crate.name = captured.get("app_name") or "Federated learning run" crate.description = ( diff --git a/poetry.lock b/poetry.lock index ac78d6a..8703d51 100644 --- a/poetry.lock +++ b/poetry.lock @@ -32,6 +32,47 @@ files = [ {file = "arcp-0.2.1.tar.gz", hash = "sha256:5c17ac7972c9ef82979cc2caf2b3a87c1aefd3fefe9adb8a5dd728ada57715dd"}, ] +[[package]] +name = "attrs" +version = "26.1.0" +description = "Classes Without Boilerplate" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, +] + +[[package]] +name = "cattrs" +version = "26.2.1" +description = "Composable complex class support for attrs and dataclasses." +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "cattrs-26.2.1-py3-none-any.whl", hash = "sha256:a12aaa3453dc8f633a815293179f08b7421ed18d2575c459c3c736f840beac24"}, + {file = "cattrs-26.2.1.tar.gz", hash = "sha256:679132bfdc225c5ee40c024fc42519954767c387f950dc6751946c586bccdc6d"}, +] + +[package.dependencies] +attrs = ">=25.4.0" +typing-extensions = ">=4.14.0" + +[package.extras] +bson = ["pymongo (>=4.4.0)"] +cbor2 = ["cbor2 (>=5.4.6)"] +msgpack = ["msgpack (>=1.0.5)"] +msgspec = ["msgspec (>=0.21.1) ; implementation_name == \"cpython\""] +orjson = ["orjson (>=3.11.3) ; implementation_name == \"cpython\""] +pyyaml = ["pyyaml (>=6.0)"] +tomlkit = ["tomlkit (>=0.11.8)"] +tomllib = ["tomli (>=1.1.0) ; python_version < \"3.11\"", "tomli-w (>=1.1.0)"] +ujson = ["ujson (>=5.10.0)"] + [[package]] name = "certifi" version = "2026.6.17" @@ -309,6 +350,25 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "colorlog" +version = "6.12.0" +description = "Add colours to the output of Python's logging module." +optional = true +python-versions = ">=3.6" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e"}, + {file = "colorlog-6.12.0.tar.gz", hash = "sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +development = ["black", "flake8", "mypy", "pytest", "types-colorama"] + [[package]] name = "coverage" version = "7.14.3" @@ -486,16 +546,37 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "enum-tools" +version = "0.12.0" +description = "Tools to expand Python's enum module." +optional = true +python-versions = ">=3.6" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "enum_tools-0.12.0-py3-none-any.whl", hash = "sha256:d69b019f193c7b850b17d9ce18440db7ed62381571409af80ccc08c5218b340a"}, + {file = "enum_tools-0.12.0.tar.gz", hash = "sha256:13ceb9376a4c5f574a1e7c5f9c8eb7f3d3fbfbb361cc18a738df1a58dfefd460"}, +] + +[package.dependencies] +pygments = ">=2.6.1" +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["sphinx (>=3.4.0)", "sphinx-jinja2-compat (>=0.1.1)", "sphinx-toolbox (>=2.16.0)"] +sphinx = ["sphinx (>=3.4.0)", "sphinx-jinja2-compat (>=0.1.1)", "sphinx-toolbox (>=2.16.0)"] + [[package]] name = "flwr" -version = "1.32.0" +version = "1.31.0" description = "Flower: A Friendly Federated AI Framework" optional = false python-versions = "<4.0,>=3.11" groups = ["main"] files = [ - {file = "flwr-1.32.0-py3-none-any.whl", hash = "sha256:3f0a5e625f6d18bef54c9780f87d2b55e3781a1d8cbdf413c7a4cc0583cd0e86"}, - {file = "flwr-1.32.0.tar.gz", hash = "sha256:78e7fb56ef7b21960ff0751be744bdd2ad82f80f31758e923648dbf9339054f7"}, + {file = "flwr-1.31.0-py3-none-any.whl", hash = "sha256:2582d4408671fefe98f672a8eb8b31437a41f78f250c40343499727324ba3feb"}, + {file = "flwr-1.31.0.tar.gz", hash = "sha256:5c1dffd4dad442094de163ffb94fb41543e93d8ca388d0962db7b65956de825d"}, ] [package.dependencies] @@ -512,7 +593,7 @@ protobuf = ">=5.28.0,<7.0.0" pycryptodome = ">=3.18.0,<4.0.0" pyyaml = ">=6.0.2,<7.0.0" requests = ">=2.33.0,<3.0.0" -rich = ">=14.0.0,<15.0.0" +rich = ">=13.5.0,<14.0.0" sqlalchemy = ">=2.0.45,<3.0.0" tomli = ">=2.0.1,<3.0.0" tomli-w = ">=1.0.0,<2.0.0" @@ -520,7 +601,6 @@ typer = ">=0.13.0,<0.21.0" uv = ">=0.11.15,<0.12.0" [package.extras] -agent = ["browser-use[core] (>=0.13.1,<0.14.0)", "trafilatura (>=2.1.0,<3.0.0)"] rest = ["starlette (>=0.50.0,<0.51.0)", "uvicorn[standard] (>=0.40.0,<0.41.0)"] simulation = ["ray (==2.55.1) ; python_full_version >= \"3.11.0\" and python_full_version < \"3.13.0\"", "ray (==2.55.1) ; python_full_version >= \"3.13.0\" and python_full_version < \"3.15.0\" and sys_platform != \"win32\""] @@ -701,6 +781,19 @@ files = [ grpcio = ">=1.81.1" protobuf = ">=6.33.5,<7.0.0" +[[package]] +name = "html5rdf" +version = "1.2.1" +description = "HTML parser based on the WHATWG HTML specification" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "html5rdf-1.2.1-py2.py3-none-any.whl", hash = "sha256:1f519121bc366af3e485310dc8041d2e86e5173c1a320fac3dc9d2604069b83e"}, + {file = "html5rdf-1.2.1.tar.gz", hash = "sha256:ace9b420ce52995bb4f05e7425eedf19e433c981dfe7a831ab391e2fa2e1a195"}, +] + [[package]] name = "idna" version = "3.18" @@ -716,6 +809,31 @@ files = [ [package.extras] all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "importlib-metadata" +version = "9.0.1" +description = "Read metadata from Python packages" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\" and python_version == \"3.11\"" +files = [ + {file = "importlib_metadata-9.0.1-py3-none-any.whl", hash = "sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0"}, + {file = "importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.17)"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [[package]] name = "iniconfig" version = "2.3.0" @@ -729,6 +847,26 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "inquirerpy" +version = "0.3.4" +description = "Python port of Inquirer.js (A collection of common interactive command-line user interfaces)" +optional = true +python-versions = ">=3.7,<4.0" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4"}, + {file = "InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e"}, +] + +[package.dependencies] +pfzy = ">=0.3.1,<0.4.0" +prompt-toolkit = ">=3.0.1,<4.0.0" + +[package.extras] +docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"] + [[package]] name = "iterators" version = "0.0.2" @@ -996,6 +1134,25 @@ files = [ {file = "numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda"}, ] +[[package]] +name = "owlrl" +version = "7.6.2" +description = "A simple implementation of the OWL2 RL Profile, as well as a basic RDFS inference, on top of RDFLib. Based mechanical forward chaining." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "owlrl-7.6.2-py3-none-any.whl", hash = "sha256:83347bf7f133979e87b2b18695d51d25510b99cec3f6919b5df05d4fbf058ae0"}, + {file = "owlrl-7.6.2.tar.gz", hash = "sha256:c743f35c2d908396e77823852bb1ebbce88340cd49961493983bec42c93283a8"}, +] + +[package.dependencies] +rdflib = ">=7.6.0" + +[package.extras] +oxigraph = ["pyoxigraph (>=0.5.6)"] + [[package]] name = "packaging" version = "25.0" @@ -1020,6 +1177,35 @@ files = [ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] +[[package]] +name = "pfzy" +version = "0.3.4" +description = "Python port of the fzy fuzzy string matching algorithm" +optional = true +python-versions = ">=3.7,<4.0" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96"}, + {file = "pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1"}, +] + +[package.extras] +docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"] + +[[package]] +name = "platformdirs" +version = "4.12.4" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7"}, + {file = "platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250"}, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1037,6 +1223,41 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "prettytable" +version = "3.18.0" +description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "prettytable-3.18.0-py3-none-any.whl", hash = "sha256:b3346e0e6f79180833aebaac088ae926340586cf6d7d991b9eb125b65f72313a"}, + {file = "prettytable-3.18.0.tar.gz", hash = "sha256:439217116152244369caf3d9f1caf2f9fe29b03bd79e88d2928c8e718c95d680"}, +] + +[package.dependencies] +wcwidth = ">=0.3.5" + +[package.extras] +tests = ["pytest (>=9)", "pytest-cov", "pytest-lazy-fixtures"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +description = "Library for building powerful interactive command lines in Python" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2"}, + {file = "prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6"}, +] + +[package.dependencies] +wcwidth = ">=0.1.4" + [[package]] name = "protobuf" version = "6.33.6" @@ -1136,6 +1357,53 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyparsing" +version = "3.3.3" +description = "pyparsing - Classes and methods to define and execute parsing grammars" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4"}, + {file = "pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "pyshacl" +version = "0.40.1" +description = "Python SHACL Validator" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pyshacl-0.40.1-py3-none-any.whl", hash = "sha256:27dd58c8ddfa103303b4a8c40b2c666332ffc912dbcd3137f7adc7b7bc5e6bda"}, + {file = "pyshacl-0.40.1.tar.gz", hash = "sha256:011e3cf1a68b31747cb762ba3d755ae1bdcc464c8fad0dc212a9adc550719552"}, +] + +[package.dependencies] +importlib-metadata = {version = ">6", markers = "python_version < \"3.12\""} +owlrl = ">=7.6.2,<8" +packaging = ">=21.3" +prettytable = [ + {version = ">=3.5.0", markers = "python_version < \"3.12\""}, + {version = ">=3.7.0", markers = "python_version >= \"3.12\""}, +] +rdflib = {version = ">=7.3.0,<8.0", extras = ["html"]} + +[package.extras] +dev-coverage = ["coverage (>6.1,!=6.1.1,<7)", "platformdirs", "pytest-cov (>=2.8.1,<3)"] +dev-lint = ["platformdirs", "ruff (>=0.9.3,<0.10)"] +dev-type-checking = ["mypy (>=1.13.0)", "platformdirs", "types-setuptools"] +http = ["sanic (>=22.12,<23)", "sanic-cors (==2.2.0)", "sanic-ext (>=23.3,<23.6)"] +js = ["pyduktape2 (>=0.4.6,<1) ; python_version <= \"3.13\"", "pyduktape2 (>=0.5.0,<1) ; python_version >= \"3.13\""] +oxigraph = ["pyoxigraph (>=0.5.6)"] + [[package]] name = "pytest" version = "9.1.1" @@ -1278,6 +1546,32 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "rdflib" +version = "7.6.0" +description = "RDFLib is a Python library for working with RDF, a simple yet powerful language for representing information." +optional = true +python-versions = ">=3.8.1" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd"}, + {file = "rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df"}, +] + +[package.dependencies] +html5rdf = {version = ">=1.2,<2", optional = true, markers = "extra == \"html\""} +pyparsing = ">=2.1.0,<4" + +[package.extras] +berkeleydb = ["berkeleydb (>=18.1.0,<19.0.0)"] +graphdb = ["httpx (>=0.28.1,<0.29.0)"] +html = ["html5rdf (>=1.2,<2)"] +lxml = ["lxml (>=4.3,<6.0)"] +networkx = ["networkx (>=2,<4)"] +orjson = ["orjson (>=3.9.14,<4)"] +rdf4j = ["httpx (>=0.28.1,<0.29.0)"] + [[package]] name = "requests" version = "2.34.2" @@ -1300,16 +1594,45 @@ urllib3 = ">=1.26,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] +[[package]] +name = "requests-cache" +version = "1.3.3" +description = "A persistent cache for python requests" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "requests_cache-1.3.3-py3-none-any.whl", hash = "sha256:c8df20ff874ebfc026959e3874e6c12bd6724934cdb10925915908453d4b17e4"}, + {file = "requests_cache-1.3.3.tar.gz", hash = "sha256:79b72d5ac5143992d1836ad78f4d8e65666061dd44e220548caab3723089826b"}, +] + +[package.dependencies] +attrs = ">=21.2" +cattrs = ">=22.2" +platformdirs = ">=2.5" +requests = ">=2.22" +url-normalize = ">=2.0" +urllib3 = ">=1.25.5" + +[package.extras] +all = ["boto3 (>=1.15)", "botocore (>=1.18)", "itsdangerous (>=2.0)", "orjson (>=3.0) ; python_version < \"3.14\"", "pymongo (>=3)", "pyyaml (>=6.0.1)", "redis (>=3)", "ujson (>=5.4)"] +dynamodb = ["boto3 (>=1.15)", "botocore (>=1.18)"] +mongodb = ["pymongo (>=3)"] +redis = ["redis (>=3)"] +security = ["itsdangerous (>=2.0)"] +yaml = ["pyyaml (>=6.0.1)"] + [[package]] name = "rich" -version = "14.3.4" +version = "13.9.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" groups = ["main"] files = [ - {file = "rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952"}, - {file = "rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9"}, + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, ] [package.dependencies] @@ -1319,6 +1642,50 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "rich-click" +version = "1.9.9" +description = "Format click help output nicely with rich" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "rich_click-1.9.9-py3-none-any.whl", hash = "sha256:365e7a9d0adb42e41ea832a0a12e02c44c079a26536dee688125eb9814f97274"}, + {file = "rich_click-1.9.9.tar.gz", hash = "sha256:324cba7513cd4187ee92b2eef21f071714e45be062458c8b157bd7e0c81103e3"}, +] + +[package.dependencies] +click = ">=8" +colorama = {version = "*", markers = "platform_system == \"Windows\""} +rich = ">=12" + +[[package]] +name = "roc-validator" +version = "0.13.0" +description = "A Python package to validate RO-Crates" +optional = true +python-versions = "<4.0,>=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "roc_validator-0.13.0-py3-none-any.whl", hash = "sha256:1d27d8f62f376d8c0ee3701a433acbf27355e1f42748e1c5aeb1bd0b9e10e9ac"}, + {file = "roc_validator-0.13.0.tar.gz", hash = "sha256:e56fee490df014370c84a9f5741226d0c0f0b71925cf4a921a69a7596d6ef200"}, +] + +[package.dependencies] +click = ">=8.2,<9.0" +colorlog = ">=6.9,<7.0" +enum-tools = ">=0.12,<0.13" +inquirerpy = ">=0.3.4,<0.4.0" +pyshacl = ">=0.26" +rdflib = ">=7.1,<8.0" +requests = ">=2.32,<3.0" +requests-cache = ">=1.2,<2.0" +rich = ">=13.9,<14.0" +rich-click = ">=1.8,<2.0" +toml = ">=0.10.2,<1.0" + [[package]] name = "rocrate" version = "0.15.0" @@ -1462,6 +1829,19 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +optional = true +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + [[package]] name = "tomli" version = "2.4.1" @@ -1561,6 +1941,25 @@ files = [ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +[[package]] +name = "url-normalize" +version = "3.0.1" +description = "URL normalization for Python" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "url_normalize-3.0.1-py3-none-any.whl", hash = "sha256:97ea68fc543b1fc9f270f34c90cf164453e7d490da2ec653dcd8ebd4e3ac1faf"}, + {file = "url_normalize-3.0.1.tar.gz", hash = "sha256:1655cd214159d9d47dc37aa6ce993c2149da44fa35cac6bafd90036a4eda3ac3"}, +] + +[package.dependencies] +idna = ">=3.3" + +[package.extras] +dev = ["mypy", "pre-commit", "pytest", "pytest-cov", "pytest-socket", "ruff"] + [[package]] name = "urllib3" version = "2.7.0" @@ -1608,10 +2007,71 @@ files = [ {file = "uv-0.11.26.tar.gz", hash = "sha256:2a433ece2ace088dd572d8abb0e6bd9a4ecb0e10bc9856447bbb37545f384f29"}, ] +[[package]] +name = "wcwidth" +version = "0.9.2" +description = "Measures the displayed width of unicode strings in a terminal" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "wcwidth-0.9.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07"}, + {file = "wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17"}, + {file = "wcwidth-0.9.2-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79"}, + {file = "wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724"}, + {file = "wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389"}, + {file = "wcwidth-0.9.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7"}, + {file = "wcwidth-0.9.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2"}, + {file = "wcwidth-0.9.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04"}, + {file = "wcwidth-0.9.2-cp310-abi3-win32.whl", hash = "sha256:ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4"}, + {file = "wcwidth-0.9.2-cp310-abi3-win_amd64.whl", hash = "sha256:2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec"}, + {file = "wcwidth-0.9.2-cp310-abi3-win_arm64.whl", hash = "sha256:b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa"}, + {file = "wcwidth-0.9.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eda88ffdc97c0fbf193d407114f2c7a54b379f67f6e52a7531ee3b9fe749eca7"}, + {file = "wcwidth-0.9.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf361c8705576760623b4724ae564666d73b016f9a778bcfd1c7345378ef4ec"}, + {file = "wcwidth-0.9.2-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:97b878d1e158da5ed9ac5aac53fa3a55e282103af6a09ec353865613d1a31a76"}, + {file = "wcwidth-0.9.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:59dab4049cbd982b478bca098528df2c79a9160636a3a163ffebffcbd7d1b892"}, + {file = "wcwidth-0.9.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb08ceb501d6aaf94066c3ee122dd825b152df40ff0bd0df4dc27126233b948e"}, + {file = "wcwidth-0.9.2-cp314-cp314t-win32.whl", hash = "sha256:8b4e381590b9b7390e07e22b2c0c1bb96ce50e1d2243c866d9387600362d51ed"}, + {file = "wcwidth-0.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2f7b3bba5a5d5f31fc350fd36ce5b84b693c83b7eb95ee630b720da5a5ce06f"}, + {file = "wcwidth-0.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:734aa9405b321d1042301aa19c943c4731ee9e3460e4f8feea3299c064c97a14"}, + {file = "wcwidth-0.9.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724"}, + {file = "wcwidth-0.9.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2"}, + {file = "wcwidth-0.9.2-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c"}, + {file = "wcwidth-0.9.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d"}, + {file = "wcwidth-0.9.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270"}, + {file = "wcwidth-0.9.2-cp315-cp315t-win32.whl", hash = "sha256:0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b"}, + {file = "wcwidth-0.9.2-cp315-cp315t-win_amd64.whl", hash = "sha256:33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9"}, + {file = "wcwidth-0.9.2-cp315-cp315t-win_arm64.whl", hash = "sha256:48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8"}, + {file = "wcwidth-0.9.2-py3-none-any.whl", hash = "sha256:89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e"}, + {file = "wcwidth-0.9.2.tar.gz", hash = "sha256:ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b"}, +] + +[[package]] +name = "zipp" +version = "4.1.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\" and python_version == \"3.11\"" +files = [ + {file = "zipp-4.1.1-py3-none-any.whl", hash = "sha256:8979f52d874162f485ff2981e3891f3a3317b7a3dd43ff1e1775b9304f307a9c"}, + {file = "zipp-4.1.1.tar.gz", hash = "sha256:7ebb7a44c021b29fd8dbd7cce6812d0d7b5b454521f93cc71af6ccd155aaa70b"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [extras] -dev = ["pytest", "pytest-cov"] +dev = ["pytest", "pytest-cov", "roc-validator"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<4.0" -content-hash = "1c5e8aa5ed88a7791c9dce76102b62f1232ad8ab88e7d691be49ba5c3c266bc1" +content-hash = "b1ccb28740297fafece0f9b321b3f8a23f4f754b7abee9a57cbb9fdfe2e9d434" diff --git a/pyproject.toml b/pyproject.toml index 21f114a..0c3e7ee 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,11 +13,11 @@ maintainers = [{ name = "Ali Feizollah", email = "faizollah2005@yahoo.com" }] requires-python = ">=3.11,<4.0" dependencies = [ "flwr>=1.29.0", - "rocrate>=0.13", + "rocrate>=0.15", ] [project.optional-dependencies] -dev = ["pytest>=7.0", "pytest-cov>=4.0"] +dev = ["pytest>=7.0", "pytest-cov>=4.0", "roc-validator>=0.13"] [project.urls] Homepage = "https://github.com/eScienceLab/flwrCrate" diff --git a/tests/e2e/validate_crate.py b/tests/e2e/validate_crate.py index a6730b3..2a5b357 100644 --- a/tests/e2e/validate_crate.py +++ b/tests/e2e/validate_crate.py @@ -1,11 +1,31 @@ """Validate a real generated RO-Crate for the e2e workflow. Usage: python validate_crate.py -Exits non-zero (failing the CI job) if the crate is missing or incomplete. +Exits non-zero (failing the CI job) if the crate is missing or incomplete, or if +rocrate-validator reports it is not valid RO-Crate 1.2 or Process Run Crate. +Needs rocrate-validator installed (see realapps.yml). """ import json import sys +from pathlib import Path + +from rocrate_validator import models, services + + +def validate(crate_dir, profile, **settings): + """Validate at REQUIRED level, offline; return the failures as readable lines.""" + result = services.validate(services.ValidationSettings( + rocrate_uri=str(crate_dir), + profile_identifier=profile, + requirement_severity=models.Severity.REQUIRED, + offline=True, + extra_checks=True, + **settings, + )) + issues = [f"{i.check.identifier}: {i.message}" for i in result.get_issues()] + issues += [f"{e.identifier} (extra): {e.message}" for e in result.extra_checks] + return issues def main(): @@ -19,6 +39,13 @@ def main(): sys.exit(f"FAIL: crate not found at {path} (did the run produce one?)") g = {e["@id"]: e for e in graph} + crate_dir = Path(path).parent + ro_crate_issues = validate(crate_dir, "ro-crate-1.2") + # Process Run Crate 0.5 inherits RO-Crate 1.1 checks that clash with a 1.2 + # crate; the RO-Crate 1.2 check above already covers the base rules. + process_run_issues = validate(crate_dir, "process-run-crate-0.5", + disable_inherited_profiles_issue_reporting=True) + checks = { "root mentions the run (#1)": g["./"].get("mentions") == [{"@id": "#fl-run"}], "run completed": g.get("#fl-run", {}).get("actionStatus", {}).get("@id", "").endswith("CompletedActionStatus"), @@ -29,11 +56,15 @@ def main(): "license + author + agent (#5)": all([ g["./"].get("license"), g["./"].get("author"), g.get("#fl-run", {}).get("agent"), ]), + "valid RO-Crate 1.2 (rocrate-validator)": not ro_crate_issues, + "satisfies Process Run Crate (rocrate-validator)": not process_run_issues, } failed = [name for name, ok in checks.items() if not ok] for name, ok in checks.items(): print(f" [{'PASS' if ok else 'FAIL'}] {name}") + for issue in ro_crate_issues + process_run_issues: + print(f" {issue}") if failed: sys.exit(f"FAIL: {len(failed)} check(s) failed: {failed}") print(f"OK: valid end-to-end crate ({expected_strategy} / {expected_fw}).") diff --git a/tests/test_integration.py b/tests/test_integration.py index feb3d9a..af16ef4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -46,7 +46,7 @@ def test_full_lifecycle_produces_complete_crate( assert g["#fl-strategy"]["name"] == "FedAvg" # #2 assert any(i["@id"] == "metrics_log.json" for i in g["#fl-run"]["result"]) # #3 assert "#framework-torch" in g # #4 - assert g["./"]["license"][0]["@id"] == "http://spdx.org/licenses/MIT" # #5 # #5 + assert g["./"]["license"][0]["@id"] == "http://spdx.org/licenses/MIT" # #5 assert g["#fl-run"]["agent"]["@id"].endswith("0000-0001") # result-side capture happened (the record_result path) diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..71e9585 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,61 @@ +""" +Checks that the crates flwrCrate writes are valid RO-Crate 1.2 and satisfy the +Process Run Crate profile. Validation runs offline and reads only +rocrate-validator's cache, so fill it once before running the tests: +rocrate-validator cache warm -p ro-crate-1.2 -p process-run-crate-0.5 +""" + +from rocrate_validator import models, services + +from flwrcrate.crate_builder import build_crate +from tests.test_crate_builder import _minimal_capture + +AUTHOR = {"name": "john", "orcid": "https://orcid.org/0000-0000-0000-0001"} +LICENSE = "http://spdx.org/licenses/MIT" + + +def _validate(crate_dir, profile, **settings): + return services.validate(services.ValidationSettings( + rocrate_uri=str(crate_dir), + profile_identifier=profile, + requirement_severity=models.Severity.REQUIRED, + offline=True, + extra_checks=True, + **settings, + )) + + +def _issues(result): + """Readable list of failures and extra-check findings, shown if an assertion fails.""" + lines = [f"{i.check.identifier}: {i.message}" for i in result.get_issues()] + lines += [f"{e.identifier} (extra): {e.message}" for e in result.extra_checks] + return "\n".join(lines) + + +def test_crate_is_valid_ro_crate_1_2(tmp_path): + crate_dir = build_crate(_minimal_capture(), crate_dir=tmp_path / "ro-crate", + author=AUTHOR, license=LICENSE) + result = _validate(crate_dir, "ro-crate-1.2") + assert result.passed(), _issues(result) + assert not result.extra_checks, _issues(result) + + +def test_crate_satisfies_process_run_crate(tmp_path): + crate_dir = build_crate(_minimal_capture(), crate_dir=tmp_path / "ro-crate", + author=AUTHOR, license=LICENSE) + # The validator only has a Process Run Crate 0.5 profile, which inherits + # RO-Crate 1.1 checks that clash with a 1.2 crate. The test above already + # covers the base RO-Crate rules, so only report the profile's own checks. + result = _validate(crate_dir, "process-run-crate-0.5", + disable_inherited_profiles_issue_reporting=True) + assert result.passed(), _issues(result) + assert not result.extra_checks, _issues(result) + + +def test_missing_license_is_reported(tmp_path): + crate_dir = build_crate(_minimal_capture(), crate_dir=tmp_path / "ro-crate", + author=AUTHOR) + result = _validate(crate_dir, "ro-crate-1.2") + assert not result.passed() + assert any(i.violatingProperty == "http://schema.org/license" + for i in result.get_issues()), _issues(result)