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,255 @@
1
+ """The response every transport returns, with the decoding they share."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno as _errno
6
+ import typing as _ty
7
+ import zlib as _zlib
8
+
9
+ from ._headers import Headers
10
+
11
+ CHUNK = 64 * 1024
12
+ """Most bytes read from a transport's body in one call."""
13
+
14
+ _STEP = 256 * 1024
15
+ """Most decoded bytes one decompression step produces. The compressed bytes
16
+ that are left wait for the next step, so a body that expands a thousandfold
17
+ is never held whole because its header said it was small."""
18
+
19
+
20
+ class _Memory:
21
+ """A body that is already in memory."""
22
+
23
+ def __init__(self, data):
24
+ self._data = memoryview(bytes(data))
25
+ self._at = 0
26
+
27
+ def read(self, amount: int) -> bytes:
28
+ chunk = bytes(self._data[self._at : self._at + amount])
29
+ self._at += len(chunk)
30
+ return chunk
31
+
32
+ def close(self) -> None:
33
+ pass
34
+
35
+
36
+ class _Inflater:
37
+ """Decompresses one `gzip` or `deflate` body in bounded steps. `deflate` is
38
+ read as zlib-wrapped data, or as raw data when the zlib header is absent,
39
+ which is what servers send under that name; concatenated gzip members are
40
+ one body."""
41
+
42
+ def __init__(self, gzip: bool):
43
+ self._gzip = gzip
44
+ self._object = None
45
+ self._input = b""
46
+ self._members = 0
47
+
48
+ @property
49
+ def has_input(self) -> bool:
50
+ return bool(self._input)
51
+
52
+ def feed(self, data: bytes) -> None:
53
+ self._input += data
54
+
55
+ def _new(self, data: bytes):
56
+ if self._gzip:
57
+ return _zlib.decompressobj(16 + _zlib.MAX_WBITS)
58
+ wrapped = len(data) < 2 or (
59
+ data[0] & 0x0F == 8 and ((data[0] << 8) | data[1]) % 31 == 0
60
+ )
61
+ return _zlib.decompressobj(_zlib.MAX_WBITS if wrapped else -_zlib.MAX_WBITS)
62
+
63
+ def step(self) -> bytes:
64
+ data = self._input
65
+ if self._object is None:
66
+ self._object = self._new(data)
67
+ try:
68
+ out = self._object.decompress(data, _STEP)
69
+ except _zlib.error:
70
+ if self._members:
71
+ # Bytes after the last complete member are not a body.
72
+ self._input, self._object = b"", None
73
+ return b""
74
+ kind = "gzip" if self._gzip else "deflate"
75
+ raise OSError(
76
+ _errno.EIO, f"the response body is not valid {kind} data"
77
+ ) from None
78
+ if self._object.eof:
79
+ self._members += 1
80
+ self._input = self._object.unused_data
81
+ self._object = None
82
+ else:
83
+ self._input = self._object.unconsumed_tail
84
+ if not out and len(self._input) >= len(data):
85
+ self._input = b""
86
+ return out
87
+
88
+ def finish(self) -> None:
89
+ if self._object is not None:
90
+ raise OSError(_errno.EIO, "the compressed response body ended early")
91
+
92
+
93
+ def _inflater_for(headers: Headers) -> "_Inflater | None":
94
+ names = [
95
+ name.strip().lower()
96
+ for name in headers.get("Content-Encoding", "").split(",")
97
+ if name.strip().lower() not in ("", "identity")
98
+ ]
99
+ if len(names) != 1:
100
+ return None
101
+ if names[0] in ("gzip", "x-gzip"):
102
+ return _Inflater(gzip=True)
103
+ if names[0] == "deflate":
104
+ return _Inflater(gzip=False)
105
+ return None
106
+
107
+
108
+ class HTTPResponse:
109
+ """One response: `status`, `reason`, `headers` (a read-only mapping that
110
+ ignores the case of a name), `url` (the URL the request went to) and the
111
+ body, read with `read()` or `iter_bytes()`. A context manager that closes.
112
+
113
+ The body is decoded when the response says it is `gzip` or `deflate`
114
+ encoded, unless the request set its own `Accept-Encoding`; `headers` are
115
+ the headers as received, so `Content-Length` counts the encoded bytes. A
116
+ body that ends before the length the server stated is `OSError(EIO)`, never
117
+ a shorter body that reads as complete.
118
+
119
+ A transport builds one with `body` as `bytes`, or as an object with
120
+ `read(amount) -> bytes` (empty at the end) and `close()`, and
121
+ `decode=False` for a body it already decoded or that was not asked for
122
+ in a coding."""
123
+
124
+ status: int
125
+ reason: str
126
+ headers: Headers
127
+ url: str
128
+
129
+ def __init__(
130
+ self,
131
+ status: int,
132
+ reason: str,
133
+ headers: "_ty.Mapping[str, str] | _ty.Iterable[tuple[str, str]]",
134
+ url: str,
135
+ body: "bytes | _ty.Any" = b"",
136
+ *,
137
+ decode: bool = True,
138
+ ):
139
+ self._closed = False
140
+ self._ended = False
141
+ self.status = int(status)
142
+ self.reason = reason or ""
143
+ self.headers = headers if isinstance(headers, Headers) else Headers(headers)
144
+ self.url = url
145
+ self._kept = False
146
+ if isinstance(body, (bytes, bytearray, memoryview)):
147
+ body = _Memory(body)
148
+ self._kept = True # in memory: readable after close
149
+ self._source = body
150
+ self._inflater = _inflater_for(self.headers) if decode else None
151
+ self._leftover = b""
152
+
153
+ def __repr__(self) -> str:
154
+ return f"HTTPResponse({self.status}, {self.reason!r})"
155
+
156
+ def __enter__(self) -> "HTTPResponse":
157
+ return self
158
+
159
+ def __exit__(self, *exc_info) -> None:
160
+ self.close()
161
+
162
+ def __del__(self):
163
+ try:
164
+ self.close()
165
+ except Exception:
166
+ pass
167
+
168
+ def _check_open(self) -> None:
169
+ if self._closed and not self._kept:
170
+ raise ValueError("read of a closed response")
171
+
172
+ def _next(self, limit: int = CHUNK) -> bytes:
173
+ """The next decoded bytes of the body; empty at its end. Without a
174
+ coding, no more than `limit` bytes are asked of the transport."""
175
+ if self._ended:
176
+ return b""
177
+ self._check_open()
178
+ inflater = self._inflater
179
+ while True:
180
+ if inflater is not None and inflater.has_input:
181
+ out = inflater.step()
182
+ if out:
183
+ return out
184
+ continue
185
+ out = self._source.read(CHUNK if inflater else min(CHUNK, limit))
186
+ if not out:
187
+ if inflater is not None:
188
+ inflater.finish()
189
+ self._ended = True
190
+ return b""
191
+ if inflater is None:
192
+ return out
193
+ inflater.feed(out)
194
+
195
+ def read(self, size: "int | None" = -1) -> bytes:
196
+ """Up to `size` bytes of the decoded body, fewer only at its end; the
197
+ whole rest of it when `size` is negative or `None`."""
198
+ self._check_open()
199
+ parts = []
200
+ have = 0
201
+ while size is None or size < 0 or have < size:
202
+ wanted = CHUNK if size is None or size < 0 else size - have
203
+ chunk = self._leftover or self._next(wanted)
204
+ if not chunk:
205
+ break
206
+ if size is not None and 0 <= size < have + len(chunk):
207
+ self._leftover = chunk[size - have :]
208
+ chunk = chunk[: size - have]
209
+ else:
210
+ self._leftover = b""
211
+ parts.append(chunk)
212
+ have += len(chunk)
213
+ return b"".join(parts)
214
+
215
+ def iter_bytes(self, chunk_size: int = CHUNK) -> "_ty.Iterator[bytes]":
216
+ """The rest of the body in pieces of at most `chunk_size` bytes, each
217
+ as soon as it has arrived."""
218
+ self._check_open()
219
+ while True:
220
+ chunk = self._leftover or self._next(chunk_size)
221
+ if not chunk:
222
+ return
223
+ self._leftover = chunk[chunk_size:]
224
+ yield chunk[:chunk_size]
225
+
226
+ def close(self) -> None:
227
+ """Releases the connection. Harmless when called again. A body that was
228
+ not read to its end is abandoned, and its connection is closed."""
229
+ if not getattr(self, "_closed", True):
230
+ self._closed = True
231
+ self._source.close()
232
+
233
+ def buffered(self) -> "HTTPResponse":
234
+ """Reads the rest of the body into memory, closes the connection and
235
+ returns this response, which can then be read after it is closed. A
236
+ transport returns its response this way when `send()` is called with
237
+ `stream=False`."""
238
+ return self._buffered()
239
+
240
+ def _buffered(
241
+ self, limit: "int | None" = None, message: str = ""
242
+ ) -> "HTTPResponse":
243
+ """Reads the rest of the body into memory and closes the source, so the
244
+ response can be read after the connection has been given back. A
245
+ decoded body longer than `limit` bytes is `OSError(EFBIG)` with
246
+ `message`, and its connection is closed."""
247
+ try:
248
+ data = self.read(-1 if limit is None else limit + 1)
249
+ if limit is not None and len(data) > limit:
250
+ raise OSError(_errno.EFBIG, message or f"larger than {limit} bytes")
251
+ self._leftover = data
252
+ self._kept = True
253
+ finally:
254
+ self.close()
255
+ return self
@@ -0,0 +1,12 @@
1
+ """The module-level setting of the HTTP client. Code reads it as an attribute
2
+ of this module, so assigning it applies at once."""
3
+
4
+ from __future__ import annotations
5
+
6
+ MAX_REPLY_BYTES = 8 * 1024 * 1024
7
+ """Most bytes of a reply the library reads whole, after any content decoding:
8
+ what `HTTPClient.request(stream=False)` returns, and a reply read for what it
9
+ says (`read_limited()`, `json_body()`). A longer body raises `OSError(EFBIG)`
10
+ naming the request and closes the connection. The body of a reply nobody looks
11
+ at is read up to this many bytes and dropped (`drop_body()`). Raise it for a
12
+ document that is legitimately larger. Read at the time of each use."""
@@ -0,0 +1,68 @@
1
+ """The status of a reply, and how much of its body the library reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+
7
+ from . import _settings
8
+ from ._headers import peer_text
9
+ from ._response import CHUNK, HTTPResponse
10
+
11
+
12
+ class HTTPStatusError(Exception):
13
+ """A reply with an error status (4xx or 5xx). `response` is the reply;
14
+ `translate_errors()` turns the error into the exception `pathlib` raises."""
15
+
16
+ response: HTTPResponse
17
+
18
+ def __init__(self, response: HTTPResponse):
19
+ super().__init__(response.status)
20
+ self.response = response
21
+
22
+
23
+ def raise_for_status(resp: HTTPResponse) -> None:
24
+ """Raises `HTTPStatusError` for a reply with status 400 or above."""
25
+ if resp.status >= 400:
26
+ raise HTTPStatusError(resp)
27
+
28
+
29
+ def too_large(what: object, limit: int) -> str:
30
+ return (
31
+ f"The reply for {peer_text(what, 300)} is larger than {limit} bytes "
32
+ "(MAX_REPLY_BYTES)"
33
+ )
34
+
35
+
36
+ def read_limited(resp: HTTPResponse, what: object, limit: "int | None" = None) -> bytes:
37
+ """The decoded body of `resp`, which must be at most `limit` bytes (default
38
+ `MAX_REPLY_BYTES`): `OSError(EFBIG)` naming `what` otherwise, with the
39
+ connection closed."""
40
+ limit = _settings.MAX_REPLY_BYTES if limit is None else limit
41
+ resp._buffered(limit, too_large(what, limit))
42
+ return resp.read()
43
+
44
+
45
+ def drop_body(resp: HTTPResponse, limit: "int | None" = None) -> None:
46
+ """Gives up the body of a reply nobody looks at: it is read, up to `limit`
47
+ bytes (default `MAX_REPLY_BYTES`), so the connection can serve the next
48
+ request, and `resp` is closed (which closes the connection when the body
49
+ was longer). A failure while reading it is not the caller's concern: the
50
+ status was already used."""
51
+ remaining = _settings.MAX_REPLY_BYTES if limit is None else limit
52
+ try:
53
+ while remaining > 0:
54
+ chunk = resp.read(min(CHUNK, remaining))
55
+ if not chunk:
56
+ break
57
+ remaining -= len(chunk)
58
+ except (OSError, ValueError):
59
+ pass
60
+ finally:
61
+ resp.close()
62
+
63
+
64
+ def json_body(resp: HTTPResponse, what: object, limit: "int | None" = None):
65
+ """The JSON document in the body of `resp` (at most `limit` bytes, default
66
+ `MAX_REPLY_BYTES`, else `OSError(EFBIG)` naming `what`); `ValueError` when
67
+ it is not one."""
68
+ return _json.loads(read_limited(resp, what, limit))
@@ -0,0 +1,173 @@
1
+ """The HTTP transports that exist, which one is the default, and what a new one
2
+ starts from."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os as _os
7
+ import typing as _ty
8
+
9
+ from ._body import prepare_body as _prepare_body
10
+ from ._builtin import HTTPTransport
11
+ from ._headers import (
12
+ check_body as _check_body,
13
+ check_headers as _check_headers,
14
+ check_method as _check_method,
15
+ check_timeout as _check_timeout,
16
+ check_url as _check_url,
17
+ has_header as _has_header,
18
+ wants_decoding as _wants_decoding,
19
+ )
20
+
21
+ TRANSPORT_GROUP = "pathapi.http_transports"
22
+ """The entry-point group of the HTTP transports. An entry's name is the
23
+ transport's name (`requests`) and its value names a class that is built with
24
+ no arguments."""
25
+
26
+ TRANSPORT_ENV = "PATHAPI_HTTP_TRANSPORT"
27
+ """Names the transport an `HTTPClient` builds for itself: `builtin`, or the
28
+ name of one that an installed distribution declares in `TRANSPORT_GROUP`
29
+ (`requests`, from `pathapi-requests`). Read when that transport is first
30
+ needed; unset means `builtin`."""
31
+
32
+ _BUILTIN = "builtin"
33
+
34
+ # Transport -> (the distribution that provides it, its import name, what to run
35
+ # to get it). The name is known without the distribution, whose own entry in
36
+ # `TRANSPORT_GROUP` is found first.
37
+ _PROVIDED = {
38
+ "requests": (
39
+ "pathapi-requests",
40
+ "pathapi_requests",
41
+ "pip install pathapi-requests",
42
+ ),
43
+ }
44
+
45
+ # The methods whose request says `Content-Length: 0` when it has no body.
46
+ _EXPECTING_BODY = frozenset({"PATCH", "POST", "PUT"})
47
+
48
+
49
+ def _declared() -> "dict[str, _ty.Any]":
50
+ """The entry points of `TRANSPORT_GROUP` by name, the first declaration of
51
+ a name winning."""
52
+ import importlib.metadata as _metadata
53
+
54
+ try:
55
+ found = _metadata.entry_points(group=TRANSPORT_GROUP)
56
+ except TypeError: # Python 3.9
57
+ found = _metadata.entry_points().get(TRANSPORT_GROUP, ())
58
+ declared: "dict[str, _ty.Any]" = {}
59
+ for entry in found:
60
+ declared.setdefault(entry.name, entry)
61
+ return declared
62
+
63
+
64
+ def transport_names() -> "tuple[str, ...]":
65
+ """The names of the HTTP transports, sorted: `builtin`, every entry of
66
+ `TRANSPORT_GROUP`, and the transports that another distribution provides
67
+ whether or not it is installed (`requests`). Nothing is imported."""
68
+ return tuple(sorted({_BUILTIN, *_declared(), *_PROVIDED}))
69
+
70
+
71
+ def get_transport(name: str) -> type:
72
+ """The class of the transport called `name` (case-sensitive), loading its
73
+ entry point. `builtin` is always `HTTPTransport`: no entry point replaces
74
+ it.
75
+
76
+ Raises `ImportError` naming what to install for a transport that another
77
+ distribution provides and that is not installed, and `ValueError` naming
78
+ the choices for any other name."""
79
+ if name == _BUILTIN:
80
+ return HTTPTransport
81
+ entry = _declared().get(name) if isinstance(name, str) else None
82
+ if entry is not None:
83
+ found = entry.load()
84
+ if not callable(found):
85
+ raise TypeError(f"the HTTP transport {name!r} is not a class")
86
+ return found
87
+ if isinstance(name, str) and name in _PROVIDED:
88
+ distribution, package, install = _PROVIDED[name]
89
+ raise ImportError(
90
+ f"the {name!r} HTTP transport is provided by the {distribution} "
91
+ f"distribution: {install}",
92
+ name=package,
93
+ )
94
+ raise ValueError(
95
+ f"{name!r} is not an HTTP transport "
96
+ f"(expected one of {', '.join(map(repr, transport_names()))})"
97
+ )
98
+
99
+
100
+ def default_transport():
101
+ """A new transport of the kind `PATHAPI_HTTP_TRANSPORT` names. A value
102
+ that is empty, differently cased or unknown is `ValueError` naming the
103
+ choices; one that another distribution provides and that is not installed
104
+ is `ImportError` naming it."""
105
+ value = _os.environ.get(TRANSPORT_ENV)
106
+ kind = _BUILTIN if value is None else value
107
+ if kind == _BUILTIN:
108
+ return HTTPTransport()
109
+ try:
110
+ transport_class = get_transport(kind)
111
+ except ValueError:
112
+ raise ValueError(
113
+ f"{TRANSPORT_ENV}={kind!r} is not an HTTP transport "
114
+ f"(expected one of {', '.join(map(repr, transport_names()))})"
115
+ ) from None
116
+ return transport_class()
117
+
118
+
119
+ class PreparedRequest(_ty.NamedTuple):
120
+ """A request that passed the checks of the transport contract.
121
+
122
+ `headers` hold text values, with the `Content-Length` of a body whose
123
+ length is known (`0` for a `PATCH`, `POST` or `PUT` without a body) and an
124
+ `Accept-Encoding` of `gzip, deflate` unless the caller named one. `body` is
125
+ `None`, `bytes`, an object that sends exactly the `Content-Length` bytes
126
+ when it is read or iterated, or an iterable of `bytes` to send chunked.
127
+ `timeout` is `(connect, read)`. `decode` says whether the response decodes
128
+ a `gzip` or `deflate` body: build it with `HTTPResponse(...,
129
+ decode=decode)`."""
130
+
131
+ method: str
132
+ url: str
133
+ headers: "dict[str, str]"
134
+ body: _ty.Any
135
+ timeout: "tuple[float | None, float | None]"
136
+ decode: bool
137
+
138
+
139
+ def prepare_request(
140
+ method: str,
141
+ url: str,
142
+ *,
143
+ headers: "_ty.Mapping[str, str] | None",
144
+ body: "bytes | _ty.BinaryIO | _ty.Iterable[bytes] | None" = None,
145
+ timeout: "float | tuple | None",
146
+ ) -> PreparedRequest:
147
+ """The arguments of `send()` as the contract reads them.
148
+
149
+ `method` is upper-cased. Raises `ValueError`, before anything is sent, for
150
+ a method or header name that is not an HTTP token, a header value or URL
151
+ with a control character, a URL with credentials or another scheme, a
152
+ `Content-Length` that is not a count of bytes or exceeds the body, and a
153
+ `timeout` that is not a positive number, `None` or a pair of them;
154
+ `TypeError` for a `str` body. None of the messages quotes the URL or a
155
+ header value. The fragment is dropped from `url`."""
156
+ method = _check_method(method)
157
+ _check_url(url)
158
+ _check_body(body)
159
+ timeout = _check_timeout(timeout)
160
+ pairs = _check_headers(headers)
161
+ sent = dict(pairs)
162
+ body, length = _prepare_body(body, pairs)
163
+ if length is not None:
164
+ sent["Content-Length"] = str(length)
165
+ elif not _has_header(sent, "Content-Length"):
166
+ if isinstance(body, (bytes, bytearray, memoryview)):
167
+ sent["Content-Length"] = str(memoryview(body).nbytes)
168
+ elif body is None and method in _EXPECTING_BODY:
169
+ sent["Content-Length"] = "0"
170
+ decode = _wants_decoding(sent.items())
171
+ if not _has_header(sent, "Accept-Encoding"):
172
+ sent["Accept-Encoding"] = "gzip, deflate"
173
+ return PreparedRequest(method, url.split("#", 1)[0], sent, body, timeout, decode)
@@ -0,0 +1,80 @@
1
+ """Testing support: the suite that holds every HTTP transport to the same
2
+ promises.
3
+
4
+ This module is testing support, not part of the stable API: it is importable
5
+ and documented, but its contents (the test names, the fixtures, the helper
6
+ signatures, `MODULES` and `HELPERS`) may change in any release. `pathapi.http`
7
+ never imports it.
8
+
9
+ `HTTPClient` and the transport contract are tested over a transport chosen by
10
+ name, against loopback servers scripted at the byte level. A distribution that
11
+ provides a transport (see `pathapi.http.get_transport`) runs the same
12
+ tests over its own, with its distribution installed::
13
+
14
+ python -m pathapi.http.testing --http-transports requests -q
15
+
16
+ Everything after `python -m pathapi.http.testing` is passed to pytest,
17
+ which this module needs. `--http-transports` takes names that
18
+ `pathapi.http.transport_names()` lists, separated by commas; it defaults to
19
+ `builtin`. Every request goes to the loopback interface: the suite fails a test
20
+ that tries to reach any other host. The TLS tests need `cryptography` and skip
21
+ without it. This module imports nothing but the standard library: importing it
22
+ loads `pathapi.http` (and so `ssl`) but not `pytest`, which `run()` imports.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import pathlib as _pathlib
28
+ import typing as _ty
29
+
30
+ __all__ = ["HELPERS", "MODULES", "run", "suite_directory"]
31
+
32
+ MODULES = (
33
+ "test_client_default.py",
34
+ "test_client_errors.py",
35
+ "test_client_peer_text.py",
36
+ "test_client_redirect_origin.py",
37
+ "test_client_redirects.py",
38
+ "test_client_reply_limits.py",
39
+ "test_client_write_redirects.py",
40
+ "test_transport_body.py",
41
+ "test_transport_send.py",
42
+ )
43
+ """The test modules that run once per transport, by file name."""
44
+
45
+ HELPERS = (
46
+ "conftest.py",
47
+ "transport_fakes.py",
48
+ "transport_guard.py",
49
+ "transport_wire.py",
50
+ )
51
+ """The files beside them that they import: the fixtures and the loopback servers."""
52
+
53
+
54
+ def suite_directory() -> _pathlib.Path:
55
+ """The directory that holds `MODULES` and `HELPERS`: the copy installed
56
+ with the package, or `tests/transport/` of a source checkout. Raises
57
+ `FileNotFoundError` when neither is there."""
58
+ here = _pathlib.Path(__file__).resolve().parent
59
+ for candidate in (here / "suite", here.parents[3] / "tests" / "transport"):
60
+ if all((candidate / name).is_file() for name in (*MODULES, *HELPERS)):
61
+ return candidate
62
+ raise FileNotFoundError("the transport suite is not installed with pathapi")
63
+
64
+
65
+ def run(arguments: "_ty.Sequence[str] | None" = None) -> int:
66
+ """Runs `MODULES` with pytest and `arguments` (pytest options, such as
67
+ `--http-transports requests`) and returns the exit status. Raises
68
+ `ImportError` when pytest is not installed."""
69
+ try:
70
+ import pytest
71
+ except ImportError as error:
72
+ raise ImportError(
73
+ "the transport suite needs pytest: pip install pytest", name="pytest"
74
+ ) from error
75
+ directory = suite_directory()
76
+ files = [str(directory / name) for name in MODULES]
77
+ # The files go first: with an option and its value ahead of them, pytest
78
+ # 8 reads the value as the first path and loads no `conftest.py`, so the
79
+ # option `--http-transports` is not known.
80
+ return int(pytest.main([*files, *(arguments or ())]))
@@ -0,0 +1,10 @@
1
+ """`python -m pathapi.http.testing [pytest options]` runs the transport suite."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from . import run
8
+
9
+ if __name__ == "__main__":
10
+ raise SystemExit(run(sys.argv[1:]))