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