lightcone-cli 0.3.7__tar.gz → 0.4.0__tar.gz

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 (67) hide show
  1. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/PKG-INFO +2 -1
  2. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/pyproject.toml +12 -0
  3. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/cli/commands.py +291 -58
  4. lightcone_cli-0.4.0/src/lightcone/engine/cloudbuild.py +415 -0
  5. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/container.py +138 -23
  6. lightcone_cli-0.4.0/src/lightcone/engine/dask_cluster.py +516 -0
  7. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/manifest.py +7 -0
  8. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/scratch.py +5 -9
  9. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/site_registry.py +42 -4
  10. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/snakefile.py +15 -6
  11. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/status.py +10 -2
  12. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/snakemake_executor_plugin_dask/__init__.py +8 -4
  13. lightcone_cli-0.4.0/src/snakemake_executor_plugin_dask/executor.py +229 -0
  14. lightcone_cli-0.3.7/src/lightcone/engine/dask_cluster.py +0 -252
  15. lightcone_cli-0.3.7/src/snakemake_executor_plugin_dask/executor.py +0 -173
  16. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/.gitignore +0 -0
  17. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/LICENSE +0 -0
  18. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/README.md +0 -0
  19. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/agents/lc-extractor.md +0 -0
  20. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/hooks.json +0 -0
  21. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/scripts/activate-venv.sh +0 -0
  22. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/scripts/session-start.sh +0 -0
  23. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/scripts/validate-on-save.sh +0 -0
  24. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/README.md +0 -0
  25. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/astra/SKILL.md +0 -0
  26. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/check-sentence-by-sentence/SKILL.md +0 -0
  27. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/figure-comparison/SKILL.md +0 -0
  28. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-cli/SKILL.md +0 -0
  29. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-feedback/SKILL.md +0 -0
  30. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-code/SKILL.md +0 -0
  31. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-paper/SKILL.md +0 -0
  32. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-paper/references/architect.md +0 -0
  33. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-paper/references/compare.md +0 -0
  34. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-paper/references/implement.md +0 -0
  35. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-paper/references/literature.md +0 -0
  36. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-paper/references/orient.md +0 -0
  37. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-paper/references/review.md +0 -0
  38. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-paper/references/run.md +0 -0
  39. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-paper/references/specify.md +0 -0
  40. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-paper/templates/CLAUDE.md +0 -0
  41. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-from-paper/templates/constitution.md +0 -0
  42. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/lc-new/SKILL.md +0 -0
  43. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/paper-extraction/SKILL.md +0 -0
  44. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/paper-extraction/references/arxiv-source.md +0 -0
  45. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/paper-extraction/references/pdf-fallback.md +0 -0
  46. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/paper-extraction/scripts/extract-paper-substrate.py +0 -0
  47. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/ralph/SKILL.md +0 -0
  48. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/ralph/references/constitution.md +0 -0
  49. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/ralph/references/crafting.md +0 -0
  50. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/skills/ralph/scripts/ralph +0 -0
  51. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/claude/lightcone/templates/CLAUDE.md +0 -0
  52. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/cli/__init__.py +0 -0
  53. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/cli/plugin.py +0 -0
  54. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/__init__.py +0 -0
  55. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/runner.py +0 -0
  56. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/tree.py +0 -0
  57. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/validation.py +0 -0
  58. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/verify.py +0 -0
  59. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/engine/wrroc.py +0 -0
  60. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/eval/__init__.py +0 -0
  61. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/eval/build.py +0 -0
  62. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/eval/cli.py +0 -0
  63. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/eval/graders.py +0 -0
  64. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/eval/harness.py +0 -0
  65. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/eval/models.py +0 -0
  66. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/eval/report.py +0 -0
  67. {lightcone_cli-0.3.7 → lightcone_cli-0.4.0}/src/lightcone/eval/sandbox.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lightcone-cli
3
- Version: 0.3.7
3
+ Version: 0.4.0
4
4
  Summary: Lightcone command-line toolchain
5
5
  Author: Lightcone Research
