pathapi 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 (104) hide show
  1. pathapi/AGENTS.md +2355 -0
  2. pathapi/README.md +205 -0
  3. pathapi/__init__.py +62 -0
  4. pathapi/_commit.py +116 -0
  5. pathapi/_discard.py +92 -0
  6. pathapi/_errorpolicy.py +106 -0
  7. pathapi/_filestat.py +194 -0
  8. pathapi/_lru.py +138 -0
  9. pathapi/_names.py +70 -0
  10. pathapi/_permissions.py +102 -0
  11. pathapi/_stubs.py +76 -0
  12. pathapi/_tree.py +136 -0
  13. pathapi/archive/__init__.py +44 -0
  14. pathapi/archive/_backend.py +393 -0
  15. pathapi/archive/_names.py +63 -0
  16. pathapi/archive/_pack.py +394 -0
  17. pathapi/archive/_path.py +352 -0
  18. pathapi/archive/_snapshot.py +269 -0
  19. pathapi/archive/_tar.py +126 -0
  20. pathapi/archive/_write.py +359 -0
  21. pathapi/archive/_zip.py +563 -0
  22. pathapi/archive/_zipentries.py +272 -0
  23. pathapi/checksum.py +61 -0
  24. pathapi/exceptions.py +22 -0
  25. pathapi/fspath.py +513 -0
  26. pathapi/ftp/__init__.py +27 -0
  27. pathapi/ftp/_backend.py +418 -0
  28. pathapi/ftp/_facts.py +117 -0
  29. pathapi/ftp/_path.py +582 -0
  30. pathapi/glob/__init__.py +27 -0
  31. pathapi/glob/_entries.py +103 -0
  32. pathapi/glob/_patterns.py +164 -0
  33. pathapi/glob/_recursion.py +180 -0
  34. pathapi/glob/_selection.py +303 -0
  35. pathapi/http/__init__.py +84 -0
  36. pathapi/http/_body.py +156 -0
  37. pathapi/http/_builtin.py +553 -0
  38. pathapi/http/_client.py +211 -0
  39. pathapi/http/_errors.py +79 -0
  40. pathapi/http/_follow.py +230 -0
  41. pathapi/http/_headers.py +233 -0
  42. pathapi/http/_pool.py +169 -0
  43. pathapi/http/_proxies.py +142 -0
  44. pathapi/http/_response.py +255 -0
  45. pathapi/http/_settings.py +12 -0
  46. pathapi/http/_status.py +68 -0
  47. pathapi/http/_transports.py +173 -0
  48. pathapi/http/testing/__init__.py +80 -0
  49. pathapi/http/testing/__main__.py +10 -0
  50. pathapi/http/testing/suite/conftest.py +98 -0
  51. pathapi/http/testing/suite/test_client_default.py +285 -0
  52. pathapi/http/testing/suite/test_client_errors.py +181 -0
  53. pathapi/http/testing/suite/test_client_peer_text.py +86 -0
  54. pathapi/http/testing/suite/test_client_redirect_origin.py +134 -0
  55. pathapi/http/testing/suite/test_client_redirects.py +231 -0
  56. pathapi/http/testing/suite/test_client_reply_limits.py +201 -0
  57. pathapi/http/testing/suite/test_client_write_redirects.py +239 -0
  58. pathapi/http/testing/suite/test_transport_body.py +302 -0
  59. pathapi/http/testing/suite/test_transport_send.py +662 -0
  60. pathapi/http/testing/suite/transport_fakes.py +133 -0
  61. pathapi/http/testing/suite/transport_guard.py +174 -0
  62. pathapi/http/testing/suite/transport_wire.py +511 -0
  63. pathapi/mempath.py +438 -0
  64. pathapi/path/__init__.py +19 -0
  65. pathapi/path/_info.py +10 -0
  66. pathapi/path/_path.py +198 -0
  67. pathapi/path/_pure.py +522 -0
  68. pathapi/path/_relation.py +10 -0
  69. pathapi/path/_transfer.py +10 -0
  70. pathapi/protocols/__init__.py +35 -0
  71. pathapi/protocols/_bundles.py +22 -0
  72. pathapi/protocols/_errors.py +12 -0
  73. pathapi/protocols/_info.py +65 -0
  74. pathapi/protocols/_relation.py +191 -0
  75. pathapi/protocols/_removal.py +69 -0
  76. pathapi/protocols/_support.py +122 -0
  77. pathapi/protocols/_transfer.py +100 -0
  78. pathapi/protocols/checksum.py +80 -0
  79. pathapi/protocols/fs.py +429 -0
  80. pathapi/protocols/io.py +224 -0
  81. pathapi/protocols/links.py +135 -0
  82. pathapi/protocols/pure.py +31 -0
  83. pathapi/protocols/resolve.py +97 -0
  84. pathapi/protocols/transfer.py +656 -0
  85. pathapi/protocols/tree.py +492 -0
  86. pathapi/providers.py +48 -0
  87. pathapi/py.typed +1 -0
  88. pathapi/sync/__init__.py +8 -0
  89. pathapi/sync/_compare.py +157 -0
  90. pathapi/sync/_events.py +97 -0
  91. pathapi/sync/_links.py +93 -0
  92. pathapi/sync/_offer.py +33 -0
  93. pathapi/sync/_paths.py +50 -0
  94. pathapi/sync/_replace.py +98 -0
  95. pathapi/sync/_syncer.py +989 -0
  96. pathapi/testing/__init__.py +96 -0
  97. pathapi/testing/_contract.py +522 -0
  98. pathapi/testing/_fixtures.py +83 -0
  99. pathapi/testing/_read.py +279 -0
  100. pathapi-0.1.0.dist-info/METADATA +242 -0
  101. pathapi-0.1.0.dist-info/RECORD +104 -0
  102. pathapi-0.1.0.dist-info/WHEEL +4 -0
  103. pathapi-0.1.0.dist-info/entry_points.txt +2 -0
  104. pathapi-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,511 @@
