pathapi-uri 0.1.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.
- pathapi_uri-0.1.0.dist-info/METADATA +259 -0
- pathapi_uri-0.1.0.dist-info/RECORD +48 -0
- pathapi_uri-0.1.0.dist-info/WHEEL +4 -0
- pathapi_uri-0.1.0.dist-info/entry_points.txt +17 -0
- pathapi_uri-0.1.0.dist-info/licenses/LICENSE +21 -0
- uripath/AGENTS.md +1094 -0
- uripath/README.md +213 -0
- uripath/__init__.py +27 -0
- uripath/__main__.py +7 -0
- uripath/_pathtext.py +32 -0
- uripath/_rfc3986.py +67 -0
- uripath/_transfer.py +108 -0
- uripath/_uri.py +1007 -0
- uripath/_version.py +11 -0
- uripath/cli/__init__.py +54 -0
- uripath/cli/_common.py +48 -0
- uripath/cli/_root.py +70 -0
- uripath/cli/cp.py +61 -0
- uripath/cli/read.py +22 -0
- uripath/cli/rm.py +38 -0
- uripath/cli/sync.py +78 -0
- uripath/cli/write.py +49 -0
- uripath/exceptions.py +38 -0
- uripath/py.typed +1 -0
- uripath/query.py +240 -0
- uripath/schemes/__init__.py +120 -0
- uripath/schemes/_archive.py +338 -0
- uripath/schemes/_data.py +99 -0
- uripath/schemes/_dav.py +459 -0
- uripath/schemes/_delegate.py +127 -0
- uripath/schemes/_elsewhere.py +102 -0
- uripath/schemes/_file.py +186 -0
- uripath/schemes/_ftp.py +200 -0
- uripath/schemes/_http/__init__.py +4 -0
- uripath/schemes/_http/_backend.py +132 -0
- uripath/schemes/_http/_headers.py +8 -0
- uripath/schemes/_http/_listing.py +403 -0
- uripath/schemes/_http/_path.py +371 -0
- uripath/schemes/_http/_settings.py +6 -0
- uripath/schemes/_http/_streams.py +230 -0
- uripath/schemes/_httpdate.py +48 -0
- uripath/schemes/_path.py +629 -0
- uripath/schemes/_registry.py +134 -0
- uripath/source/__init__.py +20 -0
- uripath/source/_compose.py +212 -0
- uripath/source/_locality.py +44 -0
- uripath/source/_parse.py +183 -0
- uripath/source/_source.py +239 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html.parser as _html_parser
|
|
4
|
+
import re as _re
|
|
5
|
+
import time as _time
|
|
6
|
+
import typing as _ty
|
|
7
|
+
import urllib.parse as _urlparse
|
|
8
|
+
|
|
9
|
+
_RE_ISO8601 = _re.compile(r"\d{4}-\d+-\d+T\d+:\d{2}:\d{2}Z")
|
|
10
|
+
_DATETIME_FMTs = (
|
|
11
|
+
(_re.compile(r"\d+-[A-S][a-y]{2}-\d{4} \d+:\d{2}:\d{2}"), "%d-%b-%Y %H:%M:%S"),
|
|
12
|
+
(_re.compile(r"\d+-[A-S][a-y]{2}-\d{4} \d+:\d{2}"), "%d-%b-%Y %H:%M"),
|
|
13
|
+
(_re.compile(r"\d{4}-\d+-\d+ \d+:\d{2}:\d{2}"), "%Y-%m-%d %H:%M:%S"),
|
|
14
|
+
(_RE_ISO8601, "%Y-%m-%dT%H:%M:%SZ"),
|
|
15
|
+
(_re.compile(r"\d{4}-\d+-\d+ \d+:\d{2}"), "%Y-%m-%d %H:%M"),
|
|
16
|
+
(_re.compile(r"\d{4}-[A-S][a-y]{2}-\d+ \d+:\d{2}:\d{2}"), "%Y-%b-%d %H:%M:%S"),
|
|
17
|
+
(_re.compile(r"\d{4}-[A-S][a-y]{2}-\d+ \d+:\d{2}"), "%Y-%b-%d %H:%M"),
|
|
18
|
+
(
|
|
19
|
+
_re.compile(r"[F-W][a-u]{2} [A-S][a-y]{2} +\d+ \d{2}:\d{2}:\d{2} \d{4}"),
|
|
20
|
+
"%a %b %d %H:%M:%S %Y",
|
|
21
|
+
),
|
|
22
|
+
(
|
|
23
|
+
_re.compile(r"[F-W][a-u]{2}, \d+ [A-S][a-y]{2} \d{4} \d{2}:\d{2}:\d{2} \S+"),
|
|
24
|
+
"%a, %d %b %Y %H:%M:%S %Z",
|
|
25
|
+
),
|
|
26
|
+
(_re.compile(r"\d{4}-\d+-\d+"), "%Y-%m-%d"),
|
|
27
|
+
(_re.compile(r"\d+/\d+/\d{4} \d{2}:\d{2}:\d{2} [+-]\d{4}"), "%d/%m/%Y %H:%M:%S %z"),
|
|
28
|
+
(_re.compile(r"\d{2} [A-S][a-y]{2} \d{4}"), "%d %b %Y"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_RE_FILESIZE = _re.compile(r"\d[\d,]*(\.\d+)? ?[BKMGTPEZY]|\d[\d,]*|-", _re.I)
|
|
33
|
+
_RE_COMMONHEAD = _re.compile(
|
|
34
|
+
"Name|(Last )?modifi(ed|cation)|date|Size|Description|Metadata|Type|Parent Directory",
|
|
35
|
+
_re.I,
|
|
36
|
+
)
|
|
37
|
+
_RE_HEAD_NAME = _re.compile("name$|^file|^download")
|
|
38
|
+
_RE_HEAD_MOD = _re.compile("modifi|^uploaded|date|time")
|
|
39
|
+
_RE_HEAD_SIZE = _re.compile("size|bytes$")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _human2bytes(s):
|
|
43
|
+
if s is None:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return int(s)
|
|
47
|
+
except ValueError:
|
|
48
|
+
symbols = "BKMGTPEZY"
|
|
49
|
+
letter = s[-1:].strip().upper()
|
|
50
|
+
num = float(s[:-1])
|
|
51
|
+
prefix = {symbols[0]: 1}
|
|
52
|
+
for i, sym in enumerate(symbols[1:]):
|
|
53
|
+
prefix[sym] = 1 << (i + 1) * 10
|
|
54
|
+
return int(num * prefix.get(letter, 1))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _aherf2filename(a_href):
|
|
58
|
+
isdir = "/" if a_href.endswith("/") else ""
|
|
59
|
+
path = _urlparse.urlsplit(a_href).path
|
|
60
|
+
# The URI layer's handler: a name that is not UTF-8 (`caf%E9`) keeps its
|
|
61
|
+
# bytes, so the child's request names the file the server listed.
|
|
62
|
+
name = _urlparse.unquote(path.rstrip("/"), errors="surrogateescape")
|
|
63
|
+
return name.rsplit("/", 1)[-1] + isdir
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _origin(split: _urlparse.SplitResult) -> "tuple[str, str, int | None]":
|
|
70
|
+
scheme = split.scheme.lower()
|
|
71
|
+
try:
|
|
72
|
+
port = split.port
|
|
73
|
+
except ValueError:
|
|
74
|
+
port = -1
|
|
75
|
+
# `hostname` is lower case already; a host name is case-insensitive.
|
|
76
|
+
return scheme, (split.hostname or "").lower(), port or _DEFAULT_PORTS.get(scheme)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _FileEntry(_ty.NamedTuple):
|
|
80
|
+
name: str
|
|
81
|
+
modified: _ty.Optional[_time.struct_time]
|
|
82
|
+
size: _ty.Optional[int]
|
|
83
|
+
description: _ty.Optional[str]
|
|
84
|
+
# False when `size` came from a human-readable column ("1.2K"): the
|
|
85
|
+
# value is approximate and must not be reported as `st_size`.
|
|
86
|
+
size_exact: bool = True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_exact_size(sizestr: str) -> bool:
|
|
90
|
+
sizestr = sizestr.replace(" ", "").replace(",", "")
|
|
91
|
+
if sizestr[-1:] in ("b", "B"):
|
|
92
|
+
sizestr = sizestr[:-1]
|
|
93
|
+
return sizestr.isdigit()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _DirectoryListingParser(_html_parser.HTMLParser):
|
|
97
|
+
"""Scrapes an HTML directory index into `_FileEntry`s.
|
|
98
|
+
|
|
99
|
+
`base_url` is the URL the listing was fetched from (after redirects).
|
|
100
|
+
When given, an entry is kept only if its href, resolved against that URL
|
|
101
|
+
as a browser resolves it, names a direct child of the listed directory
|
|
102
|
+
on the same origin -- so a page's links elsewhere, and a reverse proxy
|
|
103
|
+
whose `<title>` names a different path than the request, are handled.
|
|
104
|
+
Without it, absolute hrefs are scoped by the "Index of ..." title."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, base_url: "str | None" = None):
|
|
107
|
+
super().__init__()
|
|
108
|
+
self.listing = []
|
|
109
|
+
self.base_url = base_url
|
|
110
|
+
self._base_origin = None
|
|
111
|
+
self._base_segments = None
|
|
112
|
+
if base_url:
|
|
113
|
+
split = _urlparse.urlsplit(base_url)
|
|
114
|
+
self._base_origin = _origin(split)
|
|
115
|
+
self._base_segments = [
|
|
116
|
+
_urlparse.unquote(segment)
|
|
117
|
+
for segment in split.path.rstrip("/").split("/")
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
self.in_title = False
|
|
121
|
+
self.in_pre = False
|
|
122
|
+
self.in_table = False
|
|
123
|
+
self.in_tr = False
|
|
124
|
+
self.in_td = False
|
|
125
|
+
self.in_a = False
|
|
126
|
+
|
|
127
|
+
self.title_text = ""
|
|
128
|
+
self.cwd = None
|
|
129
|
+
|
|
130
|
+
self.table_rows = []
|
|
131
|
+
self.current_row = []
|
|
132
|
+
self.current_cell_text = []
|
|
133
|
+
self.current_cell_href = None
|
|
134
|
+
self.headers = None
|
|
135
|
+
|
|
136
|
+
self.last_href = None
|
|
137
|
+
self.pre_collect_data = False
|
|
138
|
+
self.pre_data_buffer = []
|
|
139
|
+
|
|
140
|
+
self.all_links = []
|
|
141
|
+
self.current_a_href = None
|
|
142
|
+
self.current_a_text = []
|
|
143
|
+
|
|
144
|
+
def handle_starttag(self, tag, attrs):
|
|
145
|
+
attrs_dict = dict(attrs)
|
|
146
|
+
if tag == "title":
|
|
147
|
+
self.in_title = True
|
|
148
|
+
self.title_text = ""
|
|
149
|
+
elif tag == "pre":
|
|
150
|
+
self.in_pre = True
|
|
151
|
+
elif tag == "table":
|
|
152
|
+
self.in_table = True
|
|
153
|
+
self.table_rows = []
|
|
154
|
+
self.headers = None
|
|
155
|
+
elif tag == "tr" and self.in_table:
|
|
156
|
+
self.in_tr = True
|
|
157
|
+
self.current_row = []
|
|
158
|
+
elif (tag == "td" or tag == "th") and self.in_tr:
|
|
159
|
+
self.in_td = True
|
|
160
|
+
self.current_cell_text = []
|
|
161
|
+
self.current_cell_href = None
|
|
162
|
+
elif tag == "a":
|
|
163
|
+
self.in_a = True
|
|
164
|
+
href = attrs_dict.get("href")
|
|
165
|
+
if href:
|
|
166
|
+
self.current_a_href = href
|
|
167
|
+
self.current_a_text = []
|
|
168
|
+
if self.in_td:
|
|
169
|
+
self.current_cell_href = href
|
|
170
|
+
elif self.in_pre:
|
|
171
|
+
self._flush_pre_entry()
|
|
172
|
+
self.last_href = href
|
|
173
|
+
self.pre_collect_data = False
|
|
174
|
+
|
|
175
|
+
def handle_endtag(self, tag):
|
|
176
|
+
if tag == "title":
|
|
177
|
+
self.in_title = False
|
|
178
|
+
title = self.title_text.strip()
|
|
179
|
+
if title.startswith("Index of "):
|
|
180
|
+
self.cwd = title[9:]
|
|
181
|
+
elif tag == "pre":
|
|
182
|
+
self._flush_pre_entry()
|
|
183
|
+
self.in_pre = False
|
|
184
|
+
elif tag == "table":
|
|
185
|
+
self.in_table = False
|
|
186
|
+
self._process_table()
|
|
187
|
+
elif tag == "tr" and self.in_tr:
|
|
188
|
+
self.in_tr = False
|
|
189
|
+
self.table_rows.append(self.current_row)
|
|
190
|
+
elif (tag == "td" or tag == "th") and self.in_td:
|
|
191
|
+
self.in_td = False
|
|
192
|
+
cell_text = "".join(self.current_cell_text).strip()
|
|
193
|
+
self.current_row.append((cell_text, self.current_cell_href))
|
|
194
|
+
elif tag == "a":
|
|
195
|
+
self.in_a = False
|
|
196
|
+
if self.current_a_href:
|
|
197
|
+
text = "".join(self.current_a_text).strip()
|
|
198
|
+
self.all_links.append((text, self.current_a_href))
|
|
199
|
+
self.current_a_href = None
|
|
200
|
+
if self.in_pre and self.last_href:
|
|
201
|
+
self.pre_data_buffer = []
|
|
202
|
+
self.pre_collect_data = True
|
|
203
|
+
|
|
204
|
+
def handle_data(self, data):
|
|
205
|
+
if self.in_title:
|
|
206
|
+
self.title_text += data
|
|
207
|
+
elif self.in_td:
|
|
208
|
+
self.current_cell_text.append(data)
|
|
209
|
+
elif self.in_pre and self.pre_collect_data:
|
|
210
|
+
self.pre_data_buffer.append(data)
|
|
211
|
+
if self.in_a:
|
|
212
|
+
self.current_a_text.append(data)
|
|
213
|
+
|
|
214
|
+
def _is_child_href(self, href) -> bool:
|
|
215
|
+
"""Whether `href`, resolved against `base_url`, is a direct child of
|
|
216
|
+
the listed directory on the same scheme, host and port."""
|
|
217
|
+
split = _urlparse.urlsplit(_urlparse.urljoin(self.base_url, href))
|
|
218
|
+
if _origin(split) != self._base_origin:
|
|
219
|
+
return False
|
|
220
|
+
segments = [
|
|
221
|
+
_urlparse.unquote(segment) for segment in split.path.rstrip("/").split("/")
|
|
222
|
+
]
|
|
223
|
+
base = self._base_segments
|
|
224
|
+
return (
|
|
225
|
+
len(segments) == len(base) + 1
|
|
226
|
+
and segments[:-1] == base
|
|
227
|
+
and segments[-1] != ""
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _is_ancestor_href(self, href):
|
|
231
|
+
# With a `base_url`, anything but a direct child is skipped: the parent link is
|
|
232
|
+
# not always "../" (it may be the absolute "/files/"), yet a server that renders
|
|
233
|
+
# every entry as an absolute href must keep its listing, so the filter is scoped.
|
|
234
|
+
if self.base_url:
|
|
235
|
+
return not self._is_child_href(href)
|
|
236
|
+
if not href.startswith("/"):
|
|
237
|
+
return False
|
|
238
|
+
if not self.cwd:
|
|
239
|
+
return True
|
|
240
|
+
path = _urlparse.unquote(_urlparse.urlsplit(href).path)
|
|
241
|
+
cwd = self.cwd if self.cwd.endswith("/") else self.cwd + "/"
|
|
242
|
+
# a strict descendant of cwd is a real child; cwd itself (a
|
|
243
|
+
# self-referencing "up" link, e.g. at the site root) or anything
|
|
244
|
+
# outside cwd is the ancestor/parent link.
|
|
245
|
+
return path == cwd or not path.startswith(cwd)
|
|
246
|
+
|
|
247
|
+
def _flush_pre_entry(self):
|
|
248
|
+
if not self.last_href:
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
name = _aherf2filename(self.last_href)
|
|
252
|
+
if (
|
|
253
|
+
name in ("Parent Directory", "..", "../")
|
|
254
|
+
or self.last_href.startswith("?")
|
|
255
|
+
or self._is_ancestor_href(self.last_href)
|
|
256
|
+
):
|
|
257
|
+
self.last_href = None
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
modified = None
|
|
261
|
+
size = None
|
|
262
|
+
size_exact = True
|
|
263
|
+
description = None
|
|
264
|
+
|
|
265
|
+
text = (
|
|
266
|
+
"".join(self.pre_data_buffer).replace("\r", "").split("\n", 1)[0].lstrip()
|
|
267
|
+
)
|
|
268
|
+
if text:
|
|
269
|
+
for regex, fmt in _DATETIME_FMTs:
|
|
270
|
+
match = regex.match(text)
|
|
271
|
+
if match:
|
|
272
|
+
try:
|
|
273
|
+
modified = _time.strptime(match.group(0), fmt)
|
|
274
|
+
except ValueError:
|
|
275
|
+
pass
|
|
276
|
+
text = text[match.end() :].lstrip()
|
|
277
|
+
break
|
|
278
|
+
|
|
279
|
+
match = _RE_FILESIZE.match(text)
|
|
280
|
+
if match:
|
|
281
|
+
sizestr = match.group(0)
|
|
282
|
+
if sizestr != "-":
|
|
283
|
+
size = _human2bytes(sizestr.replace(" ", "").replace(",", ""))
|
|
284
|
+
size_exact = _is_exact_size(sizestr)
|
|
285
|
+
text = text[match.end() :].lstrip()
|
|
286
|
+
|
|
287
|
+
if text:
|
|
288
|
+
description = text.rstrip()
|
|
289
|
+
if description == "/":
|
|
290
|
+
name += "/"
|
|
291
|
+
description = None
|
|
292
|
+
|
|
293
|
+
self.listing.append(_FileEntry(name, modified, size, description, size_exact))
|
|
294
|
+
self.last_href = None
|
|
295
|
+
|
|
296
|
+
def _process_table(self):
|
|
297
|
+
started = False
|
|
298
|
+
for row in self.table_rows:
|
|
299
|
+
has_head = False
|
|
300
|
+
cell_texts = [cell[0] for cell in row]
|
|
301
|
+
for text in cell_texts:
|
|
302
|
+
if _RE_COMMONHEAD.search(text):
|
|
303
|
+
has_head = True
|
|
304
|
+
break
|
|
305
|
+
|
|
306
|
+
if has_head and not started:
|
|
307
|
+
self.headers = []
|
|
308
|
+
name_found = False
|
|
309
|
+
for text in cell_texts:
|
|
310
|
+
norm = text.strip(" \t\n\r\x0b\x0c\xa0↑↓").lower()
|
|
311
|
+
if not norm:
|
|
312
|
+
continue
|
|
313
|
+
if not name_found and _RE_HEAD_NAME.search(norm):
|
|
314
|
+
self.headers.append("name")
|
|
315
|
+
name_found = True
|
|
316
|
+
elif norm in ("size", "description"):
|
|
317
|
+
self.headers.append(norm)
|
|
318
|
+
elif _RE_HEAD_MOD.search(norm):
|
|
319
|
+
self.headers.append("modified")
|
|
320
|
+
elif _RE_HEAD_SIZE.search(norm):
|
|
321
|
+
self.headers.append("size")
|
|
322
|
+
elif norm.endswith("signature"):
|
|
323
|
+
self.headers.append("signature")
|
|
324
|
+
else:
|
|
325
|
+
self.headers.append("description")
|
|
326
|
+
if not self.headers:
|
|
327
|
+
self.headers = ["name", "modified", "size", "description"]
|
|
328
|
+
elif not name_found:
|
|
329
|
+
self.headers[0] = "name"
|
|
330
|
+
started = True
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
if started:
|
|
334
|
+
file_name = None
|
|
335
|
+
file_mod = None
|
|
336
|
+
file_size = None
|
|
337
|
+
file_size_exact = True
|
|
338
|
+
file_desc = None
|
|
339
|
+
|
|
340
|
+
status = 0
|
|
341
|
+
for cell_text, cell_href in row:
|
|
342
|
+
if status >= len(self.headers):
|
|
343
|
+
break
|
|
344
|
+
|
|
345
|
+
header = self.headers[status]
|
|
346
|
+
if header == "name":
|
|
347
|
+
if not cell_href or cell_href.startswith("#"):
|
|
348
|
+
continue
|
|
349
|
+
name_val = cell_text.strip()
|
|
350
|
+
if name_val == "Parent Directory" or cell_href == "../":
|
|
351
|
+
break
|
|
352
|
+
if self.base_url and not self._is_child_href(cell_href):
|
|
353
|
+
break
|
|
354
|
+
file_name = _aherf2filename(cell_href)
|
|
355
|
+
status = 1
|
|
356
|
+
elif header == "modified":
|
|
357
|
+
timestr = cell_text.strip()
|
|
358
|
+
if timestr:
|
|
359
|
+
for regex, fmt in _DATETIME_FMTs:
|
|
360
|
+
match = regex.match(timestr)
|
|
361
|
+
if match:
|
|
362
|
+
try:
|
|
363
|
+
file_mod = _time.strptime(match.group(0), fmt)
|
|
364
|
+
except ValueError:
|
|
365
|
+
pass
|
|
366
|
+
break
|
|
367
|
+
status += 1
|
|
368
|
+
elif header == "size":
|
|
369
|
+
sizestr = cell_text.strip().replace(",", "")
|
|
370
|
+
if sizestr and sizestr != "-":
|
|
371
|
+
match = _RE_FILESIZE.match(sizestr)
|
|
372
|
+
if match:
|
|
373
|
+
file_size = _human2bytes(
|
|
374
|
+
match.group(0).replace(" ", "")
|
|
375
|
+
)
|
|
376
|
+
file_size_exact = _is_exact_size(match.group(0))
|
|
377
|
+
status += 1
|
|
378
|
+
elif header == "description":
|
|
379
|
+
file_desc = cell_text or None
|
|
380
|
+
status += 1
|
|
381
|
+
else:
|
|
382
|
+
status += 1
|
|
383
|
+
|
|
384
|
+
if file_name:
|
|
385
|
+
self.listing.append(
|
|
386
|
+
_FileEntry(
|
|
387
|
+
file_name, file_mod, file_size, file_desc, file_size_exact
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def close(self):
|
|
392
|
+
super().close()
|
|
393
|
+
self._flush_pre_entry()
|
|
394
|
+
if not self.listing:
|
|
395
|
+
for text, href in self.all_links:
|
|
396
|
+
name = _aherf2filename(href)
|
|
397
|
+
if (
|
|
398
|
+
name in ("Parent Directory", "..", "../")
|
|
399
|
+
or href.startswith("?")
|
|
400
|
+
or self._is_ancestor_href(href)
|
|
401
|
+
):
|
|
402
|
+
continue
|
|
403
|
+
self.listing.append(_FileEntry(name, None, None, None))
|