boxd 0.2.6.dev355__tar.gz → 0.2.6.dev398__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 (41) hide show
  1. {boxd-0.2.6.dev355/src/boxd.egg-info → boxd-0.2.6.dev398}/PKG-INFO +1 -1
  2. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/pyproject.toml +1 -1
  3. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/_mappers.py +10 -2
  4. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/_transport.py +12 -1
  5. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/_version_check.py +37 -1
  6. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/models.py +5 -0
  7. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/resources/machines.py +8 -4
  8. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398/src/boxd.egg-info}/PKG-INFO +1 -1
  9. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/tests/test_mappers.py +12 -0
  10. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/tests/test_namespaces.py +1 -0
  11. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/tests/test_version_check.py +47 -0
  12. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/LICENSE +0 -0
  13. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/README.md +0 -0
  14. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/setup.cfg +0 -0
  15. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/__init__.py +0 -0
  16. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/_client.py +0 -0
  17. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/_credentials.py +0 -0
  18. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/_generated/__init__.py +0 -0
  19. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/_generated/api_pb2.py +0 -0
  20. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/_generated/api_pb2_grpc.py +0 -0
  21. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/_requests.py +0 -0
  22. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/_streaming.py +0 -0
  23. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/_urls.py +0 -0
  24. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/errors.py +0 -0
  25. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/resources/__init__.py +0 -0
  26. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/resources/account.py +0 -0
  27. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/resources/credentials.py +0 -0
  28. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/resources/disks.py +0 -0
  29. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/resources/domains.py +0 -0
  30. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/resources/orgs.py +0 -0
  31. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/resources/snapshots.py +0 -0
  32. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd/resources/vars.py +0 -0
  33. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd.egg-info/SOURCES.txt +0 -0
  34. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd.egg-info/dependency_links.txt +0 -0
  35. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd.egg-info/requires.txt +0 -0
  36. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/src/boxd.egg-info/top_level.txt +0 -0
  37. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/tests/test_credentials.py +0 -0
  38. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/tests/test_requests.py +0 -0
  39. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/tests/test_streaming.py +0 -0
  40. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/tests/test_transport.py +0 -0
  41. {boxd-0.2.6.dev355 → boxd-0.2.6.dev398}/tests/test_urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boxd
3
- Version: 0.2.6.dev355
3
+ Version: 0.2.6.dev398
4
4
  Summary: Python SDK for the boxd cloud VM platform
5
5
  Author: Azin
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "boxd"
3
- version = "0.2.6.dev355"
3
+ version = "0.2.6.dev398"
4
4
  description = "Python SDK for the boxd cloud VM platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -80,6 +80,11 @@ def _epoch_ms_always(millis: int) -> datetime:
80
80
  # ── Machines ─────────────────────────────────────────────────────────
81
81
 
82
82
 
83
+ def _desktop_url(name: str, console_origin: str) -> str:
84
+ """`<console>/desktop/<name>`, or "" when the console origin is unknown."""
85
+ return f"{console_origin}/desktop/{name}" if console_origin else ""
86
+
87
+
83
88
  def _machine_url(name: str, domain: str) -> str:
84
89
  """`https://<name>.<domain>`, or "" when the domain is not known yet."""
85
90
  return f"https://{name}.{domain}" if domain else ""
@@ -99,7 +104,7 @@ def machine_source(msg) -> MachineSource | None:
99
104
  )
100
105
 
101
106
 
102
- def machine(msg) -> Machine:
107
+ def machine(msg, console_origin: str = "") -> Machine:
103
108
  # `billing_org`/`billing_org_id` name the machine's org whether or not it
104
109
  # is shared. `org`/`org_id` are the same org, blanked when it is private —
105
110
  # so they answer "is it shared", and nothing else.
@@ -131,6 +136,7 @@ def machine(msg) -> Machine:
131
136
  ssh_port=msg.ssh_port or None,
132
137
  domain=msg.access_domain,
133
138
  url=_machine_url(msg.name, msg.access_domain),
139
+ desktop_url=_desktop_url(msg.name, console_origin),
134
140
  custom_domains=[custom_domain(d) for d in msg.custom_domains],
135
141
  ),
136
142
  # 0 is MEANINGFUL here — disabled, not unset. Do not null it.
@@ -167,7 +173,9 @@ def machine_from_created(msg) -> Machine:
167
173
  # Not reported by these responses — `create`/`fork` re-read the
