haplohub-cli 3.0.0__tar.gz → 3.2.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.
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/.github/workflows/on_pr.yml +1 -1
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/.github/workflows/on_push_main.yml +1 -1
- haplohub_cli-3.2.0/AGENTS.md +36 -0
- haplohub_cli-3.2.0/CLAUDE.md +1 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/PKG-INFO +3 -3
- haplohub_cli-3.2.0/docs/index.md +14 -0
- haplohub_cli-3.2.0/docs/release.md +26 -0
- haplohub_cli-3.2.0/docs/sdk-coupling.md +43 -0
- haplohub_cli-3.2.0/docs/stage-verification.md +30 -0
- haplohub_cli-3.2.0/docs/testing.md +29 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/_version.py +3 -3
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/oauth_client.py +3 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/cli.py +0 -6
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/file.py +38 -6
- haplohub_cli-3.2.0/haplohub_cli/commands/tests/test_file.py +56 -0
- haplohub_cli-3.2.0/haplohub_cli/core/api/client.py +40 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/tests/test_checksum.py +3 -2
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/__init__.py +0 -4
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/algorithm.py +0 -6
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/algorithm_result.py +0 -6
- haplohub_cli-3.2.0/haplohub_cli/formatters/file.py +26 -0
- haplohub_cli-3.2.0/haplohub_cli/formatters/formatter_registry.py +35 -0
- haplohub_cli-3.2.0/haplohub_cli/formatters/generic_table.py +104 -0
- haplohub_cli-3.2.0/haplohub_cli/formatters/tests/__init__.py +0 -0
- haplohub_cli-3.2.0/haplohub_cli/formatters/tests/test_generic_table.py +102 -0
- haplohub_cli-3.2.0/haplohub_cli/tests/__init__.py +0 -0
- haplohub_cli-3.2.0/haplohub_cli/tests/test_cli.py +16 -0
- haplohub_cli-3.2.0/haplohub_cli/types/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/PKG-INFO +3 -3
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/SOURCES.txt +14 -4
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/requires.txt +1 -1
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/scm_file_list.json +14 -4
- haplohub_cli-3.2.0/haplohub_cli.egg-info/scm_version.json +8 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/pyproject.toml +2 -2
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/uv.lock +9 -314
- haplohub_cli-3.0.0/haplohub_cli/core/api/client.py +0 -92
- haplohub_cli-3.0.0/haplohub_cli/formatters/cohort.py +0 -39
- haplohub_cli-3.0.0/haplohub_cli/formatters/file.py +0 -46
- haplohub_cli-3.0.0/haplohub_cli/formatters/formatter_registry.py +0 -21
- haplohub_cli-3.0.0/haplohub_cli/formatters/member.py +0 -61
- haplohub_cli-3.0.0/haplohub_cli/formatters/metadata.py +0 -27
- haplohub_cli-3.0.0/haplohub_cli/formatters/sample.py +0 -41
- haplohub_cli-3.0.0/haplohub_cli.egg-info/scm_version.json +0 -8
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/.github/dependabot.yml +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/.gitignore +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/.pre-commit-config.yaml +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/README.md +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/auth.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/auth_web_server.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/tests/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/tests/test_auth_web_server.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/tests/test_token_storage.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/token_storage.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/algorithm/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/algorithm/result/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/biomarker.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/member/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/member/report.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/sample.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/tests/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/tests/test_delete.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/config.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/login.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/metadata/__init__.py +0 -0
- {haplohub_cli-3.0.0/haplohub_cli/config → haplohub_cli-3.2.0/haplohub_cli/commands/tests}/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/variant.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/version.py +0 -0
- {haplohub_cli-3.0.0/haplohub_cli/core/api → haplohub_cli-3.2.0/haplohub_cli/config}/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/config/config.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/config/config_manager.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/__init__.py +0 -0
- {haplohub_cli-3.0.0/haplohub_cli/core/tests → haplohub_cli-3.2.0/haplohub_cli/core/api}/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/checksum.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/deletion.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/network.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/slug.py +0 -0
- {haplohub_cli-3.0.0/haplohub_cli/types → haplohub_cli-3.2.0/haplohub_cli/core/tests}/__init__.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/tests/test_network.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/tests/test_slug.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/types.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/upload.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/biomarker.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/config.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/decorators.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/generic.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/utils.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/variant.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/settings.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/types/variant_range.py +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/dependency_links.txt +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/entry_points.txt +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/top_level.txt +0 -0
- {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/setup.cfg +0 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# haplohub-cli
|
|
2
|
+
|
|
3
|
+
Command line interface for the HaploHub API. A Click application (`haplohub` entry
|
|
4
|
+
point) that wraps the auto-generated `haplohub` Python SDK. Python 3.10+, managed
|
|
5
|
+
with uv, released to PyPI.
|
|
6
|
+
|
|
7
|
+
Start with [docs/index.md](docs/index.md). It is the table of contents for the
|
|
8
|
+
docs and the entry point for anything deeper than this file.
|
|
9
|
+
|
|
10
|
+
## Commands
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
uv sync --dev # install (uses the local ../haplohub-python SDK checkout)
|
|
14
|
+
uv run pytest # tests
|
|
15
|
+
uv run ruff format . && uv run ruff check .
|
|
16
|
+
uv run haplohub --help # run the CLI
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Pre-commit hooks run ruff and the test suite on every commit.
|
|
20
|
+
|
|
21
|
+
## Architecture
|
|
22
|
+
|
|
23
|
+
Commands do not print results. They return SDK response models, and the
|
|
24
|
+
`result_callback` in `haplohub_cli/cli.py` renders them through the formatter
|
|
25
|
+
registry in `haplohub_cli/formatters/`, which is keyed on the model type.
|
|
26
|
+
Models without a registered formatter render via the generic fallback in
|
|
27
|
+
`formatters/generic_table.py` (wrapper responses unwrap to `.result`; list and
|
|
28
|
+
single models become introspected tables), so write a formatter only when the
|
|
29
|
+
generic output needs curating. `-f json` bypasses formatters entirely. `haplohub_cli/core/api/client.py` exposes
|
|
30
|
+
lazy per-resource API accessors over one authenticated SDK client. Command groups
|
|
31
|
+
in `haplohub_cli/commands/` mirror API resources. Tests live in `tests/`
|
|
32
|
+
subpackages next to the code they cover.
|
|
33
|
+
|
|
34
|
+
Model and request class names come from the generated SDK. When the SDK
|
|
35
|
+
regenerates, renames ripple into commands and formatters here. See
|
|
36
|
+
[docs/sdk-coupling.md](docs/sdk-coupling.md) before touching the dependency.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: haplohub-cli
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: HaploHub Command Line Interface
|
|
5
5
|
Author-email: Mike Polcari <mike@haplotype-labs.com>, Ilya Khrustalev <ilya@haplotype-labs.com>
|
|
6
|
-
Requires-Python: >=3.
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Requires-Dist: click>=8.1.8
|
|
9
|
-
Requires-Dist: haplohub<4,>=3.0
|
|
9
|
+
Requires-Dist: haplohub<4,>=3.1.0
|
|
10
10
|
Requires-Dist: pendulum>=3.0.0
|
|
11
11
|
Requires-Dist: requests>=2.32.0
|
|
12
12
|
Requires-Dist: rich>=13.9.4
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Docs
|
|
2
|
+
|
|
3
|
+
Table of contents for this repo's documentation. Read the file that matches your
|
|
4
|
+
task before starting it.
|
|
5
|
+
|
|
6
|
+
- [sdk-coupling.md](sdk-coupling.md) - How this CLI depends on the generated
|
|
7
|
+
`haplohub` SDK: the editable local override, the version constraint policy, and
|
|
8
|
+
why CI stays red until the matching SDK version is on real PyPI.
|
|
9
|
+
- [testing.md](testing.md) - Test conventions: unittest style, `CliRunner`,
|
|
10
|
+
mocking the API client, and the import-time rules that the smoke test enforces.
|
|
11
|
+
- [release.md](release.md) - Versioning and release flow: setuptools_scm tags,
|
|
12
|
+
Test PyPI vs PyPI publishing, the CI matrix, and branch protection gotchas.
|
|
13
|
+
- [stage-verification.md](stage-verification.md) - How to verify the CLI against
|
|
14
|
+
the stage environment end to end without disturbing prod config or real data.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Release and CI
|
|
2
|
+
|
|
3
|
+
## Versioning
|
|
4
|
+
|
|
5
|
+
Versions come from git tags via setuptools_scm (`v3.1.0` style, written to
|
|
6
|
+
`haplohub_cli/_version.py` at build time). There is no version field to edit in
|
|
7
|
+
`pyproject.toml`. The CLI version loosely tracks the API and SDK major version.
|
|
8
|
+
|
|
9
|
+
## Publishing
|
|
10
|
+
|
|
11
|
+
- Push to `main` publishes a dev version (for example `3.0.1.dev2`) to Test PyPI.
|
|
12
|
+
- Pushing a `vX.Y.Z` tag publishes a release to PyPI.
|
|
13
|
+
|
|
14
|
+
So cutting a release is: merge to main, then tag `vX.Y.Z` and push the tag.
|
|
15
|
+
Sensibly, tag only after the matching API version is deployed to prod and the
|
|
16
|
+
matching SDK release exists on PyPI.
|
|
17
|
+
|
|
18
|
+
## CI
|
|
19
|
+
|
|
20
|
+
`on_pr.yml` runs a lint + test matrix on Python 3.10 through 3.13 with
|
|
21
|
+
`uv sync --no-sources` (see sdk-coupling.md for what that implies).
|
|
22
|
+
|
|
23
|
+
Branch protection on `main` requires the matrix job names as status checks
|
|
24
|
+
(`lint (3.10)` ... `lint (3.13)`). If you change the matrix, update the required
|
|
25
|
+
checks in the branch protection settings too, otherwise merges get blocked on
|
|
26
|
+
check names that no longer exist and need an admin override.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SDK coupling
|
|
2
|
+
|
|
3
|
+
The `haplohub` dependency is an OpenAPI-generated SDK. This repo contains no API
|
|
4
|
+
models of its own; every request and response class is imported from the SDK.
|
|
5
|
+
|
|
6
|
+
## Local development
|
|
7
|
+
|
|
8
|
+
`[tool.uv.sources]` overrides `haplohub` to an editable install of the sibling
|
|
9
|
+
checkout `../haplohub-python`. Locally you always run against that checkout's
|
|
10
|
+
current state, whatever version PyPI has. Keep this in mind when results look
|
|
11
|
+
newer or older than the released SDK.
|
|
12
|
+
|
|
13
|
+
## Version constraint
|
|
14
|
+
|
|
15
|
+
`pyproject.toml` pins a range like `haplohub>=3.1.0,<4`. Policy:
|
|
16
|
+
|
|
17
|
+
- The lower bound is the oldest SDK release whose API surface this code uses.
|
|
18
|
+
Raise it whenever code starts using something newer.
|
|
19
|
+
- The upper bound excludes the next major.
|
|
20
|
+
- Do not lower the bound past 3.0.1: the 3.0.0 release on PyPI is broken
|
|
21
|
+
(published from the wrong spec, later yanked).
|
|
22
|
+
|
|
23
|
+
## CI resolves from real PyPI
|
|
24
|
+
|
|
25
|
+
CI installs with `uv sync --no-sources`, which ignores the editable override and
|
|
26
|
+
resolves `haplohub` from pypi.org only. Test PyPI is never consulted.
|
|
27
|
+
Consequence: a PR that needs an unreleased SDK version cannot go green. Release
|
|
28
|
+
the SDK first (see the release docs in `../haplohub-python`), then land the CLI
|
|
29
|
+
change.
|
|
30
|
+
|
|
31
|
+
## Surviving SDK regenerations
|
|
32
|
+
|
|
33
|
+
Regenerations rename response models (for example, the 3.0 change replaced
|
|
34
|
+
`PaginatedResponse*Schema` wrappers with per-operation models like
|
|
35
|
+
`ListMembersResponse`). When bumping the SDK:
|
|
36
|
+
|
|
37
|
+
1. Diff the names the CLI imports against the new SDK exports, and check the
|
|
38
|
+
field shapes (`.result`, `.items`, `total_count`) at every call site.
|
|
39
|
+
2. Update commands and formatter registrations together; formatters are keyed on
|
|
40
|
+
model classes and fail silently at registration time if a class is gone. A
|
|
41
|
+
model that loses its formatter falls back to the generic table renderer, so
|
|
42
|
+
the symptom is degraded (uncurated) output rather than a crash.
|
|
43
|
+
3. Run the test suite. The CLI `--help` smoke test catches import-time breakage.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Stage verification
|
|
2
|
+
|
|
3
|
+
Unit tests never touch the network, so real endpoint behavior is verified
|
|
4
|
+
manually against the stage environment.
|
|
5
|
+
|
|
6
|
+
## Environment switching
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
haplohub config switch https://api.stage.haplohub.com # switch to stage
|
|
10
|
+
haplohub config switch https://api.haplohub.com # switch back when done
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Credentials are stored per environment in `~/.haplohub/credentials.json`, so
|
|
14
|
+
switching does not require a new login if stage credentials already exist.
|
|
15
|
+
Always switch back to prod at the end.
|
|
16
|
+
|
|
17
|
+
## Practices
|
|
18
|
+
|
|
19
|
+
- Use `haplohub -f json <command>` for output you want to parse in scripts.
|
|
20
|
+
- Read endpoints: run against existing cohorts, found via `haplohub cohort list`.
|
|
21
|
+
Prefer a large cohort when exercising pagination.
|
|
22
|
+
- Mutating endpoints: create a clearly named scratch cohort, do all
|
|
23
|
+
create/update/delete work inside it, then delete the cohort. Never mutate
|
|
24
|
+
cohorts you did not create.
|
|
25
|
+
- Uploads materialize asynchronously; a file appears in `file list` some seconds
|
|
26
|
+
after `file upload` returns.
|
|
27
|
+
- Variant queries can take minutes on first access to a sample (cold storage)
|
|
28
|
+
and run in seconds afterwards.
|
|
29
|
+
- Endpoints that launch real work (algorithm runs, report generation) are
|
|
30
|
+
expensive on stage. Skip them in routine smoke tests and note the gap instead.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
Run with `uv run pytest`. Pre-commit runs the suite on every commit, so a
|
|
4
|
+
failing test blocks commits too.
|
|
5
|
+
|
|
6
|
+
## Conventions
|
|
7
|
+
|
|
8
|
+
- unittest `TestCase` classes, plain asserts from unittest, pytest as the runner.
|
|
9
|
+
- Tests live in a `tests/` subpackage next to the code they cover
|
|
10
|
+
(`haplohub_cli/commands/tests/`, `haplohub_cli/auth/tests/`, ...).
|
|
11
|
+
- Invoke commands through `click.testing.CliRunner` and assert on
|
|
12
|
+
`result.exit_code` and `result.output`.
|
|
13
|
+
- Mock the API at the client boundary:
|
|
14
|
+
`patch("haplohub_cli.commands.<module>.client")`. Build fake responses with
|
|
15
|
+
`types.SimpleNamespace`; do not construct real SDK models in unit tests.
|
|
16
|
+
- No network in tests. Anything that talks to a real environment belongs to
|
|
17
|
+
stage verification, not the suite.
|
|
18
|
+
|
|
19
|
+
## Import-time rules
|
|
20
|
+
|
|
21
|
+
`cli.py` imports every command module at startup, so module-level code in
|
|
22
|
+
`commands/` runs on every invocation, including `--help`. Two consequences:
|
|
23
|
+
|
|
24
|
+
- Syntax and annotations evaluated at import must work on the oldest supported
|
|
25
|
+
Python (3.10, see `requires-python`).
|
|
26
|
+
- Keep module level free of slow work or network calls.
|
|
27
|
+
|
|
28
|
+
`haplohub_cli/tests/test_cli.py` invokes `--help` to catch import-time breakage
|
|
29
|
+
(SDK renames, bad annotations). Keep that test passing; it is the canary.
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '3.
|
|
22
|
-
__version_tuple__ = version_tuple = (3,
|
|
21
|
+
__version__ = version = '3.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (3, 2, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gd9453247c'
|
|
@@ -28,6 +28,9 @@ class AuthRequest:
|
|
|
28
28
|
f"&scope={' '.join(self.scopes)}"
|
|
29
29
|
f"&code_challenge={self.code_challenge}"
|
|
30
30
|
f"&code_challenge_method=S256"
|
|
31
|
+
# WorkOS routes the request to the hosted AuthKit sign-in; without
|
|
32
|
+
# a provider the authorize endpoint rejects the request.
|
|
33
|
+
f"&provider=authkit"
|
|
31
34
|
)
|
|
32
35
|
|
|
33
36
|
def generate_code_verifier(self):
|
|
@@ -44,12 +44,6 @@ def formatter_callback(ctx, result, **_):
|
|
|
44
44
|
click.echo(json.dumps(result, default=str))
|
|
45
45
|
return
|
|
46
46
|
|
|
47
|
-
if not formatter_registry.has_formatter(type(result)):
|
|
48
|
-
from pprint import pprint
|
|
49
|
-
|
|
50
|
-
pprint(result)
|
|
51
|
-
return
|
|
52
|
-
|
|
53
47
|
formatter_registry.format(result)
|
|
54
48
|
|
|
55
49
|
|
|
@@ -25,11 +25,36 @@ def file():
|
|
|
25
25
|
@click.option("--cohort", "-c", type=click.STRING, required=True)
|
|
26
26
|
@click.option("--path", "-p", type=click.STRING, required=False)
|
|
27
27
|
@click.option("--recursive", "-r", is_flag=True, required=False)
|
|
28
|
-
|
|
28
|
+
@click.option("--sample", "-s", type=click.STRING, required=False, help="Filter by sample id")
|
|
29
|
+
@click.option("--sample-external-id", "-xs", type=click.STRING, required=False, help="Filter by sample external id")
|
|
30
|
+
@click.option("--member", "-m", type=click.STRING, required=False, help="Filter by member id")
|
|
31
|
+
@click.option("--member-external-id", "-xm", type=click.STRING, required=False, help="Filter by member external id")
|
|
32
|
+
@click.option("--search", type=click.STRING, required=False, help="Search file names")
|
|
33
|
+
@click.option("--offset", type=click.IntRange(min=0), required=False, help="Skip the first N files [default: 0]")
|
|
34
|
+
@click.option("--limit", type=click.IntRange(1, 1000), required=False, help="Page size [default: 100, max: 1000]")
|
|
35
|
+
def list(
|
|
36
|
+
cohort: str,
|
|
37
|
+
path: str = None,
|
|
38
|
+
recursive: bool = False,
|
|
39
|
+
sample: str = None,
|
|
40
|
+
sample_external_id: str = None,
|
|
41
|
+
member: str = None,
|
|
42
|
+
member_external_id: str = None,
|
|
43
|
+
search: str = None,
|
|
44
|
+
offset: int = None,
|
|
45
|
+
limit: int = None,
|
|
46
|
+
):
|
|
29
47
|
return client.file.list_files(
|
|
30
48
|
cohort,
|
|
31
49
|
recursive=recursive,
|
|
32
50
|
path=path,
|
|
51
|
+
sample_id=sample,
|
|
52
|
+
sample_external_id=sample_external_id,
|
|
53
|
+
member_id=member,
|
|
54
|
+
member_external_id=member_external_id,
|
|
55
|
+
search=search,
|
|
56
|
+
offset=offset,
|
|
57
|
+
limit=limit,
|
|
33
58
|
)
|
|
34
59
|
|
|
35
60
|
|
|
@@ -67,13 +92,20 @@ def _resolve_file_id(cohort: str, file_path: str) -> str:
|
|
|
67
92
|
"""Look a file up by its location.
|
|
68
93
|
|
|
69
94
|
The API deletes by id only - unlike downloads, which have a by-path endpoint - so
|
|
70
|
-
the path is resolved here to keep --file-path working on both commands.
|
|
95
|
+
the path is resolved here to keep --file-path working on both commands. Listings
|
|
96
|
+
are paginated server-side, so pages are walked until the file is found.
|
|
71
97
|
"""
|
|
72
|
-
listing = client.file.list_files(cohort, recursive=True)
|
|
73
98
|
wanted = file_path.lstrip("/")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
99
|
+
offset = 0
|
|
100
|
+
while True:
|
|
101
|
+
result = client.file.list_files(cohort, recursive=True, offset=offset, limit=1000).result
|
|
102
|
+
for entry in result.files:
|
|
103
|
+
if entry.location == wanted:
|
|
104
|
+
return entry.id
|
|
105
|
+
|
|
106
|
+
offset += len(result.files)
|
|
107
|
+
if not result.files or offset >= result.total_count:
|
|
108
|
+
break
|
|
77
109
|
|
|
78
110
|
raise click.ClickException(f"No file at '{file_path}' in cohort {cohort}.")
|
|
79
111
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
from unittest import TestCase
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from haplohub_cli.commands.file import _resolve_file_id
|
|
8
|
+
|
|
9
|
+
COHORT = "coh-abc123"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def page(locations, total_count, first_id="fil-1"):
|
|
13
|
+
files = [SimpleNamespace(id=f"{first_id}-{i}", location=loc) for i, loc in enumerate(locations)]
|
|
14
|
+
return SimpleNamespace(result=SimpleNamespace(files=files, total_count=total_count))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ResolveFileIdTestCase(TestCase):
|
|
18
|
+
def test_finds_file_on_first_page(self):
|
|
19
|
+
with patch("haplohub_cli.commands.file.client") as client:
|
|
20
|
+
client.file.list_files.return_value = page(["a.vcf", "b.vcf"], total_count=2)
|
|
21
|
+
|
|
22
|
+
file_id = _resolve_file_id(COHORT, "/b.vcf")
|
|
23
|
+
|
|
24
|
+
self.assertEqual(file_id, "fil-1-1")
|
|
25
|
+
client.file.list_files.assert_called_once_with(COHORT, recursive=True, offset=0, limit=1000)
|
|
26
|
+
|
|
27
|
+
def test_walks_pages_until_found(self):
|
|
28
|
+
pages = [
|
|
29
|
+
page([f"page1/{i}.vcf" for i in range(1000)], total_count=1001),
|
|
30
|
+
page(["deep/target.vcf"], total_count=1001, first_id="fil-2"),
|
|
31
|
+
]
|
|
32
|
+
with patch("haplohub_cli.commands.file.client") as client:
|
|
33
|
+
client.file.list_files.side_effect = pages
|
|
34
|
+
|
|
35
|
+
file_id = _resolve_file_id(COHORT, "deep/target.vcf")
|
|
36
|
+
|
|
37
|
+
self.assertEqual(file_id, "fil-2-0")
|
|
38
|
+
self.assertEqual(client.file.list_files.call_count, 2)
|
|
39
|
+
client.file.list_files.assert_called_with(COHORT, recursive=True, offset=1000, limit=1000)
|
|
40
|
+
|
|
41
|
+
def test_raises_when_not_found(self):
|
|
42
|
+
with patch("haplohub_cli.commands.file.client") as client:
|
|
43
|
+
client.file.list_files.return_value = page(["a.vcf"], total_count=1)
|
|
44
|
+
|
|
45
|
+
with self.assertRaises(click.ClickException):
|
|
46
|
+
_resolve_file_id(COHORT, "missing.vcf")
|
|
47
|
+
|
|
48
|
+
def test_stops_on_empty_page(self):
|
|
49
|
+
# A stale total_count must not loop forever when the server returns no more files.
|
|
50
|
+
with patch("haplohub_cli.commands.file.client") as client:
|
|
51
|
+
client.file.list_files.return_value = page([], total_count=5)
|
|
52
|
+
|
|
53
|
+
with self.assertRaises(click.ClickException):
|
|
54
|
+
_resolve_file_id(COHORT, "missing.vcf")
|
|
55
|
+
|
|
56
|
+
client.file.list_files.assert_called_once()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from functools import cached_property
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from haplohub import ApiClient, Configuration
|
|
5
|
+
|
|
6
|
+
from haplohub_cli import settings
|
|
7
|
+
from haplohub_cli.auth.token_storage import TokenStorage
|
|
8
|
+
from haplohub_cli.config.config_manager import config_manager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Client:
|
|
12
|
+
token_storage = TokenStorage(settings.CREDENTIALS_FILE)
|
|
13
|
+
|
|
14
|
+
@cached_property
|
|
15
|
+
def client(self) -> ApiClient:
|
|
16
|
+
api_url = config_manager.config.api_url
|
|
17
|
+
|
|
18
|
+
if not self.token_storage.credentials_exist(api_url):
|
|
19
|
+
raise click.ClickException(f"No credentials found for {api_url}. Run `haplohub login` first.")
|
|
20
|
+
|
|
21
|
+
if self.token_storage.has_api_key(api_url):
|
|
22
|
+
config = Configuration(
|
|
23
|
+
host=api_url,
|
|
24
|
+
api_key={"ApiKeyAuth": self.token_storage.get_api_key(api_url)},
|
|
25
|
+
)
|
|
26
|
+
else:
|
|
27
|
+
config = Configuration(
|
|
28
|
+
host=api_url,
|
|
29
|
+
access_token=self.token_storage.get_access_token(api_url),
|
|
30
|
+
)
|
|
31
|
+
return ApiClient(config)
|
|
32
|
+
|
|
33
|
+
def __getattr__(self, name: str):
|
|
34
|
+
# client.cohort -> ApiClient.cohort_api, client.member_report -> ApiClient.member_report_api, ...
|
|
35
|
+
if name.startswith("_"):
|
|
36
|
+
raise AttributeError(name)
|
|
37
|
+
return getattr(self.client, f"{name}_api")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
client = Client()
|
|
@@ -48,8 +48,9 @@ class ChecksumTestCase(TestCase):
|
|
|
48
48
|
return False
|
|
49
49
|
|
|
50
50
|
fake_file = FakeFile(data)
|
|
51
|
-
with
|
|
52
|
-
"haplohub_cli.core.checksum.
|
|
51
|
+
with (
|
|
52
|
+
patch("haplohub_cli.core.checksum.open", return_value=fake_file) as mocked_open,
|
|
53
|
+
patch("haplohub_cli.core.checksum.CHUNK_SIZE", 4),
|
|
53
54
|
):
|
|
54
55
|
result = calculate_checksum("dummy/path")
|
|
55
56
|
|
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
from .algorithm import *
|
|
3
3
|
from .algorithm_result import *
|
|
4
4
|
from .biomarker import *
|
|
5
|
-
from .cohort import *
|
|
6
5
|
from .config import *
|
|
7
6
|
from .file import *
|
|
8
7
|
from .generic import *
|
|
9
|
-
from .member import *
|
|
10
|
-
from .metadata import *
|
|
11
|
-
from .sample import *
|
|
12
8
|
from .variant import *
|
|
@@ -3,7 +3,6 @@ from typing import Optional
|
|
|
3
3
|
from haplohub import (
|
|
4
4
|
AlgorithmSchema,
|
|
5
5
|
AlgorithmVersionSchema,
|
|
6
|
-
GetAlgorithmResponse,
|
|
7
6
|
ListAlgorithmsResponse,
|
|
8
7
|
)
|
|
9
8
|
from rich.table import Table
|
|
@@ -28,11 +27,6 @@ def format_algorithm(data: AlgorithmSchema):
|
|
|
28
27
|
return table
|
|
29
28
|
|
|
30
29
|
|
|
31
|
-
@register(GetAlgorithmResponse)
|
|
32
|
-
def format_single_algorithm(data: GetAlgorithmResponse):
|
|
33
|
-
return format_algorithm(data.result)
|
|
34
|
-
|
|
35
|
-
|
|
36
30
|
@register(ListAlgorithmsResponse)
|
|
37
31
|
def format_algorithms(data: ListAlgorithmsResponse):
|
|
38
32
|
table = Table(title="Algorithms", caption=f"Total: {data.total_count}")
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
|
|
3
3
|
from haplohub import (
|
|
4
|
-
AlgorithmResultResponse,
|
|
5
4
|
AlgorithmResultSchema,
|
|
6
5
|
ListAlgorithmResultsResponse,
|
|
7
6
|
)
|
|
@@ -32,11 +31,6 @@ def format_algorithm_result(data: AlgorithmResultSchema):
|
|
|
32
31
|
return table
|
|
33
32
|
|
|
34
33
|
|
|
35
|
-
@register(AlgorithmResultResponse)
|
|
36
|
-
def format_single_algorithm_result(data: AlgorithmResultResponse):
|
|
37
|
-
return format_algorithm_result(data.result)
|
|
38
|
-
|
|
39
|
-
|
|
40
34
|
@register(ListAlgorithmResultsResponse)
|
|
41
35
|
def format_algorithm_results(data: ListAlgorithmResultsResponse):
|
|
42
36
|
table = Table(title="Algorithm Results", caption=f"Total: {data.total_count}")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from haplohub import ListFilesResponse
|
|
2
|
+
from rich.table import Table
|
|
3
|
+
|
|
4
|
+
from haplohub_cli.formatters import utils
|
|
5
|
+
from haplohub_cli.formatters.decorators import register
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@register(ListFilesResponse)
|
|
9
|
+
def format_files(data: ListFilesResponse):
|
|
10
|
+
result = data.result
|
|
11
|
+
table = Table(
|
|
12
|
+
title=f"Contents of {result.location or 'ROOT'}",
|
|
13
|
+
caption=f"Files: {len(result.files)} of {result.total_count}, dirs: {len(result.dirs)}",
|
|
14
|
+
)
|
|
15
|
+
table.add_column("Id")
|
|
16
|
+
table.add_column("File name")
|
|
17
|
+
table.add_column("Size")
|
|
18
|
+
table.add_column("Created")
|
|
19
|
+
|
|
20
|
+
for dir in result.dirs:
|
|
21
|
+
table.add_row("DIR", dir.location + "/")
|
|
22
|
+
|
|
23
|
+
for file in result.files:
|
|
24
|
+
table.add_row(utils.format_id(file.id), file.location, str(file.file_size), utils.format_dt(file.created))
|
|
25
|
+
|
|
26
|
+
return table
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from pprint import pprint
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from haplohub_cli.formatters.generic_table import build_generic
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FormatterRegistry:
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.formatters = {}
|
|
12
|
+
self.console = Console()
|
|
13
|
+
|
|
14
|
+
def register_formatter(self, model_type, formatter):
|
|
15
|
+
self.formatters[model_type] = formatter
|
|
16
|
+
|
|
17
|
+
def format(self, model):
|
|
18
|
+
formatter = self.formatters.get(type(model))
|
|
19
|
+
if formatter is not None:
|
|
20
|
+
self.console.print(formatter(model))
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
# Response wrappers (XResponse with a `result` payload) render as their payload.
|
|
24
|
+
if isinstance(model, BaseModel) and "result" in type(model).model_fields:
|
|
25
|
+
self.format(model.result)
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
output = build_generic(model)
|
|
29
|
+
if output is None:
|
|
30
|
+
pprint(model)
|
|
31
|
+
return
|
|
32
|
+
self.console.print(output)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
formatter_registry = FormatterRegistry()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Fallback rendering for SDK models without a registered formatter.
|
|
2
|
+
|
|
3
|
+
Builds rich tables by introspecting pydantic model fields, so new SDK
|
|
4
|
+
response types render without any formatter code. Register a formatter
|
|
5
|
+
only when the generic output needs curating.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import typing
|
|
11
|
+
from datetime import date, datetime
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
from pydantic_core import to_jsonable_python
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from haplohub_cli.formatters import utils
|
|
18
|
+
|
|
19
|
+
LIST_TRUNCATE = 50
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_generic(model):
|
|
23
|
+
if not isinstance(model, BaseModel):
|
|
24
|
+
return None
|
|
25
|
+
if "items" in type(model).model_fields:
|
|
26
|
+
return _list_table(model)
|
|
27
|
+
return _single_table(model)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _list_table(model):
|
|
31
|
+
items = model.items or []
|
|
32
|
+
total = getattr(model, "total_count", None)
|
|
33
|
+
table = Table(title=_title(type(model)), caption=f"Total: {total if total is not None else len(items)}")
|
|
34
|
+
|
|
35
|
+
item_type = _item_type(type(model))
|
|
36
|
+
if item_type is not None:
|
|
37
|
+
fields = _ordered_fields(item_type)
|
|
38
|
+
for field in fields:
|
|
39
|
+
table.add_column(_header(field))
|
|
40
|
+
for item in items:
|
|
41
|
+
table.add_row(*(_render_value(getattr(item, field), truncate_to=LIST_TRUNCATE) for field in fields))
|
|
42
|
+
else:
|
|
43
|
+
table.add_column("Value")
|
|
44
|
+
for item in items:
|
|
45
|
+
table.add_row(_render_value(item))
|
|
46
|
+
|
|
47
|
+
return table
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _single_table(model):
|
|
51
|
+
fields = _ordered_fields(type(model))
|
|
52
|
+
caption = f"Id: {model.id}" if "id" in fields else None
|
|
53
|
+
table = Table(title=_title(type(model)), caption=caption)
|
|
54
|
+
for field in fields:
|
|
55
|
+
table.add_column(_header(field))
|
|
56
|
+
table.add_row(*(_render_value(getattr(model, field)) for field in fields))
|
|
57
|
+
return table
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _item_type(model_type):
|
|
61
|
+
annotation = model_type.model_fields["items"].annotation
|
|
62
|
+
for arg in typing.get_args(annotation):
|
|
63
|
+
if isinstance(arg, type) and issubclass(arg, BaseModel):
|
|
64
|
+
return arg
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _ordered_fields(model_type):
|
|
69
|
+
# SDK models list fields alphabetically; pin identity up front and timestamps last.
|
|
70
|
+
fields = list(model_type.model_fields)
|
|
71
|
+
head = [f for f in ("id", "name") if f in fields]
|
|
72
|
+
tail = [f for f in fields if f in ("created", "modified")]
|
|
73
|
+
return head + [f for f in fields if f not in head and f not in tail] + tail
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _header(field: str) -> str:
|
|
77
|
+
return field.replace("_", " ").title()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _title(model_type) -> str:
|
|
81
|
+
name = model_type.__name__
|
|
82
|
+
for prefix in ("List", "Get"):
|
|
83
|
+
name = name.removeprefix(prefix)
|
|
84
|
+
for suffix in ("Response", "Schema"):
|
|
85
|
+
name = name.removesuffix(suffix)
|
|
86
|
+
return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", name) or model_type.__name__
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _render_value(value, truncate_to: int | None = None) -> str:
|
|
90
|
+
if value is None:
|
|
91
|
+
return ""
|
|
92
|
+
if isinstance(value, datetime):
|
|
93
|
+
return utils.format_dt(value)
|
|
94
|
+
if isinstance(value, date):
|
|
95
|
+
return utils.format_date(value)
|
|
96
|
+
if hasattr(value, "actual_instance"):
|
|
97
|
+
text = utils.format_id(value)
|
|
98
|
+
elif isinstance(value, (dict, list, BaseModel)):
|
|
99
|
+
text = json.dumps(to_jsonable_python(value), default=str)
|
|
100
|
+
else:
|
|
101
|
+
text = str(value)
|
|
102
|
+
if truncate_to is not None:
|
|
103
|
+
text = utils.truncate(text, truncate_to)
|
|
104
|
+
return text
|
|
File without changes
|