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/asanypath.py ADDED
@@ -0,0 +1,556 @@
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 abc import ABC, abstractmethod
10
+ from collections.abc import AsyncIterator, Callable
11
+ from os import PathLike, getenv, stat_result
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any, TypeVar
14
+ from urllib.parse import urlsplit
15
+
16
+ if TYPE_CHECKING:
17
+ from typing import Self
18
+
19
+ from anyio import AsyncFile
20
+ from anyio import Path as AioPath
21
+
22
+ from asanypath.exceptions import InvalidPathError
23
+ from asanypath.unsupported import UnsupportedProtocolPath
24
+
25
+ if TYPE_CHECKING:
26
+ from asanypath.options import AccessPolicy, AccessPolicyPatch, BackendOptions
27
+
28
+
29
+ class _LazyDict(dict):
30
+ def _ensure(self):
31
+ if not self:
32
+ from asanypath.artifactory import ArtifactoryPath
33
+ from asanypath.azure import AzurePath
34
+ from asanypath.gcs import GCSPath
35
+ from asanypath.http import HTTPPath, HTTPSPath
36
+ from asanypath.local import AsyncPath
37
+ from asanypath.s3 import S3Path
38
+
39
+ self.update(
40
+ file=AsyncPath,
41
+ s3=S3Path,
42
+ gs=GCSPath,
43
+ az=AzurePath,
44
+ http=HTTPPath,
45
+ https=HTTPSPath,
46
+ art=ArtifactoryPath,
47
+ )
48
+ try:
49
+ from asanypath.ssh import SSHPath
50
+
51
+ self.update(ssh=SSHPath)
52
+ except ImportError:
53
+ pass
54
+ try:
55
+ from asanypath.ftp import FTPPath, FTPSPath
56
+
57
+ self.update(ftp=FTPPath, ftps=FTPSPath)
58
+ except ImportError:
59
+ pass
60
+
61
+ def items(self):
62
+ self._ensure()
63
+ return super().items()
64
+
65
+ def __iter__(self):
66
+ self._ensure()
67
+ return super().__iter__()
68
+
69
+ def __contains__(self, key):
70
+ self._ensure()
71
+ return super().__contains__(key)
72
+
73
+ def __missing__(self, key):
74
+ self._ensure()
75
+ if key not in self:
76
+ return UnsupportedProtocolPath
77
+ return self[key] # KeyError if truly unknown
78
+
79
+
80
+ PROTOCOL_MAP = _LazyDict()
81
+
82
+ # Flavors whose abstract-method implementation has been validated once.
83
+ _VALIDATED_FLAVORS: set[type] = set()
84
+
85
+
86
+ def _match_base_url_flavor(url: str) -> type | None:
87
+ """Check if an HTTP(S) URL matches any backend's configured BASE_URL."""
88
+ parsed = urlsplit(url)
89
+ scheme = parsed.scheme.lower() or "file"
90
+
91
+ if scheme in ("http", "https"):
92
+ url_host_path = f"{parsed.netloc}{parsed.path}".rstrip("/")
93
+ for proto, klass in PROTOCOL_MAP.items():
94
+ if proto in ("http", "https", "file"):
95
+ continue
96
+
97
+ env_names = [
98
+ f"{proto.upper()}_BASE_URL",
99
+ *getattr(klass, "_BASE_URL_ENV_ALIASES", {}).get(proto, ()),
100
+ ]
101
+ for base_url in filter(None, map(getenv, env_names)):
102
+ bp = urlsplit(base_url if "://" in base_url else f"https://{base_url}")
103
+ if not bp.netloc:
104
+ break
105
+ bp_path = bp.path.strip("/")
106
+ prefix = f"{bp.netloc}/{bp_path}" if bp_path else bp.netloc
107
+ if url_host_path == prefix or url_host_path.startswith(f"{prefix}/"):
108
+ return klass
109
+ return PROTOCOL_MAP[scheme]
110
+
111
+
112
+ _T = TypeVar("_T", bound="AsAnyPurePath")
113
+
114
+
115
+ class AsAnyPurePath(ABC):
116
+ """Virtual superclass for pure path manipulation (no I/O).
117
+
118
+ Similar to pathlib.PurePath, this provides only path string manipulation
119
+ methods that do not access the filesystem. Concrete implementations can
120
+ share a common implementation for these methods via CloudPathMixin or similar.
121
+
122
+ Dispatches to the appropriate concrete implementation based on the URL protocol.
123
+ """
124
+
125
+ def __new__(
126
+ cls,
127
+ arg: str | Path | AioPath | _T | None = ".",
128
+ *args: str | Path | AioPath | _T,
129
+ **kwargs,
130
+ ) -> _T:
131
+ if arg is None:
132
+ raise InvalidPathError("expected str, bytes or os.PathLike object, not NoneType")
133
+ flavor = _match_base_url_flavor(str(arg))
134
+ # Validate a flavor's abstract-method coverage once, then memoize:
135
+ # dir()+set-diff is ~half the per-construction cost otherwise.
136
+ if flavor not in _VALIDATED_FLAVORS:
137
+ from asanypath.local import AsyncPath, SyncPath
138
+
139
+ if flavor not in (AsyncPath, SyncPath) and not getattr(
140
+ flavor, "_asanypath_placeholder", False
141
+ ):
142
+ if missing := "\n* ".join(cls.__abstractmethods__ - set(dir(flavor))):
143
+ raise NotImplementedError(
144
+ f"{flavor.__name__} is missing implementations for methods:\n* {missing}"
145
+ )
146
+ _VALIDATED_FLAVORS.add(flavor)
147
+ return flavor(arg, *args, **kwargs) if kwargs else flavor(arg, *args)
148
+
149
+ @classmethod
150
+ def __subclasshook__(cls, sub: type) -> bool:
151
+ from asanypath.local import SyncPath
152
+
153
+ return sub in [*PROTOCOL_MAP.values(), SyncPath]
154
+
155
+ @abstractmethod
156
+ def __rtruediv__(self, other: str | PathLike[str]) -> Self:
157
+ pass
158
+
159
+ @abstractmethod
160
+ def __truediv__(self, other: str | PathLike[str]) -> Self:
161
+ pass
162
+
163
+ @property
164
+ @abstractmethod
165
+ def anchor(self) -> str:
166
+ pass
167
+
168
+ @abstractmethod
169
+ def as_posix(self) -> str:
170
+ pass
171
+
172
+ @abstractmethod
173
+ def as_uri(self) -> str:
174
+ pass
175
+
176
+ @property
177
+ @abstractmethod
178
+ def drive(self) -> str:
179
+ pass
180
+
181
+ @abstractmethod
182
+ def full_match(self, pattern: str, *, case_sensitive: bool | None = None) -> bool:
183
+ pass
184
+
185
+ @abstractmethod
186
+ def is_absolute(self) -> bool:
187
+ pass
188
+
189
+ @abstractmethod
190
+ def is_relative_to(self, other: str | PathLike[str]) -> bool:
191
+ pass
192
+
193
+ @abstractmethod
194
+ def joinpath(self, *args: str | PathLike[str]) -> Self:
195
+ pass
196
+
197
+ @abstractmethod
198
+ def match(self, pattern: str, *, case_sensitive: bool | None = None) -> bool:
199
+ pass
200
+
201
+ @property
202
+ @abstractmethod
203
+ def name(self) -> str:
204
+ pass
205
+
206
+ @property
207
+ @abstractmethod
208
+ def parent(self) -> Self:
209
+ pass
210
+
211
+ @property
212
+ @abstractmethod
213
+ def parents(self) -> tuple[Self, ...]:
214
+ pass
215
+
216
+ @property
217
+ @abstractmethod
218
+ def parts(self) -> tuple[str, ...]:
219
+ pass
220
+
221
+ @abstractmethod
222
+ def relative_to(self, *other: str | PathLike[str], walk_up: bool = False) -> Self:
223
+ pass
224
+
225
+ @property
226
+ @abstractmethod
227
+ def root(self) -> str:
228
+ pass
229
+
230
+ @property
231
+ @abstractmethod
232
+ def stem(self) -> str:
233
+ pass
234
+
235
+ @property
236
+ @abstractmethod
237
+ def suffix(self) -> str:
238
+ pass
239
+
240
+ @property
241
+ @abstractmethod
242
+ def suffixes(self) -> list[str]:
243
+ pass
244
+
245
+ @abstractmethod
246
+ def with_name(self, name: str) -> Self:
247
+ pass
248
+
249
+ @abstractmethod
250
+ def with_parent(self, parent: str | PathLike[str] | Self) -> Self:
251
+ pass
252
+
253
+ @abstractmethod
254
+ def with_segments(self, *pathsegments: str | PathLike[str] | Self) -> Self:
255
+ pass
256
+
257
+ @abstractmethod
258
+ def with_stem(self, stem: str) -> Self:
259
+ pass
260
+
261
+ @abstractmethod
262
+ def with_suffix(self, suffix: str) -> Self:
263
+ pass
264
+
265
+
266
+ class AsAnyPath(AsAnyPurePath):
267
+ """Virtual superclass for all AsAnyPath implementations.
268
+
269
+ Similar to pathlib.Path, this extends AsAnyPurePath with filesystem I/O methods.
270
+ Exposes the full interface of all path implementations, and dispatches to the
271
+ appropriate implementation based on the protocol of the input path.
272
+ """
273
+
274
+ @abstractmethod
275
+ def __bytes__(self) -> bytes:
276
+ pass
277
+
278
+ @abstractmethod
279
+ def __fspath__(self) -> str:
280
+ pass
281
+
282
+ @abstractmethod
283
+ def absolute(self) -> Self:
284
+ pass
285
+
286
+ @abstractmethod
287
+ def chmod(self, mode: int, *, follow_symlinks: bool = True) -> None:
288
+ pass
289
+
290
+ @classmethod
291
+ @abstractmethod
292
+ def cwd(cls) -> Self:
293
+ pass
294
+
295
+ @abstractmethod
296
+ async def exists(self) -> bool:
297
+ pass
298
+
299
+ @abstractmethod
300
+ def expanduser(self) -> Self:
301
+ pass
302
+
303
+ @abstractmethod
304
+ async def get_access_policy(
305
+ self, *, backend_options: BackendOptions | None = None
306
+ ) -> AccessPolicy:
307
+ """Return access controls managed directly by this backend."""
308
+ pass
309
+
310
+ @abstractmethod
311
+ async def glob(
312
+ self, pattern: str, *, case_sensitive: bool | None = None
313
+ ) -> AsyncIterator[Self]:
314
+ pass
315
+
316
+ @abstractmethod
317
+ def group(self) -> str:
318
+ pass
319
+
320
+ @abstractmethod
321
+ def hardlink_to(self, target: str | bytes | PathLike[str | bytes]) -> None:
322
+ pass
323
+
324
+ @classmethod
325
+ @abstractmethod
326
+ def home(cls) -> Self:
327
+ pass
328
+
329
+ @abstractmethod
330
+ def is_block_device(self) -> bool:
331
+ pass
332
+
333
+ @abstractmethod
334
+ def is_char_device(self) -> bool:
335
+ pass
336
+
337
+ @abstractmethod
338
+ async def is_dir(self) -> bool:
339
+ pass
340
+
341
+ @abstractmethod
342
+ def is_fifo(self) -> bool:
343
+ pass
344
+
345
+ @abstractmethod
346
+ async def is_file(self) -> bool:
347
+ pass
348
+
349
+ @abstractmethod
350
+ def is_junction(self) -> bool:
351
+ pass
352
+
353
+ @abstractmethod
354
+ def is_mount(self) -> bool:
355
+ pass
356
+
357
+ @abstractmethod
358
+ def is_socket(self) -> bool:
359
+ pass
360
+
361
+ @abstractmethod
362
+ def is_symlink(self) -> bool:
363
+ pass
364
+
365
+ @abstractmethod
366
+ async def iterdir(self) -> AsyncIterator[Self]:
367
+ pass
368
+
369
+ @abstractmethod
370
+ def lchmod(self, mode: int) -> None:
371
+ pass
372
+
373
+ @abstractmethod
374
+ def lstat(self) -> stat_result:
375
+ pass
376
+
377
+ @abstractmethod
378
+ async def mkdir(self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False) -> None:
379
+ pass
380
+
381
+ @abstractmethod
382
+ def open(
383
+ self,
384
+ mode: str = "r",
385
+ buffering: int = -1,
386
+ encoding: str | None = None,
387
+ errors: str | None = None,
388
+ newline: str | None = None,
389
+ *,
390
+ backend_options: BackendOptions | None = None,
391
+ ) -> AsyncFile[Any]:
392
+ pass
393
+
394
+ @abstractmethod
395
+ def owner(self) -> str:
396
+ pass
397
+
398
+ @abstractmethod
399
+ async def read_bytes(self) -> bytes:
400
+ pass
401
+
402
+ @abstractmethod
403
+ def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]:
404
+ """Yield file contents in byte chunks (``chunk_size=None`` uses a default)."""
405
+ ...
406
+
407
+ @abstractmethod
408
+ async def read_text(
409
+ self,
410
+ encoding: str | None = None,
411
+ errors: str | None = None,
412
+ newline: str | None = None,
413
+ ) -> str:
414
+ pass
415
+
416
+ @abstractmethod
417
+ def readlink(self) -> Self:
418
+ pass
419
+
420
+ @abstractmethod
421
+ async def rename(
422
+ self,
423
+ target: str | Path | Self,
424
+ *,
425
+ force: bool = False,
426
+ destination_backend_options: BackendOptions | None = None,
427
+ ) -> Self:
428
+ pass
429
+
430
+ @abstractmethod
431
+ async def replace(
432
+ self,
433
+ target: str | Path | Self,
434
+ *,
435
+ destination_backend_options: BackendOptions | None = None,
436
+ ) -> Self:
437
+ pass
438
+
439
+ @abstractmethod
440
+ def resolve(self, strict: bool = False) -> Self:
441
+ pass
442
+
443
+ @abstractmethod
444
+ async def rglob(
445
+ self, pattern: str, *, case_sensitive: bool | None = None
446
+ ) -> AsyncIterator[Self]:
447
+ pass
448
+
449
+ @abstractmethod
450
+ async def rmdir(self) -> None:
451
+ pass
452
+
453
+ @abstractmethod
454
+ def samefile(self, other_path: str | PathLike[str]) -> bool:
455
+ pass
456
+
457
+ @abstractmethod
458
+ async def stat(self, *, follow_symlinks: bool = True) -> stat_result:
459
+ pass
460
+
461
+ @abstractmethod
462
+ def symlink_to(
463
+ self,
464
+ target: str | bytes | PathLike[str | bytes] | Self,
465
+ target_is_directory: bool = False,
466
+ ) -> None:
467
+ pass
468
+
469
+ @abstractmethod
470
+ async def touch(self, mode: int = 0o666, exist_ok: bool = True) -> None:
471
+ pass
472
+
473
+ @abstractmethod
474
+ async def unlink(self, missing_ok: bool = False) -> None:
475
+ pass
476
+
477
+ @abstractmethod
478
+ async def update_access_policy(
479
+ self,
480
+ policy_patch: AccessPolicyPatch,
481
+ *,
482
+ backend_options: BackendOptions | None = None,
483
+ ) -> None:
484
+ """Apply targeted access-control changes managed by this backend."""
485
+ pass
486
+
487
+ @abstractmethod
488
+ async def walk(
489
+ self,
490
+ top_down: bool = True,
491
+ on_error: Callable[[OSError], object] | None = None,
492
+ follow_symlinks: bool = False,
493
+ ) -> AsyncIterator[tuple[Self, list[str], list[str]]]:
494
+ pass
495
+
496
+ @abstractmethod
497
+ async def write_bytes(
498
+ self, data: bytes, *, backend_options: BackendOptions | None = None
499
+ ) -> int:
500
+ pass
501
+
502
+ @abstractmethod
503
+ async def write_text(
504
+ self,
505
+ data: str,
506
+ encoding: str | None = None,
507
+ errors: str | None = None,
508
+ newline: str | None = None,
509
+ *,
510
+ backend_options: BackendOptions | None = None,
511
+ ) -> int:
512
+ pass
513
+
514
+ @abstractmethod
515
+ async def checksums(self) -> dict[str, str]:
516
+ pass
517
+
518
+
519
+ def register_protocol(protocol: str, path_class: type[AsAnyPurePath]) -> None:
520
+ """
521
+ Register a custom protocol handler.
522
+
523
+ This allows users to extend AsAnyPath with custom cloud storage providers
524
+ or other path implementations.
525
+
526
+ Args:
527
+ protocol: The protocol scheme (e.g., 'custom', 'mycloud')
528
+ path_class: A AsAnyPurePath (or AsAnyPath) subclass to handle this protocol
529
+
530
+ Raises:
531
+ ValueError: If protocol is invalid or path_class is not a AsAnyPurePath subclass
532
+
533
+ Example:
534
+ >>> class MyCloudPath(AsAnyPath):
535
+ ... # implementation
536
+ ... pass
537
+ >>> register_protocol("mycloud", MyCloudPath)
538
+ >>> p = AsAnyPath("mycloud://bucket/key")
539
+ """
540
+ if not protocol:
541
+ raise ValueError("Protocol cannot be empty")
542
+
543
+ if not isinstance(path_class, type) or not issubclass(path_class, AsAnyPurePath):
544
+ raise ValueError(f"{path_class} must be a subclass of AsAnyPurePath")
545
+
546
+ PROTOCOL_MAP[protocol.lower()] = path_class
547
+
548
+
549
+ def list_protocols() -> list[str]:
550
+ """
551
+ List all registered protocols.
552
+
553
+ Returns:
554
+ A sorted list of all registered protocol schemes
555
+ """
556
+ return sorted(set(PROTOCOL_MAP))