6
6
  License: BSD-3-Clause
@@ -22,6 +22,7 @@ Classifier: Topic :: Scientific/Engineering :: Human Machine Interfaces
22
22
  Requires-Python: >=3.11
23
23
  Requires-Dist: astra-tools>=0.2.10
24
24
  Requires-Dist: click>=8.0
25
+ Requires-Dist: dask-gateway>=2024.1
25
26
  Requires-Dist: dask>=2024.1
26
27
  Requires-Dist: distributed>=2024.1
27
28
  Requires-Dist: jinja2>=3.0
@@ -36,6 +36,11 @@ dependencies = [
36
36
  "snakemake-interface-common>=1.14",
37
37
  "dask>=2024.1",
38
38
  "distributed>=2024.1",
39
+ # Dask Gateway client for JupyterHub/Kubernetes deployments. A
40
+ # normal dependency (not an extra) so `lc run` works out of the box
41
+ # on a hub and scaffolded project images inherit it via their
42
+ # lightcone-cli pin.
43
+ "dask-gateway>=2024.1",
39
44
  "rocrate>=0.11",
40
45
  ]
41
46
 
@@ -104,6 +109,13 @@ module = ["dask.*", "distributed.*"]
104
109
  ignore_missing_imports = true
105
110
  follow_untyped_imports = true
106
111
 
112
+ [[tool.mypy.overrides]]
113
+ # Untyped and without explicit re-exports; skip so `from dask_gateway
114
+ # import Gateway` doesn't trip strict no_implicit_reexport.
115
+ module = ["dask_gateway", "dask_gateway.*"]
116
+ ignore_missing_imports = true
117
+ follow_imports = "skip"
118
+
107
119
  [[tool.mypy.overrides]]
108
120
  module = ["rocrate.*"]
109
121
  ignore_missing_imports = true
@@ -6,7 +6,8 @@ The redesigned CLI is a thin shim over Snakemake. Provenance integrity
6
6
  ``astra.yaml`` and shells out to ``snakemake``.
7
7
 
8
8
  Commands:
9
- - ``lc init`` — scaffold a project (CLAUDE.md, .claude/, venv, gitignore).
9
+ - ``lc init`` — scaffold a project (CLAUDE.md, .claude/, venv, gitignore,
10
+ MyST report template).
10
11
  - ``lc run`` — generate Snakefile and run snakemake.
11
12
  - ``lc status`` — manifest-driven status walk (no Snakemake needed).
12
13
  - ``lc verify`` — recompute hashes and validate the provenance chain.
@@ -172,7 +173,8 @@ def init(
172
173
  base ``.gitignore``, ``src/``) to ``astra init``, then layers on the
173
174
  lightcone-specific bits: ``Containerfile`` + ``requirements.txt``,
174
175
  ``.lightcone/`` project state, ``.claude/`` plugin bundle, ``CLAUDE.md``,
