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,98 @@
1
+ """Fixtures of the HTTP transport suite.
2
+
3
+ The suite checks one transport per run (`--http-transports`), against servers
4
+ on the loopback interface only, and needs nothing but `pytest`, the standard
5
+ library and `pathapi` (the TLS tests also need `cryptography`, and skip
6
+ without it). It is shipped with `pathapi.http.testing` so that another
7
+ distribution can hold its transport to the same promises.
8
+ """
9
+
10
+ import socket
11
+ import tempfile
12
+
13
+ import pytest
14
+
15
+ import transport_guard
16
+
17
+
18
+ def pytest_addoption(parser):
19
+ parser.addoption(
20
+ "--http-transports",
21
+ default="builtin",
22
+ help="the HTTP transports the transport suite runs against, by the "
23
+ "names of pathapi.http.transport_names(), separated by commas "
24
+ "(for example builtin,requests)",
25
+ )
26
+
27
+
28
+ def pytest_configure(config):
29
+ # The session's environment is set before the first test's snapshot of it
30
+ # is taken, and put back when pytest exits.
31
+ home = tempfile.TemporaryDirectory(prefix="pathapi-transport-home-")
32
+ patch = pytest.MonkeyPatch()
33
+ transport_guard.isolate_environment(patch, home.name)
34
+ config.add_cleanup(home.cleanup)
35
+ config.add_cleanup(patch.undo)
36
+
37
+
38
+ @pytest.fixture(autouse=True)
39
+ def _environment_unchanged():
40
+ """A test that leaves the process environment changed fails; the
41
+ environment is put back first so that the next test starts clean."""
42
+ before = transport_guard.environment_snapshot()
43
+ yield
44
+ changed = transport_guard.changed_variables(
45
+ before, transport_guard.environment_snapshot()
46
+ )
47
+ if changed:
48
+ transport_guard.restore_environment(before)
49
+ pytest.fail(
50
+ "the test left the process environment changed (use monkeypatch): "
51
+ + ", ".join(f"{k}: {old!r} -> {new!r}" for k, (old, new) in changed.items())
52
+ )
53
+
54
+
55
+ @pytest.fixture(autouse=True)
56
+ def _block_non_loopback_network(_environment_unchanged, monkeypatch):
57
+ """Any connect, send or name lookup aimed at a host other than loopback
58
+ raises `NetworkAccessBlocked`, and a blocked attempt fails the test at
59
+ teardown even if the code under test swallowed the error."""
60
+ blocked = []
61
+ transport_guard.install_network_guard(monkeypatch, blocked)
62
+ yield blocked
63
+ if blocked:
64
+ pytest.fail(f"non-loopback network access attempted: {blocked}")
65
+
66
+
67
+ def pytest_generate_tests(metafunc):
68
+ if "http_transport_kind" in metafunc.fixturenames:
69
+ option = metafunc.config.getoption("--http-transports", default="builtin")
70
+ kinds = [name.strip() for name in option.split(",") if name.strip()]
71
+ metafunc.parametrize("http_transport_kind", kinds, indirect=True)
72
+
73
+
74
+ @pytest.fixture
75
+ def http_transport_kind(request, monkeypatch):
76
+ """Runs the test once per HTTP transport (`--http-transports`): the
77
+ default transport of every client the test builds is that kind
78
+ (`PATHAPI_HTTP_TRANSPORT`), and `transport_fakes.make_transport(kind)`
79
+ builds one of it."""
80
+ monkeypatch.setenv("PATHAPI_HTTP_TRANSPORT", request.param)
81
+ return request.param
82
+
83
+
84
+ @pytest.fixture
85
+ def http_transport(http_transport_kind):
86
+ """A transport of the kind under test, closed after the test."""
87
+ from transport_fakes import make_transport
88
+
89
+ with make_transport(http_transport_kind) as transport:
90
+ yield transport
91
+
92
+
93
+ @pytest.fixture
94
+ def unused_tcp_port():
95
+ """A port nobody is listening on, for connection-refused coverage."""
96
+ with socket.socket() as probe:
97
+ probe.bind(("127.0.0.1", 0))
98
+ return probe.getsockname()[1]
@@ -0,0 +1,285 @@
1
+ """The client: which transport it sends with when it is not given one (the
2
+ built-in one, the `requests` one only when asked, and nothing imported that is
3
+ not needed), what it closes, and the headers and timeout every request gets."""
4
+
5
+ import base64
6
+ import os
7
+ import pathlib
8
+ import subprocess
9
+ import sys
10
+ import textwrap
11
+
12
+ import pytest
13
+
14
+ import pathapi
15
+ import pathapi.http as http
16
+ from pathapi.http import HTTPClient, HTTPTransport
17
+ from transport_fakes import FakeTransport, reply
18
+ from transport_wire import Hang, Wire
19
+
20
+ _SRC = pathlib.Path(pathapi.__file__).resolve().parent.parent
21
+ VARIABLE = "PATHAPI_HTTP_TRANSPORT"
22
+
23
+
24
+ def test_the_variable_in_the_module_is_the_one_the_code_reads():
25
+ assert http.TRANSPORT_ENV == VARIABLE
26
+
27
+
28
+ def test_without_a_setting_the_built_in_transport_is_used(monkeypatch):
29
+ monkeypatch.delenv(VARIABLE, raising=False)
30
+ with HTTPClient() as client:
31
+ assert type(client.transport) is HTTPTransport
32
+
33
+
34
+ def test_a_client_builds_its_transport_on_first_use_and_keeps_it(monkeypatch):
35
+ monkeypatch.delenv(VARIABLE, raising=False)
36
+ with HTTPClient() as client:
37
+ assert client._transport is None
38
+ first = client.transport
39
+ assert client.transport is first
40
+
41
+
42
+ def test_the_variable_chooses_the_transport(monkeypatch):
43
+ monkeypatch.setenv(VARIABLE, "builtin")
44
+ with HTTPClient() as client:
45
+ assert type(client.transport) is HTTPTransport
46
+
47
+
48
+ def test_the_uripath_variable_does_not_choose_the_transport(monkeypatch):
49
+ monkeypatch.delenv(VARIABLE, raising=False)
50
+ monkeypatch.setenv("URIPATH_HTTP_TRANSPORT", "requests")
51
+ with HTTPClient() as client:
52
+ assert type(client.transport) is HTTPTransport
53
+
54
+
55
+ @pytest.mark.parametrize("value", ["", "Requests", "BUILTIN", "urllib3", " builtin"])
56
+ def test_a_value_that_is_not_a_choice_is_a_value_error_naming_the_choices(
57
+ monkeypatch, value
58
+ ):
59
+ monkeypatch.setenv(VARIABLE, value)
60
+ client = HTTPClient()
61
+ with pytest.raises(ValueError) as excinfo:
62
+ client.transport
63
+ message = str(excinfo.value)
64
+ assert VARIABLE in message and "'builtin'" in message and "'requests'" in message
65
+
66
+
67
+ def test_a_bad_setting_is_reported_when_the_client_sends_not_when_it_is_built(
68
+ monkeypatch,
69
+ ):
70
+ monkeypatch.setenv(VARIABLE, "nonsense")
71
+ client = HTTPClient()
72
+ with pytest.raises(ValueError):
73
+ client.request("GET", "http://127.0.0.1:1/x")
74
+
75
+
76
+ def test_a_client_closes_the_transport_it_built_and_not_one_it_was_given(monkeypatch):
77
+ monkeypatch.delenv(VARIABLE, raising=False)
78
+ given = FakeTransport(default=reply(200))
79
+ client = HTTPClient(given)
80
+ client.close()
81
+ assert given.closed == 0
82
+
83
+ built = HTTPClient()
84
+ transport = built.transport
85
+ closed = []
86
+ transport.close = lambda: closed.append(True)
87
+ built.close()
88
+ built.close()
89
+ assert closed == [True]
90
+
91
+
92
+ def test_a_transport_is_an_object_with_a_send_method():
93
+ with pytest.raises(TypeError):
94
+ HTTPClient(object())
95
+
96
+
97
+ def test_the_representation_of_a_client_shows_no_header():
98
+ client = HTTPClient(headers={"Authorization": "Bearer SECRET"})
99
+ assert "SECRET" not in repr(client) and "Authorization" not in repr(client)
100
+
101
+
102
+ # --- what every request carries -------------------------------------------------
103
+
104
+
105
+ def test_a_request_names_the_library_and_its_version_as_its_user_agent():
106
+ transport = FakeTransport(default=reply(200))
107
+ HTTPClient(transport).request("GET", "http://h/x")
108
+ sent = transport.sent[0].headers
109
+ assert sent["User-Agent"] == f"pathapi/{pathapi.__version__}"
110
+ assert sent["Accept"] == "*/*"
111
+
112
+
113
+ def test_the_user_agent_can_be_named_by_the_caller_of_the_client():
114
+ transport = FakeTransport(default=reply(200))
115
+ client = HTTPClient(transport, user_agent="tool/1.2")
116
+ assert client.user_agent == "tool/1.2"
117
+ client.request("GET", "http://h/x")
118
+ assert transport.sent[0].headers["User-Agent"] == "tool/1.2"
119
+
120
+
121
+ def test_headers_are_merged_by_name_the_call_over_the_client_over_the_base():
122
+ transport = FakeTransport(default=reply(200))
123
+ client = HTTPClient(transport, headers={"accept": "text/html", "X-A": "client"})
124
+ client.request("GET", "http://h/x", headers={"x-a": "call", "X-B": "call"})
125
+ sent = transport.sent[0].headers
126
+ assert {k.lower(): v for k, v in sent.items()} == {
127
+ "user-agent": f"pathapi/{pathapi.__version__}",
128
+ "accept": "text/html",
129
+ "x-a": "call",
130
+ "x-b": "call",
131
+ }
132
+ assert len(sent) == 4
133
+
134
+
135
+ def test_a_header_set_to_none_in_the_call_is_not_sent():
136
+ transport = FakeTransport(default=reply(200))
137
+ HTTPClient(transport).request("GET", "http://h/x", headers={"Accept": None})
138
+ assert "Accept" not in transport.sent[0].headers
139
+
140
+
141
+ def test_credentials_given_as_auth_are_sent_as_a_basic_authorization():
142
+ transport = FakeTransport(default=reply(200))
143
+ HTTPClient(transport).request("GET", "http://h/x", auth=("al@ice", "s3cr3t"))
144
+ expected = "Basic " + base64.b64encode(b"al@ice:s3cr3t").decode("ascii")
145
+ assert transport.sent[0].headers["Authorization"] == expected
146
+ assert transport.sent[0].url == "http://h/x"
147
+
148
+
149
+ def test_a_string_given_as_auth_is_the_authorization_value():
150
+ transport = FakeTransport(default=reply(200))
151
+ HTTPClient(transport).request("GET", "http://h/x", auth="Bearer TOKEN")
152
+ assert transport.sent[0].headers["Authorization"] == "Bearer TOKEN"
153
+
154
+
155
+ @pytest.mark.parametrize("where", ["client", "call"])
156
+ def test_an_authorization_header_wins_over_auth(where):
157
+ transport = FakeTransport(default=reply(200))
158
+ options = {"headers": {"authorization": "Bearer B"}}
159
+ client = HTTPClient(transport, **(options if where == "client" else {}))
160
+ client.request(
161
+ "GET",
162
+ "http://h/x",
163
+ auth=("alice", "pw"),
164
+ **(options if where == "call" else {}),
165
+ )
166
+ sent = transport.sent[0].headers
167
+ assert [v for k, v in sent.items() if k.lower() == "authorization"] == ["Bearer B"]
168
+
169
+
170
+ def test_a_url_with_credentials_is_refused_before_anything_is_sent():
171
+ transport = FakeTransport(default=reply(200))
172
+ with pytest.raises(ValueError) as excinfo:
173
+ HTTPClient(transport).request("GET", "http://alice:s3cr3t@h/x")
174
+ assert "s3cr3t" not in str(excinfo.value)
175
+ assert transport.sent == []
176
+
177
+
178
+ def test_a_header_or_url_with_a_line_break_is_refused_before_any_transport_call():
179
+ transport = FakeTransport(default=reply(200))
180
+ for headers in ({"X-A": "1\r\nX-B: 2"}, {"X-A": "1\n2"}):
181
+ with pytest.raises(ValueError):
182
+ HTTPClient(transport, headers=headers).request("GET", "http://h/x")
183
+ with pytest.raises(ValueError):
184
+ HTTPClient(transport).request("GET", "http://h/x", headers=headers)
185
+ with pytest.raises(ValueError):
186
+ HTTPClient(transport).request("GET", "http://h/x\r\nX-A: 1")
187
+ assert transport.sent == []
188
+
189
+
190
+ def test_a_text_body_is_sent_as_utf_8():
191
+ transport = FakeTransport(default=reply(200))
192
+ HTTPClient(transport).request("PUT", "http://h/x", data="café")
193
+ assert transport.sent[0].body == "café".encode("utf-8")
194
+
195
+
196
+ def test_a_reply_is_asked_for_streamed_whatever_the_caller_asked():
197
+ transport = FakeTransport(default=reply(200, b"abc"))
198
+ client = HTTPClient(transport)
199
+ assert client.request("GET", "http://h/x").read() == b"abc"
200
+ client.request("GET", "http://h/x", stream=True).close()
201
+ assert [call.stream for call in transport.sent] == [True, True]
202
+
203
+
204
+ # --- the timeout -----------------------------------------------------------------
205
+
206
+
207
+ def test_a_request_without_a_timeout_gets_the_default():
208
+ transport = FakeTransport()
209
+ HTTPClient(transport).request("GET", "http://h/x")
210
+ assert transport.sent[-1].timeout == (10, 60) == http.DEFAULT_TIMEOUT
211
+
212
+
213
+ @pytest.mark.parametrize("timeout", [5, None])
214
+ def test_a_timeout_of_the_client_or_the_call_wins(timeout):
215
+ transport = FakeTransport()
216
+ HTTPClient(transport, timeout=timeout).request("GET", "http://h/x")
217
+ assert transport.sent[-1].timeout == timeout
218
+ HTTPClient(transport).request("GET", "http://h/x", timeout=timeout)
219
+ assert transport.sent[-1].timeout == timeout
220
+
221
+
222
+ def test_a_stalled_server_times_out_when_the_client_has_a_timeout(http_transport_kind):
223
+ with Wire(lambda r: Hang()) as wire:
224
+ with HTTPClient(timeout=(2, 0.5)) as client:
225
+ with pytest.raises(TimeoutError):
226
+ client.request("GET", wire.url + "/x")
227
+
228
+
229
+ # --- no third-party library ---------------------------------------------------
230
+
231
+ _CHILD = """
232
+ import sys
233
+
234
+ import pathapi
235
+
236
+ assert not {"ssl", "http.client"} & set(sys.modules)
237
+ sys.modules["requests"] = None
238
+ sys.modules["urllib3"] = None
239
+
240
+ import http.server, threading
241
+
242
+
243
+ class Handler(http.server.BaseHTTPRequestHandler):
244
+ protocol_version = "HTTP/1.1"
245
+
246
+ def log_message(self, *args):
247
+ pass
248
+
249
+ def do_GET(self):
250
+ body = b"hello"
251
+ self.send_response(200)
252
+ self.send_header("Content-Length", str(len(body)))
253
+ self.end_headers()
254
+ self.wfile.write(body)
255
+
256
+
257
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
258
+ threading.Thread(target=server.serve_forever, daemon=True).start()
259
+
260
+ from pathapi.http import HTTPClient
261
+
262
+ with HTTPClient() as client:
263
+ reply = client.request("GET", "http://127.0.0.1:%d/f" % server.server_port)
264
+ print(reply.status, reply.read())
265
+ print(sorted(m for m in ("requests", "urllib3") if sys.modules.get(m) is not None))
266
+ server.shutdown()
267
+ server.server_close()
268
+ """
269
+
270
+
271
+ def test_a_request_works_with_requests_and_urllib3_hidden_and_the_root_loads_no_ssl():
272
+ env = dict(os.environ)
273
+ env["PYTHONPATH"] = os.pathsep.join(
274
+ filter(None, [str(_SRC), env.get("PYTHONPATH")])
275
+ )
276
+ env.pop(VARIABLE, None)
277
+ result = subprocess.run(
278
+ [sys.executable, "-c", textwrap.dedent(_CHILD)],
279
+ capture_output=True,
280
+ text=True,
281
+ timeout=120,
282
+ env=env,
283
+ )
284
+ assert result.returncode == 0, result.stderr
285
+ assert result.stdout.splitlines() == ["200 b'hello'", "[]"]
@@ -0,0 +1,181 @@
1
+ """How a failed request becomes the `OSError` a path raises: the status of a
2
+ reply maps to the `pathlib` exception type for its meaning, and the error of a
3
+ transport is raised again without its cause, so that a URL, a header or a
4
+ credential it quotes is in no message or traceback."""
5
+
6
+ import errno
7
+ import traceback
8
+
9
+ import pytest
10
+
11
+ from pathapi.http import (
12
+ HTTPClient,
13
+ HTTPStatusError,
14
+ os_error,
15
+ raise_for_status,
16
+ server_size,
17
+ translate_errors,
18
+ )
19
+ from transport_fakes import FakeTransport, reply
20
+ from transport_wire import Wire, refusing_port, response
21
+
22
+ SUBJECT = "http://host.test/f"
23
+
24
+
25
+ def formatted(error: BaseException) -> str:
26
+ """Everything a log line or traceback could show for `error`."""
27
+ return "".join(
28
+ traceback.format_exception(type(error), error, error.__traceback__)
29
+ ) + "\n".join((str(error), repr(error)))
30
+
31
+
32
+ def failing_with(status, reason="", **options):
33
+ client = HTTPClient(FakeTransport({("GET", SUBJECT): reply(status, reason=reason)}))
34
+ with translate_errors(SUBJECT, **options):
35
+ raise_for_status(client.request("GET", SUBJECT))
36
+
37
+
38
+ @pytest.mark.parametrize(
39
+ "status, error, number",
40
+ [
41
+ (404, FileNotFoundError, errno.ENOENT),
42
+ (410, FileNotFoundError, errno.ENOENT),
43
+ (401, PermissionError, errno.EACCES),
44
+ (403, PermissionError, errno.EACCES),
45
+ (409, FileExistsError, errno.EEXIST),
46
+ ],
47
+ )
48
+ def test_a_status_becomes_the_exception_pathlib_raises_for_its_meaning(
49
+ status, error, number
50
+ ):
51
+ with pytest.raises(error) as excinfo:
52
+ failing_with(status)
53
+ assert type(excinfo.value) is error
54
+ assert excinfo.value.errno == number
55
+ assert excinfo.value.filename == SUBJECT
56
+
57
+
58
+ def test_a_conflict_means_a_missing_parent_for_a_write():
59
+ with pytest.raises(FileNotFoundError):
60
+ failing_with(409, conflict=FileNotFoundError)
61
+
62
+
63
+ @pytest.mark.parametrize("status", [405, 501])
64
+ def test_a_method_the_server_does_not_allow_is_a_permission_error_naming_the_status(
65
+ status,
66
+ ):
67
+ with pytest.raises(PermissionError) as excinfo:
68
+ failing_with(status)
69
+ assert f"HTTP {status}" in str(excinfo.value)
70
+
71
+
72
+ @pytest.mark.parametrize("status", [400, 500, 502, 503, 429])
73
+ def test_any_other_status_is_oserror_eio_naming_it_and_the_subject(status):
74
+ with pytest.raises(OSError) as excinfo:
75
+ failing_with(status, "Reason")
76
+ assert type(excinfo.value) is OSError
77
+ assert excinfo.value.errno == errno.EIO
78
+ assert f"HTTP Error {status} Reason for {SUBJECT}" in str(excinfo.value)
79
+
80
+
81
+ def test_a_status_error_keeps_the_reply_it_was_raised_for():
82
+ answer = reply(404)
83
+ with pytest.raises(HTTPStatusError) as excinfo:
84
+ raise_for_status(answer)
85
+ assert excinfo.value.response is answer
86
+ raise_for_status(reply(399))
87
+
88
+
89
+ def test_an_error_is_raised_without_its_cause():
90
+ with pytest.raises(FileNotFoundError) as excinfo:
91
+ failing_with(404)
92
+ assert excinfo.value.__cause__ is None
93
+ assert excinfo.value.__suppress_context__
94
+
95
+
96
+ @pytest.mark.parametrize(
97
+ "make, expected",
98
+ [
99
+ (ConnectionError, ConnectionError),
100
+ (ConnectionRefusedError, ConnectionError),
101
+ (TimeoutError, TimeoutError),
102
+ ],
103
+ )
104
+ def test_the_error_of_a_transport_never_reaches_the_raised_error(make, expected):
105
+ # A transport's error can quote a proxy URL taken from the environment,
106
+ # credentials included; none of it may be in the error or its traceback.
107
+ quoted = "http://proxyuser:pr0xyS3cret@proxy.example:3128"
108
+ transport = FakeTransport()
109
+ transport.fail = make(f"ProxyError({quoted})")
110
+ with pytest.raises(expected) as excinfo:
111
+ with translate_errors(SUBJECT):
112
+ HTTPClient(transport).request("GET", SUBJECT)
113
+ assert "pr0xyS3cret" not in formatted(excinfo.value)
114
+ assert SUBJECT in str(excinfo.value)
115
+ assert type(make("")).__name__ in str(excinfo.value)
116
+ assert excinfo.value.__cause__ is None
117
+ assert excinfo.value.__suppress_context__
118
+
119
+
120
+ def test_a_refused_connection_carries_no_cause(http_transport_kind):
121
+ with HTTPClient() as client:
122
+ with pytest.raises(ConnectionError) as excinfo:
123
+ with translate_errors(SUBJECT):
124
+ client.request("GET", f"http://127.0.0.1:{refusing_port()}/x")
125
+ assert excinfo.value.__cause__ is None
126
+ assert excinfo.value.__suppress_context__
127
+
128
+
129
+ def test_a_stalled_server_is_a_timeout_error_without_a_cause(http_transport_kind):
130
+ from transport_wire import Hang
131
+
132
+ with Wire(lambda r: Hang()) as wire:
133
+ with HTTPClient(timeout=0.5) as client:
134
+ with pytest.raises(TimeoutError) as excinfo:
135
+ with translate_errors(SUBJECT):
136
+ client.request("GET", wire.url + "/x")
137
+ assert SUBJECT in str(excinfo.value)
138
+ assert excinfo.value.__cause__ is None
139
+ assert excinfo.value.__suppress_context__
140
+
141
+
142
+ def test_an_error_that_is_not_a_request_failure_passes_through():
143
+ with pytest.raises(KeyError):
144
+ with translate_errors(SUBJECT):
145
+ raise KeyError("x")
146
+
147
+
148
+ def test_os_error_is_built_as_pathlib_builds_it():
149
+ error = os_error(IsADirectoryError, SUBJECT)
150
+ assert (type(error), error.errno, error.filename) == (
151
+ IsADirectoryError,
152
+ errno.EISDIR,
153
+ SUBJECT,
154
+ )
155
+ other = os_error(OSError, SUBJECT)
156
+ assert (other.errno, other.filename) == (errno.EIO, SUBJECT)
157
+
158
+
159
+ @pytest.mark.parametrize(
160
+ "text, size",
161
+ [("5", 5), (" 12 ", 12), ("0", 0), (None, None), ("", None)]
162
+ + [("-1", None), ("1e3", None), ("1.5", None), ("٣", None)]
163
+ + [("9" * 20, None), ("0x10", None)],
164
+ )
165
+ def test_a_size_a_server_states_is_a_plain_count_or_nothing(text, size):
166
+ assert server_size(text) == size
167
+
168
+
169
+ def test_a_reply_status_error_text_has_the_reason_escaped():
170
+ esc = "\x1b[31m"
171
+ with Wire(
172
+ lambda r: (
173
+ f"HTTP/1.1 500 Server {esc}Error\r\nContent-Length: 0\r\n\r\n"
174
+ ).encode("latin-1")
175
+ ) as wire:
176
+ with HTTPClient() as client:
177
+ with pytest.raises(OSError) as excinfo:
178
+ with translate_errors(SUBJECT):
179
+ raise_for_status(client.request("GET", wire.url + "/x"))
180
+ assert "\x1b" not in str(excinfo.value)
181
+ assert "Server \\x1b[31mError" in str(excinfo.value)
@@ -0,0 +1,86 @@
1
+ """Text from the other end that goes into an exception message: a reason
2
+ phrase, a `Location`. It is shown with its control characters escaped and cut
3
+ to a length, whichever transport sent the request."""
4
+
5
+ import pytest
6
+
7
+ from pathapi.http import HTTPClient, peer_text, raise_for_status, translate_errors
8
+ from transport_wire import Reply, Wire
9
+
10
+ pytestmark = pytest.mark.usefixtures("http_transport_kind")
11
+
12
+ ESC = "\x1b[31m"
13
+
14
+
15
+ def reply_with_reason(reason, status=500):
16
+ raw = f"HTTP/1.1 {status} {reason}\r\nContent-Length: 0\r\n\r\n".encode("latin-1")
17
+ return lambda request: Reply(raw)
18
+
19
+
20
+ # --- the helper --------------------------------------------------------------------
21
+
22
+
23
+ @pytest.mark.parametrize(
24
+ "text, shown",
25
+ [
26
+ ("plain", "plain"),
27
+ ("café 日本", "café 日本"),
28
+ ("a\x1b[31mb", "a\\x1b[31mb"),
29
+ ("a\r\nb\tc\x00d", "a\\r\\nb\\tc\\x00d"),
30
+ ("a‮b", "a\\u202eb"), # a right-to-left override
31
+ ("a
b", "a\\u2028b"), # a line separator
32
+ ("a\x9bb", "a\\x9bb"), # the C1 control that starts an escape sequence
33
+ ],
34
+ )
35
+ def test_peer_text_escapes_what_a_terminal_would_act_on(text, shown):
36
+ assert peer_text(text) == shown
37
+
38
+
39
+ def test_peer_text_is_cut_to_its_limit():
40
+ assert peer_text("x" * 500) == "x" * 120 + "..."
41
+ assert peer_text("x" * 20, limit=20) == "x" * 20
42
+ assert peer_text("\x1b" * 500, limit=10) == "\\x1b" * 10 + "..."
43
+
44
+
45
+ # --- where the library shows it --------------------------------------------------
46
+
47
+
48
+ def test_a_reason_phrase_is_shown_escaped():
49
+ with Wire(reply_with_reason(f"Server {ESC}Error")) as wire:
50
+ with HTTPClient() as client:
51
+ with pytest.raises(OSError) as excinfo:
52
+ with translate_errors(wire.url):
53
+ raise_for_status(client.request("GET", wire.url + "/x"))
54
+ message = str(excinfo.value)
55
+ assert "\x1b" not in message
56
+ assert "Server \\x1b[31mError" in message
57
+
58
+
59
+ def test_a_long_reason_phrase_is_cut():
60
+ with Wire(reply_with_reason("R" * 20000)) as wire:
61
+ with HTTPClient() as client:
62
+ with pytest.raises(OSError) as excinfo:
63
+ with translate_errors(wire.url):
64
+ raise_for_status(client.request("GET", wire.url + "/x"))
65
+ assert len(str(excinfo.value)) < 400
66
+
67
+
68
+ def test_a_redirect_that_is_refused_is_shown_cut_and_with_an_escaped_reason(
69
+ http_transport,
70
+ ):
71
+ location = "/" + "a" * 5000
72
+
73
+ def script(request):
74
+ raw = (
75
+ f"HTTP/1.1 302 Found {ESC}\r\nLocation: {location}\r\n"
76
+ "Content-Length: 0\r\n\r\n"
77
+ ).encode("latin-1")
78
+ return Reply(raw)
79
+
80
+ with Wire(script) as wire:
81
+ client = HTTPClient(http_transport)
82
+ with pytest.raises(OSError) as excinfo:
83
+ client.request("PUT", wire.url + "/f", data=b"x")
84
+ message = str(excinfo.value)
85
+ assert "\x1b" not in message and "\\x1b[31m" in message
86
+ assert len(message) < 500