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.
Files changed (97) hide show
  1. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/.github/workflows/on_pr.yml +1 -1
  2. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/.github/workflows/on_push_main.yml +1 -1
  3. haplohub_cli-3.2.0/AGENTS.md +36 -0
  4. haplohub_cli-3.2.0/CLAUDE.md +1 -0
  5. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/PKG-INFO +3 -3
  6. haplohub_cli-3.2.0/docs/index.md +14 -0
  7. haplohub_cli-3.2.0/docs/release.md +26 -0
  8. haplohub_cli-3.2.0/docs/sdk-coupling.md +43 -0
  9. haplohub_cli-3.2.0/docs/stage-verification.md +30 -0
  10. haplohub_cli-3.2.0/docs/testing.md +29 -0
  11. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/_version.py +3 -3
  12. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/oauth_client.py +3 -0
  13. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/cli.py +0 -6
  14. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/file.py +38 -6
  15. haplohub_cli-3.2.0/haplohub_cli/commands/tests/test_file.py +56 -0
  16. haplohub_cli-3.2.0/haplohub_cli/core/api/client.py +40 -0
  17. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/tests/test_checksum.py +3 -2
  18. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/__init__.py +0 -4
  19. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/algorithm.py +0 -6
  20. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/algorithm_result.py +0 -6
  21. haplohub_cli-3.2.0/haplohub_cli/formatters/file.py +26 -0
  22. haplohub_cli-3.2.0/haplohub_cli/formatters/formatter_registry.py +35 -0
  23. haplohub_cli-3.2.0/haplohub_cli/formatters/generic_table.py +104 -0
  24. haplohub_cli-3.2.0/haplohub_cli/formatters/tests/__init__.py +0 -0
  25. haplohub_cli-3.2.0/haplohub_cli/formatters/tests/test_generic_table.py +102 -0
  26. haplohub_cli-3.2.0/haplohub_cli/tests/__init__.py +0 -0
  27. haplohub_cli-3.2.0/haplohub_cli/tests/test_cli.py +16 -0
  28. haplohub_cli-3.2.0/haplohub_cli/types/__init__.py +0 -0
  29. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/PKG-INFO +3 -3
  30. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/SOURCES.txt +14 -4
  31. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/requires.txt +1 -1
  32. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/scm_file_list.json +14 -4
  33. haplohub_cli-3.2.0/haplohub_cli.egg-info/scm_version.json +8 -0
  34. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/pyproject.toml +2 -2
  35. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/uv.lock +9 -314
  36. haplohub_cli-3.0.0/haplohub_cli/core/api/client.py +0 -92
  37. haplohub_cli-3.0.0/haplohub_cli/formatters/cohort.py +0 -39
  38. haplohub_cli-3.0.0/haplohub_cli/formatters/file.py +0 -46
  39. haplohub_cli-3.0.0/haplohub_cli/formatters/formatter_registry.py +0 -21
  40. haplohub_cli-3.0.0/haplohub_cli/formatters/member.py +0 -61
  41. haplohub_cli-3.0.0/haplohub_cli/formatters/metadata.py +0 -27
  42. haplohub_cli-3.0.0/haplohub_cli/formatters/sample.py +0 -41
  43. haplohub_cli-3.0.0/haplohub_cli.egg-info/scm_version.json +0 -8
  44. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/.github/dependabot.yml +0 -0
  45. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/.gitignore +0 -0
  46. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/.pre-commit-config.yaml +0 -0
  47. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/README.md +0 -0
  48. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/__init__.py +0 -0
  49. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/__init__.py +0 -0
  50. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/auth.py +0 -0
  51. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/auth_web_server.py +0 -0
  52. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/tests/__init__.py +0 -0
  53. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/tests/test_auth_web_server.py +0 -0
  54. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/tests/test_token_storage.py +0 -0
  55. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/auth/token_storage.py +0 -0
  56. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/__init__.py +0 -0
  57. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/algorithm/__init__.py +0 -0
  58. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/algorithm/result/__init__.py +0 -0
  59. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/__init__.py +0 -0
  60. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/biomarker.py +0 -0
  61. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/member/__init__.py +0 -0
  62. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/member/report.py +0 -0
  63. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/sample.py +0 -0
  64. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/tests/__init__.py +0 -0
  65. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/cohort/tests/test_delete.py +0 -0
  66. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/config.py +0 -0
  67. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/login.py +0 -0
  68. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/metadata/__init__.py +0 -0
  69. {haplohub_cli-3.0.0/haplohub_cli/config → haplohub_cli-3.2.0/haplohub_cli/commands/tests}/__init__.py +0 -0
  70. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/variant.py +0 -0
  71. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/commands/version.py +0 -0
  72. {haplohub_cli-3.0.0/haplohub_cli/core/api → haplohub_cli-3.2.0/haplohub_cli/config}/__init__.py +0 -0
  73. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/config/config.py +0 -0
  74. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/config/config_manager.py +0 -0
  75. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/__init__.py +0 -0
  76. {haplohub_cli-3.0.0/haplohub_cli/core/tests → haplohub_cli-3.2.0/haplohub_cli/core/api}/__init__.py +0 -0
  77. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/checksum.py +0 -0
  78. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/deletion.py +0 -0
  79. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/network.py +0 -0
  80. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/slug.py +0 -0
  81. {haplohub_cli-3.0.0/haplohub_cli/types → haplohub_cli-3.2.0/haplohub_cli/core/tests}/__init__.py +0 -0
  82. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/tests/test_network.py +0 -0
  83. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/tests/test_slug.py +0 -0
  84. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/types.py +0 -0
  85. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/core/upload.py +0 -0
  86. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/biomarker.py +0 -0
  87. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/config.py +0 -0
  88. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/decorators.py +0 -0
  89. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/generic.py +0 -0
  90. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/utils.py +0 -0
  91. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/formatters/variant.py +0 -0
  92. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/settings.py +0 -0
  93. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli/types/variant_range.py +0 -0
  94. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/dependency_links.txt +0 -0
  95. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/entry_points.txt +0 -0
  96. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/haplohub_cli.egg-info/top_level.txt +0 -0
  97. {haplohub_cli-3.0.0 → haplohub_cli-3.2.0}/setup.cfg +0 -0
@@ -10,7 +10,7 @@ jobs:
10
10
  runs-on: ubuntu-latest
11
11
  strategy:
12
12
  matrix:
13
- python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
13
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
14
14
 
15
15
  steps:
16
16
  - uses: actions/checkout@v6
@@ -65,7 +65,7 @@ jobs:
65
65
  name: Packages
66
66
  path: dist
67
67
  - name: Sign the dists with Sigstore
68
- uses: sigstore/gh-action-sigstore-python@v3.3.0
68
+ uses: sigstore/gh-action-sigstore-python@v3.5.0
69
69
  with:
70
70
  inputs: >-
71
71
  ./dist/*.tar.gz
@@ -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.0.0
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.8
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.1
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.0.0'
22
- __version_tuple__ = version_tuple = (3, 0, 0)
21
+ __version__ = version = '3.2.0'
22
+ __version_tuple__ = version_tuple = (3, 2, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g391c0fcf5'
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
- def list(cohort: str, path: str = None, recursive: bool = False):
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
- for entry in listing.result.files:
75
- if entry.location == wanted:
76
- return entry.id
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 patch("haplohub_cli.core.checksum.open", return_value=fake_file) as mocked_open, patch(
52
- "haplohub_cli.core.checksum.CHUNK_SIZE", 4
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