168
174
  # machine, so this fallback only shows when that read failed.
169
175
  networking=MachineNetworking(networks=[], isolated=False),
170
- access=MachineAccess(ssh_port=None, domain="", url="", custom_domains=[]),
176
+ access=MachineAccess(
177
+ ssh_port=None, domain="", url="", desktop_url="", custom_domains=[]
178
+ ),
171
179
  idle=MachineIdle(suspend_after=0, hibernate_after=0, destroy_after=0),
172
180
  source=None,
173
181
  hibernated_at=None,
@@ -17,7 +17,7 @@ import grpc.aio
17
17
  from . import _version_check
18
18
  from ._credentials import AsyncCredentials, SyncCredentials
19
19
  from ._generated import api_pb2_grpc
20
- from ._urls import DEFAULT_BASE_URL, resolve_endpoint
20
+ from ._urls import DEFAULT_BASE_URL, console_base_url, resolve_endpoint
21
21
  from .errors import BoxdError, from_grpc_error, is_retryable
22
22
 
23
23
  # First backoff, doubled per attempt. Two retries by default ⇒ 0.5s then 1.0s.
@@ -49,6 +49,17 @@ class _TransportBase:
49
49
  or DEFAULT_BASE_URL
50
50
  )
51
51
 
52
+ @property
53
+ def console_origin(self) -> str:
54
+ """The web console's origin for whichever cluster this client talks to.
55
+
56
+ Derived from the same `base_url` the channel dials, so the two can
57
+ never disagree. Note it cannot come from a machine's own
58
+ `access.domain`: that may be the ORG's wildcard domain, and the
59
+ console is never under it.
60
+ """
61
+ return console_base_url(self.base_url)
62
+
52
63
  def _build_channel(self, factory_secure, factory_insecure):
53
64
  endpoint = resolve_endpoint(self.base_url)
54
65
  if endpoint.use_tls:
@@ -11,6 +11,7 @@ produced; the comparison and the nag are shared.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import os
14
15
  import re
15
16
  import sys
16
17
  from collections.abc import Iterable
@@ -18,6 +19,7 @@ from collections.abc import Iterable
18
19
  REQ_HEADER = "x-boxd-py-sdk-version"
19
20
  RESP_HEADER = "x-boxd-py-sdk-latest"
20
21
  UPGRADE_CMD = "pip install --upgrade boxd"
22
+ PRERELEASE_UPGRADE_CMD = "pip install --upgrade --pre boxd"
21
23
 
22
24
  _notified = False
23
25
 
@@ -40,6 +42,15 @@ def note_metadata(md: Iterable[tuple[str, str]] | None) -> None:
40
42
  global _notified
41
43
  if _notified or md is None:
42
44
  return
45
+ # Only for a person at a terminal: in CI, or in an automation job whose
46
+ # stderr is its log file, this is noise on every run and nothing acts on
47
+ # it.
48
+ # An explicit opt-out beats every heuristic: set BOXD_NO_UPDATE_NOTICE to
49
+ # any non-empty value and this is never printed.
50
+ if os.environ.get("BOXD_NO_UPDATE_NOTICE"):
51
+ return
52
+ if not _stderr_is_tty():
53
+ return
43
54
  latest = None
44
55
  for key, value in md:
45
56
  if key.lower() == RESP_HEADER:
@@ -50,13 +61,38 @@ def note_metadata(md: Iterable[tuple[str, str]] | None) -> None:
50
61
  current = _current_version()
51
62
  if _compare_semver(latest, current) <= 0:
52
63
  return
64
+ # A pre-release is opt-in (`pip install --pre`), so someone on a release
65
+ # build is not behind on anything they asked for — and the command below
66
+ # would not install it anyway, leaving the nag to repeat forever. Staging
67
+ # advertises its own pre-releases, so this is the normal case there.
68
+ if _is_prerelease(latest) and not _is_prerelease(current):
69
+ return
53
70
  _notified = True
54
71
  print(file=sys.stderr)
55
72
  print(
56
73
  f" A new version of boxd is available (v{latest}, you have v{current}). Update with:",
57
74
  file=sys.stderr,
58
75
  )
