pyattacker 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.
pyattacker/__init__.py ADDED
@@ -0,0 +1,198 @@
1
+ """pyattacker —— an artifact-centric async task orchestration framework.
2
+
3
+ Core model (five concepts, in data-flow order)::
4
+
5
+ artifact a task's persisted state (content-addressed, persisted as soon as it is produced → task-level checkpoint)
6
+ task a unary function (artifact) -> artifact, the smallest unit of scheduling
7
+ pipeline several tasks chained linearly; the unit of **completion** and **resume**, semantically independent of each other
8
+ resource a concurrency-safe publishable/subscribable resource (such as a provider endpoint)
9
+ algorithm the strategy for "how to take a resource from the pool" (wait/backoff/switch pool/pick the least busy)
10
+
11
+ Minimal usage::
12
+
13
+ from pyattacker import Runner, pipeline, task, Pool, Resource
14
+
15
+ @task("fetch")
16
+ def fetch(seed, ctx): ...
17
+
18
+ @task("ask", resource="apis", retry={"max_attempts": 3})
19
+ async def ask(row, ctx):
20
+ async with ctx.acquire(model="gpt-4o") as lease: # returned on exit, guaranteed even on exception
21
+ return await lease.client.chat(row["q"])
22
+
23
+ pool = Pool("apis", [Resource.create("llm", capacity=4, options={...})], algorithm="backoff")
24
+ with Runner(store="runs.db", pools=[pool], concurrency=16) as runner:
25
+ report = runner.run(pipeline("qa", fetch | ask).map(dataset))
26
+ print(report.summary())
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from .algorithm import (
32
+ Backoff,
33
+ Failover,
34
+ Immediate,
35
+ LeastBusy,
36
+ QuotaAware,
37
+ Sticky,
38
+ Wait,
39
+ resolve_algorithm,
40
+ )
41
+ from .artifact import (
42
+ Artifact,
43
+ BytesCodec,
44
+ Codec,
45
+ CodecRegistry,
46
+ Encoded,
47
+ JsonCodec,
48
+ canonical_json,
49
+ digest_of,
50
+ )
51
+ from .backends import ArtifactBackend, FileBackend, InlineBackend, NullBackend, resolve_backend
52
+ from .declarative import load_spec
53
+ from .errors import (
54
+ AcquireTimeout,
55
+ ArtifactCodecError,
56
+ BudgetExceeded,
57
+ ConfigError,
58
+ FatalError,
59
+ LeaseLeakError,
60
+ PipelineBuildError,
61
+ PluginError,
62
+ PoolNotFound,
63
+ PyAttackerError,
64
+ ResourceUnavailable,
65
+ RetryableError,
66
+ RunInterrupted,
67
+ StoreUnavailable,
68
+ error_class_of,
69
+ )
70
+ from .export import FORMATS, ROW_KINDS, export_store, export_stores, iter_rows
71
+ from .merge import MergedReport, merge_reports
72
+ from .pipeline import Chain, PipelineSpec, PipelineTemplate, compute_spec_digest, pipeline, with_retry
73
+ from .plugins import PLUGINS, PluginRegistry, list_plugins
74
+ from .resource import Bus, Lease, Pool, Resource, ResourceEvent, ResourceState
75
+ from .runner import RunConfig, Runner, RunReport
76
+ from .server import StatsServer
77
+ from .shard import in_shard, parse_shard, shard_index, shard_specs, shard_store_path
78
+ from .store import AttemptRecord, EventRecord, MemoryStore, PipelineRecord, SqliteStore, open_store
79
+ from .task import Retrying, TaskContext, TaskSpec, build_task_spec, task
80
+ from .tasks import (
81
+ boom,
82
+ delay,
83
+ echo,
84
+ fanout,
85
+ flaky,
86
+ jsonl_source,
87
+ leaky,
88
+ seed_factory,
89
+ shell_run,
90
+ simulate_llm,
91
+ write_jsonl,
92
+ )
93
+
94
+ __version__ = "0.1.0"
95
+
96
+ __all__ = [
97
+ "__version__",
98
+ # core objects
99
+ "Runner",
100
+ "RunConfig",
101
+ "RunReport",
102
+ "pipeline",
103
+ "task",
104
+ "Pool",
105
+ "Resource",
106
+ "Lease",
107
+ "Bus",
108
+ "ResourceEvent",
109
+ "ResourceState",
110
+ "Retrying",
111
+ "TaskSpec",
112
+ "TaskContext",
113
+ "build_task_spec",
114
+ "Chain",
115
+ "PipelineSpec",
116
+ "PipelineTemplate",
117
+ "compute_spec_digest",
118
+ "with_retry",
119
+ "Artifact",
120
+ "Codec",
121
+ "CodecRegistry",
122
+ "JsonCodec",
123
+ "BytesCodec",
124
+ "Encoded",
125
+ "canonical_json",
126
+ "digest_of",
127
+ # algorithms
128
+ "resolve_algorithm",
129
+ "Immediate",
130
+ "Wait",
131
+ "Backoff",
132
+ "LeastBusy",
133
+ "Failover",
134
+ "Sticky",
135
+ "QuotaAware",
136
+ # store
137
+ "open_store",
138
+ "MemoryStore",
139
+ "SqliteStore",
140
+ "AttemptRecord",
141
+ "EventRecord",
142
+ "PipelineRecord",
143
+ # declarative
144
+ "load_spec",
145
+ # sharding, merging, export
146
+ "shard_index",
147
+ "in_shard",
148
+ "shard_specs",
149
+ "shard_store_path",
150
+ "parse_shard",
151
+ "merge_reports",
152
+ "MergedReport",
153
+ "iter_rows",
154
+ "export_store",
155
+ "export_stores",
156
+ "ROW_KINDS",
157
+ "FORMATS",
158
+ # plugins
159
+ "PLUGINS",
160
+ "PluginRegistry",
161
+ "list_plugins",
162
+ # artifact backends
163
+ "ArtifactBackend",
164
+ "InlineBackend",
165
+ "FileBackend",
166
+ "NullBackend",
167
+ "resolve_backend",
168
+ # monitoring endpoint
169
+ "StatsServer",
170
+ # built-in tasks
171
+ "echo",
172
+ "fanout",
173
+ "flaky",
174
+ "delay",
175
+ "boom",
176
+ "leaky",
177
+ "simulate_llm",
178
+ "shell_run",
179
+ "write_jsonl",
180
+ "jsonl_source",
181
+ "seed_factory",
182
+ # exceptions
183
+ "PyAttackerError",
184
+ "ConfigError",
185
+ "PipelineBuildError",
186
+ "PluginError",
187
+ "ArtifactCodecError",
188
+ "ResourceUnavailable",
189
+ "AcquireTimeout",
190
+ "PoolNotFound",
191
+ "LeaseLeakError",
192
+ "RetryableError",
193
+ "FatalError",
194
+ "BudgetExceeded",
195
+ "RunInterrupted",
196
+ "StoreUnavailable",
197
+ "error_class_of",
198
+ ]
pyattacker/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ """``python -m pyattacker`` — the same entry point as the console script.
2
+
3
+ Used by ``pyattacker run --shards N``, which spawns child processes this way so it works even
4
+ when the package is imported from a source checkout rather than an installed script.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .cli import main
10
+
11
+ if __name__ == "__main__":
12
+ raise SystemExit(main())
@@ -0,0 +1,355 @@
1
+ """Algorithm —— the strategy for "how to get a resource out of a pool".
2
+
3
+ How it differs from retry (two orthogonal axes; do not conflate them):
4
+
5
+ * **algorithm**: before doing the work, how to wait for/select an available
6
+ resource (wait, back off, switch pools, pick the least-busy one).
7
+ * **retry**: after the work fails, whether/when to run it again (exception-driven).
8
+
9
+ The single exit for every algorithm is :meth:`Pool.acquire`; they only compose
10
+ three primitives: ``pool.try_acquire()`` (synchronous attempt),
11
+ ``pool.wait_slot()`` (wait for a broadcast), and ``clock.sleep()`` (backoff).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import random
17
+ from collections.abc import Callable, Sequence
18
+ from dataclasses import dataclass
19
+ from typing import Any, Protocol, runtime_checkable
20
+
21
+ from .errors import AcquireTimeout, PoolNotFound, ResourceUnavailable
22
+ from .resource import Lease, Pool
23
+
24
+ __all__ = [
25
+ "AcquireAlgorithm",
26
+ "Immediate",
27
+ "Wait",
28
+ "Backoff",
29
+ "LeastBusy",
30
+ "Failover",
31
+ "Sticky",
32
+ "QuotaAware",
33
+ "resolve_algorithm",
34
+ "ALGORITHMS",
35
+ "backoff_delay",
36
+ ]
37
+
38
+
39
+ def backoff_delay(attempt: int, rng: random.Random, *, base: float, factor: float, cap: float, jitter: str) -> float:
40
+ """Exponential backoff + jitter, shared by :class:`Backoff` (acquire) and ``Retrying.delay_for`` (retry).
41
+
42
+ Both call sites want the same "raw exponential, then jitter" shape even though they back off
43
+ for different reasons (waiting for pool capacity vs. waiting to retry a failed attempt); a
44
+ single implementation means changing the formula (e.g. to decorrelated jitter) only has to
45
+ happen once, instead of two copies quietly drifting apart from each other.
46
+ """
47
+ raw = min(cap, base * factor ** max(0, attempt - 1))
48
+ if jitter == "full":
49
+ return rng.uniform(0, raw)
50
+ if jitter == "equal":
51
+ return raw / 2 + rng.uniform(0, raw / 2)
52
+ return raw
53
+
54
+
55
+ @runtime_checkable
56
+ class AcquireAlgorithm(Protocol):
57
+ name: str
58
+
59
+ async def acquire(
60
+ self,
61
+ pool: Pool,
62
+ *,
63
+ ctx: Any = None,
64
+ where: Callable[[Any], bool] | None = None,
65
+ timeout: float | None = None,
66
+ selector: dict[str, Any] | None = None,
67
+ ) -> Lease: ...
68
+
69
+
70
+ def _describe(pool: Pool, selector: dict[str, Any] | None, where: Any) -> str:
71
+ stats = pool.stats(**{k: v for k, v in (selector or {}).items() if k in ("kind", "id")})
72
+ return (
73
+ f"pool={pool.name!r} selector={selector or {}} where={'yes' if where else 'no'} "
74
+ f"resources={stats.total} ready={stats.ready} degraded={stats.degraded} "
75
+ f"dead={stats.dead} active={stats.active}/{stats.capacity} waiting={stats.waiting}"
76
+ )
77
+
78
+
79
+ @dataclass
80
+ class Immediate:
81
+ """Fail immediately when nothing is available (fast-fail on capacity shortage)."""
82
+
83
+ name: str = "immediate"
84
+
85
+ async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
86
+ lease = pool.try_acquire(where=where, ctx=ctx, **(selector or {}))
87
+ if lease is None:
88
+ raise ResourceUnavailable(f"no resource available: {_describe(pool, selector, where)}")
89
+ return lease
90
+
91
+
92
+ @dataclass
93
+ class Wait:
94
+ """FIFO wait (the default algorithm), optionally with a timeout."""
95
+
96
+ name: str = "wait"
97
+ timeout: float | None = None
98
+
99
+ async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
100
+ deadline_timeout = timeout if timeout is not None else self.timeout
101
+ started = pool.clock.now()
102
+ while True:
103
+ lease = pool.try_acquire(where=where, ctx=ctx, **(selector or {}))
104
+ if lease is not None:
105
+ # Backfill the real waiting time: try_acquire only ever sees 0, and time spent
106
+ # blocked here is exactly what pool stats should report.
107
+ pool.note_wait(lease, (pool.clock.now() - started) * 1000.0, selector=selector)
108
+ return lease
109
+ remaining = None
110
+ if deadline_timeout is not None:
111
+ remaining = deadline_timeout - (pool.clock.now() - started)
112
+ if remaining <= 0:
113
+ raise AcquireTimeout(f"timed out waiting for a resource ({deadline_timeout}s): {_describe(pool, selector, where)}")
114
+ got = await pool.wait_slot(remaining, ctx=ctx, where=where, selector=selector)
115
+ if not got and deadline_timeout is not None:
116
+ raise AcquireTimeout(f"timed out waiting for a resource ({deadline_timeout}s): {_describe(pool, selector, where)}")
117
+
118
+
119
+ @dataclass
120
+ class Backoff:
121
+ """Backoff-style acquire: when the pool is saturated / a resource is in circuit-break cooldown, retry after exponential backoff.
122
+
123
+ This is the default remedy for the "API is saturated" scenario.
124
+ """
125
+
126
+ name: str = "backoff"
127
+ base: float = 0.2
128
+ factor: float = 2.0
129
+ cap: float = 10.0
130
+ jitter: str = "full"
131
+ max_wait: float | None = None
132
+
133
+ async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
134
+ limit = timeout if timeout is not None else self.max_wait
135
+ started = pool.clock.now()
136
+ rng = getattr(ctx, "rng", None) or random.Random()
137
+ attempt = 0
138
+ while True:
139
+ lease = pool.try_acquire(where=where, ctx=ctx, **(selector or {}))
140
+ if lease is not None:
141
+ pool.note_wait(lease, (pool.clock.now() - started) * 1000.0, selector=selector)
142
+ return lease
143
+ attempt += 1
144
+ delay = backoff_delay(attempt, rng, base=self.base, factor=self.factor, cap=self.cap, jitter=self.jitter)
145
+ elapsed = pool.clock.now() - started
146
+ if limit is not None and elapsed + delay >= limit:
147
+ raise AcquireTimeout(f"timed out waiting for a resource with backoff ({limit}s): {_describe(pool, selector, where)}")
148
+ if ctx is not None:
149
+ ctx.emit(
150
+ "acquire.backoff",
151
+ pool=pool.name,
152
+ attempt=attempt,
153
+ delay_s=round(delay, 4),
154
+ selector=dict(selector or {}),
155
+ )
156
+ await pool.clock.sleep(delay)
157
+
158
+
159
+ @dataclass
160
+ class LeastBusy:
161
+ """Pick the resource with the lowest load ratio; if none is available, fall back to ``fallback`` (Wait by default)."""
162
+
163
+ name: str = "least_busy"
164
+ fallback: AcquireAlgorithm | None = None
165
+
166
+ async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
167
+ def pick(resource: Any) -> bool:
168
+ return where is None or bool(where(resource))
169
+
170
+ lease = pool.try_acquire(where=pick, ctx=ctx, **(selector or {}))
171
+ if lease is not None:
172
+ return lease
173
+ fallback = self.fallback or Wait()
174
+ return await fallback.acquire(pool, ctx=ctx, where=where, timeout=timeout, selector=selector)
175
+
176
+
177
+ @dataclass
178
+ class Failover:
179
+ """Fail over across multiple pools in order; when all are unavailable, defer to ``fallback``.
180
+
181
+ ``fallback`` (``Wait()`` by default) only ever runs against ``pools[0]`` — once every pool has
182
+ been tried immediately and none had capacity, this waits on the first (primary) pool rather
183
+ than looping ``fallback`` across the whole list. That is deliberate: the list is meant to be
184
+ read as "try these in order, then park on the one you actually want", not as "wait on
185
+ whichever of these frees up first" (that is what :class:`LeastBusy` or plain ``Wait`` on a
186
+ single pool covering all resources are for).
187
+ """
188
+
189
+ name: str = "failover"
190
+ pools: Sequence[str] = ()
191
+ fallback: AcquireAlgorithm | None = None
192
+
193
+ async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
194
+ names = list(self.pools) or [pool.name]
195
+ available = getattr(ctx, "pools", {}) or {}
196
+ reasons: dict[str, str] = {}
197
+ last_error: Exception | None = None
198
+ for name in names:
199
+ target = available.get(name)
200
+ if target is None:
201
+ reasons[name] = "unknown resource pool"
202
+ last_error = last_error or PoolNotFound(f"unknown resource pool: {name}")
203
+ continue
204
+ try:
205
+ return await Immediate().acquire(
206
+ target, ctx=ctx, where=where, timeout=timeout, selector=selector
207
+ )
208
+ except ResourceUnavailable as exc:
209
+ reasons[name] = str(exc).split(":")[0]
210
+ last_error = exc
211
+ fallback = self.fallback or Wait()
212
+ try:
213
+ target = available.get(names[0], pool)
214
+ return await fallback.acquire(
215
+ target, ctx=ctx, where=where, timeout=timeout, selector=selector
216
+ )
217
+ except (ResourceUnavailable, AcquireTimeout) as exc:
218
+ # Report every pool's reason: hiding them behind the last error makes failover
219
+ # misconfiguration (a typo'd pool name) look like plain capacity shortage.
220
+ reasons[names[0]] = f"{reasons.get(names[0], 'no resource available')} (fallback: {exc})"
221
+ detail = "; ".join(f"{name}: {reason}" for name, reason in reasons.items())
222
+ error = ResourceUnavailable(f"no pool available: {names} -> {detail}")
223
+ raise error from (last_error or exc)
224
+
225
+
226
+ def _affinity(ctx: Any, pool_name: str) -> str | None:
227
+ if ctx is None:
228
+ return None
229
+ return (ctx.meta.get("sticky") or {}).get(pool_name)
230
+
231
+
232
+ def _remember_affinity(ctx: Any, pool_name: str, resource_id: str) -> None:
233
+ if ctx is None:
234
+ return
235
+ ctx.meta.setdefault("sticky", {})[pool_name] = resource_id
236
+
237
+
238
+ @dataclass
239
+ class Sticky:
240
+ """Prefer the resource this attempt already used.
241
+
242
+ Useful when the provider gives you something for free by staying on one endpoint:
243
+ prompt/prefix caches, warm connections, sticky sessions. The affinity lives on the task
244
+ context, which is created per **attempt**, so it holds for every acquire inside one attempt
245
+ (including a loop that leases and returns several times) and never leaks between pipelines.
246
+ A retry or the next task starts a fresh context and may land elsewhere.
247
+ """
248
+
249
+ name: str = "sticky"
250
+ fallback: AcquireAlgorithm | None = None
251
+
252
+ async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
253
+ preferred = _affinity(ctx, pool.name)
254
+ if preferred is not None:
255
+ lease = pool.select(
256
+ None, where=lambda r, _pref=preferred: r.id == _pref, ctx=ctx, **(selector or {})
257
+ )
258
+ if lease is not None:
259
+ return lease
260
+ inner = self.fallback or Wait()
261
+ lease = await inner.acquire(pool, ctx=ctx, where=where, timeout=timeout, selector=selector)
262
+ _remember_affinity(ctx, pool.name, lease.resource.id)
263
+ return lease
264
+
265
+
266
+ @dataclass
267
+ class QuotaAware:
268
+ """Pick the resource with the most remaining quota.
269
+
270
+ Quota is declared on the resource (``options={"quota": {"tokens": 1_000_000}}``) and
271
+ consumed through ``lease.report(usage={"tokens": n})``. This is a *preference*, not a hard
272
+ limit: when every candidate is exhausted the best of them is still used, because refusing
273
+ to work is worse than overspending. For a hard stop, have the task raise once its own
274
+ budget is gone.
275
+ """
276
+
277
+ name: str = "quota_aware"
278
+ metric: str = "tokens"
279
+ reserve: float = 0.05
280
+ fallback: AcquireAlgorithm | None = None
281
+
282
+ def score(self, resource: Any, stats: Any) -> tuple[float, float]:
283
+ """Rank by *remaining ratio*, then by *absolute remaining* as the tie-break.
284
+
285
+ Ratio first so a nearly-exhausted resource is avoided even when its quota is huge;
286
+ absolute second so that between two equally-fresh resources the roomier one wins.
287
+ Unknown quota ranks below anything metered, and anything inside the reserve ranks
288
+ below everything else while staying rankable.
289
+ """
290
+ quota = (resource.options.get("quota") or {}).get(self.metric)
291
+ if not quota:
292
+ return (-100.0, 0.0) # unknown quota ranks below anything with a known one
293
+ quota_f = float(quota)
294
+ remaining = quota_f - float(stats.usage.get(self.metric, 0.0))
295
+ ratio = remaining / quota_f
296
+ if ratio <= self.reserve:
297
+ ratio -= 10.0 # inside the reserve: usable, but only as a last resort
298
+ return (ratio, remaining)
299
+
300
+ async def acquire(self, pool, *, ctx=None, where=None, timeout=None, selector=None) -> Lease:
301
+ lease = pool.select(self.score, where=where, ctx=ctx, **(selector or {}))
302
+ if lease is not None:
303
+ return lease
304
+ inner = self.fallback or Wait()
305
+ return await inner.acquire(pool, ctx=ctx, where=where, timeout=timeout, selector=selector)
306
+
307
+
308
+ ALGORITHMS: dict[str, type] = {
309
+ "immediate": Immediate,
310
+ "wait": Wait,
311
+ "backoff": Backoff,
312
+ "least_busy": LeastBusy,
313
+ "failover": Failover,
314
+ "sticky": Sticky,
315
+ "quota_aware": QuotaAware,
316
+ }
317
+
318
+
319
+ def resolve_algorithm(spec: Any) -> AcquireAlgorithm:
320
+ """Normalize ``"backoff"`` / ``{"name": "backoff", "base": 1}`` / an instance into an algorithm object."""
321
+ if spec is None:
322
+ return Wait()
323
+ if isinstance(spec, str):
324
+ if spec in ALGORITHMS:
325
+ return ALGORITHMS[spec]()
326
+ from .plugins import PLUGINS
327
+
328
+ plugin = PLUGINS.algorithm(spec)
329
+ if plugin is not None:
330
+ return plugin
331
+ raise PoolNotFound(
332
+ f"unknown acquire algorithm: {spec!r}, choices: {sorted(ALGORITHMS)} "
333
+ f"+ installed plugins {PLUGINS.names('algorithms')}"
334
+ )
335
+ if isinstance(spec, dict):
336
+ params = dict(spec)
337
+ name = params.pop("name", "wait")
338
+ cls = ALGORITHMS.get(name)
339
+ if cls is None:
340
+ from .plugins import PLUGINS
341
+
342
+ plugin = PLUGINS.algorithm(name)
343
+ if plugin is not None:
344
+ if params:
345
+ raise PoolNotFound(
346
+ f"algorithm plugin {name!r} does not take parameters: {sorted(params)}"
347
+ )
348
+ return plugin
349
+ raise PoolNotFound(f"unknown acquire algorithm: {name!r}, choices: {sorted(ALGORITHMS)}")
350
+ if "fallback" in params and isinstance(params["fallback"], (str, dict)):
351
+ params["fallback"] = resolve_algorithm(params["fallback"])
352
+ return cls(**params)
353
+ if isinstance(spec, AcquireAlgorithm):
354
+ return spec
355
+ raise PoolNotFound(f"cannot resolve acquire algorithm: {spec!r}")