goodput-http 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- goodput/CLAUDE.md +115 -0
- goodput/__init__.py +139 -0
- goodput/api.py +33 -0
- goodput/checkpoint.py +88 -0
- goodput/circuit.py +139 -0
- goodput/classify.py +191 -0
- goodput/cli.py +178 -0
- goodput/clock.py +61 -0
- goodput/config.py +323 -0
- goodput/control/__init__.py +9 -0
- goodput/control/aimd.py +112 -0
- goodput/control/base.py +109 -0
- goodput/control/gradient.py +83 -0
- goodput/control_mode.py +268 -0
- goodput/engine.py +588 -0
- goodput/events.py +79 -0
- goodput/exceptions.py +35 -0
- goodput/models.py +217 -0
- goodput/plugins.py +63 -0
- goodput/py.typed +0 -0
- goodput/ratelimit.py +140 -0
- goodput/redaction.py +103 -0
- goodput/report.py +261 -0
- goodput/retry.py +146 -0
- goodput/retry_after.py +105 -0
- goodput/routing/__init__.py +26 -0
- goodput/routing/health.py +138 -0
- goodput/routing/route.py +110 -0
- goodput/routing/selector.py +143 -0
- goodput/scope.py +64 -0
- goodput/sim.py +111 -0
- goodput/sinks/__init__.py +9 -0
- goodput/sinks/base.py +34 -0
- goodput/sinks/jsonl.py +76 -0
- goodput/sinks/memory.py +46 -0
- goodput/sources.py +126 -0
- goodput/stats.py +182 -0
- goodput/transport.py +158 -0
- goodput_http-0.1.0.dist-info/METADATA +222 -0
- goodput_http-0.1.0.dist-info/RECORD +43 -0
- goodput_http-0.1.0.dist-info/WHEEL +4 -0
- goodput_http-0.1.0.dist-info/entry_points.txt +10 -0
- goodput_http-0.1.0.dist-info/licenses/LICENSE +19 -0
goodput/classify.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Classification of attempts into success / throttle / error (brief §14).
|
|
2
|
+
|
|
3
|
+
Classification is fully pluggable. The engine runs, in order:
|
|
4
|
+
|
|
5
|
+
1. a :class:`ThrottleClassifier` (does this attempt evidence throttling?),
|
|
6
|
+
2. a :class:`SuccessClassifier` (is this a logical success?),
|
|
7
|
+
3. an :class:`ErrorClassifier` (if not success, what kind of error, retryable?).
|
|
8
|
+
|
|
9
|
+
Default implementations cover the common cases (2xx success, 429/Retry-After
|
|
10
|
+
throttle, 5xx retryable). Users supply their own by implementing the protocols
|
|
11
|
+
or passing predicate callbacks to :class:`Classifiers.build`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
from .models import Classification, ErrorClass, Outcome, ThrottleSignal
|
|
21
|
+
from .retry_after import parse_rate_limit_headers, parse_retry_after
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ResponseView:
|
|
26
|
+
"""The minimal, transport-agnostic view classifiers operate on.
|
|
27
|
+
|
|
28
|
+
Keeping this separate from httpx's ``Response`` lets simulations and tests
|
|
29
|
+
classify without a real HTTP layer, and keeps body access bounded.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
status_code: int | None
|
|
33
|
+
headers: Mapping[str, str]
|
|
34
|
+
elapsed: float
|
|
35
|
+
body_preview: bytes = b""
|
|
36
|
+
error_class: ErrorClass = ErrorClass.NONE
|
|
37
|
+
error_message: str | None = None
|
|
38
|
+
now_epoch: float | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# A user predicate may be sync or async and returns a bool or a Classification.
|
|
42
|
+
SyncPredicate = Callable[[ResponseView], bool]
|
|
43
|
+
ThrottlePredicate = Callable[[ResponseView], ThrottleSignal | None]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SuccessClassifier(Protocol):
|
|
47
|
+
def __call__(self, view: ResponseView) -> bool: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ThrottleClassifier(Protocol):
|
|
51
|
+
def __call__(self, view: ResponseView) -> ThrottleSignal | None: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ErrorClassifier(Protocol):
|
|
55
|
+
def __call__(self, view: ResponseView) -> tuple[ErrorClass, bool]:
|
|
56
|
+
"""Return ``(error_class, retryable)``."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# -- defaults ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def default_success(view: ResponseView, *, ok_statuses: range = range(200, 300)) -> bool:
|
|
64
|
+
return view.status_code is not None and view.status_code in ok_statuses
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def default_throttle(
|
|
68
|
+
view: ResponseView,
|
|
69
|
+
*,
|
|
70
|
+
throttle_statuses: frozenset[int] = frozenset({429}),
|
|
71
|
+
) -> ThrottleSignal | None:
|
|
72
|
+
"""Detect throttling from status + Retry-After + rate-limit headers."""
|
|
73
|
+
evidence: list[str] = []
|
|
74
|
+
throttled = False
|
|
75
|
+
retry_after: float | None = None
|
|
76
|
+
reset_at: float | None = None
|
|
77
|
+
|
|
78
|
+
if view.status_code in throttle_statuses:
|
|
79
|
+
throttled = True
|
|
80
|
+
evidence.append(f"status={view.status_code}")
|
|
81
|
+
|
|
82
|
+
ra = parse_retry_after(_get(view.headers, "retry-after"), now_epoch=view.now_epoch)
|
|
83
|
+
if ra is not None:
|
|
84
|
+
retry_after = ra
|
|
85
|
+
evidence.append(f"retry-after={ra:g}s")
|
|
86
|
+
# Retry-After on a 503 is a strong throttle/overload signal too.
|
|
87
|
+
if view.status_code == 503:
|
|
88
|
+
throttled = True
|
|
89
|
+
|
|
90
|
+
rl = parse_rate_limit_headers(view.headers, now_epoch=view.now_epoch)
|
|
91
|
+
if rl is not None and rl.exhausted:
|
|
92
|
+
throttled = True
|
|
93
|
+
reset_at = rl.reset_seconds
|
|
94
|
+
evidence.append(f"ratelimit-remaining=0 ({rl.source})")
|
|
95
|
+
|
|
96
|
+
if not throttled:
|
|
97
|
+
return None
|
|
98
|
+
return ThrottleSignal(
|
|
99
|
+
throttled=True,
|
|
100
|
+
confidence=1.0 if view.status_code in throttle_statuses else 0.7,
|
|
101
|
+
retry_after=retry_after,
|
|
102
|
+
reset_at=reset_at,
|
|
103
|
+
evidence=tuple(evidence),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def default_error(view: ResponseView) -> tuple[ErrorClass, bool]:
|
|
108
|
+
"""Classify a non-success, non-throttle attempt and whether it is retryable."""
|
|
109
|
+
if view.error_class is not ErrorClass.NONE:
|
|
110
|
+
# Transport-level error already classified upstream.
|
|
111
|
+
retryable = view.error_class in {
|
|
112
|
+
ErrorClass.DNS,
|
|
113
|
+
ErrorClass.CONNECT,
|
|
114
|
+
ErrorClass.TLS,
|
|
115
|
+
ErrorClass.PROXY,
|
|
116
|
+
ErrorClass.TIMEOUT,
|
|
117
|
+
ErrorClass.READ,
|
|
118
|
+
}
|
|
119
|
+
return view.error_class, retryable
|
|
120
|
+
sc = view.status_code
|
|
121
|
+
if sc is None:
|
|
122
|
+
return ErrorClass.UNKNOWN, True
|
|
123
|
+
if sc in (401, 403):
|
|
124
|
+
return ErrorClass.AUTH, False
|
|
125
|
+
if 500 <= sc < 600:
|
|
126
|
+
return ErrorClass.HTTP_STATUS, True # transient server error → retryable
|
|
127
|
+
if 400 <= sc < 500:
|
|
128
|
+
return ErrorClass.HTTP_STATUS, False # client error → not retryable
|
|
129
|
+
return ErrorClass.HTTP_STATUS, False
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _get(headers: Mapping[str, str], key: str) -> str | None:
|
|
133
|
+
key = key.lower()
|
|
134
|
+
for k, v in headers.items():
|
|
135
|
+
if k.lower() == key:
|
|
136
|
+
return v
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class Classifiers:
|
|
142
|
+
"""Bundle of the three classifier stages used by the engine."""
|
|
143
|
+
|
|
144
|
+
success: SuccessClassifier
|
|
145
|
+
throttle: ThrottleClassifier
|
|
146
|
+
error: ErrorClassifier
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def default(cls) -> Classifiers:
|
|
150
|
+
return cls(success=default_success, throttle=default_throttle, error=default_error)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def build(
|
|
154
|
+
cls,
|
|
155
|
+
*,
|
|
156
|
+
ok_statuses: Sequence[int] | None = None,
|
|
157
|
+
success: SuccessClassifier | None = None,
|
|
158
|
+
throttle: ThrottleClassifier | None = None,
|
|
159
|
+
error: ErrorClassifier | None = None,
|
|
160
|
+
) -> Classifiers:
|
|
161
|
+
"""Build classifiers from common overrides without writing classes."""
|
|
162
|
+
succ = success
|
|
163
|
+
if succ is None and ok_statuses is not None:
|
|
164
|
+
ok = frozenset(ok_statuses)
|
|
165
|
+
succ = lambda v: v.status_code is not None and v.status_code in ok # noqa: E731
|
|
166
|
+
return cls(
|
|
167
|
+
success=succ or default_success,
|
|
168
|
+
throttle=throttle or default_throttle,
|
|
169
|
+
error=error or default_error,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def classify(self, view: ResponseView) -> Classification:
|
|
173
|
+
"""Run all stages and produce a single :class:`Classification`."""
|
|
174
|
+
throttle = self.throttle(view)
|
|
175
|
+
if throttle is not None and throttle.throttled:
|
|
176
|
+
return Classification(
|
|
177
|
+
outcome=Outcome.THROTTLED,
|
|
178
|
+
error_class=ErrorClass.THROTTLE,
|
|
179
|
+
retryable=True,
|
|
180
|
+
throttle=throttle,
|
|
181
|
+
reason="throttle classifier matched",
|
|
182
|
+
)
|
|
183
|
+
if self.success(view):
|
|
184
|
+
return Classification(outcome=Outcome.SUCCESS, reason="success classifier matched")
|
|
185
|
+
error_class, retryable = self.error(view)
|
|
186
|
+
return Classification(
|
|
187
|
+
outcome=Outcome.FAILURE,
|
|
188
|
+
error_class=error_class,
|
|
189
|
+
retryable=retryable,
|
|
190
|
+
reason=f"error classifier: {error_class.value} (retryable={retryable})",
|
|
191
|
+
)
|
goodput/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Command-line interface (brief §33).
|
|
2
|
+
|
|
3
|
+
Implemented with the standard library ``argparse`` so the CLI works without any
|
|
4
|
+
optional dependency. Commands:
|
|
5
|
+
|
|
6
|
+
* ``validate`` — validate a config file / inline options and print the dry-run plan.
|
|
7
|
+
* ``run`` — execute a repeated-endpoint or JSONL workload.
|
|
8
|
+
* ``plugins`` — list discovered plugins.
|
|
9
|
+
* ``version`` — print the version.
|
|
10
|
+
|
|
11
|
+
Secrets are never echoed; the CLI discourages putting secrets on the command
|
|
12
|
+
line (brief §33) and supports machine-readable JSON output and no-color mode.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
from .api import run as _run
|
|
25
|
+
from .config import EngineConfig, Mode, load_config
|
|
26
|
+
from .control_mode import bounded_adaptive, fixed
|
|
27
|
+
from .models import Request
|
|
28
|
+
from .plugins import PluginRegistry
|
|
29
|
+
from .sources import from_jsonl, repeat
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_config(args: argparse.Namespace) -> EngineConfig:
|
|
33
|
+
overrides: dict[str, object] = {}
|
|
34
|
+
if args.mode:
|
|
35
|
+
overrides["mode"] = Mode(args.mode)
|
|
36
|
+
if args.timeout:
|
|
37
|
+
overrides["default_timeout"] = args.timeout
|
|
38
|
+
cfg = (
|
|
39
|
+
load_config(Path(args.config), overrides=overrides)
|
|
40
|
+
if args.config
|
|
41
|
+
else EngineConfig(**overrides)
|
|
42
|
+
)
|
|
43
|
+
# Concurrency from CLI (explicit override beats file/defaults — brief §34).
|
|
44
|
+
if args.concurrency is not None:
|
|
45
|
+
if args.adaptive:
|
|
46
|
+
cfg.global_concurrency = bounded_adaptive(
|
|
47
|
+
minimum=1, maximum=args.max_concurrency or args.concurrency * 10,
|
|
48
|
+
initial=args.concurrency, name="global_concurrency",
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
cfg.global_concurrency = fixed(args.concurrency, name="global_concurrency")
|
|
52
|
+
if args.rate is not None:
|
|
53
|
+
cfg.request_rate = fixed(float(args.rate), name="request_rate")
|
|
54
|
+
return cfg
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
58
|
+
cfg = _build_config(args)
|
|
59
|
+
plan = cfg.validate_constraints()
|
|
60
|
+
if args.json:
|
|
61
|
+
print(json.dumps({
|
|
62
|
+
"ok": plan.ok,
|
|
63
|
+
"conflicts": plan.conflicts,
|
|
64
|
+
"warnings": plan.warnings,
|
|
65
|
+
}, indent=2))
|
|
66
|
+
else:
|
|
67
|
+
print(plan.render())
|
|
68
|
+
return 0 if plan.ok else 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
72
|
+
import anyio
|
|
73
|
+
|
|
74
|
+
cfg = _build_config(args)
|
|
75
|
+
plan = cfg.validate_constraints()
|
|
76
|
+
if not plan.ok:
|
|
77
|
+
print("configuration is invalid:", file=sys.stderr)
|
|
78
|
+
print(plan.render(), file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
if args.jsonl:
|
|
82
|
+
source = from_jsonl(args.jsonl)
|
|
83
|
+
elif args.url:
|
|
84
|
+
source = repeat(Request(args.method, args.url), times=args.count)
|
|
85
|
+
else:
|
|
86
|
+
print("error: provide --url or --jsonl", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
|
|
89
|
+
sinks = None
|
|
90
|
+
if args.output:
|
|
91
|
+
from .sinks.jsonl import JSONLSink
|
|
92
|
+
|
|
93
|
+
sinks = [JSONLSink(args.output)]
|
|
94
|
+
|
|
95
|
+
async def _go(): # type: ignore[no-untyped-def]
|
|
96
|
+
return await _run(source, config=cfg, sinks=sinks)
|
|
97
|
+
|
|
98
|
+
result = anyio.run(_go)
|
|
99
|
+
if args.json:
|
|
100
|
+
print(json.dumps(result.report.to_dict(), indent=2))
|
|
101
|
+
else:
|
|
102
|
+
print(result.report.summary())
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cmd_plugins(args: argparse.Namespace) -> int:
|
|
107
|
+
registry = PluginRegistry()
|
|
108
|
+
found = registry.discover_all()
|
|
109
|
+
if args.json:
|
|
110
|
+
print(json.dumps(found, indent=2))
|
|
111
|
+
else:
|
|
112
|
+
for group, names in found.items():
|
|
113
|
+
print(f"{group}:")
|
|
114
|
+
for name in names:
|
|
115
|
+
print(f" - {name}")
|
|
116
|
+
if not names:
|
|
117
|
+
print(" (none)")
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _cmd_version(args: argparse.Namespace) -> int:
|
|
122
|
+
print(f"goodput {__version__}")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
127
|
+
parser = argparse.ArgumentParser(
|
|
128
|
+
prog="goodput",
|
|
129
|
+
description="Adaptive HTTP execution engine that maximizes successful goodput.",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument("--no-color", action="store_true", help="disable ANSI colors")
|
|
132
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
133
|
+
|
|
134
|
+
def add_common(p: argparse.ArgumentParser) -> None:
|
|
135
|
+
p.add_argument("--config", help="path to a TOML/JSON config file")
|
|
136
|
+
p.add_argument("--concurrency", type=int, help="global concurrency")
|
|
137
|
+
p.add_argument("--max-concurrency", type=int, help="upper bound for adaptive concurrency")
|
|
138
|
+
p.add_argument("--adaptive", action="store_true", help="make concurrency bounded-adaptive")
|
|
139
|
+
p.add_argument("--rate", type=float, help="fixed request-start rate (req/s)")
|
|
140
|
+
p.add_argument("--mode", choices=[m.value for m in Mode], help="operating mode")
|
|
141
|
+
p.add_argument("--timeout", type=float, help="default per-request timeout (s)")
|
|
142
|
+
p.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
143
|
+
|
|
144
|
+
p_val = sub.add_parser("validate", help="validate config and print the plan")
|
|
145
|
+
add_common(p_val)
|
|
146
|
+
p_val.set_defaults(func=_cmd_validate)
|
|
147
|
+
|
|
148
|
+
p_run = sub.add_parser("run", help="execute a workload")
|
|
149
|
+
add_common(p_run)
|
|
150
|
+
p_run.add_argument("--url", help="endpoint to call repeatedly")
|
|
151
|
+
p_run.add_argument("--method", default="GET", help="HTTP method for --url")
|
|
152
|
+
p_run.add_argument("--count", type=int, default=100, help="number of requests for --url")
|
|
153
|
+
p_run.add_argument("--jsonl", help="JSONL workload file")
|
|
154
|
+
p_run.add_argument("--output", help="write per-result JSONL to this path")
|
|
155
|
+
p_run.set_defaults(func=_cmd_run)
|
|
156
|
+
|
|
157
|
+
p_plug = sub.add_parser("plugins", help="list discovered plugins")
|
|
158
|
+
p_plug.add_argument("--json", action="store_true")
|
|
159
|
+
p_plug.set_defaults(func=_cmd_plugins)
|
|
160
|
+
|
|
161
|
+
p_ver = sub.add_parser("version", help="print version")
|
|
162
|
+
p_ver.set_defaults(func=_cmd_version)
|
|
163
|
+
|
|
164
|
+
return parser
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
168
|
+
parser = build_parser()
|
|
169
|
+
args = parser.parse_args(argv)
|
|
170
|
+
try:
|
|
171
|
+
return int(args.func(args))
|
|
172
|
+
except KeyboardInterrupt: # graceful Ctrl+C (brief §33)
|
|
173
|
+
print("\ninterrupted", file=sys.stderr)
|
|
174
|
+
return 130
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__": # pragma: no cover
|
|
178
|
+
raise SystemExit(main())
|
goodput/clock.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Clock abstraction (brief §4: monotonic clocks; §36: deterministic fake clocks).
|
|
2
|
+
|
|
3
|
+
Durations are always measured with a *monotonic* clock. Tests and controller
|
|
4
|
+
simulations inject a :class:`ManualClock` for determinism — no test ever sleeps
|
|
5
|
+
in wall-clock time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
import anyio
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Clock(Protocol):
|
|
17
|
+
"""A source of monotonic time and async sleeping."""
|
|
18
|
+
|
|
19
|
+
def now(self) -> float:
|
|
20
|
+
"""Monotonic seconds. Only differences are meaningful."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
async def sleep(self, seconds: float) -> None:
|
|
24
|
+
"""Sleep for ``seconds`` (cancellation-safe)."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MonotonicClock:
|
|
29
|
+
"""The production clock: :func:`time.monotonic` + :func:`anyio.sleep`."""
|
|
30
|
+
|
|
31
|
+
def now(self) -> float:
|
|
32
|
+
return time.monotonic()
|
|
33
|
+
|
|
34
|
+
async def sleep(self, seconds: float) -> None:
|
|
35
|
+
if seconds > 0:
|
|
36
|
+
await anyio.sleep(seconds)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ManualClock:
|
|
40
|
+
"""A deterministic clock for tests and simulations.
|
|
41
|
+
|
|
42
|
+
Time advances only when :meth:`advance` is called. ``sleep`` does not block
|
|
43
|
+
on wall time; it registers a wakeup and yields control so other tasks run.
|
|
44
|
+
For most unit tests, advancing time explicitly between assertions is enough.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, start: float = 0.0) -> None:
|
|
48
|
+
self._t = start
|
|
49
|
+
|
|
50
|
+
def now(self) -> float:
|
|
51
|
+
return self._t
|
|
52
|
+
|
|
53
|
+
def advance(self, seconds: float) -> None:
|
|
54
|
+
if seconds < 0:
|
|
55
|
+
raise ValueError("cannot move ManualClock backwards")
|
|
56
|
+
self._t += seconds
|
|
57
|
+
|
|
58
|
+
async def sleep(self, seconds: float) -> None:
|
|
59
|
+
# Advance virtual time and yield so cooperating tasks make progress.
|
|
60
|
+
self.advance(max(0.0, seconds))
|
|
61
|
+
await anyio.lowlevel.checkpoint()
|