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]]

Reply via email to