haplohub-cli 2.3.0__tar.gz → 2.5.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 (79) hide show
  1. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/PKG-INFO +1 -1
  2. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/_version.py +3 -3
  3. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/auth/auth.py +2 -2
  4. haplohub_cli-2.5.0/haplohub_cli/auth/tests/test_token_storage.py +74 -0
  5. haplohub_cli-2.5.0/haplohub_cli/auth/token_storage.py +107 -0
  6. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/config.py +2 -0
  7. haplohub_cli-2.5.0/haplohub_cli/commands/variant.py +124 -0
  8. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/config/config_manager.py +2 -1
  9. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/api/client.py +11 -5
  10. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/algorithm.py +12 -6
  11. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/variant.py +20 -3
  12. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli.egg-info/PKG-INFO +1 -1
  13. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli.egg-info/SOURCES.txt +2 -0
  14. haplohub_cli-2.5.0/haplohub_cli.egg-info/scm_file_list.json +69 -0
  15. haplohub_cli-2.5.0/haplohub_cli.egg-info/scm_version.json +8 -0
  16. haplohub_cli-2.3.0/haplohub_cli/auth/tests/test_token_storage.py +0 -64
  17. haplohub_cli-2.3.0/haplohub_cli/auth/token_storage.py +0 -37
  18. haplohub_cli-2.3.0/haplohub_cli/commands/variant.py +0 -66
  19. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/.github/dependabot.yml +0 -0
  20. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/.github/workflows/on_pr.yml +0 -0
  21. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/.github/workflows/on_push_main.yml +0 -0
  22. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/.gitignore +0 -0
  23. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/.pre-commit-config.yaml +0 -0
  24. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/README.md +0 -0
  25. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/__init__.py +0 -0
  26. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/auth/__init__.py +0 -0
  27. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/auth/auth_web_server.py +0 -0
  28. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/auth/oauth_client.py +0 -0
  29. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/auth/tests/__init__.py +0 -0
  30. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/auth/tests/test_auth_web_server.py +0 -0
  31. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/cli.py +0 -0
  32. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/__init__.py +0 -0
  33. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/algorithm/__init__.py +0 -0
  34. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/algorithm/result/__init__.py +0 -0
  35. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/cohort/__init__.py +0 -0
  36. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/cohort/biomarker.py +0 -0
  37. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/cohort/member/__init__.py +0 -0
  38. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/cohort/member/report.py +0 -0
  39. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/cohort/sample.py +0 -0
  40. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/file.py +0 -0
  41. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/login.py +0 -0
  42. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/metadata/__init__.py +0 -0
  43. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/commands/version.py +0 -0
  44. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/config/__init__.py +0 -0
  45. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/config/config.py +0 -0
  46. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/__init__.py +0 -0
  47. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/api/__init__.py +0 -0
  48. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/checksum.py +0 -0
  49. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/network.py +0 -0
  50. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/slug.py +0 -0
  51. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/tests/__init__.py +0 -0
  52. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/tests/test_checksum.py +0 -0
  53. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/tests/test_network.py +0 -0
  54. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/tests/test_slug.py +0 -0
  55. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/types.py +0 -0
  56. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/core/upload.py +0 -0
  57. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/__init__.py +0 -0
  58. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/algorithm_result.py +0 -0
  59. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/biomarker.py +0 -0
  60. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/cohort.py +0 -0
  61. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/config.py +0 -0
  62. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/decorators.py +0 -0
  63. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/file.py +0 -0
  64. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/formatter_registry.py +0 -0
  65. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/generic.py +0 -0
  66. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/member.py +0 -0
  67. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/metadata.py +0 -0
  68. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/sample.py +0 -0
  69. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/formatters/utils.py +0 -0
  70. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/settings.py +0 -0
  71. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/types/__init__.py +0 -0
  72. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli/types/variant_range.py +0 -0
  73. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli.egg-info/dependency_links.txt +0 -0
  74. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli.egg-info/entry_points.txt +0 -0
  75. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli.egg-info/requires.txt +0 -0
  76. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/haplohub_cli.egg-info/top_level.txt +0 -0
  77. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/pyproject.toml +0 -0
  78. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/setup.cfg +0 -0
  79. {haplohub_cli-2.3.0 → haplohub_cli-2.5.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haplohub-cli
3
- Version: 2.3.0
3
+ Version: 2.5.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
@@ -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.3.0'
22
- __version_tuple__ = version_tuple = (2, 3, 0)
21
+ __version__ = version = '2.5.0'
22
+ __version_tuple__ = version_tuple = (2, 5, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g6d8dda047'
24
+ __commit_id__ = commit_id = 'gcd8a204d2'
@@ -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)
@@ -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
 
@@ -0,0 +1,124 @@
1
+ import click
2
+ from haplohub import (
3
+ GetVariantRequest,
4
+ GwasTargetSchema,
5
+ HgvsTargetSchema,
6
+ RegionTargetSchema,
7
+ TargetsInner,
8
+ VariantFilterSchema,
9
+ )
10
+
11
+ from haplohub_cli.core.api.client import client
12
+
13
+
14
+ def _selectors(clinvar: bool, gwas: bool) -> list[str] | None:
15
+ """Build the selector list; None when no annotations were requested."""
16
+ selectors = []
17
+ if clinvar:
18
+ selectors.append("clinvar")
19
+ if gwas:
20
+ selectors.append("gwas")
21
+ return selectors or None
22
+
23
+
24
+ @click.group()
25
+ def variant():
26
+ """
27
+ Work with variants
28
+ """
29
+ pass
30
+
31
+
32
+ @variant.command()
33
+ @click.argument("cohort_id", type=str)
34
+ @click.option("--sample", "-s", "sample_ids", type=str, multiple=True, required=True, help="Sample ID (repeatable)")
35
+ @click.option("--accession", "-a", type=str, required=True, help="Chromosome accession (e.g. NC_000001.11)")
36
+ @click.option("--start", type=int, required=True, help="Genomic start position")
37
+ @click.option("--end", type=int, required=True, help="Genomic end position")
38
+ @click.option("--reference", type=str, default=None, help="Reference allele")
39
+ @click.option("--alternate", type=str, default=None, help="Alternate allele")
40
+ @click.option("--call-state", type=click.Choice(["called", "all"]), default="called", help="Filter by call state")
41
+ @click.option("--clinvar", is_flag=True, default=False, help="Include ClinVar annotations")
42
+ @click.option("--gwas", is_flag=True, default=False, help="Include GWAS Catalog annotations")
43
+ def region(sample_ids, cohort_id, accession, start, end, reference, alternate, call_state, clinvar, gwas):
44
+ """
45
+ Query variants by genomic region
46
+ """
47
+ target = RegionTargetSchema(
48
+ accession=accession,
49
+ start=start,
50
+ end=end,
51
+ reference=reference,
52
+ alternate=alternate,
53
+ )
54
+
55
+ request = GetVariantRequest(
56
+ sample_ids=list(sample_ids),
57
+ targets=[TargetsInner(target)],
58
+ filters=VariantFilterSchema(call_state=call_state),
59
+ selectors=_selectors(clinvar, gwas),
60
+ )
61
+
62
+ return client.variant.get_variant(cohort_id, request)
63
+
64
+
65
+ @variant.command()
66
+ @click.argument("cohort_id", type=str)
67
+ @click.option("--sample", "-s", "sample_ids", type=str, multiple=True, required=True, help="Sample ID (repeatable)")
68
+ @click.option("--hgvs", "-h", type=str, multiple=True, required=True, help="HGVS notation (repeatable)")
69
+ @click.option("--call-state", type=click.Choice(["called", "all"]), default="called", help="Filter by call state")
70
+ @click.option("--clinvar", is_flag=True, default=False, help="Include ClinVar annotations")
71
+ @click.option("--gwas", is_flag=True, default=False, help="Include GWAS Catalog annotations")
72
+ def hgvs(sample_ids, cohort_id, hgvs, call_state, clinvar, gwas):
73
+ """
74
+ Query variants by HGVS notation
75
+ """
76
+ targets = [TargetsInner(HgvsTargetSchema(value=v)) for v in hgvs]
77
+
78
+ request = GetVariantRequest(
79
+ sample_ids=list(sample_ids),
80
+ targets=targets,
81
+ filters=VariantFilterSchema(call_state=call_state),
82
+ selectors=_selectors(clinvar, gwas),
83
+ )
84
+
85
+ return client.variant.get_variant(cohort_id, request)
86
+
87
+
88
+ @variant.command()
89
+ @click.argument("cohort_id", type=str)
90
+ @click.option("--sample", "-s", "sample_ids", type=str, multiple=True, required=True, help="Sample ID (repeatable)")
91
+ @click.option("--gene", type=str, default=None, help="Mapped gene symbol (e.g. TCF7L2)")
92
+ @click.option("--trait-id", type=str, default=None, help="EFO trait id (e.g. EFO_0001360)")
93
+ @click.option("--rs", "rs_ids", type=str, multiple=True, help="rsID to genotype (repeatable)")
94
+ @click.option("--p-value-max", type=float, default=None, help="Keep only associations with p-value <= this")
95
+ @click.option("--limit", type=int, default=100, help="Max associations to resolve (most significant first)")
96
+ @click.option("--call-state", type=click.Choice(["called", "all"]), default="called", help="Filter by call state")
97
+ @click.option("--clinvar", is_flag=True, default=False, help="Also include ClinVar annotations")
98
+ def gwas(sample_ids, cohort_id, gene, trait_id, rs_ids, p_value_max, limit, call_state, clinvar):
99
+ """
100
+ Genotype the SNPs of GWAS Catalog associations in a sample.
101
+
102
+ Selects associations by --gene, --trait-id and/or --rs (at least one required),
103
+ genotypes their SNP positions in the sample, and attaches the matching GWAS
104
+ associations to each result. Combine with --clinvar to also attach ClinVar.
105
+ """
106
+ if not (gene or trait_id or rs_ids):
107
+ raise click.UsageError("Provide at least one of --gene, --trait-id or --rs.")
108
+
109
+ target = GwasTargetSchema(
110
+ gene=gene,
111
+ trait_id=trait_id,
112
+ rs_ids=list(rs_ids),
113
+ p_value_max=p_value_max,
114
+ limit=limit,
115
+ )
116
+
117
+ request = GetVariantRequest(
118
+ sample_ids=list(sample_ids),
119
+ targets=[TargetsInner(target)],
120
+ filters=VariantFilterSchema(call_state=call_state),
121
+ selectors=_selectors(clinvar, gwas=True),
122
+ )
123
+
124
+ return client.variant.get_variant(cohort_id, request)
@@ -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, **data["result"]["cli"]}
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,5 +1,6 @@
1
1
  from functools import cached_property
