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.
Files changed (24) hide show
  1. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/PKG-INFO +1 -1
  2. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/auth.py +31 -4
  3. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/main.py +105 -65
  4. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/output.py +5 -0
  5. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/runtime.py +8 -0
  6. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/skills_commands.py +2 -1
  7. dataspring_cli-0.5.1/cli/transport.py +143 -0
  8. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/version.py +16 -7
  9. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/pyproject.toml +1 -1
  10. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/.gitignore +0 -0
  11. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/README.md +0 -0
  12. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/__init__.py +0 -0
  13. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/bundled_manifest.py +0 -0
  14. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/contract.py +0 -0
  15. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/forspring.py +0 -0
  16. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/generated.py +0 -0
  17. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/refusals.py +0 -0
  18. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/skilltree.py +0 -0
  19. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/cli/upgrade.py +0 -0
  20. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/hatch_build.py +0 -0
  21. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/settings.py +0 -0
  22. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/skills/dataspring-author/SKILL.md +0 -0
  23. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/skills/dataspring-consume/SKILL.md +0 -0
  24. {dataspring_cli-0.5.0 → dataspring_cli-0.5.1}/skills/dataspring-correct/SKILL.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dataspring-cli
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: DataSpring CLI - Query metrics and manage dashboards from the terminal
5
5
  Project-URL: Homepage, https://dataspring.app
6
6
  Project-URL: Documentation, https://dataspring.app/docs
@@ -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
- r = httpx.get(f"{_host()}/api/me", headers={"Authorization": f"Bearer {key}"}, timeout=15.0)
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
- async with httpx.AsyncClient(timeout=15.0) as client:
657
- r = await client.get(f"{_host()}/api/me", headers={"Authorization": f"Bearer {key}"})
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=10.0)
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.runtime import call_tool
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
- return super().parse_args(ctx, hoist_org(list(args)))
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 api_get(path: str, token: str, timeout: float = 60.0) -> dict:
214
- """Make an authenticated GET request to the backend API."""
215
- import httpx
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
- url = f"{get_api_base()}{path}"
218
- with httpx.Client(timeout=timeout) as client:
219
- response = client.get(
220
- url,
221
- headers={"Authorization": f"Bearer {token}"},
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
- with httpx.Client(timeout=timeout) as client:
233
- response = client.delete(
234
- url,
235
- headers={"Authorization": f"Bearer {token}"},
236
- )
237
- response.raise_for_status()
238
- return response.json()
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 = 60.0) -> dict:
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
- import httpx
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
- url = f"{get_api_base()}{path}"
273
- with httpx.Client(timeout=timeout) as client:
274
- response = client.post(
275
- url,
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 = 60.0) -> dict:
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`` from `api_post`. `format_api_error` turns either
308
- into a message the user can act on.
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 logged in."""
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 = auth.get_access_token() if auth.is_logged_in() else None
494
- try:
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 logged in, its skew against the server."""
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=15.0,
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=30.0)
2599
- result = api_post(f"/api/secret-links/{_link_nonce(minted['link'])}", {"value": value}, token, timeout=60.0)
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=30.0)
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=30.0)
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=30.0)
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=30.0, **CLIENT_OPTIONS) as client:
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: the version still prints; the comparison is a
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
- with httpx.Client(timeout=15.0) as client:
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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataspring-cli"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  description = "DataSpring CLI - Query metrics and manage dashboards from the terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
File without changes