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.
- spacefs_s3sdk-0.1.0/.gitignore +6 -0
- spacefs_s3sdk-0.1.0/LICENSE +21 -0
- spacefs_s3sdk-0.1.0/PKG-INFO +75 -0
- spacefs_s3sdk-0.1.0/README.md +63 -0
- spacefs_s3sdk-0.1.0/pyproject.toml +21 -0
- spacefs_s3sdk-0.1.0/src/s3sdk/__init__.py +41 -0
- spacefs_s3sdk-0.1.0/src/s3sdk/client.py +637 -0
- spacefs_s3sdk-0.1.0/src/s3sdk/conformance.py +253 -0
- spacefs_s3sdk-0.1.0/src/s3sdk/patch.py +91 -0
- spacefs_s3sdk-0.1.0/tests/test_patch.py +77 -0
|
@@ -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")])
|