2
2
 
3
+ import click
3
4
  from haplohub import (
4
5
  AlgorithmApi,
5
6
  AlgorithmResultApi,
@@ -25,15 +26,20 @@ class Client:
25
26
 
26
27
  @cached_property
27
28
  def client(self):
28
- if self.token_storage.has_api_key:
29
+ api_url = config_manager.config.api_url
30
+
31
+ if not self.token_storage.credentials_exist(api_url):
32
+ raise click.ClickException(f"No credentials found for {api_url}. Run `haplohub login` first.")
33
+
34
+ if self.token_storage.has_api_key(api_url):
29
35
  config = Configuration(
30
- host=config_manager.config.api_url,
31
- api_key={"ApiKeyAuth": self.token_storage.get_api_key()},
36
+ host=api_url,
37
+ api_key={"ApiKeyAuth": self.token_storage.get_api_key(api_url)},
32
38
  )
33
39
  else:
34
40
  config = Configuration(
35
- host=config_manager.config.api_url,
36
- access_token=self.token_storage.get_access_token(),
41
+ host=api_url,
42
+ access_token=self.token_storage.get_access_token(api_url),
37
43
  )
38
44
  return ApiClient(config)
39
45
 
@@ -1,5 +1,8 @@
1
+ from typing import Optional
2
+
1
3
  from haplohub import (
2
4
  AlgorithmSchema,
5
+ AlgorithmVersionSchema,
3
6
  PaginatedResponseAlgorithmSchema,
4
7
  ResultResponseAlgorithmSchema,
5
8
  )
@@ -9,14 +12,19 @@ from haplohub_cli.formatters import utils
9
12
  from haplohub_cli.formatters.decorators import register
10
13
 
11
14
 
15
+ def _latest_version(version: Optional[AlgorithmVersionSchema]) -> str:
16
+ # Algorithms are identified by name now; a version is identified by its version string.
17
+ return version.version if version else "N/A"
18
+
19
+
12
20
  @register(AlgorithmSchema)
13
21
  def format_algorithm(data: AlgorithmSchema):
14
- table = Table(title="Algorithm", caption=f"Id: {utils.format_id(data.id)}")
15
- table.add_column("Id")
22
+ table = Table(title="Algorithm", caption=f"Name: {data.name}")
16
23
  table.add_column("Name")
17
24
  table.add_column("Description")
18
25
  table.add_column("Latest Version")
19
- table.add_row(utils.format_id(data.id), data.name, data.description)
26
+ table.add_column("Owner")
27
+ table.add_row(data.name, data.description, _latest_version(data.latest_version), data.owner)
20
28
  return table
21
29
 
22
30
 
@@ -28,7 +36,6 @@ def format_single_algorithm(data: ResultResponseAlgorithmSchema):
28
36
  @register(PaginatedResponseAlgorithmSchema)
29
37
  def format_algorithms(data: PaginatedResponseAlgorithmSchema):
30
38
  table = Table(title="Algorithms", caption=f"Total: {data.total_count}")
31
- table.add_column("Id")
32
39
  table.add_column("Name")
33
40
  table.add_column("Description")
34
41
  table.add_column("Latest Version")
@@ -36,10 +43,9 @@ def format_algorithms(data: PaginatedResponseAlgorithmSchema):
36
43
 
37
44
  for item in data.items:
38
45
  table.add_row(
39
- utils.format_id(item.id),
40
46
  item.name,
41
47
  utils.truncate(item.description, 50),
42
- "%s (%s)" % (utils.format_id(item.latest_version.id), item.latest_version.version),
48
+ _latest_version(item.latest_version),
43
49
  utils.format_dt(item.created),
44
50
  )
45
51
 
@@ -16,10 +16,11 @@ def format_variant_results(data: ResultListResponseVariantResultSchema):
16
16
  table.add_column("Sample")
17
17
  table.add_column("Called")
18
18
  table.add_column("Dosage")
19
+ table.add_column("Annotations")
19
20
 
20
21
  for item in data.items:
21
22
  if item.error_message:
22
- table.add_row(item.target.input, f"[red]{item.error_message}[/red]", "", "", "", "", "", "", "")
23
+ table.add_row(item.target.input, f"[red]{item.error_message}[/red]", "", "", "", "", "", "", "", "")
23
24
  continue
24
25
 
25
26
  variant = item.variant
@@ -30,9 +31,10 @@ def format_variant_results(data: ResultListResponseVariantResultSchema):
30
31
  reference = variant.reference or "" if variant else ""
31
32
  alternate = ", ".join(variant.alternate) if variant and variant.alternate else ""
32
33
  quality = f"{variant.quality:.4f}" if variant and variant.quality is not None else ""
34
+ annotations = _format_annotations(item.attachments)
33
35
 
34
36
  if not samples:
35
- table.add_row(item.target.input, accession, position, reference, alternate, quality, "", "", "")
37
+ table.add_row(item.target.input, accession, position, reference, alternate, quality, "", "", "", annotations)
36
38
  else:
37
39
  for i, sample in enumerate(samples):
38
40
  target_col = item.target.input if i == 0 else ""
@@ -41,10 +43,25 @@ def format_variant_results(data: ResultListResponseVariantResultSchema):
41
43
  ref_col = reference if i == 0 else ""
42
44
  alt_col = alternate if i == 0 else ""
43
45
  qual_col = quality if i == 0 else ""
46
+ ann_col = annotations if i == 0 else ""
44
47
 
45
48
  is_called = f"[green]{sample.is_called}[/green]" if sample.is_called else f"[red]{sample.is_called}[/red]"
46
49
  dosage = str(sample.dosage) if sample.dosage is not None else ""
47
50
 
48
- table.add_row(target_col, acc_col, pos_col, ref_col, alt_col, qual_col, sample.sample_id, is_called, dosage)
51
+ table.add_row(
52
+ target_col, acc_col, pos_col, ref_col, alt_col, qual_col, sample.sample_id, is_called, dosage, ann_col
53
+ )
49
54
 
50
55
  return table
56
+
57
+
58
+ def _format_annotations(attachments) -> str:
59
+ """Summarize per-source attachment counts, e.g. "clinvar:1 gwas:5"."""
60
+ if attachments is None:
61
+ return ""
62
+ parts = []
63
+ for source in ("clinvar", "gwas"):
64
+ rows = getattr(attachments, source, None) or []
65
+ if rows:
66
+ parts.append(f"{source}:{len(rows)}")
67
+ return " ".join(parts)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haplohub-cli
3
- Version: 2.3.0
3
+ Version: 2.5.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
@@ -15,6 +15,8 @@ haplohub_cli.egg-info/SOURCES.txt
15
15
  haplohub_cli.egg-info/dependency_links.txt
16
16
  haplohub_cli.egg-info/entry_points.txt
17
17
  haplohub_cli.egg-info/requires.txt
18
+ haplohub_cli.egg-info/scm_file_list.json
19
+ haplohub_cli.egg-info/scm_version.json
18
20
  haplohub_cli.egg-info/top_level.txt
19
21
  haplohub_cli/auth/__init__.py
20
22
  haplohub_cli/auth/auth.py
@@ -0,0 +1,69 @@
1
+ {
2
+ "files": [
3
+ ".pre-commit-config.yaml",
4
+ "README.md",
5
+ "uv.lock",
6
+ "pyproject.toml",
7
+ ".gitignore",
8
+ "haplohub_cli/__init__.py",
9
+ "haplohub_cli/settings.py",
10
+ "haplohub_cli/cli.py",
11
+ "haplohub_cli/commands/__init__.py",
12
+ "haplohub_cli/commands/version.py",
13
+ "haplohub_cli/commands/config.py",
14
+ "haplohub_cli/commands/file.py",
15
+ "haplohub_cli/commands/variant.py",
16
+ "haplohub_cli/commands/login.py",
17
+ "haplohub_cli/commands/cohort/biomarker.py",
18
+ "haplohub_cli/commands/cohort/__init__.py",
19
+ "haplohub_cli/commands/cohort/sample.py",
20
+ "haplohub_cli/commands/cohort/member/__init__.py",
21
+ "haplohub_cli/commands/cohort/member/report.py",
22
+ "haplohub_cli/commands/metadata/__init__.py",
23
+ "haplohub_cli/commands/algorithm/__init__.py",
24
+ "haplohub_cli/commands/algorithm/result/__init__.py",
25
+ "haplohub_cli/core/network.py",
26
+ "haplohub_cli/core/__init__.py",
27
+ "haplohub_cli/core/checksum.py",
28
+ "haplohub_cli/core/slug.py",
29
+ "haplohub_cli/core/upload.py",
30
+ "haplohub_cli/core/types.py",
31
+ "haplohub_cli/core/api/__init__.py",
32
+ "haplohub_cli/core/api/client.py",
33
+ "haplohub_cli/core/tests/test_checksum.py",
34
+ "haplohub_cli/core/tests/__init__.py",
35
+ "haplohub_cli/core/tests/test_slug.py",
36
+ "haplohub_cli/core/tests/test_network.py",
37
+ "haplohub_cli/auth/__init__.py",
38
+ "haplohub_cli/auth/token_storage.py",
39
+ "haplohub_cli/auth/auth.py",
40
+ "haplohub_cli/auth/oauth_client.py",
41
+ "haplohub_cli/auth/auth_web_server.py",
42
+ "haplohub_cli/auth/tests/test_token_storage.py",
43
+ "haplohub_cli/auth/tests/__init__.py",
44
+ "haplohub_cli/auth/tests/test_auth_web_server.py",
45
+ "haplohub_cli/config/__init__.py",
46
+ "haplohub_cli/config/config_manager.py",
47
+ "haplohub_cli/config/config.py",
48
+ "haplohub_cli/formatters/biomarker.py",
49
+ "haplohub_cli/formatters/__init__.py",
50
+ "haplohub_cli/formatters/algorithm.py",
51
+ "haplohub_cli/formatters/config.py",
52
+ "haplohub_cli/formatters/file.py",
53
+ "haplohub_cli/formatters/utils.py",
54
+ "haplohub_cli/formatters/member.py",
55
+ "haplohub_cli/formatters/cohort.py",
56
+ "haplohub_cli/formatters/decorators.py",
57
+ "haplohub_cli/formatters/formatter_registry.py",
58
+ "haplohub_cli/formatters/metadata.py",
59
+ "haplohub_cli/formatters/variant.py",
60
+ "haplohub_cli/formatters/algorithm_result.py",
61
+ "haplohub_cli/formatters/sample.py",
62
+ "haplohub_cli/formatters/generic.py",
63
+ "haplohub_cli/types/__init__.py",
64
+ "haplohub_cli/types/variant_range.py",
65
+ ".github/dependabot.yml",
66
+ ".github/workflows/on_pr.yml",
67
+ ".github/workflows/on_push_main.yml"
68
+ ]
69
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.5.0",
3
+ "distance": 0,
4
+ "node": "gcd8a204d2e893b5eed8f318fb0e8fb3ed1a25d32",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-14"
8
+ }
@@ -1,64 +0,0 @@
1
- import json
2
-
3
- from pyfakefs.fake_filesystem_unittest import TestCase
4
-
5
- from haplohub_cli.auth.token_storage import TokenStorage
6
-
7
-
8
- class TokenStorageTestCase(TestCase):
9
- @classmethod
10
- def setUpClass(cls):
11
- cls.setUpClassPyfakefs()
12
- cls.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)
16
-
17
- def test_credentials_exist_should_return_true_when_file_exists(self):
18
- with open(self.instance.token_file, "w"):
19
- pass
20
-
21
- self.assertTrue(self.instance.credentials_exist)
22
-
23
- def test_store_credentials_should_save_credentials_to_file(self):
24
- creds = {"test": True}
25
-
26
- self.instance.store_credentials(creds)
27
-
28
- with open(self.instance.token_file, "r") as f:
29
- self.assertEqual(json.load(f), creds)
30
-
31
- def test_get_credentials_should_return_credentials_from_file(self):
32
- creds = {"test": True}
33
-
34
- self.instance.store_credentials(creds)
35
-
36
- self.assertEqual(self.instance.get_credentials(), creds)
37
-
38
- def test_get_access_token_should_return_access_token_from_credentials(self):
39
- creds = {"access_token": "123456"}
40
-
41
- self.instance.store_credentials(creds)
42
-
43
- self.assertEqual(self.instance.get_access_token(), "123456")
44
-
45
- def test_get_api_key_should_return_api_key_from_credentials(self):
46
- creds = {"api_key": "ak_789"}
47
-
48
- self.instance.store_credentials(creds)
49
-
50
- self.assertEqual(self.instance.get_api_key(), "ak_789")
51
-
52
- def test_has_api_key_should_return_true_when_api_key_exists(self):
53
- creds = {"api_key": "ak_789"}
54
-
55
- self.instance.store_credentials(creds)
56
-
57
- self.assertTrue(self.instance.has_api_key)
58
-
59
- def test_has_api_key_should_return_false_when_access_token_credentials(self):
60
- creds = {"access_token": "123456"}
61
-
62
- self.instance.store_credentials(creds)
63
-
64
- self.assertFalse(self.instance.has_api_key)
@@ -1,37 +0,0 @@
1
- import json
2
- from genericpath import exists
3
-
4
- from haplohub_cli import settings
5
-
6
-
7
- class TokenStorage:
8
- def __init__(self, token_file: str):
9
- self.token_file = token_file
10
-
11
- @property
12
- def credentials_exist(self):
13
- return exists(self.token_file)
14
-
15
- def store_credentials(self, credentials: dict):
16
- with open(self.token_file, "w") as f:
17
- json.dump(credentials, f)
18
-
19
- def get_credentials(self):
20
- with open(self.token_file, "r") as f:
21
- return json.load(f)
22
-
23
- def get_access_token(self):
24
- return self.get_credentials()["access_token"]
25
-
26
- def get_api_key(self):
27
- return self.get_credentials()["api_key"]
28
-
29
- def get_refresh_token(self):
30
- return self.get_credentials().get("refresh_token")
31
-
32
- @property
33
- def has_api_key(self):
34
- return self.credentials_exist and "api_key" in self.get_credentials()
35
-
36
-
37
- token_storage = TokenStorage(settings.CREDENTIALS_FILE)
@@ -1,66 +0,0 @@
1
- import click
2
- from haplohub import GetVariantRequest, HgvsTargetSchema, RegionTargetSchema, TargetsInner, VariantFilterSchema
3
-
4
- from haplohub_cli.core.api.client import client
5
-
6
-
7
- @click.group()
8
- def variant():
9
- """
10
- Work with variants
11
- """
12
- pass
13
-
14
-
15
- @variant.command()
16
- @click.argument("cohort_id", type=str)
17
- @click.option("--sample", "-s", "sample_ids", type=str, multiple=True, required=True, help="Sample ID (repeatable)")
18
- @click.option("--accession", "-a", type=str, required=True, help="Chromosome accession (e.g. NC_000001.11)")
19
- @click.option("--start", type=int, required=True, help="Genomic start position")
20
- @click.option("--end", type=int, required=True, help="Genomic end position")
21
- @click.option("--reference", type=str, default=None, help="Reference allele")
22
- @click.option("--alternate", type=str, default=None, help="Alternate allele")
23
- @click.option("--call-state", type=click.Choice(["called", "all"]), default="called", help="Filter by call state")
24
- @click.option("--clinvar", is_flag=True, default=False, help="Include ClinVar annotations")
25
- def region(sample_ids, cohort_id, accession, start, end, reference, alternate, call_state, clinvar):
26
- """
27
- Query variants by genomic region
28
- """
29
- target = RegionTargetSchema(
30
- accession=accession,
31
- start=start,
32
- end=end,
33
- reference=reference,
34
- alternate=alternate,
35
- )
36
-
37
- request = GetVariantRequest(
38
- sample_ids=list(sample_ids),
39
- targets=[TargetsInner(target)],
40
- filters=VariantFilterSchema(call_state=call_state),
41
- selectors=["clinvar"] if clinvar else None,
42
- )
43
-
44
- return client.variant.get_variant(cohort_id, request)
45
-
46
-
47
- @variant.command()
48
- @click.argument("cohort_id", type=str)
49
- @click.option("--sample", "-s", "sample_ids", type=str, multiple=True, required=True, help="Sample ID (repeatable)")
50
- @click.option("--hgvs", "-h", type=str, multiple=True, required=True, help="HGVS notation (repeatable)")
51
- @click.option("--call-state", type=click.Choice(["called", "all"]), default="called", help="Filter by call state")
52
- @click.option("--clinvar", is_flag=True, default=False, help="Include ClinVar annotations")
53
- def hgvs(sample_ids, cohort_id, hgvs, call_state, clinvar):
54
- """
55
- Query variants by HGVS notation
56
- """
57
- targets = [TargetsInner(HgvsTargetSchema(value=v)) for v in hgvs]
58
-
59
- request = GetVariantRequest(
60
- sample_ids=list(sample_ids),
61
- targets=targets,
62
- filters=VariantFilterSchema(call_state=call_state),
63
- selectors=["clinvar"] if clinvar else None,
64
- )
65
-
66
- return client.variant.get_variant(cohort_id, request)
File without changes
File without changes
File without changes
File without changes