Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-openqa-async for
openSUSE:Factory checked in at 2026-07-21 23:00:29
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-openqa-async (Old)
and /work/SRC/openSUSE:Factory/.python-openqa-async.new.24530 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-openqa-async"
Tue Jul 21 23:00:29 2026 rev:2 rq:1366734 version:1.0.0
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-openqa-async/python-openqa-async.changes
2026-06-05 17:40:25.262328826 +0200
+++
/work/SRC/openSUSE:Factory/.python-openqa-async.new.24530/python-openqa-async.changes
2026-07-21 23:00:31.385962581 +0200
@@ -1,0 +2,7 @@
+Mon Jul 20 10:32:07 UTC 2026 - Ondřej Súkup <[email protected]>
+
+- update to 1.0.0
+ * harden retry mechanics
+ * set longer timeout by default
+
+-------------------------------------------------------------------
Old:
----
v0.1.0.tar.gz
New:
----
v1.0.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-openqa-async.spec ++++++
--- /var/tmp/diff_new_pack.L2IH8N/_old 2026-07-21 23:00:33.606038894 +0200
+++ /var/tmp/diff_new_pack.L2IH8N/_new 2026-07-21 23:00:33.606038894 +0200
@@ -19,7 +19,7 @@
%global skip_python311 1
%global skip_python312 1
Name: python-openqa-async
-Version: 0.1.0
+Version: 1.0.0
Release: 0
Summary: Client library for openQA with async support
License: GPL-2.0-or-later
++++++ v0.1.0.tar.gz -> v1.0.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/openqa-async-0.1.0/.github/dependabot.yml
new/openqa-async-1.0.0/.github/dependabot.yml
--- old/openqa-async-0.1.0/.github/dependabot.yml 1970-01-01
01:00:00.000000000 +0100
+++ new/openqa-async-1.0.0/.github/dependabot.yml 2026-07-20
12:29:50.000000000 +0200
@@ -0,0 +1,19 @@
+version: 2
+updates:
+ - package-ecosystem: "uv"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ groups:
+ python-dependencies:
+ patterns:
+ - "*"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ groups:
+ github-actions:
+ patterns:
+ - "*"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/openqa-async-0.1.0/.github/workflows/ci.yml
new/openqa-async-1.0.0/.github/workflows/ci.yml
--- old/openqa-async-0.1.0/.github/workflows/ci.yml 2026-05-30
17:53:44.000000000 +0200
+++ new/openqa-async-1.0.0/.github/workflows/ci.yml 2026-07-20
12:29:50.000000000 +0200
@@ -15,10 +15,10 @@
name: Lint & type check
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Install uv
- uses: astral-sh/[email protected]
+ uses: astral-sh/[email protected]
with:
enable-cache: true
@@ -45,10 +45,10 @@
matrix:
python-version: ["3.13", "3.14"]
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Install uv
- uses: astral-sh/[email protected]
+ uses: astral-sh/[email protected]
with:
enable-cache: true
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/openqa-async-0.1.0/.github/workflows/release.yml
new/openqa-async-1.0.0/.github/workflows/release.yml
--- old/openqa-async-0.1.0/.github/workflows/release.yml 2026-05-30
17:53:44.000000000 +0200
+++ new/openqa-async-1.0.0/.github/workflows/release.yml 2026-07-20
12:29:50.000000000 +0200
@@ -9,16 +9,16 @@
name: Build distribution
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Install uv
- uses: astral-sh/[email protected]
+ uses: astral-sh/[email protected]
- name: Build sdist and wheel
run: uv build
- name: Upload distribution artifacts
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
@@ -34,7 +34,7 @@
id-token: write
steps:
- name: Download distribution artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: dist
path: dist/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/openqa-async-0.1.0/pyproject.toml
new/openqa-async-1.0.0/pyproject.toml
--- old/openqa-async-0.1.0/pyproject.toml 2026-05-30 17:53:44.000000000
+0200
+++ new/openqa-async-1.0.0/pyproject.toml 2026-07-20 12:29:50.000000000
+0200
@@ -1,6 +1,6 @@
[project]
name = "openqa-async"
-version = "0.1.0"
+version = "1.0.0"
description = "OpenqQA Client Library with Async Support"
readme = "README.md"
requires-python = ">=3.13"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/openqa-async-0.1.0/src/openqa_async/_base.py
new/openqa-async-1.0.0/src/openqa_async/_base.py
--- old/openqa-async-0.1.0/src/openqa_async/_base.py 2026-05-30
17:53:44.000000000 +0200
+++ new/openqa-async-1.0.0/src/openqa_async/_base.py 2026-07-20
12:29:50.000000000 +0200
@@ -25,7 +25,10 @@
import configparser
import logging
import os
+import random
import ssl
+from datetime import UTC, datetime
+from email.utils import parsedate_to_datetime
from typing import Any
from urllib.parse import urlparse, urlunparse
@@ -40,6 +43,14 @@
#: HTTP status codes that warrant a retry, matching upstream's tuple.
_RETRY_STATUS = frozenset((408, 413, 429, 444, 500, 502, 503, 504, 509, 521,
522, 599))
+#: Default timeout for every request; openQA servers are frequently slow,
+#: so this is higher than httpx's own 5 s default. ``None`` disables it.
+_DEFAULT_TIMEOUT: float | httpx.Timeout | None = 30.0
+
+#: HTTP methods safe to retry after a transport error without risking a
+#: double-fired mutation. ``POST`` is deliberately excluded.
+_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})
+
class _OpenQAClientBase:
"""Shared, transport-agnostic state and helpers for the openQA clients."""
@@ -51,10 +62,25 @@
retries: int = 5,
wait: int = 10,
verify: bool | str | ssl.SSLContext = True,
+ timeout: float | httpx.Timeout | None = _DEFAULT_TIMEOUT,
+ retry_methods: frozenset[str] | None = None,
+ deadline: float | None = None,
) -> None:
self.retries = retries
self.wait = wait
self.verify = verify
+ #: Per-request timeout forwarded to the httpx client; ``None``
disables.
+ self.timeout = timeout
+ #: HTTP methods eligible for transport-error retries (default: the
+ #: idempotent set). ``POST`` is excluded so mutations aren't re-sent.
+ self.retry_methods = (
+ _IDEMPOTENT_METHODS if retry_methods is None else
frozenset(retry_methods)
+ )
+ #: Optional overall wall-clock budget (seconds) for all retries of a
+ #: single call; ``None`` means no cap.
+ self.deadline = deadline
+ #: RNG for jittered backoff; overridable in tests for determinism.
+ self._rng = random.Random()
# Read in config files.
config = configparser.ConfigParser()
@@ -172,6 +198,43 @@
return status_or_exc in _RETRY_STATUS
return False
+ def _may_retry_transport(self, method: str, retry_non_idempotent: bool) ->
bool:
+ """Whether a transport error on ``method`` should be retried."""
+ return retry_non_idempotent or method.upper() in self.retry_methods
+
def _next_wait(self, wait: int | float) -> int | float:
"""Exponential backoff, capped at 60 seconds (matches upstream)."""
return min(wait + wait, 60)
+
+ def _backoff(self, wait: int | float) -> float:
+ """Full-jitter backoff: a random value in ``[0, min(wait, 60)]``.
+
+ ``wait`` carries the growing exponential base (doubled via
+ ``_next_wait`` between attempts); jitter spreads retries to avoid a
+ thundering herd. Uses the injectable ``self._rng`` for determinism.
+ """
+ return self._rng.uniform(0, min(wait, 60))
+
+ def _parse_retry_after(self, resp: httpx.Response) -> float | None:
+ """Parse a ``Retry-After`` header into a non-negative delay (seconds).
+
+ Accepts an integer number of seconds or an HTTP-date; returns ``None``
+ for a missing or malformed header. Negative/past values clamp to 0.
+ """
+ value = resp.headers.get("retry-after")
+ if value is None:
+ return None
+ value = value.strip()
+ try:
+ return max(0.0, float(int(value)))
+ except ValueError:
+ pass
+ try:
+ when = parsedate_to_datetime(value)
+ except (TypeError, ValueError):
+ return None
+ if when is None:
+ return None
+ if when.tzinfo is None:
+ when = when.replace(tzinfo=UTC)
+ return max(0.0, (when - datetime.now(UTC)).total_seconds())
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/openqa-async-0.1.0/src/openqa_async/aclient.py
new/openqa-async-1.0.0/src/openqa_async/aclient.py
--- old/openqa-async-0.1.0/src/openqa_async/aclient.py 2026-05-30
17:53:44.000000000 +0200
+++ new/openqa-async-1.0.0/src/openqa_async/aclient.py 2026-07-20
12:29:50.000000000 +0200
@@ -24,17 +24,25 @@
import asyncio
import ssl
+from time import monotonic
from types import TracebackType
from typing import Any, Self
import httpx
-from ._base import _OpenQAClientBase
+from ._base import _DEFAULT_TIMEOUT, _OpenQAClientBase
from .exceptions import ConnectionError
class AsyncOpenQAClient(_OpenQAClientBase):
- """Asynchronous client for an openQA server's REST API."""
+ """Asynchronous client for an openQA server's REST API.
+
+ ``timeout`` is the per-request timeout forwarded to the underlying
+ ``httpx.AsyncClient`` (default 30 s; ``None`` disables). ``retry_methods``
+ restricts which HTTP methods are retried on transport errors (default: the
+ idempotent set, so ``POST`` mutations are not re-sent). ``deadline`` bounds
+ the total wall-clock time spent retrying a single call.
+ """
def __init__(
self,
@@ -43,6 +51,9 @@
retries: int = 5,
wait: int = 10,
verify: bool | str | ssl.SSLContext = True,
+ timeout: float | httpx.Timeout | None = _DEFAULT_TIMEOUT,
+ retry_methods: frozenset[str] | None = None,
+ deadline: float | None = None,
) -> None:
super().__init__(
server=server,
@@ -50,6 +61,9 @@
retries=retries,
wait=wait,
verify=verify,
+ timeout=timeout,
+ retry_methods=retry_methods,
+ deadline=deadline,
)
self.client = httpx.AsyncClient(
base_url=self.baseurl,
@@ -57,6 +71,7 @@
auth=self._build_auth(),
trust_env=True,
verify=self.verify,
+ timeout=self.timeout,
)
async def do_request(
@@ -65,43 +80,90 @@
retries: int | None = None,
wait: int | float | None = None,
parse: bool = True,
+ retry_non_idempotent: bool = False,
+ deadline: float | None = None,
) -> Any:
"""Send ``request`` with retry/backoff and return parsed output.
Retries on the upstream status-code set and on transport errors,
- sleeping with exponential backoff between attempts. A value of
- ``retries`` means up to that many *retries* (so ``retries + 1``
- total attempts). After the attempts are exhausted, a transport
- failure raises :class:`~openqa_async.exceptions.ConnectionError`
- and a non-2xx response raises
- :class:`~openqa_async.exceptions.RequestError` (via
+ sleeping with jittered exponential backoff between attempts. A value of
+ ``retries`` means up to that many *retries* (so ``retries + 1`` total
+ attempts). Transport errors are only retried for idempotent methods
+ unless ``retry_non_idempotent`` is set, so a ``POST`` that fails
+ mid-flight is not re-sent. When the server returns a retryable status
+ with a ``Retry-After`` header, that guidance is honoured (bounded by
+ ``deadline``). ``deadline`` (seconds) caps the total time spent
+ retrying; on expiry the last error is raised.
+
+ After the attempts are exhausted, a transport failure raises
+ :class:`~openqa_async.exceptions.ConnectionError` and a non-2xx
+ response raises :class:`~openqa_async.exceptions.RequestError` (via
``_handle_response``).
"""
if retries is None:
retries = self.retries
if wait is None:
wait = self.wait
+ if deadline is None:
+ deadline = self.deadline
+ retries = max(retries, 0)
+ method = request.method
+ start = monotonic()
for attempt in range(retries + 1):
last_attempt = attempt == retries
try:
resp = await self.client.send(request)
except httpx.TransportError as exc:
- if last_attempt:
+ may_retry = self._may_retry_transport(method,
retry_non_idempotent)
+ if last_attempt or not may_retry:
+ raise ConnectionError(
+ f"Connection to {request.url} failed: "
+ f"{type(exc).__name__}: {exc}"
+ ) from exc
+ if not await self._sleep_backoff(wait, start, deadline):
raise ConnectionError(
- f"Connection to {request.url} failed: {exc}"
+ f"Connection to {request.url} failed: "
+ f"{type(exc).__name__}: {exc}"
) from exc
- await asyncio.sleep(wait)
wait = self._next_wait(wait)
continue
if not last_attempt and self._should_retry(resp.status_code):
- await asyncio.sleep(wait)
+ retry_after = self._parse_retry_after(resp)
+ sleep_for = self._backoff(wait)
+ if retry_after is not None:
+ sleep_for = max(retry_after, sleep_for)
+ if not await self._sleep_within_deadline(sleep_for, start,
deadline):
+ return self._handle_response(resp, parse)
wait = self._next_wait(wait)
continue
return self._handle_response(resp, parse)
+ # Unreachable in practice (loop always returns/raises above), but
+ # guards against future edits and non-positive ``retries``.
+ raise ConnectionError(f"Connection to {request.url} failed: no
attempts made")
+
+ async def _sleep_backoff(
+ self, wait: int | float, start: float, deadline: float | None
+ ) -> bool:
+ """Sleep a jittered backoff; return ``False`` if it would blow the
deadline."""
+ return await self._sleep_within_deadline(self._backoff(wait), start,
deadline)
+
+ async def _sleep_within_deadline(
+ self, sleep_for: float, start: float, deadline: float | None
+ ) -> bool:
+ """Sleep ``sleep_for`` unless it would exceed ``deadline``.
+
+ Returns ``True`` if it slept, ``False`` if the deadline would be
+ exceeded (caller should then stop retrying).
+ """
+ if deadline is not None and (monotonic() - start) + sleep_for >
deadline:
+ return False
+ await asyncio.sleep(sleep_for)
+ return True
+
async def openqa_request(
self,
method: str,
@@ -111,11 +173,20 @@
wait: int | float | None = None,
data: Any = None,
json: Any = None,
+ retry_non_idempotent: bool = False,
+ deadline: float | None = None,
) -> Any:
"""Build and dispatch a request against the openQA API."""
args = self._build_request_args(method, path, params, data, json)
request = self.client.build_request(**args)
- return await self.do_request(request, retries=retries, wait=wait,
parse=True)
+ return await self.do_request(
+ request,
+ retries=retries,
+ wait=wait,
+ parse=True,
+ retry_non_idempotent=retry_non_idempotent,
+ deadline=deadline,
+ )
async def aclose(self) -> None:
"""Close the underlying ``httpx.AsyncClient``."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/openqa-async-0.1.0/src/openqa_async/client.py
new/openqa-async-1.0.0/src/openqa_async/client.py
--- old/openqa-async-0.1.0/src/openqa_async/client.py 2026-05-30
17:53:44.000000000 +0200
+++ new/openqa-async-1.0.0/src/openqa_async/client.py 2026-07-20
12:29:50.000000000 +0200
@@ -26,12 +26,19 @@
import httpx
-from ._base import _OpenQAClientBase
+from ._base import _DEFAULT_TIMEOUT, _OpenQAClientBase
from .exceptions import ConnectionError
class OpenQAClient(_OpenQAClientBase):
- """Synchronous client for an openQA server's REST API."""
+ """Synchronous client for an openQA server's REST API.
+
+ ``timeout`` is the per-request timeout forwarded to the underlying
+ ``httpx.Client`` (default 30 s; ``None`` disables). ``retry_methods``
+ restricts which HTTP methods are retried on transport errors (default: the
+ idempotent set, so ``POST`` mutations are not re-sent). ``deadline`` bounds
+ the total wall-clock time spent retrying a single call.
+ """
def __init__(
self,
@@ -40,6 +47,9 @@
retries: int = 5,
wait: int = 10,
verify: bool | str | ssl.SSLContext = True,
+ timeout: float | httpx.Timeout | None = _DEFAULT_TIMEOUT,
+ retry_methods: frozenset[str] | None = None,
+ deadline: float | None = None,
) -> None:
super().__init__(
server=server,
@@ -47,6 +57,9 @@
retries=retries,
wait=wait,
verify=verify,
+ timeout=timeout,
+ retry_methods=retry_methods,
+ deadline=deadline,
)
self.client = httpx.Client(
base_url=self.baseurl,
@@ -54,6 +67,7 @@
auth=self._build_auth(),
trust_env=True,
verify=self.verify,
+ timeout=self.timeout,
)
#: Legacy alias for ``self.client`` (upstream exposed ``session``).
self.session = self.client
@@ -64,43 +78,90 @@
retries: int | None = None,
wait: int | float | None = None,
parse: bool = True,
+ retry_non_idempotent: bool = False,
+ deadline: float | None = None,
) -> Any:
"""Send ``request`` with retry/backoff and return parsed output.
Retries on the upstream status-code set and on transport errors,
- sleeping with exponential backoff between attempts. A value of
- ``retries`` means up to that many *retries* (so ``retries + 1``
- total attempts). After the attempts are exhausted, a transport
- failure raises :class:`~openqa_async.exceptions.ConnectionError`
- and a non-2xx response raises
- :class:`~openqa_async.exceptions.RequestError` (via
+ sleeping with jittered exponential backoff between attempts. A value of
+ ``retries`` means up to that many *retries* (so ``retries + 1`` total
+ attempts). Transport errors are only retried for idempotent methods
+ unless ``retry_non_idempotent`` is set, so a ``POST`` that fails
+ mid-flight is not re-sent. When the server returns a retryable status
+ with a ``Retry-After`` header, that guidance is honoured (bounded by
+ ``deadline``). ``deadline`` (seconds) caps the total time spent
+ retrying; on expiry the last error is raised.
+
+ After the attempts are exhausted, a transport failure raises
+ :class:`~openqa_async.exceptions.ConnectionError` and a non-2xx
+ response raises :class:`~openqa_async.exceptions.RequestError` (via
``_handle_response``).
"""
if retries is None:
retries = self.retries
if wait is None:
wait = self.wait
+ if deadline is None:
+ deadline = self.deadline
+ retries = max(retries, 0)
+ method = request.method
+ start = time.monotonic()
for attempt in range(retries + 1):
last_attempt = attempt == retries
try:
resp = self.client.send(request)
except httpx.TransportError as exc:
- if last_attempt:
+ may_retry = self._may_retry_transport(method,
retry_non_idempotent)
+ if last_attempt or not may_retry:
+ raise ConnectionError(
+ f"Connection to {request.url} failed: "
+ f"{type(exc).__name__}: {exc}"
+ ) from exc
+ if not self._sleep_backoff(wait, start, deadline):
raise ConnectionError(
- f"Connection to {request.url} failed: {exc}"
+ f"Connection to {request.url} failed: "
+ f"{type(exc).__name__}: {exc}"
) from exc
- time.sleep(wait)
wait = self._next_wait(wait)
continue
if not last_attempt and self._should_retry(resp.status_code):
- time.sleep(wait)
+ retry_after = self._parse_retry_after(resp)
+ sleep_for = self._backoff(wait)
+ if retry_after is not None:
+ sleep_for = max(retry_after, sleep_for)
+ if not self._sleep_within_deadline(sleep_for, start, deadline):
+ return self._handle_response(resp, parse)
wait = self._next_wait(wait)
continue
return self._handle_response(resp, parse)
+ # Unreachable in practice (loop always returns/raises above), but
+ # guards against future edits and non-positive ``retries``.
+ raise ConnectionError(f"Connection to {request.url} failed: no
attempts made")
+
+ def _sleep_backoff(
+ self, wait: int | float, start: float, deadline: float | None
+ ) -> bool:
+ """Sleep a jittered backoff; return ``False`` if it would blow the
deadline."""
+ return self._sleep_within_deadline(self._backoff(wait), start,
deadline)
+
+ def _sleep_within_deadline(
+ self, sleep_for: float, start: float, deadline: float | None
+ ) -> bool:
+ """Sleep ``sleep_for`` unless it would exceed ``deadline``.
+
+ Returns ``True`` if it slept, ``False`` if the deadline would be
+ exceeded (caller should then stop retrying).
+ """
+ if deadline is not None and (time.monotonic() - start) + sleep_for >
deadline:
+ return False
+ time.sleep(sleep_for)
+ return True
+
def openqa_request(
self,
method: str,
@@ -110,11 +171,20 @@
wait: int | float | None = None,
data: Any = None,
json: Any = None,
+ retry_non_idempotent: bool = False,
+ deadline: float | None = None,
) -> Any:
"""Build and dispatch a request against the openQA API."""
args = self._build_request_args(method, path, params, data, json)
request = self.client.build_request(**args)
- return self.do_request(request, retries=retries, wait=wait, parse=True)
+ return self.do_request(
+ request,
+ retries=retries,
+ wait=wait,
+ parse=True,
+ retry_non_idempotent=retry_non_idempotent,
+ deadline=deadline,
+ )
def close(self) -> None:
"""Close the underlying ``httpx.Client``."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/openqa-async-0.1.0/tests/test_async_client.py
new/openqa-async-1.0.0/tests/test_async_client.py
--- old/openqa-async-0.1.0/tests/test_async_client.py 2026-05-30
17:53:44.000000000 +0200
+++ new/openqa-async-1.0.0/tests/test_async_client.py 2026-07-20
12:29:50.000000000 +0200
@@ -147,6 +147,30 @@
assert captured["verify"] is True
+def test_default_timeout_reaches_async_httpx_client(write_config):
+ client = _aclient(write_config)
+ assert client.client.timeout == httpx.Timeout(30.0)
+
+
+def test_explicit_timeout_reaches_async_httpx_client(write_config):
+ client = _aclient(write_config, timeout=5.0)
+ assert client.client.timeout == httpx.Timeout(5.0)
+
+
+def test_timeout_none_disables_async(write_config):
+ client = _aclient(write_config, timeout=None)
+ assert client.client.timeout == httpx.Timeout(None)
+
+
[email protected]
+async def test_connection_error_message_names_exception(write_config):
+ respx.get(URL).mock(side_effect=httpx.ReadTimeout("slow"))
+ async with _aclient(write_config) as client:
+ with pytest.raises(ConnectionError) as excinfo:
+ await client.openqa_request("GET", "/api/v1/jobs", retries=0,
wait=0)
+ assert "ReadTimeout" in str(excinfo.value)
+
+
@respx.mock
async def test_sync_async_parity(write_config):
"""The same respx endpoint yields identical results from both clients."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/openqa-async-0.1.0/tests/test_client.py
new/openqa-async-1.0.0/tests/test_client.py
--- old/openqa-async-0.1.0/tests/test_client.py 2026-05-30 17:53:44.000000000
+0200
+++ new/openqa-async-1.0.0/tests/test_client.py 2026-07-20 12:29:50.000000000
+0200
@@ -160,3 +160,27 @@
assert client._should_retry(503) is True
assert client._should_retry(404) is False
assert client._should_retry(ValueError("nope")) is False
+
+
+def test_default_timeout_reaches_httpx_client(write_config):
+ with _client(write_config) as client:
+ assert client.client.timeout == httpx.Timeout(30.0)
+
+
+def test_explicit_timeout_reaches_httpx_client(write_config):
+ with _client(write_config, timeout=5.0) as client:
+ assert client.client.timeout == httpx.Timeout(5.0)
+
+
+def test_timeout_none_disables(write_config):
+ with _client(write_config, timeout=None) as client:
+ assert client.client.timeout == httpx.Timeout(None)
+
+
[email protected]
+def test_connection_error_message_names_exception(write_config):
+ respx.get(URL).mock(side_effect=httpx.ReadTimeout("slow"))
+ with _client(write_config) as client:
+ with pytest.raises(ConnectionError) as excinfo:
+ client.openqa_request("GET", "/api/v1/jobs", retries=0, wait=0)
+ assert "ReadTimeout" in str(excinfo.value)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/openqa-async-0.1.0/tests/test_retry.py
new/openqa-async-1.0.0/tests/test_retry.py
--- old/openqa-async-0.1.0/tests/test_retry.py 1970-01-01 01:00:00.000000000
+0100
+++ new/openqa-async-1.0.0/tests/test_retry.py 2026-07-20 12:29:50.000000000
+0200
@@ -0,0 +1,253 @@
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""Tests for the hardened retry mechanic (jitter, idempotency policy,
+``Retry-After`` handling, deadline, and loop fall-off) across both the sync
+and async clients.
+"""
+
+import random
+from datetime import UTC, datetime, timedelta
+from email.utils import format_datetime
+
+import httpx
+import pytest
+import respx
+
+from openqa_async.aclient import AsyncOpenQAClient
+from openqa_async.client import OpenQAClient
+from openqa_async.exceptions import ConnectionError, RequestError
+
+BASE = "https://openqa.example.com"
+URL = f"{BASE}/api/v1/jobs"
+
+
+def _client(**kwargs) -> OpenQAClient:
+ return OpenQAClient(server="openqa.example.com", **kwargs)
+
+
+def _aclient(**kwargs) -> AsyncOpenQAClient:
+ return AsyncOpenQAClient(server="openqa.example.com", **kwargs)
+
+
+# --- idempotency policy -----------------------------------------------------
+
+
[email protected]
+def test_get_transport_error_retried_then_succeeds(write_config):
+ route = respx.get(URL).mock(
+ side_effect=[httpx.ConnectError("boom"), httpx.Response(200,
json={"ok": 1})]
+ )
+ with _client() as client:
+ result = client.openqa_request("GET", "/api/v1/jobs", retries=3,
wait=0)
+ assert result == {"ok": 1}
+ assert route.call_count == 2
+
+
[email protected]
+def test_post_transport_error_not_retried(write_config):
+ route = respx.post(URL).mock(side_effect=httpx.ConnectError("boom"))
+ with _client() as client:
+ with pytest.raises(ConnectionError):
+ client.openqa_request("POST", "/api/v1/jobs", retries=5, wait=0)
+ assert route.call_count == 1
+
+
[email protected]
+def test_post_retry_non_idempotent_opt_in(write_config):
+ route = respx.post(URL).mock(
+ side_effect=[httpx.ConnectError("boom"), httpx.Response(200,
json={"ok": 1})]
+ )
+ with _client() as client:
+ result = client.openqa_request(
+ "POST", "/api/v1/jobs", retries=3, wait=0,
retry_non_idempotent=True
+ )
+ assert result == {"ok": 1}
+ assert route.call_count == 2
+
+
[email protected]
+async def test_async_post_transport_error_not_retried(write_config):
+ route = respx.post(URL).mock(side_effect=httpx.ConnectError("boom"))
+ async with _aclient() as client:
+ with pytest.raises(ConnectionError):
+ await client.openqa_request("POST", "/api/v1/jobs", retries=5,
wait=0)
+ assert route.call_count == 1
+
+
[email protected]
+async def test_async_get_transport_error_retried(write_config):
+ route = respx.get(URL).mock(
+ side_effect=[httpx.ConnectError("boom"), httpx.Response(200,
json={"ok": 1})]
+ )
+ async with _aclient() as client:
+ result = await client.openqa_request("GET", "/api/v1/jobs", retries=3,
wait=0)
+ assert result == {"ok": 1}
+ assert route.call_count == 2
+
+
+# --- Retry-After ------------------------------------------------------------
+
+
[email protected]
+def test_retry_after_integer_seconds(write_config, monkeypatch):
+ slept: list[float] = []
+ monkeypatch.setattr("openqa_async.client.time.sleep", lambda s:
slept.append(s))
+ route = respx.get(URL).mock(
+ side_effect=[
+ httpx.Response(429, headers={"Retry-After": "2"}),
+ httpx.Response(200, json={"ok": 1}),
+ ]
+ )
+ with _client() as client:
+ # wait small so jitter can't exceed Retry-After
+ result = client.openqa_request("GET", "/api/v1/jobs", retries=3,
wait=1)
+ assert result == {"ok": 1}
+ assert route.call_count == 2
+ assert slept == [2.0]
+
+
[email protected]
+def test_retry_after_http_date(write_config, monkeypatch):
+ slept: list[float] = []
+ monkeypatch.setattr("openqa_async.client.time.sleep", lambda s:
slept.append(s))
+ when = datetime.now(UTC) + timedelta(seconds=3)
+ route = respx.get(URL).mock(
+ side_effect=[
+ httpx.Response(503, headers={"Retry-After":
format_datetime(when)}),
+ httpx.Response(200, json={"ok": 1}),
+ ]
+ )
+ with _client() as client:
+ result = client.openqa_request("GET", "/api/v1/jobs", retries=3,
wait=0)
+ assert result == {"ok": 1}
+ assert route.call_count == 2
+ assert len(slept) == 1
+ assert 2.0 <= slept[0] <= 3.1
+
+
[email protected]
+def test_retry_after_garbage_falls_back_to_backoff(write_config, monkeypatch):
+ slept: list[float] = []
+ monkeypatch.setattr("openqa_async.client.time.sleep", lambda s:
slept.append(s))
+ route = respx.get(URL).mock(
+ side_effect=[
+ httpx.Response(503, headers={"Retry-After": "not-a-date"}),
+ httpx.Response(200, json={"ok": 1}),
+ ]
+ )
+ with _client(deadline=None) as client:
+ client._rng = random.Random(0)
+ result = client.openqa_request("GET", "/api/v1/jobs", retries=3,
wait=10)
+ assert result == {"ok": 1}
+ assert route.call_count == 2
+ assert len(slept) == 1
+ assert 0.0 <= slept[0] <= 10.0
+
+
+def test_parse_retry_after_units(write_config):
+ with _client() as client:
+ r = httpx.Response(
+ 429, headers={"Retry-After": "5"}, request=httpx.Request("GET",
URL)
+ )
+ assert client._parse_retry_after(r) == 5.0
+ r = httpx.Response(429, request=httpx.Request("GET", URL))
+ assert client._parse_retry_after(r) is None
+ r = httpx.Response(
+ 429, headers={"Retry-After": "junk"}, request=httpx.Request("GET",
URL)
+ )
+ assert client._parse_retry_after(r) is None
+ past = format_datetime(datetime.now(UTC) - timedelta(seconds=30))
+ r = httpx.Response(
+ 429, headers={"Retry-After": past}, request=httpx.Request("GET",
URL)
+ )
+ assert client._parse_retry_after(r) == 0.0
+
+
+# --- deadline ---------------------------------------------------------------
+
+
[email protected]
+def test_deadline_caps_total_retry_time(write_config, monkeypatch):
+ slept: list[float] = []
+ monkeypatch.setattr("openqa_async.client.time.sleep", lambda s:
slept.append(s))
+ route = respx.get(URL).mock(return_value=httpx.Response(503))
+ with _client() as client:
+ client._rng = random.Random(1)
+ with pytest.raises(RequestError) as excinfo:
+ client.openqa_request(
+ "GET", "/api/v1/jobs", retries=100, wait=10, deadline=0.5
+ )
+ # Never slept past the budget, and stopped well before 100 attempts.
+ assert sum(slept) <= 0.5
+ assert route.call_count < 100
+ # The last error surfaces (a 503 -> RequestError).
+ assert excinfo.value.status_code == 503
+
+
[email protected]
+async def test_async_deadline_caps_total_retry_time(write_config, monkeypatch):
+ slept: list[float] = []
+
+ async def fake_sleep(s):
+ slept.append(s)
+
+ monkeypatch.setattr("openqa_async.aclient.asyncio.sleep", fake_sleep)
+ route = respx.get(URL).mock(return_value=httpx.Response(503))
+ async with _aclient() as client:
+ client._rng = random.Random(1)
+ with pytest.raises(RequestError):
+ await client.openqa_request(
+ "GET", "/api/v1/jobs", retries=100, wait=10, deadline=0.5
+ )
+ assert sum(slept) <= 0.5
+ assert route.call_count < 100
+
+
+# --- jitter -----------------------------------------------------------------
+
+
+def test_backoff_is_bounded_and_seed_deterministic(write_config):
+ with _client() as client:
+ client._rng = random.Random(42)
+ seq1 = [client._backoff(10) for _ in range(20)]
+ with _client() as client2:
+ client2._rng = random.Random(42)
+ seq2 = [client2._backoff(10) for _ in range(20)]
+ assert seq1 == seq2
+ assert all(0.0 <= v <= 10.0 for v in seq1)
+ # Cap at 60 even for large waits.
+ with _client() as client3:
+ client3._rng = random.Random(0)
+ assert all(0.0 <= client3._backoff(1000) <= 60.0 for _ in range(20))
+
+
+# --- fall-off / edge cases --------------------------------------------------
+
+
[email protected]
+def test_negative_retries_makes_one_attempt(write_config):
+ route = respx.get(URL).mock(return_value=httpx.Response(200, json={"ok":
1}))
+ with _client() as client:
+ result = client.openqa_request("GET", "/api/v1/jobs", retries=-3,
wait=0)
+ assert result == {"ok": 1}
+ assert route.call_count == 1
+
+
[email protected]
+def test_negative_retries_transport_error_raises(write_config):
+ route = respx.get(URL).mock(side_effect=httpx.ConnectError("boom"))
+ with _client() as client:
+ with pytest.raises(ConnectionError):
+ client.openqa_request("GET", "/api/v1/jobs", retries=-3, wait=0)
+ assert route.call_count == 1
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/openqa-async-0.1.0/uv.lock
new/openqa-async-1.0.0/uv.lock
--- old/openqa-async-0.1.0/uv.lock 2026-05-30 17:53:44.000000000 +0200
+++ new/openqa-async-1.0.0/uv.lock 2026-07-20 12:29:50.000000000 +0200
@@ -158,7 +158,7 @@
[[package]]
name = "openqa-async"
-version = "0.1.0"
+version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
@@ -226,7 +226,7 @@
[[package]]
name = "pytest"
-version = "9.0.3"
+version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -235,9 +235,9 @@
{ name = "pluggy" },
{ name = "pygments" },
]
-sdist = { url =
"https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz",
hash =
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size
= 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+sdist = { url =
"https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz",
hash =
"sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size
= 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
- { url =
"https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl",
hash =
"sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size
= 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+ { url =
"https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl",
hash =
"sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size
= 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
@@ -316,52 +316,52 @@
[[package]]
name = "ruff"
-version = "0.15.15"
+version = "0.15.20"
source = { registry = "https://pypi.org/simple" }
-sdist = { url =
"https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz",
hash =
"sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size
= 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
+sdist = { url =
"https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz",
hash =
"sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size
= 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
wheels = [
- { url =
"https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl",
hash =
"sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size
= 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
- { url =
"https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl",
hash =
"sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size
= 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
- { url =
"https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl",
hash =
"sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size
= 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
- { url =
"https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size
= 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
- { url =
"https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size
= 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
- { url =
"https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",
hash =
"sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size
= 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
- { url =
"https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size
= 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
- { url =
"https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size
= 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
- { url =
"https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size
= 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
- { url =
"https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl",
hash =
"sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size
= 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
- { url =
"https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl",
hash =
"sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size
= 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
- { url =
"https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl",
hash =
"sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size
= 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
- { url =
"https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl",
hash =
"sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size
= 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
- { url =
"https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl",
hash =
"sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size
= 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
- { url =
"https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl",
hash =
"sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size
= 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
- { url =
"https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl",
hash =
"sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size
= 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
- { url =
"https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl",
hash =
"sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size
= 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
+ { url =
"https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl",
hash =
"sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size
= 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
+ { url =
"https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl",
hash =
"sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size
= 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
+ { url =
"https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl",
hash =
"sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size
= 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
+ { url =
"https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size
= 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
+ { url =
"https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size
= 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
+ { url =
"https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",
hash =
"sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size
= 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
+ { url =
"https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size
= 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
+ { url =
"https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size
= 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
+ { url =
"https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size
= 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
+ { url =
"https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl",
hash =
"sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size
= 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
+ { url =
"https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl",
hash =
"sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size
= 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
+ { url =
"https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl",
hash =
"sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size
= 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
+ { url =
"https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl",
hash =
"sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size
= 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
+ { url =
"https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl",
hash =
"sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size
= 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
+ { url =
"https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl",
hash =
"sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size
= 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
+ { url =
"https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl",
hash =
"sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size
= 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
+ { url =
"https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl",
hash =
"sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size
= 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
]
[[package]]
name = "ty"
-version = "0.0.40"
+version = "0.0.56"
source = { registry = "https://pypi.org/simple" }
-sdist = { url =
"https://files.pythonhosted.org/packages/5a/f8/a754c96967b71de8723f88be17df8738216bd382ffed229cd500b7a24d13/ty-0.0.40.tar.gz",
hash =
"sha256:883b53dd98f6e5b33ab1c8e1a3cd94b0f29c762ef22cdf1e86aaffb4fd711c67", size
= 5726484, upload-time = "2026-05-27T17:55:43.615Z" }
+sdist = { url =
"https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz",
hash =
"sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size
= 6050123, upload-time = "2026-07-01T16:44:56.036Z" }
wheels = [
- { url =
"https://files.pythonhosted.org/packages/2c/42/d029a72165ad39f95228b67355927fbd35c821dc8e3e475d49f47c2eeb1e/ty-0.0.40-py3-none-linux_armv6l.whl",
hash =
"sha256:9defb4742450e569a6a09de286a04008d6c2e815112da4362c88b6eaa2f52a36", size
= 11406372, upload-time = "2026-05-27T17:55:49.633Z" },
- { url =
"https://files.pythonhosted.org/packages/23/99/7f8ea09b7e49afbf795cb3341a3217f30f228db7e62a2268ed8cbbf813d6/ty-0.0.40-py3-none-macosx_10_12_x86_64.whl",
hash =
"sha256:868258a3330db88b683fcafe2c4e936d6226a6312799bf15b585d93557b2d38c", size
= 11159782, upload-time = "2026-05-27T17:55:47.405Z" },
- { url =
"https://files.pythonhosted.org/packages/04/d8/1ea745ee97a98b26ae9564d19a430a76a35297cd450e84dcaad22e1f7ee8/ty-0.0.40-py3-none-macosx_11_0_arm64.whl",
hash =
"sha256:589c81060cf1e7a9ffa2f45bfa35ffd9b9fbd214104e3f13959f113627efcd91", size
= 10594139, upload-time = "2026-05-27T17:55:37.206Z" },
- { url =
"https://files.pythonhosted.org/packages/39/1a/fbef21273c6617ff4715b4827ee1c0b6550aa7d1df4b8c43b325545c1cf4/ty-0.0.40-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:7b06108990cb338d941c315ae6e9ba2fff8f518bc15d3f33e5619ff6a6c9beab", size
= 11114156, upload-time = "2026-05-27T17:55:56.11Z" },
- { url =
"https://files.pythonhosted.org/packages/3c/f9/389fc4976d7ec016a7473cf1274bf9c4f491bb54c66649bd022bff9f2b6a/ty-0.0.40-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:3913ef37336bec4f96bd2512f8c3a543ca34c259b7170f7eb5adf75b3ed7f04c", size
= 11189050, upload-time = "2026-05-27T17:55:54.099Z" },
- { url =
"https://files.pythonhosted.org/packages/fa/a9/4ecabbf4bdda7df0d99d8d3892c6edac0efc8c4cae756a5109178a3d0e86/ty-0.0.40-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",
hash =
"sha256:8fd1486bd5fe48779a8aa857137f3642a0a9161f5cf57d4380f4a0ecea01c8f3", size
= 11664266, upload-time = "2026-05-27T17:55:28.17Z" },
- { url =
"https://files.pythonhosted.org/packages/45/02/0aa78730116507c265afb1d6d5961c583b49d4c2e368c4a49fd81bcae6dc/ty-0.0.40-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:1668364d5254a734329917ee66c2c5fdd5665389d41043f6fce0f22ddb32b749", size
= 12187743, upload-time = "2026-05-27T17:56:04.337Z" },
- { url =
"https://files.pythonhosted.org/packages/e6/68/ccabf2d173523598271a385c1d3f864dbda23e5ebdc67f5969b9e830ea05/ty-0.0.40-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:43f77a73edb91e5dfa2ab9af7c4cac64614f8cc121f38a8875f22e830d3aba6a", size
= 11862999, upload-time = "2026-05-27T17:55:58.087Z" },
- { url =
"https://files.pythonhosted.org/packages/03/8d/6d7ec22771bb23d534797cdb446eb644bccfe7a62b729bb99e7235a02fc3/ty-0.0.40-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:1274ce0212ecbfed01bda7c3659c46e8bd0068e32d00c46c790466a95274c3df", size
= 11743896, upload-time = "2026-05-27T17:56:00.017Z" },
- { url =
"https://files.pythonhosted.org/packages/cd/a4/f9fa076b010c91cb249b1fcc3476569b7b8462cb4b688da2d04c23a0622f/ty-0.0.40-py3-none-manylinux_2_31_riscv64.whl",
hash =
"sha256:5ee1261dbc363e5cc1a0c5bb0c8612c192bfe53491214df8bc85a540835685f9", size
= 11883581, upload-time = "2026-05-27T17:56:02.319Z" },
- { url =
"https://files.pythonhosted.org/packages/fd/0f/5b776a2328c756d574dd4d6afbd30fc24e1ab4b76935c7c3c23f27ebbcb9/ty-0.0.40-py3-none-musllinux_1_2_aarch64.whl",
hash =
"sha256:6220e2cd5cdc4683dd87fb150d195bbd9f1a021395e04cb08bd3c66ea6da6ef8", size
= 11093946, upload-time = "2026-05-27T17:55:33.284Z" },
- { url =
"https://files.pythonhosted.org/packages/64/c4/eb23154bae83ad7c2935e9e5916660fb3e31598a92ee232aebd79410480c/ty-0.0.40-py3-none-musllinux_1_2_armv7l.whl",
hash =
"sha256:46b9ed69d01d98ef046afac9983c68336f572605ea2a27b90fbe6f80bfc8d6b7", size
= 11210737, upload-time = "2026-05-27T17:55:45.523Z" },
- { url =
"https://files.pythonhosted.org/packages/ff/19/1fb2529703f708cacfd13a89f98613cae2907dfa941b26976467e6119803/ty-0.0.40-py3-none-musllinux_1_2_i686.whl",
hash =
"sha256:ddbca9fab4406260f141674ab5efcfe7b02bd468e6985e4cdde0a21626e69ffe", size
= 11332563, upload-time = "2026-05-27T17:55:41.674Z" },
- { url =
"https://files.pythonhosted.org/packages/87/69/b3f5a8ef26c31204e0391147b3adcdb0674eda3e7d99868478ef168a41c6/ty-0.0.40-py3-none-musllinux_1_2_x86_64.whl",
hash =
"sha256:b1fcc082a749e6dc11b68fe9aab0420238bbf2a2374c2c7aa3c22e8c1618b136", size
= 11843216, upload-time = "2026-05-27T17:55:35.367Z" },
- { url =
"https://files.pythonhosted.org/packages/ac/e8/20193069d32787f3e1a6ec8940aaa3759d3de8f48f9281bcc0c5cb0939da/ty-0.0.40-py3-none-win32.whl",
hash =
"sha256:75feb115b3587824c5bdf8f8305e9547b0d1e398e3077b0addc7a1988ea9bb50", size
= 10670731, upload-time = "2026-05-27T17:55:31.316Z" },
- { url =
"https://files.pythonhosted.org/packages/a3/f9/8b2aa4da61db81322d4a2f9db227afeb48110ca15ae31d380f64c64ceb63/ty-0.0.40-py3-none-win_amd64.whl",
hash =
"sha256:b0f905edaad788bd61f779a85801b60a267a25ed57fca05aaddd168d9d8896be", size
= 11766211, upload-time = "2026-05-27T17:55:51.898Z" },
- { url =
"https://files.pythonhosted.org/packages/04/87/369056ed46f1b235130ec0595393262f9cd2061ca3dab276d490980f9343/ty-0.0.40-py3-none-win_arm64.whl",
hash =
"sha256:07da2b09d9130e2c9a257d2a29beb53105835b0256ee5fdb288fe1aab83fee47", size
= 11117369, upload-time = "2026-05-27T17:55:39.329Z" },
+ { url =
"https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl",
hash =
"sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size
= 11643066, upload-time = "2026-07-01T16:44:18.374Z" },
+ { url =
"https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl",
hash =
"sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size
= 11407487, upload-time = "2026-07-01T16:44:20.956Z" },
+ { url =
"https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl",
hash =
"sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size
= 10962270, upload-time = "2026-07-01T16:44:22.959Z" },
+ { url =
"https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size
= 11471406, upload-time = "2026-07-01T16:44:25.327Z" },
+ { url =
"https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size
= 11445612, upload-time = "2026-07-01T16:44:27.531Z" },
+ { url =
"https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",
hash =
"sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size
= 12093889, upload-time = "2026-07-01T16:44:29.548Z" },
+ { url =
"https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size
= 12666337, upload-time = "2026-07-01T16:44:31.586Z" },
+ { url =
"https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size
= 12280247, upload-time = "2026-07-01T16:44:33.637Z" },
+ { url =
"https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size
= 11991107, upload-time = "2026-07-01T16:44:36.122Z" },
+ { url =
"https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl",
hash =
"sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size
= 12298970, upload-time = "2026-07-01T16:44:38.243Z" },
+ { url =
"https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl",
hash =
"sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size
= 11425683, upload-time = "2026-07-01T16:44:40.473Z" },
+ { url =
"https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl",
hash =
"sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size
= 11469258, upload-time = "2026-07-01T16:44:42.513Z" },
+ { url =
"https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl",
hash =
"sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size
= 11758736, upload-time = "2026-07-01T16:44:44.567Z" },
+ { url =
"https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl",
hash =
"sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size
= 12100242, upload-time = "2026-07-01T16:44:46.584Z" },
+ { url =
"https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl",
hash =
"sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size
= 11093759, upload-time = "2026-07-01T16:44:49.171Z" },
+ { url =
"https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl",
hash =
"sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size
= 12100327, upload-time = "2026-07-01T16:44:51.26Z" },
+ { url =
"https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl",
hash =
"sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size
= 11538780, upload-time = "2026-07-01T16:44:53.8Z" },
]
[[package]]