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/http.py ADDED
@@ -0,0 +1,516 @@
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 base64 import b64encode
10
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable
11
+ from os import getenv, linesep
12
+ from typing import Literal, TypeVar
13
+ from xml.etree.ElementTree import XMLParser
14
+
15
+ import msgspec
16
+
17
+ from asanypath.cloud import CloudPathMixin
18
+ from asanypath_native import (
19
+ http_delete,
20
+ http_exists,
21
+ http_get,
22
+ http_head,
23
+ http_options,
24
+ http_patch,
25
+ http_post,
26
+ http_put,
27
+ http_scrape_links,
28
+ )
29
+
30
+ try:
31
+ from asanypath_native import http_request
32
+ except ImportError: # pragma: no cover
33
+ http_request = None # type: ignore[assignment]
34
+
35
+ T = TypeVar("T", bound="HTTPPath")
36
+
37
+
38
+ class _TagCollector:
39
+ """SAX-style target for stdlib XMLParser that collects matching tag texts."""
40
+
41
+ __slots__ = ("_tagmap", "_current", "_text", "results")
42
+
43
+ def __init__(self, tagmap: dict[str, str]):
44
+ self._tagmap = tagmap
45
+ self._current: str | None = None
46
+ self._text: list[str] = []
47
+ self.results: list[tuple[str, str]] = []
48
+
49
+ def start(self, tag, attrib):
50
+ if tag in self._tagmap:
51
+ self._current = tag
52
+ self._text = []
53
+
54
+ def end(self, tag):
55
+ if tag == self._current:
56
+ self.results.append((self._tagmap[tag], "".join(self._text)))
57
+ self._current = None
58
+
59
+ def data(self, data):
60
+ if self._current is not None:
61
+ self._text.append(data)
62
+
63
+ def close(self):
64
+ return self.results
65
+
66
+
67
+ async def assemble_xml_chunks(
68
+ chunks: list[bytes], ns: str | None = None, tags: str | list[str] = None
69
+ ) -> AsyncGenerator[tuple[str, str], None]:
70
+ if isinstance(tags, str):
71
+ tags = [tags]
72
+ tagmap = {t: t for t in tags}
73
+ if isinstance(ns, str):
74
+ tagmap = {f"{{{ns}}}{t}": t for t in tags}
75
+ target = _TagCollector(tagmap)
76
+ parser = XMLParser(target=target)
77
+ for chunk in chunks:
78
+ parser.feed(chunk)
79
+ while target.results:
80
+ yield target.results.pop(0)
81
+ parser.close()
82
+ while target.results: # pragma: no cover
83
+ yield target.results.pop(0) # pragma: no cover
84
+
85
+
86
+ _NATIVE_VERBS = {
87
+ "GET": http_get,
88
+ "PUT": http_put,
89
+ "POST": http_post,
90
+ "HEAD": http_head,
91
+ "DELETE": http_delete,
92
+ "OPTIONS": http_options,
93
+ "PATCH": http_patch,
94
+ }
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # HTTPResponse — structured return from request()
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ class HTTPResponse(msgspec.Struct, frozen=True):
103
+ """Structured response from HTTPPath.request()."""
104
+
105
+ status_code: int
106
+ body: bytes
107
+ headers: dict[str, str]
108
+
109
+ @property
110
+ def ok(self) -> bool:
111
+ """True if status_code is 2xx."""
112
+ return 200 <= self.status_code < 300
113
+
114
+ def json(self) -> object:
115
+ """Parse body as JSON."""
116
+ return msgspec.json.decode(self.body)
117
+
118
+ def text(self, encoding: str = "utf-8", errors: str = "strict") -> str:
119
+ """Decode body as text."""
120
+ return self.body.decode(encoding=encoding, errors=errors)
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Auth helpers
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ def _build_auth_headers() -> list[tuple[str, str]]:
129
+ """Build auth headers from environment variables.
130
+
131
+ Detection order:
132
+ - HTTP_AUTH_USER + HTTP_AUTH_PASSWORD → Basic auth
133
+ - HTTP_AUTH_TOKEN (+ optional HTTP_AUTH_HEADER) → Bearer or custom header
134
+ """
135
+ user = getenv("HTTP_AUTH_USER")
136
+ password = getenv("HTTP_AUTH_PASSWORD")
137
+ if user and password is not None:
138
+ encoded = b64encode(f"{user}:{password}".encode()).decode("ascii")
139
+ return [("authorization", f"Basic {encoded}")]
140
+
141
+ token = getenv("HTTP_AUTH_TOKEN")
142
+ if token:
143
+ header_name = getenv("HTTP_AUTH_HEADER", "authorization")
144
+ if header_name.lower() == "authorization":
145
+ return [("authorization", f"Bearer {token}")]
146
+ return [(header_name, token)]
147
+
148
+ return []
149
+
150
+
151
+ class HTTPPath(CloudPathMixin):
152
+ """Async path implementation for HTTP, backed by Rust/reqwest.
153
+
154
+ Authentication is configured via environment variables:
155
+ - HTTP_AUTH_USER + HTTP_AUTH_PASSWORD → Basic auth
156
+ - HTTP_AUTH_TOKEN → Bearer token (or custom header via HTTP_AUTH_HEADER)
157
+
158
+ Auth headers are automatically injected into all requests (read_bytes,
159
+ write_bytes, exists, etc.) and into the explicit request() method.
160
+
161
+ Directory listing (``iterdir``/``walk``/``glob``) defaults to a generic
162
+ ``a[href]`` scrape (``listing="auto"``) which works for Python's
163
+ ``http.server``, Apache, and nginx autoindex pages. Pass an explicit
164
+ preset (``"python"``, ``"apache"``, ``"nginx"``) or a raw CSS selector
165
+ for tighter matching, ``listing_attr`` to override the extracted
166
+ attribute (default ``href``), or ``listing=None`` to disable listing
167
+ entirely (in which case dir-style methods raise ``ValueError``).
168
+
169
+ Examples:
170
+ AsAnyPath("http://localhost:8000/") # auto
171
+ AsAnyPath("http://server/dir/", listing="td a[href]")
172
+ """
173
+
174
+ protocol: str = "http"
175
+
176
+ #: Named selector presets for common directory-listing servers.
177
+ #: Each entry is (css_selector, attribute_name).
178
+ LISTING_PRESETS: dict[str, tuple[str, str]] = {
179
+ "auto": ("a[href]", "href"),
180
+ "python": ("li a[href]", "href"),
181
+ "apache": ("td a[href]", "href"),
182
+ "nginx": ("a[href]", "href"),
183
+ }
184
+
185
+ def __init__(
186
+ self,
187
+ *parts,
188
+ listing: str | None = "auto",
189
+ listing_attr: str | None = None,
190
+ ) -> None:
191
+ super().__init__(*parts)
192
+ self._listing = listing
193
+ self._listing_attr = listing_attr
194
+ # Pathlib normalises trailing slashes away; preserve the hint so
195
+ # iterdir/is_dir can distinguish directory-style URLs.
196
+ self._trailing_slash = bool(parts) and str(parts[-1]).endswith("/")
197
+
198
+ def _resolve_listing(self) -> tuple[str, str] | None:
199
+ """Return (selector, attr) for the configured listing, or None if unset."""
200
+ if self._listing is None:
201
+ return None
202
+ preset = self.LISTING_PRESETS.get(self._listing)
203
+ if preset is not None:
204
+ sel, attr = preset
205
+ return sel, self._listing_attr or attr
206
+ return self._listing, self._listing_attr or "href"
207
+
208
+ def _auth_headers(self) -> list[tuple[str, str]]:
209
+ """Return auth headers for this path. Override for per-instance auth."""
210
+ return _build_auth_headers()
211
+
212
+ def _merge_headers(
213
+ self,
214
+ user_headers: dict[str, str] | list[tuple[str, str]] | None,
215
+ ) -> list[tuple[str, str]] | None:
216
+ """Merge auth headers with user-supplied headers."""
217
+ auth = self._auth_headers()
218
+ if not user_headers and not auth:
219
+ return None
220
+ merged = list(auth)
221
+ if user_headers:
222
+ extra = (
223
+ list(user_headers.items()) if isinstance(user_headers, dict) else list(user_headers)
224
+ )
225
+ # User-supplied headers override auth headers with same name
226
+ extra_keys = {ek.lower() for ek, _ in extra}
227
+ merged = [(k, v) for k, v in merged if k.lower() not in extra_keys]
228
+ merged.extend(extra)
229
+ return merged or None
230
+
231
+ async def request(
232
+ self,
233
+ method: Literal["GET", "PUT", "POST", "HEAD", "DELETE", "OPTIONS", "PATCH"],
234
+ url: str | None = None,
235
+ *,
236
+ headers: dict[str, str] | list[tuple[str, str]] | None = None,
237
+ data: bytes | None = None,
238
+ json: object | None = None,
239
+ ) -> HTTPResponse:
240
+ """Execute an HTTP request, returning a structured HTTPResponse.
241
+
242
+ Unlike read_bytes/write_bytes, this does NOT raise on non-2xx status.
243
+
244
+ Args:
245
+ method: HTTP verb.
246
+ url: Target URL. Defaults to str(self).
247
+ headers: Extra headers (merged with auth headers).
248
+ data: Raw body bytes for PUT/POST/PATCH.
249
+ json: JSON-serializable object (sets Content-Type and body).
250
+ """
251
+ if url is None:
252
+ url = str(self)
253
+ if http_request is None:
254
+ raise ImportError(
255
+ "http_request not available; upgrade asanypath-native to use request()"
256
+ )
257
+ if json is not None:
258
+ data = msgspec.json.encode(json)
259
+ headers = dict(headers) if isinstance(headers, dict) else dict(headers or [])
260
+ headers.setdefault("content-type", "application/json")
261
+ merged = self._merge_headers(headers)
262
+ status_code, body, resp_headers = await http_request(
263
+ method,
264
+ url,
265
+ body=data,
266
+ headers=merged,
267
+ )
268
+ return HTTPResponse(status_code=status_code, body=body, headers=resp_headers)
269
+
270
+ async def exists(self) -> bool:
271
+ return await http_exists(str(self), headers=self._merge_headers(None))
272
+
273
+ async def _probe_dir_slash(self) -> bool:
274
+ """Probe ``url + "/"`` via HEAD; on success, mark self as directory."""
275
+ if self._trailing_slash:
276
+ return True
277
+ if await http_exists(str(self) + "/", headers=self._merge_headers(None)):
278
+ self._trailing_slash = True
279
+ return True
280
+ return False
281
+
282
+ async def is_dir(self) -> bool:
283
+ """True if this path looks like a directory listing.
284
+
285
+ When ``listing`` is set (default ``"auto"``), URLs ending in ``/``
286
+ are directories. URLs without a trailing slash are probed via HEAD
287
+ on ``url + "/"`` and, on success, transparently treated as
288
+ directories (the trailing slash is remembered on the instance).
289
+ If listing has been explicitly disabled (``listing=None``),
290
+ raises ValueError.
291
+ """
292
+ if self._resolve_listing() is None:
293
+ raise ValueError(
294
+ "HTTPPath.is_dir() called with listing=None; "
295
+ "pass a 'listing' kwarg (e.g. 'auto', 'python', 'apache', "
296
+ "'nginx', or a CSS selector)"
297
+ )
298
+ return await self._probe_dir_slash()
299
+
300
+ async def is_file(self) -> bool:
301
+ if self._resolve_listing() is None:
302
+ raise ValueError("HTTPPath.is_file() called with listing=None")
303
+ if self._trailing_slash:
304
+ return False
305
+ if await self._probe_dir_slash(): # pragma: no cover
306
+ return False # pragma: no cover
307
+ return await self.exists() # pragma: no cover
308
+
309
+ async def iterdir(self) -> AsyncIterator[T]:
310
+ """Yield child paths by scraping links from this URL.
311
+
312
+ Uses the path's ``listing`` configuration (default ``"auto"``).
313
+ Each child inherits the same ``listing`` and ``listing_attr`` so
314
+ recursive operations (walk, glob, cp -r, sync) work naturally.
315
+ """
316
+ resolved = self._resolve_listing()
317
+ if resolved is None:
318
+ raise ValueError(
319
+ "HTTPPath.iterdir() called with listing=None; "
320
+ "pass a 'listing' kwarg (e.g. 'auto', 'python', 'apache', "
321
+ "'nginx', or a CSS selector)"
322
+ )
323
+ selector, attr = resolved
324
+ url = str(self)
325
+ if not url.endswith("/"):
326
+ url = url + "/"
327
+ self._trailing_slash = True
328
+ # children retain trailing-slash hint from the link text
329
+ urls = await http_scrape_links(
330
+ url,
331
+ selector,
332
+ attr=attr,
333
+ base_url=url,
334
+ headers=self._merge_headers(None),
335
+ )
336
+ for link in urls:
337
+ yield type(self)(
338
+ link,
339
+ listing=self._listing,
340
+ listing_attr=self._listing_attr,
341
+ )
342
+
343
+ async def walk(
344
+ self,
345
+ top_down: bool = True,
346
+ on_error: Callable[[OSError], object] | None = None,
347
+ follow_symlinks: bool = False,
348
+ ) -> AsyncIterator[tuple[T, list[str], list[str]]]:
349
+ """Recursively walk the listing tree, yielding (root, dirs, files).
350
+
351
+ ``follow_symlinks`` is accepted for API parity; HTTP listings have no symlinks.
352
+ """
353
+ if self._resolve_listing() is None:
354
+ raise ValueError("HTTPPath.walk() called with listing=None")
355
+ dirs: list[T] = []
356
+ files: list[T] = []
357
+ try:
358
+ async for child in self.iterdir():
359
+ if child._trailing_slash:
360
+ dirs.append(child)
361
+ else:
362
+ files.append(child)
363
+ except OSError as e:
364
+ if on_error is not None:
365
+ on_error(e)
366
+ return
367
+ raise
368
+ if top_down:
369
+ yield self, [d.name for d in dirs], [f.name for f in files]
370
+ for d in dirs:
371
+ async for triple in d.walk(
372
+ top_down=top_down, on_error=on_error, follow_symlinks=follow_symlinks
373
+ ):
374
+ yield triple
375
+ if not top_down:
376
+ yield self, [d.name for d in dirs], [f.name for f in files]
377
+
378
+ async def glob(self, pattern: str, *, case_sensitive: bool | None = None) -> AsyncIterator[T]:
379
+ """Match descendants against a glob pattern (uses ``listing``)."""
380
+ if self._resolve_listing() is None:
381
+ raise ValueError("HTTPPath.glob() called with listing=None")
382
+ async for child in self.iterdir():
383
+ if child.match(pattern, case_sensitive=case_sensitive):
384
+ yield child
385
+ if child._trailing_slash:
386
+ async for entry in child.glob(pattern, case_sensitive=case_sensitive):
387
+ yield entry
388
+
389
+ async def mkdir(self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False) -> None:
390
+ raise NotImplementedError
391
+
392
+ def open(self, mode="r", buffering=-1, encoding=None, errors=None, newline=None):
393
+ raise NotImplementedError(f"{type(self).__name__} does not support open()")
394
+
395
+ async def read_bytes(self) -> bytes:
396
+ return await http_get(str(self), headers=self._merge_headers(None))
397
+
398
+ async def read_text(
399
+ self,
400
+ encoding: str | None = None,
401
+ errors: str | None = None,
402
+ newline: Literal[None, "", "\n", "\r", "\r\n"] = None,
403
+ ) -> str:
404
+ text = (await self.read_bytes()).decode(
405
+ encoding=encoding or "utf-8", errors=errors or "strict"
406
+ )
407
+ match newline:
408
+ case None:
409
+ return text.replace("\r\n", "\n").replace("\r", "\n")
410
+ case "":
411
+ return text
412
+ case _:
413
+ return text.replace(newline, "\n")
414
+
415
+ async def rename(self, target: str, *, force: bool = False) -> T:
416
+ return await super().rename(target, force=force)
417
+
418
+ async def replace(self, target: str) -> T:
419
+ return await self.rename(target, force=True)
420
+
421
+ async def touch(self, mode: int = 0o666, exist_ok: bool = True) -> None:
422
+ if not exist_ok and await self.exists():
423
+ raise FileExistsError(17, f"File exists: '{self}'")
424
+ if mode != 0o666:
425
+ raise NotImplementedError("Custom mode is not supported for HTTPPath touch")
426
+ await self.write_bytes(b"")
427
+
428
+ async def unlink(self, missing_ok: bool = False) -> None:
429
+ try:
430
+ if not (missing_ok or await self.exists()):
431
+ raise FileNotFoundError(2, f"No such file or directory: '{self}'")
432
+ await http_delete(str(self), headers=self._merge_headers(None))
433
+ except FileNotFoundError:
434
+ if not missing_ok:
435
+ raise
436
+ except Exception as e:
437
+ raise OSError(f"Failed to delete {self}: {e}") from None
438
+
439
+ async def rglob(self, pattern: str, *, case_sensitive: bool | None = None) -> AsyncIterator[T]:
440
+ if self._resolve_listing() is None:
441
+ raise ValueError("HTTPPath.rglob() called with listing=None")
442
+ async for entry in self.glob(pattern, case_sensitive=case_sensitive):
443
+ yield entry
444
+
445
+ async def rmdir(self) -> None:
446
+ raise NotImplementedError(f"{type(self).__name__} does not support rmdir")
447
+
448
+ async def stat(self, *, follow_symlinks: bool = True):
449
+ """Return partial file metadata derived from a HEAD request.
450
+
451
+ Only ``st_size`` (from ``Content-Length``) and ``st_mtime``/``st_ctime``
452
+ (from ``Last-Modified``) are populated; HTTP has no equivalent for the
453
+ remaining ``stat`` fields. Returns a ``SimpleNamespace`` matching the
454
+ cloud-backend convention (see ``S3Path.stat``).
455
+ """
456
+ from datetime import datetime, timezone
457
+ from email.utils import parsedate_to_datetime
458
+ from types import SimpleNamespace
459
+
460
+ headers = await http_head(str(self), headers=self._merge_headers(None))
461
+ size_raw = headers.get("Content-Length") or headers.get("content-length")
462
+ size = int(size_raw) if size_raw is not None else None
463
+ last_modified = headers.get("Last-Modified") or headers.get("last-modified")
464
+ mtime: float | None = None
465
+ if last_modified:
466
+ try:
467
+ mtime = parsedate_to_datetime(last_modified).timestamp()
468
+ except (TypeError, ValueError):
469
+ try:
470
+ mtime = (
471
+ datetime.strptime(last_modified, "%a, %d %b %Y %H:%M:%S GMT")
472
+ .replace(tzinfo=timezone.utc)
473
+ .timestamp()
474
+ )
475
+ except ValueError:
476
+ mtime = None
477
+ return SimpleNamespace(st_size=size, st_ctime=mtime, st_mtime=mtime)
478
+
479
+ async def checksums(self) -> dict[str, str]:
480
+ from hashlib import md5, sha1, sha256
481
+
482
+ data = await self.read_bytes()
483
+ return {
484
+ "md5": md5(data).hexdigest(),
485
+ "sha1": sha1(data).hexdigest(),
486
+ "sha256": sha256(data).hexdigest(),
487
+ }
488
+
489
+ async def write_bytes(self, data: bytes) -> int:
490
+ try:
491
+ merged = self._merge_headers(None) or []
492
+ merged.append(("content-length", str(len(data))))
493
+ await http_put(str(self), body=data, headers=merged)
494
+ except Exception as e:
495
+ raise OSError(f"Failed to write to {self}: {e}") from e
496
+ return len(data)
497
+
498
+ async def write_text(
499
+ self,
500
+ data: str,
501
+ encoding: str | None = None,
502
+ errors: str | None = None,
503
+ newline: Literal[None, "", "\n", "\r", "\r\n"] = None,
504
+ ) -> int:
505
+ if newline is None:
506
+ newline = linesep
507
+ if newline not in ("", "\n"):
508
+ data = data.replace("\n", newline)
509
+ encoded_data = data.encode(encoding=encoding or "utf-8", errors=errors or "strict")
510
+ return await self.write_bytes(encoded_data)
511
+
512
+
513
+ class HTTPSPath(HTTPPath):
514
+ """Async path implementation for HTTPS."""
515
+
516
+ protocol: str = "https"