asanypath 2.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.
asanypath/sync.py ADDED
@@ -0,0 +1,220 @@
1
+ # Copyright (C) 2026 Axis Communications AB, Lund, Sweden
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ """Synchronous wrapper for AsAnyPath.
8
+
9
+ Usage::
10
+
11
+ from asanypath.sync import AsAnyPath
12
+
13
+ p = AsAnyPath("s3://bucket/key")
14
+ data = p.read_bytes() # blocks, no await
15
+ for child in p.parent.iterdir():
16
+ print(child.name)
17
+
18
+ Local paths bypass the proxy entirely and return SyncPath (pure pathlib).
19
+ Cloud paths use a shared background event loop in a daemon thread.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import inspect
26
+ import threading
27
+ from abc import ABC
28
+ from collections.abc import AsyncIterator
29
+ from typing import Any
30
+
31
+
32
+ class _SyncRunner:
33
+ """Lazy singleton that runs coroutines on a background event loop."""
34
+
35
+ _instance: _SyncRunner | None = None
36
+ _lock = threading.Lock()
37
+
38
+ def __init__(self) -> None:
39
+ self._loop: asyncio.AbstractEventLoop | None = None
40
+ self._thread: threading.Thread | None = None
41
+
42
+ @classmethod
43
+ def get(cls) -> _SyncRunner:
44
+ if cls._instance is None:
45
+ with cls._lock:
46
+ if cls._instance is None:
47
+ cls._instance = cls()
48
+ return cls._instance
49
+
50
+ def _start(self) -> None:
51
+ if self._loop is None or self._loop.is_closed():
52
+ self._loop = asyncio.new_event_loop()
53
+ self._thread = threading.Thread(
54
+ target=self._loop.run_forever, daemon=True, name="asanypath-sync"
55
+ )
56
+ self._thread.start()
57
+
58
+ def run(self, coro):
59
+ """Submit a coroutine and block until it completes."""
60
+ self._start()
61
+ return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
62
+
63
+ def iter(self, async_iter: AsyncIterator):
64
+ """Drain an async iterator, yielding items synchronously."""
65
+ self._start()
66
+
67
+ async def _collect_next():
68
+ return await async_iter.__anext__()
69
+
70
+ while True:
71
+ try:
72
+ yield asyncio.run_coroutine_threadsafe(_collect_next(), self._loop).result()
73
+ except StopAsyncIteration:
74
+ return
75
+
76
+
77
+ class _SyncProxy:
78
+ """Transparent sync proxy around an async path instance."""
79
+
80
+ __slots__ = ("_async",)
81
+
82
+ def __init__(self, async_path) -> None:
83
+ object.__setattr__(self, "_async", async_path)
84
+
85
+ # ------------------------------------------------------------------
86
+ # Pure path properties / methods — delegate directly (no await needed)
87
+ # ------------------------------------------------------------------
88
+
89
+ def __str__(self) -> str:
90
+ return str(self._async)
91
+
92
+ def __repr__(self) -> str:
93
+ return f"SyncProxy({self._async!r})"
94
+
95
+ def __fspath__(self) -> str:
96
+ return self._async.__fspath__()
97
+
98
+ def __eq__(self, other) -> bool:
99
+ if isinstance(other, _SyncProxy):
100
+ return self._async == other._async
101
+ return self._async == other
102
+
103
+ def __hash__(self) -> int:
104
+ return hash(self._async)
105
+
106
+ def __lt__(self, other) -> bool:
107
+ return self._async < (other._async if isinstance(other, _SyncProxy) else other)
108
+
109
+ def __le__(self, other) -> bool:
110
+ return self._async <= (other._async if isinstance(other, _SyncProxy) else other)
111
+
112
+ def __gt__(self, other) -> bool:
113
+ return self._async > (other._async if isinstance(other, _SyncProxy) else other)
114
+
115
+ def __ge__(self, other) -> bool:
116
+ return self._async >= (other._async if isinstance(other, _SyncProxy) else other)
117
+
118
+ def __truediv__(self, other) -> _SyncProxy:
119
+ return _wrap(self._async / other)
120
+
121
+ def __rtruediv__(self, other) -> _SyncProxy:
122
+ return _wrap(other / self._async)
123
+
124
+ # ------------------------------------------------------------------
125
+ # Attribute access — proxy everything, wrapping coroutines/iterators
126
+ # ------------------------------------------------------------------
127
+
128
+ def __getattr__(self, name: str) -> Any: # pragma: no cover
129
+ # This proxy mechanism is thoroughly tested through integration tests
130
+ # (every cloud path method call triggers these branches), but edge cases
131
+ # like "non-callable attributes that are collections" are hard to trigger
132
+ # directly without synthetic mocking.
133
+ attr = getattr(self._async, name)
134
+
135
+ # Properties and plain values
136
+ if not callable(attr): # pragma: no cover
137
+ if _is_path(attr): # pragma: no cover
138
+ return _wrap(attr)
139
+ if isinstance(attr, (list, tuple)): # pragma: no cover
140
+ return type(attr)(_wrap(x) if _is_path(x) else x for x in attr)
141
+ return attr
142
+
143
+ # Check if it's a coroutine function or async generator
144
+ if inspect.iscoroutinefunction(attr): # pragma: no cover
145
+
146
+ def _sync_method(*args, **kwargs): # pragma: no cover
147
+ result = _SyncRunner.get().run(attr(*args, **kwargs))
148
+ # Wrap path results so chaining stays sync
149
+ if _is_path(result): # pragma: no cover
150
+ return _wrap(result)
151
+ return result
152
+
153
+ return _sync_method
154
+
155
+ if inspect.isasyncgenfunction(attr): # pragma: no cover
156
+
157
+ def _sync_iter_method(*args, **kwargs): # pragma: no cover
158
+ async_it = attr(*args, **kwargs)
159
+ for item in _SyncRunner.get().iter(async_it): # pragma: no cover
160
+ yield _wrap(item) if _is_path(item) else item
161
+
162
+ return _sync_iter_method
163
+
164
+ # Regular sync method — return as-is
165
+ def _passthrough(*args, **kwargs): # pragma: no cover
166
+ result = attr(*args, **kwargs)
167
+ if _is_path(result): # pragma: no cover
168
+ return _wrap(result)
169
+ return result
170
+
171
+ return _passthrough
172
+
173
+
174
+ def _is_path(obj) -> bool:
175
+ """Check if obj is a path-like instance that should be wrapped."""
176
+ from asanypath.common import CommonPurePathMixin
177
+
178
+ return isinstance(obj, CommonPurePathMixin)
179
+
180
+
181
+ def _wrap(path_obj):
182
+ """Wrap an async path in a sync proxy; pass SyncPath through unchanged."""
183
+ from asanypath.local import SyncPath
184
+ from asanypath.unsupported import UnsupportedProtocolPath
185
+
186
+ if isinstance(path_obj, (SyncPath, UnsupportedProtocolPath)):
187
+ return path_obj
188
+ return _SyncProxy(path_obj)
189
+
190
+
191
+ class AsAnyPath(ABC):
192
+ """Synchronous AsAnyPath — same interface, no await required.
193
+
194
+ Dispatches to the appropriate backend just like the async version.
195
+ Local file:// paths return SyncPath directly (zero overhead).
196
+ Cloud paths return a sync proxy backed by a shared background loop.
197
+ """
198
+
199
+ @classmethod
200
+ def __subclasshook__(cls, sub: type) -> bool:
201
+ from asanypath.local import SyncPath
202
+
203
+ return issubclass(sub, (SyncPath, _SyncProxy))
204
+
205
+ def __new__(cls, *args, **kwargs) -> _SyncProxy | Any:
206
+ from asanypath.asanypath import AsAnyPath as _AsyncAsAnyPath
207
+ from asanypath.local import AsyncPath
208
+ from asanypath.unsupported import UnsupportedProtocolPath
209
+
210
+ async_path = _AsyncAsAnyPath(*args, **kwargs)
211
+
212
+ if isinstance(async_path, AsyncPath):
213
+ from asanypath.local import SyncPath
214
+
215
+ return SyncPath(*(args or (".",)), **kwargs)
216
+
217
+ if isinstance(async_path, UnsupportedProtocolPath):
218
+ return async_path
219
+
220
+ return _SyncProxy(async_path)
@@ -0,0 +1,202 @@
1
+ # Copyright (C) 2026 Axis Communications AB, Lund, Sweden
2
+ #
3
+ # Use of this source code is governed by an MIT-style
4
+ # license that can be found in the LICENSE file or at
5
+ # https://opensource.org/licenses/MIT.
6
+
7
+ from __future__ import annotations
8
+
9
+ from os import PathLike
10
+ from pathlib import Path
11
+ from urllib.parse import urlsplit
12
+
13
+ from anyio import Path as AioPath
14
+
15
+ from asanypath.common import CommonPurePathMixin
16
+ from asanypath.exceptions import UnsupportedProtocolError
17
+
18
+
19
+ class UnsupportedProtocolPath(CommonPurePathMixin):
20
+ """Path-like placeholder for unknown protocols.
21
+
22
+ Pure path operations continue to work, while backend and I/O operations
23
+ fail with UnsupportedProtocolError when invoked.
24
+ """
25
+
26
+ _asanypath_placeholder = True
27
+
28
+ def __init__(
29
+ self,
30
+ part: str | Path | AioPath | CommonPurePathMixin | None = None,
31
+ *parts: str | Path | AioPath | CommonPurePathMixin,
32
+ ) -> None:
33
+ raw = str(part)
34
+ parsed = urlsplit(raw)
35
+ self._protocol = (parsed.scheme or "unsupported").lower()
36
+ super().__init__(part, *parts)
37
+
38
+ @property
39
+ def protocol(self) -> str:
40
+ return self._protocol
41
+
42
+ def _raise_unsupported(self, operation: str) -> None:
43
+ raise UnsupportedProtocolError(
44
+ f"Unsupported protocol: {self.protocol} (operation: {operation}, path: {self})"
45
+ )
46
+
47
+ def __bytes__(self) -> bytes:
48
+ return str(self).encode()
49
+
50
+ def __fspath__(self) -> str:
51
+ self._raise_unsupported("__fspath__")
52
+
53
+ def chmod(self, mode: int, *, follow_symlinks: bool = True) -> None:
54
+ self._raise_unsupported("chmod")
55
+
56
+ @classmethod
57
+ def cwd(cls):
58
+ raise UnsupportedProtocolError("Unsupported protocol operation: cwd")
59
+
60
+ def exists(self) -> bool:
61
+ self._raise_unsupported("exists")
62
+
63
+ def expanduser(self):
64
+ self._raise_unsupported("expanduser")
65
+
66
+ def glob(self, pattern: str, *, case_sensitive: bool | None = None):
67
+ self._raise_unsupported("glob")
68
+
69
+ def group(self) -> str:
70
+ self._raise_unsupported("group")
71
+
72
+ def hardlink_to(self, target: str | bytes | PathLike[str | bytes]) -> None:
73
+ self._raise_unsupported("hardlink_to")
74
+
75
+ @classmethod
76
+ def home(cls):
77
+ raise UnsupportedProtocolError("Unsupported protocol operation: home")
78
+
79
+ def is_block_device(self) -> bool:
80
+ self._raise_unsupported("is_block_device")
81
+
82
+ def is_char_device(self) -> bool:
83
+ self._raise_unsupported("is_char_device")
84
+
85
+ def is_dir(self) -> bool:
86
+ self._raise_unsupported("is_dir")
87
+
88
+ def is_fifo(self) -> bool:
89
+ self._raise_unsupported("is_fifo")
90
+
91
+ def is_file(self) -> bool:
92
+ self._raise_unsupported("is_file")
93
+
94
+ def is_junction(self) -> bool:
95
+ self._raise_unsupported("is_junction")
96
+
97
+ def is_mount(self) -> bool:
98
+ self._raise_unsupported("is_mount")
99
+
100
+ def is_socket(self) -> bool:
101
+ self._raise_unsupported("is_socket")
102
+
103
+ def is_symlink(self) -> bool:
104
+ self._raise_unsupported("is_symlink")
105
+
106
+ def iter_bytes(self, chunk_size: int | None = None):
107
+ self._raise_unsupported("iter_bytes")
108
+
109
+ def iterdir(self):
110
+ self._raise_unsupported("iterdir")
111
+
112
+ def lchmod(self, mode: int) -> None:
113
+ self._raise_unsupported("lchmod")
114
+
115
+ def lstat(self):
116
+ self._raise_unsupported("lstat")
117
+
118
+ def mkdir(self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False) -> None:
119
+ self._raise_unsupported("mkdir")
120
+
121
+ def open(
122
+ self,
123
+ mode: str = "r",
124
+ buffering: int = -1,
125
+ encoding: str | None = None,
126
+ errors: str | None = None,
127
+ newline: str | None = None,
128
+ ):
129
+ self._raise_unsupported("open")
130
+
131
+ def owner(self) -> str:
132
+ self._raise_unsupported("owner")
133
+
134
+ def read_bytes(self) -> bytes:
135
+ self._raise_unsupported("read_bytes")
136
+
137
+ def read_text(
138
+ self,
139
+ encoding: str | None = None,
140
+ errors: str | None = None,
141
+ ) -> str:
142
+ self._raise_unsupported("read_text")
143
+
144
+ def readlink(self):
145
+ self._raise_unsupported("readlink")
146
+
147
+ def rename(self, target: str | Path | CommonPurePathMixin):
148
+ self._raise_unsupported("rename")
149
+
150
+ def replace(self, target: str | Path | CommonPurePathMixin):
151
+ self._raise_unsupported("replace")
152
+
153
+ def resolve(self, strict: bool = False):
154
+ self._raise_unsupported("resolve")
155
+
156
+ def rglob(self, pattern: str, *, case_sensitive: bool | None = None):
157
+ self._raise_unsupported("rglob")
158
+
159
+ def rmdir(self) -> None:
160
+ self._raise_unsupported("rmdir")
161
+
162
+ def samefile(self, other_path: str | PathLike[str]) -> bool:
163
+ self._raise_unsupported("samefile")
164
+
165
+ def stat(self, *, follow_symlinks: bool = True):
166
+ self._raise_unsupported("stat")
167
+
168
+ def symlink_to(
169
+ self,
170
+ target: str | bytes | PathLike[str | bytes] | CommonPurePathMixin,
171
+ target_is_directory: bool = False,
172
+ ) -> None:
173
+ self._raise_unsupported("symlink_to")
174
+
175
+ def touch(self, mode: int = 0o666, exist_ok: bool = True) -> None:
176
+ self._raise_unsupported("touch")
177
+
178
+ def unlink(self, missing_ok: bool = False) -> None:
179
+ self._raise_unsupported("unlink")
180
+
181
+ def walk(
182
+ self,
183
+ top_down: bool = True,
184
+ on_error=None,
185
+ follow_symlinks: bool = False,
186
+ ):
187
+ self._raise_unsupported("walk")
188
+
189
+ def write_bytes(self, data: bytes) -> int:
190
+ self._raise_unsupported("write_bytes")
191
+
192
+ def write_text(
193
+ self,
194
+ data: str,
195
+ encoding: str | None = None,
196
+ errors: str | None = None,
197
+ newline: str | None = None,
198
+ ) -> int:
199
+ self._raise_unsupported("write_text")
200
+
201
+ def checksums(self) -> dict[str, str]:
202
+ self._raise_unsupported("checksums")