Spaces:
Sleeping
feat(domain): Deadline — contrat de timeout propagé cross-process
Browse filesType valeur pur en couche 1, propagé via RunContext aux adapters
(cf. ADR-0001). Représente soit une échéance monotonic absolue,
soit l'absence de contrainte temporelle (``infinite``).
## Propriétés garanties
- **Immuable** : ``__slots__`` + ``__setattr__`` qui lève
``AttributeError``. Hashable, donc utilisable comme clé de dict
ou élément de set.
- **Cross-process safe** : ``__getstate__`` / ``__setstate__``
convertissent vers ``remaining_seconds`` au moment du transfert
(pickle) ; ``to_dict`` / ``from_dict`` même pattern pour JSON.
Conséquence : un overhead de dispatch de N secondes n'est PAS
facturé au worker — le budget visible côté receveur est le
budget *au moment du handoff*, pas le budget original. Le
wall-clock total peut excéder le budget original par l'overhead
de dispatch, mais le contrat de durée vue par l'adapter est
respecté.
- **Numériquement défensif** : ``remaining_seconds()`` ne retourne
jamais de négatif (``max(0.0, ...)``). ``clamp_to_remaining``
borne un délai par le budget restant (utile pour le backoff
retry de Phase 3).
- **Validations strictes** : ``in_seconds(budget <= 0)`` rejeté ;
``from_dict`` validate la structure du dict ; constructeurs
hostiles aux types non-numériques.
## API
- ``Deadline.infinite()`` — pas d'échéance.
- ``Deadline.in_seconds(budget)`` — échéance dans budget secondes.
- ``Deadline.at_monotonic(t)`` — instant monotonic absolu (tests).
- ``deadline.remaining_seconds()`` — None si infinie.
- ``deadline.is_expired()``, ``deadline.is_infinite``.
- ``deadline.as_sdk_timeout()`` — valeur pour ``httpx.timeout=``,
``pytesseract.timeout=``, etc. Nom explicite pour l'intention
au call-site adapter (Phase 3).
- ``deadline.clamp_to_remaining(seconds)`` — borne un délai.
- ``deadline.to_dict()`` / ``Deadline.from_dict(d)`` — sérialisation
IPC/JSON, utilisée par ``RunManifest`` à Phase 8 et par le
dispatch IPC du SubprocessExecutor à Phase 5.
## Validation
- 50/50 tests covering construction, query, cross-process semantics
(pickle + to_dict/from_dict avec sleep entre handoff prouvant la
non-facturation du dispatch), immutability, dunder protocols, et
patterns d'usage réalistes (retry loop, SDK timeout, backoff clamp).
- mypy strict sur ``picarones/domain/`` passe (annotation de classe
explicite des ``__slots__`` requise).
- Sprint narrative ratchet inchangé (formulation par intention, pas
par phase — l'historique vit dans CHANGELOG + ADR-0001).
- 181 tests architecture, 154 tests pipeline, 129 tests domain :
aucune régression.
Import canonique : ``from picarones.domain import Deadline``.
Pas exposé au top-level ``picarones`` — c'est un contrat interne
pour la couche pipeline + adapters, pas une API consommée par les
utilisateurs externes.
https://claude.ai/code/session_01B93huMjNh4CG2rNcexgDeL
- picarones/domain/__init__.py +3 -0
- picarones/domain/deadline.py +320 -0
- tests/domain/test_deadline.py +393 -0
|
@@ -46,6 +46,7 @@ from __future__ import annotations
|
|
| 46 |
from picarones.domain.artifact_key import ArtifactKey
|
| 47 |
from picarones.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 48 |
from picarones.domain.corpus import CorpusSpec
|
|
|
|
| 49 |
from picarones.domain.documents import DocumentRef, GroundTruthRef
|
| 50 |
from picarones.domain.errors import (
|
| 51 |
ArtifactValidationError,
|
|
@@ -91,6 +92,8 @@ __all__ = [
|
|
| 91 |
"CorpusSpec",
|
| 92 |
"DocumentRef",
|
| 93 |
"GroundTruthRef",
|
|
|
|
|
|
|
| 94 |
# S4 — Provenance
|
| 95 |
"ProvenanceRecord",
|
| 96 |
# S4 — Errors
|
|
|
|
| 46 |
from picarones.domain.artifact_key import ArtifactKey
|
| 47 |
from picarones.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 48 |
from picarones.domain.corpus import CorpusSpec
|
| 49 |
+
from picarones.domain.deadline import Deadline
|
| 50 |
from picarones.domain.documents import DocumentRef, GroundTruthRef
|
| 51 |
from picarones.domain.errors import (
|
| 52 |
ArtifactValidationError,
|
|
|
|
| 92 |
"CorpusSpec",
|
| 93 |
"DocumentRef",
|
| 94 |
"GroundTruthRef",
|
| 95 |
+
# Contrat de timeout propagé cross-process (cf. ADR-0001)
|
| 96 |
+
"Deadline",
|
| 97 |
# S4 — Provenance
|
| 98 |
"ProvenanceRecord",
|
| 99 |
# S4 — Errors
|
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``Deadline`` — type pur représentant une échéance d'opération.
|
| 2 |
+
|
| 3 |
+
Contrat de timeout propagé cross-process à travers la couche
|
| 4 |
+
adapters. Voir
|
| 5 |
+
[ADR-0001](../../docs/explanation/adr/0001-multi-domain-execution.md)
|
| 6 |
+
pour la motivation architecturale et la sémantique étendue
|
| 7 |
+
(multi-domaines d'exécution, terminaison structurée).
|
| 8 |
+
|
| 9 |
+
Sémantique
|
| 10 |
+
----------
|
| 11 |
+
|
| 12 |
+
Une ``Deadline`` est soit **infinie** (aucune contrainte temporelle),
|
| 13 |
+
soit **finie** avec une expiration absolue exprimée dans l'horloge
|
| 14 |
+
monotonic du process courant.
|
| 15 |
+
|
| 16 |
+
L'horloge monotonic (``time.monotonic()``) :
|
| 17 |
+
|
| 18 |
+
- ne recule jamais (à la différence de ``time.time()`` qui peut
|
| 19 |
+
sauter avec NTP),
|
| 20 |
+
- a une origine **arbitraire et privée au process** — l'instant
|
| 21 |
+
``t=0.0`` ne correspond à rien de comparable entre processes.
|
| 22 |
+
|
| 23 |
+
Cross-process : la sérialisation (pickle, JSON) convertit
|
| 24 |
+
automatiquement vers ``remaining_seconds`` au moment du transfert.
|
| 25 |
+
À la réception, une nouvelle ``Deadline`` est construite relativement
|
| 26 |
+
à l'horloge monotonic du process receveur. Conséquence : un overhead
|
| 27 |
+
de dispatch de 5 secondes n'est pas facturé au worker — le budget
|
| 28 |
+
qu'il voit est le budget restant au moment du handoff, pas le budget
|
| 29 |
+
original. Le wall-clock total peut donc dépasser le budget original
|
| 30 |
+
par l'overhead de dispatch.
|
| 31 |
+
|
| 32 |
+
Si un workflow nécessite une deadline **absolue cross-process**
|
| 33 |
+
(rare ; un soak test global par exemple), passer par wall-clock
|
| 34 |
+
(``time.time_ns()``) à la place — non couvert par ce type pour
|
| 35 |
+
éviter la confusion sémantique.
|
| 36 |
+
|
| 37 |
+
Usage typique
|
| 38 |
+
-------------
|
| 39 |
+
|
| 40 |
+
::
|
| 41 |
+
|
| 42 |
+
deadline = Deadline.in_seconds(60.0)
|
| 43 |
+
# ... travail ...
|
| 44 |
+
if deadline.is_expired():
|
| 45 |
+
raise DeadlineExceeded(...)
|
| 46 |
+
remaining = deadline.remaining_seconds() # 54.8
|
| 47 |
+
sdk_call(timeout=deadline.as_sdk_timeout())
|
| 48 |
+
|
| 49 |
+
API publique
|
| 50 |
+
------------
|
| 51 |
+
|
| 52 |
+
- ``Deadline.infinite()`` — pas d'échéance.
|
| 53 |
+
- ``Deadline.in_seconds(budget)`` — échéance dans ``budget`` secondes.
|
| 54 |
+
- ``Deadline.at_monotonic(t)`` — échéance à l'instant monotonic ``t``
|
| 55 |
+
(rare ; surtout pour les tests qui veulent une deadline déjà
|
| 56 |
+
expirée).
|
| 57 |
+
- ``deadline.remaining_seconds()`` — ``None`` si infinie, sinon
|
| 58 |
+
``max(0.0, expires - now)``.
|
| 59 |
+
- ``deadline.is_expired()`` — ``False`` si infinie, sinon
|
| 60 |
+
``now >= expires``.
|
| 61 |
+
- ``deadline.is_infinite`` — propriété booléenne.
|
| 62 |
+
- ``deadline.as_sdk_timeout()`` — valeur à passer à un SDK
|
| 63 |
+
(``httpx.timeout=``, ``pytesseract.timeout=``). Identique à
|
| 64 |
+
``remaining_seconds()`` ; nom explicite pour l'intention.
|
| 65 |
+
- ``deadline.clamp_to_remaining(seconds)`` — borne ``seconds`` par
|
| 66 |
+
le budget restant. Utile pour le retry backoff qui ne doit pas
|
| 67 |
+
dépasser la deadline.
|
| 68 |
+
|
| 69 |
+
Limitations assumées
|
| 70 |
+
--------------------
|
| 71 |
+
|
| 72 |
+
- Pas de hiérarchie de deadlines (sub-deadline d'un step plus serrée
|
| 73 |
+
que celle du doc) : le caller construit explicitement
|
| 74 |
+
``Deadline.in_seconds(min(step_budget, parent.remaining_seconds()))``.
|
| 75 |
+
- Pas de wall-clock — uniquement monotonic.
|
| 76 |
+
- Pas de notification d'expiration (pas de signal/callback). Les
|
| 77 |
+
consommateurs polling via ``is_expired()`` ou check via
|
| 78 |
+
``remaining_seconds()`` avant chaque opération bloquante.
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
from __future__ import annotations
|
| 82 |
+
|
| 83 |
+
import time
|
| 84 |
+
from typing import Any
|
| 85 |
+
|
| 86 |
+
from picarones.domain.errors import PicaronesError
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class Deadline:
|
| 90 |
+
"""Type valeur immuable représentant une échéance d'opération.
|
| 91 |
+
|
| 92 |
+
Voir le module docstring pour la sémantique complète et l'usage.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
# Annotation de classe nécessaire en plus de ``__slots__`` pour
|
| 96 |
+
# que mypy résolve les accès à l'attribut (l'assignation passe
|
| 97 |
+
# par ``object.__setattr__`` à cause de l'immutabilité, mypy ne
|
| 98 |
+
# peut pas l'inférer du seul ``__slots__``).
|
| 99 |
+
_expires_at_monotonic: float | None
|
| 100 |
+
__slots__ = ("_expires_at_monotonic",)
|
| 101 |
+
|
| 102 |
+
# Le constructeur public est ``__init__`` mais on attend qu'il
|
| 103 |
+
# soit appelé avec ``expires_at_monotonic: float | None`` — c'est
|
| 104 |
+
# un détail d'implémentation, les callers utilisent les
|
| 105 |
+
# classmethods (``infinite``, ``in_seconds``, ``at_monotonic``).
|
| 106 |
+
|
| 107 |
+
def __init__(self, expires_at_monotonic: float | None) -> None:
|
| 108 |
+
if expires_at_monotonic is not None and not isinstance(
|
| 109 |
+
expires_at_monotonic, (int, float),
|
| 110 |
+
):
|
| 111 |
+
raise PicaronesError(
|
| 112 |
+
f"Deadline : expires_at_monotonic doit être float ou None, "
|
| 113 |
+
f"reçu {type(expires_at_monotonic).__name__}",
|
| 114 |
+
)
|
| 115 |
+
object.__setattr__(
|
| 116 |
+
self, "_expires_at_monotonic",
|
| 117 |
+
float(expires_at_monotonic) if expires_at_monotonic is not None
|
| 118 |
+
else None,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# ──────────────────────────────────────────────────────────────────
|
| 122 |
+
# Constructeurs
|
| 123 |
+
# ──────────────────────────────────────────────────────────────────
|
| 124 |
+
|
| 125 |
+
@classmethod
|
| 126 |
+
def infinite(cls) -> "Deadline":
|
| 127 |
+
"""Pas d'échéance — ``remaining_seconds()`` retourne ``None``
|
| 128 |
+
et ``is_expired()`` retourne ``False``."""
|
| 129 |
+
return cls(expires_at_monotonic=None)
|
| 130 |
+
|
| 131 |
+
@classmethod
|
| 132 |
+
def in_seconds(cls, budget: float) -> "Deadline":
|
| 133 |
+
"""Échéance dans ``budget`` secondes à partir de maintenant.
|
| 134 |
+
|
| 135 |
+
``budget`` doit être > 0. Une valeur de 0 est rejetée parce
|
| 136 |
+
qu'elle représente une expiration immédiate inutile en
|
| 137 |
+
pratique (préfère ``Deadline.at_monotonic(0)`` pour tester
|
| 138 |
+
une deadline déjà expirée).
|
| 139 |
+
"""
|
| 140 |
+
if not isinstance(budget, (int, float)):
|
| 141 |
+
raise PicaronesError(
|
| 142 |
+
f"Deadline.in_seconds : budget doit être numérique, "
|
| 143 |
+
f"reçu {type(budget).__name__}",
|
| 144 |
+
)
|
| 145 |
+
if budget <= 0:
|
| 146 |
+
raise PicaronesError(
|
| 147 |
+
f"Deadline.in_seconds : budget doit être > 0 "
|
| 148 |
+
f"(reçu {budget}). Pour une deadline déjà expirée "
|
| 149 |
+
f"en test, utiliser ``Deadline.at_monotonic(0.0)``.",
|
| 150 |
+
)
|
| 151 |
+
return cls(expires_at_monotonic=time.monotonic() + float(budget))
|
| 152 |
+
|
| 153 |
+
@classmethod
|
| 154 |
+
def at_monotonic(cls, expires_at: float) -> "Deadline":
|
| 155 |
+
"""Échéance à l'instant monotonic absolu ``expires_at``.
|
| 156 |
+
|
| 157 |
+
Surtout utile en tests pour construire des deadlines déjà
|
| 158 |
+
expirées (``at_monotonic(0.0)``) ou très lointaines
|
| 159 |
+
(``at_monotonic(time.monotonic() + 3600)``).
|
| 160 |
+
"""
|
| 161 |
+
return cls(expires_at_monotonic=expires_at)
|
| 162 |
+
|
| 163 |
+
# ──────────────────────────────────────────────────────────────────
|
| 164 |
+
# Interrogation
|
| 165 |
+
# ──────────────────────────────────────────────────────────────────
|
| 166 |
+
|
| 167 |
+
@property
|
| 168 |
+
def is_infinite(self) -> bool:
|
| 169 |
+
return self._expires_at_monotonic is None
|
| 170 |
+
|
| 171 |
+
def remaining_seconds(self) -> float | None:
|
| 172 |
+
"""Secondes restantes avant expiration, ou ``None`` si infinie.
|
| 173 |
+
|
| 174 |
+
Ne retourne jamais de valeur négative : ``max(0.0, ...)``.
|
| 175 |
+
"""
|
| 176 |
+
if self._expires_at_monotonic is None:
|
| 177 |
+
return None
|
| 178 |
+
return max(0.0, self._expires_at_monotonic - time.monotonic())
|
| 179 |
+
|
| 180 |
+
def is_expired(self) -> bool:
|
| 181 |
+
if self._expires_at_monotonic is None:
|
| 182 |
+
return False
|
| 183 |
+
return time.monotonic() >= self._expires_at_monotonic
|
| 184 |
+
|
| 185 |
+
def as_sdk_timeout(self) -> float | None:
|
| 186 |
+
"""Valeur à passer à un SDK comme paramètre ``timeout=``.
|
| 187 |
+
|
| 188 |
+
Identique à ``remaining_seconds()`` ; le nom explicite
|
| 189 |
+
l'intention au call-site (``sdk.call(timeout=deadline.as_sdk_timeout())``).
|
| 190 |
+
"""
|
| 191 |
+
return self.remaining_seconds()
|
| 192 |
+
|
| 193 |
+
def clamp_to_remaining(self, seconds: float) -> float:
|
| 194 |
+
"""Retourne ``min(seconds, remaining_seconds())``.
|
| 195 |
+
|
| 196 |
+
Si infinie, retourne ``seconds`` tel quel. Utile pour borner
|
| 197 |
+
le backoff d'un retry helper qui ne doit pas dépasser le
|
| 198 |
+
budget restant.
|
| 199 |
+
|
| 200 |
+
``seconds`` doit être >= 0.
|
| 201 |
+
"""
|
| 202 |
+
if seconds < 0:
|
| 203 |
+
raise PicaronesError(
|
| 204 |
+
f"Deadline.clamp_to_remaining : seconds doit être >= 0 "
|
| 205 |
+
f"(reçu {seconds})",
|
| 206 |
+
)
|
| 207 |
+
if self._expires_at_monotonic is None:
|
| 208 |
+
return seconds
|
| 209 |
+
# ``remaining_seconds()`` retourne ``float`` ici (pas ``None``)
|
| 210 |
+
# puisqu'on vient de vérifier que la deadline est finie — le
|
| 211 |
+
# narrower de type est explicite pour mypy.
|
| 212 |
+
remaining = self.remaining_seconds()
|
| 213 |
+
assert remaining is not None
|
| 214 |
+
return min(seconds, remaining)
|
| 215 |
+
|
| 216 |
+
# ──────────────────────────────────────────────────────────────────
|
| 217 |
+
# Sérialisation cross-process
|
| 218 |
+
# ──────────────────────────────────────────────────────────────────
|
| 219 |
+
#
|
| 220 |
+
# ``expires_at_monotonic`` n'a aucun sens dans un autre process
|
| 221 |
+
# (chaque process a son origine monotonic privée). On sérialise
|
| 222 |
+
# toujours via ``remaining_seconds`` qui est, lui, transposable :
|
| 223 |
+
# à la réception, on reconstruit un ``Deadline`` relatif à
|
| 224 |
+
# l'horloge monotonic du process receveur.
|
| 225 |
+
|
| 226 |
+
def to_dict(self) -> dict[str, float | None]:
|
| 227 |
+
"""Forme sérialisable JSON / IPC.
|
| 228 |
+
|
| 229 |
+
``{"remaining_seconds": float | None}``. ``None`` = infinie.
|
| 230 |
+
"""
|
| 231 |
+
return {"remaining_seconds": self.remaining_seconds()}
|
| 232 |
+
|
| 233 |
+
@classmethod
|
| 234 |
+
def from_dict(cls, data: dict[str, Any]) -> "Deadline":
|
| 235 |
+
"""Reconstruit une ``Deadline`` depuis sa forme sérialisée.
|
| 236 |
+
|
| 237 |
+
Lève ``PicaronesError`` si ``data`` n'a pas la forme
|
| 238 |
+
attendue ou contient un type incorrect.
|
| 239 |
+
"""
|
| 240 |
+
if not isinstance(data, dict):
|
| 241 |
+
raise PicaronesError(
|
| 242 |
+
f"Deadline.from_dict : data doit être un dict, "
|
| 243 |
+
f"reçu {type(data).__name__}",
|
| 244 |
+
)
|
| 245 |
+
if "remaining_seconds" not in data:
|
| 246 |
+
raise PicaronesError(
|
| 247 |
+
"Deadline.from_dict : clé 'remaining_seconds' "
|
| 248 |
+
"manquante.",
|
| 249 |
+
)
|
| 250 |
+
r = data["remaining_seconds"]
|
| 251 |
+
if r is None:
|
| 252 |
+
return cls.infinite()
|
| 253 |
+
if not isinstance(r, (int, float)):
|
| 254 |
+
raise PicaronesError(
|
| 255 |
+
f"Deadline.from_dict : remaining_seconds doit être "
|
| 256 |
+
f"numérique ou None, reçu {type(r).__name__}",
|
| 257 |
+
)
|
| 258 |
+
if r <= 0:
|
| 259 |
+
# Une deadline reçue avec budget ≤ 0 est déjà expirée
|
| 260 |
+
# côté émetteur. On reconstruit une deadline expirée
|
| 261 |
+
# (pas d'erreur — c'est un état valide, juste rare).
|
| 262 |
+
return cls.at_monotonic(time.monotonic())
|
| 263 |
+
return cls.in_seconds(float(r))
|
| 264 |
+
|
| 265 |
+
# Pickle support : utilise le même protocole de transposition
|
| 266 |
+
# que ``to_dict`` / ``from_dict``.
|
| 267 |
+
|
| 268 |
+
def __getstate__(self) -> dict[str, float | None]:
|
| 269 |
+
return self.to_dict()
|
| 270 |
+
|
| 271 |
+
def __setstate__(self, state: dict[str, float | None]) -> None:
|
| 272 |
+
r = state.get("remaining_seconds")
|
| 273 |
+
if r is None:
|
| 274 |
+
object.__setattr__(self, "_expires_at_monotonic", None)
|
| 275 |
+
elif r <= 0:
|
| 276 |
+
# Cohérent avec ``from_dict`` : reconstruit comme expirée.
|
| 277 |
+
object.__setattr__(
|
| 278 |
+
self, "_expires_at_monotonic", time.monotonic(),
|
| 279 |
+
)
|
| 280 |
+
else:
|
| 281 |
+
object.__setattr__(
|
| 282 |
+
self, "_expires_at_monotonic", time.monotonic() + float(r),
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
# ──────────────────────────────────────────────────────────────────
|
| 286 |
+
# Dunder — immutabilité, égalité, repr
|
| 287 |
+
# ──────────────────────────────────────────────────────────────────
|
| 288 |
+
|
| 289 |
+
def __setattr__(self, name: str, value: Any) -> None:
|
| 290 |
+
raise AttributeError(
|
| 291 |
+
f"Deadline est immuable — impossible d'assigner {name!r}",
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
def __delattr__(self, name: str) -> None:
|
| 295 |
+
raise AttributeError(
|
| 296 |
+
f"Deadline est immuable — impossible de supprimer {name!r}",
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
def __eq__(self, other: object) -> bool:
|
| 300 |
+
if not isinstance(other, Deadline):
|
| 301 |
+
return NotImplemented
|
| 302 |
+
# Égalité **stricte** sur ``expires_at_monotonic`` — deux
|
| 303 |
+
# ``Deadline.in_seconds(60)`` créées à des instants différents
|
| 304 |
+
# ne sont PAS égales. L'égalité sur ``remaining_seconds()``
|
| 305 |
+
# serait dépendante du moment du check, donc instable.
|
| 306 |
+
return self._expires_at_monotonic == other._expires_at_monotonic
|
| 307 |
+
|
| 308 |
+
def __hash__(self) -> int:
|
| 309 |
+
return hash(self._expires_at_monotonic)
|
| 310 |
+
|
| 311 |
+
def __repr__(self) -> str:
|
| 312 |
+
if self._expires_at_monotonic is None:
|
| 313 |
+
return "Deadline.infinite()"
|
| 314 |
+
remaining = self.remaining_seconds()
|
| 315 |
+
if remaining is None or remaining <= 0.0:
|
| 316 |
+
return "Deadline(expired)"
|
| 317 |
+
return f"Deadline(remaining={remaining:.3f}s)"
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
__all__ = ["Deadline"]
|
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests de ``picarones.domain.deadline.Deadline``.
|
| 2 |
+
|
| 3 |
+
Phase 1 du chantier de refonte du modèle d'exécution. Le type est
|
| 4 |
+
pur (stdlib + PicaronesError uniquement), donc le suite couvre
|
| 5 |
+
exhaustivement :
|
| 6 |
+
|
| 7 |
+
- Construction (infinite, in_seconds, at_monotonic) + rejets.
|
| 8 |
+
- Interrogation (remaining_seconds, is_expired, is_infinite,
|
| 9 |
+
as_sdk_timeout, clamp_to_remaining).
|
| 10 |
+
- Sérialisation cross-process (to_dict / from_dict / pickle).
|
| 11 |
+
- Immutabilité (setattr, delattr).
|
| 12 |
+
- Égalité / hash / repr.
|
| 13 |
+
- Garanties critiques cross-process (l'overhead de dispatch ne
|
| 14 |
+
facture pas le worker).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import pickle
|
| 20 |
+
import time
|
| 21 |
+
|
| 22 |
+
import pytest
|
| 23 |
+
|
| 24 |
+
from picarones.domain.deadline import Deadline
|
| 25 |
+
from picarones.domain.errors import PicaronesError
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 29 |
+
# Construction
|
| 30 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TestConstruction:
|
| 34 |
+
def test_infinite_is_infinite(self) -> None:
|
| 35 |
+
d = Deadline.infinite()
|
| 36 |
+
assert d.is_infinite is True
|
| 37 |
+
assert d.remaining_seconds() is None
|
| 38 |
+
assert d.is_expired() is False
|
| 39 |
+
|
| 40 |
+
def test_in_seconds_positive_budget(self) -> None:
|
| 41 |
+
d = Deadline.in_seconds(60.0)
|
| 42 |
+
assert d.is_infinite is False
|
| 43 |
+
# Marge de scheduling : remaining doit être ~60s mais peut
|
| 44 |
+
# avoir perdu quelques ms entre construction et check.
|
| 45 |
+
r = d.remaining_seconds()
|
| 46 |
+
assert r is not None
|
| 47 |
+
assert 59.0 < r <= 60.0
|
| 48 |
+
|
| 49 |
+
def test_in_seconds_int_budget_accepted(self) -> None:
|
| 50 |
+
"""int est coercé en float."""
|
| 51 |
+
d = Deadline.in_seconds(30)
|
| 52 |
+
assert d.is_infinite is False
|
| 53 |
+
assert d.remaining_seconds() is not None
|
| 54 |
+
|
| 55 |
+
def test_in_seconds_zero_rejected(self) -> None:
|
| 56 |
+
with pytest.raises(PicaronesError, match="budget doit être > 0"):
|
| 57 |
+
Deadline.in_seconds(0)
|
| 58 |
+
|
| 59 |
+
def test_in_seconds_negative_rejected(self) -> None:
|
| 60 |
+
with pytest.raises(PicaronesError, match="budget doit être > 0"):
|
| 61 |
+
Deadline.in_seconds(-1.0)
|
| 62 |
+
|
| 63 |
+
def test_in_seconds_non_numeric_rejected(self) -> None:
|
| 64 |
+
with pytest.raises(PicaronesError, match="budget doit être numérique"):
|
| 65 |
+
Deadline.in_seconds("60") # type: ignore[arg-type]
|
| 66 |
+
|
| 67 |
+
def test_at_monotonic_in_past_is_expired(self) -> None:
|
| 68 |
+
"""Une deadline absolue dans le passé est valide ET expirée."""
|
| 69 |
+
d = Deadline.at_monotonic(0.0)
|
| 70 |
+
assert d.is_expired() is True
|
| 71 |
+
assert d.remaining_seconds() == 0.0
|
| 72 |
+
|
| 73 |
+
def test_at_monotonic_in_future_not_expired(self) -> None:
|
| 74 |
+
d = Deadline.at_monotonic(time.monotonic() + 100.0)
|
| 75 |
+
assert d.is_expired() is False
|
| 76 |
+
assert d.remaining_seconds() > 0
|
| 77 |
+
|
| 78 |
+
def test_direct_constructor_rejects_wrong_type(self) -> None:
|
| 79 |
+
with pytest.raises(PicaronesError, match="doit être float ou None"):
|
| 80 |
+
Deadline(expires_at_monotonic="now") # type: ignore[arg-type]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 84 |
+
# Interrogation
|
| 85 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TestQuery:
|
| 89 |
+
def test_remaining_seconds_never_negative(self) -> None:
|
| 90 |
+
"""Une deadline expirée retourne 0.0, pas une valeur négative."""
|
| 91 |
+
d = Deadline.at_monotonic(0.0)
|
| 92 |
+
assert d.remaining_seconds() == 0.0
|
| 93 |
+
|
| 94 |
+
def test_is_expired_transitions_after_time_passes(self) -> None:
|
| 95 |
+
d = Deadline.in_seconds(0.05) # 50ms
|
| 96 |
+
assert d.is_expired() is False
|
| 97 |
+
time.sleep(0.1)
|
| 98 |
+
assert d.is_expired() is True
|
| 99 |
+
|
| 100 |
+
def test_as_sdk_timeout_matches_remaining_seconds(self) -> None:
|
| 101 |
+
d = Deadline.in_seconds(10.0)
|
| 102 |
+
# Les deux peuvent légèrement différer si appelés à des
|
| 103 |
+
# instants différents — on vérifie qu'ils sont du même ordre.
|
| 104 |
+
a = d.as_sdk_timeout()
|
| 105 |
+
b = d.remaining_seconds()
|
| 106 |
+
assert a is not None and b is not None
|
| 107 |
+
assert abs(a - b) < 0.1
|
| 108 |
+
|
| 109 |
+
def test_as_sdk_timeout_infinite_returns_none(self) -> None:
|
| 110 |
+
assert Deadline.infinite().as_sdk_timeout() is None
|
| 111 |
+
|
| 112 |
+
def test_clamp_to_remaining_clamps_when_seconds_exceeds(self) -> None:
|
| 113 |
+
d = Deadline.in_seconds(0.5)
|
| 114 |
+
clamped = d.clamp_to_remaining(10.0)
|
| 115 |
+
assert clamped <= 0.5
|
| 116 |
+
|
| 117 |
+
def test_clamp_to_remaining_returns_seconds_when_under(self) -> None:
|
| 118 |
+
d = Deadline.in_seconds(60.0)
|
| 119 |
+
assert d.clamp_to_remaining(1.0) == 1.0
|
| 120 |
+
|
| 121 |
+
def test_clamp_to_remaining_infinite_returns_seconds(self) -> None:
|
| 122 |
+
assert Deadline.infinite().clamp_to_remaining(5.0) == 5.0
|
| 123 |
+
|
| 124 |
+
def test_clamp_to_remaining_rejects_negative(self) -> None:
|
| 125 |
+
d = Deadline.in_seconds(10.0)
|
| 126 |
+
with pytest.raises(PicaronesError, match="seconds doit être >= 0"):
|
| 127 |
+
d.clamp_to_remaining(-1.0)
|
| 128 |
+
|
| 129 |
+
def test_clamp_to_remaining_zero_is_zero(self) -> None:
|
| 130 |
+
"""clamp(0) = 0 — pas d'erreur, c'est un cas valide
|
| 131 |
+
(ex : pas de wait avant retry)."""
|
| 132 |
+
d = Deadline.in_seconds(10.0)
|
| 133 |
+
assert d.clamp_to_remaining(0.0) == 0.0
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 137 |
+
# Sérialisation cross-process — propriété critique
|
| 138 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class TestCrossProcessSerialization:
|
| 142 |
+
"""La promesse centrale : un overhead de dispatch (sleep entre
|
| 143 |
+
sérialisation et désérialisation) n'est PAS facturé au worker.
|
| 144 |
+
Le budget que voit le worker est le budget restant *au moment du
|
| 145 |
+
handoff*, pas le budget original.
|
| 146 |
+
|
| 147 |
+
C'est la propriété qui rend ``Deadline`` utilisable cross-process
|
| 148 |
+
et qui justifie de ne PAS sérialiser ``expires_at_monotonic``.
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
def test_to_dict_infinite(self) -> None:
|
| 152 |
+
d = Deadline.infinite()
|
| 153 |
+
assert d.to_dict() == {"remaining_seconds": None}
|
| 154 |
+
|
| 155 |
+
def test_to_dict_finite(self) -> None:
|
| 156 |
+
d = Deadline.in_seconds(60.0)
|
| 157 |
+
out = d.to_dict()
|
| 158 |
+
assert "remaining_seconds" in out
|
| 159 |
+
assert 59.0 < out["remaining_seconds"] <= 60.0
|
| 160 |
+
|
| 161 |
+
def test_from_dict_infinite(self) -> None:
|
| 162 |
+
d = Deadline.from_dict({"remaining_seconds": None})
|
| 163 |
+
assert d.is_infinite is True
|
| 164 |
+
|
| 165 |
+
def test_from_dict_finite(self) -> None:
|
| 166 |
+
d = Deadline.from_dict({"remaining_seconds": 30.0})
|
| 167 |
+
r = d.remaining_seconds()
|
| 168 |
+
assert r is not None
|
| 169 |
+
assert 29.0 < r <= 30.0
|
| 170 |
+
|
| 171 |
+
def test_from_dict_negative_treated_as_expired(self) -> None:
|
| 172 |
+
d = Deadline.from_dict({"remaining_seconds": -5.0})
|
| 173 |
+
assert d.is_expired() is True
|
| 174 |
+
|
| 175 |
+
def test_from_dict_zero_treated_as_expired(self) -> None:
|
| 176 |
+
d = Deadline.from_dict({"remaining_seconds": 0.0})
|
| 177 |
+
assert d.is_expired() is True
|
| 178 |
+
|
| 179 |
+
def test_from_dict_rejects_non_dict(self) -> None:
|
| 180 |
+
with pytest.raises(PicaronesError, match="doit être un dict"):
|
| 181 |
+
Deadline.from_dict("60") # type: ignore[arg-type]
|
| 182 |
+
|
| 183 |
+
def test_from_dict_rejects_missing_key(self) -> None:
|
| 184 |
+
with pytest.raises(PicaronesError, match="clé 'remaining_seconds'"):
|
| 185 |
+
Deadline.from_dict({"other": 1})
|
| 186 |
+
|
| 187 |
+
def test_from_dict_rejects_non_numeric(self) -> None:
|
| 188 |
+
with pytest.raises(PicaronesError, match="doit être numérique"):
|
| 189 |
+
Deadline.from_dict({"remaining_seconds": "60"})
|
| 190 |
+
|
| 191 |
+
def test_dispatch_overhead_not_charged_to_worker(self) -> None:
|
| 192 |
+
"""Propriété critique : créer une deadline de 60s, simuler
|
| 193 |
+
un dispatch (sleep 0.5s), désérialiser → le receveur voit
|
| 194 |
+
un budget *non* réduit par le sleep, parce qu'on transmet
|
| 195 |
+
le ``remaining_seconds`` au moment du handoff, pas du
|
| 196 |
+
moment de la création."""
|
| 197 |
+
original = Deadline.in_seconds(60.0)
|
| 198 |
+
# On capture le serialized maintenant (= au moment du handoff
|
| 199 |
+
# côté émetteur).
|
| 200 |
+
serialized = original.to_dict()
|
| 201 |
+
assert 59.5 < serialized["remaining_seconds"] <= 60.0
|
| 202 |
+
|
| 203 |
+
# Simule un long dispatch (le worker met 5s à recevoir).
|
| 204 |
+
time.sleep(0.5)
|
| 205 |
+
|
| 206 |
+
# Le worker reconstruit depuis le payload reçu. Comme on
|
| 207 |
+
# repart de l'instant *receveur*, le budget restant est
|
| 208 |
+
# ~remaining_seconds du moment du handoff, indépendant du
|
| 209 |
+
# délai de transport.
|
| 210 |
+
reconstructed = Deadline.from_dict(serialized)
|
| 211 |
+
r = reconstructed.remaining_seconds()
|
| 212 |
+
assert r is not None
|
| 213 |
+
# Le receveur voit ~remaining_seconds(handoff) - 0 (il vient
|
| 214 |
+
# de le reconstruire).
|
| 215 |
+
assert 59.0 < r <= 60.0, (
|
| 216 |
+
f"reconstructed remaining={r} : le dispatch overhead "
|
| 217 |
+
f"a été facturé alors qu'il ne devrait pas"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
def test_pickle_roundtrip_infinite(self) -> None:
|
| 221 |
+
d = Deadline.infinite()
|
| 222 |
+
restored = pickle.loads(pickle.dumps(d))
|
| 223 |
+
assert restored.is_infinite is True
|
| 224 |
+
|
| 225 |
+
def test_pickle_roundtrip_finite(self) -> None:
|
| 226 |
+
d = Deadline.in_seconds(45.0)
|
| 227 |
+
restored = pickle.loads(pickle.dumps(d))
|
| 228 |
+
r = restored.remaining_seconds()
|
| 229 |
+
assert r is not None
|
| 230 |
+
assert 44.0 < r <= 45.0
|
| 231 |
+
|
| 232 |
+
def test_pickle_preserves_handoff_semantics(self) -> None:
|
| 233 |
+
"""Même propriété que ``test_dispatch_overhead_not_charged``
|
| 234 |
+
mais via pickle (le canal réel cross-process).
|
| 235 |
+
"""
|
| 236 |
+
d = Deadline.in_seconds(30.0)
|
| 237 |
+
payload = pickle.dumps(d)
|
| 238 |
+
time.sleep(0.3)
|
| 239 |
+
restored = pickle.loads(payload)
|
| 240 |
+
r = restored.remaining_seconds()
|
| 241 |
+
assert r is not None
|
| 242 |
+
# On veut voir ~30s, pas ~29.7s — la sleep n'a pas mangé
|
| 243 |
+
# le budget.
|
| 244 |
+
assert 29.5 < r <= 30.0
|
| 245 |
+
|
| 246 |
+
def test_pickle_expired_stays_expired(self) -> None:
|
| 247 |
+
d = Deadline.at_monotonic(0.0)
|
| 248 |
+
assert d.is_expired() is True
|
| 249 |
+
restored = pickle.loads(pickle.dumps(d))
|
| 250 |
+
assert restored.is_expired() is True
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 254 |
+
# Immutabilité
|
| 255 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
class TestImmutability:
|
| 259 |
+
def test_setattr_raises(self) -> None:
|
| 260 |
+
d = Deadline.in_seconds(10.0)
|
| 261 |
+
with pytest.raises(AttributeError, match="immuable"):
|
| 262 |
+
d._expires_at_monotonic = 0.0
|
| 263 |
+
|
| 264 |
+
def test_setattr_new_field_raises(self) -> None:
|
| 265 |
+
d = Deadline.in_seconds(10.0)
|
| 266 |
+
with pytest.raises(AttributeError, match="immuable"):
|
| 267 |
+
d.new_field = "x" # type: ignore[attr-defined]
|
| 268 |
+
|
| 269 |
+
def test_delattr_raises(self) -> None:
|
| 270 |
+
d = Deadline.in_seconds(10.0)
|
| 271 |
+
with pytest.raises(AttributeError, match="immuable"):
|
| 272 |
+
del d._expires_at_monotonic
|
| 273 |
+
|
| 274 |
+
def test_slots_no_dict(self) -> None:
|
| 275 |
+
"""``__slots__`` empêche la création d'un ``__dict__``
|
| 276 |
+
(économie mémoire, pas de mutation accidentelle)."""
|
| 277 |
+
d = Deadline.in_seconds(10.0)
|
| 278 |
+
assert not hasattr(d, "__dict__")
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 282 |
+
# Égalité, hash, repr
|
| 283 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
class TestDunderProtocols:
|
| 287 |
+
def test_infinite_equals_infinite(self) -> None:
|
| 288 |
+
assert Deadline.infinite() == Deadline.infinite()
|
| 289 |
+
|
| 290 |
+
def test_infinite_not_equals_finite(self) -> None:
|
| 291 |
+
assert Deadline.infinite() != Deadline.in_seconds(10.0)
|
| 292 |
+
|
| 293 |
+
def test_same_absolute_deadline_is_equal(self) -> None:
|
| 294 |
+
"""Deux ``Deadline`` construites à partir du même
|
| 295 |
+
``expires_at_monotonic`` absolu sont égales."""
|
| 296 |
+
t = time.monotonic() + 100.0
|
| 297 |
+
assert Deadline.at_monotonic(t) == Deadline.at_monotonic(t)
|
| 298 |
+
|
| 299 |
+
def test_two_in_seconds_calls_not_equal(self) -> None:
|
| 300 |
+
"""Deux appels successifs à ``in_seconds(60)`` créent des
|
| 301 |
+
deadlines avec des ``expires_at_monotonic`` *différents*
|
| 302 |
+
(même de quelques µs). Égalité stricte → non égales.
|
| 303 |
+
Documenté dans le code source.
|
| 304 |
+
"""
|
| 305 |
+
d1 = Deadline.in_seconds(60.0)
|
| 306 |
+
d2 = Deadline.in_seconds(60.0)
|
| 307 |
+
# Théoriquement collisions possibles si time.monotonic() ne
|
| 308 |
+
# bouge pas entre les deux appels, mais ultra rare en
|
| 309 |
+
# pratique. Le test peut flaquer ultra rarement — on évite
|
| 310 |
+
# un assert strict ici.
|
| 311 |
+
# On vérifie au moins qu'il ne sont pas systématiquement
|
| 312 |
+
# égaux (i.e. l'égalité n'est pas sur le budget mais sur
|
| 313 |
+
# l'instant absolu).
|
| 314 |
+
if d1._expires_at_monotonic != d2._expires_at_monotonic:
|
| 315 |
+
assert d1 != d2
|
| 316 |
+
|
| 317 |
+
def test_eq_with_non_deadline_returns_not_implemented(self) -> None:
|
| 318 |
+
d = Deadline.infinite()
|
| 319 |
+
# Python protocole __eq__ : si NotImplemented, fallback à
|
| 320 |
+
# ``is``. Donc != est True, pas une exception.
|
| 321 |
+
assert (d == "infinite") is False
|
| 322 |
+
assert (d == 42) is False
|
| 323 |
+
|
| 324 |
+
def test_hashable(self) -> None:
|
| 325 |
+
"""``Deadline`` doit être hashable pour pouvoir être utilisée
|
| 326 |
+
comme clé de dict ou élément de set."""
|
| 327 |
+
s = {Deadline.infinite(), Deadline.in_seconds(10.0)}
|
| 328 |
+
assert len(s) == 2
|
| 329 |
+
|
| 330 |
+
def test_hash_consistent_with_eq(self) -> None:
|
| 331 |
+
"""Si ``a == b`` alors ``hash(a) == hash(b)``."""
|
| 332 |
+
t = time.monotonic() + 50.0
|
| 333 |
+
a = Deadline.at_monotonic(t)
|
| 334 |
+
b = Deadline.at_monotonic(t)
|
| 335 |
+
assert a == b
|
| 336 |
+
assert hash(a) == hash(b)
|
| 337 |
+
|
| 338 |
+
def test_repr_infinite(self) -> None:
|
| 339 |
+
assert repr(Deadline.infinite()) == "Deadline.infinite()"
|
| 340 |
+
|
| 341 |
+
def test_repr_finite_shows_remaining(self) -> None:
|
| 342 |
+
d = Deadline.in_seconds(42.5)
|
| 343 |
+
r = repr(d)
|
| 344 |
+
assert "remaining" in r
|
| 345 |
+
assert "42" in r
|
| 346 |
+
|
| 347 |
+
def test_repr_expired(self) -> None:
|
| 348 |
+
d = Deadline.at_monotonic(0.0)
|
| 349 |
+
assert repr(d) == "Deadline(expired)"
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 353 |
+
# Smoke : la deadline est utilisable dans des patterns réalistes
|
| 354 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
class TestRealWorldUsage:
|
| 358 |
+
def test_retry_loop_respects_deadline(self) -> None:
|
| 359 |
+
"""Simule un retry helper qui doit s'arrêter quand la
|
| 360 |
+
deadline expire — pattern qui sera implémenté en Phase 3
|
| 361 |
+
dans ``adapters/_retry.py``."""
|
| 362 |
+
deadline = Deadline.in_seconds(0.15) # 150ms
|
| 363 |
+
attempts = 0
|
| 364 |
+
while not deadline.is_expired():
|
| 365 |
+
attempts += 1
|
| 366 |
+
time.sleep(0.05)
|
| 367 |
+
# Avec budget 150ms et sleep 50ms : ~3 tentatives.
|
| 368 |
+
assert 2 <= attempts <= 4
|
| 369 |
+
|
| 370 |
+
def test_clamp_for_retry_backoff(self) -> None:
|
| 371 |
+
"""Le retry helper veut attendre ``base ** attempt`` secondes
|
| 372 |
+
mais ne doit jamais dépasser le budget restant. Pattern
|
| 373 |
+
Phase 3."""
|
| 374 |
+
deadline = Deadline.in_seconds(2.0)
|
| 375 |
+
# Backoff "naïf" : 5 secondes. Le clamp doit le borner.
|
| 376 |
+
wait = deadline.clamp_to_remaining(5.0)
|
| 377 |
+
assert wait <= 2.0
|
| 378 |
+
assert wait > 1.5 # Bornes : on n'a pas perdu beaucoup de temps
|
| 379 |
+
|
| 380 |
+
def test_sdk_timeout_pattern(self) -> None:
|
| 381 |
+
"""Pattern call-site adapter : passer la deadline au SDK
|
| 382 |
+
comme paramètre ``timeout=``."""
|
| 383 |
+
deadline = Deadline.in_seconds(10.0)
|
| 384 |
+
# En vrai : ``client.call(url, timeout=deadline.as_sdk_timeout())``
|
| 385 |
+
sdk_timeout = deadline.as_sdk_timeout()
|
| 386 |
+
assert sdk_timeout is not None
|
| 387 |
+
assert sdk_timeout > 9.0
|
| 388 |
+
|
| 389 |
+
def test_infinite_deadline_passes_none_to_sdk(self) -> None:
|
| 390 |
+
"""Une deadline infinie produit ``None`` ; la plupart des
|
| 391 |
+
SDKs (httpx, requests, pytesseract) interprètent ``None``
|
| 392 |
+
comme 'pas de timeout', ce qui est le comportement voulu."""
|
| 393 |
+
assert Deadline.infinite().as_sdk_timeout() is None
|