175
- and an optional Python venv.
176
+ a template MyST report (``myst.yml`` + ``index.md``), and an optional
177
+ Python venv.
176
178
  """
177
179
  console.print(f"[cyan]{_LIGHTCONE}[/cyan]")
178
180
 
@@ -180,6 +182,7 @@ def init(
180
182
 
181
183
  from lightcone.engine.site_registry import detect_current_site
182
184
 
185
+ site = detect_current_site()
183
186
  directory = directory.resolve()
184
187
 
185
188
  if (directory / "astra.yaml").exists():
@@ -204,8 +207,16 @@ def init(
204
207
  "container: python:3.12-slim", "container: Containerfile", 1
205
208
  )
206
209
  )
210
+ # One scaffold everywhere — the Containerfile is agnostic to the
211
+ # execution environment. lightcone-cli in requirements.txt brings
212
+ # the whole execution stack (snakemake, dask, distributed,
213
+ # dask-gateway) so the same image can wrap recipes locally or run
214
+ # as a Dask Gateway worker pod on a hub; anything pod-specific
215
+ # (uid, mounts) is deployment configuration, not image content.
207
216
  (directory / "Containerfile").write_text(_CONTAINERFILE)
208
- (directory / "requirements.txt").write_text(_REQUIREMENTS)
217
+ (directory / "requirements.txt").write_text(
218
+ _REQUIREMENTS + _lightcone_requirement()
219
+ )
209
220
 
210
221
  # Append lightcone-specific entries to the .gitignore astra wrote.
211
222
  gitignore_path = directory / ".gitignore"
@@ -231,6 +242,11 @@ def init(
231
242
  # Project CLAUDE.md (a stub)
232
243
  (directory / "CLAUDE.md").write_text(_PROJECT_CLAUDE_MD)
233
244
 
245
+ # Template MyST report. MyST support is a recommended add-on on top of
246
+ # the spec, not part of it — which is why the report scaffold lives here
247
+ # and not in `astra init`.
248
+ _create_report_template(directory)
249
+
234
250
  # git init last so the initial commit captures every scaffolded file.
235
251
  no_git = no_git or (directory / ".git").exists()
236
252
  if not no_git:
@@ -287,7 +303,6 @@ def init(
287
303
  # state (snakemake metadata, dask spill, cross-node locks). On NERSC
288
304
  # this is critical: $HOME and CFS are mounted via DVS (no flock, slow
289
305
  # small-file I/O), so lightcone keeps everything on $SCRATCH (Lustre).
290
- site = detect_current_site()
291
306
  if site:
292
307
  scratch_expr = scratch_override or site.get("scratch_root")
293
308
  if scratch_expr:
@@ -305,6 +320,10 @@ def init(
305
320
  "[cyan]/lc-from-code[/cyan] to port existing code, "
306
321
  "or [cyan]/lc-from-paper[/cyan] to reproduce a paper"
307
322
  )
323
+ console.print(
324
+ " • Preview the report with [cyan]myst start[/cyan] "
325
+ "(requires the MyST CLI: [cyan]npm i -g mystmd[/cyan])"
326
+ )
308
327
 
309
328
 
310
329
  _CONTAINERFILE = """\
@@ -313,16 +332,7 @@ FROM python:3.12-slim
313
332
  WORKDIR /app
314
333
 
315
334
  COPY requirements.txt .
