calkit-python 0.47.8__py3-none-any.whl → 0.47.9__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.
Files changed (48) hide show
  1. calkit/agent_skills/conventions/SKILL.md +26 -0
  2. calkit/cli/config.py +9 -1
  3. calkit/cli/latex.py +188 -7
  4. calkit/cli/main/core.py +192 -8
  5. calkit/cli/new.py +7 -8
  6. calkit/cli/overleaf.py +8 -7
  7. calkit/cli/update.py +251 -0
  8. calkit/docker.py +129 -5
  9. calkit/dvc/core.py +39 -0
  10. calkit/environments.py +4 -6
  11. calkit/install.py +7 -7
  12. calkit/latex.py +95 -1
  13. calkit/models/pipeline.py +19 -4
  14. calkit/pipeline.py +146 -6
  15. calkit/questions.py +31 -4
  16. calkit/resources/devcontainer/devcontainer.json +1 -1
  17. calkit/resources/vscode/settings.json +1 -1
  18. calkit/tests/cli/main/test_core.py +186 -0
  19. calkit/tests/cli/main/test_xr.py +1 -0
  20. calkit/tests/cli/test_check.py +20 -9
  21. calkit/tests/cli/test_latex.py +113 -0
  22. calkit/tests/cli/test_new.py +3 -2
  23. calkit/tests/cli/test_overleaf.py +2 -1
  24. calkit/tests/cli/test_update.py +73 -0
  25. calkit/tests/models/test_pipeline.py +15 -0
  26. calkit/tests/test_conda.py +3 -0
  27. calkit/tests/test_docker.py +61 -0
  28. calkit/tests/test_environments.py +2 -1
  29. calkit/tests/test_pipeline.py +94 -0
  30. calkit/tests/test_questions.py +37 -0
  31. {calkit_python-0.47.8.dist-info → calkit_python-0.47.9.dist-info}/METADATA +190 -58
  32. {calkit_python-0.47.8.dist-info → calkit_python-0.47.9.dist-info}/RECORD +48 -48
  33. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  34. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  35. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  36. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  37. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  38. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  39. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  40. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  41. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  42. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  43. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  44. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  45. {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  46. {calkit_python-0.47.8.dist-info → calkit_python-0.47.9.dist-info}/WHEEL +0 -0
  47. {calkit_python-0.47.8.dist-info → calkit_python-0.47.9.dist-info}/entry_points.txt +0 -0
  48. {calkit_python-0.47.8.dist-info → calkit_python-0.47.9.dist-info}/licenses/LICENSE +0 -0
@@ -25,6 +25,8 @@ is `calkit.yaml`, the project's metadata database.
25
25
  - `environments`—computational environments (Python venvs, Conda, Docker,
26
26
  R, Julia, MATLAB, etc.)
27
27
  - `pipeline.stages`—the reproducible pipeline
28
+ - `questions`—research questions, hypotheses, and answers backed by
29
+ evidence the pipeline produces
28
30
  - `notebooks`—registered Jupyter notebooks
29
31
  - `datasets`, `figures`, `publications`—versioned project outputs
30
32
  - `procedures`, `calculations`, `references`—supporting metadata
@@ -209,6 +211,9 @@ DVC handles:
209
211
  | `calkit add <file>` | Add a file to version control |
210
212
  | `calkit check env --name <env>` | Verify an environment matches its spec |
211
213
  | `calkit new` | Create new project objects (notebook, dataset, etc.) |
214
+ | `calkit new question "<text>"` | Record a research question |
215
+ | `calkit list questions` | Show questions with answers rendered from evidence |
216
+ | `calkit check questions` | Check answers against current evidence |
212
217
 
213
218
  ## `calkit xr`: The fastest path to a reproducible stage
214
219
 
@@ -233,6 +238,27 @@ calkit xr scripts/run.py --environment main
233
238
  calkit xr scripts/run.py --dry-run # see what would happen without running
234
239
  ```
235
240
 
241
+ ## Questions and answers
242
+
243
+ Research in a Calkit project is organized around the `questions` list in
244
+ `calkit.yaml`. Follow this workflow:
245
+
246
+ 1. Before starting an analysis, make sure the question it answers is
247
+ recorded, e.g., with `calkit new question`. If the user hasn't stated
248
+ one, ask them for it rather than inventing it. Add a `hypothesis` if
249
+ they have one.
250
+ 2. Produce evidence with pipeline stages, never by hand, so every figure,
251
+ table, and number traces back to data and code.
252
+ 3. Write the `answer` citing that evidence. Read numbers out of results
253
+ files and put them in the prose with `{name}` placeholders instead of
254
+ typing them in, either one per `kind: value` entry, or several from one
255
+ file with a `kind: result` entry's `values` mapping of names to keys.
256
+ 4. After a pipeline run changes results, run `calkit check questions`,
257
+ then use the `check-questions` skill to judge whether each answer's
258
+ wording still follows from its evidence.
259
+
260
+ See the Calkit docs on questions for the full evidence schema.
261
+
236
262
  ## Version control conventions
237
263
 
238
264
  - Source code and small outputs: tracked with Git
calkit/cli/config.py CHANGED
@@ -12,7 +12,7 @@ import typer
12
12
  from typing_extensions import Annotated
13
13
 
14
14
  import calkit
15
- from calkit.cli.core import raise_error
15
+ from calkit.cli.core import raise_error, warn
16
16
 
17
17
  config_app = typer.Typer(no_args_is_help=True)
18
18
 
@@ -189,9 +189,17 @@ def setup_remote(
189
189
  ):
190
190
  """Set up the Calkit hub as the default DVC remote and store a token
191
191
  in the local config.
192
+
193
+ Deprecated: this configures the project, not Calkit itself, which is
194
+ what the rest of this app is for.
192
195
  """
193
196
  from git.exc import InvalidGitRepositoryError
194
197
 
198
+ warn(
199
+ "'calkit config remote' is deprecated; use 'calkit update hub', "
200
+ "which also creates the project on the hub if it isn't there"
201
+ )
202
+
195
203
  from calkit.dvc import configure_remote, set_remote_auth
196
204
 
197
205
  try:
calkit/cli/latex.py CHANGED
@@ -137,6 +137,20 @@ def from_json(
137
137
  json2latex.dump(cmd_name, formatted, f)
138
138
 
139
139
 
140
+ def _tex_env_vars(source_date_epoch: str | None) -> dict[str, str]:
141
+ r"""The environmental variables a TeX command needs, beyond the ambient.
142
+
143
+ ``FORCE_SOURCE_DATE`` is what makes pdfTeX apply the date to
144
+ ``\pdfcreationdate`` and friends, not only to the trailer ID.
145
+ """
146
+ if source_date_epoch is None:
147
+ return {}
148
+ return {
149
+ "SOURCE_DATE_EPOCH": source_date_epoch,
150
+ "FORCE_SOURCE_DATE": "1",
151
+ }
152
+
153
+
140
154
  @latex_app.command(name="from-questions")
141
155
  def from_questions(
142
156
  output_fpaths: Annotated[
@@ -180,6 +194,7 @@ def _tex_cmd(
180
194
  no_check: bool,
181
195
  verbose: bool,
182
196
  dep: str,
197
+ env_vars: dict[str, str] | None = None,
183
198
  ) -> list[str]:
184
199
  """Wrap a TeX command so it runs wherever the project's TeX lives.
185
200
 
@@ -189,31 +204,162 @@ def _tex_cmd(
189
204
  inside the project -- which is why the diff builds its copy of the
190
205
  base revision there rather than in a temp directory.
191
206
  """
207
+ env_vars = env_vars or {}
192
208
  if environment is not None:
193
209
  cmd = (
194
210
  ["calkit", "xenv", "--name", environment]
195
211
  + (["--no-check"] if no_check else [])
212
+ # Named rather than inherited: the environment may run in a
213
+ # container, which inherits nothing from here
214
+ + [f"--env-var={k}={v}" for k, v in env_vars.items()]
196
215
  + ["--"]
197
216
  + tex_cmd
198
217
  )
199
218
  elif calkit.check_dep_exists(dep):
200
219
  cmd = tex_cmd
201
220
  else:
221
+ # Pulled deliberately, since docker run's implicit pull of an
222
+ # image that isn't there can stall rather than report it
223
+ try:
224
+ calkit.docker.ensure_image_available(
225
+ calkit.latex.DEFAULT_LATEX_IMAGE
226
+ )
227
+ except ValueError as e:
228
+ raise_error(str(e))
229
+ # Packages fetched at run time go to the project's cache, which
230
+ # the working directory mount already covers, rather than over
231
+ # the image's own tree, which would hide the distribution entirely
232
+ os.makedirs(calkit.latex.get_texmf_cache_dir(), exist_ok=True)
202
233
  cmd = [
203
234
  "docker",
204
235
  "run",
205
236
  "--rm",
206
237
  "-v",
207
238
  f"{os.getcwd()}:/work",
239
+ "-e",
240
+ f"TEXMFHOME={calkit.latex.CONTAINER_TEXMF_DIR}",
208
241
  "-w",
209
242
  "/work",
210
- "texlive/texlive:latest-full",
211
- ] + tex_cmd
243
+ ]
244
+ # As the user, so a PDF doesn't come back owned by root
245
+ try:
246
+ cmd += ["--user", f"{os.getuid()}:{os.getgid()}"]
247
+ except AttributeError:
248
+ # Windows has no UID to map
249
+ pass
250
+ # The container gets its own environment, so anything the command
251
+ # needs has to be handed to it rather than inherited
252
+ for key, value in env_vars.items():
253
+ cmd += ["-e", f"{key}={value}"]
254
+ cmd += [calkit.latex.DEFAULT_LATEX_IMAGE] + tex_cmd
212
255
  if verbose:
213
256
  typer.echo(f"Running command: {cmd}")
214
257
  return cmd
215
258
 
216
259
 
260
+ def _run_latexmk(
261
+ cmd: list[str],
262
+ env: dict[str, str] | None,
263
+ log_path: str,
264
+ fdb_path: str,
265
+ environment: str | None,
266
+ verbose: bool,
267
+ ) -> int:
268
+ """Run latexmk, fetching the TeX packages it's missing and retrying.
269
+
270
+ Only where what's fetched is kept with the project, i.e., Calkit's
271
+ LaTeX image, run directly or as a Docker environment built on it. A
272
+ system TeX is the user's to manage, and anything installed in another
273
+ container is gone when it exits. Returns latexmk's exit status.
274
+ """
275
+ import shlex
276
+
277
+ def in_tex(tex_cmd: list[str]) -> list[str]:
278
+ # Where the build runs, so what's installed is what it sees
279
+ return _tex_cmd(
280
+ tex_cmd,
281
+ environment=environment,
282
+ no_check=True,
283
+ verbose=verbose,
284
+ dep="latexmk",
285
+ )
286
+
287
+ def can_fetch() -> bool:
288
+ if environment is None:
289
+ return not calkit.check_dep_exists("latexmk")
290
+ envs = calkit.load_calkit_info().get("environments", {})
291
+ if envs.get(environment, {}).get("kind") != "docker":
292
+ return False
293
+ # The image switches to the project's cache when it's there
294
+ os.makedirs(calkit.latex.get_texmf_cache_dir(), exist_ok=True)
295
+ out = subprocess.run(
296
+ in_tex(["printenv", "TEXMFHOME"]), capture_output=True, text=True
297
+ ).stdout.strip()
298
+ return out.endswith("/.calkit/local/texmf")
299
+
300
+ fetched: set[str] = set()
301
+ fetchable = None
302
+ while True:
303
+ try:
304
+ subprocess.check_call(cmd, env=env)
305
+ return 0
306
+ except subprocess.CalledProcessError as e:
307
+ status = e.returncode
308
+ try:
309
+ with open(log_path, encoding="utf-8", errors="replace") as f:
310
+ log = f.read()
311
+ except OSError:
312
+ return status
313
+ # A file still missing after fetching it is one fetching can't fix
314
+ missing = [
315
+ f
316
+ for f in calkit.latex.find_missing_tex_files(log)
317
+ if f not in fetched
318
+ ]
319
+ if not missing:
320
+ return status
321
+ if fetchable is None:
322
+ fetchable = can_fetch()
323
+ if not fetchable:
324
+ return status
325
+ packages = []
326
+ for name in missing:
327
+ out = subprocess.run(
328
+ in_tex(["tlmgr", "search", "--global", "--file", f"/{name}"]),
329
+ capture_output=True,
330
+ text=True,
331
+ ).stdout
332
+ # Package names end with a colon; the paths under them are
333
+ # indented, and the first one ending in the file is its owner
334
+ owner = None
335
+ for line in out.splitlines():
336
+ if line.endswith(":") and not line.startswith((" ", "\t")):
337
+ owner = line[:-1]
338
+ elif owner and line.strip().endswith(f"/{name}"):
339
+ packages.append(owner)
340
+ break
341
+ packages = list(dict.fromkeys(packages))
342
+ if not packages:
343
+ return status
344
+ typer.echo(
345
+ f"Fetching TeX packages for {', '.join(missing)}: "
346
+ f"{', '.join(packages)}"
347
+ )
348
+ install = in_tex(["tlmgr", "--usermode", "install", *packages])
349
+ if verbose:
350
+ typer.echo(f"Running command: {shlex.join(install)}")
351
+ # A font's install fails at its last step, updating the font map,
352
+ # which user mode can't do in this image, with the files already in
353
+ # place and usable. Whether it worked is the retry's to say.
354
+ if subprocess.run(install, capture_output=not verbose).returncode:
355
+ if verbose:
356
+ warn(f"tlmgr reported an error installing {packages}")
357
+ fetched.update(missing)
358
+ # Otherwise latexmk remembers the failure and won't try again
359
+ if os.path.isfile(fdb_path):
360
+ os.remove(fdb_path)
361
+
362
+
217
363
  @latex_app.command(name="build")
218
364
  def build(
219
365
  tex_file: Annotated[str, typer.Argument(help="The .tex file to compile.")],
@@ -300,6 +446,21 @@ def build(
300
446
  system environment if available. If not available, a TeX Live Docker
301
447
  container will be used.
302
448
  """
449
+ # latexmk records a failed run in its file database and then refuses
450
+ # to try again, reporting "Nothing to do" and exiting non-zero with no
451
+ # PDF. Running it again is the first thing anyone does after a
452
+ # failure, so the record is cleared when there is no PDF to show for
453
+ # it, which makes a retry a real retry.
454
+ tex_dir = os.path.dirname(tex_file) or "."
455
+ stem = Path(tex_file).stem
456
+ pdf_dir = output_dir if output_dir is not None else tex_dir
457
+ if not os.path.isfile(os.path.join(pdf_dir, stem + ".pdf")):
458
+ fdb_dir = aux_dir if aux_dir is not None else tex_dir
459
+ fdb_fpath = os.path.join(fdb_dir, stem + ".fdb_latexmk")
460
+ if os.path.isfile(fdb_fpath):
461
+ if verbose:
462
+ typer.echo(f"Removing {fdb_fpath} so latexmk will retry")
463
+ os.remove(fdb_fpath)
303
464
  # Now formulate the command
304
465
  latexmk_cmd = ["latexmk", "-pdf", "-cd"]
305
466
  if latexmk_rc_path is not None:
@@ -313,7 +474,6 @@ def build(
313
474
  # latexmk runs with -cd, so its -outdir/-auxdir are relative to the .tex
314
475
  # file's directory; convert the (current-directory-relative) Calkit paths
315
476
  # into that frame.
316
- tex_dir = os.path.dirname(tex_file) or "."
317
477
  if output_dir is not None:
318
478
  rel = Path(os.path.relpath(output_dir, tex_dir)).as_posix()
319
479
  latexmk_cmd.append(f"-outdir={rel}")
@@ -324,16 +484,24 @@ def build(
324
484
  # User pass-through args come last so they can override Calkit's defaults.
325
485
  latexmk_cmd += latexmk_args
326
486
  latexmk_cmd.append(tex_file)
487
+ tex_env_vars = _tex_env_vars(calkit.latex.get_source_date_epoch(tex_file))
327
488
  cmd = _tex_cmd(
328
489
  latexmk_cmd,
329
490
  environment=environment,
330
491
  no_check=no_check,
331
492
  verbose=verbose,
332
493
  dep="latexmk",
494
+ env_vars=tex_env_vars,
333
495
  )
334
- try:
335
- subprocess.check_call(cmd)
336
- except subprocess.CalledProcessError:
496
+ log_dir = aux_dir or output_dir or tex_dir
497
+ if _run_latexmk(
498
+ cmd,
499
+ env=(os.environ | tex_env_vars) if tex_env_vars else None,
500
+ log_path=os.path.join(log_dir, stem + ".log"),
501
+ fdb_path=os.path.join(log_dir, stem + ".fdb_latexmk"),
502
+ environment=environment,
503
+ verbose=verbose,
504
+ ):
337
505
  raise_error("latexmk failed")
338
506
 
339
507
 
@@ -1064,16 +1232,29 @@ def _build_diff(
1064
1232
  # defaults
1065
1233
  latexmk_cmd += latexmk_args
1066
1234
  latexmk_cmd.append(diff_tex_fpath)
1235
+ tex_env_vars = _tex_env_vars(
1236
+ calkit.latex.get_source_date_epoch(tex_file_fpath)
1237
+ )
1067
1238
  cmd = _tex_cmd(
1068
1239
  latexmk_cmd,
1069
1240
  environment=environment,
1070
1241
  no_check=no_check,
1071
1242
  verbose=verbose,
1072
1243
  dep="latexmk",
1244
+ env_vars=tex_env_vars,
1073
1245
  )
1074
1246
  typer.echo("Building the marked-up document")
1075
1247
  try:
1076
- subprocess.check_call(cmd)
1248
+ status = _run_latexmk(
1249
+ cmd,
1250
+ env=(os.environ | tex_env_vars) if tex_env_vars else None,
1251
+ log_path=os.path.join(aux_dir, f"{stem}-diff.log"),
1252
+ fdb_path=os.path.join(aux_dir, f"{stem}-diff.fdb_latexmk"),
1253
+ environment=environment,
1254
+ verbose=verbose,
1255
+ )
1256
+ if status:
1257
+ raise subprocess.CalledProcessError(status, cmd)
1077
1258
  except subprocess.CalledProcessError as e:
1078
1259
  # -silent hides why, so show the errors LaTeX logged
1079
1260
  log_path = Path(aux_dir, f"{stem}-diff.log")
calkit/cli/main/core.py CHANGED
@@ -545,7 +545,7 @@ def get_status(
545
545
  raise subprocess.CalledProcessError(result, "dvc init")
546
546
  except subprocess.CalledProcessError as e:
547
547
  raise_error(f"Failed to initialize DVC repository: {e}")
548
- valid_categories = ["project", "git", "dvc", "pipeline"]
548
+ valid_categories = ["project", "questions", "git", "dvc", "pipeline"]
549
549
  if categories is not None:
550
550
  for category in categories:
551
551
  if category not in valid_categories:
@@ -580,6 +580,37 @@ def get_status(
580
580
  "Failed pipeline environment checks for: "
581
581
  + ", ".join(pipeline_status.failed_environment_checks)
582
582
  )
583
+ # Checking questions needs to know which stages are stale, which is the
584
+ # expensive part of the pipeline status computed above, so reuse that
585
+ # rather than asking DVC a second time
586
+ questions_status = None
587
+ if "questions" in categories and ck_info.get("questions"):
588
+ from calkit.pipeline import frozen_tainted_stage_names
589
+ from calkit.questions import check_questions
590
+
591
+ stale_stages = None
592
+ frozen_stages = None
593
+ # A pipeline status that bailed out, e.g., on a failed environment
594
+ # check, has no staleness in it; let the check work it out itself
595
+ # rather than reporting evidence as current because nothing was
596
+ # computed
597
+ if (
598
+ pipeline_status is not None
599
+ and not pipeline_status.errors
600
+ and not pipeline_status.failed_environment_checks
601
+ ):
602
+ stale_stages = {
603
+ n.split("@")[0] for n in pipeline_status.stale_stage_names
604
+ }
605
+ try:
606
+ frozen_stages = frozen_tainted_stage_names(ck_info=ck_info)
607
+ except Exception:
608
+ frozen_stages = set()
609
+ questions_status = check_questions(
610
+ ck_info=ck_info,
611
+ stale_stages=stale_stages,
612
+ frozen_stages=frozen_stages,
613
+ )
583
614
  if as_json:
584
615
  status_dict: dict[str, Any] = {}
585
616
  if "project" in categories:
@@ -593,6 +624,8 @@ def get_status(
593
624
  "timestamp": status.timestamp.isoformat(),
594
625
  }
595
626
  )
627
+ if questions_status is not None:
628
+ status_dict["questions"] = questions_status.model_dump(mode="json")
596
629
  if "git" in categories:
597
630
  try:
598
631
  repo = calkit.git.get_repo()
@@ -675,6 +708,16 @@ def get_status(
675
708
  'Project status not set. Use "calkit new status" to update.'
676
709
  )
677
710
  typer.echo()
711
+ if questions_status is not None:
712
+ from calkit.questions import format_summary
713
+
714
+ print_sep("Questions")
715
+ # The summary can carry a check mark, which a Windows console
716
+ # can't encode
717
+ calkit.echo(format_summary(questions_status))
718
+ if not questions_status.ok:
719
+ typer.echo("Run 'calkit check questions' for detail.")
720
+ typer.echo()
678
721
  if "git" in categories:
679
722
  print_sep("Git")
680
723
  git_cmd = ["git", "status"]
@@ -861,7 +904,27 @@ def add(
861
904
  """
862
905
  import dvc.repo
863
906
  from dvc.exceptions import NotDvcRepoError
864
- from git.exc import InvalidGitRepositoryError
907
+ from git.exc import GitCommandError, InvalidGitRepositoryError
908
+
909
+ def git_add(*paths_to_add: str) -> None:
910
+ """Stage paths, saying why rather than stopping if Git refuses.
911
+
912
+ Git refuses for reasons that don't make the rest of what was asked
913
+ for wrong, e.g., a path a .gitignore excludes, and this used to
914
+ shell out and ignore the exit status entirely. Paths are relative
915
+ to the working directory, which is what someone running this from
916
+ a subdirectory means.
917
+ """
918
+ try:
919
+ # Resolved here, since GitPython runs Git from the repo's root
920
+ # while these paths are relative to the working directory
921
+ repo.git.add(*[os.path.abspath(p) for p in paths_to_add])
922
+ except GitCommandError as e:
923
+ # GitPython labels and quotes what Git wrote; what it wrote is
924
+ # the part worth reading
925
+ reason = (e.stderr or str(e)).strip()
926
+ reason = reason.removeprefix("stderr: ").strip("'")
927
+ warn(f"Failed to add {', '.join(paths_to_add)} to Git: {reason}")
865
928
 
866
929
  if dry_run:
867
930
  typer.echo("Dry run: No files will be added")
@@ -944,7 +1007,7 @@ def add(
944
1007
  for path in paths:
945
1008
  typer.echo(f"Would add {path} to {to}")
946
1009
  elif to == "git":
947
- subprocess.call(["git", "add"] + paths)
1010
+ git_add(*paths)
948
1011
  elif to == "dvc":
949
1012
  for path in paths:
950
1013
  calkit.git.ensure_dvc_pointer_is_not_ignored(repo, path)
@@ -1040,6 +1103,8 @@ def add(
1040
1103
  paths.append(changed_file)
1041
1104
  zip_path_map = calkit.dvc.zip.get_zip_path_map()
1042
1105
  pipeline_output_storage = calkit.pipeline.get_output_storage_map()
1106
+ lock_out_paths = calkit.dvc.get_lock_out_paths()
1107
+ dvc_scm = None
1043
1108
  for path in paths:
1044
1109
  # Check if this path is already registered as a zip
1045
1110
  posix_path = Path(path).as_posix()
@@ -1061,7 +1126,7 @@ def add(
1061
1126
  typer.echo(
1062
1127
  f"Adding {path} to Git since it's already in the repo"
1063
1128
  )
1064
- subprocess.call(["git", "add", path])
1129
+ git_add(path)
1065
1130
  elif path in dvc_paths:
1066
1131
  if dry_run:
1067
1132
  typer.echo(
@@ -1087,7 +1152,7 @@ def add(
1087
1152
  typer.echo(
1088
1153
  f"Adding {path} to Git per pipeline output storage"
1089
1154
  )
1090
- subprocess.call(["git", "add", path])
1155
+ git_add(path)
1091
1156
  elif pipeline_storage == "dvc-zip":
1092
1157
  if dry_run:
1093
1158
  typer.echo(
@@ -1111,7 +1176,38 @@ def add(
1111
1176
  f"Adding dvc.lock to Git "
1112
1177
  f"({path} is a DVC pipeline output)"
1113
1178
  )
1114
- subprocess.call(["git", "add", "dvc.lock"])
1179
+ git_add("dvc.lock")
1180
+ elif (
1181
+ locked_out := next(
1182
+ (
1183
+ out
1184
+ for out in lock_out_paths
1185
+ if posix_path == out
1186
+ or posix_path.startswith(out + "/")
1187
+ ),
1188
+ None,
1189
+ )
1190
+ ) is not None:
1191
+ # DVC already caches this, and it's only showing up at all
1192
+ # because its ignore entry went missing, e.g., DVC removes
1193
+ # every entry a failed repro added, including those for the
1194
+ # stages that succeeded. Sized up like a new file, a small
1195
+ # one would go to Git alongside DVC's copy.
1196
+ if dry_run:
1197
+ typer.echo(
1198
+ f"Would ignore {path} ({path} is a DVC pipeline output)"
1199
+ )
1200
+ else:
1201
+ typer.echo(
1202
+ f"Ignoring {path} since it's a DVC pipeline output"
1203
+ )
1204
+ # Where and how DVC itself would have written the entry
1205
+ if dvc_scm is None:
1206
+ dvc_scm = calkit.dvc.get_dvc_repo().scm
1207
+ gitignore = dvc_scm.ignore(os.path.abspath(locked_out))
1208
+ if gitignore:
1209
+ repo.git.add(gitignore)
1210
+ repo.git.add("dvc.lock")
1115
1211
  elif os.path.splitext(path)[-1] in DVC_EXTENSIONS:
1116
1212
  if dry_run:
1117
1213
  typer.echo(f"Would add {path} to DVC (per extension)")
@@ -1145,10 +1241,17 @@ def add(
1145
1241
  typer.echo(f"Would add {path} to Git")
1146
1242
  else:
1147
1243
  typer.echo(f"Adding {path} to Git")
1148
- subprocess.call(["git", "add", path])
1244
+ git_add(path)
1149
1245
  if not dry_run:
1150
1246
  if commit_message is not None:
1151
- subprocess.call(["git", "commit", "-m", commit_message])
1247
+ try:
1248
+ repo.git.commit("-m", commit_message)
1249
+ except GitCommandError as e:
1250
+ # Nothing staged is a normal outcome here, e.g., when what
1251
+ # was asked for was already committed
1252
+ output = f"{e.stdout or ''}{e.stderr or ''}"
1253
+ if "nothing to commit" not in output:
1254
+ warn(f"Failed to commit: {output.strip() or e}")
1152
1255
  if push_commit:
1153
1256
  push()
1154
1257
  else:
@@ -1390,6 +1493,10 @@ def save(
1390
1493
  """
1391
1494
  if not paths and not save_all:
1392
1495
  raise_error("Paths must be provided if not using --all")
1496
+ # Asked before anything else runs: the Git and DVC commands below read
1497
+ # from the terminal too, and would take an answer typed ahead
1498
+ if not no_push and not _has_somewhere_to_push():
1499
+ no_push = not _offer_to_connect()
1393
1500
  if paths is not None:
1394
1501
  add(paths, to=to)
1395
1502
  elif save_all:
@@ -1603,6 +1710,9 @@ def push(
1603
1710
  if excluded:
1604
1711
  selected.discard(target)
1605
1712
  _warn_on_hub_mismatch()
1713
+ if selected & {"git", "dvc"} and not _has_somewhere_to_push():
1714
+ if not _offer_to_connect():
1715
+ return
1606
1716
  if "dvc" in selected:
1607
1717
  remotes = calkit.dvc.get_remotes()
1608
1718
  if not no_check_auth:
@@ -1692,6 +1802,51 @@ def push(
1692
1802
  _tell_hub_we_pushed(sorted(selected), git_args)
1693
1803
 
1694
1804
 
1805
+ def _offer_to_connect() -> bool:
1806
+ """Offer to connect a project that has nowhere to push to a hub.
1807
+
1808
+ Pushing it would fail on a Git remote that isn't there, which says
1809
+ nothing about what to do next. What it needs is a hub, so offer one
1810
+ rather than reporting the symptom. True if it can be pushed now.
1811
+ """
1812
+ from calkit.cli.update import update_hub
1813
+ from calkit.dependencies import _is_interactive
1814
+
1815
+ typer.echo(
1816
+ "This project isn't connected to a hub, so there's nowhere to "
1817
+ "push its code and data."
1818
+ )
1819
+ if not _is_interactive():
1820
+ warn("Skipping push; run 'calkit update hub' to connect")
1821
+ return False
1822
+ answer = typer.prompt(
1823
+ "Connect it now? [Y/n]", default="y", show_default=False
1824
+ )
1825
+ if answer.strip().lower() not in ("", "y", "yes"):
1826
+ warn("Skipping push; run 'calkit update hub' when you want to")
1827
+ return False
1828
+ update_hub()
1829
+ return True
1830
+
1831
+
1832
+ def _has_somewhere_to_push() -> bool:
1833
+ """Whether a push has anywhere at all to go.
1834
+
1835
+ Any remote counts, not only a hub's. Plenty of projects push to a Git
1836
+ remote they set up themselves and keep their data elsewhere, and a
1837
+ push for them works exactly as it always did.
1838
+ """
1839
+ try:
1840
+ if calkit.git.get_repo().remotes:
1841
+ return True
1842
+ except Exception:
1843
+ pass
1844
+ try:
1845
+ return bool(calkit.dvc.get_remotes())
1846
+ except Exception:
1847
+ return False
1848
+
1849
+
1695
1850
  def _is_connected_to_a_hub() -> bool:
1696
1851
  """Whether this project belongs to a hub at all.
1697
1852
 
@@ -3117,6 +3272,10 @@ def run(
3117
3272
  # last run's status stays inspectable; it is gitignored.
3118
3273
  os.environ.pop("CALKIT_PIPELINE_RUNNING", None)
3119
3274
  if failed:
3275
+ try:
3276
+ calkit.dvc.restore_output_ignores()
3277
+ except Exception as e:
3278
+ warn(f"Failed to re-ignore pipeline outputs: {e}")
3120
3279
  raise_error("Pipeline failed")
3121
3280
  else:
3122
3281
  calkit.echo("Pipeline completed successfully ✅")
@@ -3270,6 +3429,18 @@ def run_in_env(
3270
3429
  ),
3271
3430
  ),
3272
3431
  ] = None,
3432
+ env_var: Annotated[
3433
+ list[str],
3434
+ typer.Option(
3435
+ "--env-var",
3436
+ help=(
3437
+ "Environmental variable to set for the command, as "
3438
+ "KEY=VALUE. Can be given multiple times. Set in the "
3439
+ "process the command runs in, and passed into a container "
3440
+ "for an environment that runs in one."
3441
+ ),
3442
+ ),
3443
+ ] = [],
3273
3444
  verbose: Annotated[
3274
3445
  bool, typer.Option("--verbose", "-v", help="Print verbose output.")
3275
3446
  ] = False,
@@ -3316,6 +3487,15 @@ def run_in_env(
3316
3487
  "--setup only applies to a 'system' environment, and "
3317
3488
  f"'{env_name}' is of kind '{env.get('kind')}'"
3318
3489
  )
3490
+ # Set here rather than per kind: a container is handed them below,
3491
+ # and everything else runs as a child of this process
3492
+ extra_env_vars = {}
3493
+ for item in env_var:
3494
+ key, sep, value = item.partition("=")
3495
+ if not sep or not key:
3496
+ raise_error(f"Invalid --env-var '{item}'; write it as KEY=VALUE")
3497
+ extra_env_vars[key] = value
3498
+ os.environ[key] = value
3319
3499
  docker_wdir = env.get("wdir", "/work")
3320
3500
  docker_wdir_mount = docker_wdir
3321
3501
  if wdir is not None:
@@ -3445,6 +3625,10 @@ def run_in_env(
3445
3625
  if isinstance(value, str):
3446
3626
  value = os.path.expandvars(value)
3447
3627
  docker_cmd += ["-e", f"{key}={value}"]
3628
+ # A container inherits nothing from here, so what --env-var asked
3629
+ # for has to be handed to it
3630
+ for key, value in extra_env_vars.items():
3631
+ docker_cmd += ["-e", f"{key}={value}"]
3448
3632
  if (gpus := env.get("gpus")) is not None:
3449
3633
  docker_cmd += ["--gpus", gpus]
3450
3634
  if ports := env.get("ports"):