pineforge-data 0.2.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.
- pineforge_data/__init__.py +124 -0
- pineforge_data/backtest.py +552 -0
- pineforge_data/cli/__init__.py +1 -0
- pineforge_data/cli/backtest.py +354 -0
- pineforge_data/compile_cache.py +171 -0
- pineforge_data/docker_runtime.py +226 -0
- pineforge_data/engine.py +185 -0
- pineforge_data/errors.py +5 -0
- pineforge_data/models.py +199 -0
- pineforge_data/providers/__init__.py +69 -0
- pineforge_data/providers/base.py +51 -0
- pineforge_data/providers/ccxt.py +411 -0
- pineforge_data/providers/local.py +207 -0
- pineforge_data/providers/registry.py +252 -0
- pineforge_data/providers/sqlalchemy.py +138 -0
- pineforge_data/providers/tabular.py +530 -0
- pineforge_data/py.typed +1 -0
- pineforge_data/release_contract.py +153 -0
- pineforge_data/requests.py +87 -0
- pineforge_data/server.py +646 -0
- pineforge_data/server_client.py +142 -0
- pineforge_data-0.2.0.dist-info/METADATA +364 -0
- pineforge_data-0.2.0.dist-info/RECORD +26 -0
- pineforge_data-0.2.0.dist-info/WHEEL +4 -0
- pineforge_data-0.2.0.dist-info/entry_points.txt +3 -0
- pineforge_data-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Fetch provider OHLCV and run a PineForge strategy without intermediate files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import cast
|
|
15
|
+
|
|
16
|
+
from ..backtest import BacktestOptions, JsonValue
|
|
17
|
+
from ..docker_runtime import DockerBacktestRuntime
|
|
18
|
+
from ..models import MarketListing
|
|
19
|
+
from ..providers import create_provider
|
|
20
|
+
from ..release_contract import DEFAULT_RELEASE_IMAGE
|
|
21
|
+
from ..requests import BarRequest
|
|
22
|
+
from ..server_client import FastApiBacktestClient
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_timestamp(value: str) -> int:
|
|
26
|
+
"""Parse Unix milliseconds or a timezone-aware ISO-8601 timestamp."""
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
timestamp = int(value)
|
|
30
|
+
except ValueError:
|
|
31
|
+
try:
|
|
32
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
33
|
+
except ValueError as exc:
|
|
34
|
+
raise argparse.ArgumentTypeError(f"invalid timestamp: {value}") from exc
|
|
35
|
+
if parsed.tzinfo is None:
|
|
36
|
+
raise argparse.ArgumentTypeError("ISO timestamps must include a timezone") from None
|
|
37
|
+
timestamp = int(parsed.timestamp() * 1_000)
|
|
38
|
+
if timestamp < 0:
|
|
39
|
+
raise argparse.ArgumentTypeError("timestamps must be non-negative")
|
|
40
|
+
return timestamp
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def source_timeframe_to_pine(timeframe: str) -> str:
|
|
44
|
+
"""Translate common compact timeframe spelling into Pine timeframe spelling."""
|
|
45
|
+
|
|
46
|
+
match = re.fullmatch(r"([1-9][0-9]*)([smhdwM])", timeframe)
|
|
47
|
+
if match is None:
|
|
48
|
+
raise ValueError(f"cannot translate source timeframe to PineForge: {timeframe}")
|
|
49
|
+
count = int(match.group(1))
|
|
50
|
+
unit = match.group(2)
|
|
51
|
+
if unit == "s":
|
|
52
|
+
return f"{count}S"
|
|
53
|
+
if unit == "m":
|
|
54
|
+
return str(count)
|
|
55
|
+
if unit == "h":
|
|
56
|
+
return str(count * 60)
|
|
57
|
+
return f"{count}{unit.upper() if unit != 'M' else unit}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def warmup_request_start_ms(start_ms: int, timeframe: str, warmup_bars: int) -> int:
|
|
61
|
+
"""Return an inclusive provider start that can contain ``warmup_bars`` bars."""
|
|
62
|
+
|
|
63
|
+
if warmup_bars < 0:
|
|
64
|
+
raise ValueError("warmup_bars must be non-negative")
|
|
65
|
+
if warmup_bars == 0:
|
|
66
|
+
return start_ms
|
|
67
|
+
match = re.fullmatch(r"([1-9][0-9]*)([smhdwM])", timeframe)
|
|
68
|
+
if match is None:
|
|
69
|
+
raise ValueError(f"cannot calculate warmup for source timeframe: {timeframe}")
|
|
70
|
+
count = int(match.group(1))
|
|
71
|
+
unit = match.group(2)
|
|
72
|
+
# A calendar month has no fixed duration. Thirty-one days deliberately
|
|
73
|
+
# over-fetches; run_harness trims the result to the requested bar count.
|
|
74
|
+
unit_ms = {
|
|
75
|
+
"s": 1_000,
|
|
76
|
+
"m": 60_000,
|
|
77
|
+
"h": 3_600_000,
|
|
78
|
+
"d": 86_400_000,
|
|
79
|
+
"w": 604_800_000,
|
|
80
|
+
"M": 2_678_400_000,
|
|
81
|
+
}[unit]
|
|
82
|
+
duration_ms = count * unit_ms * warmup_bars
|
|
83
|
+
return max(0, start_ms - duration_ms)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Backward-compatible import for callers of the CCXT-only bootstrap API.
|
|
87
|
+
ccxt_timeframe_to_pine = source_timeframe_to_pine
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_json_object(path: Path | None) -> dict[str, JsonValue]:
|
|
91
|
+
if path is None:
|
|
92
|
+
return {}
|
|
93
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
94
|
+
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
|
95
|
+
raise ValueError(f"JSON file must contain an object: {path}")
|
|
96
|
+
return cast(dict[str, JsonValue], value)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _market_payload(listing: MarketListing) -> dict[str, JsonValue]:
|
|
100
|
+
instrument = listing.instrument
|
|
101
|
+
contract = instrument.contract
|
|
102
|
+
contract_payload: JsonValue = None
|
|
103
|
+
if contract is not None:
|
|
104
|
+
contract_payload = {
|
|
105
|
+
"contract_size": contract.contract_size,
|
|
106
|
+
"linear": contract.linear,
|
|
107
|
+
"inverse": contract.inverse,
|
|
108
|
+
"expiry_ms": contract.expiry_ms,
|
|
109
|
+
"strike": contract.strike,
|
|
110
|
+
"option_type": contract.option_type.value if contract.option_type else None,
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
"symbol": instrument.symbol,
|
|
114
|
+
"provider_id": instrument.provider_id,
|
|
115
|
+
"asset_class": instrument.asset_class.value,
|
|
116
|
+
"market_type": instrument.market_type.value,
|
|
117
|
+
"base": instrument.base,
|
|
118
|
+
"quote": instrument.quote,
|
|
119
|
+
"settle": instrument.settle,
|
|
120
|
+
"volume_unit": instrument.volume_unit,
|
|
121
|
+
"active": listing.active,
|
|
122
|
+
"margin_supported": listing.margin_supported,
|
|
123
|
+
"contract": contract_payload,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
128
|
+
parser = argparse.ArgumentParser(
|
|
129
|
+
prog="pineforge-backtest",
|
|
130
|
+
description="Transpile raw PineScript and backtest provider OHLCV in Docker",
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument("--pine", type=Path, required=True, help="raw PineScript v6 strategy")
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--provider", default="ccxt", help="provider adapter name; defaults to ccxt"
|
|
135
|
+
)
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--venue",
|
|
138
|
+
"--exchange",
|
|
139
|
+
dest="venue",
|
|
140
|
+
required=True,
|
|
141
|
+
help="exchange, broker, or provider environment; for example kraken",
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--symbol",
|
|
145
|
+
required=True,
|
|
146
|
+
help="exact provider-normalized symbol, such as BTC/USD or BTC/USDT:USDT",
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument("--timeframe", required=True, help="source timeframe, such as 15m or 1h")
|
|
149
|
+
parser.add_argument("--start", type=parse_timestamp, required=True)
|
|
150
|
+
parser.add_argument("--end", type=parse_timestamp, required=True)
|
|
151
|
+
parser.add_argument("--limit", type=int)
|
|
152
|
+
parser.add_argument(
|
|
153
|
+
"--warmup-bars",
|
|
154
|
+
type=int,
|
|
155
|
+
default=0,
|
|
156
|
+
help="source bars before --start used for indicator warmup; trading stays disabled",
|
|
157
|
+
)
|
|
158
|
+
parser.add_argument("--timezone", default="UTC", help="IANA chart and exchange timezone")
|
|
159
|
+
parser.add_argument("--session", default="24x7", help="PineForge syminfo session")
|
|
160
|
+
parser.add_argument(
|
|
161
|
+
"--engine-timeframe",
|
|
162
|
+
help="PineForge input timeframe; defaults to a translation of --timeframe",
|
|
163
|
+
)
|
|
164
|
+
parser.add_argument("--script-timeframe", default="")
|
|
165
|
+
parser.add_argument("--provider-config", type=Path, help="provider options JSON file")
|
|
166
|
+
parser.add_argument("--strategy-params", type=Path, help="strategy parameters JSON file")
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--strategy-overrides", type=Path, help="strategy() header overrides JSON file"
|
|
169
|
+
)
|
|
170
|
+
parser.add_argument("--bar-magnifier", action="store_true")
|
|
171
|
+
parser.add_argument("--magnifier-samples", type=int, default=4)
|
|
172
|
+
parser.add_argument("--trace", action="store_true")
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"--runtime-image",
|
|
175
|
+
"--image",
|
|
176
|
+
dest="runtime_image",
|
|
177
|
+
default=DEFAULT_RELEASE_IMAGE,
|
|
178
|
+
help="pineforge-release image; defaults to the package's pinned digest",
|
|
179
|
+
)
|
|
180
|
+
parser.add_argument(
|
|
181
|
+
"--pull-policy",
|
|
182
|
+
choices=("always", "missing", "never"),
|
|
183
|
+
default="missing",
|
|
184
|
+
help="local release-image pull policy",
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
"--execution-timeout",
|
|
188
|
+
type=float,
|
|
189
|
+
default=300.0,
|
|
190
|
+
help="local or server request timeout in seconds",
|
|
191
|
+
)
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
"--server-url",
|
|
194
|
+
help="FastAPI base URL; also read from PINEFORGE_SERVER_URL",
|
|
195
|
+
)
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
"--server-api-key-env",
|
|
198
|
+
default="PINEFORGE_SERVER_API_KEY",
|
|
199
|
+
help="environment variable containing the server bearer token",
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument("--output", type=Path, help="report path; defaults to stdout")
|
|
202
|
+
parser.add_argument("--pretty", action="store_true", help="pretty-print report JSON")
|
|
203
|
+
return parser
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def run_harness(args: argparse.Namespace) -> dict[str, JsonValue]:
|
|
207
|
+
"""Execute the provider-to-engine pipeline for parsed CLI arguments."""
|
|
208
|
+
|
|
209
|
+
if args.warmup_bars < 0:
|
|
210
|
+
raise ValueError("--warmup-bars must be non-negative")
|
|
211
|
+
if args.end <= args.start:
|
|
212
|
+
raise ValueError("--end must be later than --start")
|
|
213
|
+
if args.limit is not None and args.limit <= 0:
|
|
214
|
+
raise ValueError("--limit must be positive")
|
|
215
|
+
|
|
216
|
+
provider_config = _load_json_object(args.provider_config)
|
|
217
|
+
strategy_params = _load_json_object(args.strategy_params)
|
|
218
|
+
strategy_overrides = _load_json_object(args.strategy_overrides)
|
|
219
|
+
provider = create_provider(args.provider, args.venue, config=provider_config)
|
|
220
|
+
try:
|
|
221
|
+
listing = await provider.resolve_market(args.symbol)
|
|
222
|
+
instrument = replace(
|
|
223
|
+
listing.instrument,
|
|
224
|
+
timezone=args.timezone,
|
|
225
|
+
session=args.session,
|
|
226
|
+
)
|
|
227
|
+
provider_start_ms = warmup_request_start_ms(args.start, args.timeframe, args.warmup_bars)
|
|
228
|
+
request_limit = None if args.limit is None else args.limit + args.warmup_bars
|
|
229
|
+
request = BarRequest(
|
|
230
|
+
instrument,
|
|
231
|
+
args.timeframe,
|
|
232
|
+
provider_start_ms,
|
|
233
|
+
args.end,
|
|
234
|
+
limit=request_limit,
|
|
235
|
+
)
|
|
236
|
+
fetched_bars = list(await provider.fetch_bars(request))
|
|
237
|
+
provider_name = provider.name
|
|
238
|
+
finally:
|
|
239
|
+
await provider.close()
|
|
240
|
+
if not fetched_bars:
|
|
241
|
+
raise RuntimeError("provider returned no confirmed bars for the requested interval")
|
|
242
|
+
requested_bars = [bar for bar in fetched_bars if bar.timestamp_ms >= args.start]
|
|
243
|
+
if not requested_bars:
|
|
244
|
+
raise RuntimeError("provider returned no confirmed bars at or after --start")
|
|
245
|
+
warmup_candidates = [bar for bar in fetched_bars if bar.timestamp_ms < args.start]
|
|
246
|
+
warmup = warmup_candidates[-args.warmup_bars :] if args.warmup_bars else []
|
|
247
|
+
bars = [*warmup, *requested_bars]
|
|
248
|
+
|
|
249
|
+
engine_timeframe = args.engine_timeframe or source_timeframe_to_pine(args.timeframe)
|
|
250
|
+
options = BacktestOptions(
|
|
251
|
+
input_timeframe=engine_timeframe,
|
|
252
|
+
script_timeframe=args.script_timeframe or engine_timeframe,
|
|
253
|
+
bar_magnifier=args.bar_magnifier,
|
|
254
|
+
magnifier_samples=args.magnifier_samples,
|
|
255
|
+
trace_enabled=args.trace,
|
|
256
|
+
chart_timezone=args.timezone,
|
|
257
|
+
trade_start_time_ms=args.start if args.warmup_bars else None,
|
|
258
|
+
)
|
|
259
|
+
pine_path = args.pine.expanduser().resolve()
|
|
260
|
+
if not pine_path.is_file():
|
|
261
|
+
raise FileNotFoundError(f"PineScript file not found: {pine_path}")
|
|
262
|
+
pine_source = pine_path.read_text(encoding="utf-8")
|
|
263
|
+
server_url = args.server_url or os.environ.get("PINEFORGE_SERVER_URL")
|
|
264
|
+
if server_url:
|
|
265
|
+
api_key = os.environ.get(args.server_api_key_env) if args.server_api_key_env else None
|
|
266
|
+
client = FastApiBacktestClient(
|
|
267
|
+
server_url,
|
|
268
|
+
timeout_seconds=args.execution_timeout + 30,
|
|
269
|
+
api_key=api_key,
|
|
270
|
+
)
|
|
271
|
+
container = await asyncio.to_thread(
|
|
272
|
+
client.run,
|
|
273
|
+
pine_source,
|
|
274
|
+
bars,
|
|
275
|
+
instrument=instrument,
|
|
276
|
+
source=provider_name,
|
|
277
|
+
options=options,
|
|
278
|
+
strategy_params=strategy_params,
|
|
279
|
+
strategy_overrides=strategy_overrides,
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
runtime = DockerBacktestRuntime(
|
|
283
|
+
image=args.runtime_image,
|
|
284
|
+
pull_policy=args.pull_policy,
|
|
285
|
+
timeout_seconds=args.execution_timeout,
|
|
286
|
+
)
|
|
287
|
+
container = await asyncio.to_thread(
|
|
288
|
+
runtime.run,
|
|
289
|
+
pine_source,
|
|
290
|
+
bars,
|
|
291
|
+
instrument=instrument,
|
|
292
|
+
source=provider_name,
|
|
293
|
+
options=options,
|
|
294
|
+
strategy_params=strategy_params,
|
|
295
|
+
strategy_overrides=strategy_overrides,
|
|
296
|
+
)
|
|
297
|
+
required_sections = ("runtime", "backtest")
|
|
298
|
+
if any(section not in container for section in required_sections):
|
|
299
|
+
raise RuntimeError("Docker report is missing a required section")
|
|
300
|
+
return {
|
|
301
|
+
"schema_version": 1,
|
|
302
|
+
"request_id": cast(JsonValue, container.get("request_id")),
|
|
303
|
+
"provider": {
|
|
304
|
+
"name": provider_name,
|
|
305
|
+
"adapter": args.provider,
|
|
306
|
+
"venue": args.venue,
|
|
307
|
+
"source_timeframe": args.timeframe,
|
|
308
|
+
"market": _market_payload(listing),
|
|
309
|
+
},
|
|
310
|
+
"data": {
|
|
311
|
+
"requested_start_ms": args.start,
|
|
312
|
+
"requested_end_ms": args.end,
|
|
313
|
+
"provider_start_ms": provider_start_ms,
|
|
314
|
+
"first_bar_ms": bars[0].timestamp_ms,
|
|
315
|
+
"last_bar_ms": bars[-1].timestamp_ms,
|
|
316
|
+
"bars": len(bars),
|
|
317
|
+
"requested_bars": len(requested_bars),
|
|
318
|
+
"warmup_bars_requested": args.warmup_bars,
|
|
319
|
+
"warmup_bars_loaded": len(warmup),
|
|
320
|
+
"trade_start_time_ms": options.trade_start_time_ms,
|
|
321
|
+
},
|
|
322
|
+
"strategy": {
|
|
323
|
+
"pine": str(pine_path),
|
|
324
|
+
"input_timeframe": engine_timeframe,
|
|
325
|
+
"script_timeframe": options.script_timeframe,
|
|
326
|
+
},
|
|
327
|
+
"runtime": cast(JsonValue, container["runtime"]),
|
|
328
|
+
"backtest": cast(JsonValue, container["backtest"]),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def main(argv: list[str] | None = None) -> int:
|
|
333
|
+
parser = build_parser()
|
|
334
|
+
args = parser.parse_args(argv)
|
|
335
|
+
try:
|
|
336
|
+
payload = asyncio.run(run_harness(args))
|
|
337
|
+
rendered = json.dumps(
|
|
338
|
+
payload,
|
|
339
|
+
indent=2 if args.pretty else None,
|
|
340
|
+
sort_keys=args.pretty,
|
|
341
|
+
allow_nan=False,
|
|
342
|
+
)
|
|
343
|
+
if args.output is None:
|
|
344
|
+
print(rendered)
|
|
345
|
+
else:
|
|
346
|
+
args.output.write_text(f"{rendered}\n", encoding="utf-8")
|
|
347
|
+
return 0
|
|
348
|
+
except Exception as exc:
|
|
349
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
350
|
+
return 1
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if __name__ == "__main__":
|
|
354
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Concurrency-safe compiled-strategy cache keyed by generated C++."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class _LockState:
|
|
17
|
+
lock: asyncio.Lock
|
|
18
|
+
references: int = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CompileCache:
|
|
22
|
+
"""Store `.so` artifacts atomically and deduplicate concurrent compiles."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, root: Path, *, max_entries: int, max_bytes: int) -> None:
|
|
25
|
+
if max_entries <= 0 or max_bytes <= 0:
|
|
26
|
+
raise ValueError("cache limits must be positive")
|
|
27
|
+
self.root = root
|
|
28
|
+
self.max_entries = max_entries
|
|
29
|
+
self.max_bytes = max_bytes
|
|
30
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
self._lock_guard = asyncio.Lock()
|
|
32
|
+
self._files_guard = asyncio.Lock()
|
|
33
|
+
self._locks: dict[str, _LockState] = {}
|
|
34
|
+
self._active: defaultdict[str, int] = defaultdict(int)
|
|
35
|
+
self.hits = 0
|
|
36
|
+
self.misses = 0
|
|
37
|
+
self.compiles = 0
|
|
38
|
+
|
|
39
|
+
def path(self, key: str) -> Path:
|
|
40
|
+
return self.root / f"{key}.so"
|
|
41
|
+
|
|
42
|
+
def temporary_path(self, key: str) -> Path:
|
|
43
|
+
return self.root / f".{key}.{uuid4().hex}.tmp"
|
|
44
|
+
|
|
45
|
+
@asynccontextmanager
|
|
46
|
+
async def compile_lock(self, key: str) -> AsyncIterator[None]:
|
|
47
|
+
async with self._lock_guard:
|
|
48
|
+
state = self._locks.get(key)
|
|
49
|
+
if state is None:
|
|
50
|
+
state = _LockState(asyncio.Lock())
|
|
51
|
+
self._locks[key] = state
|
|
52
|
+
state.references += 1
|
|
53
|
+
await state.lock.acquire()
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
state.lock.release()
|
|
58
|
+
async with self._lock_guard:
|
|
59
|
+
state.references -= 1
|
|
60
|
+
if state.references == 0:
|
|
61
|
+
self._locks.pop(key, None)
|
|
62
|
+
|
|
63
|
+
@asynccontextmanager
|
|
64
|
+
async def use(self, key: str) -> AsyncIterator[Path]:
|
|
65
|
+
path = await self.acquire(key)
|
|
66
|
+
if path is None:
|
|
67
|
+
raise FileNotFoundError(self.path(key))
|
|
68
|
+
try:
|
|
69
|
+
yield path
|
|
70
|
+
finally:
|
|
71
|
+
await self.release(key)
|
|
72
|
+
|
|
73
|
+
async def lookup(self, key: str) -> Path | None:
|
|
74
|
+
path = self.path(key)
|
|
75
|
+
exists = await asyncio.to_thread(path.is_file)
|
|
76
|
+
async with self._files_guard:
|
|
77
|
+
if exists:
|
|
78
|
+
self.hits += 1
|
|
79
|
+
else:
|
|
80
|
+
self.misses += 1
|
|
81
|
+
return path if exists else None
|
|
82
|
+
|
|
83
|
+
async def commit(self, key: str, temporary: Path) -> Path:
|
|
84
|
+
target = self.path(key)
|
|
85
|
+
await asyncio.to_thread(os.replace, temporary, target)
|
|
86
|
+
await asyncio.to_thread(target.chmod, 0o555)
|
|
87
|
+
async with self._files_guard:
|
|
88
|
+
self.compiles += 1
|
|
89
|
+
return target
|
|
90
|
+
|
|
91
|
+
async def acquire(self, key: str) -> Path | None:
|
|
92
|
+
"""Atomically look up and reserve an artifact against eviction."""
|
|
93
|
+
|
|
94
|
+
async with self._files_guard:
|
|
95
|
+
path = self.path(key)
|
|
96
|
+
if not path.is_file():
|
|
97
|
+
self.misses += 1
|
|
98
|
+
return None
|
|
99
|
+
self.hits += 1
|
|
100
|
+
self._active[key] += 1
|
|
101
|
+
await asyncio.to_thread(os.utime, path, None)
|
|
102
|
+
return path
|
|
103
|
+
|
|
104
|
+
async def commit_and_acquire(self, key: str, temporary: Path) -> Path:
|
|
105
|
+
"""Atomically publish and reserve a newly compiled artifact."""
|
|
106
|
+
|
|
107
|
+
async with self._files_guard:
|
|
108
|
+
target = self.path(key)
|
|
109
|
+
await asyncio.to_thread(os.replace, temporary, target)
|
|
110
|
+
await asyncio.to_thread(target.chmod, 0o555)
|
|
111
|
+
self.compiles += 1
|
|
112
|
+
self._active[key] += 1
|
|
113
|
+
return target
|
|
114
|
+
|
|
115
|
+
async def release(self, key: str) -> None:
|
|
116
|
+
async with self._files_guard:
|
|
117
|
+
self._active[key] -= 1
|
|
118
|
+
if self._active[key] == 0:
|
|
119
|
+
self._active.pop(key, None)
|
|
120
|
+
|
|
121
|
+
async def status(self) -> dict[str, int | str]:
|
|
122
|
+
async with self._files_guard:
|
|
123
|
+
files, sizes = await asyncio.to_thread(self._snapshot_sync)
|
|
124
|
+
return {
|
|
125
|
+
"directory": str(self.root),
|
|
126
|
+
"entries": len(files),
|
|
127
|
+
"bytes": sizes,
|
|
128
|
+
"hits": self.hits,
|
|
129
|
+
"misses": self.misses,
|
|
130
|
+
"compiles": self.compiles,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async def trim(self) -> None:
|
|
134
|
+
async with self._files_guard:
|
|
135
|
+
active = set(self._active)
|
|
136
|
+
await asyncio.to_thread(self._trim_sync, active)
|
|
137
|
+
|
|
138
|
+
def _snapshot_sync(self) -> tuple[list[Path], int]:
|
|
139
|
+
files: list[Path] = []
|
|
140
|
+
size = 0
|
|
141
|
+
for path in self.root.glob("*.so"):
|
|
142
|
+
try:
|
|
143
|
+
stat = path.stat()
|
|
144
|
+
except FileNotFoundError:
|
|
145
|
+
continue
|
|
146
|
+
files.append(path)
|
|
147
|
+
size += stat.st_size
|
|
148
|
+
return files, size
|
|
149
|
+
|
|
150
|
+
def _trim_sync(self, active: set[str]) -> None:
|
|
151
|
+
entries = []
|
|
152
|
+
for path in self.root.glob("*.so"):
|
|
153
|
+
try:
|
|
154
|
+
stat = path.stat()
|
|
155
|
+
except FileNotFoundError:
|
|
156
|
+
continue
|
|
157
|
+
entries.append((stat.st_mtime_ns, stat.st_size, path))
|
|
158
|
+
entries.sort()
|
|
159
|
+
total = sum(size for _, size, _ in entries)
|
|
160
|
+
count = len(entries)
|
|
161
|
+
for _, size, path in entries:
|
|
162
|
+
if count <= self.max_entries and total <= self.max_bytes:
|
|
163
|
+
break
|
|
164
|
+
if path.stem in active:
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
path.unlink()
|
|
168
|
+
except FileNotFoundError:
|
|
169
|
+
continue
|
|
170
|
+
count -= 1
|
|
171
|
+
total -= size
|