objpath 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.
- objpath/AGENTS.md +311 -0
- objpath/README.md +153 -0
- objpath/__init__.py +68 -0
- objpath/_az.py +547 -0
- objpath/_az_backend.py +130 -0
- objpath/_az_errors.py +110 -0
- objpath/_clients.py +108 -0
- objpath/_digests.py +10 -0
- objpath/_failures.py +84 -0
- objpath/_gs.py +447 -0
- objpath/_gs_backend.py +95 -0
- objpath/_gs_errors.py +112 -0
- objpath/_keys.py +119 -0
- objpath/_optional.py +60 -0
- objpath/_places.py +26 -0
- objpath/_reader.py +83 -0
- objpath/_s3.py +489 -0
- objpath/_s3_backend.py +68 -0
- objpath/_s3_errors.py +106 -0
- objpath/_storepath.py +579 -0
- objpath/_uri_store.py +162 -0
- objpath/_version.py +11 -0
- objpath/_writer.py +113 -0
- objpath/exceptions.py +31 -0
- objpath/py.typed +0 -0
- objpath/uri.py +213 -0
- objpath-0.1.0.dist-info/METADATA +204 -0
- objpath-0.1.0.dist-info/RECORD +31 -0
- objpath-0.1.0.dist-info/WHEEL +4 -0
- objpath-0.1.0.dist-info/entry_points.txt +4 -0
- objpath-0.1.0.dist-info/licenses/LICENSE +21 -0
objpath/_az_errors.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""How a failure of Azure Blob Storage becomes an `OSError`: the status of an
|
|
2
|
+
error reply, the SDK's report of a connection failure, and the translation of
|
|
3
|
+
both."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextlib as _contextlib
|
|
8
|
+
import errno as _errno
|
|
9
|
+
import sys as _sys
|
|
10
|
+
|
|
11
|
+
from . import _failures
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def http_status(error: BaseException) -> "int | None":
|
|
15
|
+
"""The HTTP status of an `azure.core.exceptions.HttpResponseError`
|
|
16
|
+
(`ResourceNotFoundError`, `ResourceExistsError`, ...), read from its
|
|
17
|
+
`status_code` attribute -- or its type when the SDK set none -- so the
|
|
18
|
+
SDK need not be importable to classify it. None for anything that is
|
|
19
|
+
not an error reply."""
|
|
20
|
+
if isinstance(error, OSError):
|
|
21
|
+
return None
|
|
22
|
+
status = getattr(error, "status_code", None)
|
|
23
|
+
if isinstance(status, int) and not isinstance(status, bool):
|
|
24
|
+
return status
|
|
25
|
+
try:
|
|
26
|
+
from azure.core import exceptions as _azexc
|
|
27
|
+
except ImportError:
|
|
28
|
+
return None
|
|
29
|
+
if isinstance(error, _azexc.ResourceNotFoundError):
|
|
30
|
+
return 404
|
|
31
|
+
if isinstance(error, _azexc.ResourceExistsError):
|
|
32
|
+
return 409
|
|
33
|
+
if isinstance(error, _azexc.ClientAuthenticationError):
|
|
34
|
+
return 401
|
|
35
|
+
if isinstance(error, _azexc.HttpResponseError):
|
|
36
|
+
return 500
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def transport_kind(error: BaseException) -> "str | None":
|
|
41
|
+
"""Which `_failures.transport_error()` kind `error` is when it is the SDK's
|
|
42
|
+
report of a connection failure (not of a reply), else None."""
|
|
43
|
+
# An SDK error exists only once its module is loaded, so there is nothing
|
|
44
|
+
# to import here.
|
|
45
|
+
azexc = _sys.modules.get("azure.core.exceptions")
|
|
46
|
+
if azexc is None:
|
|
47
|
+
return None
|
|
48
|
+
timeouts = tuple(
|
|
49
|
+
getattr(azexc, name)
|
|
50
|
+
for name in ("ServiceRequestTimeoutError", "ServiceResponseTimeoutError")
|
|
51
|
+
if hasattr(azexc, name)
|
|
52
|
+
)
|
|
53
|
+
if isinstance(error, timeouts):
|
|
54
|
+
return _failures.TIMEOUT
|
|
55
|
+
if isinstance(error, azexc.ServiceRequestError):
|
|
56
|
+
# The request could not be sent: no connection, no name, no route.
|
|
57
|
+
return (
|
|
58
|
+
_failures.TIMEOUT
|
|
59
|
+
if _failures.mentions_timeout(error)
|
|
60
|
+
else _failures.UNREACHABLE
|
|
61
|
+
)
|
|
62
|
+
if isinstance(error, azexc.ServiceResponseError):
|
|
63
|
+
# Sent, but the answer did not arrive whole.
|
|
64
|
+
return (
|
|
65
|
+
_failures.TIMEOUT
|
|
66
|
+
if _failures.mentions_timeout(error)
|
|
67
|
+
else _failures.INTERRUPTED
|
|
68
|
+
)
|
|
69
|
+
incomplete = getattr(azexc, "IncompleteReadError", None)
|
|
70
|
+
if incomplete is not None and isinstance(error, incomplete):
|
|
71
|
+
return _failures.INTERRUPTED
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def oserror(error: BaseException, path, *, create=False):
|
|
76
|
+
"""The OSError an error reply or a connection failure means for `path`,
|
|
77
|
+
or None for any other exception (a missing SDK, bad configuration, a
|
|
78
|
+
bug), which must propagate as itself rather than read as "no such
|
|
79
|
+
file". `create`: the reply answers a conditional create, where 409/412
|
|
80
|
+
mean the blob exists (elsewhere a 412 is e.g. a lease held by someone
|
|
81
|
+
else). A request that ran out of time is TimeoutError, an endpoint that
|
|
82
|
+
cannot be reached ConnectionError. Raise it `from None`."""
|
|
83
|
+
kind = transport_kind(error)
|
|
84
|
+
if kind is not None:
|
|
85
|
+
return _failures.transport_error(kind, "Azure", path, error)
|
|
86
|
+
status = http_status(error)
|
|
87
|
+
if status is None:
|
|
88
|
+
return None
|
|
89
|
+
if status == 404:
|
|
90
|
+
return FileNotFoundError(
|
|
91
|
+
_errno.ENOENT, f"No such file or directory ({error})", str(path)
|
|
92
|
+
)
|
|
93
|
+
if status in (401, 403):
|
|
94
|
+
return PermissionError(_errno.EACCES, str(error), str(path))
|
|
95
|
+
if create and status in (409, 412):
|
|
96
|
+
return FileExistsError(_errno.EEXIST, f"File exists ({error})", str(path))
|
|
97
|
+
if status in (408, 504):
|
|
98
|
+
return _failures.transport_error(_failures.TIMEOUT, "Azure", path, error)
|
|
99
|
+
return OSError(_errno.EIO, f"Azure request failed: {error}", str(path))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@_contextlib.contextmanager
|
|
103
|
+
def translate_errors(path, *, create=False):
|
|
104
|
+
try:
|
|
105
|
+
yield
|
|
106
|
+
except Exception as error:
|
|
107
|
+
translated = oserror(error, path, create=create)
|
|
108
|
+
if translated is None:
|
|
109
|
+
raise
|
|
110
|
+
raise translated from None
|
objpath/_clients.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""What the backend of every store shares: the context manager, the client
|
|
2
|
+
built on first use, and what a pickled or copied backend keeps."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import threading as _threading
|
|
7
|
+
|
|
8
|
+
#: Backend attributes that are the live client and what guards it: left out
|
|
9
|
+
#: of a pickled or copied backend, which builds its own on first use.
|
|
10
|
+
_LIVE = frozenset({"_client", "_client_lock", "__dict__", "__weakref__"})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def lazy_client(backend, build):
|
|
14
|
+
"""`backend._client`, made by `build()` the first time under
|
|
15
|
+
`backend._client_lock` so that threads racing for it build one."""
|
|
16
|
+
client = backend._client
|
|
17
|
+
if client is None:
|
|
18
|
+
with backend._client_lock:
|
|
19
|
+
client = backend._client
|
|
20
|
+
if client is None:
|
|
21
|
+
client = backend._client = build()
|
|
22
|
+
return client
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def backend_state(backend) -> dict:
|
|
26
|
+
"""What a pickled or copied backend keeps: its slots and `__dict__`,
|
|
27
|
+
without the client it built and the lock that guarded the build."""
|
|
28
|
+
state = {}
|
|
29
|
+
for cls in type(backend).__mro__:
|
|
30
|
+
slots = getattr(cls, "__slots__", ())
|
|
31
|
+
for name in (slots,) if isinstance(slots, str) else slots:
|
|
32
|
+
if name not in _LIVE and hasattr(backend, name):
|
|
33
|
+
state[name] = getattr(backend, name)
|
|
34
|
+
state.update(
|
|
35
|
+
(name, value)
|
|
36
|
+
for name, value in getattr(backend, "__dict__", {}).items()
|
|
37
|
+
if name not in _LIVE
|
|
38
|
+
)
|
|
39
|
+
return state
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def restore_backend(backend, state: dict) -> None:
|
|
43
|
+
"""The inverse of `backend_state()`: a backend with no client yet."""
|
|
44
|
+
for name, value in state.items():
|
|
45
|
+
setattr(backend, name, value)
|
|
46
|
+
backend._client = None
|
|
47
|
+
backend._client_lock = _threading.Lock()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ClientBackend(object):
|
|
51
|
+
"""The base of the backend contract of every store: a context manager
|
|
52
|
+
whose `close()` releases what the backend holds, and the `picklable` flag.
|
|
53
|
+
|
|
54
|
+
`picklable` says whether a path that holds the backend pickles it. It is
|
|
55
|
+
`False`: the options of a client carry credentials, so a pickled path
|
|
56
|
+
names its place only and the receiving process builds the default backend.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Weakly referenceable so a path can be told which backends it built.
|
|
60
|
+
__slots__ = ("__weakref__",)
|
|
61
|
+
|
|
62
|
+
picklable = False
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
"""Releases what this backend holds. Nothing here; a backend that holds
|
|
66
|
+
a client closes it. Harmless when called again; the backend stays
|
|
67
|
+
usable."""
|
|
68
|
+
|
|
69
|
+
def __enter__(self):
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, *exc_info) -> None:
|
|
73
|
+
self.close()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LazyClientBackend(ClientBackend):
|
|
77
|
+
"""A backend that builds its SDK client the first time it is asked for it
|
|
78
|
+
(`_build_client()`), once however many threads ask first, and keeps it.
|
|
79
|
+
|
|
80
|
+
`close()` closes the client when the SDK's has a `close()`, and the next
|
|
81
|
+
`client()` builds a new one. A pickled or copied backend keeps its
|
|
82
|
+
options and builds its own client."""
|
|
83
|
+
|
|
84
|
+
__slots__ = ("_client", "_client_lock")
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self._client = None
|
|
88
|
+
self._client_lock = _threading.Lock()
|
|
89
|
+
|
|
90
|
+
def client(self):
|
|
91
|
+
"""The SDK client every call of a path goes through."""
|
|
92
|
+
return lazy_client(self, self._build_client)
|
|
93
|
+
|
|
94
|
+
def _build_client(self):
|
|
95
|
+
raise NotImplementedError
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
with self._client_lock:
|
|
99
|
+
client, self._client = self._client, None
|
|
100
|
+
closer = getattr(client, "close", None)
|
|
101
|
+
if callable(closer):
|
|
102
|
+
closer()
|
|
103
|
+
|
|
104
|
+
def __getstate__(self):
|
|
105
|
+
return backend_state(self)
|
|
106
|
+
|
|
107
|
+
def __setstate__(self, state):
|
|
108
|
+
restore_backend(self, state)
|
objpath/_digests.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""The digests a store reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64 as _base64
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def digest_hex(encoded: str) -> str:
|
|
9
|
+
"""The hex digest of `encoded`, the base64 text a store reports one in."""
|
|
10
|
+
return _base64.b64decode(encoded).hex()
|
objpath/_failures.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""How a failure of the connection to a store becomes an `OSError`, and how a
|
|
2
|
+
stream tells that its object changed."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import errno as _errno
|
|
7
|
+
|
|
8
|
+
from .exceptions import ObjectChangedError
|
|
9
|
+
|
|
10
|
+
#: What a failure of the connection to the store is, as `transport_error()` raises
|
|
11
|
+
#: it: the request ran out of time, the endpoint was unreachable, the connection
|
|
12
|
+
#: broke after the store began to answer, or anything else the HTTP client reported.
|
|
13
|
+
TIMEOUT, UNREACHABLE, INTERRUPTED, FAILED = (
|
|
14
|
+
"timeout",
|
|
15
|
+
"unreachable",
|
|
16
|
+
"interrupted",
|
|
17
|
+
"failed",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_TRANSPORT = {
|
|
21
|
+
TIMEOUT: (TimeoutError, _errno.ETIMEDOUT, "request timed out"),
|
|
22
|
+
UNREACHABLE: (ConnectionError, _errno.EHOSTUNREACH, "endpoint unreachable"),
|
|
23
|
+
INTERRUPTED: (
|
|
24
|
+
ConnectionResetError,
|
|
25
|
+
_errno.ECONNRESET,
|
|
26
|
+
"connection lost before the response was complete",
|
|
27
|
+
),
|
|
28
|
+
FAILED: (OSError, _errno.EIO, "request failed"),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def transport_error(kind: str, store: str, path, error: BaseException) -> OSError:
|
|
33
|
+
"""The `OSError` for a transport failure of `store` ("S3", "GCS", "Azure")
|
|
34
|
+
on `path`: `TimeoutError`, `ConnectionError`, `ConnectionResetError` or
|
|
35
|
+
plain `OSError` by `kind`, with the path as `filename`. Only the type name
|
|
36
|
+
of `error` reaches the message: an HTTP client's own text carries the
|
|
37
|
+
request URL, and with it any query-string credential. Raise it
|
|
38
|
+
`from None`."""
|
|
39
|
+
cls, number, text = _TRANSPORT[kind]
|
|
40
|
+
return cls(number, f"{store} {text} ({type(error).__name__})", str(path))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def mentions_timeout(error: BaseException) -> bool:
|
|
44
|
+
"""Whether `error`, or an exception it wraps (`__cause__`, `__context__`,
|
|
45
|
+
an SDK's `inner_exception` or `cause`, a `reason` or an argument), is a
|
|
46
|
+
timeout of any HTTP client: a type named `Timeout`, `ReadTimeout`,
|
|
47
|
+
`ConnectTimeoutError`, ..., or `TimeoutError` itself. The client libraries
|
|
48
|
+
are matched by name so none has to be imported. urllib3's
|
|
49
|
+
`NewConnectionError` derives from its `ConnectTimeoutError` but means the
|
|
50
|
+
connection was refused or the name did not resolve: it is not one."""
|
|
51
|
+
pending = [error]
|
|
52
|
+
for _ in range(8):
|
|
53
|
+
if not pending:
|
|
54
|
+
break
|
|
55
|
+
current = pending.pop()
|
|
56
|
+
if current is None:
|
|
57
|
+
continue
|
|
58
|
+
names = {cls.__name__ for cls in type(current).__mro__}
|
|
59
|
+
if "NewConnectionError" in names:
|
|
60
|
+
continue
|
|
61
|
+
if any(
|
|
62
|
+
name.endswith(("Timeout", "TimeoutError")) or name == "timeout"
|
|
63
|
+
for name in names
|
|
64
|
+
):
|
|
65
|
+
return True
|
|
66
|
+
for attr in ("__cause__", "__context__", "inner_exception", "cause", "reason"):
|
|
67
|
+
pending.append(getattr(current, attr, None))
|
|
68
|
+
pending.extend(
|
|
69
|
+
arg
|
|
70
|
+
for arg in getattr(current, "args", ())
|
|
71
|
+
if isinstance(arg, BaseException)
|
|
72
|
+
)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def changed_error(path) -> ObjectChangedError:
|
|
77
|
+
"""The error for a stream whose object was replaced while it was being
|
|
78
|
+
read: a read is one version of the object, and what follows the change is
|
|
79
|
+
another's. `ESTALE` where the platform has it, else `EIO`."""
|
|
80
|
+
return ObjectChangedError(
|
|
81
|
+
getattr(_errno, "ESTALE", _errno.EIO),
|
|
82
|
+
"the object changed while it was being read",
|
|
83
|
+
str(path),
|
|
84
|
+
)
|