316
-
317
- # Install curl and certificates, then clean up to keep image size small
318
- RUN apt-get update && \
319
- apt-get install -y --no-install-recommends curl ca-certificates && \
320
- rm -rf /var/lib/apt/lists/*
321
-
322
- # Install uv
323
- RUN curl -LsSf https://astral.sh/uv/install.sh | sh
324
- ENV PATH="/root/.local/bin:$PATH"
325
- RUN uv pip install -r requirements.txt
335
+ RUN pip install --no-cache-dir -r requirements.txt
326
336
 
327
337
  COPY . .
328
338
  """
@@ -334,6 +344,31 @@ pandas
334
344
  """
335
345
 
336
346
 
347
+ def _lightcone_requirement() -> str:
348
+ """Requirements lines pinning lightcone-cli into the project image.
349
+
350
+ The project image must be able to execute rules on any backend —
351
+ including as a Dask Gateway worker pod, where the dask worker and
352
+ the child snakemake run *inside* the image. lightcone-cli carries
353
+ that whole stack (snakemake, dask, distributed, dask-gateway) as
354
+ normal dependencies, so one line covers it. The pin mirrors the
355
+ version running ``lc init`` to keep driver and image in lockstep;
356
+ dev builds fall back to unpinned (their version isn't published).
357
+ """
358
+ from importlib.metadata import PackageNotFoundError, version
359
+
360
+ try:
361
+ v = version("lightcone-cli")
362
+ except PackageNotFoundError:
363
+ v = ""
364
+ pin = f"lightcone-cli=={v}" if v and "dev" not in v else "lightcone-cli"
365
+ return (
366
+ "\n# Execution stack — lets this image run rules on any backend,\n"
367
+ "# including as a Dask Gateway worker pod.\n"
368
+ f"{pin}\n"
369
+ )
370
+
371
+
337
372
  _GITIGNORE_APPEND = """
338
373
  # lightcone-cli
339
374
  .lightcone/Snakefile
@@ -341,6 +376,68 @@ _GITIGNORE_APPEND = """
341
376
  .snakemake/
342
377
  .snakemake.legacy/
343
378
  results/
379
+
380
+ # MyST build output
381
+ _build/
382
+ """
383
+
384
+
385
+ def _create_report_template(directory: Path) -> None:
386
+ """Write ``myst.yml`` + ``index.md`` — a starter report for the analysis.
387
+
388
+ The report references the boilerplate ``astra.yaml`` elements by path via
389
+ the MySTRA plugin, so the ids used below must track the ``astra init``
390
+ boilerplate (``example_method``, ``main_result``).
391
+ """
392
+ name = directory.name or "My Analysis"
393
+ (directory / "myst.yml").write_text(_MYST_YML)
394
+ (directory / "index.md").write_text(f"# {name}\n" + _INDEX_MD_BODY)
395
+
396
+
397
+ _MYST_YML = """\
398
+ # MyST configuration for the analysis report (https://mystmd.org/).
399
+ # The MySTRA plugin resolves {astra}`...` references against astra.yaml.
400
+ # `latest` always tracks the newest MySTRA release; for a reproducible
401
+ # build, pin a tag instead, e.g. .../releases/download/v0.0.1/mystra.mjs
402
+ version: 1
403
+ project:
404
+ plugins:
405
+ - https://github.com/LightconeResearch/MySTRA/releases/latest/download/mystra.mjs
406
+ toc:
407
+ - file: index.md
408
+ site:
409
+ template: book-theme
410
+ """
411
+
412
+
413
+ _INDEX_MD_BODY = """
414
+ > **TODO:** this report was scaffolded by `lc init`. It references the
415
+ > analysis elements declared in `astra.yaml` *by path* — figures, decisions,
416
+ > and numbers stay single-sourced in the analysis, so never hard-type a
417
+ > measured value here. Preview with `myst start` (requires the MyST CLI).
418
+
419
+ ## Introduction
420
+
421
+ TODO: the research question, its context, and why it matters.
422
+
423
+ ## Methods
424
+
425
+ TODO: describe the approach. Reference the decisions the analysis exposes
426
+ rather than restating them — for example, we adopt the
427
+ {astra}`decisions.example_method` for this analysis:
428
+
429
+ :::{astra} decisions.example_method
430
+ :::
431
+
432
+ ## Results
433
+
434
+ TODO: present the outputs. Once `lc run` has materialized results, pull
435
+ numbers in live, e.g.:
436
+
437
+ % The analysis yields {astra:value}`outputs.main_result`.
438
+
439
+ :::{astra} outputs
440
+ :::
344
441
  """
345
442
 
346
443
  _PROJECT_CLAUDE_MD = """# Project Notes for Claude
@@ -367,6 +464,14 @@ lc verify # validate the provenance chain
367
464
  Outputs land in `results/<universe>/<output_id>/` along with a sidecar
368
465
  `.lightcone-manifest.json` recording the recipe, container, decisions,
369
466
  input hashes, and output hash.
467
+
468
+ ## Report
469
+
470
+ `index.md` + `myst.yml` are a template MyST report wired to the MySTRA
471
+ plugin. The report references analysis elements by path — inline mentions
472
+ with the `{astra}` role, block embeds with the `{astra}` directive, live
473
+ numbers with `{astra:value}` — so never hard-type a measured value in the
474
+ prose. Preview with `myst start` (requires the MyST CLI, `npm i -g mystmd`).
370
475
  """
371
476
 
372
477
 
@@ -456,13 +561,14 @@ def run(
456
561
  """Materialize outputs declared in astra.yaml.
457
562
 
458
563
  Always dispatches through a Dask cluster: a ``LocalCluster`` on a
459
- workstation, srun-launched workers inside a SLURM allocation, or an
564
+ workstation, srun-launched workers inside a SLURM allocation, a
565
+ run-scoped Dask Gateway cluster on a JupyterHub deployment, or an
460
566
  existing scheduler if ``DASK_SCHEDULER_ADDRESS`` is set.
461
567
  """
462
568
  _abort_on_perlmutter_login()
463
569
 
464
570
  from lightcone.engine.container import load_runtime
465
- from lightcone.engine.dask_cluster import cluster_for_run
571
+ from lightcone.engine.dask_cluster import cluster_for_run, gateway_branch_active
466
572
  from lightcone.engine.scratch import (
467
573
  RunLockBusyError,
468
574
  acquire_run_lock,
@@ -487,11 +593,26 @@ def run(
487
593
  console.print(f"[dim]Scratch root:[/dim] {resolve_scratch_root(project)}")
488
594
 
489
595
  choice = load_runtime(project_path=project)
490
- _ensure_images(project, runtime=choice.runtime)
596
+ images = _ensure_images(project, runtime=choice.runtime)
491
597
  snakefile_path, cfg_path = generate(
492
598
  project, universes=universes, runtime=choice.runtime
493
599
  )
494
600
 
601
+ # On the Gateway branch the cluster is created with one image — the
602
+ # worker pod is the container for every rule, so a spec declaring
603
+ # several distinct containers cannot be honoured per-rule.
604
+ worker_image: str | None = None
605
+ if gateway_branch_active():
606
+ if len(images) > 1:
607
+ raise click.ClickException(
608
+ "This deployment runs recipes natively in worker pods, "
609
+ "which supports one container image per run; astra.yaml "
610
+ "declares several: " + ", ".join(images) + ". "
611
+ "Consolidate on a single Containerfile (or one shared "
612
+ "prebuilt image)."
613
+ )
614
+ worker_image = images[0] if images else None
615
+
495
616
  # Provenance guard: when ``runtime: auto`` silently fell back to
496
617
  # ``none`` and the spec declares any containers, the recipe will run
497
618
  # on the host while the manifest's ``container_image`` field still
@@ -557,57 +678,80 @@ def run(
557
678
  raise click.ClickException(str(e))
558
679
 
559
680
  with cluster_for_run(
560
- verbose=verbose, local_directory=str(rundirs.dask_local)
561
- ) as scheduler_addr:
562
- env = {
563
- **os.environ,
564
- "DASK_SCHEDULER_ADDRESS": scheduler_addr,
565
- # The dask plugin's worker-side ``_run_shell`` takes this
566
- # ``flock`` before forwarding a rule's lightcone output, so
567
- # parallel rules' blocks never interleave at the line level
568
- # — even across nodes. The lockfile sits under our scratch
569
- # root specifically to avoid DVS on NERSC.
570
- "LIGHTCONE_OUT_LOCK": str(rundirs.lock_path),
571
- }
681
+ verbose=verbose,
682
+ local_directory=str(rundirs.dask_local),
683
+ worker_image=worker_image,
684
+ max_workers=int(n),
685
+ ) as cluster_env:
686
+ env = {**os.environ, **cluster_env}
572
687
  if verbose:
573
688
  console.print(f"[dim]$ {' '.join(cmd)}[/dim]")
574
- sys.exit(subprocess.run(cmd, env=env).returncode)
575
- sys.exit(_run_silent(cmd, env=env, scratch_root=rundirs.root))
689
+ sys.exit(
690
+ _run_snakemake(
691
+ cmd, env=env, scratch_root=rundirs.root, verbose=verbose
692
+ )
693
+ )
576
694
 
577
695
 
578
- def _run_silent(
696
+ def _run_snakemake(
579
697
  cmd: list[str],
580
698
  *,
581
699
  env: dict[str, str],
582
700
  scratch_root: Path,
701
+ verbose: bool,
583
702
  ) -> int:
584
- """Run snakemake with its own output suppressed.
585
-
586
- All user-facing output for the run flows through dask workers
587
- (``_run_shell`` in the executor plugin → terminal stdout under
588
- ``flock``). The parent snakemake process here only emits its own
589
- bootstrap chatter (DAG building, rule selection, "Workflow finished")
590
- plus, on failure, a workflow-level diagnostic. We discard stdout
591
- wholesale and tail stderr into a bounded ring buffer so a workflow
592
- crash leaves a real log behind without that log being visible
593
- during a successful run.
703
+ """Run snakemake, forwarding the run's narrative output.
704
+
705
+ The executor plugin prints each finished rule's block of
706
+ sentinel-prefixed lines (see :data:`lightcone.engine.runner.SENTINEL`)
707
+ on the snakemake process's stdout. We forward those lines — prefix
708
+ stripped — to the terminal and drop everything else snakemake emits
709
+ (DAG chatter, job stats), so a run reads as a clean narrative.
710
+ Verbose mode forwards the noise too.
711
+
712
+ stderr is tailed into a bounded ring buffer so a workflow crash
713
+ leaves a real log behind without it being visible during a
714
+ successful run (verbose passes stderr straight through instead).
594
715
  """
595
716
  from collections import deque
596
717
 
718
+ from lightcone.engine.runner import SENTINEL
719
+
597
720
  proc = subprocess.Popen(
598
721
  cmd,
599
722
  env=env,
600
- stdout=subprocess.DEVNULL,
601
- stderr=subprocess.PIPE,
723
+ stdout=subprocess.PIPE,
724
+ stderr=None if verbose else subprocess.PIPE,
602
725
  text=True,
603
726
  bufsize=1,
604
727
  )
605
- assert proc.stderr is not None
728
+ assert proc.stdout is not None
606
729
  tail: deque[str] = deque(maxlen=400)
607
- for line in proc.stderr:
608
- tail.append(line)
730
+
731
+ def _pump_stderr() -> None:
732
+ assert proc.stderr is not None
733
+ for line in proc.stderr:
734
+ tail.append(line)
735
+
736
+ import threading
737
+
738
+ stderr_thread: threading.Thread | None = None
739
+ if not verbose:
740
+ stderr_thread = threading.Thread(target=_pump_stderr, daemon=True)
741
+ stderr_thread.start()
742
+
743
+ for line in proc.stdout:
744
+ if line.startswith(SENTINEL):
745
+ sys.stdout.write(line[len(SENTINEL):])
746
+ sys.stdout.flush()
747
+ elif verbose:
748
+ sys.stdout.write(line)
749
+ sys.stdout.flush()
750
+
609
751
  rc = proc.wait()
610
- if rc != 0:
752
+ if stderr_thread is not None:
753
+ stderr_thread.join(timeout=5)
754
+ if rc != 0 and not verbose:
611
755
  log = scratch_root / f"snakemake-stderr-{os.getpid()}.log"
612
756
  try:
613
757
  log.parent.mkdir(parents=True, exist_ok=True)
@@ -637,6 +781,18 @@ def _build_snakemake_cmd(
637
781
  ``--rerun-triggers`` uses ``nargs=+`` in snakemake's argparse, so without
638
782
  an explicit ``--`` separator it greedily consumes the first positional
639
783
  target path as an extra trigger value, causing an "invalid choice" error.
784
+
785
+ ``--shared-fs-usage`` lists everything *except*
786
+ ``software-deployment``. With it included (snakemake's default),
787
+ spawned job commands embed the *driver's* ``sys.executable`` — a
788
+ path that doesn't exist inside a Dask Gateway worker image. Without
789
+ it, workers invoke plain ``python`` from their own environment,
790
+ which is equally correct on the other backends: LocalCluster
791
+ threads and srun-launched SLURM workers inherit the driver's
792
+ activated environment (and SLURM setups already require it — see
793
+ the ``dask``-on-PATH check in the cluster module). One invocation
794
+ shape for every backend; everything else stays shared via the
795
+ common filesystem (persistence, inputs/outputs, sources).
640
796
  """
641
797
  cmd: list[str] = [
642
798
  "snakemake",
@@ -650,6 +806,12 @@ def _build_snakemake_cmd(
650
806
  n,
651
807
  "--executor",
652
808
  "dask",
809
+ "--shared-fs-usage",
810
+ "persistence",
811
+ "input-output",
812
+ "sources",
813
+ "storage-local-copies",
814
+ "source-cache",
653
815
  "--rerun-triggers",
654
816
  *rerun_triggers.split(","),
655
817
  ]
@@ -809,7 +971,10 @@ def verify(universe: str | None) -> None:
809
971
  @click.option(
810
972
  "--runtime",
811
973
  default=None,
812
- help="docker | podman | podman-hpc (overrides ~/.lightcone/config.yaml)",
974
+ help=(
975
+ "docker | podman | podman-hpc | kubernetes "
976
+ "(overrides ~/.lightcone/config.yaml)"
977
+ ),
813
978
  )
814
979
  def build(force: bool, runtime: str | None) -> None:
815
980
  """Build container images declared in astra.yaml.
@@ -820,6 +985,12 @@ def build(force: bool, runtime: str | None) -> None:
820
985
  image store. Pre-built registry images (``python:3.12-slim``,
821
986
  ``ghcr.io/foo/bar:tag``) are skipped — the runtime pulls them at
822
987
  ``lc run`` time.
988
+
989
+ On a deployment without a local OCI runtime (the ``kubernetes``
990
+ runtime on a lightcone JupyterHub), the same command builds through
991
+ the deployment's GCP Cloud Build service instead and pushes
992
+ ``<registry>/lc-<project>:<hash>`` — same content-addressed
993
+ identity, zero configuration.
823
994
  """
824
995
  from lightcone.engine.container import ContainerBuildError, load_runtime
825
996
 
@@ -842,21 +1013,27 @@ def build(force: bool, runtime: str | None) -> None:
842
1013
  console.print("[green]Done.[/green]")
843
1014
 
844
1015
 
845
- def _ensure_images(project: Path, *, runtime: str, force: bool = False) -> None:
1016
+ def _ensure_images(project: Path, *, runtime: str, force: bool = False) -> list[str]:
846
1017
  """Build/pull every container image referenced in astra.yaml.
847
1018
 
848
- No-op when *runtime* is ``"none"``. Idempotent: skips images already
849
- present in the runtime's local image store. Used by ``lc build`` (with
850
- ``--force`` exposed) and as a pre-flight by ``lc run`` so the first
851
- invocation after editing a Containerfile doesn't fail mid-DAG with a
852
- missing image.
1019
+ Returns the distinct resolved images, in declaration order (local
1020
+ tags or registry refs for Containerfile specs, prebuilt specs
1021
+ as-is). No-op (and empty) when *runtime* is ``"none"``.
1022
+
1023
+ Idempotent: skips images already present (local image store, or the
1024
+ deployment registry on the ``kubernetes`` runtime — where builds go
1025
+ through Cloud Build instead of a local OCI CLI). Used by ``lc build``
1026
+ (with ``--force`` exposed) and as a pre-flight by ``lc run`` so the
1027
+ first invocation after editing a Containerfile doesn't fail mid-DAG
1028
+ with a missing image.
853
1029
  """
854
1030
  if runtime == "none":
855
- return
1031
+ return []
856
1032
 
857
1033
  from astra.helpers import load_yaml, resolve_analysis_tree
858
1034
 
859
1035
  from lightcone.engine.container import (
1036
+ KUBERNETES,
860
1037
  ContainerBuildError,
861
1038
  build_image,
862
1039
  compute_image_tag,
@@ -869,6 +1046,7 @@ def _ensure_images(project: Path, *, runtime: str, force: bool = False) -> None:
869
1046
  spec = resolve_analysis_tree(load_yaml(project / "astra.yaml"), project)
870
1047
  project_name = (spec.get("name") or project.name).lower().replace(" ", "-")
871
1048
 
1049
+ images: list[str] = []
872
1050
  seen: set[str] = set()
873
1051
  for to in collect_tree_outputs(spec):
874
1052
  recipe = to.output_def.get("recipe") or {}
@@ -881,8 +1059,13 @@ def _ensure_images(project: Path, *, runtime: str, force: bool = False) -> None:
881
1059
  continue
882
1060
  seen.add(spec_str)
883
1061
  if not is_containerfile(spec_str, project):
884
- # Registry image — pull so ``lc run`` can use ``--pull=never``
885
- # without depending on the runtime's registry resolution.
1062
+ images.append(spec_str)
1063
+ if runtime == KUBERNETES:
1064
+ # Nothing to materialize: worker pods pull registry
1065
+ # images straight from their source.
1066
+ continue
1067
+ # Pull so ``lc run`` can use ``--pull=never`` without
1068
+ # depending on the runtime's registry resolution.
886
1069
  if image_exists_locally(spec_str, runtime=runtime) and not force:
887
1070
  continue
888
1071
  console.print(f"[cyan]Pulling[/cyan] {spec_str} [dim](via {runtime})[/dim]")
@@ -892,8 +1075,13 @@ def _ensure_images(project: Path, *, runtime: str, force: bool = False) -> None:
892
1075
  raise click.ClickException(str(e))
893
1076
  continue
894
1077
 
1078
+ if runtime == KUBERNETES:
1079
+ images.append(_cloudbuild_image(project, spec_str, project_name, force))
1080
+ continue
1081
+
895
1082
  containerfile = project / spec_str
896
1083
  tag = compute_image_tag(project_name, containerfile, project)
1084
+ images.append(tag)
897
1085
  if image_exists_locally(tag, runtime=runtime) and not force:
898
1086
  continue
899
1087
  console.print(
@@ -903,6 +1091,51 @@ def _ensure_images(project: Path, *, runtime: str, force: bool = False) -> None:
903
1091
  build_image(tag, containerfile, project, runtime=runtime)
904
1092
  except ContainerBuildError as e:
905
1093
  raise click.ClickException(str(e))
1094
+ return images
1095
+
1096
+
1097
+ def _cloudbuild_image(
1098
+ project: Path, spec_str: str, project_name: str, force: bool
1099
+ ) -> str:
1100
+ """Ensure one Containerfile's image via GCP Cloud Build; return its ref."""
1101
+ from lightcone.engine.cloudbuild import (
1102
+ CloudBuildError,
1103
+ cloudbuild_available,
1104
+ ensure_image,
1105
+ )
1106
+
1107
+ if not cloudbuild_available():
1108
+ raise click.ClickException(
1109
+ "No image build backend on this host: the kubernetes runtime "
1110
+ "has no local OCI CLI and this environment does not provide "
1111
+ "the Cloud Build contract (LIGHTCONE_REGISTRY + "
1112
+ "LIGHTCONE_BUILD_BUCKET). On a lightcone JupyterHub these are "
1113
+ "injected into every user pod — ask the hub admin."
1114
+ )
1115
+
1116
+ status = console.status(f"[cyan]Ensuring image for[/cyan] {spec_str} …")
1117
+ status.start()
1118
+
1119
+ def on_progress(phase: str, detail: str) -> None:
1120
+ note = f" [dim]{detail}[/dim]" if detail else ""
1121
+ status.update(
1122
+ f"[cyan]Ensuring image for[/cyan] {spec_str}: {phase}{note}"
1123
+ )
1124
+
1125
+ try:
1126
+ ref = ensure_image(
1127
+ project,
1128
+ spec_str,
1129
+ project_name=project_name,
1130
+ force=force,
1131
+ on_progress=on_progress,
1132
+ )
1133
+ except CloudBuildError as e:
1134
+ raise click.ClickException(str(e))
1135
+ finally:
1136
+ status.stop()
1137
+ console.print(f"[green]✓[/green] Worker image: [cyan]{ref}[/cyan]")
1138
+ return ref
906
1139
 
907
1140
 
908
1141
  # =============================================================================