59
- print(f" {UPGRADE_CMD}", file=sys.stderr)
76
+ cmd = PRERELEASE_UPGRADE_CMD if _is_prerelease(latest) else UPGRADE_CMD
77
+ print(f" {cmd}", file=sys.stderr)
78
+
79
+
80
+ def _stderr_is_tty() -> bool:
81
+ """Is stderr a terminal? Indirected through a function so tests can say.
82
+
83
+ (A capture fixture replaces ``sys.stderr`` wholesale, so patching the
84
+ stream's ``isatty`` is not something a test can rely on.)
85
+ """
86
+ try:
87
+ return bool(sys.stderr.isatty())
88
+ except Exception: # closed or exotic stream: not a terminal
89
+ return False
90
+
91
+
92
+ def _is_prerelease(v: str) -> bool:
93
+ """Does this version carry a pre-release suffix ("0.2.6.dev356")?"""
94
+ m = _SEMVER_RE.match(v.strip())
95
+ return bool(m and m.group(4))
60
96
 
61
97
 
62
98
  _SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(.*)$")
@@ -68,6 +68,11 @@ class MachineAccess(BaseModel):
68
68
  """The domain the machine is addressed under."""
69
69
  url: str
70
70
  """`https://<name>.<domain>` — built for you, not sent by the server."""
71
+ desktop_url: str = ""
72
+ """The machine's live Desktop view in the console —
73
+ `https://app.<zone>/desktop/<name>`. Built for you, like `url`. Empty when
74
+ the client has no endpoint to derive the console origin from. Opening it
75
+ needs a signed-in browser; the machine is woken if it is asleep."""
71
76
  custom_domains: list[CustomDomain]
72
77
  """Per-VM custom domains bound to this machine (BOX-30), e.g.
73
78
  `app.example.com`. Distinct from `domain` above, which is the org's
@@ -259,7 +259,8 @@ class Machines(Namespace):
259
259
  def get(self, machine_id: str) -> Machine:
260
260
  """Fetch one machine by id or name."""
261
261
  return m.machine(
262
- self._transport.call("GetVm", api_pb2.GetVmRequest(vm_id=machine_id))
262
+ self._transport.call("GetVm", api_pb2.GetVmRequest(vm_id=machine_id)),
263
+ self._transport.console_origin,
263
264
  )
264
265
 
265
266
  def list(self, *, org: str | None = None, all_contexts: bool = False) -> list[Machine]:
@@ -271,7 +272,8 @@ class Machines(Namespace):
271
272
  resp = self._transport.call(
272
273
  "ListVms", req.list_vms(org=org, all_contexts=all_contexts)
273
274
  )
274
- return [m.machine(v) for v in resp.vms]
275
+ console_origin = self._transport.console_origin
276
+ return [m.machine(v, console_origin) for v in resp.vms]
275
277
 
276
278
  def delete(self, machine_id: str) -> None:
277
279
  """Destroy a machine. Terminal."""
@@ -828,7 +830,8 @@ class AsyncMachines(Namespace):
828
830
  async def get(self, machine_id: str) -> Machine:
829
831
  """Fetch one machine by id or name."""
830
832
  return m.machine(
831
- await self._transport.call("GetVm", api_pb2.GetVmRequest(vm_id=machine_id))
833
+ await self._transport.call("GetVm", api_pb2.GetVmRequest(vm_id=machine_id)),
834
+ self._transport.console_origin,
832
835
  )
833
836
 
834
837
  async def list(
@@ -838,7 +841,8 @@ class AsyncMachines(Namespace):
838
841
  resp = await self._transport.call(
839
842
  "ListVms", req.list_vms(org=org, all_contexts=all_contexts)
840
843
  )
841
- return [m.machine(v) for v in resp.vms]
844
+ console_origin = self._transport.console_origin
845
+ return [m.machine(v, console_origin) for v in resp.vms]
842
846
 
843
847
  async def delete(self, machine_id: str) -> None:
844
848
  """Destroy a machine. Terminal."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boxd
3
- Version: 0.2.6.dev355
3
+ Version: 0.2.6.dev398
4
4
  Summary: Python SDK for the boxd cloud VM platform
5
5
  Author: Azin
6
6
  License-Expression: MIT
@@ -518,3 +518,15 @@ def test_backup_dedup_pointer_and_checksum_are_absent_on_a_dedup_row():
518
518
  assert backup.status == "dedup"
519
519
  assert backup.checksum is None
520
520
  assert backup.points_to_backup_id == "bkp-1"
