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/__init__.py +81 -0
- asanypath/__init__.pyi +62 -0
- asanypath/_credstore.py +102 -0
- asanypath/_transfer.py +216 -0
- asanypath/_version.py +24 -0
- asanypath/artifactory.py +366 -0
- asanypath/asanypath.py +556 -0
- asanypath/asanypath.pyi +225 -0
- asanypath/azure.py +274 -0
- asanypath/batcher.py +206 -0
- asanypath/cli.py +2566 -0
- asanypath/cloud.py +1042 -0
- asanypath/common.py +355 -0
- asanypath/exceptions.py +43 -0
- asanypath/ftp.py +543 -0
- asanypath/gcs.py +253 -0
- asanypath/http.py +516 -0
- asanypath/local.py +817 -0
- asanypath/options.py +57 -0
- asanypath/s3.py +277 -0
- asanypath/ssh.py +776 -0
- asanypath/sync.py +220 -0
- asanypath/unsupported.py +202 -0
- asanypath-2.0.0.dist-info/METADATA +591 -0
- asanypath-2.0.0.dist-info/RECORD +28 -0
- asanypath-2.0.0.dist-info/WHEEL +4 -0
- asanypath-2.0.0.dist-info/entry_points.txt +2 -0
- asanypath-2.0.0.dist-info/licenses/LICENSE +21 -0
asanypath/common.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
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
|
+
import fnmatch
|
|
10
|
+
import re
|
|
11
|
+
from os import PathLike, getenv, sep
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from typing import Self
|
|
18
|
+
|
|
19
|
+
from anyio import Path as AioPath
|
|
20
|
+
from yarl import URL
|
|
21
|
+
|
|
22
|
+
from asanypath.exceptions import InvalidPathError, UnsupportedProtocolError
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T", bound="CommonPurePathMixin")
|
|
25
|
+
|
|
26
|
+
SCHEME_SEP = "://"
|
|
27
|
+
OS_SEP = sep
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Cached regex patterns and base URL resolution (avoid repeated work)
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
# Pre-compiled: strip "file:" prefix variants
|
|
34
|
+
_FILE_PREFIX_RE: re.Pattern = re.compile(r"^file:/{,2}")
|
|
35
|
+
# Pre-compiled: collapse 3+ slashes
|
|
36
|
+
_MULTI_SLASH_RE: re.Pattern = re.compile(r"/{3,}")
|
|
37
|
+
# Pre-compiled: strip trailing slashes (preserves ://)
|
|
38
|
+
_TRAILING_SLASH_RE: re.Pattern = re.compile(r"(?<!:)/+$")
|
|
39
|
+
|
|
40
|
+
# Per-protocol compiled regex: matches "protocol://" prefix (case-insensitive).
|
|
41
|
+
# Bounded by number of protocols (~6 entries max).
|
|
42
|
+
_SCHEME_RE_CACHE: dict[str, re.Pattern] = {}
|
|
43
|
+
|
|
44
|
+
# Per-protocol resolved base URL prefix (from env vars).
|
|
45
|
+
# Bounded by number of protocols (~6 entries max).
|
|
46
|
+
# _BASE_URL_PREFIX_SENTINEL distinguishes "resolved to None" from "not yet resolved".
|
|
47
|
+
_BASE_URL_PREFIX_SENTINEL = object()
|
|
48
|
+
_BASE_URL_PREFIX_CACHE: dict[str, str | None | object] = {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_scheme_re(protocol: str) -> re.Pattern:
|
|
52
|
+
"""Get or compile the scheme-matching regex for a protocol."""
|
|
53
|
+
pat = _SCHEME_RE_CACHE.get(protocol)
|
|
54
|
+
if pat is None:
|
|
55
|
+
pat = re.compile(rf"^({protocol}:/{{,2}})?", re.IGNORECASE)
|
|
56
|
+
_SCHEME_RE_CACHE[protocol] = pat
|
|
57
|
+
return pat
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _get_base_url_prefix(protocol: str, aliases: dict[str, tuple[str, ...]]) -> str | None:
|
|
61
|
+
"""Resolve and cache the base URL prefix for a protocol from env vars.
|
|
62
|
+
|
|
63
|
+
Cache is bounded by number of protocols (finite, small).
|
|
64
|
+
"""
|
|
65
|
+
cached = _BASE_URL_PREFIX_CACHE.get(protocol, _BASE_URL_PREFIX_SENTINEL)
|
|
66
|
+
if cached is not _BASE_URL_PREFIX_SENTINEL:
|
|
67
|
+
return cached # type: ignore[return-value]
|
|
68
|
+
env_names = [
|
|
69
|
+
f"{protocol.upper()}_BASE_URL",
|
|
70
|
+
*aliases.get(protocol, ()),
|
|
71
|
+
]
|
|
72
|
+
base_url = next((getenv(name) for name in env_names if getenv(name)), None)
|
|
73
|
+
if not base_url:
|
|
74
|
+
_BASE_URL_PREFIX_CACHE[protocol] = None
|
|
75
|
+
return None
|
|
76
|
+
parsed = urlsplit(base_url if "://" in base_url else f"{protocol}://{base_url}")
|
|
77
|
+
if not parsed.netloc:
|
|
78
|
+
_BASE_URL_PREFIX_CACHE[protocol] = None
|
|
79
|
+
return None
|
|
80
|
+
base_path = parsed.path.strip("/")
|
|
81
|
+
result = f"{parsed.netloc}/{base_path}" if base_path else parsed.netloc
|
|
82
|
+
_BASE_URL_PREFIX_CACHE[protocol] = result
|
|
83
|
+
return result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CommonPurePathMixin:
|
|
87
|
+
"""Common implementation for pure path manipulation methods shared by all path types."""
|
|
88
|
+
|
|
89
|
+
protocol: str = "common" # Default protocol, should be overridden by subclasses
|
|
90
|
+
|
|
91
|
+
_BASE_URL_ENV_ALIASES: dict[str, tuple[str, ...]] = {
|
|
92
|
+
"art": ("ARTIFACTORY_BASE_URL", "ART_BASE_URL"),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
part: str | Path | AioPath | CommonPurePathMixin | None = None,
|
|
98
|
+
*parts: str | Path | AioPath | CommonPurePathMixin,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Parse path parts into internal representation."""
|
|
101
|
+
if not parts and part is None:
|
|
102
|
+
raise InvalidPathError("Cannot construct path from None or empty parts")
|
|
103
|
+
if self.protocol == "file":
|
|
104
|
+
# Fast path — skip regex and URL expansion
|
|
105
|
+
s = str(part)
|
|
106
|
+
if s.startswith("file:"):
|
|
107
|
+
s = _FILE_PREFIX_RE.sub("", s, count=1)
|
|
108
|
+
self._path = Path(s, *parts)
|
|
109
|
+
return
|
|
110
|
+
part = self._expand_protocol_base_url(str(part))
|
|
111
|
+
scheme_re = _get_scheme_re(self.protocol)
|
|
112
|
+
part = scheme_re.sub(f"{self.protocol}{SCHEME_SEP}", part, count=1)
|
|
113
|
+
if part.endswith(SCHEME_SEP) and parts:
|
|
114
|
+
part = part[:-1]
|
|
115
|
+
elif part.endswith(":"):
|
|
116
|
+
part = part + OS_SEP
|
|
117
|
+
joined = OS_SEP.join(map(str, (part, *parts)))
|
|
118
|
+
joined = _MULTI_SLASH_RE.sub("//", joined)
|
|
119
|
+
joined = _TRAILING_SLASH_RE.sub("", joined)
|
|
120
|
+
# Preserve pre-encoded query strings (e.g. AWS SigV4 %2F separators in
|
|
121
|
+
# presigned URLs) — yarl would otherwise percent-decode them and break
|
|
122
|
+
# the signature.
|
|
123
|
+
self._path = URL(joined, encoded="?" in joined)
|
|
124
|
+
|
|
125
|
+
def __repr__(self) -> str:
|
|
126
|
+
return f"{type(self).__name__}('{self}')"
|
|
127
|
+
|
|
128
|
+
def __str__(self) -> str:
|
|
129
|
+
return str(self._path)
|
|
130
|
+
|
|
131
|
+
def __eq__(self, other) -> bool:
|
|
132
|
+
if isinstance(other, self.__class__):
|
|
133
|
+
return self._path == other._path
|
|
134
|
+
if isinstance(other, str):
|
|
135
|
+
return str(self) == other
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def __hash__(self) -> int:
|
|
139
|
+
return hash(self._path)
|
|
140
|
+
|
|
141
|
+
def __lt__(self, other) -> bool:
|
|
142
|
+
if isinstance(other, (CommonPurePathMixin, str)):
|
|
143
|
+
return str(self) < str(other)
|
|
144
|
+
return NotImplemented
|
|
145
|
+
|
|
146
|
+
def __le__(self, other) -> bool:
|
|
147
|
+
if isinstance(other, (CommonPurePathMixin, str)):
|
|
148
|
+
return str(self) <= str(other)
|
|
149
|
+
return NotImplemented
|
|
150
|
+
|
|
151
|
+
def __gt__(self, other) -> bool:
|
|
152
|
+
if isinstance(other, (CommonPurePathMixin, str)):
|
|
153
|
+
return str(self) > str(other)
|
|
154
|
+
return NotImplemented
|
|
155
|
+
|
|
156
|
+
def __ge__(self, other) -> bool:
|
|
157
|
+
if isinstance(other, (CommonPurePathMixin, str)):
|
|
158
|
+
return str(self) >= str(other)
|
|
159
|
+
return NotImplemented
|
|
160
|
+
|
|
161
|
+
def __truediv__(self, other: T | str | PathLike[str]) -> Self:
|
|
162
|
+
if isinstance(other, self.__class__):
|
|
163
|
+
other = other.parts[bool(other.root) :]
|
|
164
|
+
elif isinstance(other, str):
|
|
165
|
+
if SCHEME_SEP in other and not other.startswith(self.protocol):
|
|
166
|
+
raise UnsupportedProtocolError(f"Cannot join {self.protocol} path with {other}")
|
|
167
|
+
other = [other.replace(self.protocol + SCHEME_SEP, "", 1)]
|
|
168
|
+
else:
|
|
169
|
+
raise UnsupportedProtocolError(f"Cannot join {self.protocol} path with {other}")
|
|
170
|
+
|
|
171
|
+
return type(self)(str(self), *other)
|
|
172
|
+
|
|
173
|
+
def __rtruediv__(self, other: T | str | PathLike[str]) -> Self:
|
|
174
|
+
if not isinstance(other, cls := type(self)):
|
|
175
|
+
other = cls(other)
|
|
176
|
+
return other / self
|
|
177
|
+
|
|
178
|
+
def absolute(self) -> Self:
|
|
179
|
+
return self if self.protocol != "file" else type(self)(self._path.absolute())
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def anchor(self) -> str:
|
|
183
|
+
return self.drive + self.root
|
|
184
|
+
|
|
185
|
+
def as_posix(self) -> str:
|
|
186
|
+
return str(self).replace(f"{self.protocol}{SCHEME_SEP}", OS_SEP)
|
|
187
|
+
|
|
188
|
+
def as_uri(self) -> str:
|
|
189
|
+
return str(self) if self.protocol != "file" else f"{self.protocol}{SCHEME_SEP}{self}"
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def drive(self) -> str:
|
|
193
|
+
return self.protocol + SCHEME_SEP if self.protocol != "file" else ""
|
|
194
|
+
|
|
195
|
+
def full_match(self, pattern: str, *, case_sensitive: bool | None = None) -> bool:
|
|
196
|
+
if not pattern:
|
|
197
|
+
raise ValueError("empty pattern")
|
|
198
|
+
repat = (
|
|
199
|
+
fnmatch.translate(pattern.replace("**", "@@@"))
|
|
200
|
+
.replace(".*", "[^/]*")
|
|
201
|
+
.replace("@@@", ".*")
|
|
202
|
+
)
|
|
203
|
+
flags = bool(case_sensitive) * re.IGNORECASE
|
|
204
|
+
return re.match(repat, str(self), flags) is not None
|
|
205
|
+
|
|
206
|
+
def is_absolute(self) -> bool:
|
|
207
|
+
return bool(self.anchor)
|
|
208
|
+
|
|
209
|
+
def is_relative_to(self, other: str | PathLike[str]) -> bool:
|
|
210
|
+
try:
|
|
211
|
+
self.relative_to(other)
|
|
212
|
+
return True
|
|
213
|
+
except ValueError:
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
def joinpath(self, *other: T | str) -> Self:
|
|
217
|
+
return self.__truediv__(*other)
|
|
218
|
+
|
|
219
|
+
def match(self, pattern: str, *, case_sensitive: bool | None = None) -> bool:
|
|
220
|
+
if not pattern:
|
|
221
|
+
raise ValueError("empty pattern")
|
|
222
|
+
repat = fnmatch.translate(pattern.replace("**", "*")).replace(".*", "[^/]*")
|
|
223
|
+
flags = bool(case_sensitive) * re.IGNORECASE
|
|
224
|
+
return re.match(f".*(?:{repat})", str(self), flags) is not None
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def name(self) -> str:
|
|
228
|
+
return self.parts[-1]
|
|
229
|
+
|
|
230
|
+
def _parent(self, idx: int = 1) -> Self:
|
|
231
|
+
remaining = self.parts[:-idx] or [self.drive]
|
|
232
|
+
return type(self)(*remaining)
|
|
233
|
+
|
|
234
|
+
parent = property(_parent)
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def parents(self) -> tuple[Self, ...]:
|
|
238
|
+
return tuple(self._parent(idx) for idx in range(1, len(self.parts) - bool(self.drive)))
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def parts(self) -> tuple[str, ...]:
|
|
242
|
+
match self.protocol:
|
|
243
|
+
case "file":
|
|
244
|
+
drive, parts = [], self._path.parts
|
|
245
|
+
case _:
|
|
246
|
+
drive = [self.drive]
|
|
247
|
+
parts = [p for p in str(self._path).split(SCHEME_SEP)[1].split(OS_SEP) if p]
|
|
248
|
+
return (*drive, *parts)
|
|
249
|
+
|
|
250
|
+
def relative_to(self, other: str | PathLike[str], *, walk_up: bool = False) -> Self:
|
|
251
|
+
try:
|
|
252
|
+
if self.protocol == "file":
|
|
253
|
+
return type(self)(self._path.relative_to(other))
|
|
254
|
+
if not (str(self).startswith(str(other)) or walk_up):
|
|
255
|
+
raise ValueError
|
|
256
|
+
other = type(self)(other).absolute()
|
|
257
|
+
if not str(self).startswith(str(other)):
|
|
258
|
+
raise ValueError
|
|
259
|
+
return type(self)(str(self).replace(str(other), ""))
|
|
260
|
+
except ValueError:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
f"{self} is not in the subpath of {other}"
|
|
263
|
+
"OR one path is relative and the other is absolute."
|
|
264
|
+
) from None
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def root(self) -> str:
|
|
268
|
+
try:
|
|
269
|
+
default_root = str(self._path.host)
|
|
270
|
+
except AttributeError:
|
|
271
|
+
default_root = self._path.root
|
|
272
|
+
return default_root
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def stem(self) -> str:
|
|
276
|
+
return self.name.rsplit(".", 1)[0]
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def suffix(self) -> str:
|
|
280
|
+
try:
|
|
281
|
+
return self.suffixes[-1]
|
|
282
|
+
except IndexError:
|
|
283
|
+
return ""
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def suffixes(self) -> list[str]:
|
|
287
|
+
_, *suffixes = self.name.rsplit(".")
|
|
288
|
+
return list(map(".{}".format, suffixes))
|
|
289
|
+
|
|
290
|
+
def with_name(self, name: str) -> Self:
|
|
291
|
+
return self.parent / name
|
|
292
|
+
|
|
293
|
+
def with_parent(self, parent: str | PathLike[str] | Self) -> Self:
|
|
294
|
+
return type(self)(parent) / self.name
|
|
295
|
+
|
|
296
|
+
def with_segments(self, *pathsegments: str | PathLike[str] | Self) -> Self:
|
|
297
|
+
return type(self)(*pathsegments)
|
|
298
|
+
|
|
299
|
+
def with_stem(self, stem: str) -> Self:
|
|
300
|
+
return self.parent / (stem + self.suffix)
|
|
301
|
+
|
|
302
|
+
def with_suffix(self, suffix: str) -> Self:
|
|
303
|
+
return self.parent / (self.stem + suffix)
|
|
304
|
+
|
|
305
|
+
def _resolve_base_url_prefix(self) -> str | None:
|
|
306
|
+
"""Resolve base URL host/path prefix for this protocol from environment.
|
|
307
|
+
|
|
308
|
+
Uses module-level cache bounded by protocol count (~6 entries).
|
|
309
|
+
"""
|
|
310
|
+
return _get_base_url_prefix(self.protocol, self._BASE_URL_ENV_ALIASES)
|
|
311
|
+
|
|
312
|
+
def _expand_protocol_base_url(self, part: str) -> str:
|
|
313
|
+
"""Expand shorthand protocol URLs using configured base URL.
|
|
314
|
+
|
|
315
|
+
Example with ARTIFACTORY_BASE_URL=artifacts.example.com/artifactory:
|
|
316
|
+
art://repo/path -> art://artifacts.example.com/artifactory/repo/path
|
|
317
|
+
"""
|
|
318
|
+
if self.protocol == "file":
|
|
319
|
+
return part
|
|
320
|
+
|
|
321
|
+
base_prefix = self._resolve_base_url_prefix()
|
|
322
|
+
|
|
323
|
+
# Convert http(s):// URLs that match our base URL to our protocol scheme
|
|
324
|
+
if base_prefix and self.protocol not in ("http", "https"):
|
|
325
|
+
for http_scheme in ("https://", "http://"):
|
|
326
|
+
if part.lower().startswith(http_scheme):
|
|
327
|
+
rest = part[len(http_scheme) :]
|
|
328
|
+
bp = base_prefix.rstrip("/")
|
|
329
|
+
if rest.rstrip("/") == bp or rest.startswith(f"{bp}/"):
|
|
330
|
+
return f"{self.protocol}{SCHEME_SEP}{rest}"
|
|
331
|
+
|
|
332
|
+
if not base_prefix:
|
|
333
|
+
return part
|
|
334
|
+
|
|
335
|
+
scheme_re = _get_scheme_re(self.protocol)
|
|
336
|
+
normalized = scheme_re.sub(f"{self.protocol}{SCHEME_SEP}", part, count=1)
|
|
337
|
+
normalized_rest = normalized.split(SCHEME_SEP, 1)[1].lstrip("/")
|
|
338
|
+
base_prefix = base_prefix.rstrip("/")
|
|
339
|
+
if normalized_rest == base_prefix or normalized_rest.startswith(f"{base_prefix}/"):
|
|
340
|
+
return normalized
|
|
341
|
+
|
|
342
|
+
parsed = urlsplit(normalized)
|
|
343
|
+
host = parsed.netloc
|
|
344
|
+
# Heuristic: only expand shorthand hosts (e.g., repository names),
|
|
345
|
+
# not fully qualified hostnames, localhost, or explicit host:port.
|
|
346
|
+
if not host or "." in host or ":" in host or host == "localhost":
|
|
347
|
+
return normalized
|
|
348
|
+
|
|
349
|
+
rel_path = f"{host}{parsed.path}".lstrip("/")
|
|
350
|
+
expanded = f"{self.protocol}{SCHEME_SEP}{base_prefix}/{rel_path}".rstrip("/")
|
|
351
|
+
if parsed.query:
|
|
352
|
+
expanded = f"{expanded}?{parsed.query}"
|
|
353
|
+
if parsed.fragment:
|
|
354
|
+
expanded = f"{expanded}#{parsed.fragment}"
|
|
355
|
+
return expanded
|
asanypath/exceptions.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
"""Exceptions for AsAnyPath."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsAnyPathError(Exception):
|
|
11
|
+
"""Base exception for AsAnyPath."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidPathError(AsAnyPathError, TypeError):
|
|
17
|
+
"""Raised when a path argument is invalid."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnsupportedProtocolError(AsAnyPathError):
|
|
23
|
+
"""Raised when a protocol is not supported."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PathNotFoundError(AsAnyPathError, FileNotFoundError):
|
|
29
|
+
"""Raised when a path does not exist."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PathIsADirectoryError(AsAnyPathError, IsADirectoryError):
|
|
35
|
+
"""Raised when a file operation is attempted on a directory."""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PathNotADirectoryError(AsAnyPathError, NotADirectoryError):
|
|
41
|
+
"""Raised when a directory operation is attempted on a file."""
|
|
42
|
+
|
|
43
|
+
pass
|