ondio 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.
- ondio/__init__.py +53 -0
- ondio/_log.py +46 -0
- ondio/backends/aws.py +161 -0
- ondio/backends/gcs.py +159 -0
- ondio/backends/http.py +127 -0
- ondio/backends/local.py +139 -0
- ondio/backends/protocol.py +170 -0
- ondio/dispatcher.py +317 -0
- ondio/flac.py +486 -0
- ondio/parquet.py +89 -0
- ondio/registry.py +48 -0
- ondio/types.py +45 -0
- ondio-0.1.0.dist-info/METADATA +314 -0
- ondio-0.1.0.dist-info/RECORD +16 -0
- ondio-0.1.0.dist-info/WHEEL +4 -0
- ondio-0.1.0.dist-info/licenses/LICENSE.md +13 -0
ondio/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""ondio: uniform IO for audio data across AWS S3, GCS, HTTP, and local files.
|
|
2
|
+
|
|
3
|
+
One URI-addressed interface; the platform is detected from the scheme and the
|
|
4
|
+
call is dispatched to the matching backend. See PROJECT.md for the full design.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ondio.dispatcher import (
|
|
8
|
+
delete,
|
|
9
|
+
download,
|
|
10
|
+
download_flac,
|
|
11
|
+
download_json,
|
|
12
|
+
exists,
|
|
13
|
+
extract_flac_header,
|
|
14
|
+
list_files,
|
|
15
|
+
object_count,
|
|
16
|
+
read,
|
|
17
|
+
read_flac,
|
|
18
|
+
upload,
|
|
19
|
+
write,
|
|
20
|
+
write_json,
|
|
21
|
+
)
|
|
22
|
+
from ondio.registry import detect_platform
|
|
23
|
+
from ondio.types import (
|
|
24
|
+
AuthError,
|
|
25
|
+
FlacHeader,
|
|
26
|
+
ObjectNotFoundError,
|
|
27
|
+
OndioError,
|
|
28
|
+
UnknownPlatformError,
|
|
29
|
+
UnsupportedOperationError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AuthError",
|
|
34
|
+
"FlacHeader",
|
|
35
|
+
"ObjectNotFoundError",
|
|
36
|
+
"OndioError",
|
|
37
|
+
"UnknownPlatformError",
|
|
38
|
+
"UnsupportedOperationError",
|
|
39
|
+
"delete",
|
|
40
|
+
"detect_platform",
|
|
41
|
+
"download",
|
|
42
|
+
"download_flac",
|
|
43
|
+
"download_json",
|
|
44
|
+
"exists",
|
|
45
|
+
"extract_flac_header",
|
|
46
|
+
"list_files",
|
|
47
|
+
"object_count",
|
|
48
|
+
"read",
|
|
49
|
+
"read_flac",
|
|
50
|
+
"upload",
|
|
51
|
+
"write",
|
|
52
|
+
"write_json",
|
|
53
|
+
]
|
ondio/_log.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Lazy, optional logging through cocina's Printer.
|
|
2
|
+
|
|
3
|
+
cocina's `Printer` is a process-wide singleton: whoever constructs it first
|
|
4
|
+
fixes its configuration (log file, silence, ...), and later constructor
|
|
5
|
+
arguments are silently ignored. This module therefore never constructs it at
|
|
6
|
+
import time — the instance is resolved on each attribute access, so an
|
|
7
|
+
application that configures `Printer(...)` before ondio's first log line
|
|
8
|
+
wins. If cocina is not installed, every call is a silent no-op: logging
|
|
9
|
+
narrates, exceptions are the failure signal, so nothing is lost.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from ondio._log import printer
|
|
13
|
+
|
|
14
|
+
printer.message("using byte-range strategy", start_byte=0, end_byte=1024)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from cocina.printer import Printer
|
|
23
|
+
except ImportError:
|
|
24
|
+
Printer = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _noop(*args: Any, **kwargs: Any) -> None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _LazyPrinter:
|
|
32
|
+
"""Forwards attribute access to the Printer singleton (or a no-op).
|
|
33
|
+
|
|
34
|
+
Attribute access returns the singleton's *bound method*, so the call
|
|
35
|
+
itself puts no ondio._log frame on the stack — cocina's automatic
|
|
36
|
+
caller-name headers (`caller_name` skips only cocina frames) keep
|
|
37
|
+
labeling messages with the real calling module.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __getattr__(self, name: str) -> Any:
|
|
41
|
+
if Printer is None:
|
|
42
|
+
return _noop
|
|
43
|
+
return getattr(Printer(), name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
printer = _LazyPrinter()
|
ondio/backends/aws.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""AWS S3 backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from ondio.backends.protocol import _matches_filters
|
|
11
|
+
from ondio.types import AuthError, ObjectNotFoundError, OndioError
|
|
12
|
+
|
|
13
|
+
_NOT_FOUND_CODES = {"404", "NoSuchKey", "NoSuchBucket"}
|
|
14
|
+
_AUTH_CODES = {"403", "AccessDenied", "InvalidAccessKeyId", "SignatureDoesNotMatch", "ExpiredToken"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _split(uri: str) -> tuple[str, str]:
|
|
18
|
+
parsed = urlparse(uri)
|
|
19
|
+
if parsed.scheme != "s3" or not parsed.netloc:
|
|
20
|
+
raise ValueError(f"not an s3:// URI: {uri!r}")
|
|
21
|
+
return parsed.netloc, parsed.path.lstrip("/")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AwsBackend:
|
|
25
|
+
"""StorageBackend for AWS S3 (`s3://bucket/key` URIs), via boto3.
|
|
26
|
+
|
|
27
|
+
See [`StorageBackend`][ondio.backends.StorageBackend] for method
|
|
28
|
+
semantics. Credentials resolve through boto3's native chain; botocore
|
|
29
|
+
errors are translated into ondio's exception types.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
**session_kwargs: Forwarded to `boto3.Session` (e.g. `profile_name`,
|
|
33
|
+
`region_name`).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, **session_kwargs):
|
|
37
|
+
import boto3
|
|
38
|
+
|
|
39
|
+
self._client = boto3.Session(**session_kwargs).client("s3")
|
|
40
|
+
|
|
41
|
+
@contextlib.contextmanager
|
|
42
|
+
def _translate(self, uri: str):
|
|
43
|
+
from botocore.exceptions import ClientError
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
yield
|
|
47
|
+
except ClientError as exc:
|
|
48
|
+
code = exc.response.get("Error", {}).get("Code", "")
|
|
49
|
+
if code in _NOT_FOUND_CODES:
|
|
50
|
+
raise ObjectNotFoundError(f"no such object: {uri}") from exc
|
|
51
|
+
if code in _AUTH_CODES:
|
|
52
|
+
raise AuthError(f"access denied ({code}): {uri}") from exc
|
|
53
|
+
raise OndioError(f"S3 error ({code}) for {uri}") from exc
|
|
54
|
+
|
|
55
|
+
def read(self, uri: str) -> bytes:
|
|
56
|
+
bucket, key = _split(uri)
|
|
57
|
+
with self._translate(uri):
|
|
58
|
+
return self._client.get_object(Bucket=bucket, Key=key)["Body"].read()
|
|
59
|
+
|
|
60
|
+
def read_range(self, uri: str, start_byte: int, end_byte: int | None) -> bytes:
|
|
61
|
+
if start_byte < 0:
|
|
62
|
+
raise ValueError(f"start_byte must be >= 0, got {start_byte}")
|
|
63
|
+
if end_byte is not None and end_byte < start_byte:
|
|
64
|
+
raise ValueError(f"end_byte {end_byte} < start_byte {start_byte}")
|
|
65
|
+
bucket, key = _split(uri)
|
|
66
|
+
header = f"bytes={start_byte}-" if end_byte is None else f"bytes={start_byte}-{end_byte}"
|
|
67
|
+
from botocore.exceptions import ClientError
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
return self._client.get_object(Bucket=bucket, Key=key, Range=header)["Body"].read()
|
|
71
|
+
except ClientError as exc:
|
|
72
|
+
if exc.response.get("Error", {}).get("Code") == "InvalidRange":
|
|
73
|
+
return b"" # start past EOF: protocol says return the available bytes
|
|
74
|
+
with self._translate(uri):
|
|
75
|
+
raise
|
|
76
|
+
|
|
77
|
+
def size(self, uri: str) -> int:
|
|
78
|
+
bucket, key = _split(uri)
|
|
79
|
+
with self._translate(uri):
|
|
80
|
+
return self._client.head_object(Bucket=bucket, Key=key)["ContentLength"]
|
|
81
|
+
|
|
82
|
+
def write(self, uri: str, data: bytes) -> None:
|
|
83
|
+
bucket, key = _split(uri)
|
|
84
|
+
with self._translate(uri):
|
|
85
|
+
self._client.put_object(Bucket=bucket, Key=key, Body=data)
|
|
86
|
+
|
|
87
|
+
def download(self, uri: str, out_path: str | os.PathLike[str]) -> None:
|
|
88
|
+
bucket, key = _split(uri)
|
|
89
|
+
dest = Path(out_path)
|
|
90
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
with self._translate(uri):
|
|
92
|
+
self._client.download_file(bucket, key, str(dest))
|
|
93
|
+
|
|
94
|
+
def list_files(
|
|
95
|
+
self,
|
|
96
|
+
uri_prefix: str,
|
|
97
|
+
*,
|
|
98
|
+
recursive: bool = True,
|
|
99
|
+
max_items: int | None = None,
|
|
100
|
+
required_prefix: str | None = None,
|
|
101
|
+
required_ext: str | None = None,
|
|
102
|
+
) -> list[str]:
|
|
103
|
+
bucket, key = _split(uri_prefix)
|
|
104
|
+
# required_prefix filters the *filename* of every (recursively listed)
|
|
105
|
+
# key, so it cannot be folded into the native Prefix — that would miss
|
|
106
|
+
# nested keys like sub/chunk_003.flac.
|
|
107
|
+
# Likewise the native MaxItems counts unfiltered keys, so max_items
|
|
108
|
+
# must be applied after filtering.
|
|
109
|
+
filtered = required_prefix is not None or required_ext is not None
|
|
110
|
+
paginate_kwargs: dict = {"Bucket": bucket, "Prefix": key}
|
|
111
|
+
if not recursive:
|
|
112
|
+
paginate_kwargs["Delimiter"] = "/"
|
|
113
|
+
if max_items is not None and not filtered:
|
|
114
|
+
paginate_kwargs["PaginationConfig"] = {"MaxItems": max_items}
|
|
115
|
+
|
|
116
|
+
results = []
|
|
117
|
+
with self._translate(uri_prefix):
|
|
118
|
+
for page in self._client.get_paginator("list_objects_v2").paginate(**paginate_kwargs):
|
|
119
|
+
for obj in page.get("Contents", []):
|
|
120
|
+
if obj["Key"].endswith("/"): # skip folder placeholders
|
|
121
|
+
continue
|
|
122
|
+
name = obj["Key"].rsplit("/", 1)[-1]
|
|
123
|
+
if _matches_filters(name, required_prefix, required_ext):
|
|
124
|
+
results.append(f"s3://{bucket}/{obj['Key']}")
|
|
125
|
+
results.sort()
|
|
126
|
+
return results if max_items is None else results[:max_items]
|
|
127
|
+
|
|
128
|
+
def object_count(
|
|
129
|
+
self,
|
|
130
|
+
uri_prefix: str,
|
|
131
|
+
*,
|
|
132
|
+
required_prefix: str | None = None,
|
|
133
|
+
required_ext: str | None = None,
|
|
134
|
+
) -> int:
|
|
135
|
+
bucket, key = _split(uri_prefix)
|
|
136
|
+
# required_prefix filters the *filename* of every (recursively listed)
|
|
137
|
+
# key, so it cannot be folded into the native Prefix — that would miss
|
|
138
|
+
# nested keys like sub/chunk_003.flac.
|
|
139
|
+
count = 0
|
|
140
|
+
with self._translate(uri_prefix):
|
|
141
|
+
for page in self._client.get_paginator("list_objects_v2").paginate(Bucket=bucket, Prefix=key):
|
|
142
|
+
if required_prefix is None and required_ext is None:
|
|
143
|
+
count += page.get("KeyCount", len(page.get("Contents", [])))
|
|
144
|
+
continue
|
|
145
|
+
for obj in page.get("Contents", []):
|
|
146
|
+
name = obj["Key"].rsplit("/", 1)[-1]
|
|
147
|
+
if _matches_filters(name, required_prefix, required_ext):
|
|
148
|
+
count += 1
|
|
149
|
+
return count
|
|
150
|
+
|
|
151
|
+
def exists(self, uri: str) -> bool:
|
|
152
|
+
try:
|
|
153
|
+
self.size(uri)
|
|
154
|
+
return True
|
|
155
|
+
except ObjectNotFoundError:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def delete(self, uri: str) -> None:
|
|
159
|
+
bucket, key = _split(uri)
|
|
160
|
+
with self._translate(uri):
|
|
161
|
+
self._client.delete_object(Bucket=bucket, Key=key) # S3 delete is idempotent
|
ondio/backends/gcs.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Google Cloud Storage backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from ondio.backends.protocol import _matches_filters
|
|
11
|
+
from ondio.types import AuthError, ObjectNotFoundError, OndioError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _split(uri: str) -> tuple[str, str]:
|
|
15
|
+
parsed = urlparse(uri)
|
|
16
|
+
if parsed.scheme != "gs" or not parsed.netloc:
|
|
17
|
+
raise ValueError(f"not a gs:// URI: {uri!r}")
|
|
18
|
+
return parsed.netloc, parsed.path.lstrip("/")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GcsBackend:
|
|
22
|
+
"""StorageBackend for Google Cloud Storage (`gs://bucket/key` URIs).
|
|
23
|
+
|
|
24
|
+
See [`StorageBackend`][ondio.backends.StorageBackend] for method
|
|
25
|
+
semantics. Requires the `gcs` extra. Credentials resolve through the
|
|
26
|
+
google-cloud-storage client's native chain; API errors are translated
|
|
27
|
+
into ondio's exception types.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
**client_kwargs: Forwarded to `google.cloud.storage.Client`
|
|
31
|
+
(e.g. `project`).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, **client_kwargs):
|
|
35
|
+
try:
|
|
36
|
+
from google.cloud import storage
|
|
37
|
+
except ImportError as exc:
|
|
38
|
+
raise ImportError(
|
|
39
|
+
"gs:// support requires the 'gcs' extra: pip install ondio[gcs]"
|
|
40
|
+
) from exc
|
|
41
|
+
self._client = storage.Client(**client_kwargs)
|
|
42
|
+
|
|
43
|
+
@contextlib.contextmanager
|
|
44
|
+
def _translate(self, uri: str):
|
|
45
|
+
from google.api_core import exceptions as gexc
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
yield
|
|
49
|
+
except gexc.NotFound as exc:
|
|
50
|
+
raise ObjectNotFoundError(f"no such object: {uri}") from exc
|
|
51
|
+
except (gexc.Forbidden, gexc.Unauthorized) as exc:
|
|
52
|
+
raise AuthError(f"access denied: {uri}") from exc
|
|
53
|
+
except gexc.GoogleAPICallError as exc:
|
|
54
|
+
raise OndioError(f"GCS error for {uri}: {exc}") from exc
|
|
55
|
+
|
|
56
|
+
def _blob(self, uri: str):
|
|
57
|
+
bucket, key = _split(uri)
|
|
58
|
+
return self._client.bucket(bucket).blob(key)
|
|
59
|
+
|
|
60
|
+
def read(self, uri: str) -> bytes:
|
|
61
|
+
with self._translate(uri):
|
|
62
|
+
return self._blob(uri).download_as_bytes()
|
|
63
|
+
|
|
64
|
+
def read_range(self, uri: str, start_byte: int, end_byte: int | None) -> bytes:
|
|
65
|
+
if start_byte < 0:
|
|
66
|
+
raise ValueError(f"start_byte must be >= 0, got {start_byte}")
|
|
67
|
+
if end_byte is not None and end_byte < start_byte:
|
|
68
|
+
raise ValueError(f"end_byte {end_byte} < start_byte {start_byte}")
|
|
69
|
+
from google.api_core import exceptions as gexc
|
|
70
|
+
|
|
71
|
+
with self._translate(uri):
|
|
72
|
+
try:
|
|
73
|
+
# google-cloud-storage end is inclusive, matching the protocol
|
|
74
|
+
return self._blob(uri).download_as_bytes(start=start_byte, end=end_byte)
|
|
75
|
+
except gexc.RequestRangeNotSatisfiable:
|
|
76
|
+
# must be caught before _translate sees it: it subclasses
|
|
77
|
+
# GoogleAPICallError and would be swallowed into OndioError
|
|
78
|
+
return b"" # start past EOF: protocol says return the available bytes
|
|
79
|
+
|
|
80
|
+
def size(self, uri: str) -> int:
|
|
81
|
+
bucket, key = _split(uri)
|
|
82
|
+
with self._translate(uri):
|
|
83
|
+
blob = self._client.bucket(bucket).get_blob(key)
|
|
84
|
+
if blob is None:
|
|
85
|
+
raise ObjectNotFoundError(f"no such object: {uri}")
|
|
86
|
+
return blob.size
|
|
87
|
+
|
|
88
|
+
def write(self, uri: str, data: bytes) -> None:
|
|
89
|
+
with self._translate(uri):
|
|
90
|
+
self._blob(uri).upload_from_string(data, content_type="application/octet-stream")
|
|
91
|
+
|
|
92
|
+
def download(self, uri: str, out_path: str | os.PathLike[str]) -> None:
|
|
93
|
+
dest = Path(out_path)
|
|
94
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
with self._translate(uri):
|
|
96
|
+
self._blob(uri).download_to_filename(str(dest))
|
|
97
|
+
|
|
98
|
+
def list_files(
|
|
99
|
+
self,
|
|
100
|
+
uri_prefix: str,
|
|
101
|
+
*,
|
|
102
|
+
recursive: bool = True,
|
|
103
|
+
max_items: int | None = None,
|
|
104
|
+
required_prefix: str | None = None,
|
|
105
|
+
required_ext: str | None = None,
|
|
106
|
+
) -> list[str]:
|
|
107
|
+
bucket, key = _split(uri_prefix)
|
|
108
|
+
# The filters apply to the *filename* of every (recursively listed)
|
|
109
|
+
# key, so they cannot be folded into the native prefix, and the native
|
|
110
|
+
# max_results counts unfiltered keys — cap after filtering instead.
|
|
111
|
+
filtered = required_prefix is not None or required_ext is not None
|
|
112
|
+
with self._translate(uri_prefix):
|
|
113
|
+
blobs = self._client.list_blobs(
|
|
114
|
+
bucket,
|
|
115
|
+
prefix=key,
|
|
116
|
+
delimiter=None if recursive else "/",
|
|
117
|
+
max_results=None if filtered else max_items,
|
|
118
|
+
)
|
|
119
|
+
results = [
|
|
120
|
+
f"gs://{bucket}/{blob.name}"
|
|
121
|
+
for blob in blobs
|
|
122
|
+
if not blob.name.endswith("/")
|
|
123
|
+
and _matches_filters(blob.name.rsplit("/", 1)[-1], required_prefix, required_ext)
|
|
124
|
+
]
|
|
125
|
+
results.sort()
|
|
126
|
+
return results if max_items is None else results[:max_items]
|
|
127
|
+
|
|
128
|
+
def object_count(
|
|
129
|
+
self,
|
|
130
|
+
uri_prefix: str,
|
|
131
|
+
*,
|
|
132
|
+
required_prefix: str | None = None,
|
|
133
|
+
required_ext: str | None = None,
|
|
134
|
+
) -> int:
|
|
135
|
+
bucket, key = _split(uri_prefix)
|
|
136
|
+
# required_prefix filters the *filename* of every (recursively listed)
|
|
137
|
+
# key, so it cannot be folded into the native prefix — that would miss
|
|
138
|
+
# nested keys like sub/chunk_003.flac.
|
|
139
|
+
count = 0
|
|
140
|
+
with self._translate(uri_prefix):
|
|
141
|
+
for blob in self._client.list_blobs(bucket, prefix=key):
|
|
142
|
+
if _matches_filters(blob.name.rsplit("/", 1)[-1], required_prefix, required_ext):
|
|
143
|
+
count += 1
|
|
144
|
+
return count
|
|
145
|
+
|
|
146
|
+
def exists(self, uri: str) -> bool:
|
|
147
|
+
with self._translate(uri):
|
|
148
|
+
return self._blob(uri).exists()
|
|
149
|
+
|
|
150
|
+
def delete(self, uri: str) -> None:
|
|
151
|
+
from google.api_core import exceptions as gexc
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
with self._translate(uri):
|
|
155
|
+
self._blob(uri).delete()
|
|
156
|
+
except ObjectNotFoundError:
|
|
157
|
+
pass # idempotent delete, per the protocol
|
|
158
|
+
except gexc.NotFound: # pragma: no cover - mapped above
|
|
159
|
+
pass
|
ondio/backends/http.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""HTTP/HTTPS backend (read-only).
|
|
2
|
+
|
|
3
|
+
Write and listing operations are infeasible over plain HTTP and raise
|
|
4
|
+
UnsupportedOperationError. read_range verifies the server honored the Range
|
|
5
|
+
header (206) and refuses to pass off a full-body 200 as a range.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ondio.types import AuthError, ObjectNotFoundError, OndioError, UnsupportedOperationError
|
|
15
|
+
|
|
16
|
+
_CHUNK = 1024 * 1024
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HttpBackend:
|
|
20
|
+
"""Read-only StorageBackend for plain `http(s)://` URLs, via requests.
|
|
21
|
+
|
|
22
|
+
See [`StorageBackend`][ondio.backends.StorageBackend] for method
|
|
23
|
+
semantics. Requires the `http` extra. `upload`, `list_files`,
|
|
24
|
+
`object_count`, and `delete` raise `UnsupportedOperationError` —
|
|
25
|
+
infeasible over plain HTTP. `read_range` verifies the server honored the
|
|
26
|
+
`Range` header (206) and refuses to pass off a full-body 200 as a range.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
try:
|
|
31
|
+
import requests
|
|
32
|
+
except ImportError as exc:
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"http(s):// support requires the 'http' extra: pip install ondio[http]"
|
|
35
|
+
) from exc
|
|
36
|
+
self._requests = requests
|
|
37
|
+
self._local = threading.local() # requests.Session is not thread-safe
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def _session(self):
|
|
41
|
+
session = getattr(self._local, "session", None)
|
|
42
|
+
if session is None:
|
|
43
|
+
session = self._local.session = self._requests.Session()
|
|
44
|
+
return session
|
|
45
|
+
|
|
46
|
+
def _check_status(self, uri: str, response) -> None:
|
|
47
|
+
if response.status_code == 404:
|
|
48
|
+
raise ObjectNotFoundError(f"no such object: {uri}")
|
|
49
|
+
if response.status_code in (401, 403):
|
|
50
|
+
raise AuthError(f"access denied ({response.status_code}): {uri}")
|
|
51
|
+
if response.status_code >= 400:
|
|
52
|
+
raise OndioError(f"HTTP {response.status_code} for {uri}")
|
|
53
|
+
|
|
54
|
+
def read(self, uri: str) -> bytes:
|
|
55
|
+
response = self._session.get(uri)
|
|
56
|
+
self._check_status(uri, response)
|
|
57
|
+
return response.content
|
|
58
|
+
|
|
59
|
+
def read_range(self, uri: str, start_byte: int, end_byte: int | None) -> bytes:
|
|
60
|
+
if start_byte < 0:
|
|
61
|
+
raise ValueError(f"start_byte must be >= 0, got {start_byte}")
|
|
62
|
+
if end_byte is not None and end_byte < start_byte:
|
|
63
|
+
raise ValueError(f"end_byte {end_byte} < start_byte {start_byte}")
|
|
64
|
+
header = f"bytes={start_byte}-" if end_byte is None else f"bytes={start_byte}-{end_byte}"
|
|
65
|
+
response = self._session.get(uri, headers={"Range": header})
|
|
66
|
+
if response.status_code == 416:
|
|
67
|
+
return b"" # start past EOF: protocol says return the available bytes
|
|
68
|
+
if response.status_code == 200:
|
|
69
|
+
raise UnsupportedOperationError(
|
|
70
|
+
f"server ignored the Range header (answered 200, not 206): {uri}"
|
|
71
|
+
)
|
|
72
|
+
self._check_status(uri, response)
|
|
73
|
+
if response.status_code != 206:
|
|
74
|
+
raise OndioError(f"unexpected status {response.status_code} for ranged GET: {uri}")
|
|
75
|
+
return response.content
|
|
76
|
+
|
|
77
|
+
def size(self, uri: str) -> int:
|
|
78
|
+
response = self._session.head(uri, allow_redirects=True)
|
|
79
|
+
self._check_status(uri, response)
|
|
80
|
+
length = response.headers.get("Content-Length")
|
|
81
|
+
if length is None:
|
|
82
|
+
raise UnsupportedOperationError(f"server reports no Content-Length: {uri}")
|
|
83
|
+
return int(length)
|
|
84
|
+
|
|
85
|
+
def download(self, uri: str, out_path: str | os.PathLike[str]) -> None:
|
|
86
|
+
dest = Path(out_path)
|
|
87
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
with self._session.get(uri, stream=True) as response:
|
|
89
|
+
self._check_status(uri, response)
|
|
90
|
+
with open(dest, "wb") as f:
|
|
91
|
+
for chunk in response.iter_content(_CHUNK):
|
|
92
|
+
f.write(chunk)
|
|
93
|
+
|
|
94
|
+
def exists(self, uri: str) -> bool:
|
|
95
|
+
response = self._session.head(uri, allow_redirects=True)
|
|
96
|
+
if response.status_code in (405, 501): # server does not support HEAD
|
|
97
|
+
with self._session.get(uri, stream=True) as get_response:
|
|
98
|
+
if get_response.status_code == 404:
|
|
99
|
+
return False
|
|
100
|
+
self._check_status(uri, get_response)
|
|
101
|
+
return True
|
|
102
|
+
if response.status_code == 404:
|
|
103
|
+
return False
|
|
104
|
+
self._check_status(uri, response)
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
# --- infeasible over plain HTTP -------------------------------------
|
|
108
|
+
|
|
109
|
+
def write(self, uri: str, data: bytes) -> None:
|
|
110
|
+
raise UnsupportedOperationError(f"write is not supported over HTTP: {uri}")
|
|
111
|
+
|
|
112
|
+
def list_files(
|
|
113
|
+
self,
|
|
114
|
+
uri_prefix: str,
|
|
115
|
+
*,
|
|
116
|
+
recursive: bool = True,
|
|
117
|
+
max_items: int | None = None,
|
|
118
|
+
required_prefix: str | None = None,
|
|
119
|
+
required_ext: str | None = None,
|
|
120
|
+
) -> list[str]:
|
|
121
|
+
raise UnsupportedOperationError(f"list_files is not supported over HTTP: {uri_prefix}")
|
|
122
|
+
|
|
123
|
+
def object_count(self, uri_prefix: str, *, required_prefix: str | None = None, required_ext: str | None = None) -> int:
|
|
124
|
+
raise UnsupportedOperationError(f"object_count is not supported over HTTP: {uri_prefix}")
|
|
125
|
+
|
|
126
|
+
def delete(self, uri: str) -> None:
|
|
127
|
+
raise UnsupportedOperationError(f"delete is not supported over HTTP: {uri}")
|
ondio/backends/local.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Local-filesystem backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import unquote, urlparse
|
|
10
|
+
|
|
11
|
+
from ondio.backends.protocol import _matches_filters
|
|
12
|
+
from ondio.types import AuthError, ObjectNotFoundError
|
|
13
|
+
|
|
14
|
+
def _to_path(uri: str) -> Path:
|
|
15
|
+
if uri.startswith("file://"):
|
|
16
|
+
parsed = urlparse(uri)
|
|
17
|
+
if parsed.netloc not in ("", "localhost"):
|
|
18
|
+
raise ValueError(f"file:// URIs with a remote host are not supported: {uri!r}")
|
|
19
|
+
return Path(unquote(parsed.path))
|
|
20
|
+
return Path(uri)
|
|
21
|
+
|
|
22
|
+
def _wrap_oserror(uri: str, exc: OSError) -> Exception:
|
|
23
|
+
if isinstance(exc, PermissionError):
|
|
24
|
+
return AuthError(f"permission denied: {uri}")
|
|
25
|
+
return ObjectNotFoundError(f"no such object: {uri}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LocalBackend:
|
|
29
|
+
def read(self, uri: str) -> bytes:
|
|
30
|
+
try:
|
|
31
|
+
return _to_path(uri).read_bytes()
|
|
32
|
+
except OSError as exc:
|
|
33
|
+
raise _wrap_oserror(uri, exc)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_range(self, uri: str, start_byte: int, end_byte: int | None) -> bytes:
|
|
37
|
+
if start_byte < 0:
|
|
38
|
+
raise ValueError(f"start_byte must be >= 0, got {start_byte}")
|
|
39
|
+
if end_byte is not None and end_byte < start_byte:
|
|
40
|
+
raise ValueError(f"end_byte ({end_byte}) < start_byte {start_byte}")
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
with open(_to_path(uri), "rb") as f:
|
|
44
|
+
f.seek(start_byte)
|
|
45
|
+
if end_byte is None:
|
|
46
|
+
return f.read()
|
|
47
|
+
return f.read(end_byte - start_byte + 1)
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
raise _wrap_oserror(uri, exc)
|
|
50
|
+
|
|
51
|
+
def size(self, uri: str) -> int:
|
|
52
|
+
path = _to_path(uri)
|
|
53
|
+
try:
|
|
54
|
+
if not path.is_file():
|
|
55
|
+
raise ObjectNotFoundError(f"no such object: {uri}")
|
|
56
|
+
return path.stat().st_size
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise _wrap_oserror(uri, exc)
|
|
59
|
+
|
|
60
|
+
def write(self, uri: str, data : bytes) -> None:
|
|
61
|
+
path = _to_path(uri)
|
|
62
|
+
try:
|
|
63
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
path.write_bytes(data)
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
raise _wrap_oserror(uri, exc)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def download(self, uri: str, out_path: str | os.PathLike[str]) -> None:
|
|
70
|
+
src = _to_path(uri)
|
|
71
|
+
if not src.is_file():
|
|
72
|
+
raise ObjectNotFoundError(f"no such object: {uri}")
|
|
73
|
+
dest = Path(out_path)
|
|
74
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
shutil.copyfile(src, dest)
|
|
76
|
+
|
|
77
|
+
def list_files(
|
|
78
|
+
self,
|
|
79
|
+
uri_prefix: str,
|
|
80
|
+
*,
|
|
81
|
+
recursive: bool = True,
|
|
82
|
+
max_items: int | None = None,
|
|
83
|
+
required_prefix: str | None = None,
|
|
84
|
+
required_ext: str | None = None,
|
|
85
|
+
) -> list[str]:
|
|
86
|
+
as_file_uri = uri_prefix.startswith("file://")
|
|
87
|
+
matches = (
|
|
88
|
+
p
|
|
89
|
+
for p in self._iter_matches(uri_prefix, recursive=recursive)
|
|
90
|
+
if _matches_filters(p.name, required_prefix, required_ext)
|
|
91
|
+
)
|
|
92
|
+
results = sorted(p.as_uri() if as_file_uri else str(p) for p in matches)
|
|
93
|
+
return results if max_items is None else results[:max_items]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def object_count(
|
|
97
|
+
self,
|
|
98
|
+
uri_prefix: str,
|
|
99
|
+
*,
|
|
100
|
+
required_prefix: str | None = None,
|
|
101
|
+
required_ext: str | None = None,
|
|
102
|
+
) -> int:
|
|
103
|
+
return sum(
|
|
104
|
+
1
|
|
105
|
+
for path in self._iter_matches(uri_prefix, recursive=True)
|
|
106
|
+
if _matches_filters(path.name, required_prefix, required_ext)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def exists(self, uri: str) -> bool:
|
|
110
|
+
return _to_path(uri).is_file()
|
|
111
|
+
|
|
112
|
+
def delete(self, uri: str) -> None:
|
|
113
|
+
try:
|
|
114
|
+
_to_path(uri).unlink(missing_ok=True)
|
|
115
|
+
except OSError as exc:
|
|
116
|
+
raise _wrap_oserror(uri, exc) from exc
|
|
117
|
+
|
|
118
|
+
def _iter_matches(self, uri_prefix: str, *, recursive: bool):
|
|
119
|
+
"""Files matching a prefix, S3-style: a directory prefix matches everything
|
|
120
|
+
under it; otherwise the prefix matches any path that starts with it as a
|
|
121
|
+
string (e.g. /data/chunk_ matches /data/chunk_001.flac)."""
|
|
122
|
+
base = _to_path(uri_prefix)
|
|
123
|
+
if base.is_dir():
|
|
124
|
+
root, filter_prefix = base, None
|
|
125
|
+
else:
|
|
126
|
+
root, filter_prefix = base.parent, str(base)
|
|
127
|
+
if not root.is_dir():
|
|
128
|
+
return
|
|
129
|
+
if recursive:
|
|
130
|
+
candidates = (
|
|
131
|
+
Path(dirpath) / name
|
|
132
|
+
for dirpath, _, filenames in os.walk(root)
|
|
133
|
+
for name in filenames
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
candidates = (p for p in root.iterdir() if p.is_file())
|
|
137
|
+
for path in candidates:
|
|
138
|
+
if filter_prefix is None or str(path).startswith(filter_prefix):
|
|
139
|
+
yield path
|