truhe 1.0.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.
- truhe/__init__.py +9 -0
- truhe/_s3.py +118 -0
- truhe/aio/__init__.py +2 -0
- truhe/aio/s3.py +148 -0
- truhe/aio/sealed.py +89 -0
- truhe/aio/store.py +116 -0
- truhe/filesystem.py +116 -0
- truhe/format.py +361 -0
- truhe/s3.py +163 -0
- truhe/sealed.py +83 -0
- truhe/sigv4.py +177 -0
- truhe/store.py +184 -0
- truhe/tests/__init__.py +132 -0
- truhe/tests/test_aio.py +316 -0
- truhe/tests/test_format.py +228 -0
- truhe/tests/test_s3.py +309 -0
- truhe/tests/test_sealed.py +189 -0
- truhe/tests/test_sigv4.py +169 -0
- truhe/tests/test_store.py +137 -0
- truhe-1.0.0.dist-info/METADATA +298 -0
- truhe-1.0.0.dist-info/RECORD +23 -0
- truhe-1.0.0.dist-info/WHEEL +4 -0
- truhe-1.0.0.dist-info/licenses/LICENSE.rst +24 -0
truhe/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from truhe.filesystem import FileSystemBlobStore # noqa
|
|
2
|
+
from truhe.format import EncryptionError # noqa
|
|
3
|
+
from truhe.format import Keyring # noqa
|
|
4
|
+
from truhe.sealed import EncryptedBlobStore # noqa
|
|
5
|
+
from truhe.store import BlobStoreError # noqa
|
|
6
|
+
from truhe.store import BlobStream # noqa
|
|
7
|
+
from truhe.store import ListedBlob # noqa
|
|
8
|
+
from truhe.store import MemoryBlobStore # noqa
|
|
9
|
+
from truhe.store import StoredBlob # noqa
|
truhe/_s3.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""What the synchronous and the asynchronous S3 store share: addresses, the
|
|
2
|
+
listing, the reason of a refusal. No I/O."""
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from datetime import timezone
|
|
7
|
+
from truhe.sigv4 import UNRESERVED
|
|
8
|
+
from truhe.store import ListedBlob
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
from urllib.parse import urlencode
|
|
11
|
+
from xml.etree import ElementTree
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Bucket:
|
|
18
|
+
"""Where a bucket is.
|
|
19
|
+
|
|
20
|
+
**Virtual hosted** by default (``{bucket}.{endpoint host}``), the form
|
|
21
|
+
providers document, and the bucket is then in the host name, which is
|
|
22
|
+
signed. ``path_style`` puts it in the path (``{endpoint}/{bucket}``) for
|
|
23
|
+
stores which do not resolve bucket host names, e.g. MinIO or Garage
|
|
24
|
+
without a domain.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
endpoint: str
|
|
28
|
+
name: str
|
|
29
|
+
path_style: bool = False
|
|
30
|
+
|
|
31
|
+
def _scheme_host(self):
|
|
32
|
+
scheme = 'http' if self.endpoint.startswith('http://') else 'https'
|
|
33
|
+
host = self.endpoint.removeprefix('https://').removeprefix('http://')
|
|
34
|
+
return scheme, host.rstrip('/')
|
|
35
|
+
|
|
36
|
+
def url(self):
|
|
37
|
+
scheme, host = self._scheme_host()
|
|
38
|
+
if self.path_style:
|
|
39
|
+
return f'{scheme}://{host}/{self.name}'
|
|
40
|
+
return f'{scheme}://{self.name}.{host}/'
|
|
41
|
+
|
|
42
|
+
def object_url(self, key):
|
|
43
|
+
"""The object's URL. The key is quoted per segment, ``/`` stays."""
|
|
44
|
+
scheme, host = self._scheme_host()
|
|
45
|
+
quoted = quote(key, safe='/' + UNRESERVED)
|
|
46
|
+
if self.path_style:
|
|
47
|
+
return f'{scheme}://{host}/{self.name}/{quoted}'
|
|
48
|
+
return f'{scheme}://{self.name}.{host}/{quoted}'
|
|
49
|
+
|
|
50
|
+
def list_url(self, prefix, token=None):
|
|
51
|
+
"""``ListObjectsV2`` for ``prefix``, from ``token`` on."""
|
|
52
|
+
query = {'list-type': '2', 'prefix': prefix}
|
|
53
|
+
if token:
|
|
54
|
+
query['continuation-token'] = token
|
|
55
|
+
return self.url() + '?' + urlencode(query, quote_via=quote, safe=UNRESERVED)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def content_md5(data):
|
|
59
|
+
return base64.b64encode(hashlib.md5(data).digest()).decode()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def payload_sha256(data):
|
|
63
|
+
return hashlib.sha256(data).hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _local(tag):
|
|
67
|
+
return tag.rsplit('}', 1)[-1]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _children(element, name):
|
|
71
|
+
return [child for child in element if _local(child.tag) == name]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _text(element, name):
|
|
75
|
+
found = _children(element, name)
|
|
76
|
+
return found[0].text if found else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _timestamp(value):
|
|
80
|
+
# S3 writes ``2026-10-07T10:00:00.000Z``, ``fromisoformat`` before Python
|
|
81
|
+
# 3.11 does not read the ``Z``.
|
|
82
|
+
return datetime.fromisoformat(value.replace('Z', '+00:00')).astimezone(timezone.utc)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_listing(data):
|
|
86
|
+
"""The entries of one page of a listing, and the token of the next page or
|
|
87
|
+
None."""
|
|
88
|
+
root = ElementTree.fromstring(data)
|
|
89
|
+
blobs = [
|
|
90
|
+
ListedBlob(
|
|
91
|
+
key=_text(entry, 'Key'),
|
|
92
|
+
size=int(_text(entry, 'Size')),
|
|
93
|
+
modified=_timestamp(_text(entry, 'LastModified')),
|
|
94
|
+
)
|
|
95
|
+
for entry in _children(root, 'Contents')
|
|
96
|
+
]
|
|
97
|
+
if _text(root, 'IsTruncated') != 'true':
|
|
98
|
+
return blobs, None
|
|
99
|
+
return blobs, _text(root, 'NextContinuationToken')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def reason(method, key, status, body):
|
|
103
|
+
"""A message which names the cause and not just the number.
|
|
104
|
+
|
|
105
|
+
S3 puts the reason in an XML body. A bare 403 sends the reader looking at
|
|
106
|
+
credentials when the answer was ``SignatureDoesNotMatch``.
|
|
107
|
+
"""
|
|
108
|
+
detail = ''
|
|
109
|
+
try:
|
|
110
|
+
root = ElementTree.fromstring(body or b'')
|
|
111
|
+
except ElementTree.ParseError:
|
|
112
|
+
root = None
|
|
113
|
+
if root is not None:
|
|
114
|
+
for name in ('Code', 'Message'):
|
|
115
|
+
text = root.findtext(name)
|
|
116
|
+
if text:
|
|
117
|
+
detail += f' {text}'
|
|
118
|
+
return f'{method} {key}: {status}{detail}'
|
truhe/aio/__init__.py
ADDED
truhe/aio/s3.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""The asynchronous blob store against an S3 compatible store, over ``httpx``.
|
|
2
|
+
|
|
3
|
+
Install with ``truhe[httpx]``. The requests of :mod:`truhe.s3`: bytes signed
|
|
4
|
+
with ``Content-MD5``, streams with ``UNSIGNED-PAYLOAD`` and their
|
|
5
|
+
``Content-Length``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from datetime import timezone
|
|
12
|
+
from truhe._s3 import Bucket
|
|
13
|
+
from truhe._s3 import content_md5
|
|
14
|
+
from truhe._s3 import parse_listing
|
|
15
|
+
from truhe._s3 import payload_sha256
|
|
16
|
+
from truhe._s3 import reason
|
|
17
|
+
from truhe.aio.store import counted
|
|
18
|
+
from truhe.sigv4 import Credentials
|
|
19
|
+
from truhe.sigv4 import EMPTY_PAYLOAD_SHA256
|
|
20
|
+
from truhe.sigv4 import signed_headers
|
|
21
|
+
from truhe.sigv4 import UNSIGNED_PAYLOAD
|
|
22
|
+
from truhe.store import BlobStoreError
|
|
23
|
+
from truhe.store import BlobStream
|
|
24
|
+
from truhe.store import checked_length
|
|
25
|
+
from truhe.store import is_bytes
|
|
26
|
+
from truhe.store import READ_BYTES
|
|
27
|
+
from truhe.store import StoredBlob
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class S3BlobStore:
|
|
33
|
+
"""Signed access to one bucket.
|
|
34
|
+
|
|
35
|
+
``client`` is passed in, so the caller owns its lifetime. A client per
|
|
36
|
+
request would throw away connection reuse.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
credentials: Credentials
|
|
40
|
+
endpoint: str
|
|
41
|
+
bucket: str
|
|
42
|
+
client: httpx.AsyncClient
|
|
43
|
+
path_style: bool = False
|
|
44
|
+
|
|
45
|
+
def __post_init__(self):
|
|
46
|
+
self._bucket = Bucket(self.endpoint, self.bucket, self.path_style)
|
|
47
|
+
|
|
48
|
+
def url_for(self, key):
|
|
49
|
+
return self._bucket.object_url(key)
|
|
50
|
+
|
|
51
|
+
def _signed(self, method, url, payload_hash=EMPTY_PAYLOAD_SHA256, headers=None):
|
|
52
|
+
return signed_headers(
|
|
53
|
+
credentials=self.credentials,
|
|
54
|
+
method=method,
|
|
55
|
+
url=url,
|
|
56
|
+
headers=headers,
|
|
57
|
+
payload_hash=payload_hash,
|
|
58
|
+
now=datetime.now(timezone.utc),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def put(self, key, data, content_type, content_length=None):
|
|
62
|
+
length = checked_length(data, content_length)
|
|
63
|
+
url = self.url_for(key)
|
|
64
|
+
headers = {'Content-Type': content_type}
|
|
65
|
+
if is_bytes(data):
|
|
66
|
+
data = bytes(data)
|
|
67
|
+
headers['Content-MD5'] = content_md5(data)
|
|
68
|
+
signed = self._signed('PUT', url, payload_sha256(data), headers)
|
|
69
|
+
content = data
|
|
70
|
+
else:
|
|
71
|
+
# httpx leaves out the chunked transfer encoding when the
|
|
72
|
+
# Content-Length is set, S3 refuses it without its own framing.
|
|
73
|
+
headers['Content-Length'] = str(length)
|
|
74
|
+
signed = self._signed('PUT', url, UNSIGNED_PAYLOAD, headers)
|
|
75
|
+
content = counted(data, length)
|
|
76
|
+
response = await self.client.request('PUT', url, headers=signed, content=content)
|
|
77
|
+
if response.status_code >= 400:
|
|
78
|
+
raise BlobStoreError(
|
|
79
|
+
reason('PUT', key, response.status_code, response.content)
|
|
80
|
+
)
|
|
81
|
+
return StoredBlob(size=length, content_type=content_type)
|
|
82
|
+
|
|
83
|
+
@asynccontextmanager
|
|
84
|
+
async def open(self, key):
|
|
85
|
+
url = self.url_for(key)
|
|
86
|
+
request = self.client.build_request('GET', url, headers=self._signed('GET', url))
|
|
87
|
+
response = await self.client.send(request, stream=True)
|
|
88
|
+
try:
|
|
89
|
+
if response.status_code == 404:
|
|
90
|
+
raise KeyError(key)
|
|
91
|
+
if response.status_code >= 400:
|
|
92
|
+
await response.aread()
|
|
93
|
+
raise BlobStoreError(
|
|
94
|
+
reason('GET', key, response.status_code, response.content)
|
|
95
|
+
)
|
|
96
|
+
yield BlobStream(
|
|
97
|
+
meta=StoredBlob(
|
|
98
|
+
size=int(response.headers.get('content-length', 0)),
|
|
99
|
+
content_type=response.headers.get('content-type'),
|
|
100
|
+
),
|
|
101
|
+
chunks=response.aiter_bytes(READ_BYTES),
|
|
102
|
+
)
|
|
103
|
+
finally:
|
|
104
|
+
# Always: an abandoned streaming response holds its connection out
|
|
105
|
+
# of the pool until the client is closed.
|
|
106
|
+
await response.aclose()
|
|
107
|
+
|
|
108
|
+
async def head(self, key):
|
|
109
|
+
url = self.url_for(key)
|
|
110
|
+
response = await self.client.request(
|
|
111
|
+
'HEAD', url, headers=self._signed('HEAD', url)
|
|
112
|
+
)
|
|
113
|
+
if response.status_code == 404:
|
|
114
|
+
return None
|
|
115
|
+
if response.status_code >= 400:
|
|
116
|
+
raise BlobStoreError(reason('HEAD', key, response.status_code, b''))
|
|
117
|
+
return StoredBlob(
|
|
118
|
+
size=int(response.headers.get('content-length', 0)),
|
|
119
|
+
content_type=response.headers.get('content-type'),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def delete(self, key):
|
|
123
|
+
url = self.url_for(key)
|
|
124
|
+
response = await self.client.request(
|
|
125
|
+
'DELETE', url, headers=self._signed('DELETE', url)
|
|
126
|
+
)
|
|
127
|
+
if response.status_code >= 400 and response.status_code != 404:
|
|
128
|
+
raise BlobStoreError(
|
|
129
|
+
reason('DELETE', key, response.status_code, response.content)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
async def list(self, prefix):
|
|
133
|
+
"""``ListObjectsV2``, page by page along the continuation token."""
|
|
134
|
+
token = None
|
|
135
|
+
while True:
|
|
136
|
+
url = self._bucket.list_url(prefix, token)
|
|
137
|
+
response = await self.client.request(
|
|
138
|
+
'GET', url, headers=self._signed('GET', url)
|
|
139
|
+
)
|
|
140
|
+
if response.status_code >= 400:
|
|
141
|
+
raise BlobStoreError(
|
|
142
|
+
reason('LIST', prefix, response.status_code, response.content)
|
|
143
|
+
)
|
|
144
|
+
blobs, token = parse_listing(response.content)
|
|
145
|
+
for blob in blobs:
|
|
146
|
+
yield blob
|
|
147
|
+
if token is None:
|
|
148
|
+
return
|
truhe/aio/sealed.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The asynchronous blob store which seals on the way in and opens on the way
|
|
2
|
+
out. The format is :mod:`truhe.format`, the behaviour the one of
|
|
3
|
+
:class:`truhe.sealed.EncryptedBlobStore`."""
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from truhe.format import ciphertext_length
|
|
8
|
+
from truhe.format import DEFAULT_MAGIC
|
|
9
|
+
from truhe.format import Keyring
|
|
10
|
+
from truhe.format import Opener
|
|
11
|
+
from truhe.format import plaintext_length
|
|
12
|
+
from truhe.format import seal
|
|
13
|
+
from truhe.format import Sealer
|
|
14
|
+
from truhe.store import BlobStream
|
|
15
|
+
from truhe.store import checked_length
|
|
16
|
+
from truhe.store import is_bytes
|
|
17
|
+
from truhe.store import ListedBlob
|
|
18
|
+
from truhe.store import StoredBlob
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def sealed(keyring, chunks, content_length, magic=DEFAULT_MAGIC):
|
|
22
|
+
"""Iterate the stored form of the async ``chunks``."""
|
|
23
|
+
sealer = Sealer(keyring, content_length, magic=magic)
|
|
24
|
+
yield sealer.header
|
|
25
|
+
async for piece in chunks:
|
|
26
|
+
for block in sealer.feed(piece):
|
|
27
|
+
yield block
|
|
28
|
+
for block in sealer.finish():
|
|
29
|
+
yield block
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def unsealed(keyring, key, chunks, magic=DEFAULT_MAGIC):
|
|
33
|
+
"""Iterate the plaintext of a stored object from the async ``chunks``."""
|
|
34
|
+
opener = Opener(keyring, key, magic=magic)
|
|
35
|
+
async for piece in chunks:
|
|
36
|
+
for plain in opener.feed(piece):
|
|
37
|
+
yield plain
|
|
38
|
+
for plain in opener.finish():
|
|
39
|
+
yield plain
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class EncryptedBlobStore:
|
|
44
|
+
"""Encrypts what goes into the asynchronous ``inner`` with ``keyring``."""
|
|
45
|
+
|
|
46
|
+
inner: object
|
|
47
|
+
keyring: Keyring
|
|
48
|
+
magic: bytes = DEFAULT_MAGIC
|
|
49
|
+
|
|
50
|
+
async def put(self, key, data, content_type, content_length=None):
|
|
51
|
+
length = checked_length(data, content_length)
|
|
52
|
+
if is_bytes(data):
|
|
53
|
+
await self.inner.put(key, seal(self.keyring, data, self.magic), content_type)
|
|
54
|
+
else:
|
|
55
|
+
await self.inner.put(
|
|
56
|
+
key,
|
|
57
|
+
sealed(self.keyring, data, length, self.magic),
|
|
58
|
+
content_type,
|
|
59
|
+
ciphertext_length(length),
|
|
60
|
+
)
|
|
61
|
+
return StoredBlob(size=length, content_type=content_type)
|
|
62
|
+
|
|
63
|
+
@asynccontextmanager
|
|
64
|
+
async def open(self, key):
|
|
65
|
+
async with self.inner.open(key) as stream:
|
|
66
|
+
yield BlobStream(
|
|
67
|
+
meta=StoredBlob(
|
|
68
|
+
size=plaintext_length(stream.meta.size),
|
|
69
|
+
content_type=stream.meta.content_type,
|
|
70
|
+
),
|
|
71
|
+
chunks=unsealed(self.keyring, key, stream.chunks, self.magic),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def head(self, key):
|
|
75
|
+
stored = await self.inner.head(key)
|
|
76
|
+
if stored is None:
|
|
77
|
+
return None
|
|
78
|
+
return StoredBlob(
|
|
79
|
+
size=plaintext_length(stored.size), content_type=stored.content_type
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def delete(self, key):
|
|
83
|
+
await self.inner.delete(key)
|
|
84
|
+
|
|
85
|
+
async def list(self, prefix):
|
|
86
|
+
async for blob in self.inner.list(prefix):
|
|
87
|
+
yield ListedBlob(
|
|
88
|
+
key=blob.key, size=plaintext_length(blob.size), modified=blob.modified
|
|
89
|
+
)
|
truhe/aio/store.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""The asynchronous blob store interface, and an in-memory store.
|
|
2
|
+
|
|
3
|
+
The operations of :mod:`truhe.store`, as coroutines: ``put`` takes bytes or an
|
|
4
|
+
async iterable of bytes, ``open`` is an async context manager whose stream
|
|
5
|
+
yields asynchronously, ``list`` is an async iterator. The data classes and
|
|
6
|
+
errors are the synchronous ones.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from datetime import timezone
|
|
14
|
+
from truhe.store import BlobStoreError
|
|
15
|
+
from truhe.store import BlobStream
|
|
16
|
+
from truhe.store import checked_length
|
|
17
|
+
from truhe.store import is_bytes
|
|
18
|
+
from truhe.store import ListedBlob
|
|
19
|
+
from truhe.store import READ_BYTES
|
|
20
|
+
from truhe.store import StoredBlob
|
|
21
|
+
from typing import Protocol
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncBlobStore(Protocol):
|
|
25
|
+
async def put(self, key, data, content_type, content_length=None):
|
|
26
|
+
"""Store ``data``, bytes or an async iterable of bytes, under ``key``.
|
|
27
|
+
``content_length`` is required for an iterable."""
|
|
28
|
+
|
|
29
|
+
def open(self, key):
|
|
30
|
+
"""Async context manager yielding a :class:`~truhe.store.BlobStream`
|
|
31
|
+
with async ``chunks``. Raises ``KeyError`` if there is no such
|
|
32
|
+
object."""
|
|
33
|
+
|
|
34
|
+
async def head(self, key):
|
|
35
|
+
""":class:`~truhe.store.StoredBlob`, or None."""
|
|
36
|
+
|
|
37
|
+
async def delete(self, key):
|
|
38
|
+
"""Remove the object. Removing what is not there is no error."""
|
|
39
|
+
|
|
40
|
+
def list(self, prefix):
|
|
41
|
+
"""Async iterator of :class:`~truhe.store.ListedBlob`, in key order."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _too_many(content_length):
|
|
45
|
+
return BlobStoreError(
|
|
46
|
+
f'more than {content_length} bytes to store, {content_length} announced'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def counted(chunks, content_length):
|
|
51
|
+
"""Pass ``chunks`` on and fail as soon as they disagree with
|
|
52
|
+
``content_length``. The piece which completes the announced length is only
|
|
53
|
+
passed on once the stream is known to end there, see
|
|
54
|
+
:func:`truhe.store.counted`."""
|
|
55
|
+
iterator = aiter(chunks)
|
|
56
|
+
seen = 0
|
|
57
|
+
async for chunk in iterator:
|
|
58
|
+
seen += len(chunk)
|
|
59
|
+
if seen > content_length:
|
|
60
|
+
raise _too_many(content_length)
|
|
61
|
+
if seen == content_length:
|
|
62
|
+
async for extra in iterator:
|
|
63
|
+
if extra:
|
|
64
|
+
raise _too_many(content_length)
|
|
65
|
+
yield chunk
|
|
66
|
+
if seen != content_length:
|
|
67
|
+
raise BlobStoreError(f'{seen} bytes to store, {content_length} announced')
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class MemoryBlobStore:
|
|
72
|
+
"""Keeps objects in a dict. For tests, see the synchronous one."""
|
|
73
|
+
|
|
74
|
+
objects: dict = field(default_factory=dict)
|
|
75
|
+
content_types: dict = field(default_factory=dict)
|
|
76
|
+
modified: dict = field(default_factory=dict)
|
|
77
|
+
read_size: int = READ_BYTES
|
|
78
|
+
|
|
79
|
+
async def put(self, key, data, content_type, content_length=None):
|
|
80
|
+
length = checked_length(data, content_length)
|
|
81
|
+
if not is_bytes(data):
|
|
82
|
+
data = b''.join([chunk async for chunk in counted(data, length)])
|
|
83
|
+
self.objects[key] = bytes(data)
|
|
84
|
+
self.content_types[key] = content_type
|
|
85
|
+
self.modified[key] = datetime.now(timezone.utc)
|
|
86
|
+
return StoredBlob(size=length, content_type=content_type)
|
|
87
|
+
|
|
88
|
+
def _meta(self, key):
|
|
89
|
+
return StoredBlob(
|
|
90
|
+
size=len(self.objects[key]), content_type=self.content_types.get(key)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@asynccontextmanager
|
|
94
|
+
async def open(self, key):
|
|
95
|
+
data = self.objects[key]
|
|
96
|
+
|
|
97
|
+
async def chunks():
|
|
98
|
+
for i in range(0, len(data), self.read_size):
|
|
99
|
+
yield data[i : i + self.read_size]
|
|
100
|
+
|
|
101
|
+
yield BlobStream(meta=self._meta(key), chunks=chunks())
|
|
102
|
+
|
|
103
|
+
async def head(self, key):
|
|
104
|
+
return self._meta(key) if key in self.objects else None
|
|
105
|
+
|
|
106
|
+
async def delete(self, key):
|
|
107
|
+
self.objects.pop(key, None)
|
|
108
|
+
self.content_types.pop(key, None)
|
|
109
|
+
self.modified.pop(key, None)
|
|
110
|
+
|
|
111
|
+
async def list(self, prefix):
|
|
112
|
+
for key in sorted(self.objects):
|
|
113
|
+
if key.startswith(prefix):
|
|
114
|
+
yield ListedBlob(
|
|
115
|
+
key=key, size=len(self.objects[key]), modified=self.modified[key]
|
|
116
|
+
)
|
truhe/filesystem.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""A blob store on the local filesystem.
|
|
2
|
+
|
|
3
|
+
The path is the key below the root. For development, and in operation where a
|
|
4
|
+
local or mounted directory is the store. Wrapped in
|
|
5
|
+
:class:`~truhe.sealed.EncryptedBlobStore` like every store, what lies there is
|
|
6
|
+
ciphertext.
|
|
7
|
+
|
|
8
|
+
**Writes are atomic**: a temporary file next to the target, renamed into place.
|
|
9
|
+
A crash leaves the old state or the new one, never half an object. Temporary
|
|
10
|
+
files start with a dot and are never listed.
|
|
11
|
+
|
|
12
|
+
**A key never leaves the root.** Segments which are empty, ``.`` or ``..`` are
|
|
13
|
+
refused, this is the one backend in which a bad key reaches a filesystem path.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from datetime import timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from truhe.store import BlobStoreError
|
|
22
|
+
from truhe.store import BlobStream
|
|
23
|
+
from truhe.store import checked_length
|
|
24
|
+
from truhe.store import counted
|
|
25
|
+
from truhe.store import is_bytes
|
|
26
|
+
from truhe.store import ListedBlob
|
|
27
|
+
from truhe.store import READ_BYTES
|
|
28
|
+
from truhe.store import StoredBlob
|
|
29
|
+
import os
|
|
30
|
+
import tempfile
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
TEMP_SUFFIX = '.tmp-'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class FileSystemBlobStore:
|
|
38
|
+
root: Path
|
|
39
|
+
|
|
40
|
+
def __post_init__(self):
|
|
41
|
+
self.root = Path(self.root)
|
|
42
|
+
|
|
43
|
+
def _path(self, key):
|
|
44
|
+
segments = key.split('/')
|
|
45
|
+
if key.startswith('/') or any(s in ('', '.', '..') for s in segments):
|
|
46
|
+
raise BlobStoreError(f'{key!r} is no valid key')
|
|
47
|
+
return self.root.joinpath(*segments)
|
|
48
|
+
|
|
49
|
+
def put(self, key, data, content_type, content_length=None):
|
|
50
|
+
length = checked_length(data, content_length)
|
|
51
|
+
chunks = [data] if is_bytes(data) else counted(data, length)
|
|
52
|
+
path = self._path(key)
|
|
53
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
fd, temp = tempfile.mkstemp(dir=path.parent, prefix=f'.{path.name}{TEMP_SUFFIX}')
|
|
55
|
+
try:
|
|
56
|
+
with os.fdopen(fd, 'wb') as handle:
|
|
57
|
+
for chunk in chunks:
|
|
58
|
+
handle.write(chunk)
|
|
59
|
+
handle.flush()
|
|
60
|
+
os.fsync(handle.fileno())
|
|
61
|
+
os.replace(temp, path)
|
|
62
|
+
except BaseException:
|
|
63
|
+
Path(temp).unlink(missing_ok=True)
|
|
64
|
+
raise
|
|
65
|
+
return StoredBlob(size=length)
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def open(self, key):
|
|
69
|
+
path = self._path(key)
|
|
70
|
+
try:
|
|
71
|
+
handle = path.open('rb')
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
raise KeyError(key) from None
|
|
74
|
+
try:
|
|
75
|
+
size = os.fstat(handle.fileno()).st_size
|
|
76
|
+
yield BlobStream(meta=StoredBlob(size=size), chunks=_read(handle))
|
|
77
|
+
finally:
|
|
78
|
+
handle.close()
|
|
79
|
+
|
|
80
|
+
def head(self, key):
|
|
81
|
+
try:
|
|
82
|
+
return StoredBlob(size=self._path(key).stat().st_size)
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def delete(self, key):
|
|
87
|
+
self._path(key).unlink(missing_ok=True)
|
|
88
|
+
|
|
89
|
+
def list(self, prefix):
|
|
90
|
+
# Walk from the deepest folder the prefix names in full, then filter by
|
|
91
|
+
# the rest, ``org1/`` must not walk ``org10/``.
|
|
92
|
+
folder = prefix.rsplit('/', 1)[0] if '/' in prefix else ''
|
|
93
|
+
start = self._path(folder) if folder else self.root
|
|
94
|
+
found = []
|
|
95
|
+
for dirpath, _, filenames in os.walk(start):
|
|
96
|
+
for name in filenames:
|
|
97
|
+
if name.startswith('.') and TEMP_SUFFIX in name:
|
|
98
|
+
continue
|
|
99
|
+
path = Path(dirpath) / name
|
|
100
|
+
key = path.relative_to(self.root).as_posix()
|
|
101
|
+
if key.startswith(prefix):
|
|
102
|
+
found.append((key, path.stat()))
|
|
103
|
+
for key, stat in sorted(found):
|
|
104
|
+
yield ListedBlob(
|
|
105
|
+
key=key,
|
|
106
|
+
size=stat.st_size,
|
|
107
|
+
modified=datetime.fromtimestamp(stat.st_mtime, timezone.utc),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _read(handle):
|
|
112
|
+
while True:
|
|
113
|
+
chunk = handle.read(READ_BYTES)
|
|
114
|
+
if not chunk:
|
|
115
|
+
return
|
|
116
|
+
yield chunk
|