1
+ """Loopback servers for the HTTP transports, scripted at the byte level: an
2
+ origin that records what it receives and answers from a script, a proxy, and a
3
+ test certificate authority. Nothing here leaves the loopback interface."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import datetime
8
+ import ipaddress
9
+ import pathlib
10
+ import select
11
+ import socket
12
+ import ssl
13
+ import threading
14
+ import time
15
+ import typing
16
+
17
+ import pytest
18
+
19
+ CRLF = b"\r\n"
20
+
21
+
22
+ def wait_until(predicate, timeout: float = 15.0, interval: float = 0.02) -> bool:
23
+ """Poll `predicate` until it is true or `timeout` seconds have passed;
24
+ returns its last value. A test asserts the result: what must be true
25
+ after the wait, not how long it took to get there."""
26
+ deadline = time.monotonic() + timeout
27
+ while time.monotonic() < deadline:
28
+ if predicate():
29
+ return True
30
+ time.sleep(interval)
31
+ return bool(predicate())
32
+
33
+
34
+ # How often a blocked accept() looks at the stop flag. Closing a listening
35
+ # socket from another thread does not wake an accept() on every platform
36
+ # (Linux leaves it blocked), so the accept loops never rely on it.
37
+ _POLL = 0.05
38
+
39
+
40
+ def _stop_listening(stop, thread, listener, sockets, lock):
41
+ """Stop an accept loop and drop every connection it accepted."""
42
+ stop.set()
43
+ thread.join(timeout=10) # a safety net: the loop sees the flag within _POLL
44
+ try:
45
+ listener.close()
46
+ except OSError:
47
+ pass
48
+ with lock:
49
+ accepted = list(sockets)
50
+ for sock in accepted:
51
+ try:
52
+ sock.shutdown(socket.SHUT_RDWR)
53
+ except OSError:
54
+ pass
55
+ sock.close()
56
+
57
+
58
+ class Request(typing.NamedTuple):
59
+ """One request as the server received it."""
60
+
61
+ method: str
62
+ target: str
63
+ headers: "list[tuple[str, str]]"
64
+ body: bytes
65
+ connection: int # which accepted connection carried it, from 0
66
+
67
+ def header(self, name: str):
68
+ for key, value in self.headers:
69
+ if key.lower() == name.lower():
70
+ return value
71
+ return None
72
+
73
+
74
+ class Reply(typing.NamedTuple):
75
+ """Bytes to send, whether to close the connection after them, and bytes
76
+ to send `delay` seconds later on the same connection."""
77
+
78
+ raw: bytes
79
+ close: bool = False
80
+ later: bytes = b""
81
+ delay: float = 0.0
82
+
83
+
84
+ class Drop:
85
+ """Close the connection without answering."""
86
+
87
+
88
+ class Hang:
89
+ """Never answer; the connection stays open until the server closes."""
90
+
91
+
92
+ def response(status=200, headers=None, body=b"", reason="OK", length=True) -> bytes:
93
+ """A complete HTTP/1.1 response; `Content-Length` is added unless
94
+ `length` is false or `headers` names one."""
95
+ head = [f"HTTP/1.1 {status} {reason}"]
96
+ names = {k.lower() for k in (headers or {})}
97
+ for key, value in (headers or {}).items():
98
+ head.append(f"{key}: {value}")
99
+ if length and "content-length" not in names and "transfer-encoding" not in names:
100
+ head.append(f"Content-Length: {len(body)}")
101
+ return "\r\n".join(head).encode("latin-1") + CRLF + CRLF + body
102
+
103
+
104
+ def _read_until(
105
+ sock, marker: bytes, limit=1 << 20, start: bytes = b""
106
+ ) -> "tuple[bytes, bytes] | None":
107
+ data = start
108
+ while marker not in data:
109
+ chunk = sock.recv(65536)
110
+ if not chunk:
111
+ return None
112
+ data += chunk
113
+ if len(data) > limit:
114
+ return None
115
+ head, _, rest = data.partition(marker)
116
+ return head, rest
117
+
118
+
119
+ def _read_exactly(sock, rest: bytes, count: int) -> bytes:
120
+ while len(rest) < count:
121
+ chunk = sock.recv(65536)
122
+ if not chunk:
123
+ break
124
+ rest += chunk
125
+ return rest
126
+
127
+
128
+ def _read_chunked(sock, rest: bytes) -> "tuple[bytes, bytes]":
129
+ """The body, and the bytes that followed it on the connection."""
130
+ body = b""
131
+ while True:
132
+ while CRLF not in rest:
133
+ chunk = sock.recv(65536)
134
+ if not chunk:
135
+ return body, b""
136
+ rest += chunk
137
+ line, _, rest = rest.partition(CRLF)
138
+ size = int(line.split(b";")[0], 16)
139
+ rest = _read_exactly(sock, rest, size + 2)
140
+ body += rest[:size]
141
+ rest = rest[size + 2 :]
142
+ if size == 0:
143
+ return body, rest
144
+
145
+
146
+ class Wire:
147
+ """An origin on a loopback port. `script(request)` returns what to send:
148
+ `bytes`, a `Reply`, `Drop()` or `Hang()`; the default answers `200 ok`.
149
+ `requests` lists what was received, `connections` how many connections
150
+ were accepted, and `closed` the connections the client closed. With `tls`
151
+ (an `ssl.SSLContext`) every connection is wrapped by the server first."""
152
+
153
+ def __init__(self, script=None, tls: "ssl.SSLContext | None" = None):
154
+ self.script = script or (lambda request: response(body=b"ok"))
155
+ self.tls = tls
156
+ self.requests: "list[Request]" = []
157
+ self._connections = 0
158
+ self._taking = threading.Lock()
159
+ self.closed: "list[int]" = []
160
+ self.handshakes_failed = 0
161
+ self._lock = threading.Lock()
162
+ self._sockets: "list[socket.socket]" = []
163
+ self._stop = threading.Event()
164
+ self._listener = socket.create_server(("127.0.0.1", 0))
165
+ self._listener.setblocking(False)
166
+ self.port = self._listener.getsockname()[1]
167
+ self.host = "127.0.0.1"
168
+ self._thread = threading.Thread(target=self._accept, daemon=True)
169
+ self._thread.start()
170
+
171
+ @property
172
+ def url(self) -> str:
173
+ return f"{'https' if self.tls else 'http'}://127.0.0.1:{self.port}"
174
+
175
+ def __enter__(self):
176
+ return self
177
+
178
+ def __exit__(self, *exc_info):
179
+ self.close()
180
+
181
+ def close(self):
182
+ _stop_listening(
183
+ self._stop, self._thread, self._listener, self._sockets, self._lock
184
+ )
185
+
186
+ @property
187
+ def connections(self) -> int:
188
+ """How many connections the client has made. A connection waiting in
189
+ the kernel's queue is taken first, so a test asking "was one made?"
190
+ does not depend on the accept thread having run."""
191
+ self._take()
192
+ return self._connections
193
+
194
+ def _accept(self):
195
+ while not self._stop.is_set():
196
+ try:
197
+ select.select([self._listener], [], [], _POLL)
198
+ except (OSError, ValueError):
199
+ return
200
+ self._take()
201
+
202
+ def _take(self):
203
+ """Accept every connection that is waiting, and start serving it."""
204
+ with self._taking:
205
+ while True:
206
+ try:
207
+ sock, _ = self._listener.accept()
208
+ except OSError: # nothing waiting, or the listener is closed
209
+ return
210
+ sock.setblocking(True)
211
+ with self._lock:
212
+ index = self._connections
213
+ self._connections += 1
214
+ self._sockets.append(sock)
215
+ threading.Thread(
216
+ target=self._serve, args=(sock, index), daemon=True
217
+ ).start()
218
+
219
+ def _serve(self, sock, index):
220
+ try:
221
+ if self.tls is not None:
222
+ try:
223
+ sock = self.tls.wrap_socket(sock, server_side=True)
224
+ except (OSError, ssl.SSLError):
225
+ with self._lock:
226
+ self.handshakes_failed += 1
227
+ return
228
+ with self._lock:
229
+ self._sockets.append(sock)
230
+ carry = b"" # what the client sent beyond the request just read
231
+ while not self._stop.is_set():
232
+ parsed = _read_until(sock, CRLF + CRLF, start=carry)
233
+ if parsed is None:
234
+ return
235
+ head, rest = parsed
236
+ lines = head.decode("latin-1").split("\r\n")
237
+ method, target, _version = lines[0].split(" ", 2)
238
+ headers = [tuple(line.split(":", 1)) for line in lines[1:]]
239
+ headers = [(k, v.strip()) for k, v in headers]
240
+ length = next(
241
+ (int(v) for k, v in headers if k.lower() == "content-length"), 0
242
+ )
243
+ chunked = any(
244
+ k.lower() == "transfer-encoding" and "chunked" in v.lower()
245
+ for k, v in headers
246
+ )
247
+ if chunked:
248
+ body, carry = _read_chunked(sock, rest)
249
+ else:
250
+ data = _read_exactly(sock, rest, length)
251
+ body, carry = data[:length], data[length:]
252
+ request = Request(method, target, headers, body, index)
253
+ with self._lock:
254
+ self.requests.append(request)
255
+ action = self.script(request)
256
+ if isinstance(action, Drop):
257
+ return
258
+ if isinstance(action, Hang):
259
+ self._stop.wait(30)
260
+ return
261
+ if isinstance(action, bytes):
262
+ action = Reply(action)
263
+ sock.sendall(action.raw)
264
+ if action.later:
265
+ time.sleep(action.delay)
266
+ sock.sendall(action.later)
267
+ if action.close:
268
+ return
269
+ except (OSError, ssl.SSLError, ValueError):
270
+ # A request line that does not parse, as after bytes the client
271
+ # should not have sent, ends the connection as a server would.
272
+ return
273
+ finally:
274
+ with self._lock:
275
+ self.closed.append(index)
276
+ try:
277
+ sock.close()
278
+ except OSError:
279
+ pass
280
+
281
+
282
+ class Proxy:
283
+ """An HTTP proxy on a loopback port. A request in absolute form is
284
+ answered by `script(request)` as an origin would; `CONNECT host:port` is
285
+ answered `200` and then relayed to `upstream` (a `(host, port)`), whatever
286
+ the host asked for, or refused with `refuse` (a status) when given.
287
+ `requests` lists what it received, `CONNECT`s included."""
288
+
289
+ def __init__(self, script=None, upstream=None, refuse=None):
290
+ self.script = script or (lambda request: response(body=b"via proxy"))
291
+ self.upstream = upstream
292
+ self.refuse = refuse
293
+ self.requests: "list[Request]" = []
294
+ self._lock = threading.Lock()
295
+ self._sockets: "list[socket.socket]" = []
296
+ self._stop = threading.Event()
297
+ self._listener = socket.create_server(("127.0.0.1", 0))
298
+ self._listener.settimeout(_POLL)
299
+ self.port = self._listener.getsockname()[1]
300
+ self.url = f"http://127.0.0.1:{self.port}"
301
+ self._thread = threading.Thread(target=self._accept, daemon=True)
302
+ self._thread.start()
303
+
304
+ def __enter__(self):
305
+ return self
306
+
307
+ def __exit__(self, *exc_info):
308
+ self.close()
309
+
310
+ def close(self):
311
+ _stop_listening(
312
+ self._stop, self._thread, self._listener, self._sockets, self._lock
313
+ )
314
+
315
+ def _accept(self):
316
+ while not self._stop.is_set():
317
+ try:
318
+ sock, _ = self._listener.accept()
319
+ except socket.timeout:
320
+ continue
321
+ except OSError:
322
+ return
323
+ sock.settimeout(None)
324
+ with self._lock:
325
+ self._sockets.append(sock)
326
+ threading.Thread(target=self._serve, args=(sock,), daemon=True).start()
327
+
328
+ def _serve(self, sock):
329
+ try:
330
+ while not self._stop.is_set():
331
+ parsed = _read_until(sock, CRLF + CRLF)
332
+ if parsed is None:
333
+ return
334
+ head, rest = parsed
335
+ lines = head.decode("latin-1").split("\r\n")
336
+ method, target, _version = lines[0].split(" ", 2)
337
+ headers = [
338
+ (k, v.strip()) for k, v in (l.split(":", 1) for l in lines[1:])
339
+ ]
340
+ length = next(
341
+ (int(v) for k, v in headers if k.lower() == "content-length"), 0
342
+ )
343
+ body = _read_exactly(sock, rest, length)[:length]
344
+ request = Request(method, target, headers, body, 0)
345
+ with self._lock:
346
+ self.requests.append(request)
347
+ if method == "CONNECT":
348
+ if self.refuse:
349
+ sock.sendall(response(self.refuse, reason="Refused"))
350
+ return
351
+ self._tunnel(sock)
352
+ return
353
+ action = self.script(request)
354
+ if isinstance(action, Drop):
355
+ return
356
+ if isinstance(action, bytes):
357
+ action = Reply(action)
358
+ sock.sendall(action.raw)
359
+ if action.close:
360
+ return
361
+ except OSError:
362
+ return
363
+ finally:
364
+ try:
365
+ sock.close()
366
+ except OSError:
367
+ pass
368
+
369
+ def _tunnel(self, client):
370
+ upstream = socket.create_connection(self.upstream)
371
+ with self._lock:
372
+ self._sockets.append(upstream)
373
+ client.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n")
374
+
375
+ def pump(source, sink):
376
+ try:
377
+ while True:
378
+ data = source.recv(65536)
379
+ if not data:
380
+ break
381
+ sink.sendall(data)
382
+ except OSError:
383
+ pass
384
+ finally:
385
+ try:
386
+ sink.shutdown(socket.SHUT_WR)
387
+ except OSError:
388
+ pass
389
+
390
+ thread = threading.Thread(target=pump, args=(upstream, client), daemon=True)
391
+ thread.start()
392
+ pump(client, upstream)
393
+ # The client is gone, so nobody reads the upstream's side any more;
394
+ # shutting it wakes the other pump however the origin behaves.
395
+ try:
396
+ upstream.shutdown(socket.SHUT_RDWR)
397
+ except OSError:
398
+ pass
399
+ thread.join(timeout=10)
400
+ upstream.close()
401
+
402
+
403
+ def refusing_port() -> int:
404
+ """A loopback port nothing listens on."""
405
+ with socket.socket() as sock:
406
+ sock.bind(("127.0.0.1", 0))
407
+ return sock.getsockname()[1]
408
+
409
+
410
+ class Authority(typing.NamedTuple):
411
+ """A test certificate authority and a server certificate it signed."""
412
+
413
+ ca_file: str
414
+ cert_file: str
415
+ key_file: str
416
+
417
+ def server_context(self) -> ssl.SSLContext:
418
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
419
+ context.load_cert_chain(self.cert_file, self.key_file)
420
+ return context
421
+
422
+ def client_context(self) -> ssl.SSLContext:
423
+ return ssl.create_default_context(cafile=self.ca_file)
424
+
425
+
426
+ def make_authority(directory, *names: str) -> Authority:
427
+ """A CA, and a server certificate for `names` (host names or IP
428
+ addresses) signed by it, written under `directory`."""
429
+ pytest.importorskip("cryptography")
430
+ from cryptography import x509
431
+ from cryptography.hazmat.primitives import hashes, serialization
432
+ from cryptography.hazmat.primitives.asymmetric import ec
433
+ from cryptography.x509.oid import NameOID
434
+
435
+ now = datetime.datetime.now(datetime.timezone.utc)
436
+
437
+ def build(subject, issuer, key, signer, extensions):
438
+ builder = (
439
+ x509.CertificateBuilder()
440
+ .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)]))
441
+ .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer)]))
442
+ .public_key(key.public_key())
443
+ .serial_number(x509.random_serial_number())
444
+ .not_valid_before(now - datetime.timedelta(days=1))
445
+ .not_valid_after(now + datetime.timedelta(days=1))
446
+ )
447
+ for extension, critical in extensions:
448
+ builder = builder.add_extension(extension, critical=critical)
449
+ return builder.sign(signer, hashes.SHA256())
450
+
451
+ ca_key = ec.generate_private_key(ec.SECP256R1())
452
+ ca = build(
453
+ "test authority",
454
+ "test authority",
455
+ ca_key,
456
+ ca_key,
457
+ [
458
+ (x509.BasicConstraints(ca=True, path_length=None), True),
459
+ (
460
+ x509.KeyUsage(
461
+ digital_signature=False,
462
+ content_commitment=False,
463
+ key_encipherment=False,
464
+ data_encipherment=False,
465
+ key_agreement=False,
466
+ key_cert_sign=True,
467
+ crl_sign=True,
468
+ encipher_only=False,
469
+ decipher_only=False,
470
+ ),
471
+ True,
472
+ ),
473
+ (x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), False),
474
+ ],
475
+ )
476
+ key = ec.generate_private_key(ec.SECP256R1())
477
+ alt = []
478
+ for name in names:
479
+ try:
480
+ alt.append(x509.IPAddress(ipaddress.ip_address(name)))
481
+ except ValueError:
482
+ alt.append(x509.DNSName(name))
483
+ leaf = build(
484
+ names[0],
485
+ "test authority",
486
+ key,
487
+ ca_key,
488
+ [
489
+ (x509.SubjectAlternativeName(alt), False),
490
+ (x509.BasicConstraints(ca=False, path_length=None), True),
491
+ (x509.SubjectKeyIdentifier.from_public_key(key.public_key()), False),
492
+ (
493
+ x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()),
494
+ False,
495
+ ),
496
+ ],
497
+ )
498
+ directory = pathlib.Path(directory)
499
+ ca_file = directory / "ca.pem"
500
+ cert_file = directory / "server.pem"
501
+ key_file = directory / "server.key"
502
+ ca_file.write_bytes(ca.public_bytes(serialization.Encoding.PEM))
503
+ cert_file.write_bytes(leaf.public_bytes(serialization.Encoding.PEM))
504
+ key_file.write_bytes(
505
+ key.private_bytes(
506
+ serialization.Encoding.PEM,
507
+ serialization.PrivateFormat.TraditionalOpenSSL,
508
+ serialization.NoEncryption(),
509
+ )
510
+ )
511
+ return Authority(str(ca_file), str(cert_file), str(key_file))