ginext-libsoup 0.8.1__tar.gz
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.
- ginext_libsoup-0.8.1/.gitignore +57 -0
- ginext_libsoup-0.8.1/PKG-INFO +23 -0
- ginext_libsoup-0.8.1/pyproject.toml +52 -0
- ginext_libsoup-0.8.1/src/ginext_libsoup/__init__.py +2 -0
- ginext_libsoup-0.8.1/src/ginext_libsoup/_overlays/Soup.py +718 -0
- ginext_libsoup-0.8.1/src/ginext_libsoup/_overlays/__init__.py +1 -0
- ginext_libsoup-0.8.1/tests/conftest.py +33 -0
- ginext_libsoup-0.8.1/tests/soup/test_async_client.py +205 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
.agents
|
|
2
|
+
.codex
|
|
3
|
+
history
|
|
4
|
+
|
|
5
|
+
# Generated mkdocs API reference (produced by `make api-docs` and in CI) + build.
|
|
6
|
+
/docs/api/
|
|
7
|
+
/site/
|
|
8
|
+
|
|
9
|
+
apps
|
|
10
|
+
.vscode
|
|
11
|
+
.idea/
|
|
12
|
+
build/
|
|
13
|
+
build-*/
|
|
14
|
+
venv*/
|
|
15
|
+
__pycache__
|
|
16
|
+
**/__pycache__
|
|
17
|
+
*.pyc
|
|
18
|
+
*.pyo
|
|
19
|
+
*.so
|
|
20
|
+
*.egg-info/
|
|
21
|
+
dist/
|
|
22
|
+
wheelhouse/
|
|
23
|
+
stubs/
|
|
24
|
+
# Generated stub files — run `make stubs` or `uv run python -m ginext_stubgen install`
|
|
25
|
+
packages/ginext-stubs/ginext-stubs/*.pyi
|
|
26
|
+
packages/ginext-stubs/.stub-generated.stamp
|
|
27
|
+
# mypy incremental cache (per-package dirs; see ci/run-mypy.sh)
|
|
28
|
+
.mypy_cache/
|
|
29
|
+
**/.mypy_cache/
|
|
30
|
+
memory/
|
|
31
|
+
pygobject
|
|
32
|
+
!src/ginext/tests/pygobject/
|
|
33
|
+
!src/ginext/tests/pygobject/**
|
|
34
|
+
src/ginext/tests/pygobject/.ruff_cache/
|
|
35
|
+
src/ginext/tests/pygobject/__pycache__/
|
|
36
|
+
!packages/ginext-gi-compat/tests/pygobject/
|
|
37
|
+
!packages/ginext-gi-compat/tests/pygobject/**
|
|
38
|
+
packages/ginext-gi-compat/tests/pygobject/__pycache__/
|
|
39
|
+
tags
|
|
40
|
+
# Editor / IDE local state
|
|
41
|
+
.obsidian/
|
|
42
|
+
core.*
|
|
43
|
+
src/gitlab-agent-bot/.env
|
|
44
|
+
src/gitlab-agent-bot/.venv/
|
|
45
|
+
src/gitlab-agent-bot/.ruff_cache/
|
|
46
|
+
src/gitlab-agent-bot/work/
|
|
47
|
+
# Third-party apps used as integration smoke tests (Drawing etc.) —
|
|
48
|
+
# checked out locally for `make drawing` / `make showtime`, not vendored.
|
|
49
|
+
# pyedit is our own showcase and IS tracked.
|
|
50
|
+
/apps/drawing/
|
|
51
|
+
/apps/drawing-run/
|
|
52
|
+
/apps/gnome-text-editor/
|
|
53
|
+
/apps/showtime/
|
|
54
|
+
vcpkg_installed/
|
|
55
|
+
|
|
56
|
+
# Windows build/scanner venvs
|
|
57
|
+
.venv*/
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ginext-libsoup
|
|
3
|
+
Version: 0.8.1
|
|
4
|
+
Summary: Soup namespace overlay for ginext
|
|
5
|
+
Project-URL: Homepage, https://github.com/jdahlin/ginext
|
|
6
|
+
Project-URL: Repository, https://github.com/jdahlin/ginext
|
|
7
|
+
Project-URL: Issues, https://github.com/jdahlin/ginext/issues
|
|
8
|
+
Author-email: Johan Dahlin <jdahlin@gmail.com>
|
|
9
|
+
License-Expression: LGPL-2.1-or-later
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Requires-Python: >=3.14
|
|
15
|
+
Requires-Dist: ginext-core>=0.8.1
|
|
16
|
+
Requires-Dist: ginext-gio>=0.8.1
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# ginext-libsoup
|
|
20
|
+
|
|
21
|
+
Soup namespace overlay for ginext
|
|
22
|
+
|
|
23
|
+
Part of [ginext](https://github.com/jdahlin/ginext) — fast, lazy, JIT-compiled GObject-introspection bindings for free-threaded Python.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ginext-libsoup"
|
|
3
|
+
version = "0.8.1"
|
|
4
|
+
description = "Soup namespace overlay for ginext"
|
|
5
|
+
readme = { content-type = "text/markdown", text = """
|
|
6
|
+
# ginext-libsoup
|
|
7
|
+
|
|
8
|
+
Soup namespace overlay for ginext
|
|
9
|
+
|
|
10
|
+
Part of [ginext](https://github.com/jdahlin/ginext) — fast, lazy, JIT-compiled GObject-introspection bindings for free-threaded Python.
|
|
11
|
+
""" }
|
|
12
|
+
requires-python = ">=3.14"
|
|
13
|
+
license = "LGPL-2.1-or-later"
|
|
14
|
+
authors = [{ name = "Johan Dahlin", email = "jdahlin@gmail.com" }]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["ginext-core>=0.8.1", "ginext-gio>=0.8.1"]
|
|
22
|
+
|
|
23
|
+
[project.entry-points."ginext.overlays"]
|
|
24
|
+
Soup = "ginext_libsoup:_overlays.Soup"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/jdahlin/ginext"
|
|
28
|
+
Repository = "https://github.com/jdahlin/ginext"
|
|
29
|
+
Issues = "https://github.com/jdahlin/ginext/issues"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["hatchling"]
|
|
33
|
+
build-backend = "hatchling.build"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/ginext_libsoup"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
extend = "../../pyproject.toml"
|
|
40
|
+
|
|
41
|
+
[tool.ruff.lint.per-file-ignores]
|
|
42
|
+
"tests/**/*.py" = ["E402"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
addopts = "-p no:benchmark --dist=loadgroup --import-mode=importlib"
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
pythonpath = ["../.."]
|
|
48
|
+
filterwarnings = [
|
|
49
|
+
"ignore:'asyncio\\.get_event_loop_policy' is deprecated and slated for removal in Python 3\\.16:DeprecationWarning",
|
|
50
|
+
"ignore:'asyncio\\.set_event_loop_policy' is deprecated and slated for removal in Python 3\\.16:DeprecationWarning",
|
|
51
|
+
"ignore:.* positional/keyword construction is deprecated:DeprecationWarning",
|
|
52
|
+
]
|
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Iterator, Mapping, MutableMapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
8
|
+
|
|
9
|
+
from ginext import Gio, GLib, Soup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _bytes_value(value: bytes | GLib.Bytes) -> bytes:
|
|
13
|
+
if isinstance(value, GLib.Bytes):
|
|
14
|
+
return bytes(value.get_data() or b"")
|
|
15
|
+
return bytes(value)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _coerce_body(content: bytes | str | GLib.Bytes | None) -> bytes | None:
|
|
19
|
+
if content is None:
|
|
20
|
+
return None
|
|
21
|
+
if isinstance(content, GLib.Bytes):
|
|
22
|
+
return _bytes_value(content)
|
|
23
|
+
if isinstance(content, str):
|
|
24
|
+
return content.encode("utf-8")
|
|
25
|
+
return bytes(content)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _merge_query_params(
|
|
29
|
+
url: str, params: Mapping[str, object] | Iterable[tuple[str, object]] | None
|
|
30
|
+
) -> str:
|
|
31
|
+
if params is None:
|
|
32
|
+
return url
|
|
33
|
+
split = urlsplit(url)
|
|
34
|
+
query = list(parse_qsl(split.query, keep_blank_values=True))
|
|
35
|
+
if isinstance(params, Mapping):
|
|
36
|
+
query.extend((key, str(value)) for key, value in params.items())
|
|
37
|
+
else:
|
|
38
|
+
query.extend((key, str(value)) for key, value in params)
|
|
39
|
+
return urlunsplit(
|
|
40
|
+
(split.scheme, split.netloc, split.path, urlencode(query), split.fragment)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _status_code(message: Soup.Message) -> int:
|
|
45
|
+
return int(message.get_status())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _reason_phrase(message: Soup.Message) -> str:
|
|
49
|
+
return str(message.get_reason_phrase() or "")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _message_url(message: Soup.Message) -> str:
|
|
53
|
+
uri = message.get_uri()
|
|
54
|
+
return "" if uri is None else str(uri.to_string())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _raise_for_status(message: Soup.Message) -> None:
|
|
58
|
+
status_code = _status_code(message)
|
|
59
|
+
if 200 <= status_code < 300:
|
|
60
|
+
return
|
|
61
|
+
raise RuntimeError(f"{status_code} {_reason_phrase(message)}".strip())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Headers(MutableMapping[str, str]):
|
|
65
|
+
"""Mapping view over ``Soup.MessageHeaders`` with dict-like access."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, headers: Soup.MessageHeaders) -> None:
|
|
68
|
+
self._headers = headers
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_value(
|
|
72
|
+
cls,
|
|
73
|
+
value: Soup.MessageHeaders
|
|
74
|
+
| Mapping[str, str]
|
|
75
|
+
| Iterable[tuple[str, str]]
|
|
76
|
+
| None,
|
|
77
|
+
*,
|
|
78
|
+
header_type: Soup.MessageHeadersType,
|
|
79
|
+
) -> Headers:
|
|
80
|
+
if isinstance(value, Soup.MessageHeaders):
|
|
81
|
+
return cls(value)
|
|
82
|
+
headers = Soup.MessageHeaders.new(header_type)
|
|
83
|
+
wrapper = cls(headers)
|
|
84
|
+
if value is not None:
|
|
85
|
+
wrapper.update(value)
|
|
86
|
+
return wrapper
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def raw(self) -> Soup.MessageHeaders:
|
|
90
|
+
return self._headers
|
|
91
|
+
|
|
92
|
+
def get_list(self, name: str) -> list[str]:
|
|
93
|
+
value = self._headers.get_list(name)
|
|
94
|
+
if value is None:
|
|
95
|
+
return []
|
|
96
|
+
return [item.strip() for item in value.split(",")]
|
|
97
|
+
|
|
98
|
+
def multi_items(self) -> list[tuple[str, str]]:
|
|
99
|
+
items: list[tuple[str, str]] = []
|
|
100
|
+
self._headers.foreach(lambda name, value: items.append((name, value)))
|
|
101
|
+
return items
|
|
102
|
+
|
|
103
|
+
def __getitem__(self, key: str) -> str:
|
|
104
|
+
value = self._headers.get_one(key)
|
|
105
|
+
if value is None:
|
|
106
|
+
raise KeyError(key)
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
def __setitem__(self, key: str, value: str) -> None:
|
|
110
|
+
self._headers.replace(key, value)
|
|
111
|
+
|
|
112
|
+
def __delitem__(self, key: str) -> None:
|
|
113
|
+
if self._headers.get_one(key) is None:
|
|
114
|
+
raise KeyError(key)
|
|
115
|
+
self._headers.remove(key)
|
|
116
|
+
|
|
117
|
+
def __iter__(self) -> Iterator[str]:
|
|
118
|
+
seen: set[str] = set()
|
|
119
|
+
for name, _value in self.multi_items():
|
|
120
|
+
if name not in seen:
|
|
121
|
+
seen.add(name)
|
|
122
|
+
yield name
|
|
123
|
+
|
|
124
|
+
def __len__(self) -> int:
|
|
125
|
+
return len(list(iter(self)))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(slots=True)
|
|
129
|
+
class Request:
|
|
130
|
+
"""High-level HTTP request wrapper around ``Soup.Message``."""
|
|
131
|
+
|
|
132
|
+
message: Soup.Message
|
|
133
|
+
headers: Headers
|
|
134
|
+
content: bytes = b""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
method: str,
|
|
139
|
+
url: str,
|
|
140
|
+
*,
|
|
141
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
142
|
+
content: bytes | str | GLib.Bytes | None = None,
|
|
143
|
+
params: Mapping[str, object] | Iterable[tuple[str, object]] | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
message = Soup.Message.new(method, _merge_query_params(url, params))
|
|
146
|
+
if message is None:
|
|
147
|
+
raise ValueError(f"failed to create Soup.Message for {method} {url}")
|
|
148
|
+
self.message = message
|
|
149
|
+
self.headers = Headers.from_value(
|
|
150
|
+
message.get_request_headers(),
|
|
151
|
+
header_type=Soup.MessageHeadersType.REQUEST,
|
|
152
|
+
)
|
|
153
|
+
if headers is not None:
|
|
154
|
+
self.headers.update(headers)
|
|
155
|
+
body = _coerce_body(content)
|
|
156
|
+
if body is None:
|
|
157
|
+
self.content = b""
|
|
158
|
+
return
|
|
159
|
+
self.content = body
|
|
160
|
+
content_type = self.headers.get("Content-Type")
|
|
161
|
+
self.message.set_request_body_from_bytes(content_type, body)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def method(self) -> str:
|
|
165
|
+
return str(self.message.get_method())
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def url(self) -> str:
|
|
169
|
+
uri = self.message.get_uri()
|
|
170
|
+
return "" if uri is None else str(uri.to_string())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(slots=True)
|
|
174
|
+
class Response:
|
|
175
|
+
"""Buffered HTTP response with the full body already in memory."""
|
|
176
|
+
|
|
177
|
+
request: Request
|
|
178
|
+
message: Soup.Message
|
|
179
|
+
content: bytes
|
|
180
|
+
headers: Headers
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def status_code(self) -> int:
|
|
184
|
+
return _status_code(self.message)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def reason_phrase(self) -> str:
|
|
188
|
+
return _reason_phrase(self.message)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def url(self) -> str:
|
|
192
|
+
return _message_url(self.message)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def text(self) -> str:
|
|
196
|
+
return self.content.decode("utf-8", errors="replace")
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def is_success(self) -> bool:
|
|
200
|
+
return 200 <= self.status_code < 300
|
|
201
|
+
|
|
202
|
+
def json(self) -> Any:
|
|
203
|
+
return json.loads(self.content.decode("utf-8"))
|
|
204
|
+
|
|
205
|
+
def read(self) -> bytes:
|
|
206
|
+
return self.content
|
|
207
|
+
|
|
208
|
+
async def aread(self) -> bytes:
|
|
209
|
+
return self.content
|
|
210
|
+
|
|
211
|
+
def raise_for_status(self) -> None:
|
|
212
|
+
_raise_for_status(self.message)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(slots=True)
|
|
216
|
+
class StreamResponse:
|
|
217
|
+
"""Streaming HTTP response backed by a native ``Gio.InputStream``."""
|
|
218
|
+
|
|
219
|
+
request: Request
|
|
220
|
+
message: Soup.Message
|
|
221
|
+
stream: Gio.InputStream
|
|
222
|
+
headers: Headers
|
|
223
|
+
_closed: bool = False
|
|
224
|
+
|
|
225
|
+
async def __aenter__(self) -> StreamResponse:
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
|
229
|
+
await self.aclose()
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def status_code(self) -> int:
|
|
234
|
+
return _status_code(self.message)
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def reason_phrase(self) -> str:
|
|
238
|
+
return _reason_phrase(self.message)
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def url(self) -> str:
|
|
242
|
+
return _message_url(self.message)
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def is_success(self) -> bool:
|
|
246
|
+
return 200 <= self.status_code < 300
|
|
247
|
+
|
|
248
|
+
async def read(
|
|
249
|
+
self,
|
|
250
|
+
count: int = -1,
|
|
251
|
+
*,
|
|
252
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
253
|
+
cancellable: Gio.Cancellable | None = None,
|
|
254
|
+
) -> bytes:
|
|
255
|
+
"""Read up to ``count`` bytes, or the rest of the stream when omitted."""
|
|
256
|
+
if self._closed:
|
|
257
|
+
raise RuntimeError("stream response is closed")
|
|
258
|
+
if count < 0:
|
|
259
|
+
return await self.aread(
|
|
260
|
+
io_priority=io_priority,
|
|
261
|
+
cancellable=cancellable,
|
|
262
|
+
)
|
|
263
|
+
if count == 0:
|
|
264
|
+
return b""
|
|
265
|
+
data = await self.stream.read_bytes_async(
|
|
266
|
+
count,
|
|
267
|
+
io_priority,
|
|
268
|
+
cancellable,
|
|
269
|
+
)
|
|
270
|
+
return _bytes_value(data)
|
|
271
|
+
|
|
272
|
+
async def aread(
|
|
273
|
+
self,
|
|
274
|
+
*,
|
|
275
|
+
chunk_size: int = 65536,
|
|
276
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
277
|
+
cancellable: Gio.Cancellable | None = None,
|
|
278
|
+
) -> bytes:
|
|
279
|
+
"""Read the remaining response body into memory."""
|
|
280
|
+
parts: list[bytes] = []
|
|
281
|
+
async for chunk in self.iter_bytes(
|
|
282
|
+
chunk_size=chunk_size,
|
|
283
|
+
io_priority=io_priority,
|
|
284
|
+
cancellable=cancellable,
|
|
285
|
+
):
|
|
286
|
+
parts.append(chunk)
|
|
287
|
+
return b"".join(parts)
|
|
288
|
+
|
|
289
|
+
async def iter_bytes(
|
|
290
|
+
self,
|
|
291
|
+
*,
|
|
292
|
+
chunk_size: int = 65536,
|
|
293
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
294
|
+
cancellable: Gio.Cancellable | None = None,
|
|
295
|
+
) -> AsyncIterator[bytes]:
|
|
296
|
+
"""Yield the body as chunks from the underlying input stream."""
|
|
297
|
+
while True:
|
|
298
|
+
chunk = await self.read(
|
|
299
|
+
chunk_size,
|
|
300
|
+
io_priority=io_priority,
|
|
301
|
+
cancellable=cancellable,
|
|
302
|
+
)
|
|
303
|
+
if not chunk:
|
|
304
|
+
break
|
|
305
|
+
yield chunk
|
|
306
|
+
|
|
307
|
+
async def splice(
|
|
308
|
+
self,
|
|
309
|
+
output_stream: Gio.OutputStream,
|
|
310
|
+
*,
|
|
311
|
+
flags: Gio.OutputStreamSpliceFlags = Gio.OutputStreamSpliceFlags.CLOSE_SOURCE,
|
|
312
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
313
|
+
cancellable: Gio.Cancellable | None = None,
|
|
314
|
+
) -> int:
|
|
315
|
+
"""Splice the response body into another native output stream."""
|
|
316
|
+
if self._closed:
|
|
317
|
+
raise RuntimeError("stream response is closed")
|
|
318
|
+
return await output_stream.splice_async(
|
|
319
|
+
self.stream,
|
|
320
|
+
flags,
|
|
321
|
+
io_priority,
|
|
322
|
+
cancellable,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
async def aclose(self) -> None:
|
|
326
|
+
"""Close the underlying response body stream."""
|
|
327
|
+
if self._closed:
|
|
328
|
+
return
|
|
329
|
+
self._closed = True
|
|
330
|
+
await self.stream.close_async(GLib.PRIORITY_DEFAULT)
|
|
331
|
+
|
|
332
|
+
def raise_for_status(self) -> None:
|
|
333
|
+
_raise_for_status(self.message)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class _StreamContextManager:
|
|
337
|
+
def __init__(self, opener: Callable[[], Awaitable[StreamResponse]]) -> None:
|
|
338
|
+
self._opener = opener
|
|
339
|
+
self._response: StreamResponse | None = None
|
|
340
|
+
|
|
341
|
+
async def __aenter__(self) -> StreamResponse:
|
|
342
|
+
response = await self._opener()
|
|
343
|
+
self._response = response
|
|
344
|
+
return response
|
|
345
|
+
|
|
346
|
+
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
|
347
|
+
response = self._response
|
|
348
|
+
self._response = None
|
|
349
|
+
if response is not None:
|
|
350
|
+
await response.aclose()
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _make_response(request: Request, body: bytes | GLib.Bytes) -> Response:
|
|
355
|
+
data = _bytes_value(body)
|
|
356
|
+
return Response(
|
|
357
|
+
request=request,
|
|
358
|
+
message=request.message,
|
|
359
|
+
content=data,
|
|
360
|
+
headers=Headers.from_value(
|
|
361
|
+
request.message.get_response_headers(),
|
|
362
|
+
header_type=Soup.MessageHeadersType.RESPONSE,
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _make_stream_response(request: Request, stream: Gio.InputStream) -> StreamResponse:
|
|
368
|
+
return StreamResponse(
|
|
369
|
+
request=request,
|
|
370
|
+
message=request.message,
|
|
371
|
+
stream=stream,
|
|
372
|
+
headers=Headers.from_value(
|
|
373
|
+
request.message.get_response_headers(),
|
|
374
|
+
header_type=Soup.MessageHeadersType.RESPONSE,
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _request_from_session(
|
|
380
|
+
method: str,
|
|
381
|
+
url: str,
|
|
382
|
+
*,
|
|
383
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
384
|
+
content: bytes | str | GLib.Bytes | None = None,
|
|
385
|
+
params: Mapping[str, object] | Iterable[tuple[str, object]] | None = None,
|
|
386
|
+
) -> Request:
|
|
387
|
+
return Request(
|
|
388
|
+
method,
|
|
389
|
+
url,
|
|
390
|
+
headers=headers,
|
|
391
|
+
content=content,
|
|
392
|
+
params=params,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
async def _session_send(
|
|
397
|
+
session: Soup.Session,
|
|
398
|
+
request: Request,
|
|
399
|
+
*,
|
|
400
|
+
io_priority: int,
|
|
401
|
+
cancellable: Gio.Cancellable | None,
|
|
402
|
+
) -> Response:
|
|
403
|
+
"""Send a prepared request and return a buffered response."""
|
|
404
|
+
body = await session.send_and_read_async(
|
|
405
|
+
request.message,
|
|
406
|
+
io_priority,
|
|
407
|
+
cancellable,
|
|
408
|
+
)
|
|
409
|
+
return _make_response(request, body)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
async def _session_open_stream(
|
|
413
|
+
session: Soup.Session,
|
|
414
|
+
request: Request,
|
|
415
|
+
*,
|
|
416
|
+
io_priority: int,
|
|
417
|
+
cancellable: Gio.Cancellable | None,
|
|
418
|
+
) -> StreamResponse:
|
|
419
|
+
"""Send a prepared request and return a streaming response."""
|
|
420
|
+
stream = await session.send_async(
|
|
421
|
+
request.message,
|
|
422
|
+
io_priority,
|
|
423
|
+
cancellable,
|
|
424
|
+
)
|
|
425
|
+
return _make_stream_response(request, stream)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
async def _session_request(
|
|
429
|
+
session: Soup.Session,
|
|
430
|
+
method: str,
|
|
431
|
+
url: str,
|
|
432
|
+
*,
|
|
433
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
434
|
+
content: bytes | str | GLib.Bytes | None = None,
|
|
435
|
+
params: Mapping[str, object] | Iterable[tuple[str, object]] | None = None,
|
|
436
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
437
|
+
cancellable: Gio.Cancellable | None = None,
|
|
438
|
+
) -> Response:
|
|
439
|
+
"""Build and send a request with a buffered response body."""
|
|
440
|
+
request = _request_from_session(
|
|
441
|
+
method,
|
|
442
|
+
url,
|
|
443
|
+
headers=headers,
|
|
444
|
+
content=content,
|
|
445
|
+
params=params,
|
|
446
|
+
)
|
|
447
|
+
return await _session_send(
|
|
448
|
+
session,
|
|
449
|
+
request,
|
|
450
|
+
io_priority=io_priority,
|
|
451
|
+
cancellable=cancellable,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _session_stream(
|
|
456
|
+
session: Soup.Session,
|
|
457
|
+
method: str,
|
|
458
|
+
url: str,
|
|
459
|
+
*,
|
|
460
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
461
|
+
content: bytes | str | GLib.Bytes | None = None,
|
|
462
|
+
params: Mapping[str, object] | Iterable[tuple[str, object]] | None = None,
|
|
463
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
464
|
+
cancellable: Gio.Cancellable | None = None,
|
|
465
|
+
) -> _StreamContextManager:
|
|
466
|
+
"""Open a streaming request as an async context manager."""
|
|
467
|
+
async def open_stream() -> StreamResponse:
|
|
468
|
+
request = _request_from_session(
|
|
469
|
+
method,
|
|
470
|
+
url,
|
|
471
|
+
headers=headers,
|
|
472
|
+
content=content,
|
|
473
|
+
params=params,
|
|
474
|
+
)
|
|
475
|
+
return await _session_open_stream(
|
|
476
|
+
session,
|
|
477
|
+
request,
|
|
478
|
+
io_priority=io_priority,
|
|
479
|
+
cancellable=cancellable,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
return _StreamContextManager(open_stream)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
async def _session_get(
|
|
486
|
+
session: Soup.Session,
|
|
487
|
+
url: str,
|
|
488
|
+
*,
|
|
489
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
490
|
+
params: Mapping[str, object] | Iterable[tuple[str, object]] | None = None,
|
|
491
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
492
|
+
cancellable: Gio.Cancellable | None = None,
|
|
493
|
+
) -> Response:
|
|
494
|
+
"""Send a GET request and buffer the response body."""
|
|
495
|
+
return await _session_request(
|
|
496
|
+
session,
|
|
497
|
+
"GET",
|
|
498
|
+
url,
|
|
499
|
+
headers=headers,
|
|
500
|
+
params=params,
|
|
501
|
+
io_priority=io_priority,
|
|
502
|
+
cancellable=cancellable,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
async def _session_post(
|
|
507
|
+
session: Soup.Session,
|
|
508
|
+
url: str,
|
|
509
|
+
*,
|
|
510
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
511
|
+
content: bytes | str | GLib.Bytes | None = None,
|
|
512
|
+
params: Mapping[str, object] | Iterable[tuple[str, object]] | None = None,
|
|
513
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
514
|
+
cancellable: Gio.Cancellable | None = None,
|
|
515
|
+
) -> Response:
|
|
516
|
+
"""Send a POST request and buffer the response body."""
|
|
517
|
+
return await _session_request(
|
|
518
|
+
session,
|
|
519
|
+
"POST",
|
|
520
|
+
url,
|
|
521
|
+
headers=headers,
|
|
522
|
+
content=content,
|
|
523
|
+
params=params,
|
|
524
|
+
io_priority=io_priority,
|
|
525
|
+
cancellable=cancellable,
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class AsyncClient:
|
|
530
|
+
"""Convenience wrapper around ``Soup.Session`` with an ``httpx``-like shape.
|
|
531
|
+
|
|
532
|
+
Use ``AsyncClient`` when you want default headers, a base URL, and request
|
|
533
|
+
helpers such as ``get()``, ``post()``, and ``stream()`` while still working
|
|
534
|
+
with a real native ``Soup.Session`` underneath.
|
|
535
|
+
"""
|
|
536
|
+
|
|
537
|
+
def __init__(
|
|
538
|
+
self,
|
|
539
|
+
*,
|
|
540
|
+
base_url: str = "",
|
|
541
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
542
|
+
session: Soup.Session | None = None,
|
|
543
|
+
) -> None:
|
|
544
|
+
self._base_url = base_url.rstrip("/")
|
|
545
|
+
self._default_headers = dict(headers or {})
|
|
546
|
+
self._session = session if session is not None else Soup.Session()
|
|
547
|
+
self._owns_session = session is None
|
|
548
|
+
self._closed = False
|
|
549
|
+
|
|
550
|
+
async def __aenter__(self) -> AsyncClient:
|
|
551
|
+
return self
|
|
552
|
+
|
|
553
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
|
554
|
+
await self.aclose()
|
|
555
|
+
return False
|
|
556
|
+
|
|
557
|
+
def _resolve_url(self, url: str) -> str:
|
|
558
|
+
if "://" in url or not self._base_url:
|
|
559
|
+
return url
|
|
560
|
+
if url.startswith("/"):
|
|
561
|
+
return f"{self._base_url}{url}"
|
|
562
|
+
return f"{self._base_url}/{url}"
|
|
563
|
+
|
|
564
|
+
def build_request(
|
|
565
|
+
self,
|
|
566
|
+
method: str,
|
|
567
|
+
url: str,
|
|
568
|
+
*,
|
|
569
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
570
|
+
content: bytes | str | GLib.Bytes | None = None,
|
|
571
|
+
params: Mapping[str, object] | Iterable[tuple[str, object]] | None = None,
|
|
572
|
+
) -> Request:
|
|
573
|
+
"""Build a reusable request with the client's base URL and headers."""
|
|
574
|
+
merged_headers = dict(self._default_headers)
|
|
575
|
+
if headers is not None:
|
|
576
|
+
merged_headers.update(dict(headers))
|
|
577
|
+
return Request(
|
|
578
|
+
method,
|
|
579
|
+
self._resolve_url(url),
|
|
580
|
+
headers=merged_headers,
|
|
581
|
+
content=content,
|
|
582
|
+
params=params,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
@property
|
|
586
|
+
def session(self) -> Soup.Session:
|
|
587
|
+
return self._session
|
|
588
|
+
|
|
589
|
+
async def send(
|
|
590
|
+
self,
|
|
591
|
+
request: Request,
|
|
592
|
+
*,
|
|
593
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
594
|
+
cancellable: Gio.Cancellable | None = None,
|
|
595
|
+
) -> Response:
|
|
596
|
+
"""Send a prepared request and buffer the full response body."""
|
|
597
|
+
if self._closed:
|
|
598
|
+
raise RuntimeError("Soup.AsyncClient is closed")
|
|
599
|
+
return await _session_send(
|
|
600
|
+
self._session,
|
|
601
|
+
request,
|
|
602
|
+
io_priority=io_priority,
|
|
603
|
+
cancellable=cancellable,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
def stream(
|
|
607
|
+
self,
|
|
608
|
+
method: str,
|
|
609
|
+
url: str,
|
|
610
|
+
*,
|
|
611
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
612
|
+
content: bytes | str | GLib.Bytes | None = None,
|
|
613
|
+
params: Mapping[str, object] | Iterable[tuple[str, object]] | None = None,
|
|
614
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
615
|
+
cancellable: Gio.Cancellable | None = None,
|
|
616
|
+
) -> _StreamContextManager:
|
|
617
|
+
"""Open a streaming response as an async context manager."""
|
|
618
|
+
async def open_stream() -> StreamResponse:
|
|
619
|
+
request = self.build_request(
|
|
620
|
+
method,
|
|
621
|
+
url,
|
|
622
|
+
headers=headers,
|
|
623
|
+
content=content,
|
|
624
|
+
params=params,
|
|
625
|
+
)
|
|
626
|
+
return await _session_open_stream(
|
|
627
|
+
self._session,
|
|
628
|
+
request,
|
|
629
|
+
io_priority=io_priority,
|
|
630
|
+
cancellable=cancellable,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
return _StreamContextManager(open_stream)
|
|
634
|
+
|
|
635
|
+
async def request(
|
|
636
|
+
self,
|
|
637
|
+
method: str,
|
|
638
|
+
url: str,
|
|
639
|
+
*,
|
|
640
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
641
|
+
content: bytes | str | GLib.Bytes | None = None,
|
|
642
|
+
params: Mapping[str, object] | Iterable[tuple[str, object]] | None = None,
|
|
643
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
644
|
+
cancellable: Gio.Cancellable | None = None,
|
|
645
|
+
) -> Response:
|
|
646
|
+
"""Build and send a request in one call."""
|
|
647
|
+
request = self.build_request(
|
|
648
|
+
method,
|
|
649
|
+
url,
|
|
650
|
+
headers=headers,
|
|
651
|
+
content=content,
|
|
652
|
+
params=params,
|
|
653
|
+
)
|
|
654
|
+
return await self.send(
|
|
655
|
+
request,
|
|
656
|
+
io_priority=io_priority,
|
|
657
|
+
cancellable=cancellable,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
async def get(
|
|
661
|
+
self,
|
|
662
|
+
url: str,
|
|
663
|
+
*,
|
|
664
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
665
|
+
params: Mapping[str, object] | Iterable[tuple[str, object]] | None = None,
|
|
666
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
667
|
+
cancellable: Gio.Cancellable | None = None,
|
|
668
|
+
) -> Response:
|
|
669
|
+
"""Send a GET request and buffer the response body."""
|
|
670
|
+
return await self.request(
|
|
671
|
+
"GET",
|
|
672
|
+
url,
|
|
673
|
+
headers=headers,
|
|
674
|
+
params=params,
|
|
675
|
+
io_priority=io_priority,
|
|
676
|
+
cancellable=cancellable,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
async def post(
|
|
680
|
+
self,
|
|
681
|
+
url: str,
|
|
682
|
+
*,
|
|
683
|
+
headers: Mapping[str, str] | Iterable[tuple[str, str]] | None = None,
|
|
684
|
+
content: bytes | str | GLib.Bytes | None = None,
|
|
685
|
+
params: Mapping[str, object] | Iterable[tuple[str, object]] | None = None,
|
|
686
|
+
io_priority: int = GLib.PRIORITY_DEFAULT,
|
|
687
|
+
cancellable: Gio.Cancellable | None = None,
|
|
688
|
+
) -> Response:
|
|
689
|
+
"""Send a POST request and buffer the response body."""
|
|
690
|
+
return await self.request(
|
|
691
|
+
"POST",
|
|
692
|
+
url,
|
|
693
|
+
headers=headers,
|
|
694
|
+
content=content,
|
|
695
|
+
params=params,
|
|
696
|
+
io_priority=io_priority,
|
|
697
|
+
cancellable=cancellable,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
async def aclose(self) -> None:
|
|
701
|
+
"""Release the owned session by aborting outstanding native work."""
|
|
702
|
+
if self._closed:
|
|
703
|
+
return
|
|
704
|
+
self._closed = True
|
|
705
|
+
if self._owns_session:
|
|
706
|
+
self._session.abort()
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def apply_to_namespace(namespace: Any) -> None:
|
|
710
|
+
namespace.__dict__["AsyncClient"] = AsyncClient
|
|
711
|
+
namespace.__dict__["Headers"] = Headers
|
|
712
|
+
namespace.__dict__["Request"] = Request
|
|
713
|
+
namespace.__dict__["Response"] = Response
|
|
714
|
+
namespace.__dict__["StreamResponse"] = StreamResponse
|
|
715
|
+
namespace.Session.request = _session_request
|
|
716
|
+
namespace.Session.get = _session_get
|
|
717
|
+
namespace.Session.post = _session_post
|
|
718
|
+
namespace.Session.stream = _session_stream
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture(autouse=True)
|
|
7
|
+
def _require_soup() -> None:
|
|
8
|
+
# These tests need the Soup 3.0 typelib (gir1.2-soup-3.0). Skip gracefully
|
|
9
|
+
# where it is absent (e.g. a CI image without it) instead of erroring, so the
|
|
10
|
+
# suite stays portable in the unified run.
|
|
11
|
+
import ginext
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
ginext.private.require_namespace("Soup", "3.0")
|
|
15
|
+
except ImportError:
|
|
16
|
+
pytest.skip("Soup 3.0 typelib not available")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(scope="session")
|
|
20
|
+
def aio():
|
|
21
|
+
from ginext import aio
|
|
22
|
+
|
|
23
|
+
return aio
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture(scope="session")
|
|
27
|
+
def Soup():
|
|
28
|
+
import ginext
|
|
29
|
+
|
|
30
|
+
ginext.private.require_namespace("Soup", "3.0")
|
|
31
|
+
from ginext import Soup
|
|
32
|
+
|
|
33
|
+
return Soup
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Coroutine
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
14
|
+
def do_GET(self) -> None:
|
|
15
|
+
if self.path.startswith("/stream"):
|
|
16
|
+
payload = b"streaming-body"
|
|
17
|
+
self.send_response(200)
|
|
18
|
+
self.send_header("Content-Type", "application/octet-stream")
|
|
19
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
20
|
+
self.end_headers()
|
|
21
|
+
self.wfile.write(payload)
|
|
22
|
+
return
|
|
23
|
+
payload = json.dumps(
|
|
24
|
+
{
|
|
25
|
+
"method": self.command,
|
|
26
|
+
"path": self.path,
|
|
27
|
+
"x_test": self.headers.get("X-Test"),
|
|
28
|
+
}
|
|
29
|
+
).encode("utf-8")
|
|
30
|
+
self.send_response(200)
|
|
31
|
+
self.send_header("Content-Type", "application/json")
|
|
32
|
+
self.send_header("X-Reply", "ok")
|
|
33
|
+
self.end_headers()
|
|
34
|
+
self.wfile.write(payload)
|
|
35
|
+
|
|
36
|
+
def do_POST(self) -> None:
|
|
37
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
38
|
+
body = self.rfile.read(length)
|
|
39
|
+
payload = json.dumps(
|
|
40
|
+
{
|
|
41
|
+
"method": self.command,
|
|
42
|
+
"path": self.path,
|
|
43
|
+
"body": body.decode("utf-8"),
|
|
44
|
+
"content_type": self.headers.get("Content-Type"),
|
|
45
|
+
}
|
|
46
|
+
).encode("utf-8")
|
|
47
|
+
self.send_response(201)
|
|
48
|
+
self.send_header("Content-Type", "application/json")
|
|
49
|
+
self.end_headers()
|
|
50
|
+
self.wfile.write(payload)
|
|
51
|
+
|
|
52
|
+
def log_message(self, message: str, *args: object) -> None:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run(coro: "Coroutine[object, object, None]") -> None:
|
|
57
|
+
from ginext import aio
|
|
58
|
+
|
|
59
|
+
asyncio.run(coro, loop_factory=aio.EventLoop)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _server_url(server: ThreadingHTTPServer) -> str:
|
|
63
|
+
host = str(server.server_address[0])
|
|
64
|
+
port = int(server.server_address[1])
|
|
65
|
+
return f"http://{host}:{port}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _serve_forever(server: ThreadingHTTPServer) -> None:
|
|
69
|
+
server.serve_forever(poll_interval=0.01)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_async_client_get() -> None:
|
|
73
|
+
import ginext
|
|
74
|
+
|
|
75
|
+
ginext.private.require_namespace("Soup", "3.0")
|
|
76
|
+
from ginext import Soup
|
|
77
|
+
|
|
78
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
|
79
|
+
thread = threading.Thread(target=_serve_forever, args=(server,), daemon=True)
|
|
80
|
+
thread.start()
|
|
81
|
+
try:
|
|
82
|
+
async def main() -> None:
|
|
83
|
+
async with Soup.AsyncClient(headers={"X-Test": "client"}) as client:
|
|
84
|
+
response = await client.get(
|
|
85
|
+
f"{_server_url(server)}/hello",
|
|
86
|
+
params={"page": 2},
|
|
87
|
+
)
|
|
88
|
+
assert response.status_code == 200
|
|
89
|
+
assert response.is_success is True
|
|
90
|
+
assert response.headers["X-Reply"] == "ok"
|
|
91
|
+
assert response.request.method == "GET"
|
|
92
|
+
assert response.request.url.endswith("/hello?page=2")
|
|
93
|
+
assert response.url.endswith("/hello?page=2")
|
|
94
|
+
assert response.json() == {
|
|
95
|
+
"method": "GET",
|
|
96
|
+
"path": "/hello?page=2",
|
|
97
|
+
"x_test": "client",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_run(main())
|
|
101
|
+
finally:
|
|
102
|
+
server.shutdown()
|
|
103
|
+
server.server_close()
|
|
104
|
+
thread.join(timeout=1)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_async_client_post_and_request_headers() -> None:
|
|
108
|
+
import ginext
|
|
109
|
+
|
|
110
|
+
ginext.private.require_namespace("Soup", "3.0")
|
|
111
|
+
from ginext import Soup
|
|
112
|
+
|
|
113
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
|
114
|
+
thread = threading.Thread(target=_serve_forever, args=(server,), daemon=True)
|
|
115
|
+
thread.start()
|
|
116
|
+
try:
|
|
117
|
+
request = Soup.Request(
|
|
118
|
+
"POST",
|
|
119
|
+
f"{_server_url(server)}/submit",
|
|
120
|
+
headers={"Content-Type": "text/plain", "X-Test": "request"},
|
|
121
|
+
content="payload",
|
|
122
|
+
)
|
|
123
|
+
assert request.headers["Content-Type"] == "text/plain"
|
|
124
|
+
assert request.headers.get_list("X-Test") == ["request"]
|
|
125
|
+
|
|
126
|
+
async def main() -> None:
|
|
127
|
+
async with Soup.AsyncClient() as client:
|
|
128
|
+
response = await client.send(request)
|
|
129
|
+
assert response.status_code == 201
|
|
130
|
+
assert response.request.content == b"payload"
|
|
131
|
+
assert response.json() == {
|
|
132
|
+
"method": "POST",
|
|
133
|
+
"path": "/submit",
|
|
134
|
+
"body": "payload",
|
|
135
|
+
"content_type": "text/plain",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_run(main())
|
|
139
|
+
finally:
|
|
140
|
+
server.shutdown()
|
|
141
|
+
server.server_close()
|
|
142
|
+
thread.join(timeout=1)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_session_get_and_stream() -> None:
|
|
146
|
+
import ginext
|
|
147
|
+
|
|
148
|
+
ginext.private.require_namespace("Soup", "3.0")
|
|
149
|
+
from ginext import Soup
|
|
150
|
+
|
|
151
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
|
152
|
+
thread = threading.Thread(target=_serve_forever, args=(server,), daemon=True)
|
|
153
|
+
thread.start()
|
|
154
|
+
try:
|
|
155
|
+
async def main() -> None:
|
|
156
|
+
session = Soup.Session()
|
|
157
|
+
response = await session.get(f"{_server_url(server)}/hello")
|
|
158
|
+
assert response.status_code == 200
|
|
159
|
+
assert response.json()["path"] == "/hello"
|
|
160
|
+
|
|
161
|
+
async with session.stream("GET", f"{_server_url(server)}/stream") as streamed:
|
|
162
|
+
assert streamed.status_code == 200
|
|
163
|
+
assert streamed.headers["Content-Type"] == "application/octet-stream"
|
|
164
|
+
assert await streamed.read(5) == b"strea"
|
|
165
|
+
assert await streamed.read(4) == b"ming"
|
|
166
|
+
assert await streamed.aread() == b"-body"
|
|
167
|
+
|
|
168
|
+
_run(main())
|
|
169
|
+
finally:
|
|
170
|
+
server.shutdown()
|
|
171
|
+
server.server_close()
|
|
172
|
+
thread.join(timeout=1)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_native_session_async_methods_work_directly() -> None:
|
|
176
|
+
import ginext
|
|
177
|
+
|
|
178
|
+
ginext.private.require_namespace("Soup", "3.0")
|
|
179
|
+
from ginext import Soup
|
|
180
|
+
from ginext import aio
|
|
181
|
+
|
|
182
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
|
183
|
+
thread = threading.Thread(target=_serve_forever, args=(server,), daemon=True)
|
|
184
|
+
thread.start()
|
|
185
|
+
try:
|
|
186
|
+
async def main() -> None:
|
|
187
|
+
session = Soup.Session()
|
|
188
|
+
message = Soup.Message.new("GET", f"{_server_url(server)}/hello")
|
|
189
|
+
assert message is not None
|
|
190
|
+
|
|
191
|
+
body = await session.send_and_read_async(message, 0)
|
|
192
|
+
assert b'"path": "/hello"' in body
|
|
193
|
+
|
|
194
|
+
message2 = Soup.Message.new("GET", f"{_server_url(server)}/stream")
|
|
195
|
+
assert message2 is not None
|
|
196
|
+
stream = await session.send_async(message2, 0)
|
|
197
|
+
data = await stream.read_bytes_async(32, 0)
|
|
198
|
+
assert data == b"streaming-body"
|
|
199
|
+
await stream.close_async(0)
|
|
200
|
+
|
|
201
|
+
asyncio.run(main(), loop_factory=aio.EventLoop)
|
|
202
|
+
finally:
|
|
203
|
+
server.shutdown()
|
|
204
|
+
server.server_close()
|
|
205
|
+
thread.join(timeout=1)
|