calkit-python 0.41.1__py3-none-any.whl → 0.41.3__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.
- calkit/cli/core.py +8 -3
- calkit/cli/main/core.py +310 -15
- calkit/cli/scheduler.py +277 -58
- calkit/core.py +18 -0
- calkit/dependencies.py +3 -11
- calkit/dvc/core.py +42 -0
- calkit/environments.py +6 -0
- calkit/jupyterlab/routes.py +6 -3
- calkit/models/pipeline.py +16 -0
- calkit/pipeline.py +162 -5
- calkit/server.py +3 -5
- calkit/tests/cli/main/test_core.py +424 -0
- calkit/tests/cli/test_scheduler.py +163 -0
- calkit/tests/test_environments.py +34 -0
- calkit/tests/test_pipeline.py +225 -4
- {calkit_python-0.41.1.dist-info → calkit_python-0.41.3.dist-info}/METADATA +1 -1
- {calkit_python-0.41.1.dist-info → calkit_python-0.41.3.dist-info}/RECORD +35 -34
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.41.1.data → calkit_python-0.41.3.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.41.1.dist-info → calkit_python-0.41.3.dist-info}/WHEEL +0 -0
- {calkit_python-0.41.1.dist-info → calkit_python-0.41.3.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.41.1.dist-info → calkit_python-0.41.3.dist-info}/licenses/LICENSE +0 -0
calkit/cli/core.py
CHANGED
|
@@ -32,11 +32,16 @@ class AliasGroup(TyperGroup):
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def complete_stage_names(
|
|
35
|
-
ctx: "click.Context",
|
|
36
|
-
param: "click.Parameter",
|
|
35
|
+
ctx: "click.Context | None",
|
|
36
|
+
param: "click.Parameter | None",
|
|
37
37
|
incomplete: str,
|
|
38
38
|
) -> list:
|
|
39
|
-
"""Return pipeline stage names for shell tab-completion.
|
|
39
|
+
"""Return pipeline stage names for shell tab-completion.
|
|
40
|
+
|
|
41
|
+
``ctx`` and ``param`` are part of the Click completion-callback signature
|
|
42
|
+
but are unused here, so they accept ``None`` (e.g. when called directly in
|
|
43
|
+
tests).
|
|
44
|
+
"""
|
|
40
45
|
if not os.path.isfile("calkit.yaml"):
|
|
41
46
|
return []
|
|
42
47
|
try:
|
calkit/cli/main/core.py
CHANGED
|
@@ -421,7 +421,15 @@ def get_status(
|
|
|
421
421
|
else:
|
|
422
422
|
categories = valid_categories
|
|
423
423
|
pipeline_status = None
|
|
424
|
+
running_status = None
|
|
424
425
|
if "pipeline" in categories or "dvc" in categories:
|
|
426
|
+
# If a run is in progress it holds the DVC lock, so computing the full
|
|
427
|
+
# status would just error out (and would mutate files like dvc.yaml or
|
|
428
|
+
# notebooks mid-run). Report the running stage from the run log instead.
|
|
429
|
+
running_status = _get_running_pipeline_status()
|
|
430
|
+
if running_status is None and (
|
|
431
|
+
"pipeline" in categories or "dvc" in categories
|
|
432
|
+
):
|
|
425
433
|
# Sync zips so the zip files reflect current workspace state before
|
|
426
434
|
# reporting status
|
|
427
435
|
calkit.dvc.zip.sync_all(direction="to-zip")
|
|
@@ -501,7 +509,9 @@ def get_status(
|
|
|
501
509
|
except Exception as e:
|
|
502
510
|
status_dict["dvc"] = {"error": f"{e.__class__.__name__}: {e}"}
|
|
503
511
|
if "pipeline" in categories or "dvc" in categories:
|
|
504
|
-
if
|
|
512
|
+
if running_status is not None:
|
|
513
|
+
status_dict["pipeline"] = running_status
|
|
514
|
+
elif pipeline_status is None:
|
|
505
515
|
status_dict["pipeline"] = None
|
|
506
516
|
else:
|
|
507
517
|
status_dict["pipeline"] = pipeline_status.model_dump(
|
|
@@ -550,6 +560,9 @@ def get_status(
|
|
|
550
560
|
typer.echo(_format_dvc_data_status(raw, zip_path_map))
|
|
551
561
|
if "pipeline" in categories or "dvc" in categories:
|
|
552
562
|
print_sep("Pipeline")
|
|
563
|
+
if running_status is not None:
|
|
564
|
+
_print_running_pipeline_status(running_status)
|
|
565
|
+
return
|
|
553
566
|
# Nicely format the results from pipeline status
|
|
554
567
|
if pipeline_status and pipeline_status.errors:
|
|
555
568
|
warn("Pipeline status unavailable due to errors:")
|
|
@@ -566,6 +579,8 @@ def get_status(
|
|
|
566
579
|
if stale_stage is None:
|
|
567
580
|
continue
|
|
568
581
|
typer.echo(f" {typer.style(stage_name, fg='yellow')}:")
|
|
582
|
+
if stale_stage.always_run:
|
|
583
|
+
typer.echo(" always runs")
|
|
569
584
|
if stale_stage.modified_command:
|
|
570
585
|
typer.echo(" modified command")
|
|
571
586
|
# Show stale outputs for this stage
|
|
@@ -583,8 +598,16 @@ def get_status(
|
|
|
583
598
|
typer.echo(" modified inputs:")
|
|
584
599
|
for input_path in stale_stage.modified_inputs:
|
|
585
600
|
typer.echo(f" {input_path}")
|
|
601
|
+
if pipeline_status.always_run_stage_names:
|
|
602
|
+
typer.echo("Always-run stages:")
|
|
603
|
+
for stage_name in pipeline_status.always_run_stage_names:
|
|
604
|
+
typer.echo(f" {typer.style(stage_name, fg='cyan')}")
|
|
586
605
|
elif pipeline_status:
|
|
587
606
|
typer.echo("Pipeline is up to date.")
|
|
607
|
+
if pipeline_status.always_run_stage_names:
|
|
608
|
+
typer.echo("Always-run stages:")
|
|
609
|
+
for stage_name in pipeline_status.always_run_stage_names:
|
|
610
|
+
typer.echo(f" {typer.style(stage_name, fg='cyan')}")
|
|
588
611
|
|
|
589
612
|
|
|
590
613
|
@app.command(name="diff")
|
|
@@ -1304,11 +1327,12 @@ def _stage_run_info_from_log_content(log_content: str) -> dict:
|
|
|
1304
1327
|
# If we were already running a stage, add its end time
|
|
1305
1328
|
add_stage_info(current_stage_name, "end_time", timestamp)
|
|
1306
1329
|
add_stage_info(current_stage_name, "status", "completed")
|
|
1307
|
-
# This is a stage run
|
|
1330
|
+
# This is a stage run. The line is ``Running stage '<name>':``;
|
|
1331
|
+
# strip only the trailing ``:`` delimiter and surrounding quotes
|
|
1332
|
+
# so colons inside the name (e.g. ``sub1/dvc.yaml:stage-a`` for an
|
|
1333
|
+
# inline subproject target) are preserved.
|
|
1308
1334
|
current_stage_name = (
|
|
1309
|
-
message.removeprefix("Running stage ")
|
|
1310
|
-
.replace("'", "")
|
|
1311
|
-
.replace(":", "")
|
|
1335
|
+
message.removeprefix("Running stage ").rstrip(":").strip("'")
|
|
1312
1336
|
)
|
|
1313
1337
|
current_stage_status = "running"
|
|
1314
1338
|
add_stage_info(current_stage_name, "start_time", timestamp)
|
|
@@ -1344,6 +1368,239 @@ def _stage_run_info_from_log_content(log_content: str) -> dict:
|
|
|
1344
1368
|
return res
|
|
1345
1369
|
|
|
1346
1370
|
|
|
1371
|
+
def _prune_run_logs(
|
|
1372
|
+
logs_dir: str, keep: int = 10, protect: str | None = None
|
|
1373
|
+
) -> None:
|
|
1374
|
+
"""Keep only the most recent ``keep`` run logs in ``logs_dir``.
|
|
1375
|
+
|
|
1376
|
+
Run logs are named by their start timestamp, so sorting by name orders
|
|
1377
|
+
them by time; the oldest beyond ``keep`` are removed so the private log
|
|
1378
|
+
directory doesn't grow without bound. ``protect`` (the active run's log
|
|
1379
|
+
filename) is never deleted, guarding against clock skew or odd names that
|
|
1380
|
+
could otherwise sort the live log into the prune set.
|
|
1381
|
+
"""
|
|
1382
|
+
if not os.path.isdir(logs_dir):
|
|
1383
|
+
return
|
|
1384
|
+
logs = sorted(f for f in os.listdir(logs_dir) if f.endswith(".log"))
|
|
1385
|
+
for fname in logs[:-keep]:
|
|
1386
|
+
if fname == protect:
|
|
1387
|
+
continue
|
|
1388
|
+
try:
|
|
1389
|
+
os.remove(os.path.join(logs_dir, fname))
|
|
1390
|
+
except OSError:
|
|
1391
|
+
pass
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def _get_latest_run_log_content() -> str | None:
|
|
1395
|
+
"""Return the contents of the most recent run log, or ``None``.
|
|
1396
|
+
|
|
1397
|
+
Looks in the private ``.calkit/local/logs`` directory (always written)
|
|
1398
|
+
and the tracked ``.calkit/logs`` directory, choosing the latest ``.log``
|
|
1399
|
+
file across both by name (logs are named by start timestamp).
|
|
1400
|
+
"""
|
|
1401
|
+
candidates = []
|
|
1402
|
+
for d in [
|
|
1403
|
+
os.path.join(".calkit", "local", "logs"),
|
|
1404
|
+
os.path.join(".calkit", "logs"),
|
|
1405
|
+
]:
|
|
1406
|
+
if os.path.isdir(d):
|
|
1407
|
+
candidates += [
|
|
1408
|
+
os.path.join(d, f) for f in os.listdir(d) if f.endswith(".log")
|
|
1409
|
+
]
|
|
1410
|
+
if not candidates:
|
|
1411
|
+
return None
|
|
1412
|
+
latest = max(candidates, key=os.path.basename)
|
|
1413
|
+
try:
|
|
1414
|
+
with open(latest) as f:
|
|
1415
|
+
return f.read()
|
|
1416
|
+
except OSError:
|
|
1417
|
+
return None
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def _format_run_elapsed(start_iso: str) -> str:
|
|
1421
|
+
"""Format the time elapsed since ``start_iso`` (a UTC ISO timestamp)."""
|
|
1422
|
+
try:
|
|
1423
|
+
start = datetime.fromisoformat(start_iso)
|
|
1424
|
+
except ValueError:
|
|
1425
|
+
return "?"
|
|
1426
|
+
total = int((calkit.utcnow(remove_tz=True) - start).total_seconds())
|
|
1427
|
+
total = max(total, 0)
|
|
1428
|
+
hours, rem = divmod(total, 3600)
|
|
1429
|
+
minutes, seconds = divmod(rem, 60)
|
|
1430
|
+
if hours:
|
|
1431
|
+
return f"{hours}h{minutes}m{seconds}s"
|
|
1432
|
+
if minutes:
|
|
1433
|
+
return f"{minutes}m{seconds}s"
|
|
1434
|
+
return f"{seconds}s"
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _stage_target_from_cmd(cmd: str) -> str | None:
|
|
1438
|
+
"""Extract a DVC stage target from a ``dvc repro`` command string.
|
|
1439
|
+
|
|
1440
|
+
Concurrent scheduler items (an ``iterate_over`` sweep) run as separate
|
|
1441
|
+
``dvc repro --single-item <stage>`` processes whose stage name is the
|
|
1442
|
+
trailing positional argument recorded in the DVC lock. This lets the
|
|
1443
|
+
sweep items be named even before the main run log exists. Returns
|
|
1444
|
+
``None`` for commands without an explicit target (e.g. a full-pipeline
|
|
1445
|
+
``dvc repro`` or the parent ``calkit run``).
|
|
1446
|
+
"""
|
|
1447
|
+
tokens = cmd.split()
|
|
1448
|
+
if "repro" not in tokens:
|
|
1449
|
+
return None
|
|
1450
|
+
after = tokens[tokens.index("repro") + 1 :]
|
|
1451
|
+
targets = [t for t in after if not t.startswith("-")]
|
|
1452
|
+
return targets[-1] if targets else None
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _get_running_pipeline_status() -> dict | None:
|
|
1456
|
+
"""Return live pipeline run progress, or ``None`` if no run is running.
|
|
1457
|
+
|
|
1458
|
+
A run is in progress when a live process holds DVC's rwlock. The most
|
|
1459
|
+
recent run log is then parsed to report which stages have finished and
|
|
1460
|
+
which is currently running. Concurrently-run scheduler items execute in
|
|
1461
|
+
their own processes before the run log exists, so their stage names are
|
|
1462
|
+
also recovered from the lock's command strings.
|
|
1463
|
+
"""
|
|
1464
|
+
processes = calkit.dvc.get_running_pipeline_processes()
|
|
1465
|
+
if not processes:
|
|
1466
|
+
return None
|
|
1467
|
+
# Stage targets in the lock commands identify concurrently-run scheduler
|
|
1468
|
+
# items (an iterate_over sweep). These run in their own processes during a
|
|
1469
|
+
# prepass, before the current run's log exists, so the latest log on disk
|
|
1470
|
+
# is from a previous run; report only the lock's items in that case to
|
|
1471
|
+
# avoid mixing in stale stages.
|
|
1472
|
+
concurrent_stages: list[str] = []
|
|
1473
|
+
for proc in processes:
|
|
1474
|
+
target = _stage_target_from_cmd(proc.get("cmd", ""))
|
|
1475
|
+
if target is not None and target not in concurrent_stages:
|
|
1476
|
+
concurrent_stages.append(target)
|
|
1477
|
+
if concurrent_stages:
|
|
1478
|
+
return {
|
|
1479
|
+
"running": True,
|
|
1480
|
+
"processes": processes,
|
|
1481
|
+
"stages": {},
|
|
1482
|
+
"running_stages": concurrent_stages,
|
|
1483
|
+
}
|
|
1484
|
+
content = _get_latest_run_log_content()
|
|
1485
|
+
stages = (
|
|
1486
|
+
_stage_run_info_from_log_content(content)
|
|
1487
|
+
if content is not None
|
|
1488
|
+
else {}
|
|
1489
|
+
)
|
|
1490
|
+
running_stages = [
|
|
1491
|
+
name for name, info in stages.items() if "status" not in info
|
|
1492
|
+
]
|
|
1493
|
+
return {
|
|
1494
|
+
"running": True,
|
|
1495
|
+
"processes": processes,
|
|
1496
|
+
"stages": stages,
|
|
1497
|
+
"running_stages": running_stages,
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def _print_running_pipeline_status(running_status: dict) -> None:
|
|
1502
|
+
"""Print a human-readable summary of an in-progress pipeline run."""
|
|
1503
|
+
processes = running_status["processes"]
|
|
1504
|
+
pids = ", ".join(str(p["pid"]) for p in processes)
|
|
1505
|
+
typer.echo(f"Run in progress (PID {pids})")
|
|
1506
|
+
stages = running_status["stages"]
|
|
1507
|
+
finished = [
|
|
1508
|
+
name
|
|
1509
|
+
for name, info in stages.items()
|
|
1510
|
+
if info.get("status") in ("completed", "skipped")
|
|
1511
|
+
]
|
|
1512
|
+
running_stages = running_status["running_stages"]
|
|
1513
|
+
if finished:
|
|
1514
|
+
typer.echo(f" completed: {', '.join(finished)}")
|
|
1515
|
+
if running_stages:
|
|
1516
|
+
for name in running_stages:
|
|
1517
|
+
start = stages.get(name, {}).get("start_time")
|
|
1518
|
+
label = typer.style(name, fg="green")
|
|
1519
|
+
if start:
|
|
1520
|
+
typer.echo(
|
|
1521
|
+
f" running: {label} "
|
|
1522
|
+
f"({_format_run_elapsed(start)})"
|
|
1523
|
+
)
|
|
1524
|
+
else:
|
|
1525
|
+
typer.echo(f" running: {label}")
|
|
1526
|
+
elif not finished:
|
|
1527
|
+
typer.echo(" starting up...")
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _concurrent_scheduler_prepass(
|
|
1531
|
+
ck_info: dict,
|
|
1532
|
+
targets: list[str],
|
|
1533
|
+
keep_going: bool,
|
|
1534
|
+
quiet: bool,
|
|
1535
|
+
) -> None:
|
|
1536
|
+
"""Submit iterated scheduler-stage jobs concurrently before ``dvc repro``.
|
|
1537
|
+
|
|
1538
|
+
DVC's ``repro`` runs matrix items serially, so each scheduler item would
|
|
1539
|
+
otherwise submit-and-wait one at a time. Here we build each eligible
|
|
1540
|
+
stage's upstreams (serially, to avoid an rwlock race), then fan its items
|
|
1541
|
+
out as concurrent ``dvc repro --single-item`` processes---all at once,
|
|
1542
|
+
leaving it to the cluster's scheduler to queue them. The trailing
|
|
1543
|
+
``dvc repro`` then sees the items as up-to-date and records them,
|
|
1544
|
+
preserving granular per-item caching so a failed sweep resumes only the
|
|
1545
|
+
failed items. Not used under --force (the caller runs those serially).
|
|
1546
|
+
"""
|
|
1547
|
+
import sys
|
|
1548
|
+
|
|
1549
|
+
import calkit.pipeline
|
|
1550
|
+
|
|
1551
|
+
eligible = calkit.pipeline.get_concurrent_scheduler_stages(ck_info)
|
|
1552
|
+
if targets:
|
|
1553
|
+
eligible = [name for name in eligible if name in targets]
|
|
1554
|
+
if not eligible:
|
|
1555
|
+
return
|
|
1556
|
+
for stage_name in eligible:
|
|
1557
|
+
item_targets, upstream_targets = (
|
|
1558
|
+
calkit.pipeline.get_matrix_item_targets(stage_name)
|
|
1559
|
+
)
|
|
1560
|
+
if not item_targets:
|
|
1561
|
+
continue
|
|
1562
|
+
# Each item holds a local polling process for the job's lifetime, so
|
|
1563
|
+
# cap the fan-out to avoid exhausting local resources; a sweep larger
|
|
1564
|
+
# than this should be split into multiple runs.
|
|
1565
|
+
max_jobs = 100
|
|
1566
|
+
if len(item_targets) > max_jobs:
|
|
1567
|
+
raise_error(
|
|
1568
|
+
f"Stage '{stage_name}' would submit {len(item_targets)} jobs "
|
|
1569
|
+
f"at once, exceeding the limit of {max_jobs}. Each concurrent "
|
|
1570
|
+
"submission holds a local process, so reduce the sweep size "
|
|
1571
|
+
"or split it across multiple runs."
|
|
1572
|
+
)
|
|
1573
|
+
if not quiet:
|
|
1574
|
+
calkit.echo(
|
|
1575
|
+
f"🧵 Submitting {len(item_targets)} '{stage_name}' jobs"
|
|
1576
|
+
)
|
|
1577
|
+
# Build shared upstreams first; if two items raced to build the same
|
|
1578
|
+
# stale dependency, one would fail with an rwlock "busy" error. Go
|
|
1579
|
+
# through `calkit dvc` so the ck:// remote scheme is registered.
|
|
1580
|
+
if upstream_targets:
|
|
1581
|
+
up_cmd = [sys.executable, "-m", "calkit", "dvc", "repro"]
|
|
1582
|
+
up_cmd += upstream_targets
|
|
1583
|
+
if subprocess.run(up_cmd).returncode != 0:
|
|
1584
|
+
raise_error(
|
|
1585
|
+
f"Failed to build dependencies for stage '{stage_name}'"
|
|
1586
|
+
)
|
|
1587
|
+
results = calkit.pipeline.reproduce_targets_concurrently(
|
|
1588
|
+
item_targets, max_workers=len(item_targets)
|
|
1589
|
+
)
|
|
1590
|
+
failed = [t for t, rc in results.items() if rc != 0]
|
|
1591
|
+
if failed:
|
|
1592
|
+
msg = (
|
|
1593
|
+
f"{len(failed)} of {len(item_targets)} '{stage_name}' jobs "
|
|
1594
|
+
f"failed: {', '.join(failed)}"
|
|
1595
|
+
)
|
|
1596
|
+
if keep_going:
|
|
1597
|
+
warn(msg)
|
|
1598
|
+
else:
|
|
1599
|
+
raise_error(
|
|
1600
|
+
msg + ". Successful jobs are cached; rerun to resume."
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
|
|
1347
1604
|
@app.command(name="run")
|
|
1348
1605
|
def run(
|
|
1349
1606
|
targets: Annotated[
|
|
@@ -1767,24 +2024,48 @@ def run(
|
|
|
1767
2024
|
if downstream is not None:
|
|
1768
2025
|
args.append("--downstream")
|
|
1769
2026
|
args += downstream
|
|
2027
|
+
# Pre-submit iterated scheduler-stage jobs concurrently; the main repro
|
|
2028
|
+
# below then records them. Skipped for --dry so the dry plan stays intact,
|
|
2029
|
+
# and for --force, which re-runs every item: those go serially through the
|
|
2030
|
+
# main repro so we don't both pre-run and re-run each job. Also skipped
|
|
2031
|
+
# when a selector narrows the run (--downstream/--pipeline/--recursive/
|
|
2032
|
+
# --glob/--all-pipelines): positional ``targets`` is empty then, so the
|
|
2033
|
+
# prepass can't tell which sweeps will actually run and would otherwise
|
|
2034
|
+
# submit all of them.
|
|
2035
|
+
run_is_narrowed = bool(
|
|
2036
|
+
downstream or pipeline or recursive or glob or all_pipelines
|
|
2037
|
+
)
|
|
2038
|
+
if dvc_stages and not dry and not force and not run_is_narrowed:
|
|
2039
|
+
_concurrent_scheduler_prepass(
|
|
2040
|
+
ck_info=ck_info,
|
|
2041
|
+
targets=targets,
|
|
2042
|
+
keep_going=keep_going,
|
|
2043
|
+
quiet=quiet,
|
|
2044
|
+
)
|
|
1770
2045
|
start_time_no_tz = calkit.utcnow(remove_tz=True)
|
|
1771
2046
|
start_time = calkit.utcnow(remove_tz=False)
|
|
1772
2047
|
run_id = uuid.uuid4().hex
|
|
1773
|
-
|
|
1774
|
-
log_fpath = os.path.join(
|
|
1775
|
-
".calkit",
|
|
1776
|
-
"logs",
|
|
2048
|
+
log_fname = (
|
|
1777
2049
|
start_time_no_tz.isoformat(timespec="seconds").replace(":", "-")
|
|
1778
2050
|
+ "-"
|
|
1779
2051
|
+ run_id
|
|
1780
|
-
+ ".log"
|
|
2052
|
+
+ ".log"
|
|
1781
2053
|
)
|
|
2054
|
+
# Always write the run log under the gitignored .calkit/local/logs so
|
|
2055
|
+
# `calkit status` can report which stage is running while the pipeline
|
|
2056
|
+
# holds the DVC lock. With --log, the log is additionally saved to the
|
|
2057
|
+
# tracked .calkit/logs directory along with run information.
|
|
2058
|
+
local_logs_dir = os.path.join(calkit.ensure_local_dir(), "logs")
|
|
2059
|
+
os.makedirs(local_logs_dir, exist_ok=True)
|
|
2060
|
+
log_fpath = os.path.join(local_logs_dir, log_fname)
|
|
1782
2061
|
if verbose:
|
|
1783
2062
|
typer.echo(f"Starting run ID: {run_id}")
|
|
1784
2063
|
typer.echo(f"Saving logs to {log_fpath}")
|
|
1785
|
-
os.makedirs(os.path.dirname(log_fpath), exist_ok=True)
|
|
1786
2064
|
# Create a file handler for dvc.stage.run logger
|
|
1787
2065
|
file_handler = logging.FileHandler(log_fpath, mode="w")
|
|
2066
|
+
# Keep the private log directory bounded; the new log counts toward the
|
|
2067
|
+
# cap and is protected so it can never be pruned out from under this run.
|
|
2068
|
+
_prune_run_logs(local_logs_dir, keep=10, protect=log_fname)
|
|
1788
2069
|
file_handler.setLevel(logging.DEBUG)
|
|
1789
2070
|
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
|
|
1790
2071
|
formatter.converter = time.gmtime # Use UTC time for asctime
|
|
@@ -1794,7 +2075,14 @@ def run(
|
|
|
1794
2075
|
dvc.repo.reproduce.logger.setLevel(logging.ERROR)
|
|
1795
2076
|
# Disable other misc DVC output
|
|
1796
2077
|
dvc.ui.ui.write = lambda *args, **kwargs: None
|
|
1797
|
-
|
|
2078
|
+
# Tell `calkit scheduler batch` to resubmit completed jobs under --force;
|
|
2079
|
+
# otherwise it skips jobs it sees as already done.
|
|
2080
|
+
if force:
|
|
2081
|
+
os.environ["CALKIT_FORCE"] = "1"
|
|
2082
|
+
try:
|
|
2083
|
+
res = dvc_cli_main(["repro"] + args)
|
|
2084
|
+
finally:
|
|
2085
|
+
os.environ.pop("CALKIT_FORCE", None)
|
|
1798
2086
|
failed = failed or res != 0
|
|
1799
2087
|
# Parse log to get timing and which stages ran
|
|
1800
2088
|
with open(log_fpath, "r") as f:
|
|
@@ -1877,8 +2165,12 @@ def run(
|
|
|
1877
2165
|
os.makedirs(os.path.dirname(run_info_fpath), exist_ok=True)
|
|
1878
2166
|
with open(run_info_fpath, "w") as f:
|
|
1879
2167
|
json.dump(run_info, f, indent=2)
|
|
1880
|
-
|
|
1881
|
-
os.
|
|
2168
|
+
# Also keep the raw log in the tracked .calkit/logs directory
|
|
2169
|
+
saved_log_fpath = os.path.join(".calkit", "logs", log_fname)
|
|
2170
|
+
os.makedirs(os.path.dirname(saved_log_fpath), exist_ok=True)
|
|
2171
|
+
shutil.copy2(log_fpath, saved_log_fpath)
|
|
2172
|
+
# The private log under .calkit/local/logs is retained either way so the
|
|
2173
|
+
# last run's status stays inspectable; it is gitignored.
|
|
1882
2174
|
os.environ.pop("CALKIT_PIPELINE_RUNNING", None)
|
|
1883
2175
|
if failed:
|
|
1884
2176
|
raise_error("Pipeline failed")
|
|
@@ -2139,7 +2431,10 @@ def run_in_env(
|
|
|
2139
2431
|
elif env["kind"] == "pixi":
|
|
2140
2432
|
env_cmd = []
|
|
2141
2433
|
if "name" in env:
|
|
2142
|
-
env_cmd
|
|
2434
|
+
env_cmd += ["--environment", env["name"]]
|
|
2435
|
+
env_path = env.get("path")
|
|
2436
|
+
if env_path and os.path.dirname(env_path):
|
|
2437
|
+
env_cmd += ["--manifest-path", os.path.abspath(env_path)]
|
|
2143
2438
|
cmd = ["pixi", "run"] + env_cmd + cmd
|
|
2144
2439
|
if verbose:
|
|
2145
2440
|
typer.echo(f"Running command: {cmd}")
|