adagio-server 0.1.0a1__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.0a1/.gitignore +130 -0
  2. adagio_server-0.1.0a1/LICENSE +21 -0
  3. adagio_server-0.1.0a1/PKG-INFO +102 -0
  4. adagio_server-0.1.0a1/README.md +75 -0
  5. adagio_server-0.1.0a1/RELEASING.md +110 -0
  6. adagio_server-0.1.0a1/adagio_server/__init__.py +8 -0
  7. adagio_server-0.1.0a1/adagio_server/agent/__init__.py +1 -0
  8. adagio_server-0.1.0a1/adagio_server/agent/artifacts.py +114 -0
  9. adagio_server-0.1.0a1/adagio_server/agent/client.py +305 -0
  10. adagio_server-0.1.0a1/adagio_server/agent/identity.py +10 -0
  11. adagio_server-0.1.0a1/adagio_server/agent/queues.py +52 -0
  12. adagio_server-0.1.0a1/adagio_server/agent/runner.py +340 -0
  13. adagio_server-0.1.0a1/adagio_server/agent/supervisor.py +846 -0
  14. adagio_server-0.1.0a1/adagio_server/api/__init__.py +0 -0
  15. adagio_server-0.1.0a1/adagio_server/api/api.py +20 -0
  16. adagio_server-0.1.0a1/adagio_server/api/endpoints/__init__.py +1 -0
  17. adagio_server-0.1.0a1/adagio_server/api/endpoints/loopback.py +83 -0
  18. adagio_server-0.1.0a1/adagio_server/cli.py +295 -0
  19. adagio_server-0.1.0a1/adagio_server/contracts/__init__.py +1 -0
  20. adagio_server-0.1.0a1/adagio_server/contracts/agent.py +12 -0
  21. adagio_server-0.1.0a1/adagio_server/core/__init__.py +1 -0
  22. adagio_server-0.1.0a1/adagio_server/core/config.py +110 -0
  23. adagio_server-0.1.0a1/adagio_server/main.py +118 -0
  24. adagio_server-0.1.0a1/adagio_server/services/__init__.py +0 -0
  25. adagio_server-0.1.0a1/adagio_server/services/timestamps.py +8 -0
  26. adagio_server-0.1.0a1/pyproject.toml +131 -0
  27. adagio_server-0.1.0a1/tests/conftest.py +153 -0
  28. adagio_server-0.1.0a1/tests/test_artifacts.py +43 -0
  29. adagio_server-0.1.0a1/tests/test_cli.py +223 -0
  30. adagio_server-0.1.0a1/tests/test_client.py +273 -0
  31. adagio_server-0.1.0a1/tests/test_loopback.py +101 -0
  32. adagio_server-0.1.0a1/tests/test_main.py +77 -0
  33. adagio_server-0.1.0a1/tests/test_runner.py +288 -0
  34. adagio_server-0.1.0a1/tests/test_supervisor.py +908 -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,102 @@
1
+ Metadata-Version: 2.5
2
+ Name: adagio-server
3
+ Version: 0.1.0a1
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 :: 3 - Alpha
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.0a10
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
+ For the QA prerelease, after `0.1.0a1` is published to PyPI:
45
+
46
+ ```bash
47
+ python3 -m venv ~/.local/share/adagio-server-venv
48
+ source ~/.local/share/adagio-server-venv/bin/activate
49
+ python -m pip install "adagio-server==0.1.0a1"
50
+ adagio-server --version
51
+
52
+ export ADAGIO_ENROLLMENT_TOKEN='<code from Adagio>'
53
+ adagio-server install --url https://app.dev.adagio.run
54
+ ```
55
+
56
+ The install command exchanges the short-lived token for a machine credential,
57
+ stores that credential with user-only permissions, then installs and starts the
58
+ systemd user service. Jobs execute with the same Unix filesystem permissions as
59
+ the user who installed it, including supplementary groups used for shared data.
60
+ Docker-backed actions also run as that user. Images that require root during
61
+ startup are therefore unsupported on a Runtime Server.
62
+ Granting someone permission to use a server grants code execution as its Linux
63
+ account. An account with access to a rootful Docker daemon can administer the
64
+ host; this is a trusted execution environment, not a sandbox for untrusted users
65
+ or plugins. Supplementary groups are preserved for shared-data access. Do not
66
+ assume UID flags or dropped container capabilities isolate a Docker socket made
67
+ accessible through mounted paths. Docker socket isolation needs separate review.
68
+ The command prints a note when an administrator may need to enable
69
+ systemd user lingering for service operation after logout.
70
+
71
+ Install the package in a persistent virtual environment (or with `pipx`) rather
72
+ than an ephemeral `uvx` environment. The systemd unit records the interpreter
73
+ path that performed the installation.
74
+
75
+ New installations select a stable loopback port from the Linux user id so
76
+ different users on a shared host do not contend for port 8760. Pass
77
+ `--port 23456` to choose an explicit port. The listener remains bound to
78
+ `127.0.0.1` and requires a per-process bearer token.
79
+
80
+ For an interactive foreground process, use `adagio-server start`. Plain HTTP and
81
+ disabled TLS verification are refused unless `install --insecure` is passed for a
82
+ development deployment.
83
+
84
+ ## Updates and reporting
85
+
86
+ Contract mismatches stop with exit code 42 and an update-required message;
87
+ credential rejection uses exit code 41. Neither condition should restart the
88
+ same configuration repeatedly. After upgrading an existing installation, rerun
89
+ `adagio-server install` with a fresh enrollment token to update its systemd unit
90
+ (older units only suppress restarts for code 41).
91
+
92
+ Telemetry uploads have bounded text, metadata, and batch sizes. Large logs are
93
+ sent in small batches with continued long lines; full logs remain on the server.
94
+ Oversized optional event/provenance data is replaced by a `telemetry_truncated`
95
+ marker rather than losing the job's lifecycle report. These limits do not apply
96
+ to input/output file sizes or job counts.
97
+
98
+ This revision requires the adapter-schemas 0.3.1 release candidate. Source builds
99
+ use the exact commit recorded in `vendor/adapter-schemas/VENDOR_PIN`; publication
100
+ and release-tag verification must be completed before package-index installation.
101
+
102
+ Release operators and QA testers should follow `RELEASING.md` in the repository.
@@ -0,0 +1,75 @@
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
+ For the QA prerelease, after `0.1.0a1` is published to PyPI:
18
+
19
+ ```bash
20
+ python3 -m venv ~/.local/share/adagio-server-venv
21
+ source ~/.local/share/adagio-server-venv/bin/activate
22
+ python -m pip install "adagio-server==0.1.0a1"
23
+ adagio-server --version
24
+
25
+ export ADAGIO_ENROLLMENT_TOKEN='<code from Adagio>'
26
+ adagio-server install --url https://app.dev.adagio.run
27
+ ```
28
+
29
+ The install command exchanges the short-lived token for a machine credential,
30
+ stores that credential with user-only permissions, then installs and starts the
31
+ systemd user service. Jobs execute with the same Unix filesystem permissions as
32
+ the user who installed it, including supplementary groups used for shared data.
33
+ Docker-backed actions also run as that user. Images that require root during
34
+ startup are therefore unsupported on a Runtime Server.
35
+ Granting someone permission to use a server grants code execution as its Linux
36
+ account. An account with access to a rootful Docker daemon can administer the
37
+ host; this is a trusted execution environment, not a sandbox for untrusted users
38
+ or plugins. Supplementary groups are preserved for shared-data access. Do not
39
+ assume UID flags or dropped container capabilities isolate a Docker socket made
40
+ accessible through mounted paths. Docker socket isolation needs separate review.
41
+ The command prints a note when an administrator may need to enable
42
+ systemd user lingering for service operation after logout.
43
+
44
+ Install the package in a persistent virtual environment (or with `pipx`) rather
45
+ than an ephemeral `uvx` environment. The systemd unit records the interpreter
46
+ path that performed the installation.
47
+
48
+ New installations select a stable loopback port from the Linux user id so
49
+ different users on a shared host do not contend for port 8760. Pass
50
+ `--port 23456` to choose an explicit port. The listener remains bound to
51
+ `127.0.0.1` and requires a per-process bearer token.
52
+
53
+ For an interactive foreground process, use `adagio-server start`. Plain HTTP and
54
+ disabled TLS verification are refused unless `install --insecure` is passed for a
55
+ development deployment.
56
+
57
+ ## Updates and reporting
58
+
59
+ Contract mismatches stop with exit code 42 and an update-required message;
60
+ credential rejection uses exit code 41. Neither condition should restart the
61
+ same configuration repeatedly. After upgrading an existing installation, rerun
62
+ `adagio-server install` with a fresh enrollment token to update its systemd unit
63
+ (older units only suppress restarts for code 41).
64
+
65
+ Telemetry uploads have bounded text, metadata, and batch sizes. Large logs are
66
+ sent in small batches with continued long lines; full logs remain on the server.
67
+ Oversized optional event/provenance data is replaced by a `telemetry_truncated`
68
+ marker rather than losing the job's lifecycle report. These limits do not apply
69
+ to input/output file sizes or job counts.
70
+
71
+ This revision requires the adapter-schemas 0.3.1 release candidate. Source builds
72
+ use the exact commit recorded in `vendor/adapter-schemas/VENDOR_PIN`; publication
73
+ and release-tag verification must be completed before package-index installation.
74
+
75
+ Release operators and QA testers should follow `RELEASING.md` in the repository.
@@ -0,0 +1,110 @@
1
+ # adagio-server QA releases
2
+
3
+ Planned first QA release: **0.1.0a1**. 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 approved QA 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
+ The tag must include this workflow, but it does not need to be on `main` first.
31
+ This allows QA releases from reviewed `dev` commits without promoting Desktop
32
+ changes to `main` or changing the default branch. Publishing a GitHub release is
33
+ the explicit authorization to publish its package to PyPI after checks pass;
34
+ saving a draft or pushing a tag is not. No PyPI publisher configuration changes
35
+ are needed. Only `server-v*` releases are eligible; Desktop `v*` releases are ignored.
36
+
37
+ ## Release gates
38
+
39
+ 1. Merge the reviewed runner and packaging changes into `dev` and deploy the
40
+ matching hosted changes to the development environment.
41
+ 2. Publish `adapter-schemas==0.3.1` first. Confirm the already released
42
+ `adagio-cli==0.1.0a10` is available, and re-sync vendored contracts to the
43
+ verified release tag. A local `uv sync` does not prove a pip install will work.
44
+ 3. Run Server package CI. It builds wheel and source archives with explicit file
45
+ allowlists, validates metadata, and tests installed wheels on Python 3.11/3.13
46
+ with the public CLI. Only pre-publication CI uses a locally built schema wheel.
47
+ 4. Push `server-v0.1.0a1` on the reviewed commit. **Do not use `v0.1.0a1`: `v*`
48
+ tags belong to Desktop releases.** Prerelease commits must be in `dev`;
49
+ stable server release commits must be in `main`.
50
+ 5. A separate clean job must install the server wheel using only PyPI for its
51
+ dependencies. No candidate schema wheel or private source checkout is available
52
+ in that job. If this fails, publication is blocked.
53
+ 6. Explicitly publish the GitHub prerelease with the command below (or publish it
54
+ from the GitHub release UI). Only the publishing job
55
+ receives OIDC permission, after all required checks pass. There is no second
56
+ approval prompt. PR/branch/tag pushes and manual workflow dispatch never
57
+ publish. The `published` event covers both prereleases and stable releases.
58
+ 7. Repeat installation from PyPI on a clean Linux/systemd machine and record
59
+ `adagio-server --version` and `python -m pip freeze` in the QA report.
60
+
61
+ Publishing command (do not run until release approval):
62
+
63
+ ```bash
64
+ gh release create server-v0.1.0a1 --repo cymis/adagio-local --verify-tag --prerelease --latest=false --title 'adagio-server 0.1.0a1' --notes 'First MIT-licensed adagio-server QA prerelease. Use with the Adagio dev deployment.'
65
+ ```
66
+
67
+ For a build/test dry run, push the reviewed tag without publishing a GitHub
68
+ release. This still checks that dependencies are available from PyPI. A draft
69
+ release does not publish to PyPI. A failed publishing job can be rerun on the
70
+ same release; do not move the tag to different code.
71
+
72
+ Never replace a published artifact or move a released tag. Publish `0.1.0a2`,
73
+ `0.1.0a3`, etc. for subsequent QA builds and update the dev setup snippet's pin.
74
+ Publishing an alpha does not promote the application to `main` or release Desktop.
75
+
76
+ ## QA workflow (after publication)
77
+
78
+ Use a dedicated Linux account with Python 3.11+, a working systemd user session,
79
+ and access to the execution environment (for example, Docker). These are host
80
+ prerequisites; pip does not install or configure Docker or systemd.
81
+
82
+ ```bash
83
+ python3 -m venv ~/.local/share/adagio-server-venv
84
+ source ~/.local/share/adagio-server-venv/bin/activate
85
+ python -m pip install "adagio-server==0.1.0a1"
86
+ adagio-server --version
87
+ adagio-server install --url https://app.dev.adagio.run
88
+ ```
89
+
90
+ Create a Runtime Server in the dev app and paste its temporary enrollment token
91
+ at the hidden prompt. The same one-time token can instead be supplied through
92
+ `ADAGIO_ENROLLMENT_TOKEN`, as shown in the app. Keep this virtual environment:
93
+ the user service records its interpreter path. Do not use `sudo pip`.
94
+
95
+ Check the install output and the app's green connection indicator, then use
96
+ `systemctl --user status adagio-server` and
97
+ `journalctl --user -u adagio-server -n 100` for troubleshooting. If jobs need to
98
+ continue after logout or reboot, ask the host administrator to enable user lingering
99
+ with `loginctl enable-linger USERNAME` and test that behavior explicitly.
100
+
101
+ Test remote input/metadata and output paths, multiple simultaneous jobs, canceling
102
+ only one job, service stop/restart, interrupted-job reporting, re-enrollment, token
103
+ revocation, large logs, and an ordinary Desktop run against the same dev deployment.
104
+ For upgrades, wait for active work to finish (or explicitly cancel it), install the
105
+ new pinned version in the same environment, and rerun install with a fresh token.
106
+ Existing systemd units need the updated restart policy. Confirm the running process
107
+ actually restarts; pip installation alone does not restart an existing service.
108
+
109
+ Do not include enrollment tokens, machine credentials, or private scientific data
110
+ in QA 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,114 @@
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
+ try:
20
+ import yaml
21
+ except Exception: # noqa: BLE001 - yaml is optional; degrade to no introspection
22
+ yaml = None # type: ignore[assignment]
23
+
24
+ #: QIIME repr tags that a plain YAML loader cannot resolve.
25
+ _Q2_REPRS = ("!ref", "!no-provenance", "!metadata", "!color", "!cite")
26
+
27
+
28
+ def read_qiime_archive(file_path: Path) -> tuple[str | None, dict[str, Any] | None]:
29
+ """Return ``(artifact_type, provenance)`` for a QIIME archive, else ``(None, None)``.
30
+
31
+ ``artifact_type`` comes from ``*/metadata.yaml``'s ``type`` field; ``provenance``
32
+ from ``*/provenance/action/action.yaml``. Non-archives and parse failures return
33
+ ``(None, None)`` / partial results rather than raising.
34
+ """
35
+ if not zipfile.is_zipfile(file_path):
36
+ return None, None
37
+
38
+ try:
39
+ with zipfile.ZipFile(file_path, "r") as zf:
40
+ metadata = _read_yaml_member(zf, ["/metadata.yaml"])
41
+ provenance = _read_yaml_member(
42
+ zf,
43
+ ["/provenance/action/action.yaml", "/action/action.yaml"],
44
+ )
45
+ except Exception: # noqa: BLE001
46
+ return None, None
47
+
48
+ archive_type: str | None = None
49
+ if isinstance(metadata, dict):
50
+ type_name = metadata.get("type")
51
+ if isinstance(type_name, str):
52
+ archive_type = type_name
53
+
54
+ if isinstance(provenance, dict):
55
+ return archive_type, provenance
56
+ return archive_type, None
57
+
58
+
59
+ def _read_yaml_member(
60
+ zf: zipfile.ZipFile, suffixes: list[str]
61
+ ) -> dict[str, Any] | None:
62
+ names = zf.namelist()
63
+ for suffix in suffixes:
64
+ match = next((name for name in names if name.endswith(suffix)), None)
65
+ if match is None:
66
+ continue
67
+ text = zf.read(match).decode("utf-8", errors="replace")
68
+ parsed = yaml_load_with_qiime_tags(text)
69
+ if isinstance(parsed, dict):
70
+ return parsed
71
+ return None
72
+
73
+
74
+ def yaml_load_with_qiime_tags(text: str) -> Any:
75
+ """Load YAML tolerating QIIME repr tags; degrade progressively on failure."""
76
+ if yaml is None:
77
+ return None
78
+
79
+ class _Loader(yaml.SafeLoader):
80
+ pass
81
+
82
+ def _unknown_tag(loader: yaml.SafeLoader, node: yaml.Node) -> Any:
83
+ if isinstance(node, yaml.ScalarNode):
84
+ return loader.construct_scalar(node)
85
+ if isinstance(node, yaml.SequenceNode):
86
+ return loader.construct_sequence(node)
87
+ if isinstance(node, yaml.MappingNode):
88
+ return loader.construct_mapping(node)
89
+ return None
90
+
91
+ _Loader.add_constructor(None, _unknown_tag)
92
+ try:
93
+ return yaml.load(text, Loader=_Loader)
94
+ except Exception: # noqa: BLE001
95
+ pass
96
+
97
+ stripped = text
98
+ for qrepr in _Q2_REPRS:
99
+ stripped = stripped.replace(qrepr, "")
100
+
101
+ try:
102
+ return yaml.safe_load(stripped)
103
+ except Exception: # noqa: BLE001
104
+ pass
105
+
106
+ try:
107
+ return yaml.load(stripped, Loader=yaml.BaseLoader)
108
+ except Exception: # noqa: BLE001
109
+ return {
110
+ "_parse_warning": (
111
+ "Unable to fully parse QIIME provenance YAML. "
112
+ "Some provenance details were omitted."
113
+ ),
114
+ }