adagio-server 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. adagio_server-0.1.0/.gitignore +130 -0
  2. adagio_server-0.1.0/LICENSE +21 -0
  3. adagio_server-0.1.0/PKG-INFO +103 -0
  4. adagio_server-0.1.0/README.md +76 -0
  5. adagio_server-0.1.0/RELEASING.md +113 -0
  6. adagio_server-0.1.0/adagio_server/__init__.py +8 -0
  7. adagio_server-0.1.0/adagio_server/agent/__init__.py +1 -0
  8. adagio_server-0.1.0/adagio_server/agent/artifacts.py +118 -0
  9. adagio_server-0.1.0/adagio_server/agent/client.py +305 -0
  10. adagio_server-0.1.0/adagio_server/agent/identity.py +10 -0
  11. adagio_server-0.1.0/adagio_server/agent/queues.py +52 -0
  12. adagio_server-0.1.0/adagio_server/agent/runner.py +340 -0
  13. adagio_server-0.1.0/adagio_server/agent/supervisor.py +846 -0
  14. adagio_server-0.1.0/adagio_server/api/__init__.py +0 -0
  15. adagio_server-0.1.0/adagio_server/api/api.py +20 -0
  16. adagio_server-0.1.0/adagio_server/api/endpoints/__init__.py +1 -0
  17. adagio_server-0.1.0/adagio_server/api/endpoints/loopback.py +83 -0
  18. adagio_server-0.1.0/adagio_server/cli.py +295 -0
  19. adagio_server-0.1.0/adagio_server/contracts/__init__.py +1 -0
  20. adagio_server-0.1.0/adagio_server/contracts/agent.py +12 -0
  21. adagio_server-0.1.0/adagio_server/core/__init__.py +1 -0
  22. adagio_server-0.1.0/adagio_server/core/config.py +110 -0
  23. adagio_server-0.1.0/adagio_server/main.py +118 -0
  24. adagio_server-0.1.0/adagio_server/services/__init__.py +0 -0
  25. adagio_server-0.1.0/adagio_server/services/timestamps.py +8 -0
  26. adagio_server-0.1.0/pyproject.toml +131 -0
  27. adagio_server-0.1.0/tests/conftest.py +153 -0
  28. adagio_server-0.1.0/tests/test_artifacts.py +74 -0
  29. adagio_server-0.1.0/tests/test_cli.py +223 -0
  30. adagio_server-0.1.0/tests/test_client.py +273 -0
  31. adagio_server-0.1.0/tests/test_loopback.py +101 -0
  32. adagio_server-0.1.0/tests/test_main.py +77 -0
  33. adagio_server-0.1.0/tests/test_runner.py +288 -0
  34. adagio_server-0.1.0/tests/test_supervisor.py +916 -0
