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 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
+ ]
@@ -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)