@giamme/forge 0.9.0 → 0.10.0
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.
- package/CHANGELOG.md +26 -0
- package/SKILL.md +19 -1
- package/VERSION +1 -1
- package/docs/artifacts.md +17 -2
- package/docs/commands.md +50 -0
- package/docs/dataset.md +9 -0
- package/docs/decomposed-runs.md +42 -0
- package/docs/forge-ui.md +42 -6
- package/docs/reviews.md +14 -0
- package/forge +15 -0
- package/package.json +1 -1
- package/references/dataset.md +18 -1
- package/references/decompose.md +19 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +19 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +22 -0
- package/scripts/forge_dataset/importer.py +419 -17
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +37 -4
- package/ui/docs/api.md +184 -6
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +26 -1
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +80 -8
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/index.html +7 -1
- package/ui/forge_ui/static/panel.js +24 -4
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +177 -2
- package/ui/forge_ui/static/uikit.js +161 -0
|
@@ -12,16 +12,23 @@ No historical outcome is inferred from a file that a later attempt could have re
|
|
|
12
12
|
"""
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
+
import errno
|
|
16
|
+
import io
|
|
17
|
+
import itertools
|
|
15
18
|
import json
|
|
16
19
|
import os
|
|
17
20
|
import re
|
|
18
21
|
import shutil
|
|
22
|
+
import sqlite3
|
|
23
|
+
import stat
|
|
19
24
|
import tarfile
|
|
20
25
|
import tempfile
|
|
21
26
|
import time
|
|
22
27
|
from pathlib import Path
|
|
28
|
+
from types import SimpleNamespace
|
|
23
29
|
|
|
24
|
-
from . import
|
|
30
|
+
from . import (BUSY_TIMEOUT_S, DatasetError, config_runs_dir, data_home, dataset_dir, enabled,
|
|
31
|
+
location_refused, put_blob, put_file, record, record_strict, repo_id, sha256, watch_blob_failures)
|
|
25
32
|
from . import harvest as h
|
|
26
33
|
from . import capture
|
|
27
34
|
|
|
@@ -43,9 +50,9 @@ def is_temp(path: str | None) -> bool:
|
|
|
43
50
|
return real.startswith(_temp_roots())
|
|
44
51
|
|
|
45
52
|
|
|
46
|
-
def walk_attempts(top: Path) -> list[Path]:
|
|
53
|
+
def walk_attempts(top: Path, onerror=None) -> list[Path]:
|
|
47
54
|
found = []
|
|
48
|
-
for directory, subdirs, _ in os.walk(top):
|
|
55
|
+
for directory, subdirs, _ in os.walk(top, onerror=onerror):
|
|
49
56
|
subdirs[:] = [d for d in subdirs if d not in SKIP_DIRS and not d.startswith('review-')]
|
|
50
57
|
if os.path.basename(directory) == 'attempts':
|
|
51
58
|
found.extend(Path(directory) / d for d in subdirs if '-' in d)
|
|
@@ -53,18 +60,18 @@ def walk_attempts(top: Path) -> list[Path]:
|
|
|
53
60
|
return sorted(found)
|
|
54
61
|
|
|
55
62
|
|
|
56
|
-
def walk_plans(top: Path) -> list[Path]:
|
|
63
|
+
def walk_plans(top: Path, onerror=None) -> list[Path]:
|
|
57
64
|
found = []
|
|
58
|
-
for directory, subdirs, files in os.walk(top):
|
|
65
|
+
for directory, subdirs, files in os.walk(top, onerror=onerror):
|
|
59
66
|
subdirs[:] = [d for d in subdirs if d not in SKIP_DIRS and not d.startswith('review-') and d != 'attempts']
|
|
60
67
|
if 'tasks.tsv' in files:
|
|
61
68
|
found.append(Path(directory))
|
|
62
69
|
return sorted(found)
|
|
63
70
|
|
|
64
71
|
|
|
65
|
-
def walk_fractal_tasks(top: Path) -> list[Path]:
|
|
72
|
+
def walk_fractal_tasks(top: Path, onerror=None) -> list[Path]:
|
|
66
73
|
found = []
|
|
67
|
-
for directory, subdirs, files in os.walk(top):
|
|
74
|
+
for directory, subdirs, files in os.walk(top, onerror=onerror):
|
|
68
75
|
subdirs[:] = [d for d in subdirs if d not in SKIP_DIRS and not d.startswith('review-')
|
|
69
76
|
and d not in ('attempts', 'steps', 'runtime')]
|
|
70
77
|
if 'request.json' in files and 'nodes' in subdirs and Path(directory).parent.name == 'tasks':
|
|
@@ -76,11 +83,13 @@ class Mapper:
|
|
|
76
83
|
"""Maps a path inside an extracted archive back to where the run originally lived.
|
|
77
84
|
|
|
78
85
|
A run's legacy id hashes its original path, so importing an archive and importing
|
|
79
|
-
the original dir must agree on it.
|
|
86
|
+
the original dir must agree on it. An archive also brings the legacy ids it computed
|
|
87
|
+
from the whole dir (`legacy`, by original path), since it leaves out files they hash.
|
|
80
88
|
"""
|
|
81
89
|
|
|
82
90
|
def __init__(self):
|
|
83
91
|
self.pairs = []
|
|
92
|
+
self.legacy: dict[str, str] = {}
|
|
84
93
|
|
|
85
94
|
def add(self, extracted_parent: Path, original_parent: Path) -> None:
|
|
86
95
|
self.pairs.append((str(extracted_parent.resolve()), str(original_parent)))
|
|
@@ -97,10 +106,28 @@ def _uid(run_dir: Path, mapper: Mapper, cache: dict) -> str:
|
|
|
97
106
|
key = str(run_dir)
|
|
98
107
|
if key not in cache:
|
|
99
108
|
stored = h.first_line(run_dir / 'dataset.uid') or _live_uid(run_dir, mapper(run_dir))
|
|
100
|
-
cache[key] = stored or
|
|
109
|
+
cache[key] = stored or _legacy_uid(run_dir, mapper)
|
|
101
110
|
return cache[key]
|
|
102
111
|
|
|
103
112
|
|
|
113
|
+
def _legacy_uid(run_dir: Path, mapper: Mapper) -> str:
|
|
114
|
+
original = mapper(run_dir)
|
|
115
|
+
return mapper.legacy.get(str(original)) or h.legacy_uid_for(run_dir, original)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _legacy_uids(run: Path) -> dict:
|
|
119
|
+
"""The legacy id an import of `run` gives each run dir in it that has no dataset.uid,
|
|
120
|
+
keyed by its path relative to `run`.
|
|
121
|
+
|
|
122
|
+
legacy_uid_for hashes the earliest attempt start anywhere below a run dir, `review-*`
|
|
123
|
+
snapshots included, and an archive leaves those out: so the archive records the ids.
|
|
124
|
+
"""
|
|
125
|
+
dirs = {run} | set(walk_plans(run)) | {t.parent.parent for t in walk_fractal_tasks(run)}
|
|
126
|
+
dirs |= {h.locate(a.parent.parent, a.name.split('-', 1)[0])['run_dir'] for a in walk_attempts(run)}
|
|
127
|
+
return {d.relative_to(run).as_posix(): h.legacy_uid_for(d, d) for d in sorted(dirs)
|
|
128
|
+
if (d == run or run in d.parents) and not (d / 'dataset.uid').exists()}
|
|
129
|
+
|
|
130
|
+
|
|
104
131
|
def _live_uid(run_dir: Path, original: Path) -> str | None:
|
|
105
132
|
"""The id live capture gave this run, when it is still running where it was archived.
|
|
106
133
|
|
|
@@ -187,9 +214,33 @@ def import_tree(top: Path, mapper: Mapper, args, counts: dict) -> list[tuple[lis
|
|
|
187
214
|
"""Every event under `top`, grouped per repo: [(events, repo)]."""
|
|
188
215
|
batches = []
|
|
189
216
|
uids = {}
|
|
190
|
-
|
|
217
|
+
reported: set = set()
|
|
218
|
+
|
|
219
|
+
def unreadable(error):
|
|
220
|
+
# os.walk skips a directory it cannot list; import_run must not read that as empty.
|
|
221
|
+
if getattr(args, 'report_unreadable', False) and error.filename not in reported:
|
|
222
|
+
reported.add(error.filename)
|
|
223
|
+
counts.setdefault('failed', []).append('%s: cannot read: %s' % (error.filename, error.strerror or error))
|
|
224
|
+
|
|
225
|
+
onerror = unreadable
|
|
226
|
+
if getattr(args, 'report_unreadable', False):
|
|
227
|
+
# The readers below (h.read_json, h.text, h.first_line) turn a file they cannot open into
|
|
228
|
+
# "absent": a finished attempt whose metrics.json is unreadable would pass as unfinished.
|
|
229
|
+
# So every file the walkers can reach must open before any of it is trusted.
|
|
230
|
+
for directory, subdirs, files in os.walk(top, onerror=onerror):
|
|
231
|
+
subdirs[:] = [d for d in subdirs if d not in SKIP_DIRS and not d.startswith('review-')]
|
|
232
|
+
for name in files:
|
|
233
|
+
full = os.path.join(directory, name)
|
|
234
|
+
try:
|
|
235
|
+
if not stat.S_ISREG(os.lstat(full).st_mode): # links and fifos are never run data
|
|
236
|
+
continue
|
|
237
|
+
with open(full, 'rb'):
|
|
238
|
+
pass
|
|
239
|
+
except OSError as error:
|
|
240
|
+
unreadable(error)
|
|
241
|
+
plans = {p for p in walk_plans(top, onerror)}
|
|
191
242
|
by_task: dict = {}
|
|
192
|
-
for attempt in walk_attempts(top):
|
|
243
|
+
for attempt in walk_attempts(top, onerror):
|
|
193
244
|
role = attempt.name.split('-', 1)[0]
|
|
194
245
|
root = attempt.parent.parent
|
|
195
246
|
ctx = h.locate(root, role)
|
|
@@ -231,11 +282,11 @@ def import_tree(top: Path, mapper: Mapper, args, counts: dict) -> list[tuple[lis
|
|
|
231
282
|
batches.append((legacy_task_events(root, attempts, dict(prov, mode='parallel', task=root.name)), repo))
|
|
232
283
|
except Exception as error:
|
|
233
284
|
counts.setdefault('failed', []).append('%s: %s' % (root, error))
|
|
234
|
-
for task_dir in walk_fractal_tasks(top):
|
|
285
|
+
for task_dir in walk_fractal_tasks(top, onerror):
|
|
235
286
|
try:
|
|
236
287
|
ctx = h.fractal_context(task_dir, create=False)
|
|
237
288
|
if ctx.get('run') and not (task_dir.parent.parent / 'dataset.uid').exists():
|
|
238
|
-
ctx['run'] =
|
|
289
|
+
ctx['run'] = _legacy_uid(task_dir.parent.parent, mapper)
|
|
239
290
|
if is_temp(ctx['repo']) and not args.include_temp:
|
|
240
291
|
continue
|
|
241
292
|
events = h.fractal_task_events(task_dir, ctx, src='import')
|
|
@@ -255,6 +306,7 @@ def import_tree(top: Path, mapper: Mapper, args, counts: dict) -> list[tuple[lis
|
|
|
255
306
|
prov = dict(run=uid, run_id=h.first_line(plan / 'run_id') or plan.name)
|
|
256
307
|
bases = [h.first_line(t / 'base_ref') for t in sorted((plan / 'tasks').glob('*')) if (t / 'base_ref').exists()]
|
|
257
308
|
events = [h.plan_event(plan, prov, src='import', head=bases[0] if bases else None)]
|
|
309
|
+
events += intervention_events(plan, prov, counts, repo)
|
|
258
310
|
# A plan that has a dataset.uid was captured live, merges and final reviews included,
|
|
259
311
|
# so synthesizing them again would only add a second, differently keyed copy.
|
|
260
312
|
live = (plan / 'dataset.uid').exists()
|
|
@@ -282,6 +334,115 @@ def import_tree(top: Path, mapper: Mapper, args, counts: dict) -> list[tuple[lis
|
|
|
282
334
|
return batches
|
|
283
335
|
|
|
284
336
|
|
|
337
|
+
def _regular_text(path: Path) -> str | None:
|
|
338
|
+
"""A run file's text, None if it is absent; a link is never run data (forge's own writers
|
|
339
|
+
refuse them too). A file that exists but cannot be read raises OSError: h.text would
|
|
340
|
+
report it as absent."""
|
|
341
|
+
if path.is_symlink():
|
|
342
|
+
return None
|
|
343
|
+
try:
|
|
344
|
+
return path.read_bytes().decode('utf-8', 'replace')
|
|
345
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _stored_interventions(uid: str | None, repo: str | None) -> dict:
|
|
350
|
+
"""What the store already holds for a run's escalations and control changes, by rid.
|
|
351
|
+
|
|
352
|
+
Released forge captured these live under a time-based rid, which cannot be recomputed.
|
|
353
|
+
A row like that is the same fact the run dir still lists, so the importer must not add
|
|
354
|
+
it a second time under the deterministic rid. {rid: (name, task, fields)}; empty when
|
|
355
|
+
the store cannot be read (a strict write then fails on its own).
|
|
356
|
+
|
|
357
|
+
Read only, and only a store that already exists: connect() would create the store,
|
|
358
|
+
which a dry run must not do, nor an import whose store location_refused() rules out.
|
|
359
|
+
"""
|
|
360
|
+
from . import DRY
|
|
361
|
+
if not uid or not enabled() or DRY or location_refused(repo):
|
|
362
|
+
return {}
|
|
363
|
+
path = dataset_dir() / 'dataset.sqlite'
|
|
364
|
+
if not path.is_file():
|
|
365
|
+
return {}
|
|
366
|
+
found = {}
|
|
367
|
+
try:
|
|
368
|
+
db = sqlite3.connect(path.resolve().as_uri() + '?mode=ro', uri=True, timeout=BUSY_TIMEOUT_S)
|
|
369
|
+
try:
|
|
370
|
+
for name in h.INTERVENTIONS:
|
|
371
|
+
for rid, task, data in db.execute(
|
|
372
|
+
"SELECT rid, task, data FROM events WHERE kind = 'event' AND run = ? AND rid LIKE ?",
|
|
373
|
+
(uid, 'event:%s:%%' % name)):
|
|
374
|
+
values = json.loads(data)
|
|
375
|
+
if isinstance(values, dict) and values.get('name') == name:
|
|
376
|
+
found[rid] = (name, task, _normalized(name, values))
|
|
377
|
+
finally:
|
|
378
|
+
db.close()
|
|
379
|
+
except Exception:
|
|
380
|
+
return {}
|
|
381
|
+
return found
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _normalized(name: str, values: dict) -> dict:
|
|
385
|
+
data = h.intervention_data(name, values)
|
|
386
|
+
if name == 'escalation' and not data['source']:
|
|
387
|
+
data['source'] = 'policy' # rows from before the source column
|
|
388
|
+
return data
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def intervention_events(plan: Path, prov: dict, counts: dict, repo: str | None = None) -> list[dict]:
|
|
392
|
+
"""The escalations and control changes a plan dir recorded, as the events live capture emits.
|
|
393
|
+
|
|
394
|
+
Same rid as capture.point (harvest.intervention_rid), so a change seen live and then
|
|
395
|
+
imported, or imported twice, is one row. A line that cannot be read is a failure, not
|
|
396
|
+
a skip: this is the only record of what a human did once the run dir is gone.
|
|
397
|
+
"""
|
|
398
|
+
uid, run_id = prov['run'], prov['run_id']
|
|
399
|
+
events = []
|
|
400
|
+
stored = _stored_interventions(uid, repo)
|
|
401
|
+
|
|
402
|
+
def add(name, values, task, ts):
|
|
403
|
+
data = dict(h.intervention_data(name, values), name=name, blobs={})
|
|
404
|
+
rid = h.intervention_rid(name, uid, task, values)
|
|
405
|
+
fields = _normalized(name, values)
|
|
406
|
+
if rid not in stored and any(known == (name, task, fields) for known in stored.values()):
|
|
407
|
+
return # already captured live under the old time-based rid
|
|
408
|
+
events.append(dict(rid=rid, kind='event', src='import', ts=ts,
|
|
409
|
+
run=uid, run_id=run_id, mode='parallel', task=task, data=data))
|
|
410
|
+
|
|
411
|
+
def read(path):
|
|
412
|
+
try:
|
|
413
|
+
return _regular_text(path) or ''
|
|
414
|
+
except OSError as error:
|
|
415
|
+
counts.setdefault('failed', []).append('%s: cannot read: %s' % (path, error.strerror or error))
|
|
416
|
+
return ''
|
|
417
|
+
|
|
418
|
+
for tdir in sorted((plan / 'tasks').glob('*')):
|
|
419
|
+
path = tdir / 'escalations.tsv'
|
|
420
|
+
for number, line in enumerate(read(path).splitlines(), 1):
|
|
421
|
+
if not line.strip():
|
|
422
|
+
continue
|
|
423
|
+
cells = line.split('\t')
|
|
424
|
+
if len(cells) not in (4, 5) or not cells[0].isdigit():
|
|
425
|
+
counts.setdefault('failed', []).append('%s:%d: not an escalations.tsv line' % (path, number))
|
|
426
|
+
continue
|
|
427
|
+
add('escalation', dict(after_attempt=cells[1], **{'from': cells[2]}, to=cells[3],
|
|
428
|
+
source=cells[4] if len(cells) == 5 and cells[4] else 'policy'),
|
|
429
|
+
tdir.name, int(cells[0]))
|
|
430
|
+
path = plan / 'control.log'
|
|
431
|
+
for number, line in enumerate(read(path).splitlines(), 1):
|
|
432
|
+
if not line.strip():
|
|
433
|
+
continue
|
|
434
|
+
try:
|
|
435
|
+
entry = json.loads(line)
|
|
436
|
+
ts = float(entry['at'])
|
|
437
|
+
except (ValueError, KeyError, TypeError):
|
|
438
|
+
entry = None
|
|
439
|
+
if not isinstance(entry, dict):
|
|
440
|
+
counts.setdefault('failed', []).append('%s:%d: not a control.log entry' % (path, number))
|
|
441
|
+
continue
|
|
442
|
+
add('control', {field: entry.get(field) for field in h.INTERVENTIONS['control']}, entry.get('task') or None, ts)
|
|
443
|
+
return events
|
|
444
|
+
|
|
445
|
+
|
|
285
446
|
def ledger_events(ledger: Path, repo: str) -> list[dict]:
|
|
286
447
|
events = []
|
|
287
448
|
key = repo_id(repo)
|
|
@@ -334,6 +495,250 @@ def _extract(archive: Path, into: Path) -> None:
|
|
|
334
495
|
bundle.extractall(into, members=members)
|
|
335
496
|
|
|
336
497
|
|
|
498
|
+
ARCHIVE_META = 'FORGE_ARCHIVE.json'
|
|
499
|
+
ARCHIVE_EXCLUDED = 'FORGE_ARCHIVE_EXCLUDED.txt'
|
|
500
|
+
ARCHIVE_FORMAT = 1
|
|
501
|
+
ARCHIVE_MAX_FILE = 50 << 20
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _archive_mapper(work: Path, source: Path | None) -> Mapper:
|
|
505
|
+
"""Where an extracted archive's run dir lived: its own FORGE_ARCHIVE.json, else a MANIFEST's source.
|
|
506
|
+
|
|
507
|
+
The archive's word wins, and it brings the run's dataset.uid along for the case where
|
|
508
|
+
the run dir it was taken from no longer has one to read.
|
|
509
|
+
"""
|
|
510
|
+
mapper = Mapper()
|
|
511
|
+
meta_path = work / ARCHIVE_META
|
|
512
|
+
if meta_path.is_file():
|
|
513
|
+
meta = json.loads(meta_path.read_text())
|
|
514
|
+
original = meta.get('original_path') if isinstance(meta, dict) else None
|
|
515
|
+
if not isinstance(meta, dict) or meta.get('format') != ARCHIVE_FORMAT or not isinstance(original, str) \
|
|
516
|
+
or not os.path.isabs(original):
|
|
517
|
+
raise ValueError('%s is not a format-%d forge archive description' % (ARCHIVE_META, ARCHIVE_FORMAT))
|
|
518
|
+
run = work / Path(original).name
|
|
519
|
+
if not run.is_dir():
|
|
520
|
+
dirs = [d for d in work.iterdir() if d.is_dir()]
|
|
521
|
+
if len(dirs) != 1:
|
|
522
|
+
raise ValueError('the archive holds no run dir named %s' % Path(original).name)
|
|
523
|
+
run = dirs[0]
|
|
524
|
+
uid = meta.get('dataset_uid')
|
|
525
|
+
if isinstance(uid, str) and uid.strip() and not (run / 'dataset.uid').exists():
|
|
526
|
+
(run / 'dataset.uid').write_text(uid.strip() + '\n')
|
|
527
|
+
mapper.add(run, Path(original))
|
|
528
|
+
# Absent from archives written before it existed: those recompute from what they hold.
|
|
529
|
+
legacy = meta.get('legacy_uids')
|
|
530
|
+
for rel, value in (legacy.items() if isinstance(legacy, dict) else ()):
|
|
531
|
+
if isinstance(value, str) and value.strip() and not os.path.isabs(rel) and '..' not in Path(rel).parts:
|
|
532
|
+
mapper.legacy[str(Path(original) / rel)] = value.strip()
|
|
533
|
+
elif source is not None:
|
|
534
|
+
mapper.add(work, source.parent)
|
|
535
|
+
return mapper
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def import_run(path, strict: bool = True, include_temp: bool = True) -> dict:
|
|
539
|
+
"""Import one run dir or `.tar.gz` and say exactly what happened: {events, new, failed, ...}.
|
|
540
|
+
|
|
541
|
+
Strict (the default) stores through record_strict and raises DatasetError if any event
|
|
542
|
+
could not be stored or any part of the run could not be read; the error's `result` holds
|
|
543
|
+
the same dict. A store failure stops further writes (it is reported in `failed`), while an
|
|
544
|
+
unreadable part of the run is reported and the rest is still written. Unfinished executions are left
|
|
545
|
+
for live capture and counted as `skipped_unfinished`: that is not a failure. Unlike the
|
|
546
|
+
CLI, test runs (a repo in a temp dir) are kept by default: live capture keeps them too.
|
|
547
|
+
"""
|
|
548
|
+
path = Path(path).expanduser()
|
|
549
|
+
args = SimpleNamespace(include_temp=include_temp, report_unreadable=True)
|
|
550
|
+
counts: dict = {'events': 0, 'new': 0}
|
|
551
|
+
writer = record_strict if strict else record
|
|
552
|
+
|
|
553
|
+
def write(batches, blob_failures):
|
|
554
|
+
for events, repo in batches:
|
|
555
|
+
if events:
|
|
556
|
+
counts['events'] += len(events)
|
|
557
|
+
if strict and blob_failures:
|
|
558
|
+
# put_file turns a failed blob write into "no content"; the first writer of a
|
|
559
|
+
# rid wins, so storing this batch would make the loss permanent.
|
|
560
|
+
counts.setdefault('failed', []).extend(blob_failures)
|
|
561
|
+
return
|
|
562
|
+
try:
|
|
563
|
+
counts['new'] += writer(events, repo=repo, where='import')
|
|
564
|
+
except DatasetError as error:
|
|
565
|
+
counts.setdefault('failed', []).append('store: %s' % error)
|
|
566
|
+
return
|
|
567
|
+
if strict and blob_failures and not counts.get('failed'):
|
|
568
|
+
counts.setdefault('failed', []).extend(blob_failures)
|
|
569
|
+
|
|
570
|
+
with watch_blob_failures() as blob_failures:
|
|
571
|
+
if path.is_dir():
|
|
572
|
+
try:
|
|
573
|
+
write(import_tree(path, Mapper(), args, counts), blob_failures)
|
|
574
|
+
except Exception as error:
|
|
575
|
+
counts.setdefault('failed', []).append('%s: %s' % (path, error))
|
|
576
|
+
elif path.is_file() and path.name.endswith(('.tar.gz', '.tgz')):
|
|
577
|
+
work = Path(tempfile.mkdtemp(prefix='forge-dataset-import-'))
|
|
578
|
+
try:
|
|
579
|
+
_extract(path, work)
|
|
580
|
+
write(import_tree(work, _archive_mapper(work, None), args, counts), blob_failures)
|
|
581
|
+
except Exception as error:
|
|
582
|
+
counts.setdefault('failed', []).append('%s: %s' % (path, error))
|
|
583
|
+
finally:
|
|
584
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
585
|
+
else:
|
|
586
|
+
counts.setdefault('failed', []).append('%s: not a run dir or .tar.gz archive' % path)
|
|
587
|
+
result = dict(counts, failed=list(counts.get('failed', [])))
|
|
588
|
+
if strict and result['failed']:
|
|
589
|
+
error = DatasetError('import of %s failed: %s' % (path, '; '.join(result['failed'][:5])))
|
|
590
|
+
error.result = result
|
|
591
|
+
raise error
|
|
592
|
+
return result
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
# Errors of a link(2) on a filesystem without hard links.
|
|
596
|
+
_NO_HARD_LINKS = {errno.EPERM, errno.ENOTSUP, errno.EOPNOTSUPP, errno.EMLINK}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _publish(temporary: str, names) -> Path:
|
|
600
|
+
"""Give the finished temp file the first of `names` that nothing holds. Never replaces a file.
|
|
601
|
+
|
|
602
|
+
link(2) fails when the name exists, so two writers that picked the same name cannot both
|
|
603
|
+
get it. Where hard links do not exist the name is reserved with O_EXCL and then filled by
|
|
604
|
+
rename; a reservation the rename did not fill is removed again.
|
|
605
|
+
"""
|
|
606
|
+
tried = []
|
|
607
|
+
for name in names:
|
|
608
|
+
tried.append(str(name))
|
|
609
|
+
try:
|
|
610
|
+
os.link(temporary, name)
|
|
611
|
+
except FileExistsError:
|
|
612
|
+
continue
|
|
613
|
+
except OSError as error:
|
|
614
|
+
if error.errno not in _NO_HARD_LINKS:
|
|
615
|
+
raise
|
|
616
|
+
try:
|
|
617
|
+
os.close(os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600))
|
|
618
|
+
except FileExistsError:
|
|
619
|
+
continue
|
|
620
|
+
try:
|
|
621
|
+
os.replace(temporary, name)
|
|
622
|
+
except BaseException:
|
|
623
|
+
try:
|
|
624
|
+
os.unlink(name)
|
|
625
|
+
except OSError:
|
|
626
|
+
pass
|
|
627
|
+
raise
|
|
628
|
+
return Path(name)
|
|
629
|
+
try:
|
|
630
|
+
os.unlink(temporary)
|
|
631
|
+
except OSError:
|
|
632
|
+
pass # a hidden temp file left beside a complete archive
|
|
633
|
+
return Path(name)
|
|
634
|
+
raise DatasetError('every name offered for the archive is taken: %s' % ', '.join(tried))
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def archive_run(run_dir, dest, fallbacks=()) -> dict:
|
|
638
|
+
"""Write run_dir as a `.tar.gz` that `forge dataset import` maps back to the same run.
|
|
639
|
+
|
|
640
|
+
Layout: FORGE_ARCHIVE.json (the original realpath, run id, dataset uid, the legacy ids of
|
|
641
|
+
run dirs without one), then
|
|
642
|
+
FORGE_ARCHIVE_EXCLUDED.txt, then the run dir under its basename. Left out are what the
|
|
643
|
+
importer never reads (its skip rules, `review-*` snapshots), symlinks, and files over 50 MB;
|
|
644
|
+
each is listed. Written to a temp name beside `dest`, fsynced, then published under `dest`
|
|
645
|
+
or, when that name is taken, the first free one of `fallbacks` (names in the same dir, may be
|
|
646
|
+
endless). An existing file is never replaced, and no name shows a partial archive: the returned
|
|
647
|
+
path is the one it got. With every name taken it raises DatasetError.
|
|
648
|
+
"""
|
|
649
|
+
run = Path(run_dir).expanduser().resolve()
|
|
650
|
+
dest = Path(dest).expanduser()
|
|
651
|
+
if not run.is_dir():
|
|
652
|
+
raise DatasetError('%s is not a run dir' % run)
|
|
653
|
+
real_dest = Path(os.path.realpath(dest))
|
|
654
|
+
if real_dest == run or run in real_dest.parents:
|
|
655
|
+
raise DatasetError('refusing to write the archive %s inside the run dir %s' % (dest, run))
|
|
656
|
+
excluded: list[str] = []
|
|
657
|
+
entries: list[tuple[Path, str]] = [(run, run.name)]
|
|
658
|
+
|
|
659
|
+
def unreadable(error):
|
|
660
|
+
raise DatasetError('cannot read %s: %s' % (error.filename, error)) from error
|
|
661
|
+
|
|
662
|
+
for directory, subdirs, files in os.walk(run, onerror=unreadable):
|
|
663
|
+
here = Path(directory)
|
|
664
|
+
rel = here.relative_to(run)
|
|
665
|
+
keep = []
|
|
666
|
+
for name in sorted(subdirs):
|
|
667
|
+
if (here / name).is_symlink():
|
|
668
|
+
excluded.append((rel / name).as_posix())
|
|
669
|
+
elif name in SKIP_DIRS or name.startswith('review-'):
|
|
670
|
+
excluded.append((rel / name).as_posix() + '/')
|
|
671
|
+
else:
|
|
672
|
+
keep.append(name)
|
|
673
|
+
entries.append((here / name, (Path(run.name) / rel / name).as_posix()))
|
|
674
|
+
subdirs[:] = keep
|
|
675
|
+
for name in sorted(files):
|
|
676
|
+
full = here / name
|
|
677
|
+
try:
|
|
678
|
+
info = full.lstat()
|
|
679
|
+
except OSError as error:
|
|
680
|
+
raise DatasetError('cannot read %s: %s' % (full, error)) from error
|
|
681
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > ARCHIVE_MAX_FILE:
|
|
682
|
+
excluded.append((rel / name).as_posix())
|
|
683
|
+
else:
|
|
684
|
+
entries.append((full, (Path(run.name) / rel / name).as_posix()))
|
|
685
|
+
meta = dict(format=ARCHIVE_FORMAT, original_path=str(run), run_id=h.first_line(run / 'run_id') or run.name,
|
|
686
|
+
dataset_uid=h.first_line(run / 'dataset.uid') or None, legacy_uids=_legacy_uids(run),
|
|
687
|
+
created=int(time.time()))
|
|
688
|
+
|
|
689
|
+
def beside_dest(names):
|
|
690
|
+
for name in map(Path, names):
|
|
691
|
+
if name.expanduser().parent != dest.parent:
|
|
692
|
+
raise DatasetError('the archive name %s is not beside %s' % (name, dest))
|
|
693
|
+
yield name.expanduser()
|
|
694
|
+
|
|
695
|
+
def anonymous(info):
|
|
696
|
+
info.uid = info.gid = 0
|
|
697
|
+
info.uname = info.gname = ''
|
|
698
|
+
return info
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
702
|
+
fd, temporary = tempfile.mkstemp(dir=str(dest.parent), prefix='.%s.' % dest.name, suffix='.tmp')
|
|
703
|
+
except OSError as error:
|
|
704
|
+
raise DatasetError('cannot write the archive %s: %s' % (dest, error)) from error
|
|
705
|
+
try:
|
|
706
|
+
with os.fdopen(fd, 'wb') as handle:
|
|
707
|
+
with tarfile.open(fileobj=handle, mode='w:gz', dereference=True) as bundle: # no link members: _extract drops them
|
|
708
|
+
for name, body in ((ARCHIVE_META, json.dumps(meta, sort_keys=True, indent=1) + '\n'),
|
|
709
|
+
(ARCHIVE_EXCLUDED, ''.join(line + '\n' for line in excluded))):
|
|
710
|
+
info = tarfile.TarInfo(name)
|
|
711
|
+
data = body.encode()
|
|
712
|
+
info.size, info.mtime, info.mode = len(data), int(time.time()), 0o644
|
|
713
|
+
bundle.addfile(info, io.BytesIO(data))
|
|
714
|
+
for full, arcname in entries:
|
|
715
|
+
bundle.add(str(full), arcname=arcname, recursive=False, filter=anonymous)
|
|
716
|
+
handle.flush()
|
|
717
|
+
os.fsync(handle.fileno())
|
|
718
|
+
dest = _publish(temporary, itertools.chain([dest], beside_dest(fallbacks)))
|
|
719
|
+
try:
|
|
720
|
+
directory_fd = os.open(str(dest.parent), os.O_RDONLY)
|
|
721
|
+
try:
|
|
722
|
+
os.fsync(directory_fd)
|
|
723
|
+
finally:
|
|
724
|
+
os.close(directory_fd)
|
|
725
|
+
except OSError:
|
|
726
|
+
pass
|
|
727
|
+
except (OSError, tarfile.TarError) as error:
|
|
728
|
+
try:
|
|
729
|
+
os.unlink(temporary)
|
|
730
|
+
except OSError:
|
|
731
|
+
pass
|
|
732
|
+
raise DatasetError('cannot write the archive %s: %s' % (dest, error)) from error
|
|
733
|
+
except BaseException:
|
|
734
|
+
try:
|
|
735
|
+
os.unlink(temporary)
|
|
736
|
+
except OSError:
|
|
737
|
+
pass
|
|
738
|
+
raise
|
|
739
|
+
return dict(path=str(dest), bytes=dest.stat().st_size, excluded=excluded)
|
|
740
|
+
|
|
741
|
+
|
|
337
742
|
def main(args) -> int:
|
|
338
743
|
from . import harvest # noqa: F401 (keeps the import graph explicit)
|
|
339
744
|
import forge_dataset as store
|
|
@@ -384,10 +789,7 @@ def main(args) -> int:
|
|
|
384
789
|
work = Path(tempfile.mkdtemp(prefix='forge-dataset-import-'))
|
|
385
790
|
try:
|
|
386
791
|
_extract(archive, work)
|
|
387
|
-
|
|
388
|
-
if source is not None:
|
|
389
|
-
mapper.add(work, source.parent)
|
|
390
|
-
write(import_tree(work, mapper, args, counts))
|
|
792
|
+
write(import_tree(work, _archive_mapper(work, source), args, counts))
|
|
391
793
|
except Exception as error:
|
|
392
794
|
counts.setdefault('failed', []).append('%s: %s' % (archive, error))
|
|
393
795
|
finally:
|