@@ -0,0 +1,130 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ # lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ pip-wheel-metadata/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+ .DS_STORE
29
+ MANIFEST
30
+
31
+ # PyInstaller
32
+ # Usually these files are written by a python script from a template
33
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
34
+ *.manifest
35
+ *.spec
36
+
37
+ # Installer logs
38
+ pip-log.txt
39
+ pip-delete-this-directory.txt
40
+
41
+ # Unit test / coverage reports
42
+ htmlcov/
43
+ .tox/
44
+ .nox/
45
+ .coverage
46
+ .coverage.*
47
+ .cache
48
+ nosetests.xml
49
+ coverage.xml
50
+ *.cover
51
+ *.py,cover
52
+ .hypothesis/
53
+ .pytest_cache/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ .python-version
87
+
88
+ # pipenv
89
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
90
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
91
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
92
+ # install all needed dependencies.
93
+ #Pipfile.lock
94
+
95
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
96
+ __pypackages__/
97
+
98
+ # Celery stuff
99
+ celerybeat-schedule
100
+ celerybeat.pid
101
+
102
+ # SageMath parsed files
103
+ *.sage.py
104
+
105
+ # Environments
106
+ .env
107
+ .venv
108
+ env/
109
+ venv/
110
+ ENV/
111
+ env.bak/
112
+ venv.bak/
113
+
114
+ # Spyder project settings
115
+ .spyderproject
116
+ .spyproject
117
+
118
+ # Rope project settings
119
+ .ropeproject
120
+
121
+ # mkdocs documentation
122
+ /site
123
+
124
+ # mypy
125
+ .mypy_cache/
126
+ .dmypy.json
127
+ dmypy.json
128
+
129
+ # Pyre type checker
130
+ .pyre/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cymis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: adagio-server
3
+ Version: 0.1.0
4
+ Summary: Outbound self-hosted runtime server for Adagio
5
+ Project-URL: Homepage, https://adagio.run
6
+ Project-URL: Repository, https://github.com/cymis/adagio-local
7
+ Author-email: John Chase <johnhchase@protonmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: adagio,pipelines,runner,workflow
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: adagio-cli<0.2,>=0.1.0
19
+ Requires-Dist: adapter-schemas<0.4,>=0.3.1
20
+ Requires-Dist: fastapi[standard]<0.117,>=0.116
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: pydantic-settings<3.0.0,>=2.0.2
23
+ Requires-Dist: pydantic>=2.7
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: tomli-w>=1.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # adagio-server
29
+
30
+ Licensed under the [MIT License](LICENSE). This covers the server package only;
31
+ it does not change the license of the hosted Adagio application or sibling projects.
32
+
33
+ `adagio-server` is Adagio's outbound execution runner. It claims jobs over HTTPS,
34
+ executes them with the existing `adagio-cli`, and reports status and artifact paths.
35
+ It never requires an inbound network port. The initial server platform is Linux
36
+ with Python 3.11+ and user-level systemd; Docker is needed for container-backed
37
+ pipeline actions.
38
+
39
+ Each claimed job starts independently, even while other jobs are running. Adagio
40
+ does not impose a job-count limit or decide whether the host has spare CPU or
41
+ memory; the server owner manages resource usage. The outbound handoff is not a
42
+ resource scheduler. Slurm/SGE integration is separate future work.
43
+
44
+ Install the server in a persistent virtual environment, then use the enrollment
45
+ token and deployment URL shown under **Settings → Runtime Servers** in Adagio:
46
+
47
+ ```bash
48
+ python3 -m venv ~/.local/share/adagio-server-venv
49
+ source ~/.local/share/adagio-server-venv/bin/activate
50
+ python -m pip install adagio-server
51
+ adagio-server --version
52
+
53
+ export ADAGIO_ENROLLMENT_TOKEN='<code from Adagio>'
54
+ adagio-server install --url https://app.adagio.run
55
+ ```
56
+
57
+ The install command exchanges the short-lived token for a machine credential,
58
+ stores that credential with user-only permissions, then installs and starts the
59
+ systemd user service. Jobs execute with the same Unix filesystem permissions as
60
+ the user who installed it, including supplementary groups used for shared data.
61
+ Docker-backed actions also run as that user. Images that require root during
62
+ startup are therefore unsupported on a Runtime Server.
63
+ Granting someone permission to use a server grants code execution as its Linux
64
+ account. An account with access to a rootful Docker daemon can administer the
65
+ host; this is a trusted execution environment, not a sandbox for untrusted users
66
+ or plugins. Supplementary groups are preserved for shared-data access. Do not
67
+ assume UID flags or dropped container capabilities isolate a Docker socket made
68
+ accessible through mounted paths. Docker socket isolation needs separate review.
69
+ The command prints a note when an administrator may need to enable
70
+ systemd user lingering for service operation after logout.
71
+
72
+ Install the package in a persistent virtual environment (or with `pipx`) rather
73
+ than an ephemeral `uvx` environment. The systemd unit records the interpreter
74
+ path that performed the installation.
75
+
76
+ New installations select a stable loopback port from the Linux user id so
77
+ different users on a shared host do not contend for port 8760. Pass
78
+ `--port 23456` to choose an explicit port. The listener remains bound to
79
+ `127.0.0.1` and requires a per-process bearer token.
80
+
81
+ For an interactive foreground process, use `adagio-server start`. Plain HTTP and
82
+ disabled TLS verification are refused unless `install --insecure` is passed for a
83
+ development deployment.
84
+
85
+ ## Updates and reporting
86
+
87
+ Contract mismatches stop with exit code 42 and an update-required message;
88
+ credential rejection uses exit code 41. Neither condition should restart the
89
+ same configuration repeatedly. After upgrading an existing installation, rerun
90
+ `adagio-server install` with a fresh enrollment token to update its systemd unit
91
+ (older units only suppress restarts for code 41).
92
+
93
+ Telemetry uploads have bounded text, metadata, and batch sizes. Large logs are
94
+ sent in small batches with continued long lines; full logs remain on the server.
95
+ Oversized optional event/provenance data is replaced by a `telemetry_truncated`
96
+ marker rather than losing the job's lifecycle report. These limits do not apply
97
+ to input/output file sizes or job counts.
98
+
99
+ This revision requires the adapter-schemas 0.3.1 release. Source builds
100
+ use the exact commit recorded in `vendor/adapter-schemas/VENDOR_PIN`; publication
101
+ and release-tag verification must be completed before package-index installation.
102
+
103
+ Release operators and QA testers should follow `RELEASING.md` in the repository.
@@ -0,0 +1,76 @@
1
+ # adagio-server
2
+
3
+ Licensed under the [MIT License](LICENSE). This covers the server package only;
4
+ it does not change the license of the hosted Adagio application or sibling projects.
5
+
6
+ `adagio-server` is Adagio's outbound execution runner. It claims jobs over HTTPS,
7
+ executes them with the existing `adagio-cli`, and reports status and artifact paths.
8
+ It never requires an inbound network port. The initial server platform is Linux
9
+ with Python 3.11+ and user-level systemd; Docker is needed for container-backed
10
+ pipeline actions.
11
+
12
+ Each claimed job starts independently, even while other jobs are running. Adagio
13
+ does not impose a job-count limit or decide whether the host has spare CPU or
14
+ memory; the server owner manages resource usage. The outbound handoff is not a
15
+ resource scheduler. Slurm/SGE integration is separate future work.
16
+
17
+ Install the server in a persistent virtual environment, then use the enrollment
18
+ token and deployment URL shown under **Settings → Runtime Servers** in Adagio:
19
+
20
+ ```bash
21
+ python3 -m venv ~/.local/share/adagio-server-venv
22
+ source ~/.local/share/adagio-server-venv/bin/activate
23
+ python -m pip install adagio-server
24
+ adagio-server --version
25
+
26
+ export ADAGIO_ENROLLMENT_TOKEN='<code from Adagio>'
27
+ adagio-server install --url https://app.adagio.run
28
+ ```
29
+
30
+ The install command exchanges the short-lived token for a machine credential,
31
+ stores that credential with user-only permissions, then installs and starts the
32
+ systemd user service. Jobs execute with the same Unix filesystem permissions as
33
+ the user who installed it, including supplementary groups used for shared data.
34
+ Docker-backed actions also run as that user. Images that require root during
35
+ startup are therefore unsupported on a Runtime Server.
36
+ Granting someone permission to use a server grants code execution as its Linux
37
+ account. An account with access to a rootful Docker daemon can administer the
38
+ host; this is a trusted execution environment, not a sandbox for untrusted users
39
+ or plugins. Supplementary groups are preserved for shared-data access. Do not
40
+ assume UID flags or dropped container capabilities isolate a Docker socket made
41
+ accessible through mounted paths. Docker socket isolation needs separate review.
42
+ The command prints a note when an administrator may need to enable
43
+ systemd user lingering for service operation after logout.
44
+
45
+ Install the package in a persistent virtual environment (or with `pipx`) rather
46
+ than an ephemeral `uvx` environment. The systemd unit records the interpreter
47
+ path that performed the installation.
48
+
49
+ New installations select a stable loopback port from the Linux user id so
50
+ different users on a shared host do not contend for port 8760. Pass
51
+ `--port 23456` to choose an explicit port. The listener remains bound to
52
+ `127.0.0.1` and requires a per-process bearer token.
53
+
54
+ For an interactive foreground process, use `adagio-server start`. Plain HTTP and
55
+ disabled TLS verification are refused unless `install --insecure` is passed for a
56
+ development deployment.
57
+
58
+ ## Updates and reporting
59
+
60
+ Contract mismatches stop with exit code 42 and an update-required message;
61
+ credential rejection uses exit code 41. Neither condition should restart the
62
+ same configuration repeatedly. After upgrading an existing installation, rerun
63
+ `adagio-server install` with a fresh enrollment token to update its systemd unit
64
+ (older units only suppress restarts for code 41).
65
+
66
+ Telemetry uploads have bounded text, metadata, and batch sizes. Large logs are
67
+ sent in small batches with continued long lines; full logs remain on the server.
68
+ Oversized optional event/provenance data is replaced by a `telemetry_truncated`
69
+ marker rather than losing the job's lifecycle report. These limits do not apply
70
+ to input/output file sizes or job counts.
71
+
72
+ This revision requires the adapter-schemas 0.3.1 release. Source builds
73
+ use the exact commit recorded in `vendor/adapter-schemas/VENDOR_PIN`; publication
74
+ and release-tag verification must be completed before package-index installation.
75
+
76
+ Release operators and QA testers should follow `RELEASING.md` in the repository.
@@ -0,0 +1,113 @@
1
+ # Releasing `adagio-server`
2
+
3
+ Current release candidate: **0.1.0**. The source repository remains private;
4
+ the published Python distributions and their included code are public and
5
+ MIT-licensed. This license applies to the server package, not the hosted Adagio
6
+ application or other repositories. Both wheel and source archives include LICENSE.
7
+
8
+ ## One-time account setup
9
+
10
+ In https://pypi.org/manage/account/publishing/ add a pending GitHub publisher:
11
+
12
+ | Field | Value |
13
+ | --- | --- |
14
+ | PyPI project | `adagio-server` |
15
+ | GitHub owner | `cymis` |
16
+ | Repository | `adagio-local` |
17
+ | Workflow filename | `server-package.yml` |
18
+ | Environment | Leave blank |
19
+
20
+ The release policy uses explicit GitHub release publication, without a GitHub
21
+ environment or a separate reviewer gate. Repository writers can initiate a
22
+ release; access to this workflow must therefore be treated as publishing access.
23
+ No PyPI API-token secret is needed. The package license is MIT.
24
+
25
+ The repository name above identifies where the workflow lives. It does not
26
+ publish `adagio-local`: only the `adagio-server` distributions built from
27
+ `adagio-local-adapter` are uploaded. Desktop and sibling projects are excluded.
28
+
29
+ The `release: published` event runs the workflow from the tagged release commit.
30
+ That commit must contain this workflow. The workflow requires prerelease commits
31
+ to be contained in `dev` and stable release commits to be contained in `main`.
32
+ This allows reviewed QA builds without prematurely promoting Desktop changes.
33
+ Publishing a GitHub release is the explicit authorization to publish its package
34
+ to PyPI after checks pass; saving a draft or pushing a tag is not. No PyPI
35
+ publisher configuration changes are needed. Only `server-v*` releases are
36
+ eligible; Desktop `v*` releases are ignored.
37
+
38
+ ## Release gates
39
+
40
+ 1. Merge the reviewed runner and packaging changes into `dev` and validate them
41
+ against the matching hosted changes in the development environment.
42
+ 2. Publish `adapter-schemas==0.3.1` first. Confirm
43
+ `adagio-cli==0.1.0` is available, and re-sync vendored contracts to the
44
+ verified release tag. A local `uv sync` does not prove a pip install will work.
45
+ 3. Run Server package CI. It builds wheel and source archives with explicit file
46
+ allowlists, validates metadata, and tests installed wheels on Python 3.11/3.13
47
+ with the public CLI. Only pre-publication CI uses a locally built schema wheel.
48
+ 4. Promote `dev` to `main`, then push `server-v0.1.0` on the exact reviewed main
49
+ merge commit. **Do not use `v0.1.0`: `v*` tags belong to Desktop releases.**
50
+ Prerelease commits must be in `dev`; stable server release commits must be in
51
+ `main`.
52
+ 5. A separate clean job must install the server wheel using only PyPI for its
53
+ dependencies. No candidate schema wheel or private source checkout is available
54
+ in that job. If this fails, publication is blocked.
55
+ 6. Explicitly publish the GitHub release with the command below (or publish it
56
+ from the GitHub release UI). Only the publishing job
57
+ receives OIDC permission, after all required checks pass. There is no second
58
+ approval prompt. PR/branch/tag pushes and manual workflow dispatch never
59
+ publish. The `published` event covers both prereleases and stable releases.
60
+ 7. Repeat installation from PyPI on a clean Linux/systemd machine and record
61
+ `adagio-server --version` and `python -m pip freeze` in the validation report.
62
+
63
+ Publishing command (do not run until release approval):
64
+
65
+ ```bash
66
+ gh release create server-v0.1.0 --repo cymis/adagio-local --verify-tag --title 'adagio-server 0.1.0' --notes 'First public Runtime Server release for self-hosted Linux execution.'
67
+ ```
68
+
69
+ For a build/test dry run, push the reviewed tag without publishing a GitHub
70
+ release. This still checks that dependencies are available from PyPI. A draft
71
+ release does not publish to PyPI. A failed publishing job can be rerun on the
72
+ same release; do not move the tag to different code.
73
+
74
+ Never replace a published artifact or move a released tag. Publish a new patch or
75
+ prerelease version for subsequent builds and update any setup snippet pin.
76
+ Publishing a server package does not release Desktop.
77
+
78
+ ## Validation after publication
79
+
80
+ Use a dedicated Linux account with Python 3.11+, a working systemd user session,
81
+ and access to the execution environment (for example, Docker). These are host
82
+ prerequisites; pip does not install or configure Docker or systemd.
83
+
84
+ ```bash
85
+ python3 -m venv ~/.local/share/adagio-server-venv
86
+ source ~/.local/share/adagio-server-venv/bin/activate
87
+ python -m pip install "adagio-server==0.1.0"
88
+ adagio-server --version
89
+ adagio-server install --url https://app.adagio.run
90
+ ```
91
+
92
+ Create a Runtime Server in the target Adagio deployment and paste its temporary
93
+ enrollment token at the hidden prompt. The same one-time token can instead be
94
+ supplied through `ADAGIO_ENROLLMENT_TOKEN`, as shown in the app. Keep this
95
+ virtual environment: the user service records its interpreter path. Do not use
96
+ `sudo pip`.
97
+
98
+ Check the install output and the app's green connection indicator, then use
99
+ `systemctl --user status adagio-server` and
100
+ `journalctl --user -u adagio-server -n 100` for troubleshooting. If jobs need to
101
+ continue after logout or reboot, ask the host administrator to enable user lingering
102
+ with `loginctl enable-linger USERNAME` and test that behavior explicitly.
103
+
104
+ Test remote input/metadata and output paths, multiple simultaneous jobs, canceling
105
+ only one job, service stop/restart, interrupted-job reporting, re-enrollment, token
106
+ revocation, large logs, and an ordinary Desktop run against the same dev deployment.
107
+ For upgrades, wait for active work to finish (or explicitly cancel it), install the
108
+ new pinned version in the same environment, and rerun install with a fresh token.
109
+ Existing systemd units need the updated restart policy. Confirm the running process
110
+ actually restarts; pip installation alone does not restart an existing service.
111
+
112
+ Do not include enrollment tokens, machine credentials, or private scientific data
113
+ in validation reports. Record versions, sanitized logs, host OS, and the reproduction steps.
@@ -0,0 +1,8 @@
1
+ """Adagio's outbound self-hosted runtime server."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("adagio-server")
7
+ except PackageNotFoundError: # Source checkout without an installed distribution.
8
+ __version__ = "unknown"
@@ -0,0 +1 @@
1
+ """Outbound pull-agent core (register / claim / lease / run / report)."""
@@ -0,0 +1,118 @@
1
+ """QIIME ``.qza``/``.qzv`` artifact introspection.
2
+
3
+ This is the **only** domain-aware code in the agent (design §5). It is kept
4
+ verbatim in behavior from the legacy ``jobs.py`` so that when the agent relays a
5
+ CLI ``output_saved`` event to action as an :class:`ArtifactReport`, it can fill in
6
+ ``artifact_type`` (the QIIME semantic type) and ``provenance`` (the recorded
7
+ action graph). All parsing is best-effort and degrades gracefully - a file that
8
+ is not a QIIME archive simply yields ``(None, None)``.
9
+
10
+ Everything else in the agent treats artifacts as opaque URIs.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import zipfile
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from fastapi.encoders import jsonable_encoder
20
+
21
+ try:
22
+ import yaml
23
+ except Exception: # noqa: BLE001 - yaml is optional; degrade to no introspection
24
+ yaml = None # type: ignore[assignment]
25
+
26
+ #: QIIME repr tags that a plain YAML loader cannot resolve.
27
+ _Q2_REPRS = ("!ref", "!no-provenance", "!metadata", "!color", "!cite")
28
+
29
+
30
+ def read_qiime_archive(file_path: Path) -> tuple[str | None, dict[str, Any] | None]:
31
+ """Return ``(artifact_type, provenance)`` for a QIIME archive, else ``(None, None)``.
32
+
33
+ ``artifact_type`` comes from ``*/metadata.yaml``'s ``type`` field; ``provenance``
34
+ from ``*/provenance/action/action.yaml``. Non-archives and parse failures return
35
+ ``(None, None)`` / partial results rather than raising.
36
+ """
37
+ if not zipfile.is_zipfile(file_path):
38
+ return None, None
39
+
40
+ try:
41
+ with zipfile.ZipFile(file_path, "r") as zf:
42
+ metadata = _read_yaml_member(zf, ["/metadata.yaml"])
43
+ provenance = _read_yaml_member(
44
+ zf,
45
+ ["/provenance/action/action.yaml", "/action/action.yaml"],
46
+ )
47
+ except Exception: # noqa: BLE001
48
+ return None, None
49
+
50
+ archive_type: str | None = None
51
+ if isinstance(metadata, dict):
52
+ type_name = metadata.get("type")
53
+ if isinstance(type_name, str):
54
+ archive_type = type_name
55
+
56
+ if isinstance(provenance, dict):
57
+ # YAML timestamps become date/datetime objects. Normalize before the
58
+ # telemetry size validator calls json.dumps, not just at HTTP upload.
59
+ return archive_type, jsonable_encoder(provenance)
60
+ return archive_type, None
61
+
62
+
63
+ def _read_yaml_member(
64
+ zf: zipfile.ZipFile, suffixes: list[str]
65
+ ) -> dict[str, Any] | None:
66
+ names = zf.namelist()
67
+ for suffix in suffixes:
68
+ match = next((name for name in names if name.endswith(suffix)), None)
69
+ if match is None:
70
+ continue
71
+ text = zf.read(match).decode("utf-8", errors="replace")
72
+ parsed = yaml_load_with_qiime_tags(text)
73
+ if isinstance(parsed, dict):
74
+ return parsed
75
+ return None
76
+
77
+
78
+ def yaml_load_with_qiime_tags(text: str) -> Any:
79
+ """Load YAML tolerating QIIME repr tags; degrade progressively on failure."""
80
+ if yaml is None:
81
+ return None
82
+
83
+ class _Loader(yaml.SafeLoader):
84
+ pass
85
+
86
+ def _unknown_tag(loader: yaml.SafeLoader, node: yaml.Node) -> Any:
87
+ if isinstance(node, yaml.ScalarNode):
88
+ return loader.construct_scalar(node)
89
+ if isinstance(node, yaml.SequenceNode):
90
+ return loader.construct_sequence(node)
91
+ if isinstance(node, yaml.MappingNode):
92
+ return loader.construct_mapping(node)
93
+ return None
94
+
95
+ _Loader.add_constructor(None, _unknown_tag)
96
+ try:
97
+ return yaml.load(text, Loader=_Loader)
98
+ except Exception: # noqa: BLE001
99
+ pass
100
+
101
+ stripped = text
102
+ for qrepr in _Q2_REPRS:
103
+ stripped = stripped.replace(qrepr, "")
104
+
105
+ try:
106
+ return yaml.safe_load(stripped)
107
+ except Exception: # noqa: BLE001
108
+ pass
109
+
110
+ try:
111
+ return yaml.load(stripped, Loader=yaml.BaseLoader)
112
+ except Exception: # noqa: BLE001
113
+ return {
114
+ "_parse_warning": (
115
+ "Unable to fully parse QIIME provenance YAML. "
116
+ "Some provenance details were omitted."
117
+ ),
118
+ }