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.
@@ -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,2 @@
1
+ from __future__ import annotations
2
+
@@ -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)