boxd 0.1.1.dev5__tar.gz → 0.1.1.dev6__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.1.1.dev5/src/boxd.egg-info → boxd-0.1.1.dev6}/PKG-INFO +1 -1
  2. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/pyproject.toml +1 -1
  3. boxd-0.1.1.dev6/src/boxd/_version_check.py +130 -0
  4. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/client.py +4 -3
  5. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6/src/boxd.egg-info}/PKG-INFO +1 -1
  6. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd.egg-info/SOURCES.txt +3 -1
  7. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/tests/test_e2e.py +10 -0
  8. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/tests/test_v2.py +4 -2
  9. boxd-0.1.1.dev6/tests/test_version_check.py +178 -0
  10. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/LICENSE +0 -0
  11. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/README.md +0 -0
  12. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/setup.cfg +0 -0
  13. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/__init__.py +0 -0
  14. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/_generated/__init__.py +0 -0
  15. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/_generated/api_pb2.py +0 -0
  16. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/_generated/api_pb2_grpc.py +0 -0
  17. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/_sync.py +0 -0
  18. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/_utils.py +0 -0
  19. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/aio.py +0 -0
  20. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/auth.py +0 -0
  21. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/box.py +0 -0
  22. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/boxes.py +0 -0
  23. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/disks.py +0 -0
  24. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/domains.py +0 -0
  25. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/errors.py +0 -0
  26. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/exec.py +0 -0
  27. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/networks.py +0 -0
  28. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/templates.py +0 -0
  29. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/tokens.py +0 -0
  30. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd/types.py +0 -0
  31. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd.egg-info/dependency_links.txt +0 -0
  32. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd.egg-info/requires.txt +0 -0
  33. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/src/boxd.egg-info/top_level.txt +0 -0
  34. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/tests/test_auth.py +0 -0
  35. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/tests/test_boxes.py +0 -0
  36. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/tests/test_e2e_v2.py +0 -0
  37. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/tests/test_exec.py +0 -0
  38. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/tests/test_files.py +0 -0
  39. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/tests/test_lifecycle.py +0 -0
  40. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/tests/test_proxies.py +0 -0
  41. {boxd-0.1.1.dev5 → boxd-0.1.1.dev6}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boxd
3
- Version: 0.1.1.dev5
3
+ Version: 0.1.1.dev6
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.1.1.dev5"
3
+ version = "0.1.1.dev6"
4
4
  description = "Python SDK for the boxd cloud VM platform"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,130 @@
