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.
Files changed (48) hide show
  1. pathapi_uri-0.1.0.dist-info/METADATA +259 -0
  2. pathapi_uri-0.1.0.dist-info/RECORD +48 -0
  3. pathapi_uri-0.1.0.dist-info/WHEEL +4 -0
  4. pathapi_uri-0.1.0.dist-info/entry_points.txt +17 -0
  5. pathapi_uri-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. uripath/AGENTS.md +1094 -0
  7. uripath/README.md +213 -0
  8. uripath/__init__.py +27 -0
  9. uripath/__main__.py +7 -0
  10. uripath/_pathtext.py +32 -0
  11. uripath/_rfc3986.py +67 -0
  12. uripath/_transfer.py +108 -0
  13. uripath/_uri.py +1007 -0
  14. uripath/_version.py +11 -0
  15. uripath/cli/__init__.py +54 -0
  16. uripath/cli/_common.py +48 -0
  17. uripath/cli/_root.py +70 -0
  18. uripath/cli/cp.py +61 -0
  19. uripath/cli/read.py +22 -0
  20. uripath/cli/rm.py +38 -0
  21. uripath/cli/sync.py +78 -0
  22. uripath/cli/write.py +49 -0
  23. uripath/exceptions.py +38 -0
  24. uripath/py.typed +1 -0
  25. uripath/query.py +240 -0
  26. uripath/schemes/__init__.py +120 -0
  27. uripath/schemes/_archive.py +338 -0
  28. uripath/schemes/_data.py +99 -0
  29. uripath/schemes/_dav.py +459 -0
  30. uripath/schemes/_delegate.py +127 -0
  31. uripath/schemes/_elsewhere.py +102 -0
  32. uripath/schemes/_file.py +186 -0
  33. uripath/schemes/_ftp.py +200 -0
  34. uripath/schemes/_http/__init__.py +4 -0
  35. uripath/schemes/_http/_backend.py +132 -0
  36. uripath/schemes/_http/_headers.py +8 -0
  37. uripath/schemes/_http/_listing.py +403 -0
  38. uripath/schemes/_http/_path.py +371 -0
  39. uripath/schemes/_http/_settings.py +6 -0
  40. uripath/schemes/_http/_streams.py +230 -0
  41. uripath/schemes/_httpdate.py +48 -0
  42. uripath/schemes/_path.py +629 -0
  43. uripath/schemes/_registry.py +134 -0
  44. uripath/source/__init__.py +20 -0
  45. uripath/source/_compose.py +212 -0
  46. uripath/source/_locality.py +44 -0
  47. uripath/source/_parse.py +183 -0
  48. 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))