txstore 0.1.0__py3-none-any.whl
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.
Potentially problematic release.
This version of txstore might be problematic. Click here for more details.
- txstore/__init__.py +108 -0
- txstore/_capabilities.py +185 -0
- txstore/_env.py +70 -0
- txstore/_footmark_wire.py +182 -0
- txstore/_fs_paths.py +279 -0
- txstore/_reclaim.py +217 -0
- txstore/_reserved.py +47 -0
- txstore/_s3_acl.py +57 -0
- txstore/_s3_client.py +462 -0
- txstore/_s3_errors.py +119 -0
- txstore/_s3_lock.py +366 -0
- txstore/_timestamp.py +128 -0
- txstore/base.py +208 -0
- txstore/factory.py +157 -0
- txstore/footmark.py +195 -0
- txstore/hexdigest.py +162 -0
- txstore/history.py +633 -0
- txstore/metrics.py +113 -0
- txstore/py.typed +0 -0
- txstore/storage_fs.py +686 -0
- txstore/storage_http.py +258 -0
- txstore/storage_null.py +114 -0
- txstore/storage_s3.py +727 -0
- txstore/transaction.py +1086 -0
- txstore/url.py +426 -0
- txstore-0.1.0.dist-info/METADATA +151 -0
- txstore-0.1.0.dist-info/RECORD +29 -0
- txstore-0.1.0.dist-info/WHEEL +4 -0
- txstore-0.1.0.dist-info/licenses/LICENSE +21 -0
txstore/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Public façade of the `txstore` package.
|
|
2
|
+
|
|
3
|
+
Import from `txstore` only; submodules are not a supported surface.
|
|
4
|
+
|
|
5
|
+
Concrete backends and helpers:
|
|
6
|
+
|
|
7
|
+
- StorageFS : Filesystem storage with full transaction support.
|
|
8
|
+
- StorageS3 : S3 object storage with full transaction support.
|
|
9
|
+
- StorageHTTP : Read-only HTTP storage (no write/transaction support).
|
|
10
|
+
- StorageNull : Write-only /dev/null-like storage (no read/transaction support).
|
|
11
|
+
|
|
12
|
+
Transactions are exposed through `FootmarkMixin` (mixed into FS/S3).
|
|
13
|
+
Backends that do not support them still expose the `transaction_*` names
|
|
14
|
+
as `NotImplementedError` stubs; use `is_transactional()` as the capability
|
|
15
|
+
test.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from importlib.metadata import version as _pkg_version
|
|
19
|
+
|
|
20
|
+
from txstore._s3_client import BucketCannedACL, ObjectCannedACL
|
|
21
|
+
from txstore._timestamp import Timestamp
|
|
22
|
+
from txstore.base import (
|
|
23
|
+
CHUNK_SIZE,
|
|
24
|
+
REQUEST_TIMEOUT,
|
|
25
|
+
Storage,
|
|
26
|
+
StorageException,
|
|
27
|
+
StorageNotFoundException,
|
|
28
|
+
StorageType,
|
|
29
|
+
)
|
|
30
|
+
from txstore.factory import NO_ACL, make_storage
|
|
31
|
+
from txstore.footmark import ObjectOperation
|
|
32
|
+
from txstore.hexdigest import (
|
|
33
|
+
COMPARE_HASH_LIST,
|
|
34
|
+
SUPPORT_HASH_LIST,
|
|
35
|
+
ChecksumOpen,
|
|
36
|
+
calc_digests,
|
|
37
|
+
compare_digests,
|
|
38
|
+
normalize_digests,
|
|
39
|
+
)
|
|
40
|
+
from txstore.history import (
|
|
41
|
+
HISTORY_KEEP_DEFAULT,
|
|
42
|
+
HISTORY_MAX_AGE_DAYS_DEFAULT,
|
|
43
|
+
HistoryRetention,
|
|
44
|
+
PruneResult,
|
|
45
|
+
TransactionHistory,
|
|
46
|
+
history_lock,
|
|
47
|
+
maybe_transaction,
|
|
48
|
+
)
|
|
49
|
+
from txstore.metrics import StorageMetrics
|
|
50
|
+
from txstore.storage_fs import StorageFS
|
|
51
|
+
from txstore.storage_http import StorageHTTP
|
|
52
|
+
from txstore.storage_null import StorageNull
|
|
53
|
+
from txstore.storage_s3 import StorageS3, parse_bucket_acl, parse_object_acl
|
|
54
|
+
from txstore.transaction import (
|
|
55
|
+
FootmarkMixin,
|
|
56
|
+
TransactionModel,
|
|
57
|
+
is_transactional,
|
|
58
|
+
)
|
|
59
|
+
from txstore.url import (
|
|
60
|
+
get_supported_schemes,
|
|
61
|
+
is_unsafe_path,
|
|
62
|
+
join_url,
|
|
63
|
+
parse_storage_url,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
__version__ = _pkg_version("txstore")
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"CHUNK_SIZE",
|
|
70
|
+
"COMPARE_HASH_LIST",
|
|
71
|
+
"HISTORY_KEEP_DEFAULT",
|
|
72
|
+
"HISTORY_MAX_AGE_DAYS_DEFAULT",
|
|
73
|
+
"NO_ACL",
|
|
74
|
+
"REQUEST_TIMEOUT",
|
|
75
|
+
"SUPPORT_HASH_LIST",
|
|
76
|
+
"BucketCannedACL",
|
|
77
|
+
"ChecksumOpen",
|
|
78
|
+
"FootmarkMixin",
|
|
79
|
+
"is_transactional",
|
|
80
|
+
"maybe_transaction",
|
|
81
|
+
"HistoryRetention",
|
|
82
|
+
"ObjectCannedACL",
|
|
83
|
+
"ObjectOperation",
|
|
84
|
+
"PruneResult",
|
|
85
|
+
"Storage",
|
|
86
|
+
"StorageException",
|
|
87
|
+
"StorageFS",
|
|
88
|
+
"StorageHTTP",
|
|
89
|
+
"StorageMetrics",
|
|
90
|
+
"StorageNotFoundException",
|
|
91
|
+
"StorageNull",
|
|
92
|
+
"StorageS3",
|
|
93
|
+
"StorageType",
|
|
94
|
+
"Timestamp",
|
|
95
|
+
"TransactionHistory",
|
|
96
|
+
"TransactionModel",
|
|
97
|
+
"calc_digests",
|
|
98
|
+
"compare_digests",
|
|
99
|
+
"get_supported_schemes",
|
|
100
|
+
"history_lock",
|
|
101
|
+
"is_unsafe_path",
|
|
102
|
+
"join_url",
|
|
103
|
+
"make_storage",
|
|
104
|
+
"normalize_digests",
|
|
105
|
+
"parse_bucket_acl",
|
|
106
|
+
"parse_object_acl",
|
|
107
|
+
"parse_storage_url",
|
|
108
|
+
]
|
txstore/_capabilities.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Capability mixins for storage backends.
|
|
2
|
+
|
|
3
|
+
`ReadOnlyStorageMixin` and `WriteOnlyStorageMixin` supply default
|
|
4
|
+
``NotImplementedError`` stubs for the `Storage` ABC methods that a
|
|
5
|
+
read-only or write-only backend cannot fulfil. Concrete backends
|
|
6
|
+
inherit the mixin first (MRO) and then override only the methods they
|
|
7
|
+
actually implement.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import builtins
|
|
13
|
+
from typing import IO, Any, NoReturn
|
|
14
|
+
|
|
15
|
+
READ_ONLY_MSG = "This operation is not supported by read-only storage."
|
|
16
|
+
WRITE_ONLY_MSG = "This operation is not supported by write-only storage."
|
|
17
|
+
NO_TRANSACTIONS_MSG = "Transactions are not supported by this storage backend."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TransactionNotSupportedMixin:
|
|
21
|
+
"""Explicit error stubs for transaction methods on non-transactional backends.
|
|
22
|
+
|
|
23
|
+
Backends without transaction support (e.g. `StorageHTTP`, `StorageNull`)
|
|
24
|
+
inherit this to raise a clear `NotImplementedError` instead of a
|
|
25
|
+
confusing `AttributeError`, for the lifecycle and the reclaim methods alike.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def in_transaction(self) -> bool:
|
|
29
|
+
"""Always False for non-transactional backends."""
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
def transaction_begin(self, transaction_id: int = 0) -> NoReturn:
|
|
33
|
+
"""Not supported (non-transactional backend)."""
|
|
34
|
+
del transaction_id
|
|
35
|
+
raise NotImplementedError(NO_TRANSACTIONS_MSG)
|
|
36
|
+
|
|
37
|
+
def transaction_commit(self) -> NoReturn:
|
|
38
|
+
"""Not supported (non-transactional backend)."""
|
|
39
|
+
raise NotImplementedError(NO_TRANSACTIONS_MSG)
|
|
40
|
+
|
|
41
|
+
def transaction_revert(self, transaction: Any) -> NoReturn:
|
|
42
|
+
"""Not supported (non-transactional backend)."""
|
|
43
|
+
del transaction
|
|
44
|
+
raise NotImplementedError(NO_TRANSACTIONS_MSG)
|
|
45
|
+
|
|
46
|
+
def transaction_discard(self) -> NoReturn:
|
|
47
|
+
"""Not supported (non-transactional backend)."""
|
|
48
|
+
raise NotImplementedError(NO_TRANSACTIONS_MSG)
|
|
49
|
+
|
|
50
|
+
def hidden_objects(self) -> NoReturn:
|
|
51
|
+
"""Not supported (non-transactional backend)."""
|
|
52
|
+
raise NotImplementedError(NO_TRANSACTIONS_MSG)
|
|
53
|
+
|
|
54
|
+
def find_transaction_footmarks(self, models: Any, *, listing: Any = None) -> NoReturn:
|
|
55
|
+
"""Not supported (non-transactional backend)."""
|
|
56
|
+
del models, listing
|
|
57
|
+
raise NotImplementedError(NO_TRANSACTIONS_MSG)
|
|
58
|
+
|
|
59
|
+
def transaction_forget(self, models: Any, *, paths: Any = None) -> NoReturn:
|
|
60
|
+
"""Not supported (non-transactional backend)."""
|
|
61
|
+
del models, paths
|
|
62
|
+
raise NotImplementedError(NO_TRANSACTIONS_MSG)
|
|
63
|
+
|
|
64
|
+
def find_orphaned_footmarks(self, known: Any, *, listing: Any = None) -> NoReturn:
|
|
65
|
+
"""Not supported (non-transactional backend)."""
|
|
66
|
+
del known, listing
|
|
67
|
+
raise NotImplementedError(NO_TRANSACTIONS_MSG)
|
|
68
|
+
|
|
69
|
+
def transaction_forget_orphans(self, known: Any, *, orphans: Any = None) -> NoReturn:
|
|
70
|
+
"""Not supported (non-transactional backend)."""
|
|
71
|
+
del known, orphans
|
|
72
|
+
raise NotImplementedError(NO_TRANSACTIONS_MSG)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ReadOnlyStorageMixin:
|
|
76
|
+
"""Default `NotImplementedError` stubs for write/delete/metadata/copy/move.
|
|
77
|
+
|
|
78
|
+
Backends inheriting this mixin (e.g. `StorageHTTP`) still define the
|
|
79
|
+
read-side methods (`list`, `get`, `get_stream`, `get_metadata`,
|
|
80
|
+
`exists`); a backend without a listing API makes `list` raise
|
|
81
|
+
`NotImplementedError` → [STORAGE.md](STORAGE.md#backends).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def put(
|
|
85
|
+
self,
|
|
86
|
+
path: str,
|
|
87
|
+
data: bytes,
|
|
88
|
+
metadata: dict[str, str] | None = None,
|
|
89
|
+
acl: str | None = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Not supported (read-only storage)."""
|
|
92
|
+
raise NotImplementedError(READ_ONLY_MSG)
|
|
93
|
+
|
|
94
|
+
def put_stream(
|
|
95
|
+
self,
|
|
96
|
+
path: str,
|
|
97
|
+
stream: IO[bytes],
|
|
98
|
+
metadata: dict[str, str] | None = None,
|
|
99
|
+
acl: str | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Not supported (read-only storage)."""
|
|
102
|
+
raise NotImplementedError(READ_ONLY_MSG)
|
|
103
|
+
|
|
104
|
+
def add_metadata(
|
|
105
|
+
self,
|
|
106
|
+
path: str,
|
|
107
|
+
metadata: dict[str, str],
|
|
108
|
+
acl: str | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Not supported (read-only storage)."""
|
|
111
|
+
raise NotImplementedError(READ_ONLY_MSG)
|
|
112
|
+
|
|
113
|
+
def delete(self, path: str) -> None:
|
|
114
|
+
"""Not supported (read-only storage)."""
|
|
115
|
+
raise NotImplementedError(READ_ONLY_MSG)
|
|
116
|
+
|
|
117
|
+
def delete_metadata(self, path: str) -> None:
|
|
118
|
+
"""Not supported (read-only storage)."""
|
|
119
|
+
raise NotImplementedError(READ_ONLY_MSG)
|
|
120
|
+
|
|
121
|
+
def delete_metadata_fields(self, path: str, keys: builtins.list[str]) -> None:
|
|
122
|
+
"""Not supported (read-only storage)."""
|
|
123
|
+
raise NotImplementedError(READ_ONLY_MSG)
|
|
124
|
+
|
|
125
|
+
def copy(self, path_src: str, path_dst: str) -> None:
|
|
126
|
+
"""Not supported (read-only storage)."""
|
|
127
|
+
raise NotImplementedError(READ_ONLY_MSG)
|
|
128
|
+
|
|
129
|
+
def move(self, path_src: str, path_dst: str) -> None:
|
|
130
|
+
"""Not supported (read-only storage)."""
|
|
131
|
+
raise NotImplementedError(READ_ONLY_MSG)
|
|
132
|
+
|
|
133
|
+
def format(self) -> None:
|
|
134
|
+
"""Not supported (read-only storage)."""
|
|
135
|
+
raise NotImplementedError(READ_ONLY_MSG)
|
|
136
|
+
|
|
137
|
+
def mkdir(self, path: str) -> None:
|
|
138
|
+
"""Not supported (read-only storage)."""
|
|
139
|
+
raise NotImplementedError(READ_ONLY_MSG)
|
|
140
|
+
|
|
141
|
+
def rmdir(self, path: str) -> None:
|
|
142
|
+
"""Not supported (read-only storage)."""
|
|
143
|
+
raise NotImplementedError(READ_ONLY_MSG)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class WriteOnlyStorageMixin:
|
|
147
|
+
"""Default `NotImplementedError` stubs for list/get/exists/copy/move/mkdir.
|
|
148
|
+
|
|
149
|
+
Backends inheriting this mixin (e.g. `StorageNull`) only need to
|
|
150
|
+
implement the write-side methods (`put`, `put_stream`, `delete`,
|
|
151
|
+
`add_metadata`, `delete_metadata`, `delete_metadata_fields`,
|
|
152
|
+
`format`, `rmdir`).
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def list(self) -> builtins.list[str]:
|
|
156
|
+
"""Not supported (write-only storage)."""
|
|
157
|
+
raise NotImplementedError(WRITE_ONLY_MSG)
|
|
158
|
+
|
|
159
|
+
def get(self, path: str) -> bytes:
|
|
160
|
+
"""Not supported (write-only storage)."""
|
|
161
|
+
raise NotImplementedError(WRITE_ONLY_MSG)
|
|
162
|
+
|
|
163
|
+
def get_metadata(self, path: str) -> dict[str, Any]:
|
|
164
|
+
"""Not supported (write-only storage)."""
|
|
165
|
+
raise NotImplementedError(WRITE_ONLY_MSG)
|
|
166
|
+
|
|
167
|
+
def exists(self, path: str) -> bool:
|
|
168
|
+
"""Not supported (write-only storage)."""
|
|
169
|
+
raise NotImplementedError(WRITE_ONLY_MSG)
|
|
170
|
+
|
|
171
|
+
def get_stream(self, path: str) -> IO[bytes]:
|
|
172
|
+
"""Not supported (write-only storage)."""
|
|
173
|
+
raise NotImplementedError(WRITE_ONLY_MSG)
|
|
174
|
+
|
|
175
|
+
def copy(self, path_src: str, path_dst: str) -> None:
|
|
176
|
+
"""Not supported (write-only storage)."""
|
|
177
|
+
raise NotImplementedError(WRITE_ONLY_MSG)
|
|
178
|
+
|
|
179
|
+
def move(self, path_src: str, path_dst: str) -> None:
|
|
180
|
+
"""Not supported (write-only storage)."""
|
|
181
|
+
raise NotImplementedError(WRITE_ONLY_MSG)
|
|
182
|
+
|
|
183
|
+
def mkdir(self, path: str) -> None:
|
|
184
|
+
"""Not supported (write-only storage)."""
|
|
185
|
+
raise NotImplementedError(WRITE_ONLY_MSG)
|
txstore/_env.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Read ``TX_STORE_*`` environment variables.
|
|
2
|
+
|
|
3
|
+
The only module that reads `os.environ`. Each helper returns one variable's parsed value, or None
|
|
4
|
+
when it is unset or empty; callers resolve an explicit argument first, then the variable, then
|
|
5
|
+
their default. Names → [CONFIG.md](CONFIG.md#environment-variables); grammar →
|
|
6
|
+
[VALIDATION.md](VALIDATION.md#environment-values).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
ENV_PREFIX = "TX_STORE_"
|
|
15
|
+
|
|
16
|
+
_TRUE = frozenset({"1", "true", "yes", "on"})
|
|
17
|
+
_FALSE = frozenset({"0", "false", "no", "off"})
|
|
18
|
+
# `int()` also takes `1_0` and non-ASCII digits; the grammar is plain decimal → VALIDATION.md.
|
|
19
|
+
_INTEGER_RE = re.compile(r"[+-]?[0-9]+", re.ASCII)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _raw(name: str) -> str | None:
|
|
23
|
+
"""Return the value of variable `name`, or None when unset or empty.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ValueError: If `name` lacks the ``TX_STORE_`` prefix (a programming error).
|
|
27
|
+
"""
|
|
28
|
+
if not name.startswith(ENV_PREFIX):
|
|
29
|
+
raise ValueError(f"environment variable {name!r} lacks the {ENV_PREFIX} prefix")
|
|
30
|
+
value = os.environ.get(name)
|
|
31
|
+
return value if value else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def env_str(name: str) -> str | None:
|
|
35
|
+
"""Return the string value of `name`, or None when unset or empty."""
|
|
36
|
+
return _raw(name)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def env_bool(name: str) -> bool | None:
|
|
40
|
+
"""Return the boolean value of `name`, or None when unset or empty.
|
|
41
|
+
|
|
42
|
+
Accepts ``1/0``, ``true/false``, ``yes/no``, and ``on/off`` in any case.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ValueError: If the value is outside that vocabulary; the message names the variable.
|
|
46
|
+
"""
|
|
47
|
+
value = _raw(name)
|
|
48
|
+
if value is None:
|
|
49
|
+
return None
|
|
50
|
+
folded = value.strip().lower()
|
|
51
|
+
if folded in _TRUE:
|
|
52
|
+
return True
|
|
53
|
+
if folded in _FALSE:
|
|
54
|
+
return False
|
|
55
|
+
raise ValueError(f"{name}: expected a boolean (1/0, true/false, yes/no, on/off), got {value!r}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def env_int(name: str) -> int | None:
|
|
59
|
+
"""Return the integer value of `name`, or None when unset or empty.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If the value is not a decimal integer; the message names the variable.
|
|
63
|
+
"""
|
|
64
|
+
value = _raw(name)
|
|
65
|
+
if value is None:
|
|
66
|
+
return None
|
|
67
|
+
stripped = value.strip()
|
|
68
|
+
if not _INTEGER_RE.fullmatch(stripped):
|
|
69
|
+
raise ValueError(f"{name}: expected an integer, got {value!r}")
|
|
70
|
+
return int(stripped, 10)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Binary encoding of a footmark.
|
|
2
|
+
|
|
3
|
+
A footmark is the protocol-buffers message in
|
|
4
|
+
[docs/reference/footmark.proto](docs/reference/footmark.proto); this module encodes and
|
|
5
|
+
decodes it in the protobuf wire format without a protobuf runtime, so footmark objects stay
|
|
6
|
+
byte-compatible with any protobuf implementation → [STORAGE.md](STORAGE.md#footmarks).
|
|
7
|
+
|
|
8
|
+
Decoding follows proto3: absent fields are zero, the last scalar occurrence wins, repeated
|
|
9
|
+
timestamp fields merge, unknown fields and fields of an unexpected wire type are skipped. Group
|
|
10
|
+
wire types are rejected.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import NamedTuple, cast
|
|
16
|
+
|
|
17
|
+
_VARINT = 0
|
|
18
|
+
_FIXED64 = 1
|
|
19
|
+
_LENGTH = 2
|
|
20
|
+
_FIXED32 = 5
|
|
21
|
+
|
|
22
|
+
_HEADER = 1
|
|
23
|
+
_TIMESTAMP = 2
|
|
24
|
+
_TRANSACTION_ID = 3
|
|
25
|
+
_OPERATION = 4
|
|
26
|
+
|
|
27
|
+
_TS_SECONDS = 1
|
|
28
|
+
_TS_NANOS = 2
|
|
29
|
+
|
|
30
|
+
_MASK64 = (1 << 64) - 1
|
|
31
|
+
_MAX_VARINT_BYTES = 10
|
|
32
|
+
# A tag is a uint32 in at most 5 varint bytes.
|
|
33
|
+
_MAX_TAG_BYTES = 5
|
|
34
|
+
_MAX_TAG = 0xFFFF_FFFF
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FootmarkFields(NamedTuple):
|
|
38
|
+
"""Decoded footmark fields; absent fields are zero.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
header: Format marker.
|
|
42
|
+
seconds: Begin timestamp seconds since the Unix epoch.
|
|
43
|
+
nanos: Begin timestamp nanoseconds.
|
|
44
|
+
transaction_id: Transaction identifier.
|
|
45
|
+
operation: Operation code.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
header: int = 0
|
|
49
|
+
seconds: int = 0
|
|
50
|
+
nanos: int = 0
|
|
51
|
+
transaction_id: int = 0
|
|
52
|
+
operation: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def encode(fields: FootmarkFields) -> bytes:
|
|
56
|
+
"""Encode `fields`, always emitting the timestamp and omitting other zero fields.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
fields: Footmark values; integers must fit their protobuf types.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The serialized message.
|
|
63
|
+
"""
|
|
64
|
+
timestamp = _scalar(_TS_SECONDS, fields.seconds) + _scalar(_TS_NANOS, fields.nanos)
|
|
65
|
+
return b"".join(
|
|
66
|
+
(
|
|
67
|
+
_scalar(_HEADER, fields.header),
|
|
68
|
+
_tag(_TIMESTAMP, _LENGTH) + _varint(len(timestamp)) + timestamp,
|
|
69
|
+
_scalar(_TRANSACTION_ID, fields.transaction_id),
|
|
70
|
+
_scalar(_OPERATION, fields.operation),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def decode(data: bytes) -> FootmarkFields:
|
|
76
|
+
"""Decode a serialized footmark.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
data: The serialized message.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The decoded fields.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ValueError: If `data` is truncated, has a tag above 32 bits or field number 0, uses a
|
|
86
|
+
group or an invalid wire type, or has a value varint longer than 10 bytes.
|
|
87
|
+
"""
|
|
88
|
+
header = transaction_id = operation = seconds = nanos = 0
|
|
89
|
+
for field, wire_type, value in _fields(data):
|
|
90
|
+
if wire_type == _VARINT and field == _HEADER:
|
|
91
|
+
header = _int32(cast(int, value))
|
|
92
|
+
elif wire_type == _VARINT and field == _TRANSACTION_ID:
|
|
93
|
+
transaction_id = _int32(cast(int, value))
|
|
94
|
+
elif wire_type == _VARINT and field == _OPERATION:
|
|
95
|
+
operation = _int32(cast(int, value))
|
|
96
|
+
elif wire_type == _LENGTH and field == _TIMESTAMP:
|
|
97
|
+
# `_fields` yields bytes for a length-delimited value and an int for a varint.
|
|
98
|
+
for ts_field, ts_wire_type, ts_value in _fields(cast(bytes, value)):
|
|
99
|
+
if ts_wire_type == _VARINT and ts_field == _TS_SECONDS:
|
|
100
|
+
seconds = _int64(cast(int, ts_value))
|
|
101
|
+
elif ts_wire_type == _VARINT and ts_field == _TS_NANOS:
|
|
102
|
+
nanos = _int32(cast(int, ts_value))
|
|
103
|
+
return FootmarkFields(header, seconds, nanos, transaction_id, operation)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _fields(data: bytes) -> list[tuple[int, int, int | bytes]]:
|
|
107
|
+
"""Split `data` into ``(field, wire_type, value)``; varints stay unsigned 64-bit."""
|
|
108
|
+
out: list[tuple[int, int, int | bytes]] = []
|
|
109
|
+
pos = 0
|
|
110
|
+
while pos < len(data):
|
|
111
|
+
key, pos = _read_varint(data, pos, _MAX_TAG_BYTES)
|
|
112
|
+
if key > _MAX_TAG:
|
|
113
|
+
raise ValueError("footmark tag above 32 bits")
|
|
114
|
+
field, wire_type = key >> 3, key & 7
|
|
115
|
+
if field == 0:
|
|
116
|
+
raise ValueError("footmark field number 0")
|
|
117
|
+
value: int | bytes
|
|
118
|
+
if wire_type == _VARINT:
|
|
119
|
+
value, pos = _read_varint(data, pos)
|
|
120
|
+
elif wire_type == _LENGTH:
|
|
121
|
+
size, pos = _read_varint(data, pos)
|
|
122
|
+
value, pos = _take(data, pos, size)
|
|
123
|
+
elif wire_type == _FIXED64:
|
|
124
|
+
value, pos = _take(data, pos, 8)
|
|
125
|
+
elif wire_type == _FIXED32:
|
|
126
|
+
value, pos = _take(data, pos, 4)
|
|
127
|
+
else:
|
|
128
|
+
raise ValueError(f"footmark wire type {wire_type} not supported")
|
|
129
|
+
out.append((field, wire_type, value))
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _take(data: bytes, pos: int, size: int) -> tuple[bytes, int]:
|
|
134
|
+
end = pos + size
|
|
135
|
+
if end > len(data):
|
|
136
|
+
raise ValueError("footmark truncated")
|
|
137
|
+
return data[pos:end], end
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _read_varint(data: bytes, pos: int, max_bytes: int = _MAX_VARINT_BYTES) -> tuple[int, int]:
|
|
141
|
+
result = 0
|
|
142
|
+
for index in range(max_bytes):
|
|
143
|
+
if pos >= len(data):
|
|
144
|
+
raise ValueError("footmark truncated inside a varint")
|
|
145
|
+
byte = data[pos]
|
|
146
|
+
pos += 1
|
|
147
|
+
result |= (byte & 0x7F) << (7 * index)
|
|
148
|
+
if not byte & 0x80:
|
|
149
|
+
return result & _MASK64, pos
|
|
150
|
+
raise ValueError(f"footmark varint longer than {max_bytes} bytes")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _int32(value: int) -> int:
|
|
154
|
+
value &= 0xFFFF_FFFF
|
|
155
|
+
return value - (1 << 32) if value >= 1 << 31 else value
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _int64(value: int) -> int:
|
|
159
|
+
return value - (1 << 64) if value >= 1 << 63 else value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _tag(field: int, wire_type: int) -> bytes:
|
|
163
|
+
return _varint(field << 3 | wire_type)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _scalar(field: int, value: int) -> bytes:
|
|
167
|
+
"""Encode a proto3 integer field; zero is omitted, negatives take 10 bytes."""
|
|
168
|
+
if value == 0:
|
|
169
|
+
return b""
|
|
170
|
+
return _tag(field, _VARINT) + _varint(value & _MASK64)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _varint(value: int) -> bytes:
|
|
174
|
+
out = bytearray()
|
|
175
|
+
while True:
|
|
176
|
+
byte = value & 0x7F
|
|
177
|
+
value >>= 7
|
|
178
|
+
if value:
|
|
179
|
+
out.append(byte | 0x80)
|
|
180
|
+
else:
|
|
181
|
+
out.append(byte)
|
|
182
|
+
return bytes(out)
|