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 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}")
@@ -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