boxd 0.1.1.dev5__tar.gz → 0.1.1.dev7__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.
- {boxd-0.1.1.dev5/src/boxd.egg-info → boxd-0.1.1.dev7}/PKG-INFO +2 -2
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/pyproject.toml +8 -2
- boxd-0.1.1.dev7/src/boxd/_version_check.py +169 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/client.py +4 -3
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7/src/boxd.egg-info}/PKG-INFO +2 -2
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd.egg-info/SOURCES.txt +3 -1
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd.egg-info/requires.txt +1 -1
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/tests/test_e2e.py +10 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/tests/test_v2.py +4 -2
- boxd-0.1.1.dev7/tests/test_version_check.py +178 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/LICENSE +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/README.md +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/setup.cfg +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/__init__.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/_generated/__init__.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/_generated/api_pb2.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/_generated/api_pb2_grpc.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/_sync.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/_utils.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/aio.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/auth.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/box.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/boxes.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/disks.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/domains.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/errors.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/exec.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/networks.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/templates.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/tokens.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd/types.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd.egg-info/dependency_links.txt +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/src/boxd.egg-info/top_level.txt +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/tests/test_auth.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/tests/test_boxes.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/tests/test_e2e_v2.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/tests/test_exec.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/tests/test_files.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/tests/test_lifecycle.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/tests/test_proxies.py +0 -0
- {boxd-0.1.1.dev5 → boxd-0.1.1.dev7}/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.
|
|
3
|
+
Version: 0.1.1.dev7
|
|
4
4
|
Summary: Python SDK for the boxd cloud VM platform
|
|
5
5
|
Author: Azin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: grpcio>=1.60
|
|
24
24
|
Requires-Dist: protobuf>=4.25
|
|
25
|
-
Requires-Dist: httpx
|
|
25
|
+
Requires-Dist: httpx<1,>=0.27
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: grpcio-tools>=1.60; extra == "dev"
|
|
28
28
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "boxd"
|
|
3
|
-
version = "0.1.1.
|
|
3
|
+
version = "0.1.1.dev7"
|
|
4
4
|
description = "Python SDK for the boxd cloud VM platform"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -22,7 +22,13 @@ classifiers = [
|
|
|
22
22
|
dependencies = [
|
|
23
23
|
"grpcio>=1.60",
|
|
24
24
|
"protobuf>=4.25",
|
|
25
|
-
|
|
25
|
+
# Cap below 1.0: httpx 1.0 (currently in pre-release as 1.0.devN)
|
|
26
|
+
# removed `httpx.AsyncClient`, which auth.py uses for the API-key →
|
|
27
|
+
# JWT exchange. Without this cap, anyone running `pip install --pre
|
|
28
|
+
# boxd` (necessary for staging dev builds) gets httpx 1.0.devN and
|
|
29
|
+
# the SDK crashes on the first call. Re-evaluate when httpx 1.0 ships
|
|
30
|
+
# stable and the AsyncClient migration path is documented.
|
|
31
|
+
"httpx>=0.27,<1",
|
|
26
32
|
]
|
|
27
33
|
|
|
28
34
|
[project.urls]
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
_SUFFIX_SEPARATORS_RE = re.compile(r"[.-]")
|
|
104
|
+
_SUFFIX_LETTER_DIGIT_RE = re.compile(r"(?<=\D)(?=\d)|(?<=\d)(?=\D)")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _compare_semver(a: str, b: str) -> int:
|
|
108
|
+
"""Return >0 if a > b, <0 if a < b, 0 if equal/unparseable.
|
|
109
|
+
|
|
110
|
+
Handles pre-release suffixes ("0.1.1-dev.4", "0.1.1.dev4") by
|
|
111
|
+
component-wise compare, numeric where both components are all-digits.
|
|
112
|
+
A release (no suffix) ranks above any pre-release of the same
|
|
113
|
+
numeric prefix. Unparseable input returns 0 — never nag on garbage.
|
|
114
|
+
"""
|
|
115
|
+
pa = _SEMVER_RE.match(a.strip())
|
|
116
|
+
pb = _SEMVER_RE.match(b.strip())
|
|
117
|
+
if not pa or not pb:
|
|
118
|
+
return 0
|
|
119
|
+
for i in range(1, 4):
|
|
120
|
+
ai, bi = int(pa.group(i)), int(pb.group(i))
|
|
121
|
+
if ai != bi:
|
|
122
|
+
return ai - bi
|
|
123
|
+
sa, sb = pa.group(4) or "", pb.group(4) or ""
|
|
124
|
+
if sa == "" and sb != "":
|
|
125
|
+
return 1
|
|
126
|
+
if sa != "" and sb == "":
|
|
127
|
+
return -1
|
|
128
|
+
return _compare_suffix(sa, sb)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _compare_suffix(a: str, b: str) -> int:
|
|
132
|
+
if a == b:
|
|
133
|
+
return 0
|
|
134
|
+
parts_a = _parse_suffix(a)
|
|
135
|
+
parts_b = _parse_suffix(b)
|
|
136
|
+
for i in range(max(len(parts_a), len(parts_b))):
|
|
137
|
+
pa = parts_a[i] if i < len(parts_a) else None
|
|
138
|
+
pb = parts_b[i] if i < len(parts_b) else None
|
|
139
|
+
if pa is None:
|
|
140
|
+
return -1 # shorter prerelease ranks lower
|
|
141
|
+
if pb is None:
|
|
142
|
+
return 1
|
|
143
|
+
na = int(pa) if pa.isdigit() else None
|
|
144
|
+
nb = int(pb) if pb.isdigit() else None
|
|
145
|
+
if na is not None and nb is not None:
|
|
146
|
+
if na != nb:
|
|
147
|
+
return na - nb
|
|
148
|
+
elif na is not None:
|
|
149
|
+
return -1 # numeric < alphanumeric (SemVer §11.4.3)
|
|
150
|
+
elif nb is not None:
|
|
151
|
+
return 1
|
|
152
|
+
elif pa != pb:
|
|
153
|
+
return -1 if pa < pb else 1
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _parse_suffix(s: str) -> list[str]:
|
|
158
|
+
"""Decompose "-dev.6" / ".dev6" into ["dev", "6"]."""
|
|
159
|
+
if not s:
|
|
160
|
+
return []
|
|
161
|
+
stripped = s.lstrip("-.")
|
|
162
|
+
if not stripped:
|
|
163
|
+
return []
|
|
164
|
+
components: list[str] = []
|
|
165
|
+
for piece in _SUFFIX_SEPARATORS_RE.split(stripped):
|
|
166
|
+
if not piece:
|
|
167
|
+
continue
|
|
168
|
+
components.extend(p for p in _SUFFIX_LETTER_DIGIT_RE.split(piece) if p)
|
|
169
|
+
return components
|
|
@@ -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
|
-
|
|
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=
|
|
121
|
+
interceptors=interceptors,
|
|
121
122
|
)
|
|
122
123
|
else:
|
|
123
124
|
channel = grpc.aio.insecure_channel(
|
|
124
125
|
endpoint.host,
|
|
125
|
-
interceptors=
|
|
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.
|
|
3
|
+
Version: 0.1.1.dev7
|
|
4
4
|
Summary: Python SDK for the boxd cloud VM platform
|
|
5
5
|
Author: Azin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: grpcio>=1.60
|
|
24
24
|
Requires-Dist: protobuf>=4.25
|
|
25
|
-
Requires-Dist: httpx
|
|
25
|
+
Requires-Dist: httpx<1,>=0.27
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: grpcio-tools>=1.60; extra == "dev"
|
|
28
28
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -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=
|
|
58
|
-
exchange_url=
|
|
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
|
|
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
|