dataspring-cli 0.5.0__tar.gz → 0.5.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.
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/PKG-INFO +1 -1
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/auth.py +31 -4
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/main.py +105 -65
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/output.py +5 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/runtime.py +8 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/skills_commands.py +2 -1
- dataspring_cli-0.5.1/cli/transport.py +143 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/version.py +16 -7
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/pyproject.toml +1 -1
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/.gitignore +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/README.md +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/__init__.py +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/bundled_manifest.py +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/contract.py +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/forspring.py +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/generated.py +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/refusals.py +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/skilltree.py +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/upgrade.py +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/hatch_build.py +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/settings.py +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/skills/dataspring-author/SKILL.md +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/skills/dataspring-consume/SKILL.md +0 -0
- {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/skills/dataspring-correct/SKILL.md +0 -0
|
@@ -52,6 +52,8 @@ import httpx
|
|
|
52
52
|
|
|
53
53
|
from cli import forspring
|
|
54
54
|
from cli.forspring import CredentialStore, OAuthError
|
|
55
|
+
from cli import transport
|
|
56
|
+
from cli.runtime import API_TIMEOUT
|
|
55
57
|
from cli.refusals import (
|
|
56
58
|
EN,
|
|
57
59
|
EXIT_FAILURE,
|
|
@@ -517,7 +519,18 @@ class CLIAuthManager:
|
|
|
517
519
|
def _key_org(self, key: str) -> str | None:
|
|
518
520
|
"""The org a key not stored here belongs to, as the server says.
|
|
519
521
|
Synchronous: :meth:`resolve` runs inside the async commands too."""
|
|
520
|
-
|
|
522
|
+
with transport.ColdStartIndicator() as indicator:
|
|
523
|
+
for attempt in range(2): # a GET: one retry on what a cold start looks like
|
|
524
|
+
try:
|
|
525
|
+
r = httpx.get(f"{_host()}/api/me", headers={"Authorization": f"Bearer {key}"}, timeout=API_TIMEOUT)
|
|
526
|
+
break
|
|
527
|
+
except transport.RETRYABLE as e:
|
|
528
|
+
if attempt == 0:
|
|
529
|
+
indicator.mark_retrying()
|
|
530
|
+
continue
|
|
531
|
+
raise transport.unreachable(e, _host(), API_TIMEOUT) from e
|
|
532
|
+
except httpx.TransportError as e:
|
|
533
|
+
raise transport.unreachable(e, _host(), API_TIMEOUT) from e
|
|
521
534
|
return self._me_answer(r).get("org_id")
|
|
522
535
|
|
|
523
536
|
def resolved_org(self) -> str | None:
|
|
@@ -653,8 +666,22 @@ class CLIAuthManager:
|
|
|
653
666
|
return self.resolve().key
|
|
654
667
|
|
|
655
668
|
async def _me(self, key: str) -> dict:
|
|
656
|
-
|
|
657
|
-
|
|
669
|
+
"""``GET /api/me``: the first request of nearly every command, so the
|
|
670
|
+
one a cold backend meets first (`cli.transport`): the "waking up"
|
|
671
|
+
status while it waits, one retry, `ApiUnreachable` if no answer."""
|
|
672
|
+
with transport.ColdStartIndicator() as indicator:
|
|
673
|
+
async with httpx.AsyncClient(timeout=API_TIMEOUT) as client:
|
|
674
|
+
for attempt in range(2):
|
|
675
|
+
try:
|
|
676
|
+
r = await client.get(f"{_host()}/api/me", headers={"Authorization": f"Bearer {key}"})
|
|
677
|
+
break
|
|
678
|
+
except transport.RETRYABLE as e:
|
|
679
|
+
if attempt == 0:
|
|
680
|
+
indicator.mark_retrying()
|
|
681
|
+
continue
|
|
682
|
+
raise transport.unreachable(e, _host(), API_TIMEOUT) from e
|
|
683
|
+
except httpx.TransportError as e:
|
|
684
|
+
raise transport.unreachable(e, _host(), API_TIMEOUT) from e
|
|
658
685
|
return self._me_answer(r)
|
|
659
686
|
|
|
660
687
|
@staticmethod
|
|
@@ -810,7 +837,7 @@ class CLIAuthManager:
|
|
|
810
837
|
detail = ""
|
|
811
838
|
try:
|
|
812
839
|
r = httpx.delete(f"{_host()}/api/keys/{p['key_id']}",
|
|
813
|
-
headers={"Authorization": f"Bearer {key}"}, timeout=
|
|
840
|
+
headers={"Authorization": f"Bearer {key}"}, timeout=API_TIMEOUT)
|
|
814
841
|
except httpx.HTTPError as e:
|
|
815
842
|
r = None
|
|
816
843
|
reason, exit_code, detail = "server_unreachable", EXIT_FAILURE, f"DataSpring did not answer ({type(e).__name__})"
|
|
@@ -40,7 +40,9 @@ from cli.output import (
|
|
|
40
40
|
OutputFormat,
|
|
41
41
|
)
|
|
42
42
|
from cli.refusals import EXIT_CODES, EXIT_USAGE, NAME_AN_ORG, classify, from_body
|
|
43
|
-
from cli
|
|
43
|
+
from cli import transport
|
|
44
|
+
from cli.runtime import API_TIMEOUT, call_tool
|
|
45
|
+
from cli.transport import ApiUnreachable
|
|
44
46
|
from typer.core import TyperGroup
|
|
45
47
|
|
|
46
48
|
|
|
@@ -80,7 +82,13 @@ class DataSpringGroup(TyperGroup):
|
|
|
80
82
|
standards §6; a D-99 refusal's), never a traceback."""
|
|
81
83
|
|
|
82
84
|
def parse_args(self, ctx, args):
|
|
83
|
-
|
|
85
|
+
args = hoist_org(list(args))
|
|
86
|
+
# `--org` is set here, before Click runs any option: `--version` is
|
|
87
|
+
# eager and runs its callback during parsing, before the root callback
|
|
88
|
+
# (which sets it again), so `dataspring --version --org noon` must not
|
|
89
|
+
# depend on the order Click processes the two in.
|
|
90
|
+
set_org_flag(args[1] if args[:1] == ["--org"] and len(args) > 1 else None)
|
|
91
|
+
return super().parse_args(ctx, args)
|
|
84
92
|
|
|
85
93
|
def invoke(self, ctx):
|
|
86
94
|
try:
|
|
@@ -190,6 +198,9 @@ def require_auth():
|
|
|
190
198
|
return run_async(auth.get_user())
|
|
191
199
|
except AuthenticationError as e:
|
|
192
200
|
auth_failed(e)
|
|
201
|
+
except ApiUnreachable as e:
|
|
202
|
+
print_error(str(e), hint=e.hint)
|
|
203
|
+
raise typer.Exit(e.exit_code)
|
|
193
204
|
|
|
194
205
|
|
|
195
206
|
def auth_failed(e: AuthenticationError) -> NoReturn:
|
|
@@ -210,50 +221,54 @@ def get_api_base() -> str:
|
|
|
210
221
|
return base.removesuffix("/api")
|
|
211
222
|
|
|
212
223
|
|
|
213
|
-
def
|
|
214
|
-
"""
|
|
215
|
-
|
|
224
|
+
def _request(method: str, path: str, token: str, *, body: dict | None = None, timeout: float, retry: bool) -> dict:
|
|
225
|
+
"""One authenticated call to the backend API; every ``api_*`` function
|
|
226
|
+
below is this with its verb fixed (`cli.transport` has the why).
|
|
216
227
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
response.raise_for_status()
|
|
224
|
-
return response.json()
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def api_delete(path: str, token: str, timeout: float = 60.0) -> dict:
|
|
228
|
-
"""Make an authenticated DELETE request to the backend API."""
|
|
228
|
+
Shows the "waking up" status while the request is slow; turns a request
|
|
229
|
+
that got no answer into `ApiUnreachable`, never a raw httpx exception.
|
|
230
|
+
``retry`` allows one automatic retry on a timeout or a failed connection,
|
|
231
|
+
for an idempotent request only (``api_get``); a dispatch call is a POST
|
|
232
|
+
and may write, so it reaches the server at most once.
|
|
233
|
+
"""
|
|
229
234
|
import httpx
|
|
230
235
|
|
|
231
236
|
url = f"{get_api_base()}{path}"
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
237
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
238
|
+
attempts = 2 if retry else 1
|
|
239
|
+
with transport.ColdStartIndicator() as indicator, httpx.Client(timeout=timeout) as client:
|
|
240
|
+
for attempt in range(attempts):
|
|
241
|
+
try:
|
|
242
|
+
response = client.request(method, url, headers=headers, json=body)
|
|
243
|
+
break
|
|
244
|
+
except transport.RETRYABLE as e:
|
|
245
|
+
if attempt + 1 < attempts:
|
|
246
|
+
indicator.mark_retrying()
|
|
247
|
+
continue
|
|
248
|
+
raise transport.unreachable(e, get_api_base(), timeout) from e
|
|
249
|
+
except httpx.TransportError as e:
|
|
250
|
+
raise transport.unreachable(e, get_api_base(), timeout) from e
|
|
251
|
+
response.raise_for_status()
|
|
252
|
+
return response.json()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def api_get(path: str, token: str, timeout: float = API_TIMEOUT) -> dict:
|
|
256
|
+
"""Make an authenticated GET request to the backend API (idempotent:
|
|
257
|
+
retried once on a timeout or a failed connection)."""
|
|
258
|
+
return _request("GET", path, token, timeout=timeout, retry=True)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def api_delete(path: str, token: str, timeout: float = API_TIMEOUT) -> dict:
|
|
262
|
+
"""Make an authenticated DELETE request to the backend API."""
|
|
263
|
+
return _request("DELETE", path, token, timeout=timeout, retry=False)
|
|
239
264
|
|
|
240
265
|
|
|
241
|
-
def api_put(path: str, body: dict, token: str, timeout: float =
|
|
266
|
+
def api_put(path: str, body: dict, token: str, timeout: float = API_TIMEOUT) -> dict:
|
|
242
267
|
"""Make an authenticated PUT request to the backend API."""
|
|
243
|
-
|
|
268
|
+
return _request("PUT", path, token, body=body, timeout=timeout, retry=False)
|
|
244
269
|
|
|
245
|
-
url = f"{get_api_base()}{path}"
|
|
246
|
-
with httpx.Client(timeout=timeout) as client:
|
|
247
|
-
response = client.put(
|
|
248
|
-
url,
|
|
249
|
-
headers={"Authorization": f"Bearer {token}"},
|
|
250
|
-
json=body,
|
|
251
|
-
)
|
|
252
|
-
response.raise_for_status()
|
|
253
|
-
return response.json()
|
|
254
270
|
|
|
255
|
-
|
|
256
|
-
def api_post(path: str, body: dict, token: str, timeout: float = 60.0) -> dict:
|
|
271
|
+
def api_post(path: str, body: dict, token: str, timeout: float = API_TIMEOUT) -> dict:
|
|
257
272
|
"""Make an authenticated POST request to the backend API.
|
|
258
273
|
|
|
259
274
|
Args:
|
|
@@ -266,18 +281,11 @@ def api_post(path: str, body: dict, token: str, timeout: float = 60.0) -> dict:
|
|
|
266
281
|
|
|
267
282
|
Raises:
|
|
268
283
|
Exception: On HTTP or connection errors
|
|
269
|
-
"""
|
|
270
|
-
import httpx
|
|
271
284
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
headers={"Authorization": f"Bearer {token}"},
|
|
277
|
-
json=body,
|
|
278
|
-
)
|
|
279
|
-
response.raise_for_status()
|
|
280
|
-
return response.json()
|
|
285
|
+
Never retried: every write goes through here (``api_dispatch``), and a
|
|
286
|
+
dispatch call must reach the server at most once.
|
|
287
|
+
"""
|
|
288
|
+
return _request("POST", path, token, body=body, timeout=timeout, retry=False)
|
|
281
289
|
|
|
282
290
|
|
|
283
291
|
class DispatchToolError(Exception):
|
|
@@ -298,14 +306,15 @@ class DispatchToolError(Exception):
|
|
|
298
306
|
super().__init__(str(payload.get("error")))
|
|
299
307
|
|
|
300
308
|
|
|
301
|
-
def api_dispatch(tool_key: str, body: dict, token: str, timeout: float =
|
|
309
|
+
def api_dispatch(tool_key: str, body: dict, token: str, timeout: float = API_TIMEOUT) -> dict:
|
|
302
310
|
"""``POST /api/dispatch/{tool_key}`` and unwrap the handler's answer.
|
|
303
311
|
|
|
304
312
|
Action families take ``{"action": {"action": "<verb>", ...}}``; flat tools
|
|
305
313
|
take their keyword arguments as the body. A ``{"error": ...}`` answer
|
|
306
314
|
becomes `DispatchToolError`; HTTP 400/403/404 arrive as
|
|
307
|
-
``httpx.HTTPStatusError
|
|
308
|
-
|
|
315
|
+
``httpx.HTTPStatusError``, and a connect/timeout failure as
|
|
316
|
+
`ApiUnreachable`, both from `api_post`. `format_api_error` turns any of
|
|
317
|
+
these into a message the user can act on.
|
|
309
318
|
"""
|
|
310
319
|
result = api_post(f"/api/dispatch/{tool_key}", body, token, timeout=timeout)
|
|
311
320
|
if isinstance(result, dict) and result.get("error"):
|
|
@@ -357,13 +366,18 @@ def format_api_error(exc: Exception, role_action: str | None = None) -> tuple[st
|
|
|
357
366
|
``role_action`` hint (e.g. ``"delete metrics"``) is only attached to 403
|
|
358
367
|
responses; on other failures the hint is omitted so we don't gaslight the
|
|
359
368
|
user into thinking it's a permission problem. A `DispatchToolError` carries
|
|
360
|
-
the handler's own message and, when it offered one, its suggestion
|
|
369
|
+
the handler's own message and, when it offered one, its suggestion; an
|
|
370
|
+
`ApiUnreachable` (a connect or timeout failure in ``_request``) carries
|
|
371
|
+
its own message and hint the same way.
|
|
361
372
|
"""
|
|
362
373
|
import httpx
|
|
363
374
|
|
|
364
375
|
if isinstance(exc, DispatchToolError):
|
|
365
376
|
return str(exc), exc.hint
|
|
366
377
|
|
|
378
|
+
if isinstance(exc, transport.ApiUnreachable):
|
|
379
|
+
return str(exc), exc.hint
|
|
380
|
+
|
|
367
381
|
if isinstance(exc, httpx.HTTPStatusError):
|
|
368
382
|
status = exc.response.status_code
|
|
369
383
|
refusal = _response_refusal(exc.response)
|
|
@@ -486,15 +500,35 @@ def load_definition_file(file: Path) -> dict:
|
|
|
486
500
|
# ============================================================================
|
|
487
501
|
|
|
488
502
|
|
|
503
|
+
def _version_token() -> tuple[str | None, str | None]:
|
|
504
|
+
"""The credential ``--version`` compares the server with, or why there is none.
|
|
505
|
+
|
|
506
|
+
A version needs no org. The comparison is advisory, so it uses an org
|
|
507
|
+
only when one is unambiguous (``--org``, ``DATASPRING_ORG``/``FORSPRING_ORG``,
|
|
508
|
+
``.forspring.json``, the only sign-in, or ``DATASPRING_API_KEY``) and
|
|
509
|
+
otherwise says why it skipped. Nothing here may raise: before 0.5.1,
|
|
510
|
+
several sign-ins and no org named made ``dataspring --version`` end in an
|
|
511
|
+
``org_required`` traceback (the eager option runs outside the root
|
|
512
|
+
command's refusal handling).
|
|
513
|
+
"""
|
|
514
|
+
try:
|
|
515
|
+
if not auth.is_logged_in():
|
|
516
|
+
return None, "log in to compare with the server"
|
|
517
|
+
return auth.get_access_token(), None
|
|
518
|
+
except Refused as e:
|
|
519
|
+
if e.reason == "org_required":
|
|
520
|
+
return None, "server not compared (several orgs signed in; pass --org)"
|
|
521
|
+
return None, f"server not compared ({e.reason})"
|
|
522
|
+
except Exception as e: # advisory: a version prints whatever else is wrong
|
|
523
|
+
return None, f"server not compared ({type(e).__name__})"
|
|
524
|
+
|
|
525
|
+
|
|
489
526
|
def _print_version_report() -> None:
|
|
490
|
-
"""The version, and the manifest skew against the server when
|
|
527
|
+
"""The version, and the manifest skew against the server when an org is unambiguous."""
|
|
491
528
|
from cli.version import version_report
|
|
492
529
|
|
|
493
|
-
token =
|
|
494
|
-
|
|
495
|
-
lines = version_report(base=get_api_base(), token=token)
|
|
496
|
-
except AuthenticationError:
|
|
497
|
-
lines = version_report(base=get_api_base(), token=None)
|
|
530
|
+
token, note = _version_token()
|
|
531
|
+
lines = version_report(base=get_api_base(), token=token, note=note)
|
|
498
532
|
console.print(f"[bold]DataSpring CLI[/] v{lines[0].split(' ', 1)[1]}")
|
|
499
533
|
for line in lines[1:]:
|
|
500
534
|
console.print(line)
|
|
@@ -528,7 +562,7 @@ def _root(
|
|
|
528
562
|
|
|
529
563
|
@app.command()
|
|
530
564
|
def version():
|
|
531
|
-
"""Show the CLI version and, when
|
|
565
|
+
"""Show the CLI version and, when an org is unambiguous, its skew against the server."""
|
|
532
566
|
_print_version_report()
|
|
533
567
|
|
|
534
568
|
|
|
@@ -789,6 +823,12 @@ def whoami(
|
|
|
789
823
|
auth_failed(e)
|
|
790
824
|
except AuthenticationError as e:
|
|
791
825
|
auth_failed(e)
|
|
826
|
+
except ApiUnreachable as e:
|
|
827
|
+
if as_json:
|
|
828
|
+
typer.echo(json.dumps({"ok": False, "reason": "server_unreachable", "note": str(e), "fix": e.hint}))
|
|
829
|
+
else:
|
|
830
|
+
print_error(str(e), hint=e.hint)
|
|
831
|
+
raise typer.Exit(e.exit_code)
|
|
792
832
|
if as_json:
|
|
793
833
|
typer.echo(json.dumps(me))
|
|
794
834
|
return
|
|
@@ -857,7 +897,7 @@ def report(
|
|
|
857
897
|
"source": "cli",
|
|
858
898
|
},
|
|
859
899
|
token=token,
|
|
860
|
-
timeout=
|
|
900
|
+
timeout=API_TIMEOUT,
|
|
861
901
|
)
|
|
862
902
|
print_success(f"Error report submitted (ID: {result['id']})")
|
|
863
903
|
print_info("Thank you for the feedback.")
|
|
@@ -2595,8 +2635,8 @@ def secret_set_cmd(
|
|
|
2595
2635
|
raise typer.Exit(2)
|
|
2596
2636
|
|
|
2597
2637
|
try:
|
|
2598
|
-
minted = call_tool("secret_edit", "set", {"name": name}, token=token, timeout=
|
|
2599
|
-
result = api_post(f"/api/secret-links/{_link_nonce(minted['link'])}", {"value": value}, token, timeout=
|
|
2638
|
+
minted = call_tool("secret_edit", "set", {"name": name}, token=token, timeout=API_TIMEOUT)
|
|
2639
|
+
result = api_post(f"/api/secret-links/{_link_nonce(minted['link'])}", {"value": value}, token, timeout=API_TIMEOUT)
|
|
2600
2640
|
except Exception as e:
|
|
2601
2641
|
fail(e, role_action="set secrets", prefix="Failed to set the secret")
|
|
2602
2642
|
|
|
@@ -2623,7 +2663,7 @@ def airbyte_credentials(
|
|
|
2623
2663
|
require_auth()
|
|
2624
2664
|
token = auth.get_access_token()
|
|
2625
2665
|
try:
|
|
2626
|
-
minted = call_tool("secret_edit", "reveal", {}, token=token, timeout=
|
|
2666
|
+
minted = call_tool("secret_edit", "reveal", {}, token=token, timeout=API_TIMEOUT)
|
|
2627
2667
|
except Exception as e:
|
|
2628
2668
|
fail(e, role_action="reveal issued credentials", prefix="Failed to mint a reveal link")
|
|
2629
2669
|
|
|
@@ -2632,7 +2672,7 @@ def airbyte_credentials(
|
|
|
2632
2672
|
print_info(f"Shows {minted.get('secret')} once, valid until {minted.get('expires_at')}; never paste it into a chat.")
|
|
2633
2673
|
return
|
|
2634
2674
|
try:
|
|
2635
|
-
result = api_post(f"/api/secret-links/{_link_nonce(minted['link'])}/reveal", {}, token, timeout=
|
|
2675
|
+
result = api_post(f"/api/secret-links/{_link_nonce(minted['link'])}/reveal", {}, token, timeout=API_TIMEOUT)
|
|
2636
2676
|
except Exception as e:
|
|
2637
2677
|
fail(e, prefix="Failed to read the key through the link")
|
|
2638
2678
|
sys.stdout.write(result["value"])
|
|
@@ -3624,7 +3664,7 @@ def _fetch_skill_markdown(path: str) -> str:
|
|
|
3624
3664
|
|
|
3625
3665
|
url = _skill_url(path)
|
|
3626
3666
|
try:
|
|
3627
|
-
response = httpx.get(url, timeout=
|
|
3667
|
+
response = httpx.get(url, timeout=API_TIMEOUT)
|
|
3628
3668
|
response.raise_for_status()
|
|
3629
3669
|
return response.text
|
|
3630
3670
|
except httpx.HTTPStatusError as e:
|
|
@@ -16,6 +16,11 @@ from rich.table import Table
|
|
|
16
16
|
# Shared console instance
|
|
17
17
|
console = Console()
|
|
18
18
|
|
|
19
|
+
#: A second console bound to stderr, for what must never land in stdout's data
|
|
20
|
+
#: stream (a table, JSON, CSV): the transient "waking up" status
|
|
21
|
+
#: (`cli.transport.ColdStartIndicator`) while a request is pending.
|
|
22
|
+
stderr_console = Console(stderr=True)
|
|
23
|
+
|
|
19
24
|
OutputFormat = Literal["table", "json", "yaml", "csv"]
|
|
20
25
|
|
|
21
26
|
|
|
@@ -41,6 +41,14 @@ EXIT_USAGE = 2
|
|
|
41
41
|
#: the slow ones, and a query has its own byte and time caps on the server.
|
|
42
42
|
DEFAULT_TIMEOUT = 180.0
|
|
43
43
|
|
|
44
|
+
#: Seconds any other call to the DataSpring backend waits before it gives up.
|
|
45
|
+
#: A Cloud Run instance that is starting (after a deploy, or after idling to
|
|
46
|
+
#: zero) takes about 30s to answer its first request, and the Hosting proxy
|
|
47
|
+
#: in front of it cuts a request off at 60s, so less than 60 turns a cold
|
|
48
|
+
#: start into an error and more buys nothing. The account (forspring.py) and
|
|
49
|
+
#: PyPI are not behind Cloud Run and keep their own, shorter timeouts.
|
|
50
|
+
API_TIMEOUT = 60.0
|
|
51
|
+
|
|
44
52
|
|
|
45
53
|
# ---------------------------------------------------------------------------
|
|
46
54
|
# Values
|
|
@@ -29,6 +29,7 @@ import typer
|
|
|
29
29
|
|
|
30
30
|
from cli import skilltree
|
|
31
31
|
from cli.output import console, print_error, print_warning
|
|
32
|
+
from cli.runtime import API_TIMEOUT
|
|
32
33
|
|
|
33
34
|
EXIT_GENERIC = 1
|
|
34
35
|
EXIT_USAGE = 2
|
|
@@ -60,7 +61,7 @@ def _api_base() -> str:
|
|
|
60
61
|
|
|
61
62
|
def _get_json(path: str) -> dict:
|
|
62
63
|
url = f"{_api_base()}{path}"
|
|
63
|
-
with httpx.Client(timeout=
|
|
64
|
+
with httpx.Client(timeout=API_TIMEOUT, **CLIENT_OPTIONS) as client:
|
|
64
65
|
response = client.get(url)
|
|
65
66
|
if response.status_code == 404:
|
|
66
67
|
detail = response.json().get("detail", "not found") if response.headers.get("content-type", "").startswith(
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""What the CLI does while the DataSpring backend is slow to answer, and when
|
|
2
|
+
it does not answer at all.
|
|
3
|
+
|
|
4
|
+
Cloud Run scales the backend to zero, and a starting instance takes about 30s
|
|
5
|
+
to answer its first request (the Hosting proxy in front of it cuts a request
|
|
6
|
+
off at 60s). Every call to the backend goes through here - `cli.main._request`
|
|
7
|
+
for the API and dispatch calls, `cli.auth` for ``GET /api/me`` - so all of them
|
|
8
|
+
behave the same way on a cold start:
|
|
9
|
+
|
|
10
|
+
- `ColdStartIndicator`: once a request has been pending for
|
|
11
|
+
`STATUS_THRESHOLD_SECONDS`, a transient "Waking up DataSpring… 7s" status on
|
|
12
|
+
stderr, cleared the moment the answer (or the failure) is in hand. Only on an
|
|
13
|
+
interactive stderr and never with ``CI`` set, so a pipe, a log or a redirected
|
|
14
|
+
stderr sees nothing; stdout is never touched. A fast answer shows nothing and
|
|
15
|
+
makes no extra request.
|
|
16
|
+
- `RETRYABLE`: the failures a cold start looks like (a timeout, a refused or
|
|
17
|
+
dropped connection). An idempotent request (a ``GET``) is retried once on
|
|
18
|
+
these; a ``POST`` never is.
|
|
19
|
+
- `unreachable`: what a request that never got an answer becomes - an
|
|
20
|
+
`ApiUnreachable` with one line and a hint, printed as ``✗ Error: ...`` and
|
|
21
|
+
exiting 1, never an httpx traceback.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from cli.output import stderr_console
|
|
34
|
+
|
|
35
|
+
#: Seconds a request may be pending before the status shows. A warm backend
|
|
36
|
+
#: answers well inside this; only a request that outlasts it looks like a cold
|
|
37
|
+
#: start. Tests shrink it instead of sleeping.
|
|
38
|
+
STATUS_THRESHOLD_SECONDS = 2.5
|
|
39
|
+
|
|
40
|
+
#: Failures that may be a cold start, and so are worth one retry of a GET.
|
|
41
|
+
RETRYABLE: tuple[type[Exception], ...] = (httpx.TimeoutException, httpx.ConnectError)
|
|
42
|
+
|
|
43
|
+
HINT = "The service may be starting up; try again in a moment."
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ApiUnreachable(Exception):
|
|
47
|
+
"""No answer came back at all: a timeout, or a connection that failed or
|
|
48
|
+
dropped (any ``httpx.TransportError``). Carries one line and a hint;
|
|
49
|
+
`cli.main.format_api_error` prints it like any other failure (exit 1)."""
|
|
50
|
+
|
|
51
|
+
exit_code = 1
|
|
52
|
+
|
|
53
|
+
def __init__(self, message: str, hint: str = HINT):
|
|
54
|
+
self.hint = hint
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def unreachable(exc: Exception, base: str, timeout: float) -> ApiUnreachable:
|
|
59
|
+
"""The message for a transport failure, saying whether the server did
|
|
60
|
+
not answer in time or the connection itself failed."""
|
|
61
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
62
|
+
return ApiUnreachable(f"Could not reach DataSpring at {base} (timed out after {timeout:g}s).")
|
|
63
|
+
if isinstance(exc, httpx.ConnectError):
|
|
64
|
+
return ApiUnreachable(f"Could not reach DataSpring at {base}: connection failed.")
|
|
65
|
+
return ApiUnreachable(f"Could not reach DataSpring at {base}: {type(exc).__name__}.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _stderr_is_a_tty() -> bool:
|
|
69
|
+
"""Whether stderr is interactive; a seam the tests patch."""
|
|
70
|
+
try:
|
|
71
|
+
return sys.stderr.isatty()
|
|
72
|
+
except Exception:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def status_allowed() -> bool:
|
|
77
|
+
"""A status line is for a person watching: an interactive stderr, not CI.
|
|
78
|
+
Anywhere else its redraws would only leave escape codes in a log."""
|
|
79
|
+
if os.environ.get("CI"):
|
|
80
|
+
return False
|
|
81
|
+
return _stderr_is_a_tty()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ColdStartIndicator:
|
|
85
|
+
"""The "waking up" status for one request, across its retry if it has
|
|
86
|
+
one. Use as a context manager: the status starts lazily on a daemon
|
|
87
|
+
thread after `STATUS_THRESHOLD_SECONDS` and is always stopped on exit,
|
|
88
|
+
whether the request answered, raised, or was interrupted (Ctrl-C).
|
|
89
|
+
Works around sync and async requests alike, since it never blocks them.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self) -> None:
|
|
93
|
+
self._enabled = status_allowed()
|
|
94
|
+
self._stop = threading.Event()
|
|
95
|
+
self._lock = threading.Lock()
|
|
96
|
+
self._status = None
|
|
97
|
+
self._thread: threading.Thread | None = None
|
|
98
|
+
self._started_at = 0.0
|
|
99
|
+
self._retrying = False
|
|
100
|
+
|
|
101
|
+
def __enter__(self) -> "ColdStartIndicator":
|
|
102
|
+
if self._enabled:
|
|
103
|
+
self._started_at = time.monotonic()
|
|
104
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
105
|
+
self._thread.start()
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def __exit__(self, *exc_info) -> None:
|
|
109
|
+
self.stop()
|
|
110
|
+
|
|
111
|
+
def mark_retrying(self) -> None:
|
|
112
|
+
"""Switch the wording for the retry, keeping the clock running."""
|
|
113
|
+
self._retrying = True
|
|
114
|
+
|
|
115
|
+
def stop(self) -> None:
|
|
116
|
+
self._stop.set()
|
|
117
|
+
if self._thread is not None:
|
|
118
|
+
self._thread.join(timeout=1.0)
|
|
119
|
+
with self._lock:
|
|
120
|
+
status, self._status = self._status, None
|
|
121
|
+
if status is not None:
|
|
122
|
+
status.stop()
|
|
123
|
+
|
|
124
|
+
def _run(self) -> None:
|
|
125
|
+
# Nothing renders before the threshold: a request that answers first
|
|
126
|
+
# sets the event and this returns having shown nothing.
|
|
127
|
+
if self._stop.wait(STATUS_THRESHOLD_SECONDS):
|
|
128
|
+
return
|
|
129
|
+
with self._lock:
|
|
130
|
+
if self._stop.is_set():
|
|
131
|
+
return
|
|
132
|
+
self._status = stderr_console.status(self._text(), spinner="dots")
|
|
133
|
+
self._status.start()
|
|
134
|
+
while not self._stop.wait(1.0):
|
|
135
|
+
with self._lock:
|
|
136
|
+
if self._status is not None:
|
|
137
|
+
self._status.update(self._text())
|
|
138
|
+
|
|
139
|
+
def _text(self) -> str:
|
|
140
|
+
elapsed = int(time.monotonic() - self._started_at)
|
|
141
|
+
if self._retrying:
|
|
142
|
+
return f"Still waking up DataSpring… retrying ({elapsed}s)"
|
|
143
|
+
return f"Waking up DataSpring… (the server is starting, this can take up to ~30s) {elapsed}s"
|
|
@@ -4,8 +4,8 @@ The wheel is built against one dispatch manifest (``cli/bundled_manifest.py``
|
|
|
4
4
|
carries its hash). A deployed server may be newer: it then has operations or
|
|
5
5
|
fields this CLI does not know, and the only way the CLI learns that is by
|
|
6
6
|
comparing hashes with ``GET /api/dispatch`` (decision D19). Not logged in,
|
|
7
|
-
or the server unreachable
|
|
8
|
-
note, never a failure.
|
|
7
|
+
or the server unreachable, or several orgs signed in and none named: the
|
|
8
|
+
version still prints; the comparison is a note, never a failure.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
@@ -27,6 +27,9 @@ UPGRADE_HINT = "run `dataspring upgrade`"
|
|
|
27
27
|
CACHE_FILE = Path.home() / ".dataspring" / "manifest-check.json"
|
|
28
28
|
CACHE_TTL_SECONDS = 3600
|
|
29
29
|
|
|
30
|
+
#: Seconds the manifest-skew check waits; see `fetch_server_hash`.
|
|
31
|
+
SKEW_CHECK_TIMEOUT = 5.0
|
|
32
|
+
|
|
30
33
|
|
|
31
34
|
def cli_version() -> str:
|
|
32
35
|
try:
|
|
@@ -45,7 +48,11 @@ def bundled_hash() -> str | None:
|
|
|
45
48
|
|
|
46
49
|
def fetch_server_hash(base: str, token: str) -> str:
|
|
47
50
|
"""The manifest hash ``GET /api/dispatch`` answers, bearer-authenticated."""
|
|
48
|
-
|
|
51
|
+
# Advisory (the skew hint, never the command's answer), so it is kept
|
|
52
|
+
# short: on a cold backend it gives up after 5s, says nothing, and is
|
|
53
|
+
# tried again by the next command, rather than holding the real request
|
|
54
|
+
# back for a whole cold start with no status on screen.
|
|
55
|
+
with httpx.Client(timeout=SKEW_CHECK_TIMEOUT) as client:
|
|
49
56
|
response = client.get(f"{base}/api/dispatch", headers={"Authorization": f"Bearer {token}"})
|
|
50
57
|
response.raise_for_status()
|
|
51
58
|
return response.json()["hash"]
|
|
@@ -107,16 +114,18 @@ def skew_lines(server: str | None, bundled: str | None, *, server_note: str | No
|
|
|
107
114
|
]
|
|
108
115
|
|
|
109
116
|
|
|
110
|
-
def version_report(*, base: str, token: str | None) -> list[str]:
|
|
111
|
-
"""Everything ``--version`` prints, one entry per line.
|
|
117
|
+
def version_report(*, base: str, token: str | None, note: str | None = None) -> list[str]:
|
|
118
|
+
"""Everything ``--version`` prints, one entry per line. Without a token
|
|
119
|
+
the comparison is skipped with ``note`` (why there is none); the first
|
|
120
|
+
line, the CLI's own version, is always there and never needs the server."""
|
|
112
121
|
lines = [f"{DIST} {cli_version()}"]
|
|
113
122
|
bundled = bundled_hash()
|
|
114
123
|
if token is None:
|
|
115
|
-
lines += skew_lines(None, bundled, server_note="log in to compare with the server")
|
|
124
|
+
lines += skew_lines(None, bundled, server_note=note or "log in to compare with the server")
|
|
116
125
|
return lines
|
|
117
126
|
try:
|
|
118
127
|
server = fetch_server_hash(base, token)
|
|
119
|
-
except (httpx.HTTPError, KeyError, ValueError) as e:
|
|
128
|
+
except (httpx.HTTPError, KeyError, ValueError, TypeError) as e:
|
|
120
129
|
lines += skew_lines(None, bundled, server_note=f"server not compared ({type(e).__name__})")
|
|
121
130
|
return lines
|
|
122
131
|
lines += skew_lines(server, bundled)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|