qubithub 0.2.0__tar.gz → 0.2.1__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.
- {qubithub-0.2.0 → qubithub-0.2.1}/.gitignore +12 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/CHANGELOG.md +15 -2
- {qubithub-0.2.0 → qubithub-0.2.1}/PKG-INFO +2 -2
- {qubithub-0.2.0 → qubithub-0.2.1}/pyproject.toml +8 -1
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/__init__.py +1 -1
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/async_client.py +10 -6
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/auth.py +1 -1
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/cli.py +15 -27
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/repo.py +8 -11
- {qubithub-0.2.0 → qubithub-0.2.1}/tests/test_async_client.py +6 -2
- {qubithub-0.2.0 → qubithub-0.2.1}/tests/test_auth.py +23 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/tests/test_cli.py +63 -67
- {qubithub-0.2.0 → qubithub-0.2.1}/tests/test_client_contracts.py +2 -6
- {qubithub-0.2.0 → qubithub-0.2.1}/tests/test_repo.py +18 -48
- {qubithub-0.2.0 → qubithub-0.2.1}/tests/test_repo_operations.py +8 -3
- {qubithub-0.2.0 → qubithub-0.2.1}/LICENSE +0 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/README.md +0 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/_transport.py +0 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/client.py +0 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/display.py +0 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/exceptions.py +0 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/generated/__init__.py +0 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/models.py +0 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/qubithub/py.typed +0 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/tests/conftest.py +0 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/tests/test_exceptions.py +0 -0
- {qubithub-0.2.0 → qubithub-0.2.1}/tests/test_models.py +0 -0
|
@@ -342,6 +342,11 @@ ui/.env.production.local
|
|
|
342
342
|
!ui/src/lib/
|
|
343
343
|
|
|
344
344
|
# Docs site (Starlight)
|
|
345
|
+
# Any node_modules, at any depth. The root .gitignore previously listed only
|
|
346
|
+
# `docs-site/node_modules/`, and `ui/` is covered by its own .gitignore — so a
|
|
347
|
+
# REPO-ROOT node_modules (created by running `bunx vitest` from the root rather
|
|
348
|
+
# than from ui/) was tracked, and a `git add -A` committed a vitest cache file.
|
|
349
|
+
node_modules/
|
|
345
350
|
docs-site/node_modules/
|
|
346
351
|
docs-site/dist/
|
|
347
352
|
docs-site/.astro/
|
|
@@ -371,3 +376,10 @@ docs-site/.astro/
|
|
|
371
376
|
# accidental in-repo placements as a safety net.
|
|
372
377
|
migration-backup/
|
|
373
378
|
/migration-backup/
|
|
379
|
+
.ds-sync/
|
|
380
|
+
ds-bundle/
|
|
381
|
+
.design-sync/.cache/
|
|
382
|
+
.design-sync/learnings/
|
|
383
|
+
.design-sync/node_modules
|
|
384
|
+
ui/.ds-entry.tsx
|
|
385
|
+
ui/.ds-styles.css
|
|
@@ -6,7 +6,19 @@ documented in this file.
|
|
|
6
6
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
7
7
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
-
## [0.2.
|
|
9
|
+
## [0.2.1] — unreleased
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **Python 3.10:** `qubithub circuits push` and `circuits metadata` crashed with
|
|
13
|
+
`ModuleNotFoundError: No module named 'tomllib'` on reading `qubithub.toml` —
|
|
14
|
+
so a manifest `circuits init` had just written could not be published.
|
|
15
|
+
`tomllib` is standard library only from 3.11; the package now falls back to
|
|
16
|
+
`tomli` (already a declared dependency on 3.10). Python 3.11–3.13 were not
|
|
17
|
+
affected.
|
|
18
|
+
|
|
19
|
+
No other behaviour changed since 0.2.0 (the rest of the diff is formatting).
|
|
20
|
+
|
|
21
|
+
## [0.2.0] — 2026-05-22
|
|
10
22
|
|
|
11
23
|
First functional release of `qubithub` — the Python SDK and CLI for
|
|
12
24
|
[QubitHub](https://qubithub.co), developer infrastructure for quantum computing.
|
|
@@ -53,4 +65,5 @@ which has been yanked from PyPI.
|
|
|
53
65
|
- Supports Qiskit, Cirq, and PennyLane circuits executed against QubitHub's managed
|
|
54
66
|
simulators.
|
|
55
67
|
|
|
56
|
-
[0.2.
|
|
68
|
+
[0.2.1]: https://pypi.org/project/qubithub/0.2.1/
|
|
69
|
+
[0.2.0]: https://pypi.org/project/qubithub/0.2.0/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: qubithub
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Python SDK and CLI for QubitHub — developer infrastructure for quantum computing
|
|
5
5
|
Project-URL: Homepage, https://qubithub.co
|
|
6
6
|
Project-URL: Documentation, https://docs.qubithub.co
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qubithub"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Python SDK and CLI for QubitHub — developer infrastructure for quantum computing"
|
|
9
9
|
authors = [{ name = "QubitHub", email = "hi@qubithub.co" }]
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -56,6 +56,13 @@ addopts = "-q"
|
|
|
56
56
|
target-version = "py310"
|
|
57
57
|
line-length = 100
|
|
58
58
|
|
|
59
|
+
[tool.ruff.format]
|
|
60
|
+
# README.md is this package's PyPI front page, and its examples are laid out
|
|
61
|
+
# by hand. ruff >=0.16 formats Python inside Markdown code blocks, so without
|
|
62
|
+
# this the `ruff format --check` gate reflows them — and, since `ruff` is
|
|
63
|
+
# unpinned here, the gate's verdict would depend on which ruff CI resolved.
|
|
64
|
+
exclude = ["*.md"]
|
|
65
|
+
|
|
59
66
|
[tool.ruff.lint]
|
|
60
67
|
select = ["E", "F", "I", "W"]
|
|
61
68
|
ignore = ["E501"]
|
|
@@ -89,9 +89,7 @@ class AsyncQubitHubClient:
|
|
|
89
89
|
elif auto_login:
|
|
90
90
|
self._needs_auto_login = True
|
|
91
91
|
|
|
92
|
-
self._http = httpx.AsyncClient(
|
|
93
|
-
base_url=self.base_url, headers=headers, timeout=timeout
|
|
94
|
-
)
|
|
92
|
+
self._http = httpx.AsyncClient(base_url=self.base_url, headers=headers, timeout=timeout)
|
|
95
93
|
|
|
96
94
|
async def __aenter__(self) -> AsyncQubitHubClient:
|
|
97
95
|
return self
|
|
@@ -154,7 +152,9 @@ class AsyncQubitHubClient:
|
|
|
154
152
|
|
|
155
153
|
async def login(self, email: str, password: str) -> AuthResponse:
|
|
156
154
|
"""Log in, store credentials, and return AuthResponse."""
|
|
157
|
-
data = await self._request(
|
|
155
|
+
data = await self._request(
|
|
156
|
+
"POST", "/auth/login", json={"email": email, "password": password}
|
|
157
|
+
)
|
|
158
158
|
resp = AuthResponse(**data)
|
|
159
159
|
|
|
160
160
|
self._http.headers["Authorization"] = f"Bearer {resp.access_token}"
|
|
@@ -242,7 +242,9 @@ class AsyncQubitHubClient:
|
|
|
242
242
|
async def fork_circuit(self, circuit_id: str, name: str | None = None) -> CircuitDetail:
|
|
243
243
|
"""Fork a circuit. Optionally provide a custom name."""
|
|
244
244
|
body = {"name": name} if name else {}
|
|
245
|
-
return CircuitDetail(
|
|
245
|
+
return CircuitDetail(
|
|
246
|
+
**await self._request("POST", f"/circuits/{circuit_id}/fork", json=body)
|
|
247
|
+
)
|
|
246
248
|
|
|
247
249
|
async def list_circuit_files(
|
|
248
250
|
self,
|
|
@@ -369,7 +371,9 @@ class AsyncQubitHubClient:
|
|
|
369
371
|
|
|
370
372
|
async def create_api_key(self, req: APIKeyCreateRequest) -> APIKeyCreateResponse:
|
|
371
373
|
"""Create a new API key (plaintext shown once)."""
|
|
372
|
-
return APIKeyCreateResponse(
|
|
374
|
+
return APIKeyCreateResponse(
|
|
375
|
+
**await self._request("POST", "/api-keys", json=req.model_dump())
|
|
376
|
+
)
|
|
373
377
|
|
|
374
378
|
async def list_api_keys(self) -> APIKeyListResponse:
|
|
375
379
|
"""List user's API keys."""
|
|
@@ -264,6 +264,6 @@ def resolve_web_url(api_base_url: str, *, strip_api_prefix: bool = True) -> str:
|
|
|
264
264
|
|
|
265
265
|
netloc = parts.netloc
|
|
266
266
|
if strip_api_prefix and netloc.startswith("api."):
|
|
267
|
-
netloc = netloc[len("api."):]
|
|
267
|
+
netloc = netloc[len("api.") :]
|
|
268
268
|
|
|
269
269
|
return f"{parts.scheme}://{netloc}"
|
|
@@ -245,9 +245,7 @@ def _resolve_web_origin(api_base_url: str, override: str | None) -> str:
|
|
|
245
245
|
would see a Python traceback instead of a CLI error.
|
|
246
246
|
"""
|
|
247
247
|
if override:
|
|
248
|
-
return auth_module.resolve_web_url(
|
|
249
|
-
override, strip_api_prefix=False
|
|
250
|
-
).rstrip("/")
|
|
248
|
+
return auth_module.resolve_web_url(override, strip_api_prefix=False).rstrip("/")
|
|
251
249
|
return auth_module.resolve_web_url(api_base_url).rstrip("/")
|
|
252
250
|
|
|
253
251
|
|
|
@@ -303,9 +301,7 @@ def _fail_clone_probe(repo_name: str, probe: repo.GitProxyProbe) -> None:
|
|
|
303
301
|
|
|
304
302
|
@app.command()
|
|
305
303
|
def clone(
|
|
306
|
-
repo_name: Annotated[
|
|
307
|
-
str, typer.Argument(help="Repository name (e.g., qubithub/bell-state)")
|
|
308
|
-
],
|
|
304
|
+
repo_name: Annotated[str, typer.Argument(help="Repository name (e.g., qubithub/bell-state)")],
|
|
309
305
|
directory: Annotated[
|
|
310
306
|
str | None, typer.Argument(help="Target directory (default: circuit name)")
|
|
311
307
|
] = None,
|
|
@@ -348,14 +344,10 @@ def clone(
|
|
|
348
344
|
target = Path(directory) if directory else Path(slug)
|
|
349
345
|
if target.exists():
|
|
350
346
|
if not target.is_dir():
|
|
351
|
-
display.display_error(
|
|
352
|
-
f"Target '{target}' exists and is not a directory."
|
|
353
|
-
)
|
|
347
|
+
display.display_error(f"Target '{target}' exists and is not a directory.")
|
|
354
348
|
raise typer.Exit(1)
|
|
355
349
|
if any(target.iterdir()):
|
|
356
|
-
display.display_error(
|
|
357
|
-
f"Directory '{target}' already exists and is not empty."
|
|
358
|
-
)
|
|
350
|
+
display.display_error(f"Directory '{target}' already exists and is not empty.")
|
|
359
351
|
raise typer.Exit(1)
|
|
360
352
|
|
|
361
353
|
api_base_url = _client_kwargs.get("base_url") or "https://qubithub.co/api"
|
|
@@ -629,14 +621,10 @@ def _http_clone(repo_name: str, directory: str | None) -> None:
|
|
|
629
621
|
|
|
630
622
|
if target.exists():
|
|
631
623
|
if not target.is_dir():
|
|
632
|
-
display.display_error(
|
|
633
|
-
f"Target '{target}' exists and is not a directory."
|
|
634
|
-
)
|
|
624
|
+
display.display_error(f"Target '{target}' exists and is not a directory.")
|
|
635
625
|
raise typer.Exit(1)
|
|
636
626
|
if any(target.iterdir()):
|
|
637
|
-
display.display_error(
|
|
638
|
-
f"Directory '{target}' already exists and is not empty."
|
|
639
|
-
)
|
|
627
|
+
display.display_error(f"Directory '{target}' already exists and is not empty.")
|
|
640
628
|
raise typer.Exit(1)
|
|
641
629
|
|
|
642
630
|
# Get file list — deduplicate by name, keeping the highest version.
|
|
@@ -784,7 +772,9 @@ def circuits_push(
|
|
|
784
772
|
|
|
785
773
|
circuit_name = name or metadata.get("name")
|
|
786
774
|
if not circuit_name:
|
|
787
|
-
display.display_error(
|
|
775
|
+
display.display_error(
|
|
776
|
+
"Circuit name required (--name or 'name' in qubithub.toml/metadata.json)"
|
|
777
|
+
)
|
|
788
778
|
raise typer.Exit(1)
|
|
789
779
|
|
|
790
780
|
try:
|
|
@@ -838,7 +828,9 @@ def circuits_push(
|
|
|
838
828
|
commit_msg = message or "Push circuit files"
|
|
839
829
|
response: dict[str, Any] | None = None
|
|
840
830
|
if len(file_pairs) > 1:
|
|
841
|
-
response = client.upload_circuit_files(
|
|
831
|
+
response = client.upload_circuit_files(
|
|
832
|
+
circuit_id, file_pairs, commit_message=commit_msg
|
|
833
|
+
)
|
|
842
834
|
elif file_pairs:
|
|
843
835
|
local, name = file_pairs[0]
|
|
844
836
|
client.upload_circuit_file(
|
|
@@ -876,8 +868,7 @@ def circuits_push(
|
|
|
876
868
|
|
|
877
869
|
if errors and not uploaded:
|
|
878
870
|
display.display_error(
|
|
879
|
-
f"All {len(errors)} file(s) failed to upload to"
|
|
880
|
-
f" {circuit.repo_name or circuit_name}"
|
|
871
|
+
f"All {len(errors)} file(s) failed to upload to {circuit.repo_name or circuit_name}"
|
|
881
872
|
)
|
|
882
873
|
raise typer.Exit(1)
|
|
883
874
|
elif errors:
|
|
@@ -951,9 +942,7 @@ def circuits_init(
|
|
|
951
942
|
@circuits_app.command("metadata")
|
|
952
943
|
def circuits_metadata(
|
|
953
944
|
directory: str = typer.Argument(".", help="Circuit directory"),
|
|
954
|
-
output_format: str = typer.Option(
|
|
955
|
-
"json", "--format", "-f", help="Output format: json or toml"
|
|
956
|
-
),
|
|
945
|
+
output_format: str = typer.Option("json", "--format", "-f", help="Output format: json or toml"),
|
|
957
946
|
):
|
|
958
947
|
"""Show parsed circuit metadata from qubithub.toml.
|
|
959
948
|
|
|
@@ -1101,8 +1090,7 @@ def _validate_config_key(key: str) -> None:
|
|
|
1101
1090
|
"""Reject config keys that would corrupt TOML structure (dots, brackets, etc.)."""
|
|
1102
1091
|
if not _VALID_CONFIG_KEY.match(key):
|
|
1103
1092
|
raise typer.BadParameter(
|
|
1104
|
-
f"Invalid config key '{key}'. "
|
|
1105
|
-
"Keys must contain only letters, digits, and underscores."
|
|
1093
|
+
f"Invalid config key '{key}'. Keys must contain only letters, digits, and underscores."
|
|
1106
1094
|
)
|
|
1107
1095
|
|
|
1108
1096
|
|
|
@@ -27,6 +27,11 @@ from urllib.parse import urlsplit
|
|
|
27
27
|
|
|
28
28
|
import httpx
|
|
29
29
|
|
|
30
|
+
try:
|
|
31
|
+
import tomllib # Python 3.11+
|
|
32
|
+
except ModuleNotFoundError:
|
|
33
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
34
|
+
|
|
30
35
|
# ── Constants ────────────────────────────────────────────────────
|
|
31
36
|
|
|
32
37
|
REQUIRED_FILES = {"README.md", "circuit.py", "qubithub.toml"}
|
|
@@ -278,8 +283,6 @@ def validate_directory(path: Path) -> ValidationResult:
|
|
|
278
283
|
|
|
279
284
|
def _validate_toml(toml_path: Path, errors: list[str], warnings: list[str]) -> None:
|
|
280
285
|
"""Validate a qubithub.toml file using the TOML manifest parser."""
|
|
281
|
-
import tomllib
|
|
282
|
-
|
|
283
286
|
try:
|
|
284
287
|
raw = tomllib.loads(toml_path.read_text())
|
|
285
288
|
except Exception as e:
|
|
@@ -305,8 +308,7 @@ def _validate_toml(toml_path: Path, errors: list[str], warnings: list[str]) -> N
|
|
|
305
308
|
difficulty = project.get("difficulty")
|
|
306
309
|
if difficulty and difficulty not in VALID_DIFFICULTIES:
|
|
307
310
|
warnings.append(
|
|
308
|
-
f"Unknown difficulty '{difficulty}'. "
|
|
309
|
-
f"Expected: {', '.join(sorted(VALID_DIFFICULTIES))}"
|
|
311
|
+
f"Unknown difficulty '{difficulty}'. Expected: {', '.join(sorted(VALID_DIFFICULTIES))}"
|
|
310
312
|
)
|
|
311
313
|
|
|
312
314
|
qubits = project.get("qubits")
|
|
@@ -314,9 +316,7 @@ def _validate_toml(toml_path: Path, errors: list[str], warnings: list[str]) -> N
|
|
|
314
316
|
errors.append("qubithub.toml: project.qubits must be a positive integer")
|
|
315
317
|
|
|
316
318
|
|
|
317
|
-
def _validate_metadata_json(
|
|
318
|
-
metadata_path: Path, errors: list[str], warnings: list[str]
|
|
319
|
-
) -> None:
|
|
319
|
+
def _validate_metadata_json(metadata_path: Path, errors: list[str], warnings: list[str]) -> None:
|
|
320
320
|
"""Validate a metadata.json file (legacy format, backwards compat)."""
|
|
321
321
|
try:
|
|
322
322
|
metadata = json.loads(metadata_path.read_text())
|
|
@@ -341,8 +341,7 @@ def _validate_metadata_json(
|
|
|
341
341
|
difficulty = metadata.get("difficulty")
|
|
342
342
|
if difficulty and difficulty not in VALID_DIFFICULTIES:
|
|
343
343
|
warnings.append(
|
|
344
|
-
f"Unknown difficulty '{difficulty}'. "
|
|
345
|
-
f"Expected: {', '.join(sorted(VALID_DIFFICULTIES))}"
|
|
344
|
+
f"Unknown difficulty '{difficulty}'. Expected: {', '.join(sorted(VALID_DIFFICULTIES))}"
|
|
346
345
|
)
|
|
347
346
|
|
|
348
347
|
qubits = metadata.get("qubits")
|
|
@@ -369,8 +368,6 @@ def load_metadata(path: Path) -> dict:
|
|
|
369
368
|
|
|
370
369
|
def _load_metadata_from_toml(toml_path: Path) -> dict:
|
|
371
370
|
"""Load metadata from qubithub.toml and return as a metadata dict."""
|
|
372
|
-
import tomllib
|
|
373
|
-
|
|
374
371
|
raw = tomllib.loads(toml_path.read_text())
|
|
375
372
|
project = raw.get("project", {})
|
|
376
373
|
|
|
@@ -265,6 +265,7 @@ async def test_timeout_raises_network_error():
|
|
|
265
265
|
with pytest.raises(QubitHubError) as exc_info:
|
|
266
266
|
await client.me()
|
|
267
267
|
from qubithub.exceptions import NetworkError
|
|
268
|
+
|
|
268
269
|
assert isinstance(exc_info.value, NetworkError)
|
|
269
270
|
assert exc_info.value.cause is not None
|
|
270
271
|
assert "timed out" in exc_info.value.message
|
|
@@ -281,6 +282,7 @@ async def test_connection_error_raises_network_error():
|
|
|
281
282
|
with pytest.raises(QubitHubError) as exc_info:
|
|
282
283
|
await client.me()
|
|
283
284
|
from qubithub.exceptions import NetworkError
|
|
285
|
+
|
|
284
286
|
assert isinstance(exc_info.value, NetworkError)
|
|
285
287
|
|
|
286
288
|
|
|
@@ -324,11 +326,13 @@ def test_async_client_has_same_public_methods():
|
|
|
324
326
|
from qubithub.client import QubitHubClient
|
|
325
327
|
|
|
326
328
|
sync_methods = {
|
|
327
|
-
m
|
|
329
|
+
m
|
|
330
|
+
for m in dir(QubitHubClient)
|
|
328
331
|
if not m.startswith("_") and callable(getattr(QubitHubClient, m))
|
|
329
332
|
}
|
|
330
333
|
async_methods = {
|
|
331
|
-
m
|
|
334
|
+
m
|
|
335
|
+
for m in dir(AsyncQubitHubClient)
|
|
332
336
|
if not m.startswith("_") and callable(getattr(AsyncQubitHubClient, m))
|
|
333
337
|
}
|
|
334
338
|
# aclose is async-only, close is sync-only — both expected
|
|
@@ -187,6 +187,29 @@ def test_refresh_access_token_success(monkeypatch):
|
|
|
187
187
|
assert creds["refresh_token"] == "same_rt"
|
|
188
188
|
|
|
189
189
|
|
|
190
|
+
def test_refresh_access_token_stores_the_rotated_refresh_token(monkeypatch):
|
|
191
|
+
"""The API rotates refresh tokens: the presented one is retired, and
|
|
192
|
+
presenting it again after a 30 s grace window revokes the whole session.
|
|
193
|
+
The SDK must keep the successor, never the token it sent."""
|
|
194
|
+
|
|
195
|
+
def mock_post(url, **kwargs):
|
|
196
|
+
assert kwargs["json"] == {"refresh_token": "retired_rt"}
|
|
197
|
+
return httpx.Response(
|
|
198
|
+
200,
|
|
199
|
+
json={
|
|
200
|
+
"access_token": "new_at",
|
|
201
|
+
"refresh_token": "successor_rt",
|
|
202
|
+
"token_type": "bearer",
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
monkeypatch.setattr(httpx, "post", mock_post)
|
|
207
|
+
auth.store_tokens(access_token="old_at", refresh_token="retired_rt")
|
|
208
|
+
|
|
209
|
+
assert auth.refresh_access_token("http://test", "retired_rt") == "new_at"
|
|
210
|
+
assert auth.load_credentials()["refresh_token"] == "successor_rt"
|
|
211
|
+
|
|
212
|
+
|
|
190
213
|
def test_refresh_access_token_failure(monkeypatch):
|
|
191
214
|
"""Failed refresh returns None."""
|
|
192
215
|
|
|
@@ -10,9 +10,15 @@ Tests all commands by mocking the QubitHubClient layer, verifying:
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import contextlib
|
|
13
14
|
import importlib.metadata
|
|
14
15
|
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
15
18
|
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from pathlib import Path
|
|
16
22
|
from unittest.mock import MagicMock, patch
|
|
17
23
|
|
|
18
24
|
import pytest
|
|
@@ -40,6 +46,27 @@ from qubithub.models import (
|
|
|
40
46
|
|
|
41
47
|
runner = CliRunner()
|
|
42
48
|
|
|
49
|
+
|
|
50
|
+
@contextlib.contextmanager
|
|
51
|
+
def isolated_filesystem() -> Iterator[Path]:
|
|
52
|
+
"""Run the block with the working directory set to a fresh temp dir.
|
|
53
|
+
|
|
54
|
+
Replaces ``CliRunner.isolated_filesystem``. typer 0.26.0 vendored click as
|
|
55
|
+
``typer._click`` and dropped click from its dependencies; the vendored test
|
|
56
|
+
runner does not carry that helper, so ``typer.testing.CliRunner`` no longer
|
|
57
|
+
has it. Click 8.3 had deprecated it in any case, pointing at
|
|
58
|
+
``tempfile.TemporaryDirectory`` — which is what this uses.
|
|
59
|
+
"""
|
|
60
|
+
previous = os.getcwd()
|
|
61
|
+
target = tempfile.mkdtemp()
|
|
62
|
+
os.chdir(target)
|
|
63
|
+
try:
|
|
64
|
+
yield Path(target)
|
|
65
|
+
finally:
|
|
66
|
+
os.chdir(previous)
|
|
67
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
68
|
+
|
|
69
|
+
|
|
43
70
|
# ── Fixtures ─────────────────────────────────────────────────────
|
|
44
71
|
|
|
45
72
|
|
|
@@ -452,7 +479,7 @@ class TestClone:
|
|
|
452
479
|
cli.auth_module, "load_credentials", lambda: {"access_token": "jwt-abc"}
|
|
453
480
|
)
|
|
454
481
|
|
|
455
|
-
with
|
|
482
|
+
with isolated_filesystem():
|
|
456
483
|
result = runner.invoke(cli.app, ["clone", "qubithub/bell-state"])
|
|
457
484
|
|
|
458
485
|
assert result.exit_code == 0, result.output
|
|
@@ -520,7 +547,7 @@ class TestClone:
|
|
|
520
547
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
521
548
|
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {})
|
|
522
549
|
|
|
523
|
-
with
|
|
550
|
+
with isolated_filesystem():
|
|
524
551
|
result = runner.invoke(cli.app, ["clone", "qubithub/bell-state"])
|
|
525
552
|
|
|
526
553
|
assert result.exit_code == 0, result.output
|
|
@@ -537,11 +564,9 @@ class TestClone:
|
|
|
537
564
|
monkeypatch.setattr(cli.repo, "is_git_available", lambda: True)
|
|
538
565
|
monkeypatch.setattr(cli.repo, "git_clone", fake_git_clone)
|
|
539
566
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
540
|
-
monkeypatch.setattr(
|
|
541
|
-
cli.auth_module, "load_credentials", lambda: {"api_key": "qak_abc"}
|
|
542
|
-
)
|
|
567
|
+
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {"api_key": "qak_abc"})
|
|
543
568
|
|
|
544
|
-
with
|
|
569
|
+
with isolated_filesystem():
|
|
545
570
|
result = runner.invoke(cli.app, ["clone", "qubithub/bell-state"])
|
|
546
571
|
|
|
547
572
|
assert result.exit_code == 0, result.output
|
|
@@ -560,7 +585,7 @@ class TestClone:
|
|
|
560
585
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
561
586
|
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {})
|
|
562
587
|
|
|
563
|
-
with
|
|
588
|
+
with isolated_filesystem():
|
|
564
589
|
result = runner.invoke(
|
|
565
590
|
cli.app,
|
|
566
591
|
[
|
|
@@ -609,9 +634,7 @@ class TestClone:
|
|
|
609
634
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
610
635
|
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {})
|
|
611
636
|
|
|
612
|
-
result = runner.invoke(
|
|
613
|
-
cli.app, ["--format", "json", "clone", "qubithub/bell-state"]
|
|
614
|
-
)
|
|
637
|
+
result = runner.invoke(cli.app, ["--format", "json", "clone", "qubithub/bell-state"])
|
|
615
638
|
|
|
616
639
|
assert result.exit_code == 128
|
|
617
640
|
payload = json.loads(result.output)
|
|
@@ -646,9 +669,7 @@ class TestClone:
|
|
|
646
669
|
assert result.exit_code == 1
|
|
647
670
|
assert called["git"] is False
|
|
648
671
|
|
|
649
|
-
def test_clone_uses_explicit_token_flag_over_stored_creds(
|
|
650
|
-
self, mock_client, monkeypatch
|
|
651
|
-
):
|
|
672
|
+
def test_clone_uses_explicit_token_flag_over_stored_creds(self, mock_client, monkeypatch):
|
|
652
673
|
"""``--token`` from the global flag wins over any stored credentials."""
|
|
653
674
|
captured: dict = {}
|
|
654
675
|
|
|
@@ -659,16 +680,14 @@ class TestClone:
|
|
|
659
680
|
monkeypatch.setattr(cli.repo, "is_git_available", lambda: True)
|
|
660
681
|
monkeypatch.setattr(cli.repo, "git_clone", fake_git_clone)
|
|
661
682
|
# Stored creds should be IGNORED in favour of the explicit flag
|
|
662
|
-
monkeypatch.setattr(
|
|
663
|
-
cli.auth_module, "resolve_token", lambda _u: "stored-jwt-stale"
|
|
664
|
-
)
|
|
683
|
+
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: "stored-jwt-stale")
|
|
665
684
|
monkeypatch.setattr(
|
|
666
685
|
cli.auth_module,
|
|
667
686
|
"load_credentials",
|
|
668
687
|
lambda: {"api_key": "stored-key-stale"},
|
|
669
688
|
)
|
|
670
689
|
|
|
671
|
-
with
|
|
690
|
+
with isolated_filesystem():
|
|
672
691
|
result = runner.invoke(
|
|
673
692
|
cli.app, ["--token", "explicit-jwt", "clone", "qubithub/bell-state"]
|
|
674
693
|
)
|
|
@@ -676,9 +695,7 @@ class TestClone:
|
|
|
676
695
|
assert result.exit_code == 0, result.output
|
|
677
696
|
assert captured["token"] == "explicit-jwt"
|
|
678
697
|
|
|
679
|
-
def test_clone_uses_explicit_api_key_flag_when_no_token(
|
|
680
|
-
self, mock_client, monkeypatch
|
|
681
|
-
):
|
|
698
|
+
def test_clone_uses_explicit_api_key_flag_when_no_token(self, mock_client, monkeypatch):
|
|
682
699
|
"""``--api-key`` is honoured when no ``--token`` is given."""
|
|
683
700
|
captured: dict = {}
|
|
684
701
|
|
|
@@ -691,7 +708,7 @@ class TestClone:
|
|
|
691
708
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
692
709
|
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {})
|
|
693
710
|
|
|
694
|
-
with
|
|
711
|
+
with isolated_filesystem():
|
|
695
712
|
result = runner.invoke(
|
|
696
713
|
cli.app,
|
|
697
714
|
["--api-key", "qak_explicit", "clone", "qubithub/bell-state"],
|
|
@@ -700,9 +717,7 @@ class TestClone:
|
|
|
700
717
|
assert result.exit_code == 0, result.output
|
|
701
718
|
assert captured["token"] == "qak_explicit"
|
|
702
719
|
|
|
703
|
-
def test_clone_explicit_api_key_beats_explicit_token(
|
|
704
|
-
self, mock_client, monkeypatch
|
|
705
|
-
):
|
|
720
|
+
def test_clone_explicit_api_key_beats_explicit_token(self, mock_client, monkeypatch):
|
|
706
721
|
"""Within the explicit-CLI tier, api_key wins — matches QubitHubClient."""
|
|
707
722
|
captured: dict = {}
|
|
708
723
|
|
|
@@ -715,12 +730,14 @@ class TestClone:
|
|
|
715
730
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
716
731
|
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {})
|
|
717
732
|
|
|
718
|
-
with
|
|
733
|
+
with isolated_filesystem():
|
|
719
734
|
result = runner.invoke(
|
|
720
735
|
cli.app,
|
|
721
736
|
[
|
|
722
|
-
"--token",
|
|
723
|
-
"
|
|
737
|
+
"--token",
|
|
738
|
+
"tok-explicit",
|
|
739
|
+
"--api-key",
|
|
740
|
+
"qak-explicit",
|
|
724
741
|
"clone",
|
|
725
742
|
"qubithub/bell-state",
|
|
726
743
|
],
|
|
@@ -745,16 +762,14 @@ class TestClone:
|
|
|
745
762
|
monkeypatch.setattr(cli.repo, "is_git_available", lambda: True)
|
|
746
763
|
monkeypatch.setattr(cli.repo, "git_clone", fake_git_clone)
|
|
747
764
|
# Both stored: stale JWT + recent api_key
|
|
748
|
-
monkeypatch.setattr(
|
|
749
|
-
cli.auth_module, "resolve_token", lambda _u: "stale-jwt"
|
|
750
|
-
)
|
|
765
|
+
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: "stale-jwt")
|
|
751
766
|
monkeypatch.setattr(
|
|
752
767
|
cli.auth_module,
|
|
753
768
|
"load_credentials",
|
|
754
769
|
lambda: {"access_token": "stale-jwt", "api_key": "qak-recent"},
|
|
755
770
|
)
|
|
756
771
|
|
|
757
|
-
with
|
|
772
|
+
with isolated_filesystem():
|
|
758
773
|
result = runner.invoke(cli.app, ["clone", "qubithub/bell-state"])
|
|
759
774
|
|
|
760
775
|
assert result.exit_code == 0, result.output
|
|
@@ -769,9 +784,7 @@ class TestClone:
|
|
|
769
784
|
"ht!tp://qubithub.co", # invalid scheme chars (urlsplit accepts but no host parse)
|
|
770
785
|
],
|
|
771
786
|
)
|
|
772
|
-
def test_clone_rejects_invalid_git_base_url(
|
|
773
|
-
self, mock_client, monkeypatch, bad_override
|
|
774
|
-
):
|
|
787
|
+
def test_clone_rejects_invalid_git_base_url(self, mock_client, monkeypatch, bad_override):
|
|
775
788
|
"""Bad ``--git-base-url`` produces a clean ConfigurationError, not a traceback."""
|
|
776
789
|
monkeypatch.setattr(cli.repo, "is_git_available", lambda: True)
|
|
777
790
|
# Should never be invoked
|
|
@@ -781,7 +794,7 @@ class TestClone:
|
|
|
781
794
|
lambda **_: pytest.fail("git_clone should not run on bad override"),
|
|
782
795
|
)
|
|
783
796
|
|
|
784
|
-
with
|
|
797
|
+
with isolated_filesystem():
|
|
785
798
|
result = runner.invoke(
|
|
786
799
|
cli.app,
|
|
787
800
|
["clone", "--git-base-url", bad_override, "qubithub/bell-state"],
|
|
@@ -792,8 +805,7 @@ class TestClone:
|
|
|
792
805
|
assert "Traceback" not in result.output
|
|
793
806
|
# Either the configuration error message OR a clean exit with example URL
|
|
794
807
|
assert (
|
|
795
|
-
"must include scheme and host" in result.output
|
|
796
|
-
or "Invalid web origin" in result.output
|
|
808
|
+
"must include scheme and host" in result.output or "Invalid web origin" in result.output
|
|
797
809
|
)
|
|
798
810
|
|
|
799
811
|
def test_clone_rejects_file_as_target(self, mock_client, monkeypatch, tmp_path):
|
|
@@ -809,9 +821,7 @@ class TestClone:
|
|
|
809
821
|
existing_file = tmp_path / "bell-state"
|
|
810
822
|
existing_file.write_text("not a directory")
|
|
811
823
|
|
|
812
|
-
result = runner.invoke(
|
|
813
|
-
cli.app, ["clone", "qubithub/bell-state", str(existing_file)]
|
|
814
|
-
)
|
|
824
|
+
result = runner.invoke(cli.app, ["clone", "qubithub/bell-state", str(existing_file)])
|
|
815
825
|
|
|
816
826
|
assert result.exit_code == 1
|
|
817
827
|
assert "not a directory" in result.output
|
|
@@ -831,9 +841,7 @@ class TestClone:
|
|
|
831
841
|
assert result.exit_code == 0
|
|
832
842
|
assert called["value"] is True
|
|
833
843
|
|
|
834
|
-
def test_clone_fails_loud_on_server_404_when_git_available(
|
|
835
|
-
self, mock_client, monkeypatch
|
|
836
|
-
):
|
|
844
|
+
def test_clone_fails_loud_on_server_404_when_git_available(self, mock_client, monkeypatch):
|
|
837
845
|
"""Proxy 404 (disabled / not git-backed) → fail loud, never clone.
|
|
838
846
|
|
|
839
847
|
The whole point of Phase 2: a server-side fault must not
|
|
@@ -860,16 +868,14 @@ class TestClone:
|
|
|
860
868
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
861
869
|
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {})
|
|
862
870
|
|
|
863
|
-
with
|
|
871
|
+
with isolated_filesystem():
|
|
864
872
|
result = runner.invoke(cli.app, ["clone", "qubithub/bell-state"])
|
|
865
873
|
|
|
866
874
|
assert result.exit_code == 1
|
|
867
875
|
assert "does not support git clone" in result.output
|
|
868
876
|
assert "--no-git" in result.output
|
|
869
877
|
|
|
870
|
-
def test_clone_fails_loud_on_non_git_200_response(
|
|
871
|
-
self, mock_client, monkeypatch
|
|
872
|
-
):
|
|
878
|
+
def test_clone_fails_loud_on_non_git_200_response(self, mock_client, monkeypatch):
|
|
873
879
|
"""A 200 non-git response (SPA fallthrough) fails loud — and the
|
|
874
880
|
message never cites a contradictory 'HTTP 200'."""
|
|
875
881
|
monkeypatch.setattr(cli.repo, "is_git_available", lambda: True)
|
|
@@ -888,7 +894,7 @@ class TestClone:
|
|
|
888
894
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
889
895
|
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {})
|
|
890
896
|
|
|
891
|
-
with
|
|
897
|
+
with isolated_filesystem():
|
|
892
898
|
result = runner.invoke(cli.app, ["clone", "qubithub/bell-state"])
|
|
893
899
|
|
|
894
900
|
assert result.exit_code == 1
|
|
@@ -915,7 +921,7 @@ class TestClone:
|
|
|
915
921
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
916
922
|
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {})
|
|
917
923
|
|
|
918
|
-
with
|
|
924
|
+
with isolated_filesystem():
|
|
919
925
|
result = runner.invoke(cli.app, ["clone", "qubithub/bell-state"])
|
|
920
926
|
|
|
921
927
|
assert result.exit_code == 1
|
|
@@ -923,9 +929,7 @@ class TestClone:
|
|
|
923
929
|
assert "transient" in result.output
|
|
924
930
|
assert "--no-git" in result.output
|
|
925
931
|
|
|
926
|
-
def test_clone_falls_back_silently_when_git_missing(
|
|
927
|
-
self, mock_client, monkeypatch
|
|
928
|
-
):
|
|
932
|
+
def test_clone_falls_back_silently_when_git_missing(self, mock_client, monkeypatch):
|
|
929
933
|
"""git absent → HTTP fallback WITHOUT probing the proxy.
|
|
930
934
|
|
|
931
935
|
The probe distinguishes server faults from user-environment
|
|
@@ -938,9 +942,7 @@ class TestClone:
|
|
|
938
942
|
|
|
939
943
|
def counting_probe(*a, **kw):
|
|
940
944
|
probe_calls["n"] += 1
|
|
941
|
-
return cli.repo.GitProxyProbe(
|
|
942
|
-
status=cli.repo.PROXY_SUPPORTED, status_code=200
|
|
943
|
-
)
|
|
945
|
+
return cli.repo.GitProxyProbe(status=cli.repo.PROXY_SUPPORTED, status_code=200)
|
|
944
946
|
|
|
945
947
|
monkeypatch.setattr(cli.repo, "probe_git_proxy", counting_probe)
|
|
946
948
|
|
|
@@ -978,7 +980,7 @@ class TestClone:
|
|
|
978
980
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
979
981
|
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {})
|
|
980
982
|
|
|
981
|
-
with
|
|
983
|
+
with isolated_filesystem():
|
|
982
984
|
result = runner.invoke(cli.app, ["clone", "qubithub/bell-state"])
|
|
983
985
|
|
|
984
986
|
assert result.exit_code == 0, result.output
|
|
@@ -1002,19 +1004,15 @@ class TestClone:
|
|
|
1002
1004
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
1003
1005
|
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {})
|
|
1004
1006
|
|
|
1005
|
-
with
|
|
1006
|
-
result = runner.invoke(
|
|
1007
|
-
cli.app, ["--format", "json", "clone", "qubithub/bell-state"]
|
|
1008
|
-
)
|
|
1007
|
+
with isolated_filesystem():
|
|
1008
|
+
result = runner.invoke(cli.app, ["--format", "json", "clone", "qubithub/bell-state"])
|
|
1009
1009
|
|
|
1010
1010
|
assert result.exit_code == 1
|
|
1011
1011
|
payload = json.loads(result.output)
|
|
1012
1012
|
assert payload["error"] == "git_proxy_unsupported"
|
|
1013
1013
|
assert payload["status_code"] == 404
|
|
1014
1014
|
|
|
1015
|
-
def test_probe_avoids_extra_request_on_happy_path(
|
|
1016
|
-
self, mock_client, monkeypatch
|
|
1017
|
-
):
|
|
1015
|
+
def test_probe_avoids_extra_request_on_happy_path(self, mock_client, monkeypatch):
|
|
1018
1016
|
"""Happy path probes exactly once — one quick check, then clone.
|
|
1019
1017
|
|
|
1020
1018
|
Pins the contract that the SDK never double-counts requests: a
|
|
@@ -1027,9 +1025,7 @@ class TestClone:
|
|
|
1027
1025
|
|
|
1028
1026
|
def counting_probe(*a, **kw):
|
|
1029
1027
|
probe_calls["n"] += 1
|
|
1030
|
-
return cli.repo.GitProxyProbe(
|
|
1031
|
-
status=cli.repo.PROXY_SUPPORTED, status_code=200
|
|
1032
|
-
)
|
|
1028
|
+
return cli.repo.GitProxyProbe(status=cli.repo.PROXY_SUPPORTED, status_code=200)
|
|
1033
1029
|
|
|
1034
1030
|
monkeypatch.setattr(cli.repo, "probe_git_proxy", counting_probe)
|
|
1035
1031
|
|
|
@@ -1043,7 +1039,7 @@ class TestClone:
|
|
|
1043
1039
|
monkeypatch.setattr(cli.auth_module, "resolve_token", lambda _u: None)
|
|
1044
1040
|
monkeypatch.setattr(cli.auth_module, "load_credentials", lambda: {})
|
|
1045
1041
|
|
|
1046
|
-
with
|
|
1042
|
+
with isolated_filesystem():
|
|
1047
1043
|
result = runner.invoke(cli.app, ["clone", "qubithub/bell-state"])
|
|
1048
1044
|
|
|
1049
1045
|
assert result.exit_code == 0, result.output
|
|
@@ -662,13 +662,9 @@ def test_clients_default_to_prod_api_base_url():
|
|
|
662
662
|
from qubithub.async_client import AsyncQubitHubClient
|
|
663
663
|
|
|
664
664
|
expected = "https://qubithub.co/api"
|
|
665
|
+
assert inspect.signature(QubitHubClient.__init__).parameters["base_url"].default == expected
|
|
665
666
|
assert (
|
|
666
|
-
inspect.signature(
|
|
667
|
-
== expected
|
|
668
|
-
)
|
|
669
|
-
assert (
|
|
670
|
-
inspect.signature(AsyncQubitHubClient.__init__).parameters["base_url"].default
|
|
671
|
-
== expected
|
|
667
|
+
inspect.signature(AsyncQubitHubClient.__init__).parameters["base_url"].default == expected
|
|
672
668
|
)
|
|
673
669
|
# And the default flows through to the live client attribute.
|
|
674
670
|
assert QubitHubClient(auto_login=False).base_url == expected
|
|
@@ -31,6 +31,11 @@ from qubithub.repo import (
|
|
|
31
31
|
validate_directory,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
+
try:
|
|
35
|
+
import tomllib # Python 3.11+
|
|
36
|
+
except ModuleNotFoundError:
|
|
37
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
38
|
+
|
|
34
39
|
# ── Validation ───────────────────────────────────────────────────
|
|
35
40
|
|
|
36
41
|
|
|
@@ -167,8 +172,6 @@ class TestInitCircuit:
|
|
|
167
172
|
assert not (target / "metadata.json").exists()
|
|
168
173
|
|
|
169
174
|
def test_toml_content(self, tmp_path):
|
|
170
|
-
import tomllib
|
|
171
|
-
|
|
172
175
|
target = tmp_path / "bell-state"
|
|
173
176
|
init_circuit(target, "bell-state", framework="qiskit", qubits=2)
|
|
174
177
|
parsed = tomllib.loads((target / "qubithub.toml").read_text())
|
|
@@ -180,8 +183,6 @@ class TestInitCircuit:
|
|
|
180
183
|
assert parsed["project"]["entry_point"] == "circuit.py"
|
|
181
184
|
|
|
182
185
|
def test_custom_title(self, tmp_path):
|
|
183
|
-
import tomllib
|
|
184
|
-
|
|
185
186
|
target = tmp_path / "my-circuit"
|
|
186
187
|
init_circuit(target, "my-circuit", title="My Custom Circuit")
|
|
187
188
|
parsed = tomllib.loads((target / "qubithub.toml").read_text())
|
|
@@ -320,16 +321,12 @@ class TestCheckFrameworkVersion:
|
|
|
320
321
|
assert check_framework_version({"framework_version": "1.3.0"}) == []
|
|
321
322
|
|
|
322
323
|
def test_matching_version_returns_empty(self, monkeypatch):
|
|
323
|
-
monkeypatch.setattr(
|
|
324
|
-
"qubithub.repo.get_installed_framework_version", lambda _fw: "1.3.2"
|
|
325
|
-
)
|
|
324
|
+
monkeypatch.setattr("qubithub.repo.get_installed_framework_version", lambda _fw: "1.3.2")
|
|
326
325
|
warnings = check_framework_version({"framework": "qiskit", "framework_version": "1.3.0"})
|
|
327
326
|
assert warnings == []
|
|
328
327
|
|
|
329
328
|
def test_mismatch_warns(self, monkeypatch):
|
|
330
|
-
monkeypatch.setattr(
|
|
331
|
-
"qubithub.repo.get_installed_framework_version", lambda _fw: "2.0.1"
|
|
332
|
-
)
|
|
329
|
+
monkeypatch.setattr("qubithub.repo.get_installed_framework_version", lambda _fw: "2.0.1")
|
|
333
330
|
warnings = check_framework_version({"framework": "qiskit", "framework_version": "1.3.0"})
|
|
334
331
|
assert len(warnings) == 1
|
|
335
332
|
assert "mismatch" in warnings[0].lower()
|
|
@@ -337,18 +334,14 @@ class TestCheckFrameworkVersion:
|
|
|
337
334
|
assert "2.0" in warnings[0]
|
|
338
335
|
|
|
339
336
|
def test_not_installed_warns_with_install_hint(self, monkeypatch):
|
|
340
|
-
monkeypatch.setattr(
|
|
341
|
-
"qubithub.repo.get_installed_framework_version", lambda _fw: None
|
|
342
|
-
)
|
|
337
|
+
monkeypatch.setattr("qubithub.repo.get_installed_framework_version", lambda _fw: None)
|
|
343
338
|
warnings = check_framework_version({"framework": "cirq", "framework_version": "1.4.0"})
|
|
344
339
|
assert len(warnings) == 1
|
|
345
340
|
assert "not installed" in warnings[0].lower()
|
|
346
341
|
assert "pip install cirq-core" in warnings[0]
|
|
347
342
|
|
|
348
343
|
def test_unknown_framework_warns(self, monkeypatch):
|
|
349
|
-
monkeypatch.setattr(
|
|
350
|
-
"qubithub.repo.get_installed_framework_version", lambda _fw: None
|
|
351
|
-
)
|
|
344
|
+
monkeypatch.setattr("qubithub.repo.get_installed_framework_version", lambda _fw: None)
|
|
352
345
|
warnings = check_framework_version(
|
|
353
346
|
{"framework": "unknown-fw", "framework_version": "1.0.0"}
|
|
354
347
|
)
|
|
@@ -357,17 +350,13 @@ class TestCheckFrameworkVersion:
|
|
|
357
350
|
|
|
358
351
|
def test_same_major_different_patch_no_warning(self, monkeypatch):
|
|
359
352
|
"""Patch-level differences should NOT trigger a warning."""
|
|
360
|
-
monkeypatch.setattr(
|
|
361
|
-
"qubithub.repo.get_installed_framework_version", lambda _fw: "1.3.5"
|
|
362
|
-
)
|
|
353
|
+
monkeypatch.setattr("qubithub.repo.get_installed_framework_version", lambda _fw: "1.3.5")
|
|
363
354
|
warnings = check_framework_version({"framework": "qiskit", "framework_version": "1.3.0"})
|
|
364
355
|
assert warnings == []
|
|
365
356
|
|
|
366
357
|
def test_pre_release_matches(self, monkeypatch):
|
|
367
358
|
"""Pre-release installed version should match the declared major.minor."""
|
|
368
|
-
monkeypatch.setattr(
|
|
369
|
-
"qubithub.repo.get_installed_framework_version", lambda _fw: "1.3.0rc1"
|
|
370
|
-
)
|
|
359
|
+
monkeypatch.setattr("qubithub.repo.get_installed_framework_version", lambda _fw: "1.3.0rc1")
|
|
371
360
|
warnings = check_framework_version({"framework": "qiskit", "framework_version": "1.3.0"})
|
|
372
361
|
assert warnings == []
|
|
373
362
|
|
|
@@ -536,9 +525,7 @@ class TestBuildGitCloneURL:
|
|
|
536
525
|
assert url == "https://qubithub.co/qubithub/bell-state.git"
|
|
537
526
|
|
|
538
527
|
def test_preserves_port_for_localhost(self):
|
|
539
|
-
url = build_git_clone_url(
|
|
540
|
-
"http://localhost:8000", "alice", "demo", with_username=False
|
|
541
|
-
)
|
|
528
|
+
url = build_git_clone_url("http://localhost:8000", "alice", "demo", with_username=False)
|
|
542
529
|
assert url == "http://localhost:8000/alice/demo.git"
|
|
543
530
|
|
|
544
531
|
def test_invalid_origin_raises(self):
|
|
@@ -707,7 +694,6 @@ class TestGitClone:
|
|
|
707
694
|
askpass_paths.append(env["GIT_ASKPASS"])
|
|
708
695
|
raise subprocess.CalledProcessError(returncode=128, cmd=cmd)
|
|
709
696
|
|
|
710
|
-
|
|
711
697
|
with patch("qubithub.repo.subprocess.run", side_effect=fake_run):
|
|
712
698
|
with pytest.raises(subprocess.CalledProcessError):
|
|
713
699
|
git_clone(
|
|
@@ -731,7 +717,6 @@ class TestGitClone:
|
|
|
731
717
|
(Path(cmd[-1]) / "partial.txt").write_text("oops")
|
|
732
718
|
raise subprocess.CalledProcessError(returncode=128, cmd=cmd)
|
|
733
719
|
|
|
734
|
-
|
|
735
720
|
with patch("qubithub.repo.subprocess.run", side_effect=fake_run):
|
|
736
721
|
with pytest.raises(subprocess.CalledProcessError):
|
|
737
722
|
git_clone(
|
|
@@ -753,7 +738,6 @@ class TestGitClone:
|
|
|
753
738
|
def fake_run(cmd, env, check, **_kwargs):
|
|
754
739
|
raise subprocess.CalledProcessError(returncode=128, cmd=cmd)
|
|
755
740
|
|
|
756
|
-
|
|
757
741
|
with patch("qubithub.repo.subprocess.run", side_effect=fake_run):
|
|
758
742
|
with pytest.raises(subprocess.CalledProcessError):
|
|
759
743
|
git_clone(
|
|
@@ -780,10 +764,7 @@ class TestResolveWebURL:
|
|
|
780
764
|
def test_strips_api_prefix_for_staging(self):
|
|
781
765
|
from qubithub.auth import resolve_web_url
|
|
782
766
|
|
|
783
|
-
assert (
|
|
784
|
-
resolve_web_url("https://api.staging.qubithub.co")
|
|
785
|
-
== "https://staging.qubithub.co"
|
|
786
|
-
)
|
|
767
|
+
assert resolve_web_url("https://api.staging.qubithub.co") == "https://staging.qubithub.co"
|
|
787
768
|
|
|
788
769
|
def test_preserves_port(self):
|
|
789
770
|
from qubithub.auth import resolve_web_url
|
|
@@ -798,10 +779,7 @@ class TestResolveWebURL:
|
|
|
798
779
|
def test_drops_path_query_fragment(self):
|
|
799
780
|
from qubithub.auth import resolve_web_url
|
|
800
781
|
|
|
801
|
-
assert (
|
|
802
|
-
resolve_web_url("https://api.qubithub.co/v2?x=1#frag")
|
|
803
|
-
== "https://qubithub.co"
|
|
804
|
-
)
|
|
782
|
+
assert resolve_web_url("https://api.qubithub.co/v2?x=1#frag") == "https://qubithub.co"
|
|
805
783
|
|
|
806
784
|
def test_missing_scheme_raises(self):
|
|
807
785
|
from qubithub.auth import resolve_web_url
|
|
@@ -832,9 +810,7 @@ class TestResolveWebURL:
|
|
|
832
810
|
from qubithub.auth import resolve_web_url
|
|
833
811
|
|
|
834
812
|
assert (
|
|
835
|
-
resolve_web_url(
|
|
836
|
-
"https://example.com/foo?x=1#frag", strip_api_prefix=False
|
|
837
|
-
)
|
|
813
|
+
resolve_web_url("https://example.com/foo?x=1#frag", strip_api_prefix=False)
|
|
838
814
|
== "https://example.com"
|
|
839
815
|
)
|
|
840
816
|
|
|
@@ -865,9 +841,7 @@ class TestProbeGitProxy:
|
|
|
865
841
|
monkeypatch,
|
|
866
842
|
response=httpx.Response(
|
|
867
843
|
200,
|
|
868
|
-
headers={
|
|
869
|
-
"content-type": "application/x-git-upload-pack-advertisement"
|
|
870
|
-
},
|
|
844
|
+
headers={"content-type": "application/x-git-upload-pack-advertisement"},
|
|
871
845
|
),
|
|
872
846
|
)
|
|
873
847
|
probe = probe_git_proxy("https://qubithub.co", "qubithub", "bell-state")
|
|
@@ -892,9 +866,7 @@ class TestProbeGitProxy:
|
|
|
892
866
|
"""A 200 HTML body is an SPA catch-all, not a real git endpoint."""
|
|
893
867
|
self._patch_get(
|
|
894
868
|
monkeypatch,
|
|
895
|
-
response=httpx.Response(
|
|
896
|
-
200, headers={"content-type": "text/html; charset=utf-8"}
|
|
897
|
-
),
|
|
869
|
+
response=httpx.Response(200, headers={"content-type": "text/html; charset=utf-8"}),
|
|
898
870
|
)
|
|
899
871
|
probe = probe_git_proxy("https://self-hosted.example.com", "x", "y")
|
|
900
872
|
assert probe.status == PROXY_UNSUPPORTED
|
|
@@ -930,9 +902,7 @@ class TestProbeGitProxy:
|
|
|
930
902
|
monkeypatch,
|
|
931
903
|
response=httpx.Response(
|
|
932
904
|
200,
|
|
933
|
-
headers={
|
|
934
|
-
"content-type": "application/x-git-upload-pack-advertisement"
|
|
935
|
-
},
|
|
905
|
+
headers={"content-type": "application/x-git-upload-pack-advertisement"},
|
|
936
906
|
),
|
|
937
907
|
)
|
|
938
908
|
probe_git_proxy("https://qubithub.co", "qubithub", "bell-state")
|
|
@@ -20,6 +20,11 @@ from qubithub.client import QubitHubClient
|
|
|
20
20
|
from qubithub.exceptions import APIError, QubitHubError
|
|
21
21
|
from qubithub.models import CircuitDetail, User
|
|
22
22
|
|
|
23
|
+
try:
|
|
24
|
+
import tomllib # Python 3.11+
|
|
25
|
+
except ModuleNotFoundError:
|
|
26
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
27
|
+
|
|
23
28
|
runner = CliRunner()
|
|
24
29
|
|
|
25
30
|
|
|
@@ -450,7 +455,6 @@ class TestInitCLI:
|
|
|
450
455
|
monkeypatch.chdir(tmp_path)
|
|
451
456
|
result = runner.invoke(app, ["circuits", "init", "my-circuit", "--framework", "cirq"])
|
|
452
457
|
assert result.exit_code == 0
|
|
453
|
-
import tomllib
|
|
454
458
|
parsed = tomllib.loads((tmp_path / "my-circuit" / "qubithub.toml").read_text())
|
|
455
459
|
assert parsed["project"]["framework"] == "cirq"
|
|
456
460
|
|
|
@@ -480,7 +484,6 @@ class TestInitCLI:
|
|
|
480
484
|
monkeypatch.chdir(tmp_path)
|
|
481
485
|
result = runner.invoke(app, ["circuits", "init", "my-circuit", "--qubits", "4"])
|
|
482
486
|
assert result.exit_code == 0
|
|
483
|
-
import tomllib
|
|
484
487
|
parsed = tomllib.loads((tmp_path / "my-circuit" / "qubithub.toml").read_text())
|
|
485
488
|
assert parsed["project"]["qubits"] == 4
|
|
486
489
|
|
|
@@ -600,7 +603,9 @@ class TestPushCLI:
|
|
|
600
603
|
assert result.exit_code == 0
|
|
601
604
|
# Check that commit message was passed to batch upload call
|
|
602
605
|
mock_client.upload_circuit_files.assert_called_once()
|
|
603
|
-
assert
|
|
606
|
+
assert (
|
|
607
|
+
mock_client.upload_circuit_files.call_args.kwargs["commit_message"] == "Initial commit"
|
|
608
|
+
)
|
|
604
609
|
|
|
605
610
|
def test_push_not_found_without_create(self, mock_client, tmp_path, monkeypatch):
|
|
606
611
|
monkeypatch.chdir(tmp_path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|