spacefs-s3sdk 0.1.0__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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ __pycache__/
5
+ *.egg-info/
6
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Space Computer Inc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.5
2
+ Name: spacefs-s3sdk
3
+ Version: 0.1.0
4
+ Summary: s3sdk SDK: S3-compatible access to Space drives plus partial writes, copy-on-write forks, and version history.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: boto3>=1.36
9
+ Provides-Extra: test
10
+ Requires-Dist: pytest; extra == 'test'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # s3sdk — Python SDK
14
+
15
+ s3sdk is the S3 API over Space drives, plus extensions S3 can't express:
16
+ writes at an offset, batched patches, copy-on-write drive forks, and per-object version
17
+ history with restore. Wire protocol: [`../spec/PROTOCOL.md`](../spec/PROTOCOL.md).
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ pip install spacefs-s3sdk # Python >= 3.10; pulls in boto3; import s3sdk
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ from s3sdk import S3SDKClient, S3SDKError, Edit
29
+
30
+ c = S3SDKClient("S3SDK...", "secret...") # endpoint defaults to https://s3sdk.spacefs.com
31
+
32
+ c.create_drive("footage", display_name="Footage")
33
+ v1 = c.put_object("footage", "cuts/a.txt", "hello world")
34
+ c.write_at("footage", "cuts/a.txt", 6, b"WORLD") # pwrite, one new version
35
+ c.patch("footage", "cuts/a.txt", [Edit(0, b"H"), Edit(11, b"!")])
36
+ c.truncate("footage", "cuts/a.txt", 5)
37
+ print(c.read_range("footage", "cuts/a.txt", 1, 3)) # b"ell"
38
+
39
+ # Optimistic concurrency
40
+ try:
41
+ c.write_at("footage", "cuts/a.txt", 0, b"x", if_version=v1.version_id)
42
+ except S3SDKError as e:
43
+ if e.status == 412:
44
+ print("head is now", e.current_version_id)
45
+
46
+ # History
47
+ versions = c.list_versions("footage", "cuts/a.txt") # oldest first
48
+ old = c.get_object("footage", "cuts/a.txt", version_id=versions[0].version_id)
49
+ then = c.get_object("footage", "cuts/a.txt", as_of=versions[0].last_modified_raw)
50
+ c.restore_version("footage", "cuts/a.txt", versions[0].version_id)
51
+
52
+ # Copy-on-write forks
53
+ fork = c.fork_drive("footage", "footage-experiment")
54
+ print(c.describe_drive("footage-experiment").fork_of)
55
+ c.delete_drive("footage-experiment", hard=True)
56
+
57
+ # Any standard S3 call: c.s3 is a boto3 client (path-style)
58
+ c.s3.head_object(Bucket="footage", Key="cuts/a.txt")
59
+ ```
60
+
61
+ Every error — gateway extensions and boto3 calls alike — is raised as
62
+ `S3SDKError(status, code, message, current_version_id, request_id)`.
63
+
64
+ ## Development
65
+
66
+ ```sh
67
+ python3 -m venv .venv && .venv/bin/pip install -e '.[test]'
68
+ .venv/bin/python -m pytest tests
69
+
70
+ # Conformance suite against a gateway (see ../spec/conformance/README.md)
71
+ S3SDK_ENDPOINT=http://127.0.0.1:19000 \
72
+ S3SDK_ACCESS_KEY_ID=S3SDKCONFORMANCE0001 \
73
+ S3SDK_SECRET_ACCESS_KEY=conformance-secret-0000000000000000000000 \
74
+ .venv/bin/python -m s3sdk.conformance ../spec/conformance/cases.json
75
+ ```
@@ -0,0 +1,63 @@
1
+ # s3sdk — Python SDK
2
+
3
+ s3sdk is the S3 API over Space drives, plus extensions S3 can't express:
4
+ writes at an offset, batched patches, copy-on-write drive forks, and per-object version
5
+ history with restore. Wire protocol: [`../spec/PROTOCOL.md`](../spec/PROTOCOL.md).
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pip install spacefs-s3sdk # Python >= 3.10; pulls in boto3; import s3sdk
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from s3sdk import S3SDKClient, S3SDKError, Edit
17
+
18
+ c = S3SDKClient("S3SDK...", "secret...") # endpoint defaults to https://s3sdk.spacefs.com
19
+
20
+ c.create_drive("footage", display_name="Footage")
21
+ v1 = c.put_object("footage", "cuts/a.txt", "hello world")
22
+ c.write_at("footage", "cuts/a.txt", 6, b"WORLD") # pwrite, one new version
23
+ c.patch("footage", "cuts/a.txt", [Edit(0, b"H"), Edit(11, b"!")])
24
+ c.truncate("footage", "cuts/a.txt", 5)
25
+ print(c.read_range("footage", "cuts/a.txt", 1, 3)) # b"ell"
26
+
27
+ # Optimistic concurrency
28
+ try:
29
+ c.write_at("footage", "cuts/a.txt", 0, b"x", if_version=v1.version_id)
30
+ except S3SDKError as e:
31
+ if e.status == 412:
32
+ print("head is now", e.current_version_id)
33
+
34
+ # History
35
+ versions = c.list_versions("footage", "cuts/a.txt") # oldest first
36
+ old = c.get_object("footage", "cuts/a.txt", version_id=versions[0].version_id)
37
+ then = c.get_object("footage", "cuts/a.txt", as_of=versions[0].last_modified_raw)
38
+ c.restore_version("footage", "cuts/a.txt", versions[0].version_id)
39
+
40
+ # Copy-on-write forks
41
+ fork = c.fork_drive("footage", "footage-experiment")
42
+ print(c.describe_drive("footage-experiment").fork_of)
43
+ c.delete_drive("footage-experiment", hard=True)
44
+
45
+ # Any standard S3 call: c.s3 is a boto3 client (path-style)
46
+ c.s3.head_object(Bucket="footage", Key="cuts/a.txt")
47
+ ```
48
+
49
+ Every error — gateway extensions and boto3 calls alike — is raised as
50
+ `S3SDKError(status, code, message, current_version_id, request_id)`.
51
+
52
+ ## Development
53
+
54
+ ```sh
55
+ python3 -m venv .venv && .venv/bin/pip install -e '.[test]'
56
+ .venv/bin/python -m pytest tests
57
+
58
+ # Conformance suite against a gateway (see ../spec/conformance/README.md)
59
+ S3SDK_ENDPOINT=http://127.0.0.1:19000 \
60
+ S3SDK_ACCESS_KEY_ID=S3SDKCONFORMANCE0001 \
61
+ S3SDK_SECRET_ACCESS_KEY=conformance-secret-0000000000000000000000 \
62
+ .venv/bin/python -m s3sdk.conformance ../spec/conformance/cases.json
63
+ ```
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "spacefs-s3sdk"
7
+ version = "0.1.0"
8
+ description = "s3sdk SDK: S3-compatible access to Space drives plus partial writes, copy-on-write forks, and version history."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["boto3>=1.36"]
13
+
14
+ [project.optional-dependencies]
15
+ test = ["pytest"]
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["src/s3sdk"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
@@ -0,0 +1,41 @@
1
+ """s3sdk SDK: S3-compatible access to Space drives plus partial writes,
2
+ copy-on-write forks, and per-object version history. See spec/PROTOCOL.md."""
3
+
4
+ from .client import (
5
+ DEFAULT_ENDPOINT,
6
+ CreateDriveResult,
7
+ DriveInfo,
8
+ DriveSummary,
9
+ ForkOf,
10
+ ForkResult,
11
+ ListResult,
12
+ ObjectResult,
13
+ RestoreResult,
14
+ S3SDKClient,
15
+ S3SDKError,
16
+ VersionEntry,
17
+ WriteResult,
18
+ )
19
+ from .patch import Edit, PatchError, decode_patch, encode_patch
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "DEFAULT_ENDPOINT",
25
+ "CreateDriveResult",
26
+ "DriveInfo",
27
+ "DriveSummary",
28
+ "Edit",
29
+ "ForkOf",
30
+ "ForkResult",
31
+ "ListResult",
32
+ "ObjectResult",
33
+ "PatchError",
34
+ "RestoreResult",
35
+ "S3SDKClient",
36
+ "S3SDKError",
37
+ "VersionEntry",
38
+ "WriteResult",
39
+ "decode_patch",
40
+ "encode_patch",
41
+ ]
@@ -0,0 +1,637 @@
1
+ """S3SDK client.
2
+
3
+ Standard S3 calls go through the bundled boto3 client (``client.s3``). The S3SDK extensions
4
+ (partial writes, forks, version history) are plain SigV4-signed requests; see spec/PROTOCOL.md.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import re
12
+ import xml.etree.ElementTree as ET
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timedelta, timezone
15
+ from email.utils import parsedate_to_datetime
16
+ from typing import Any, Literal, Mapping, Optional, Sequence, Union
17
+ from urllib.parse import quote, urlsplit
18
+
19
+ import boto3
20
+ import urllib3
21
+ from botocore.auth import S3SigV4Auth
22
+ from botocore.awsrequest import AWSRequest
23
+ from botocore.config import Config
24
+ from botocore.credentials import Credentials
25
+ from botocore.exceptions import ClientError
26
+
27
+ from .patch import CONTENT_TYPE as PATCH_CONTENT_TYPE
28
+ from .patch import Edit, encode_patch
29
+
30
+ __all__ = [
31
+ "DEFAULT_ENDPOINT",
32
+ "S3SDKClient",
33
+ "S3SDKError",
34
+ "WriteResult",
35
+ "RestoreResult",
36
+ "ObjectResult",
37
+ "VersionEntry",
38
+ "DriveInfo",
39
+ "ForkOf",
40
+ "DriveSummary",
41
+ "ForkResult",
42
+ "ListResult",
43
+ "CreateDriveResult",
44
+ ]
45
+
46
+ DEFAULT_ENDPOINT = "https://s3sdk.spacefs.com"
47
+
48
+ Body = Union[bytes, bytearray, memoryview, str]
49
+ Operation = Literal["put", "write", "copy", "restore", "rename", "other"]
50
+
51
+
52
+ class S3SDKError(Exception):
53
+ """An S3 or S3SDK error response."""
54
+
55
+ def __init__(
56
+ self,
57
+ status: int,
58
+ code: str,
59
+ message: Optional[str] = None,
60
+ current_version_id: Optional[str] = None,
61
+ request_id: Optional[str] = None,
62
+ ) -> None:
63
+ self.status = status
64
+ self.code = code
65
+ self.message = message or f"{code} (HTTP {status})"
66
+ #: On 412: the object's current head version, when it exists.
67
+ self.current_version_id = current_version_id
68
+ self.request_id = request_id
69
+ super().__init__(self.message)
70
+
71
+ def __repr__(self) -> str:
72
+ return (
73
+ f"S3SDKError(status={self.status!r}, code={self.code!r}, message={self.message!r}, "
74
+ f"current_version_id={self.current_version_id!r}, request_id={self.request_id!r})"
75
+ )
76
+
77
+
78
+ # ---- results ------------------------------------------------------------------------------------
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class WriteResult:
83
+ version_id: str
84
+ etag: str
85
+ #: Object size after the write.
86
+ size: int
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class RestoreResult(WriteResult):
91
+ restored_from: str = ""
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class ObjectResult:
96
+ body: bytes
97
+ version_id: str
98
+ etag: str
99
+ #: Full object size.
100
+ size: int
101
+ content_type: Optional[str] = None
102
+ last_modified: Optional[datetime] = None
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class VersionEntry:
107
+ version_id: str
108
+ is_latest: bool
109
+ size: int
110
+ etag: str
111
+ last_modified: datetime
112
+ #: The exact RFC 3339 timestamp from the server (sub-millisecond); use it for ``as_of`` reads.
113
+ last_modified_raw: str
114
+ operation: str
115
+ restored_from: Optional[str] = None
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class ForkOf:
120
+ drive_id: str
121
+ fork_point: datetime
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class DriveInfo:
126
+ drive_id: str
127
+ alias: str
128
+ display_name: str
129
+ created_at: datetime
130
+ fork_of: Optional[ForkOf]
131
+ forks: list[str]
132
+ usage_bytes: Optional[int]
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class DriveSummary:
137
+ name: str
138
+ created_at: Optional[datetime] = None
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class CreateDriveResult:
143
+ drive_id: str
144
+
145
+
146
+ @dataclass(frozen=True)
147
+ class ForkResult:
148
+ drive_id: str
149
+ source_drive_id: str
150
+ fork_point: datetime
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class ListResult:
155
+ keys: list[str]
156
+ prefixes: list[str]
157
+
158
+
159
+ # ---- helpers ------------------------------------------------------------------------------------
160
+
161
+ _RFC3339 = re.compile(
162
+ r"^(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|z|[+-]\d{2}:\d{2})$"
163
+ )
164
+
165
+
166
+ def parse_timestamp(value: str) -> datetime:
167
+ """Parses an RFC 3339 timestamp (any fractional precision; truncated to microseconds)."""
168
+ m = _RFC3339.match(value.strip())
169
+ if not m:
170
+ raise ValueError(f"not an RFC 3339 timestamp: {value!r}")
171
+ y, mo, d, h, mi, s, frac, tz = m.groups()
172
+ micro = int((frac or "0")[:6].ljust(6, "0"))
173
+ if tz in ("Z", "z"):
174
+ tzinfo = timezone.utc
175
+ else:
176
+ sign = 1 if tz[0] == "+" else -1
177
+ tzinfo = timezone(sign * timedelta(hours=int(tz[1:3]), minutes=int(tz[4:6])))
178
+ return datetime(int(y), int(mo), int(d), int(h), int(mi), int(s), micro, tzinfo=tzinfo)
179
+
180
+
181
+ def format_timestamp(value: datetime) -> str:
182
+ """Formats a datetime as RFC 3339 UTC. Naive datetimes are taken to be UTC."""
183
+ if value.tzinfo is None:
184
+ value = value.replace(tzinfo=timezone.utc)
185
+ return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
186
+
187
+
188
+ def _to_bytes(body: Body) -> bytes:
189
+ return body.encode("utf-8") if isinstance(body, str) else bytes(body)
190
+
191
+
192
+ def _encode_key(key: str) -> str:
193
+ """RFC 3986 encoding of each path segment (S3 canonical form); ``/`` separators are kept."""
194
+ return "/".join(quote(seg, safe="") for seg in key.split("/"))
195
+
196
+
197
+ def _unquote_etag(etag: str) -> str:
198
+ if etag.startswith("W/"):
199
+ etag = etag[2:]
200
+ if len(etag) >= 2 and etag.startswith('"') and etag.endswith('"'):
201
+ etag = etag[1:-1]
202
+ return etag
203
+
204
+
205
+ def _xml_fields(text: str) -> dict[str, str]:
206
+ if not text:
207
+ return {}
208
+ try:
209
+ root = ET.fromstring(text)
210
+ except ET.ParseError:
211
+ out = {}
212
+ for tag in ("Code", "Message", "RequestId"):
213
+ m = re.search(rf"<{tag}>([\s\S]*?)</{tag}>", text)
214
+ if m:
215
+ out[tag] = m.group(1)
216
+ return out
217
+ return {child.tag.split("}")[-1]: (child.text or "") for child in root}
218
+
219
+
220
+ def _error_from_response(res: urllib3.BaseHTTPResponse) -> S3SDKError:
221
+ text = "" if res.status == 304 else res.data.decode("utf-8", "replace")
222
+ fields = _xml_fields(text)
223
+ return S3SDKError(
224
+ status=res.status,
225
+ code=fields.get("Code") or ("NotModified" if res.status == 304 else f"Http{res.status}"),
226
+ message=fields.get("Message"),
227
+ current_version_id=res.headers.get("x-amz-version-id"),
228
+ request_id=fields.get("RequestId") or res.headers.get("x-amz-request-id"),
229
+ )
230
+
231
+
232
+ def _error_from_client_error(err: ClientError) -> S3SDKError:
233
+ resp = err.response or {}
234
+ meta = resp.get("ResponseMetadata", {})
235
+ headers = meta.get("HTTPHeaders", {}) or {}
236
+ error = resp.get("Error", {}) or {}
237
+ status = meta.get("HTTPStatusCode") or 0
238
+ return S3SDKError(
239
+ status=status,
240
+ code=error.get("Code") or f"Http{status}",
241
+ message=error.get("Message") or str(err),
242
+ current_version_id=headers.get("x-amz-version-id"),
243
+ request_id=meta.get("RequestId") or error.get("RequestId"),
244
+ )
245
+
246
+
247
+ def _header(res: urllib3.BaseHTTPResponse, name: str) -> str:
248
+ v = res.headers.get(name)
249
+ if v is None:
250
+ raise S3SDKError(res.status, "InvalidResponse", f"missing {name} header")
251
+ return v
252
+
253
+
254
+ def _write_result(res: urllib3.BaseHTTPResponse) -> WriteResult:
255
+ return WriteResult(
256
+ version_id=_header(res, "x-amz-version-id"),
257
+ etag=_unquote_etag(_header(res, "etag")),
258
+ size=int(_header(res, "x-s3sdk-size")),
259
+ )
260
+
261
+
262
+ def _precondition_headers(if_version: Optional[str], if_match: Optional[str]) -> dict[str, str]:
263
+ h: dict[str, str] = {}
264
+ if if_version is not None:
265
+ h["x-s3sdk-if-version"] = if_version
266
+ if if_match is not None:
267
+ h["If-Match"] = "*" if if_match == "*" else f'"{_unquote_etag(if_match)}"'
268
+ return h
269
+
270
+
271
+ class _AwsErrors:
272
+ """Context manager translating botocore ClientError into S3SDKError."""
273
+
274
+ def __enter__(self) -> None:
275
+ return None
276
+
277
+ def __exit__(self, exc_type, exc, tb) -> bool:
278
+ if isinstance(exc, ClientError):
279
+ raise _error_from_client_error(exc) from exc
280
+ return False
281
+
282
+
283
+ _aws = _AwsErrors()
284
+
285
+
286
+ # ---- client -------------------------------------------------------------------------------------
287
+
288
+
289
+ class S3SDKClient:
290
+ """S3SDK client. ``.s3`` is a plain boto3 S3 client for any standard S3 call."""
291
+
292
+ def __init__(
293
+ self,
294
+ access_key_id: str,
295
+ secret_access_key: str,
296
+ endpoint: str = DEFAULT_ENDPOINT,
297
+ region: str = "us-east-1",
298
+ *,
299
+ force_path_style: bool = True,
300
+ boto_config: Optional[Config] = None,
301
+ http: Optional[urllib3.PoolManager] = None,
302
+ timeout: Union[float, urllib3.Timeout, None] = 60.0,
303
+ ) -> None:
304
+ parts = urlsplit(endpoint)
305
+ if parts.scheme not in ("http", "https") or not parts.netloc:
306
+ raise ValueError(f"invalid endpoint: {endpoint!r}")
307
+ self._scheme = parts.scheme
308
+ self._netloc = parts.netloc
309
+ self._base_path = parts.path.rstrip("/")
310
+ self._credentials = Credentials(access_key_id, secret_access_key)
311
+ self._region = region
312
+ self._http = http or urllib3.PoolManager(retries=False)
313
+ self._timeout = timeout
314
+
315
+ config = Config(
316
+ s3={"addressing_style": "path" if force_path_style else "auto"},
317
+ # The gateway returns its own ETags; SDK-side checksum validation doesn't apply.
318
+ request_checksum_calculation="when_required",
319
+ response_checksum_validation="when_required",
320
+ )
321
+ if boto_config is not None:
322
+ config = config.merge(boto_config)
323
+ #: The underlying boto3 S3 client, for any standard S3 call.
324
+ self.s3 = boto3.client(
325
+ "s3",
326
+ endpoint_url=endpoint,
327
+ region_name=region,
328
+ aws_access_key_id=access_key_id,
329
+ aws_secret_access_key=secret_access_key,
330
+ config=config,
331
+ )
332
+
333
+ # ---- drives ---------------------------------------------------------------------------------
334
+
335
+ def list_drives(self) -> list[DriveSummary]:
336
+ with _aws:
337
+ out = self.s3.list_buckets()
338
+ return [DriveSummary(b.get("Name", ""), b.get("CreationDate")) for b in out.get("Buckets", [])]
339
+
340
+ def create_drive(self, name: str, display_name: Optional[str] = None) -> CreateDriveResult:
341
+ headers: dict[str, str] = {}
342
+ if display_name is not None:
343
+ headers["x-s3sdk-display-name"] = quote(display_name, safe="")
344
+ res = self._send("PUT", name, headers=headers)
345
+ return CreateDriveResult(_header(res, "x-s3sdk-drive-id"))
346
+
347
+ def fork_drive(self, source: str, name: str) -> ForkResult:
348
+ """Copy-on-write fork of ``source``'s current state into a new drive ``name``."""
349
+ res = self._send("PUT", name, headers={"x-s3sdk-fork-source": source})
350
+ return ForkResult(
351
+ drive_id=_header(res, "x-s3sdk-drive-id"),
352
+ source_drive_id=_header(res, "x-s3sdk-fork-source-id"),
353
+ fork_point=parse_timestamp(_header(res, "x-s3sdk-fork-point")),
354
+ )
355
+
356
+ def delete_drive(self, name: str, hard: bool = False) -> None:
357
+ """Soft delete (recoverable) unless ``hard``. A hard delete fails while forks exist."""
358
+ if hard:
359
+ self._send("DELETE", name, headers={"x-s3sdk-hard-delete": "true"})
360
+ else:
361
+ with _aws:
362
+ self.s3.delete_bucket(Bucket=name)
363
+
364
+ def describe_drive(self, name: str) -> DriveInfo:
365
+ res = self._send("GET", name, query=[("x-s3sdk-drive", "")])
366
+ j = json.loads(res.data)
367
+ fork_of = j.get("forkOf")
368
+ return DriveInfo(
369
+ drive_id=j["driveId"],
370
+ alias=j["alias"],
371
+ display_name=j["displayName"],
372
+ created_at=parse_timestamp(j["createdAt"]),
373
+ fork_of=ForkOf(fork_of["driveId"], parse_timestamp(fork_of["forkPoint"])) if fork_of else None,
374
+ forks=list(j.get("forks") or []),
375
+ usage_bytes=j.get("usageBytes"),
376
+ )
377
+
378
+ # ---- objects --------------------------------------------------------------------------------
379
+
380
+ def put_object(
381
+ self,
382
+ drive: str,
383
+ key: str,
384
+ body: Body,
385
+ content_type: Optional[str] = None,
386
+ if_version: Optional[str] = None,
387
+ if_match: Optional[str] = None,
388
+ if_none_match: Optional[Literal["*"]] = None,
389
+ ) -> WriteResult:
390
+ data = _to_bytes(body)
391
+ if if_version is not None:
392
+ # `x-s3sdk-if-version` isn't a boto3 parameter; send the PUT ourselves.
393
+ headers = _precondition_headers(if_version, if_match)
394
+ if content_type:
395
+ headers["Content-Type"] = content_type
396
+ if if_none_match:
397
+ headers["If-None-Match"] = if_none_match
398
+ res = self._send("PUT", drive, key, headers=headers, body=data)
399
+ return WriteResult(_header(res, "x-amz-version-id"), _unquote_etag(_header(res, "etag")), len(data))
400
+ params: dict[str, Any] = {"Bucket": drive, "Key": key, "Body": data, "ContentLength": len(data)}
401
+ if content_type is not None:
402
+ params["ContentType"] = content_type
403
+ if if_match is not None:
404
+ params["IfMatch"] = if_match
405
+ if if_none_match is not None:
406
+ params["IfNoneMatch"] = if_none_match
407
+ with _aws:
408
+ out = self.s3.put_object(**params)
409
+ return WriteResult(out.get("VersionId", ""), _unquote_etag(out.get("ETag", "")), len(data))
410
+
411
+ def get_object(
412
+ self,
413
+ drive: str,
414
+ key: str,
415
+ version_id: Optional[str] = None,
416
+ as_of: Union[datetime, str, None] = None,
417
+ ) -> ObjectResult:
418
+ """Reads a whole object: the head, ``version_id``, or the version current at ``as_of``."""
419
+ if as_of is not None:
420
+ if version_id is not None:
421
+ raise ValueError("version_id and as_of are mutually exclusive")
422
+ stamp = as_of if isinstance(as_of, str) else format_timestamp(as_of)
423
+ res = self._send("GET", drive, key, headers={"x-s3sdk-as-of": stamp})
424
+ data = res.data
425
+ lm = res.headers.get("last-modified")
426
+ return ObjectResult(
427
+ body=data,
428
+ version_id=_header(res, "x-amz-version-id"),
429
+ etag=_unquote_etag(_header(res, "etag")),
430
+ size=len(data),
431
+ content_type=res.headers.get("content-type"),
432
+ last_modified=parsedate_to_datetime(lm) if lm else None,
433
+ )
434
+ params: dict[str, Any] = {"Bucket": drive, "Key": key}
435
+ if version_id is not None:
436
+ params["VersionId"] = version_id
437
+ with _aws:
438
+ out = self.s3.get_object(**params)
439
+ data = out["Body"].read()
440
+ return ObjectResult(
441
+ body=data,
442
+ version_id=out.get("VersionId", ""),
443
+ etag=_unquote_etag(out.get("ETag", "")),
444
+ size=out.get("ContentLength", len(data)),
445
+ content_type=out.get("ContentType"),
446
+ last_modified=out.get("LastModified"),
447
+ )
448
+
449
+ def read_range(
450
+ self,
451
+ drive: str,
452
+ key: str,
453
+ offset: int,
454
+ length: Optional[int] = None,
455
+ version_id: Optional[str] = None,
456
+ ) -> bytes:
457
+ """Reads ``[offset, offset+length)`` (clamped to the object's end); ``length=None`` = to the end."""
458
+ if length is not None and length <= 0:
459
+ return b""
460
+ rng = f"bytes={offset}-" if length is None else f"bytes={offset}-{offset + length - 1}"
461
+ params: dict[str, Any] = {"Bucket": drive, "Key": key, "Range": rng}
462
+ if version_id is not None:
463
+ params["VersionId"] = version_id
464
+ with _aws:
465
+ out = self.s3.get_object(**params)
466
+ return out["Body"].read()
467
+
468
+ def write_at(
469
+ self,
470
+ drive: str,
471
+ key: str,
472
+ offset: int,
473
+ data: Body,
474
+ size: Optional[int] = None,
475
+ if_version: Optional[str] = None,
476
+ if_match: Optional[str] = None,
477
+ ) -> WriteResult:
478
+ """``pwrite``: writes ``data`` at ``offset`` as one new version. Creates the object if absent."""
479
+ headers = _precondition_headers(if_version, if_match)
480
+ headers["x-s3sdk-offset"] = str(offset)
481
+ if size is not None:
482
+ headers["x-s3sdk-size"] = str(size)
483
+ res = self._send("PUT", drive, key, query=[("x-s3sdk-write", "")], headers=headers, body=_to_bytes(data))
484
+ return _write_result(res)
485
+
486
+ def patch(
487
+ self,
488
+ drive: str,
489
+ key: str,
490
+ edits: Sequence[Edit],
491
+ size: Optional[int] = None,
492
+ if_version: Optional[str] = None,
493
+ if_match: Optional[str] = None,
494
+ ) -> WriteResult:
495
+ """Applies several edits atomically as one new version."""
496
+ return self.raw_patch(drive, key, encode_patch(edits), size=size, if_version=if_version, if_match=if_match)
497
+
498
+ def raw_patch(
499
+ self,
500
+ drive: str,
501
+ key: str,
502
+ body: Union[bytes, bytearray, memoryview],
503
+ size: Optional[int] = None,
504
+ if_version: Optional[str] = None,
505
+ if_match: Optional[str] = None,
506
+ ) -> WriteResult:
507
+ """Sends an already-encoded ``application/vnd.s3sdk.patch`` body."""
508
+ headers = _precondition_headers(if_version, if_match)
509
+ headers["Content-Type"] = PATCH_CONTENT_TYPE
510
+ if size is not None:
511
+ headers["x-s3sdk-size"] = str(size)
512
+ res = self._send("POST", drive, key, query=[("x-s3sdk-patch", "")], headers=headers, body=bytes(body))
513
+ return _write_result(res)
514
+
515
+ def truncate(
516
+ self,
517
+ drive: str,
518
+ key: str,
519
+ size: int,
520
+ if_version: Optional[str] = None,
521
+ if_match: Optional[str] = None,
522
+ ) -> WriteResult:
523
+ """Sets the object's size (truncating, or extending with zeros)."""
524
+ headers = _precondition_headers(if_version, if_match)
525
+ headers["x-s3sdk-offset"] = "0"
526
+ headers["x-s3sdk-size"] = str(size)
527
+ res = self._send("PUT", drive, key, query=[("x-s3sdk-write", "")], headers=headers, body=b"")
528
+ return _write_result(res)
529
+
530
+ def delete_object(self, drive: str, key: str) -> None:
531
+ with _aws:
532
+ self.s3.delete_object(Bucket=drive, Key=key)
533
+
534
+ def list_objects(self, drive: str, prefix: Optional[str] = None, delimiter: Optional[str] = None) -> ListResult:
535
+ """Lists every key and common prefix (all pages)."""
536
+ keys: list[str] = []
537
+ prefixes: list[str] = []
538
+ token: Optional[str] = None
539
+ while True:
540
+ params: dict[str, Any] = {"Bucket": drive}
541
+ if prefix is not None:
542
+ params["Prefix"] = prefix
543
+ if delimiter is not None:
544
+ params["Delimiter"] = delimiter
545
+ if token:
546
+ params["ContinuationToken"] = token
547
+ with _aws:
548
+ out = self.s3.list_objects_v2(**params)
549
+ keys.extend(o["Key"] for o in out.get("Contents", []) if "Key" in o)
550
+ prefixes.extend(p["Prefix"] for p in out.get("CommonPrefixes", []) if "Prefix" in p)
551
+ token = out.get("NextContinuationToken") if out.get("IsTruncated") else None
552
+ if not token:
553
+ return ListResult(keys, prefixes)
554
+
555
+ # ---- history --------------------------------------------------------------------------------
556
+
557
+ def list_versions(self, drive: str, key: str) -> list[VersionEntry]:
558
+ """Full history of one object, oldest first."""
559
+ res = self._send("GET", drive, key, query=[("x-s3sdk-versions", "")])
560
+ j = json.loads(res.data)
561
+ return [
562
+ VersionEntry(
563
+ version_id=v["versionId"],
564
+ is_latest=bool(v["isLatest"]),
565
+ size=int(v["size"]),
566
+ etag=_unquote_etag(v["etag"]),
567
+ last_modified=parse_timestamp(v["lastModified"]),
568
+ last_modified_raw=v["lastModified"],
569
+ operation=v["operation"],
570
+ restored_from=v.get("restoredFrom"),
571
+ )
572
+ for v in j["versions"]
573
+ ]
574
+
575
+ def restore_version(
576
+ self,
577
+ drive: str,
578
+ key: str,
579
+ version_id: str,
580
+ if_version: Optional[str] = None,
581
+ if_match: Optional[str] = None,
582
+ ) -> RestoreResult:
583
+ """Makes ``version_id``'s content the new head (history is kept)."""
584
+ res = self._send(
585
+ "POST",
586
+ drive,
587
+ key,
588
+ query=[("x-s3sdk-restore", ""), ("versionId", version_id)],
589
+ headers=_precondition_headers(if_version, if_match),
590
+ )
591
+ w = _write_result(res)
592
+ return RestoreResult(w.version_id, w.etag, w.size, restored_from=_header(res, "x-s3sdk-restored-from"))
593
+
594
+ # ---- transport ------------------------------------------------------------------------------
595
+
596
+ def _send(
597
+ self,
598
+ method: str,
599
+ drive: str,
600
+ key: str = "",
601
+ *,
602
+ query: Sequence[tuple[str, str]] = (),
603
+ headers: Optional[Mapping[str, str]] = None,
604
+ body: bytes = b"",
605
+ ) -> urllib3.BaseHTTPResponse:
606
+ """Sends a SigV4-signed path-style request; raises S3SDKError on a non-2xx response."""
607
+ path = f"{self._base_path}/{quote(drive, safe='')}"
608
+ if key:
609
+ path += "/" + _encode_key(key)
610
+ qs = "&".join(
611
+ quote(k, safe="") if v == "" else f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in query
612
+ )
613
+ url = f"{self._scheme}://{self._netloc}{path}"
614
+ if qs:
615
+ url += "?" + qs
616
+
617
+ req_headers: dict[str, str] = {"X-Amz-Content-SHA256": hashlib.sha256(body).hexdigest()}
618
+ req_headers.update(headers or {})
619
+ if body or method in ("PUT", "POST"):
620
+ req_headers["Content-Length"] = str(len(body))
621
+ request = AWSRequest(method=method, url=url, headers=req_headers, data=body)
622
+ # S3SigV4Auth leaves the (already RFC 3986-encoded) path as-is instead of double-encoding
623
+ # it, matching S3's canonical form, and signs the payload hash.
624
+ S3SigV4Auth(self._credentials, "s3", self._region).add_auth(request)
625
+
626
+ res = self._http.request(
627
+ method,
628
+ url,
629
+ body=None if method in ("GET", "HEAD") else body,
630
+ headers=dict(request.headers.items()),
631
+ timeout=self._timeout,
632
+ redirect=False,
633
+ retries=False,
634
+ )
635
+ if not 200 <= res.status < 300:
636
+ raise _error_from_response(res)
637
+ return res
@@ -0,0 +1,253 @@
1
+ """Runs spec/conformance/cases.json against a live gateway.
2
+
3
+ S3SDK_ENDPOINT, S3SDK_ACCESS_KEY_ID, S3SDK_SECRET_ACCESS_KEY
4
+ python -m s3sdk.conformance ../spec/conformance/cases.json
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+ import time
14
+ from dataclasses import asdict
15
+ from typing import Any, Optional
16
+
17
+ from .client import S3SDKClient, S3SDKError
18
+ from .patch import Edit
19
+
20
+ _VAR = re.compile(r"\$\{(\w+)\}")
21
+
22
+
23
+ def _base36(n: int) -> str:
24
+ digits = "0123456789abcdefghijklmnopqrstuvwxyz"
25
+ out = ""
26
+ while True:
27
+ n, r = divmod(n, 36)
28
+ out = digits[r] + out
29
+ if n == 0:
30
+ return out
31
+
32
+
33
+ def _env(name: str) -> str:
34
+ v = os.environ.get(name)
35
+ if not v:
36
+ raise SystemExit(f"{name} is required")
37
+ return v
38
+
39
+
40
+ def subst(v: Any, vars: dict[str, str]) -> Any:
41
+ if isinstance(v, str):
42
+
43
+ def repl(m: re.Match[str]) -> str:
44
+ name = m.group(1)
45
+ if name not in vars:
46
+ raise ValueError(f"undefined variable {name}")
47
+ return vars[name]
48
+
49
+ return _VAR.sub(repl, v)
50
+ if isinstance(v, list):
51
+ return [subst(x, vars) for x in v]
52
+ if isinstance(v, dict):
53
+ return {k: subst(x, vars) for k, x in v.items()}
54
+ return v
55
+
56
+
57
+ def _body_arg(a: dict[str, Any]) -> bytes:
58
+ if isinstance(a.get("bodyHex"), str):
59
+ return bytes.fromhex(a["bodyHex"])
60
+ body = a.get("body")
61
+ return body.encode("utf-8") if isinstance(body, str) else b""
62
+
63
+
64
+ def _str(v: Any) -> Optional[str]:
65
+ return v if isinstance(v, str) else None
66
+
67
+
68
+ def _num(v: Any) -> Optional[int]:
69
+ return v if isinstance(v, int) and not isinstance(v, bool) else None
70
+
71
+
72
+ def _write(r: Any) -> dict[str, Any]:
73
+ return {"versionId": r.version_id, "etag": r.etag, "size": r.size}
74
+
75
+
76
+ def run(client: S3SDKClient, a: dict[str, Any]) -> dict[str, Any]:
77
+ op = a.get("op")
78
+ drive = _str(a.get("drive")) or ""
79
+ key = _str(a.get("key")) or ""
80
+ pre = {"if_version": _str(a.get("ifVersion")), "if_match": _str(a.get("ifMatch"))}
81
+ if op == "createDrive":
82
+ r = client.create_drive(drive, display_name=_str(a.get("displayName")))
83
+ return {"driveId": r.drive_id}
84
+ if op == "forkDrive":
85
+ f = client.fork_drive(_str(a.get("source")) or "", drive)
86
+ return {"driveId": f.drive_id, "sourceDriveId": f.source_drive_id, "forkPoint": f.fork_point.isoformat()}
87
+ if op == "describeDrive":
88
+ d = client.describe_drive(drive)
89
+ return {
90
+ "driveId": d.drive_id,
91
+ "alias": d.alias,
92
+ "displayName": d.display_name,
93
+ "forkOf": d.fork_of.drive_id if d.fork_of else None,
94
+ "forks": d.forks,
95
+ }
96
+ if op == "listDrives":
97
+ return {"names": [d.name for d in client.list_drives()]}
98
+ if op == "deleteDrive":
99
+ client.delete_drive(drive, hard=a.get("hard") is True)
100
+ return {}
101
+ if op == "putObject":
102
+ r = client.put_object(
103
+ drive, key, _body_arg(a), if_none_match="*" if a.get("ifNoneMatch") == "*" else None, **pre
104
+ )
105
+ return _write(r)
106
+ if op == "getObject":
107
+ o = client.get_object(drive, key, version_id=_str(a.get("versionId")), as_of=_str(a.get("asOf")))
108
+ return {"body": o.body, "versionId": o.version_id, "etag": o.etag, "size": o.size}
109
+ if op == "readRange":
110
+ body = client.read_range(
111
+ drive, key, _num(a.get("offset")) or 0, length=_num(a.get("length")), version_id=_str(a.get("versionId"))
112
+ )
113
+ return {"body": body}
114
+ if op == "writeAt":
115
+ return _write(client.write_at(drive, key, _num(a.get("offset")) or 0, _body_arg(a), size=_num(a.get("size")), **pre))
116
+ if op == "patch":
117
+ edits = [Edit(_num(e.get("offset")) or 0, _body_arg(e)) for e in a["edits"]]
118
+ return _write(client.patch(drive, key, edits, size=_num(a.get("size")), **pre))
119
+ if op == "rawPatch":
120
+ return _write(client.raw_patch(drive, key, _body_arg(a), size=_num(a.get("size")), **pre))
121
+ if op == "truncate":
122
+ return _write(client.truncate(drive, key, _num(a.get("size")) or 0, **pre))
123
+ if op == "listVersions":
124
+ vs = client.list_versions(drive, key)
125
+ latest = next((v for v in vs if v.is_latest), None)
126
+ return {
127
+ "count": len(vs),
128
+ "operations": [v.operation for v in vs],
129
+ "versionIds": [v.version_id for v in vs],
130
+ "latest": latest.version_id if latest else None,
131
+ "latestTime": latest.last_modified_raw if latest else None,
132
+ }
133
+ if op == "restoreVersion":
134
+ r = client.restore_version(drive, key, _str(a.get("versionId")) or "", **pre)
135
+ return {**_write(r), "restoredFrom": r.restored_from}
136
+ if op == "listObjects":
137
+ lr = client.list_objects(drive, prefix=_str(a.get("prefix")), delimiter=_str(a.get("delimiter")))
138
+ return asdict(lr)
139
+ if op == "deleteObject":
140
+ client.delete_object(drive, key)
141
+ return {}
142
+ if op == "sleep":
143
+ time.sleep((_num(a.get("ms")) or 0) / 1000)
144
+ return {}
145
+ raise ValueError(f"unknown op {op!r}")
146
+
147
+
148
+ def check(result: dict[str, Any], expect: dict[str, Any]) -> None:
149
+ for field, want in expect.items():
150
+ if field == "body":
151
+ got: Any = result["body"].decode("utf-8", "replace")
152
+ elif field == "bodyHex":
153
+ got = result["body"].hex()
154
+ elif field == "contains":
155
+ if want not in result["names"]:
156
+ raise AssertionError(f"names does not contain {want!r}")
157
+ continue
158
+ else:
159
+ if field not in result:
160
+ raise AssertionError(f"{field}: missing from result")
161
+ got = result[field]
162
+ if got != want:
163
+ raise AssertionError(f"{field}: expected {want!r}, got {got!r}")
164
+
165
+
166
+ def _save_value(v: Any) -> str:
167
+ if v is None:
168
+ return "null"
169
+ if isinstance(v, bool):
170
+ return "true" if v else "false"
171
+ return str(v)
172
+
173
+
174
+ def run_case(client: S3SDKClient, steps: list[dict[str, Any]], vars: dict[str, str]) -> None:
175
+ for si, raw in enumerate(steps):
176
+ step = subst(raw, vars)
177
+ where = f"step {si} ({step.get('op')})"
178
+ result: Optional[dict[str, Any]] = None
179
+ error: Optional[BaseException] = None
180
+ try:
181
+ result = run(client, step)
182
+ except Exception as e: # noqa: BLE001 - every failure is reported against the step
183
+ error = e
184
+ want_err = step.get("expectError")
185
+ if want_err is not None:
186
+ if not isinstance(error, S3SDKError):
187
+ raise AssertionError(f"{where}: expected {json.dumps(want_err)}, got {repr(error) if error else 'success'}")
188
+ got: dict[str, Any] = {"status": error.status, "code": error.code}
189
+ if "currentVersionId" in want_err:
190
+ got["currentVersionId"] = error.current_version_id
191
+ if got != want_err:
192
+ raise AssertionError(
193
+ f"{where}: expected error {json.dumps(want_err)}, got {json.dumps(got)} ({error.message})"
194
+ )
195
+ continue
196
+ if error is not None:
197
+ raise AssertionError(f"{where}: {error!r}") from error
198
+ assert result is not None
199
+ try:
200
+ if step.get("expect"):
201
+ check(result, step["expect"])
202
+ except AssertionError as e:
203
+ raise AssertionError(f"{where}: {e}") from None
204
+ for name, field in (step.get("save") or {}).items():
205
+ if field not in result:
206
+ raise AssertionError(f"{where}: cannot save {name}: result has no field {field}")
207
+ vars[name] = _save_value(result[field])
208
+
209
+
210
+ def cleanup(client: S3SDKClient, vars: dict[str, str]) -> None:
211
+ # Forks (${drive2}) before parents.
212
+ for d in (vars["drive2"], vars["drive"]):
213
+ try:
214
+ client.delete_drive(d, hard=True)
215
+ except S3SDKError as e:
216
+ if e.code != "NoSuchBucket":
217
+ print(f" cleanup of {d} failed: {e.status} {e.code} {e.message}", file=sys.stderr)
218
+ except Exception as e: # noqa: BLE001
219
+ print(f" cleanup of {d} failed: {e!r}", file=sys.stderr)
220
+
221
+
222
+ def main(argv: Optional[list[str]] = None) -> int:
223
+ argv = sys.argv[1:] if argv is None else argv
224
+ path = argv[0] if argv else "../spec/conformance/cases.json"
225
+ with open(path, encoding="utf-8") as f:
226
+ doc = json.load(f)
227
+ client = S3SDKClient(
228
+ endpoint=_env("S3SDK_ENDPOINT"),
229
+ access_key_id=_env("S3SDK_ACCESS_KEY_ID"),
230
+ secret_access_key=_env("S3SDK_SECRET_ACCESS_KEY"),
231
+ )
232
+ stamp = _base36(int(time.time() * 1000))
233
+ cases = doc["cases"]
234
+ failed = 0
235
+ for ci, case in enumerate(cases):
236
+ vars = {"drive": f"p{stamp}-{ci}-1", "drive2": f"p{stamp}-{ci}-2"}
237
+ try:
238
+ run_case(client, case["steps"], vars)
239
+ print(f"ok {case['name']}", flush=True)
240
+ except Exception as e: # noqa: BLE001
241
+ failed += 1
242
+ print(f"FAIL {case['name']}: {e}", flush=True)
243
+ finally:
244
+ cleanup(client, vars)
245
+ if failed:
246
+ print(f"{failed} of {len(cases)} cases failed")
247
+ else:
248
+ print(f"all {len(cases)} cases passed")
249
+ return 1 if failed else 0
250
+
251
+
252
+ if __name__ == "__main__":
253
+ sys.exit(main())
@@ -0,0 +1,91 @@
1
+ """The ``application/vnd.s3sdk.patch`` body codec (spec/PROTOCOL.md §4.2).
2
+
3
+ Wire format (all integers big-endian)::
4
+
5
+ magic 4 bytes "S3SP"
6
+ version u8 1
7
+ reserved 3 bytes 0
8
+ count u32 number of edits (1..=10_000)
9
+ repeat count times:
10
+ offset u64
11
+ length u64
12
+ bytes [length]
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+ from dataclasses import dataclass
19
+ from typing import Sequence, Union
20
+
21
+ __all__ = ["Edit", "PatchError", "encode_patch", "decode_patch", "CONTENT_TYPE", "MAX_EDITS"]
22
+
23
+ CONTENT_TYPE = "application/vnd.s3sdk.patch"
24
+ MAGIC = b"S3SP"
25
+ VERSION = 1
26
+ MAX_EDITS = 10_000
27
+ _U64_MAX = (1 << 64) - 1
28
+
29
+ _HEADER = struct.Struct(">4sB3xI")
30
+ _EDIT = struct.Struct(">QQ")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Edit:
35
+ """Write ``data`` at byte ``offset``. ``str`` data is encoded as UTF-8."""
36
+
37
+ offset: int
38
+ data: Union[bytes, bytearray, memoryview, str]
39
+
40
+ def payload(self) -> bytes:
41
+ return self.data.encode("utf-8") if isinstance(self.data, str) else bytes(self.data)
42
+
43
+
44
+ class PatchError(ValueError):
45
+ """A patch that cannot be encoded, or a body that is not a valid patch."""
46
+
47
+
48
+ def encode_patch(edits: Sequence[Edit]) -> bytes:
49
+ """Encodes ``edits`` (applied in order; later edits win) into a patch body."""
50
+ if not 1 <= len(edits) <= MAX_EDITS:
51
+ raise PatchError(f"a patch needs 1..={MAX_EDITS} edits")
52
+ parts = [_HEADER.pack(MAGIC, VERSION, len(edits))]
53
+ for e in edits:
54
+ if not isinstance(e.offset, int) or isinstance(e.offset, bool):
55
+ raise PatchError("edit offset must be an integer")
56
+ if e.offset < 0:
57
+ raise PatchError("edit offset must be non-negative")
58
+ if e.offset > _U64_MAX:
59
+ raise PatchError("edit offset exceeds u64")
60
+ data = e.payload()
61
+ parts.append(_EDIT.pack(e.offset, len(data)))
62
+ parts.append(data)
63
+ return b"".join(parts)
64
+
65
+
66
+ def decode_patch(body: Union[bytes, bytearray, memoryview]) -> list[Edit]:
67
+ """Decodes a patch body; raises :class:`PatchError` if it is malformed."""
68
+ body = bytes(body)
69
+ if len(body) < _HEADER.size:
70
+ raise PatchError("invalid patch: body shorter than header")
71
+ magic, version, count = _HEADER.unpack_from(body, 0)
72
+ if magic != MAGIC:
73
+ raise PatchError("invalid patch: bad magic")
74
+ if version != VERSION:
75
+ raise PatchError("invalid patch: unsupported version")
76
+ if not 1 <= count <= MAX_EDITS:
77
+ raise PatchError("invalid patch: edit count out of range")
78
+ edits: list[Edit] = []
79
+ pos = _HEADER.size
80
+ for _ in range(count):
81
+ if len(body) - pos < _EDIT.size:
82
+ raise PatchError("invalid patch: truncated edit header")
83
+ offset, length = _EDIT.unpack_from(body, pos)
84
+ pos += _EDIT.size
85
+ if length > len(body) - pos:
86
+ raise PatchError("invalid patch: truncated edit payload")
87
+ edits.append(Edit(offset, body[pos : pos + length]))
88
+ pos += length
89
+ if pos != len(body):
90
+ raise PatchError("invalid patch: trailing bytes")
91
+ return edits
@@ -0,0 +1,77 @@
1
+ import struct
2
+
3
+ import pytest
4
+
5
+ from s3sdk import Edit, PatchError, decode_patch, encode_patch
6
+
7
+
8
+ def test_roundtrip():
9
+ body = encode_patch([Edit(0, "hello"), Edit(2**40, b""), Edit(7, b"\xff")])
10
+ assert [(e.offset, e.data) for e in decode_patch(body)] == [
11
+ (0, b"hello"),
12
+ (2**40, b""),
13
+ (7, b"\xff"),
14
+ ]
15
+
16
+
17
+ def test_wire_format_matches_spec():
18
+ body = encode_patch([Edit(3, b"ab")])
19
+ assert body.hex() == (
20
+ "53335350" "01000000" "00000001" "0000000000000003" "0000000000000002" "6162"
21
+ )
22
+
23
+
24
+ def test_max_offset():
25
+ body = encode_patch([Edit(2**64 - 1, b"x")])
26
+ assert decode_patch(body)[0].offset == 2**64 - 1
27
+
28
+
29
+ def _header(count: int, magic: bytes = b"S3SP", version: int = 1) -> bytes:
30
+ return struct.pack(">4sB3xI", magic, version, count)
31
+
32
+
33
+ def test_rejects_truncated_bodies():
34
+ good = encode_patch([Edit(3, b"abc")])
35
+ for n in range(len(good)):
36
+ with pytest.raises(PatchError):
37
+ decode_patch(good[:n])
38
+
39
+
40
+ def test_rejects_trailing_bytes():
41
+ good = encode_patch([Edit(3, b"abc")])
42
+ with pytest.raises(PatchError, match="trailing"):
43
+ decode_patch(good + b"\x00")
44
+
45
+
46
+ def test_rejects_bad_magic_and_version():
47
+ tail = struct.pack(">QQ", 0, 1) + b"x"
48
+ with pytest.raises(PatchError, match="magic"):
49
+ decode_patch(_header(1, magic=b"S3SDKQ") + tail)
50
+ with pytest.raises(PatchError, match="version"):
51
+ decode_patch(_header(1, version=2) + tail)
52
+ # The conformance suite's malformed body (version 2).
53
+ with pytest.raises(PatchError):
54
+ decode_patch(bytes.fromhex("53335350020000000000000100"))
55
+
56
+
57
+ def test_rejects_edit_count_out_of_range():
58
+ with pytest.raises(PatchError, match="count"):
59
+ decode_patch(_header(0))
60
+ with pytest.raises(PatchError, match="count"):
61
+ decode_patch(_header(10_001) + (struct.pack(">QQ", 0, 0) * 10_001))
62
+
63
+
64
+ def test_accepts_max_edit_count():
65
+ body = encode_patch([Edit(i, b"") for i in range(10_000)])
66
+ assert len(decode_patch(body)) == 10_000
67
+
68
+
69
+ def test_encode_rejects_bad_input():
70
+ with pytest.raises(PatchError):
71
+ encode_patch([])
72
+ with pytest.raises(PatchError):
73
+ encode_patch([Edit(0, b"")] * 10_001)
74
+ with pytest.raises(PatchError):
75
+ encode_patch([Edit(-1, b"x")])
76
+ with pytest.raises(PatchError):
77
+ encode_patch([Edit(2**64, b"x")])