521
+
522
+
523
+ def test_desktop_url_is_the_console_origin_plus_the_name():
524
+ """Built client-side like `url`, and empty rather than half-built when the
525
+ client has no endpoint to derive a console origin from."""
526
+ from boxd import _mappers as m
527
+
528
+ vm = a_vm(name="box")
529
+ assert m.machine(vm, "https://app.boxd.sh").access.desktop_url == (
530
+ "https://app.boxd.sh/desktop/box"
531
+ )
532
+ assert m.machine(vm).access.desktop_url == ""
@@ -767,6 +767,7 @@ def test_machines_list_returns_a_plain_list():
767
767
  machines = Machines(transport).list()
768
768
  assert isinstance(machines, list)
769
769
  assert [x.name for x in machines] == ["alpha", "beta"]
770
+ assert machines[0].access.desktop_url == "https://app.boxd.sh/desktop/alpha"
770
771
 
771
772
 
772
773
  def test_machines_list_is_empty_when_the_account_has_none():
@@ -13,6 +13,16 @@ from boxd._version_check import (
13
13
  request_metadata,
14
14
  )
15
15
 
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def _stderr_is_a_terminal(monkeypatch):
19
+ """The nag is for a person at a terminal, and pytest is not one.
20
+
21
+ Every test here assumes a TTY unless it says otherwise; the one that
22
+ checks the non-terminal case overrides this.
23
+ """
24
+ monkeypatch.setattr(_version_check, "_stderr_is_tty", lambda: True)
25
+
16
26
  # Picked to be unambiguously newer/older than any real shipped SDK so tests
17
27
  # don't silently flip meaning when the package version bumps.
18
28
  NEWER = "99.0.0"
@@ -119,3 +129,40 @@ def test_header_lookup_is_case_insensitive(stub_current, capsys):
119
129
  def test_header_value_is_stripped(stub_current, capsys):
120
130
  note_metadata([(RESP_HEADER, f" {NEWER} ")])
121
131
  assert NEWER in capsys.readouterr().err
132
+
133
+
134
+ def test_silent_when_stderr_is_not_a_terminal(monkeypatch, capsys):
135
+ """CI, or an automation job whose stderr IS its log file: noise on every run."""
136
+ monkeypatch.setattr(_version_check, "_notified", False)
137
+ monkeypatch.setattr(_version_check, "_stderr_is_tty", lambda: False)
138
+ _version_check.note_metadata([(_version_check.RESP_HEADER, "99.0.0")])
139
+ assert capsys.readouterr().err == ""
140
+
141
+
142
+ def test_silent_when_latest_is_a_prerelease_and_we_are_on_a_release(monkeypatch, capsys):
143
+ """A pre-release is opt-in (`pip install --pre`), and `--upgrade` alone
144
+ cannot install it — so the nag would repeat forever."""
145
+ monkeypatch.setattr(_version_check, "_notified", False)
146
+ monkeypatch.setattr(_version_check, "_current_version", lambda: "0.2.4")
147
+ _version_check.note_metadata([(_version_check.RESP_HEADER, "0.2.6.dev356")])
148
+ assert capsys.readouterr().err == ""
149
+
150
+
151
+ def test_prerelease_user_is_told_the_prerelease_command(monkeypatch, capsys):
152
+ monkeypatch.setattr(_version_check, "_notified", False)
153
+ monkeypatch.setattr(_version_check, "_current_version", lambda: "0.2.6.dev1")
154
+ _version_check.note_metadata([(_version_check.RESP_HEADER, "0.2.6.dev356")])
155
+ assert "--pre" in capsys.readouterr().err
156
+
157
+
158
+ def test_is_prerelease():
159
+ assert _version_check._is_prerelease("0.2.6.dev356")
160
+ assert _version_check._is_prerelease("0.2.6-dev.356")
161
+ assert not _version_check._is_prerelease("0.2.6")
162
+
163
+
164
+ def test_silent_when_the_opt_out_env_var_is_set(monkeypatch, capsys):
165
+ monkeypatch.setattr(_version_check, "_notified", False)
166
+ monkeypatch.setenv("BOXD_NO_UPDATE_NOTICE", "1")
167
+ _version_check.note_metadata([(_version_check.RESP_HEADER, "99.0.0")])
168
+ assert capsys.readouterr().err == ""
File without changes
File without changes
File without changes