1
+ """SDK update notice — piggybacks on every gRPC call's metadata.
2
+
3
+ The proxy attaches `x-boxd-py-sdk-latest` to response initial metadata when
4
+ a newer SDK version is available. We send `x-boxd-py-sdk-version` outbound
5
+ so the server can log who's calling. On the first response that carries a
6
+ strictly-newer latest version, we print a one-time stderr nag.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import sys
13
+
14
+ import grpc
15
+ import grpc.aio
16
+
17
+ REQ_HEADER = "x-boxd-py-sdk-version"
18
+ RESP_HEADER = "x-boxd-py-sdk-latest"
19
+ UPGRADE_CMD = "pip install --upgrade boxd"
20
+
21
+ _notified = False
22
+
23
+
24
+ def _current_version() -> str:
25
+ # Late import so this module is importable from inside the package
26
+ # without circular-import drama (boxd/__init__.py imports a lot).
27
+ from . import __version__
28
+
29
+ return __version__
30
+
31
+
32
+ class VersionCheckInterceptor(
33
+ grpc.aio.UnaryUnaryClientInterceptor,
34
+ grpc.aio.UnaryStreamClientInterceptor,
35
+ grpc.aio.StreamUnaryClientInterceptor,
36
+ grpc.aio.StreamStreamClientInterceptor,
37
+ ):
38
+ """Adds outbound SDK-version header, watches inbound for an upgrade hint."""
39
+
40
+ def _add_version(self, client_call_details):
41
+ metadata = list(client_call_details.metadata or [])
42
+ metadata.append((REQ_HEADER, _current_version()))
43
+ return client_call_details._replace(metadata=metadata)
44
+
45
+ async def _maybe_notify_from_call(self, call):
46
+ global _notified
47
+ if _notified:
48
+ return
49
+ try:
50
+ md = await call.initial_metadata()
51
+ except Exception:
52
+ return
53
+ if md is None:
54
+ return
55
+ latest = None
56
+ for key, value in md:
57
+ if key.lower() == RESP_HEADER:
58
+ latest = value.strip() if isinstance(value, str) else None
59
+ break
60
+ if not latest:
61
+ return
62
+ current = _current_version()
63
+ if _compare_semver(latest, current) <= 0:
64
+ return
65
+ _notified = True
66
+ print(file=sys.stderr)
67
+ print(
68
+ f" A new version of boxd is available (v{latest}, you have v{current}). Update with:",
69
+ file=sys.stderr,
70
+ )
71
+ print(f" {UPGRADE_CMD}", file=sys.stderr)
72
+
73
+ async def intercept_unary_unary(self, continuation, client_call_details, request):
74
+ new_details = self._add_version(client_call_details)
75
+ call = await continuation(new_details, request)
76
+ await self._maybe_notify_from_call(call)
77
+ return call
78
+
79
+ async def intercept_unary_stream(self, continuation, client_call_details, request):
80
+ new_details = self._add_version(client_call_details)
81
+ call = await continuation(new_details, request)
82
+ await self._maybe_notify_from_call(call)
83
+ return call
84
+
85
+ async def intercept_stream_unary(
86
+ self, continuation, client_call_details, request_iterator
87
+ ):
88
+ new_details = self._add_version(client_call_details)
89
+ call = await continuation(new_details, request_iterator)
90
+ await self._maybe_notify_from_call(call)
91
+ return call
92
+
93
+ async def intercept_stream_stream(
94
+ self, continuation, client_call_details, request_iterator
95
+ ):
96
+ new_details = self._add_version(client_call_details)
97
+ call = await continuation(new_details, request_iterator)
98
+ await self._maybe_notify_from_call(call)
99
+ return call
100
+
101
+
102
+ _SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(.*)$")
103
+
104
+
105
+ def _compare_semver(a: str, b: str) -> int:
106
+ """Return >0 if a > b, <0 if a < b, 0 if equal/unparseable.
107
+
108
+ Handles pre-release suffixes ("0.1.1-dev.4", "0.1.1.dev4") by
109
+ treating numeric-prefix ties as: release > pre-release, otherwise
110
+ lexicographic suffix order. Unparseable input returns 0 — never nag
111
+ on garbage.
112
+ """
113
+ pa = _SEMVER_RE.match(a.strip())
114
+ pb = _SEMVER_RE.match(b.strip())
115
+ if not pa or not pb:
116
+ return 0
117
+ for i in range(1, 4):
118
+ ai, bi = int(pa.group(i)), int(pb.group(i))
119
+ if ai != bi:
120
+ return ai - bi
121
+ sa, sb = pa.group(4) or "", pb.group(4) or ""
122
+ if sa == "" and sb != "":
123
+ return 1
124
+ if sa != "" and sb == "":
125
+ return -1
126
+ if sa < sb:
127
+ return -1
128
+ if sa > sb:
129
+ return 1
130
+ return 0
@@ -9,6 +9,7 @@ import grpc.aio
9
9
 
10
10
  from ._generated import api_pb2, api_pb2_grpc
11
11
  from ._utils import resolve_endpoint
12
+ from ._version_check import VersionCheckInterceptor
12
13
  from .auth import TokenAuth
13
14
  from .boxes import BoxService
14
15
  from .disks import DiskService
@@ -109,7 +110,7 @@ class Compute:
109
110
  if self._stub is not None:
110
111
  return self._stub
111
112
 
112
- interceptor = self._auth.interceptor()
113
+ interceptors = [self._auth.interceptor(), VersionCheckInterceptor()]
113
114
  endpoint = resolve_endpoint(self._api_url)
114
115
 
115
116
  if endpoint.use_tls:
@@ -117,12 +118,12 @@ class Compute:
117
118
  channel = grpc.aio.secure_channel(
118
119
  endpoint.host,
119
120
  creds,
120
- interceptors=[interceptor],
121
+ interceptors=interceptors,
121
122
  )
122
123
  else:
123
124
  channel = grpc.aio.insecure_channel(
124
125
  endpoint.host,
125
- interceptors=[interceptor],
126
+ interceptors=interceptors,
126
127
  )
127
128
 
128
129
  self._channel = channel
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boxd
3
- Version: 0.1.1.dev5
3
+ Version: 0.1.1.dev6
4
4
  Summary: Python SDK for the boxd cloud VM platform
5
5
  Author: Azin
6
6
  License-Expression: MIT
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  src/boxd/__init__.py
5
5
  src/boxd/_sync.py
6
6
  src/boxd/_utils.py
7
+ src/boxd/_version_check.py
7
8
  src/boxd/aio.py
8
9
  src/boxd/auth.py
9
10
  src/boxd/box.py
@@ -34,4 +35,5 @@ tests/test_files.py
34
35
  tests/test_lifecycle.py
35
36
  tests/test_proxies.py
36
37
  tests/test_utils.py
37
- tests/test_v2.py
38
+ tests/test_v2.py
39
+ tests/test_version_check.py
@@ -206,6 +206,16 @@ def test_suspend_resume(compute):
206
206
  assert result.suspend_us > 0
207
207
  assert box.status == "suspended"
208
208
 
209
+ # SuspendVm currently returns before the raft commit landing the new
210
+ # status is applied (~250ms gap on staging). ResumeVm's admission
211
+ # check then sees status=running and rejects. Poll the server view
212
+ # until it agrees the VM is suspended before attempting resume.
213
+ # Track: server-side fix to make SuspendVm block until commit-applied.
214
+ for _ in range(20):
215
+ if compute.box.get(box.id).status == "suspended":
216
+ break
217
+ time.sleep(0.25)
218
+
209
219
  result = box.resume()
210
220
  assert result.resume_us > 0
211
221
  assert box.status == "running"
@@ -52,9 +52,11 @@ def test_token_roundtrip(compute):
52
52
  original_user = compute.whoami().user_id
53
53
 
54
54
  token = compute.token.create(expires_in=3600)
55
+ # Reuse the parent compute's endpoints so the test works against any
56
+ # cluster (localhost dev, staging, production), not just localhost.
55
57
  with Compute(
56
58
  token=token.token,
57
- api_url="localhost:9443",
58
- exchange_url="http://localhost:8080/api/v1/auth/token",
59
+ api_url=compute._api_url,
60
+ exchange_url=compute._exchange_url,
59
61
  ) as c2:
60
62
  assert c2.whoami().user_id == original_user
@@ -0,0 +1,178 @@
1
+ """Unit tests for boxd._version_check — semver compare + interceptor metadata behavior."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from grpc.aio import ClientCallDetails
7
+
8
+ from boxd import _version_check
9
+ from boxd._version_check import (
10
+ REQ_HEADER,
11
+ RESP_HEADER,
12
+ VersionCheckInterceptor,
13
+ _compare_semver,
14
+ )
15
+
16
+ # Picked to be unambiguously newer/older than any real shipped SDK so tests
17
+ # don't silently flip meaning when the package version bumps.
18
+ NEWER = "99.0.0"
19
+ OLDER = "0.0.1"
20
+ CURRENT = "0.1.1"
21
+
22
+
23
+ @pytest.fixture(autouse=True)
24
+ def reset_notified():
25
+ _version_check._notified = False
26
+ yield
27
+ _version_check._notified = False
28
+
29
+
30
+ @pytest.fixture
31
+ def stub_current(monkeypatch):
32
+ monkeypatch.setattr(_version_check, "_current_version", lambda: CURRENT)
33
+
34
+
35
+ @pytest.fixture
36
+ def interceptor():
37
+ return VersionCheckInterceptor()
38
+
39
+
40
+ def _details(metadata):
41
+ return ClientCallDetails(
42
+ method="/test/Method",
43
+ timeout=None,
44
+ metadata=metadata,
45
+ credentials=None,
46
+ wait_for_ready=None,
47
+ )
48
+
49
+
50
+ def _make_call(metadata):
51
+ class FakeCall:
52
+ async def initial_metadata(self):
53
+ return metadata
54
+
55
+ return FakeCall()
56
+
57
+
58
+ # --- _compare_semver ---
59
+
60
+
61
+ def test_compare_semver_patch_greater():
62
+ assert _compare_semver("0.1.1", "0.1.0") > 0
63
+
64
+
65
+ def test_compare_semver_patch_lesser():
66
+ assert _compare_semver("0.1.0", "0.1.1") < 0
67
+
68
+
69
+ def test_compare_semver_equal():
70
+ assert _compare_semver("0.1.0", "0.1.0") == 0
71
+
72
+
73
+ def test_compare_semver_major_dominates_minor():
74
+ assert _compare_semver("1.0.0", "0.99.99") > 0
75
+
76
+
77
+ def test_compare_semver_minor_dominates_patch():
78
+ assert _compare_semver("0.2.0", "0.1.99") > 0
79
+
80
+
81
+ def test_compare_semver_release_greater_than_prerelease():
82
+ assert _compare_semver("0.1.1", "0.1.1-dev.4") > 0
83
+ assert _compare_semver("0.1.1-dev.4", "0.1.1") < 0
84
+
85
+
86
+ def test_compare_semver_prerelease_lex_order():
87
+ assert _compare_semver("0.1.1-dev.4", "0.1.1-dev.5") < 0
88
+ assert _compare_semver("0.1.1-dev.5", "0.1.1-dev.4") > 0
89
+
90
+
91
+ def test_compare_semver_unparseable_returns_zero():
92
+ assert _compare_semver("garbage", "0.1.0") == 0
93
+ assert _compare_semver("0.1.0", "garbage") == 0
94
+ assert _compare_semver("v1.0.0", "0.1.0") == 0
95
+ assert _compare_semver("", "0.1.0") == 0
96
+
97
+
98
+ def test_compare_semver_whitespace_tolerated():
99
+ assert _compare_semver(" 0.1.1 ", "0.1.0") > 0
100
+
101
+
102
+ # --- VersionCheckInterceptor._add_version ---
103
+
104
+
105
+ def test_add_version_appends_header(stub_current, interceptor):
106
+ new = interceptor._add_version(_details([]))
107
+ assert (REQ_HEADER, CURRENT) in new.metadata
108
+
109
+
110
+ def test_add_version_preserves_existing_metadata(stub_current, interceptor):
111
+ new = interceptor._add_version(_details([("authorization", "Bearer abc")]))
112
+ assert ("authorization", "Bearer abc") in new.metadata
113
+ assert (REQ_HEADER, CURRENT) in new.metadata
114
+
115
+
116
+ def test_add_version_handles_none_metadata(stub_current, interceptor):
117
+ new = interceptor._add_version(_details(None))
118
+ assert new.metadata == [(REQ_HEADER, CURRENT)]
119
+
120
+
121
+ # --- VersionCheckInterceptor._maybe_notify_from_call ---
122
+
123
+
124
+ async def test_maybe_notify_warns_when_latest_greater(stub_current, interceptor, capsys):
125
+ await interceptor._maybe_notify_from_call(_make_call([(RESP_HEADER, NEWER)]))
126
+ err = capsys.readouterr().err
127
+ assert NEWER in err
128
+ assert CURRENT in err
129
+ assert "pip install --upgrade boxd" in err
130
+ assert _version_check._notified is True
131
+
132
+
133
+ @pytest.mark.parametrize(
134
+ "metadata",
135
+ [
136
+ pytest.param([(RESP_HEADER, CURRENT)], id="latest-equal"),
137
+ pytest.param([(RESP_HEADER, OLDER)], id="latest-older"),
138
+ pytest.param([("other-header", "value")], id="header-missing"),
139
+ pytest.param([], id="metadata-empty"),
140
+ pytest.param(None, id="metadata-none"),
141
+ pytest.param([(RESP_HEADER, " ")], id="header-whitespace-only"),
142
+ ],
143
+ )
144
+ async def test_maybe_notify_silent(stub_current, interceptor, capsys, metadata):
145
+ await interceptor._maybe_notify_from_call(_make_call(metadata))
146
+ assert capsys.readouterr().err == ""
147
+ assert _version_check._notified is False
148
+
149
+
150
+ async def test_maybe_notify_silent_when_initial_metadata_raises(
151
+ stub_current, interceptor, capsys
152
+ ):
153
+ class FailingCall:
154
+ async def initial_metadata(self):
155
+ raise RuntimeError("connection lost")
156
+
157
+ await interceptor._maybe_notify_from_call(FailingCall())
158
+ assert capsys.readouterr().err == ""
159
+
160
+
161
+ async def test_maybe_notify_warns_only_once(stub_current, interceptor, capsys):
162
+ call = _make_call([(RESP_HEADER, NEWER)])
163
+ await interceptor._maybe_notify_from_call(call)
164
+ assert NEWER in capsys.readouterr().err
165
+ await interceptor._maybe_notify_from_call(call)
166
+ assert capsys.readouterr().err == ""
167
+
168
+
169
+ async def test_maybe_notify_case_insensitive_header(stub_current, interceptor, capsys):
170
+ await interceptor._maybe_notify_from_call(_make_call([("X-Boxd-Py-Sdk-Latest", NEWER)]))
171
+ assert NEWER in capsys.readouterr().err
172
+
173
+
174
+ async def test_maybe_notify_strips_whitespace_in_header_value(
175
+ stub_current, interceptor, capsys
176
+ ):
177
+ await interceptor._maybe_notify_from_call(_make_call([(RESP_HEADER, f" {NEWER} ")]))
178
+ assert NEWER in capsys.readouterr().err
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
File without changes
File without changes
File without changes
File without changes
File without changes