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/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
@@ -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