@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/SKILL.md +19 -1
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +17 -2
  5. package/docs/commands.md +50 -0
  6. package/docs/dataset.md +9 -0
  7. package/docs/decomposed-runs.md +42 -0
  8. package/docs/forge-ui.md +42 -6
  9. package/docs/reviews.md +14 -0
  10. package/forge +15 -0
  11. package/package.json +1 -1
  12. package/references/dataset.md +18 -1
  13. package/references/decompose.md +19 -1
  14. package/references/ui.md +27 -5
  15. package/scripts/forge-config.py +347 -0
  16. package/scripts/forge-control.py +645 -0
  17. package/scripts/forge-issues.py +806 -0
  18. package/scripts/forge-lib-task.sh +234 -35
  19. package/scripts/forge-parallel-options.sh +3 -1
  20. package/scripts/forge-parallel.sh +3 -2
  21. package/scripts/forge-route.py +184 -27
  22. package/scripts/forge-runs-rm.py +840 -0
  23. package/scripts/forge-runs.py +19 -1
  24. package/scripts/forge-schedule.py +227 -11
  25. package/scripts/forge-ui.py +85 -19
  26. package/scripts/forge_dataset/__init__.py +77 -6
  27. package/scripts/forge_dataset/capture.py +5 -2
  28. package/scripts/forge_dataset/harvest.py +22 -0
  29. package/scripts/forge_dataset/importer.py +419 -17
  30. package/scripts/forge_registry.py +168 -16
  31. package/ui/README.md +37 -4
  32. package/ui/docs/api.md +184 -6
  33. package/ui/forge_ui/__main__.py +10 -2
  34. package/ui/forge_ui/control.py +575 -0
  35. package/ui/forge_ui/export.py +17 -3
  36. package/ui/forge_ui/reader.py +26 -1
  37. package/ui/forge_ui/server.py +125 -4
  38. package/ui/forge_ui/static/app.js +80 -8
  39. package/ui/forge_ui/static/controls.js +619 -0
  40. package/ui/forge_ui/static/findingsview.js +412 -0
  41. package/ui/forge_ui/static/index.html +7 -1
  42. package/ui/forge_ui/static/panel.js +24 -4
  43. package/ui/forge_ui/static/rundelete.js +388 -0
  44. package/ui/forge_ui/static/settings.js +399 -0
  45. package/ui/forge_ui/static/style.css +177 -2
  46. 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 config_runs_dir, data_home, put_blob, put_file, record, repo_id, sha256
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 h.legacy_uid_for(run_dir, mapper(run_dir))
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
- plans = {p for p in walk_plans(top)}
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'] = h.legacy_uid_for(task_dir.parent.parent, mapper(task_dir.parent.parent))
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
- mapper = Mapper()
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: