haplohub-cli 2.4.0__tar.gz → 2.6.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-2.4.0 → haplohub_cli-2.6.0}/PKG-INFO +2 -2
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/_version.py +3 -3
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/auth/auth.py +2 -2
- haplohub_cli-2.6.0/haplohub_cli/auth/tests/test_token_storage.py +74 -0
- haplohub_cli-2.6.0/haplohub_cli/auth/token_storage.py +107 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/algorithm/result/__init__.py +17 -3
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/cohort/__init__.py +1 -1
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/cohort/member/__init__.py +20 -9
- haplohub_cli-2.6.0/haplohub_cli/commands/cohort/sample.py +84 -0
- haplohub_cli-2.6.0/haplohub_cli/commands/cohort/tests/test_delete.py +88 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/config.py +2 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/file.py +18 -3
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/metadata/__init__.py +1 -1
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/config/config_manager.py +2 -1
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/core/api/client.py +16 -5
- haplohub_cli-2.6.0/haplohub_cli/core/deletion.py +28 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/generic.py +9 -3
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/member.py +4 -4
- haplohub_cli-2.6.0/haplohub_cli/types/__init__.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli.egg-info/PKG-INFO +2 -2
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli.egg-info/SOURCES.txt +3 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli.egg-info/requires.txt +1 -1
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli.egg-info/scm_file_list.json +50 -47
- haplohub_cli-2.6.0/haplohub_cli.egg-info/scm_version.json +8 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/pyproject.toml +1 -1
- haplohub_cli-2.4.0/haplohub_cli/auth/tests/test_token_storage.py +0 -64
- haplohub_cli-2.4.0/haplohub_cli/auth/token_storage.py +0 -37
- haplohub_cli-2.4.0/haplohub_cli/commands/cohort/sample.py +0 -51
- haplohub_cli-2.4.0/haplohub_cli.egg-info/scm_version.json +0 -8
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/.github/dependabot.yml +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/.github/workflows/on_pr.yml +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/.github/workflows/on_push_main.yml +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/.gitignore +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/.pre-commit-config.yaml +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/README.md +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/__init__.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/auth/__init__.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/auth/auth_web_server.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/auth/oauth_client.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/auth/tests/__init__.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/auth/tests/test_auth_web_server.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/cli.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/__init__.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/algorithm/__init__.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/cohort/biomarker.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/cohort/member/report.py +0 -0
- {haplohub_cli-2.4.0/haplohub_cli/config → haplohub_cli-2.6.0/haplohub_cli/commands/cohort/tests}/__init__.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/login.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/variant.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/version.py +0 -0
- {haplohub_cli-2.4.0/haplohub_cli/core/api → haplohub_cli-2.6.0/haplohub_cli/config}/__init__.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/config/config.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/core/__init__.py +0 -0
- {haplohub_cli-2.4.0/haplohub_cli/core/tests → haplohub_cli-2.6.0/haplohub_cli/core/api}/__init__.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/core/checksum.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/core/network.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/core/slug.py +0 -0
- {haplohub_cli-2.4.0/haplohub_cli/types → haplohub_cli-2.6.0/haplohub_cli/core/tests}/__init__.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/core/tests/test_checksum.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/core/tests/test_network.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/core/tests/test_slug.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/core/types.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/core/upload.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/__init__.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/algorithm.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/algorithm_result.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/biomarker.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/cohort.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/config.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/decorators.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/file.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/formatter_registry.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/metadata.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/sample.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/utils.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/formatters/variant.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/settings.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/types/variant_range.py +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli.egg-info/dependency_links.txt +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli.egg-info/entry_points.txt +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli.egg-info/top_level.txt +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/setup.cfg +0 -0
- {haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/uv.lock +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: haplohub-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.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
6
|
Requires-Python: >=3.8
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Requires-Dist: click>=8.1.8
|
|
9
|
-
Requires-Dist: haplohub<3,>=2.
|
|
9
|
+
Requires-Dist: haplohub<3,>=2.16.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
|
|
@@ -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 = '2.
|
|
22
|
-
__version_tuple__ = version_tuple = (2,
|
|
21
|
+
__version__ = version = '2.6.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 6, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gaebb47593'
|
|
@@ -20,7 +20,7 @@ def token_login(api_key: str):
|
|
|
20
20
|
|
|
21
21
|
response.raise_for_status()
|
|
22
22
|
|
|
23
|
-
oauth_client.token_storage.store_credentials({"api_key": api_key})
|
|
23
|
+
oauth_client.token_storage.store_credentials(api_url, {"api_key": api_key})
|
|
24
24
|
click.echo("Successfully authenticated with HaploHub.")
|
|
25
25
|
|
|
26
26
|
|
|
@@ -47,4 +47,4 @@ def interactive_login():
|
|
|
47
47
|
credentials = auth_request.exchange_code(auth_code)
|
|
48
48
|
|
|
49
49
|
click.echo("Successfully authenticated with HaploHub.")
|
|
50
|
-
oauth_client.token_storage.store_credentials(credentials)
|
|
50
|
+
oauth_client.token_storage.store_credentials(config_manager.config.api_url, credentials)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pyfakefs.fake_filesystem_unittest import TestCase
|
|
5
|
+
|
|
6
|
+
from haplohub_cli.auth.token_storage import CREDENTIALS_VERSION, TokenStorage, normalize_api_url
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TokenStorageTestCase(TestCase):
|
|
10
|
+
def setUp(self):
|
|
11
|
+
self.setUpPyfakefs()
|
|
12
|
+
self.instance = TokenStorage("/tmp/test_token_storage.json")
|
|
13
|
+
|
|
14
|
+
def test_credentials_exist_should_return_false_when_file_does_not_exist(self):
|
|
15
|
+
self.assertFalse(self.instance.credentials_exist("https://api.example.com"))
|
|
16
|
+
|
|
17
|
+
def test_store_credentials_should_save_versioned_credentials(self):
|
|
18
|
+
self.instance.store_credentials("https://api.example.com", {"access_token": "123456"})
|
|
19
|
+
|
|
20
|
+
with open(self.instance.token_file) as f:
|
|
21
|
+
self.assertEqual(
|
|
22
|
+
json.load(f),
|
|
23
|
+
{
|
|
24
|
+
"version": CREDENTIALS_VERSION,
|
|
25
|
+
"environments": {"https://api.example.com": {"access_token": "123456"}},
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def test_credentials_are_isolated_by_environment(self):
|
|
30
|
+
self.instance.store_credentials("https://api-a.example.com", {"access_token": "token-a"})
|
|
31
|
+
self.instance.store_credentials("https://api-b.example.com", {"api_key": "key-b"})
|
|
32
|
+
|
|
33
|
+
self.assertEqual(self.instance.get_access_token("https://api-a.example.com"), "token-a")
|
|
34
|
+
self.assertEqual(self.instance.get_api_key("https://api-b.example.com"), "key-b")
|
|
35
|
+
self.assertFalse(self.instance.has_api_key("https://api-a.example.com"))
|
|
36
|
+
self.assertTrue(self.instance.has_api_key("https://api-b.example.com"))
|
|
37
|
+
|
|
38
|
+
def test_get_credentials_should_fail_for_unknown_environment(self):
|
|
39
|
+
self.instance.store_credentials("https://api-a.example.com", {"access_token": "token-a"})
|
|
40
|
+
|
|
41
|
+
with pytest.raises(KeyError):
|
|
42
|
+
self.instance.get_credentials("https://api-b.example.com")
|
|
43
|
+
|
|
44
|
+
def test_legacy_credentials_are_migrated_to_current_environment(self):
|
|
45
|
+
credentials = {"access_token": "123456", "refresh_token": "refresh-123456"}
|
|
46
|
+
with open(self.instance.token_file, "w") as f:
|
|
47
|
+
json.dump(credentials, f)
|
|
48
|
+
|
|
49
|
+
self.assertTrue(self.instance.migrate("https://api.example.com/"))
|
|
50
|
+
self.assertEqual(self.instance.get_credentials("https://api.example.com"), credentials)
|
|
51
|
+
|
|
52
|
+
with open(self.instance.token_file) as f:
|
|
53
|
+
self.assertEqual(
|
|
54
|
+
json.load(f),
|
|
55
|
+
{
|
|
56
|
+
"version": CREDENTIALS_VERSION,
|
|
57
|
+
"environments": {"https://api.example.com": credentials},
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def test_versioned_credentials_are_not_migrated_again(self):
|
|
62
|
+
self.instance.store_credentials("https://api-a.example.com", {"access_token": "token-a"})
|
|
63
|
+
|
|
64
|
+
self.assertFalse(self.instance.migrate("https://api-b.example.com"))
|
|
65
|
+
self.assertFalse(self.instance.credentials_exist("https://api-b.example.com"))
|
|
66
|
+
|
|
67
|
+
def test_get_refresh_token_should_return_none_when_missing(self):
|
|
68
|
+
self.instance.store_credentials("https://api.example.com", {"access_token": "123456"})
|
|
69
|
+
|
|
70
|
+
self.assertIsNone(self.instance.get_refresh_token("https://api.example.com"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_normalize_api_url():
|
|
74
|
+
assert normalize_api_url(" HTTPS://API.Example.COM/base/ ") == "https://api.example.com/base"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from genericpath import exists
|
|
5
|
+
from os import path
|
|
6
|
+
from typing import Dict, Tuple
|
|
7
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
8
|
+
|
|
9
|
+
from haplohub_cli import settings
|
|
10
|
+
|
|
11
|
+
CREDENTIALS_VERSION = 2
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_api_url(api_url: str) -> str:
|
|
15
|
+
api_url = api_url.strip()
|
|
16
|
+
parsed = urlsplit(api_url)
|
|
17
|
+
|
|
18
|
+
if not parsed.scheme or not parsed.netloc:
|
|
19
|
+
return api_url.rstrip("/")
|
|
20
|
+
|
|
21
|
+
return urlunsplit(
|
|
22
|
+
(
|
|
23
|
+
parsed.scheme.lower(),
|
|
24
|
+
parsed.netloc.lower(),
|
|
25
|
+
parsed.path.rstrip("/"),
|
|
26
|
+
parsed.query,
|
|
27
|
+
parsed.fragment,
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TokenStorage:
|
|
33
|
+
def __init__(self, token_file: str):
|
|
34
|
+
self.token_file = token_file
|
|
35
|
+
|
|
36
|
+
def migrate(self, api_url: str) -> bool:
|
|
37
|
+
_, migrated = self._load(api_url)
|
|
38
|
+
return migrated
|
|
39
|
+
|
|
40
|
+
def credentials_exist(self, api_url: str) -> bool:
|
|
41
|
+
data, _ = self._load(api_url)
|
|
42
|
+
return normalize_api_url(api_url) in data["environments"]
|
|
43
|
+
|
|
44
|
+
def store_credentials(self, api_url: str, credentials: dict):
|
|
45
|
+
data, _ = self._load(api_url)
|
|
46
|
+
data["environments"][normalize_api_url(api_url)] = credentials
|
|
47
|
+
self._save(data)
|
|
48
|
+
|
|
49
|
+
def get_credentials(self, api_url: str) -> dict:
|
|
50
|
+
data, _ = self._load(api_url)
|
|
51
|
+
return data["environments"][normalize_api_url(api_url)]
|
|
52
|
+
|
|
53
|
+
def get_access_token(self, api_url: str):
|
|
54
|
+
return self.get_credentials(api_url)["access_token"]
|
|
55
|
+
|
|
56
|
+
def get_api_key(self, api_url: str):
|
|
57
|
+
return self.get_credentials(api_url)["api_key"]
|
|
58
|
+
|
|
59
|
+
def get_refresh_token(self, api_url: str):
|
|
60
|
+
return self.get_credentials(api_url).get("refresh_token")
|
|
61
|
+
|
|
62
|
+
def has_api_key(self, api_url: str) -> bool:
|
|
63
|
+
return self.credentials_exist(api_url) and "api_key" in self.get_credentials(api_url)
|
|
64
|
+
|
|
65
|
+
def _load(self, migration_api_url: str) -> Tuple[Dict[str, object], bool]:
|
|
66
|
+
if not exists(self.token_file):
|
|
67
|
+
return self._empty_store(), False
|
|
68
|
+
|
|
69
|
+
with open(self.token_file) as f:
|
|
70
|
+
data = json.load(f)
|
|
71
|
+
|
|
72
|
+
if data.get("version") == CREDENTIALS_VERSION:
|
|
73
|
+
if not isinstance(data.get("environments"), dict):
|
|
74
|
+
raise ValueError("Invalid credentials file: 'environments' must be an object")
|
|
75
|
+
return data, False
|
|
76
|
+
|
|
77
|
+
if "version" in data:
|
|
78
|
+
raise ValueError(f"Unsupported credentials file version: {data['version']}")
|
|
79
|
+
|
|
80
|
+
migrated = self._empty_store()
|
|
81
|
+
migrated["environments"][normalize_api_url(migration_api_url)] = data
|
|
82
|
+
self._save(migrated)
|
|
83
|
+
return migrated, True
|
|
84
|
+
|
|
85
|
+
def _save(self, data: Dict[str, object]):
|
|
86
|
+
directory = path.dirname(self.token_file) or "."
|
|
87
|
+
os.makedirs(directory, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
temporary_file = None
|
|
90
|
+
try:
|
|
91
|
+
with tempfile.NamedTemporaryFile("w", dir=directory, delete=False) as f:
|
|
92
|
+
temporary_file = f.name
|
|
93
|
+
json.dump(data, f, indent=4)
|
|
94
|
+
f.write("\n")
|
|
95
|
+
|
|
96
|
+
os.chmod(temporary_file, 0o600)
|
|
97
|
+
os.replace(temporary_file, self.token_file)
|
|
98
|
+
finally:
|
|
99
|
+
if temporary_file and exists(temporary_file):
|
|
100
|
+
os.unlink(temporary_file)
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _empty_store() -> Dict[str, object]:
|
|
104
|
+
return {"version": CREDENTIALS_VERSION, "environments": {}}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
token_storage = TokenStorage(settings.CREDENTIALS_FILE)
|
{haplohub_cli-2.4.0 → haplohub_cli-2.6.0}/haplohub_cli/commands/algorithm/result/__init__.py
RENAMED
|
@@ -17,10 +17,24 @@ def result():
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
@result.command()
|
|
20
|
-
|
|
20
|
+
@click.option("--algorithm", "-a", type=str, required=False, help="Filter by algorithm name (e.g. pgsc_calc)")
|
|
21
|
+
@click.option("--version", "-v", type=str, required=False, help="Filter by algorithm version")
|
|
22
|
+
@click.option("--cohort", "-c", type=types.HAPLOHUB_ID, required=False, help="Filter by cohort")
|
|
23
|
+
@click.option("--member", "-m", type=types.HAPLOHUB_ID, required=False, help="Filter by member")
|
|
24
|
+
@click.option("--sample", "-s", type=types.HAPLOHUB_ID, required=False, help="Filter by sample")
|
|
25
|
+
def list(
|
|
26
|
+
algorithm: Optional[str] = None,
|
|
27
|
+
version: Optional[str] = None,
|
|
28
|
+
cohort: Optional[str] = None,
|
|
29
|
+
member: Optional[str] = None,
|
|
30
|
+
sample: Optional[str] = None,
|
|
31
|
+
):
|
|
21
32
|
return client.algorithm_result.list_algorithm_results(
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
algorithm=algorithm,
|
|
34
|
+
version=version,
|
|
35
|
+
cohort_id=cohort,
|
|
36
|
+
member_id=member,
|
|
37
|
+
sample_id=sample,
|
|
24
38
|
)
|
|
25
39
|
|
|
26
40
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import click
|
|
2
|
-
from haplohub import CreateMemberRequest, UpdateMemberRequest
|
|
2
|
+
from haplohub import CreateMemberRequest, DeleteMode, UpdateMemberRequest
|
|
3
3
|
|
|
4
4
|
from haplohub_cli.core import types
|
|
5
5
|
from haplohub_cli.core.api.client import client
|
|
6
|
+
from haplohub_cli.core.deletion import DELETE_MODE_CHOICES, DELETE_MODE_HELP, confirm_hard_delete
|
|
6
7
|
|
|
7
8
|
from . import report
|
|
8
9
|
|
|
@@ -23,14 +24,14 @@ def list(cohort: str):
|
|
|
23
24
|
|
|
24
25
|
@member.command()
|
|
25
26
|
@click.option("--cohort", "-c", type=types.HAPLOHUB_ID, required=True)
|
|
26
|
-
@click.option("--
|
|
27
|
+
@click.option("--external-id", "-x", type=str, required=False)
|
|
27
28
|
@click.option("--first-name", "-fn", type=str, required=False)
|
|
28
29
|
@click.option("--last-name", "-ln", type=str, required=False)
|
|
29
30
|
@click.option("--gender", "-g", type=str, required=False)
|
|
30
31
|
@click.option("--birth-date", "-bd", type=str, required=False)
|
|
31
|
-
def create(cohort,
|
|
32
|
+
def create(cohort, external_id=None, first_name=None, last_name=None, gender=None, birth_date=None):
|
|
32
33
|
request = CreateMemberRequest(
|
|
33
|
-
|
|
34
|
+
external_id=external_id,
|
|
34
35
|
first_name=first_name,
|
|
35
36
|
last_name=last_name,
|
|
36
37
|
gender=gender,
|
|
@@ -42,21 +43,31 @@ def create(cohort, patient_id=None, first_name=None, last_name=None, gender=None
|
|
|
42
43
|
@member.command()
|
|
43
44
|
@click.argument("id")
|
|
44
45
|
@click.option("--cohort", "-c", type=types.HAPLOHUB_ID, required=True)
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
@click.option(
|
|
47
|
+
"--mode",
|
|
48
|
+
"-m",
|
|
49
|
+
type=click.Choice(DELETE_MODE_CHOICES),
|
|
50
|
+
default=DeleteMode.SOFT.value,
|
|
51
|
+
show_default=True,
|
|
52
|
+
help=DELETE_MODE_HELP,
|
|
53
|
+
)
|
|
54
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt for --mode hard.")
|
|
55
|
+
def delete(id, cohort, mode, yes):
|
|
56
|
+
confirm_hard_delete(mode, "member", id, yes)
|
|
57
|
+
return client.member.delete_member(cohort, id, mode=mode)
|
|
47
58
|
|
|
48
59
|
|
|
49
60
|
@member.command()
|
|
50
61
|
@click.argument("id")
|
|
51
62
|
@click.option("--cohort", "-c", type=types.HAPLOHUB_ID, required=True)
|
|
52
|
-
@click.option("--
|
|
63
|
+
@click.option("--external-id", "-x", type=str, required=False)
|
|
53
64
|
@click.option("--first-name", "-fn", type=str, required=False)
|
|
54
65
|
@click.option("--last-name", "-ln", type=str, required=False)
|
|
55
66
|
@click.option("--gender", "-g", type=str, required=False)
|
|
56
67
|
@click.option("--birth-date", "-bd", type=str, required=False)
|
|
57
|
-
def update(id, cohort,
|
|
68
|
+
def update(id, cohort, external_id=None, first_name=None, last_name=None, gender=None, birth_date=None):
|
|
58
69
|
request = UpdateMemberRequest(
|
|
59
|
-
|
|
70
|
+
external_id=external_id,
|
|
60
71
|
first_name=first_name,
|
|
61
72
|
last_name=last_name,
|
|
62
73
|
gender=gender,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from haplohub import CreateSampleRequest, DeleteMode, UpdateSampleRequest
|
|
3
|
+
|
|
4
|
+
from haplohub_cli.core import types
|
|
5
|
+
from haplohub_cli.core.api.client import client
|
|
6
|
+
from haplohub_cli.core.deletion import DELETE_MODE_CHOICES, DELETE_MODE_HELP, confirm_hard_delete
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group()
|
|
10
|
+
def sample():
|
|
11
|
+
"""
|
|
12
|
+
Manage samples
|
|
13
|
+
"""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@sample.command()
|
|
18
|
+
@click.option("--cohort", "-c", type=types.HAPLOHUB_ID, required=True)
|
|
19
|
+
def list(cohort: str):
|
|
20
|
+
return client.sample.list_samples(cohort)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@sample.command()
|
|
24
|
+
@click.option("--cohort", "-c", type=types.HAPLOHUB_ID, required=True)
|
|
25
|
+
@click.option(
|
|
26
|
+
"--external-id",
|
|
27
|
+
"-x",
|
|
28
|
+
type=click.STRING,
|
|
29
|
+
required=False,
|
|
30
|
+
help="Sample identity, uppercase alphanumeric starting with a letter. Generated when omitted.",
|
|
31
|
+
)
|
|
32
|
+
@click.option("--member", "-m", type=types.HAPLOHUB_ID, required=False, help="Member to attach the sample to.")
|
|
33
|
+
@click.option(
|
|
34
|
+
"--member-external-id",
|
|
35
|
+
"-xm",
|
|
36
|
+
type=click.STRING,
|
|
37
|
+
required=False,
|
|
38
|
+
help="Member to attach the sample to, by external id. The member is created when it does not exist.",
|
|
39
|
+
)
|
|
40
|
+
def create(cohort, external_id=None, member=None, member_external_id=None):
|
|
41
|
+
request = CreateSampleRequest(
|
|
42
|
+
external_id=external_id,
|
|
43
|
+
member_id=member,
|
|
44
|
+
member_external_id=member_external_id,
|
|
45
|
+
)
|
|
46
|
+
return client.sample.create_sample(cohort, request)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@sample.command()
|
|
50
|
+
@click.argument("id")
|
|
51
|
+
@click.option("--cohort", "-c", type=types.HAPLOHUB_ID, required=True)
|
|
52
|
+
@click.option(
|
|
53
|
+
"--mode",
|
|
54
|
+
"-m",
|
|
55
|
+
type=click.Choice(DELETE_MODE_CHOICES),
|
|
56
|
+
default=DeleteMode.SOFT.value,
|
|
57
|
+
show_default=True,
|
|
58
|
+
help=DELETE_MODE_HELP,
|
|
59
|
+
)
|
|
60
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt for --mode hard.")
|
|
61
|
+
def delete(id, cohort, mode, yes):
|
|
62
|
+
confirm_hard_delete(mode, "sample", id, yes)
|
|
63
|
+
return client.sample.delete_sample(cohort, id, mode=mode)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@sample.command()
|
|
67
|
+
@click.argument("id")
|
|
68
|
+
@click.option("--cohort", "-c", type=types.HAPLOHUB_ID, required=True)
|
|
69
|
+
@click.option("--member", "-m", type=types.HAPLOHUB_ID, required=False, help="Member to attach the sample to.")
|
|
70
|
+
@click.option(
|
|
71
|
+
"--member-external-id",
|
|
72
|
+
"-xm",
|
|
73
|
+
type=click.STRING,
|
|
74
|
+
required=False,
|
|
75
|
+
help="Member to attach the sample to, by external id.",
|
|
76
|
+
)
|
|
77
|
+
def update(id, cohort, member=None, member_external_id=None):
|
|
78
|
+
# external_id is the sample's immutable identity, embedded in the BCFs; only the
|
|
79
|
+
# member association can be changed.
|
|
80
|
+
request = UpdateSampleRequest(
|
|
81
|
+
member_id=member,
|
|
82
|
+
member_external_id=member_external_id,
|
|
83
|
+
)
|
|
84
|
+
return client.sample.update_sample(cohort, id, request)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from unittest import TestCase
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
|
|
6
|
+
from haplohub_cli.commands.cohort.member import member
|
|
7
|
+
from haplohub_cli.commands.cohort.sample import sample
|
|
8
|
+
|
|
9
|
+
COHORT = "coh-abc123"
|
|
10
|
+
MEMBER = "mbr-abc123"
|
|
11
|
+
SAMPLE = "smp-abc123"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeleteMemberTestCase(TestCase):
|
|
15
|
+
def setUp(self):
|
|
16
|
+
self.runner = CliRunner()
|
|
17
|
+
|
|
18
|
+
def run_delete(self, *args, input=None):
|
|
19
|
+
with patch("haplohub_cli.commands.cohort.member.client") as client:
|
|
20
|
+
result = self.runner.invoke(member, ["delete", MEMBER, "-c", COHORT, *args], input=input)
|
|
21
|
+
return result, client.member.delete_member
|
|
22
|
+
|
|
23
|
+
def test_defaults_to_soft(self):
|
|
24
|
+
result, delete_member = self.run_delete()
|
|
25
|
+
|
|
26
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
27
|
+
delete_member.assert_called_once_with(COHORT, MEMBER, mode="soft")
|
|
28
|
+
|
|
29
|
+
def test_cascade_needs_no_confirmation(self):
|
|
30
|
+
result, delete_member = self.run_delete("--mode", "cascade")
|
|
31
|
+
|
|
32
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
33
|
+
delete_member.assert_called_once_with(COHORT, MEMBER, mode="cascade")
|
|
34
|
+
|
|
35
|
+
def test_hard_asks_for_confirmation(self):
|
|
36
|
+
result, delete_member = self.run_delete("--mode", "hard", input="y\n")
|
|
37
|
+
|
|
38
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
39
|
+
self.assertIn("cannot be undone", result.output)
|
|
40
|
+
delete_member.assert_called_once_with(COHORT, MEMBER, mode="hard")
|
|
41
|
+
|
|
42
|
+
def test_hard_aborts_when_declined(self):
|
|
43
|
+
result, delete_member = self.run_delete("--mode", "hard", input="n\n")
|
|
44
|
+
|
|
45
|
+
self.assertEqual(result.exit_code, 1)
|
|
46
|
+
delete_member.assert_not_called()
|
|
47
|
+
|
|
48
|
+
def test_hard_skips_confirmation_with_yes(self):
|
|
49
|
+
result, delete_member = self.run_delete("--mode", "hard", "--yes")
|
|
50
|
+
|
|
51
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
52
|
+
self.assertNotIn("cannot be undone", result.output)
|
|
53
|
+
delete_member.assert_called_once_with(COHORT, MEMBER, mode="hard")
|
|
54
|
+
|
|
55
|
+
def test_rejects_an_unknown_mode(self):
|
|
56
|
+
result, delete_member = self.run_delete("--mode", "nuke")
|
|
57
|
+
|
|
58
|
+
self.assertEqual(result.exit_code, 2)
|
|
59
|
+
delete_member.assert_not_called()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DeleteSampleTestCase(TestCase):
|
|
63
|
+
def setUp(self):
|
|
64
|
+
self.runner = CliRunner()
|
|
65
|
+
|
|
66
|
+
def run_delete(self, *args, input=None):
|
|
67
|
+
with patch("haplohub_cli.commands.cohort.sample.client") as client:
|
|
68
|
+
result = self.runner.invoke(sample, ["delete", SAMPLE, "-c", COHORT, *args], input=input)
|
|
69
|
+
return result, client.sample.delete_sample
|
|
70
|
+
|
|
71
|
+
def test_defaults_to_soft(self):
|
|
72
|
+
result, delete_sample = self.run_delete()
|
|
73
|
+
|
|
74
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
75
|
+
delete_sample.assert_called_once_with(COHORT, SAMPLE, mode="soft")
|
|
76
|
+
|
|
77
|
+
def test_hard_asks_for_confirmation(self):
|
|
78
|
+
result, delete_sample = self.run_delete("--mode", "hard", input="y\n")
|
|
79
|
+
|
|
80
|
+
self.assertEqual(result.exit_code, 0, result.output)
|
|
81
|
+
self.assertIn("cannot be undone", result.output)
|
|
82
|
+
delete_sample.assert_called_once_with(COHORT, SAMPLE, mode="hard")
|
|
83
|
+
|
|
84
|
+
def test_hard_aborts_when_declined(self):
|
|
85
|
+
result, delete_sample = self.run_delete("--mode", "hard", input="n\n")
|
|
86
|
+
|
|
87
|
+
self.assertEqual(result.exit_code, 1)
|
|
88
|
+
delete_sample.assert_not_called()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import click
|
|
2
2
|
|
|
3
|
+
from haplohub_cli.auth.token_storage import token_storage
|
|
3
4
|
from haplohub_cli.config.config_manager import config_manager
|
|
4
5
|
|
|
5
6
|
|
|
@@ -28,6 +29,7 @@ def set(key, value):
|
|
|
28
29
|
@config.command()
|
|
29
30
|
@click.argument("api_url", type=click.STRING)
|
|
30
31
|
def switch(api_url):
|
|
32
|
+
token_storage.migrate(config_manager.config.api_url)
|
|
31
33
|
config_manager.switch_environment(api_url)
|
|
32
34
|
return config_manager.config
|
|
33
35
|
|
|
@@ -63,6 +63,21 @@ def download(cohort: str, file_id: str = None, file_path: str = None, output: st
|
|
|
63
63
|
click.echo(f"File saved to {output}")
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def _resolve_file_id(cohort: str, file_path: str) -> str:
|
|
67
|
+
"""Look a file up by its location.
|
|
68
|
+
|
|
69
|
+
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.
|
|
71
|
+
"""
|
|
72
|
+
listing = client.file.list_files(cohort, recursive=True)
|
|
73
|
+
wanted = file_path.lstrip("/")
|
|
74
|
+
for entry in listing.result.files:
|
|
75
|
+
if entry.location == wanted:
|
|
76
|
+
return entry.id
|
|
77
|
+
|
|
78
|
+
raise click.ClickException(f"No file at '{file_path}' in cohort {cohort}.")
|
|
79
|
+
|
|
80
|
+
|
|
66
81
|
@file.command()
|
|
67
82
|
@click.option("--cohort", "-c", type=click.STRING, required=True)
|
|
68
83
|
@click.option("--file-id", "-i", type=click.STRING, required=False)
|
|
@@ -74,10 +89,10 @@ def delete(cohort: str, file_id: str = None, file_path: str = None):
|
|
|
74
89
|
if not file_id and not file_path:
|
|
75
90
|
raise click.UsageError("Must specify either --file-id or --file-path")
|
|
76
91
|
|
|
77
|
-
if file_id:
|
|
78
|
-
|
|
92
|
+
if not file_id:
|
|
93
|
+
file_id = _resolve_file_id(cohort, file_path)
|
|
79
94
|
|
|
80
|
-
return client.file.
|
|
95
|
+
return client.file.delete_file(cohort, file_id)
|
|
81
96
|
|
|
82
97
|
|
|
83
98
|
@file.command()
|
|
@@ -37,6 +37,7 @@ class ConfigManager:
|
|
|
37
37
|
return self._config
|
|
38
38
|
|
|
39
39
|
def switch_environment(self, api_url: str):
|
|
40
|
+
api_url = api_url.strip().rstrip("/")
|
|
40
41
|
response = requests.get(f"{api_url}/api/v1/config/")
|
|
41
42
|
response.raise_for_status()
|
|
42
43
|
data = response.json()
|
|
@@ -44,7 +45,7 @@ class ConfigManager:
|
|
|
44
45
|
if data["status"] != "success":
|
|
45
46
|
raise Exception(data.get("error", "Unknown error"))
|
|
46
47
|
|
|
47
|
-
new_config = {"redirect_port": settings.REDIRECT_PORT, "api_url": api_url
|
|
48
|
+
new_config = {**data["result"]["cli"], "redirect_port": settings.REDIRECT_PORT, "api_url": api_url}
|
|
48
49
|
|
|
49
50
|
self._config = Config(**new_config)
|
|
50
51
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from functools import cached_property
|
|
2
2
|
|
|
3
|
+
import click
|
|
3
4
|
from haplohub import (
|
|
5
|
+
AccessionApi,
|
|
4
6
|
AlgorithmApi,
|
|
5
7
|
AlgorithmResultApi,
|
|
6
8
|
ApiClient,
|
|
@@ -25,15 +27,20 @@ class Client:
|
|
|
25
27
|
|
|
26
28
|
@cached_property
|
|
27
29
|
def client(self):
|
|
28
|
-
|
|
30
|
+
api_url = config_manager.config.api_url
|
|
31
|
+
|
|
32
|
+
if not self.token_storage.credentials_exist(api_url):
|
|
33
|
+
raise click.ClickException(f"No credentials found for {api_url}. Run `haplohub login` first.")
|
|
34
|
+
|
|
35
|
+
if self.token_storage.has_api_key(api_url):
|
|
29
36
|
config = Configuration(
|
|
30
|
-
host=
|
|
31
|
-
api_key={"ApiKeyAuth": self.token_storage.get_api_key()},
|
|
37
|
+
host=api_url,
|
|
38
|
+
api_key={"ApiKeyAuth": self.token_storage.get_api_key(api_url)},
|
|
32
39
|
)
|
|
33
40
|
else:
|
|
34
41
|
config = Configuration(
|
|
35
|
-
host=
|
|
36
|
-
access_token=self.token_storage.get_access_token(),
|
|
42
|
+
host=api_url,
|
|
43
|
+
access_token=self.token_storage.get_access_token(api_url),
|
|
37
44
|
)
|
|
38
45
|
return ApiClient(config)
|
|
39
46
|
|
|
@@ -77,5 +84,9 @@ class Client:
|
|
|
77
84
|
def algorithm_result(self):
|
|
78
85
|
return AlgorithmResultApi(self.client)
|
|
79
86
|
|
|
87
|
+
@cached_property
|
|
88
|
+
def accession(self):
|
|
89
|
+
return AccessionApi(self.client)
|
|
90
|
+
|
|
80
91
|
|
|
81
92
|
client = Client()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from haplohub import DeleteMode
|
|
3
|
+
|
|
4
|
+
# The generated client drops `mode` straight into the query string, so the commands pass
|
|
5
|
+
# the plain value rather than the enum member.
|
|
6
|
+
DELETE_MODE_CHOICES = [mode.value for mode in DeleteMode]
|
|
7
|
+
|
|
8
|
+
DELETE_MODE_HELP = (
|
|
9
|
+
"How far the delete reaches. "
|
|
10
|
+
"soft: the record only. "
|
|
11
|
+
"cascade: the record plus everything derived from it, files included; recoverable. "
|
|
12
|
+
"hard: irreversible, erases the records permanently and removes every version of their files."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def confirm_hard_delete(mode: str, entity: str, identifier: str, assume_yes: bool) -> None:
|
|
17
|
+
"""Guard `--mode hard`, which cannot be undone.
|
|
18
|
+
|
|
19
|
+
Softer modes leave the rows recoverable and the blobs restorable, so they go through
|
|
20
|
+
without a prompt.
|
|
21
|
+
"""
|
|
22
|
+
if mode != DeleteMode.HARD.value or assume_yes:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
click.confirm(
|
|
26
|
+
f"Permanently erase {entity} {identifier} and every version of its files? This cannot be undone.",
|
|
27
|
+
abort=True,
|
|
28
|
+
)
|