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/_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
+ )