calkit-python 0.50.3__py3-none-any.whl → 0.50.4__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 (49) hide show
  1. calkit/cli/check.py +7 -1
  2. calkit/cli/core.py +32 -0
  3. calkit/cli/export.py +139 -0
  4. calkit/cli/latex.py +49 -4
  5. calkit/cli/main/core.py +147 -5
  6. calkit/config.py +266 -57
  7. calkit/core.py +23 -4
  8. calkit/docker.py +2 -0
  9. calkit/export.py +169 -0
  10. calkit/latex.py +78 -0
  11. calkit/models/pipeline.py +183 -11
  12. calkit/pipeline.py +45 -14
  13. calkit/provenance.py +278 -6
  14. calkit/reproducibility.py +63 -1
  15. calkit/tests/cli/main/test_core.py +184 -0
  16. calkit/tests/cli/test_core.py +33 -0
  17. calkit/tests/cli/test_export.py +152 -0
  18. calkit/tests/cli/test_latex.py +63 -0
  19. calkit/tests/models/test_pipeline.py +76 -0
  20. calkit/tests/test_config.py +238 -48
  21. calkit/tests/test_core.py +24 -0
  22. calkit/tests/test_docker.py +6 -0
  23. calkit/tests/test_export.py +85 -0
  24. calkit/tests/test_latex.py +60 -0
  25. calkit/tests/test_pipeline.py +91 -0
  26. calkit/tests/test_provenance.py +186 -0
  27. calkit/tests/test_reproducibility.py +88 -4
  28. calkit/tests/test_upgrade.py +9 -1
  29. calkit/tests/test_windows.py +56 -0
  30. calkit/upgrade.py +7 -0
  31. calkit/windows.py +59 -0
  32. {calkit_python-0.50.3.dist-info → calkit_python-0.50.4.dist-info}/METADATA +56 -1
  33. {calkit_python-0.50.3.dist-info → calkit_python-0.50.4.dist-info}/RECORD +49 -43
  34. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  35. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  36. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  37. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  38. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  39. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  40. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  41. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  42. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  43. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  44. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  45. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  46. {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  47. {calkit_python-0.50.3.dist-info → calkit_python-0.50.4.dist-info}/WHEEL +0 -0
  48. {calkit_python-0.50.3.dist-info → calkit_python-0.50.4.dist-info}/entry_points.txt +0 -0
  49. {calkit_python-0.50.3.dist-info → calkit_python-0.50.4.dist-info}/licenses/LICENSE +0 -0
calkit/cli/check.py CHANGED
@@ -16,7 +16,7 @@ import dotenv
16
16
  import typer
17
17
 
18
18
  import calkit
19
- from calkit.cli import AliasGroup, raise_error, warn
19
+ from calkit.cli import AliasGroup, raise_error, warn, warn_app_control
20
20
  from calkit.core import get_md5
21
21
 
22
22
 
@@ -605,6 +605,7 @@ def check_environment(
605
605
  try:
606
606
  subprocess.check_call(cmd)
607
607
  except subprocess.CalledProcessError:
608
+ warn_app_control(maybe=True)
608
609
  raise_error("Failed to check pixi environment")
609
610
  elif env["kind"] == "uv":
610
611
  cmd = ["uv", "sync"]
@@ -616,6 +617,7 @@ def check_environment(
616
617
  try:
617
618
  subprocess.check_call(cmd)
618
619
  except subprocess.CalledProcessError:
620
+ warn_app_control(maybe=True)
619
621
  raise_error("Failed to check uv environment")
620
622
  elif (kind := env["kind"]) in ["uv-venv", "venv"]:
621
623
  if "path" not in env:
@@ -1835,6 +1837,7 @@ def check_conda_env(
1835
1837
  verbose=not quiet,
1836
1838
  )
1837
1839
  except Exception as e:
1840
+ warn_app_control(output=str(e), maybe=True)
1838
1841
  raise_error(f"Failed to check conda environment: {e}")
1839
1842
 
1840
1843
 
@@ -1917,6 +1920,7 @@ def check_venv(
1917
1920
  try:
1918
1921
  subprocess.check_call(create_cmd + [prefix], cwd=wdir)
1919
1922
  except subprocess.CalledProcessError:
1923
+ warn_app_control(maybe=True)
1920
1924
  raise_error(f"Failed to create {kind} at {prefix}")
1921
1925
  # Put a gitignore file in the env dir if one doesn't exist
1922
1926
  gitignore_fpath = os.path.join(prefix_full_path, ".gitignore")
@@ -2117,6 +2121,7 @@ def check_venv(
2117
2121
  pip_install_and_freeze(dep_file_txt)
2118
2122
  except (subprocess.CalledProcessError, OSError):
2119
2123
  if lock_is_current:
2124
+ warn_app_control(maybe=True)
2120
2125
  raise_error(
2121
2126
  f"Failed to create {kind} at {prefix} from its lock file "
2122
2127
  f"({lock_fpath}), which matches the spec ({path}). "
@@ -2132,6 +2137,7 @@ def check_venv(
2132
2137
  try:
2133
2138
  pip_install_and_freeze(f"-r {path}")
2134
2139
  except subprocess.CalledProcessError:
2140
+ warn_app_control(maybe=True)
2135
2141
  raise_error(f"Failed to check {kind} from input file {path}")
2136
2142
 
2137
2143
 
calkit/cli/core.py CHANGED
@@ -184,3 +184,35 @@ def warn(txt: str, prefix: str = "Warning: ", err: bool = False):
184
184
  text = typer.style(prefix + encode_safe(str(txt), err=err), fg="yellow")
185
185
  if not _echo_above_spinner(text, err=err):
186
186
  typer.echo(text, err=err)
187
+
188
+
189
+ def warn_app_control(
190
+ output: str = "", returncode: int | None = None, maybe: bool = False
191
+ ) -> None:
192
+ """Explain a failure Windows App Control may have caused.
193
+
194
+ Smart App Control blocks unsigned executables and DLLs it doesn't trust,
195
+ which environments are full of, and the failure reads as unrelated.
196
+ Pass ``maybe`` for a failure whose output went straight to the terminal,
197
+ to warn whenever Smart App Control is on.
198
+ """
199
+ from calkit.windows import (
200
+ SMART_APP_CONTROL_DOCS_URL,
201
+ app_control_blocked,
202
+ smart_app_control_on,
203
+ )
204
+
205
+ if app_control_blocked(output=output, returncode=returncode):
206
+ warn(
207
+ "Windows blocked a file from running, which Smart App Control "
208
+ "does to unsigned files it doesn't trust; see "
209
+ f"{SMART_APP_CONTROL_DOCS_URL}",
210
+ err=True,
211
+ )
212
+ elif maybe and smart_app_control_on():
213
+ warn(
214
+ "Smart App Control is on, which can block files in environments "
215
+ "from running; if the output above says a file was blocked, see "
216
+ f"{SMART_APP_CONTROL_DOCS_URL}",
217
+ err=True,
218
+ )
calkit/cli/export.py ADDED
@@ -0,0 +1,139 @@
1
+ """CLI for exporting data and artifacts from various apps, e.g., a PNG
2
+ from an Inkscape SVG.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from typing import TYPE_CHECKING
9
+
10
+ import typer
11
+ from typing_extensions import Annotated
12
+
13
+ from calkit.cli import raise_error
14
+
15
+ if TYPE_CHECKING:
16
+ from calkit.export import Exporter
17
+
18
+ export_app = typer.Typer(no_args_is_help=True)
19
+
20
+
21
+ def _prepare(exporter: Exporter, source: str, output: str) -> str:
22
+ """Get ready to export, returning the executable to run."""
23
+ exporter.check(source, output)
24
+ if not os.path.isfile(source):
25
+ raise ValueError(f"'{source}' doesn't exist")
26
+ os.makedirs(os.path.dirname(output) or ".", exist_ok=True)
27
+ # What was there is from before, so it can't pass for a new export
28
+ if os.path.exists(output):
29
+ os.remove(output)
30
+ exe = exporter.find_executable()
31
+ if exe is None:
32
+ raise ValueError(
33
+ f"Exporting '{source}' needs {exporter.app} installed"
34
+ )
35
+ return exe
36
+
37
+
38
+ def inkscape(source: str, output: str, dpi: int | None = None) -> None:
39
+ """Export a drawing to the format ``output``'s extension names,
40
+ raising ``ValueError`` if it can't be.
41
+ """
42
+ import subprocess
43
+
44
+ from calkit.export import INKSCAPE
45
+
46
+ exe = _prepare(INKSCAPE, source, output)
47
+ cmd = [exe, source, f"--export-filename={output}"]
48
+ if dpi is not None:
49
+ cmd.append(f"--export-dpi={dpi}")
50
+ result = subprocess.run(cmd, capture_output=True, text=True)
51
+ if not os.path.isfile(output):
52
+ detail = (result.stderr or result.stdout or "").strip()
53
+ raise ValueError(
54
+ f"Inkscape didn't export '{output}' from '{source}'"
55
+ + (f": {detail.splitlines()[-1]}" if detail else "")
56
+ )
57
+
58
+
59
+ def gimp(source: str, output: str) -> None:
60
+ """Flatten a GIMP document into the format ``output``'s extension
61
+ names, raising ``ValueError`` if it can't be.
62
+
63
+ Runs GIMP 3's batch dialect, then GIMP 2's, which differ in their file
64
+ procedures. Neither reports a failed script in its exit code, so the
65
+ written file is what says it worked.
66
+ """
67
+ import subprocess
68
+
69
+ from calkit.export import GIMP
70
+
71
+ exe = _prepare(GIMP, source, output)
72
+ src = os.path.abspath(source).replace("\\", "\\\\").replace('"', '\\"')
73
+ dst = os.path.abspath(output).replace("\\", "\\\\").replace('"', '\\"')
74
+ v3 = (
75
+ f'(let* ((image (car (gimp-file-load RUN-NONINTERACTIVE "{src}"))))'
76
+ " (gimp-image-flatten image)"
77
+ f' (gimp-file-save RUN-NONINTERACTIVE image "{dst}" "{dst}")'
78
+ " (gimp-image-delete image)"
79
+ " (gimp-quit 0))"
80
+ )
81
+ v2 = (
82
+ "(let* ((image (car (gimp-file-load RUN-NONINTERACTIVE "
83
+ f'"{src}" "{src}")))'
84
+ " (drawable (car (gimp-image-flatten image))))"
85
+ f' (gimp-file-save RUN-NONINTERACTIVE image drawable "{dst}" '
86
+ f'"{dst}")'
87
+ " (gimp-image-delete image)"
88
+ " (gimp-quit 0))"
89
+ )
90
+ detail = ""
91
+ for cmd in [
92
+ [exe, "-i", "--batch-interpreter=plug-in-script-fu-eval", "-b", v3],
93
+ [exe, "-i", "-b", v2],
94
+ ]:
95
+ result = subprocess.run(cmd, capture_output=True, text=True)
96
+ if os.path.isfile(output):
97
+ return
98
+ detail = (result.stderr or result.stdout or "").strip()
99
+ raise ValueError(
100
+ f"GIMP didn't export '{output}' from '{source}'"
101
+ + (f": {detail.splitlines()[-1]}" if detail else "")
102
+ )
103
+
104
+
105
+ @export_app.command(name="inkscape", help="Export a figure from an SVG.")
106
+ def export_inkscape(
107
+ source: Annotated[str, typer.Argument(help="SVG to export from.")],
108
+ output: Annotated[
109
+ str,
110
+ typer.Option(
111
+ "--output", "-o", help="File to export to, e.g., a PNG or PDF."
112
+ ),
113
+ ],
114
+ dpi: Annotated[
115
+ int | None, typer.Option("--dpi", help="Resolution of a PNG.")
116
+ ] = None,
117
+ ) -> None:
118
+ try:
119
+ inkscape(source, output, dpi=dpi)
120
+ except ValueError as e:
121
+ raise_error(str(e))
122
+
123
+
124
+ @export_app.command(
125
+ name="gimp", help="Export a flattened figure from an XCF file."
126
+ )
127
+ def export_gimp(
128
+ source: Annotated[str, typer.Argument(help="XCF file to export from.")],
129
+ output: Annotated[
130
+ str,
131
+ typer.Option(
132
+ "--output", "-o", help="File to export to, e.g., a PNG or TIFF."
133
+ ),
134
+ ],
135
+ ) -> None:
136
+ try:
137
+ gimp(source, output)
138
+ except ValueError as e:
139
+ raise_error(str(e))
calkit/cli/latex.py CHANGED
@@ -660,6 +660,26 @@ def build(
660
660
  """
661
661
  import time
662
662
 
663
+ def rel_to_tex_dir(path: str) -> str:
664
+ """Express a current-directory-relative path from the .tex file's
665
+ directory.
666
+
667
+ Pure path arithmetic, since ``os.path.relpath`` resolves names like
668
+ ``aux`` to devices on Windows.
669
+ """
670
+ cwd = os.getcwd()
671
+ target = Path(os.path.normpath(os.path.join(cwd, path)))
672
+ start = Path(os.path.normpath(os.path.join(cwd, tex_dir)))
673
+ # On another drive, there's no relative path
674
+ if os.path.normcase(target.anchor) != os.path.normcase(start.anchor):
675
+ return target.as_posix()
676
+ t = [os.path.normcase(p) for p in target.parts]
677
+ s = [os.path.normcase(p) for p in start.parts]
678
+ n = 0
679
+ while n < min(len(t), len(s)) and t[n] == s[n]:
680
+ n += 1
681
+ return "/".join([".."] * (len(s) - n) + list(target.parts[n:])) or "."
682
+
663
683
  # latexmk records a failed run in its file database and then refuses
664
684
  # to try again, reporting "Nothing to do" and exiting non-zero with no
665
685
  # PDF. Running it again is the first thing anyone does after a
@@ -672,6 +692,33 @@ def build(
672
692
  # \ckfigure, have to be beside it before TeX can read them
673
693
  for problem in calkit.latex.map_ck_components(tex_file):
674
694
  warn(f"{problem}; it will print as ??")
695
+ # Plain \includegraphics counts as much as \ckfigure, and is fine
696
+ # wherever the pipeline made the file. Paths are the enclosing
697
+ # project's, wherever the build runs from, e.g., a stage's wdir
698
+ root = os.path.abspath(tex_dir)
699
+ while not os.path.isfile(os.path.join(root, "calkit.yaml")):
700
+ parent = os.path.dirname(root)
701
+ if parent == root:
702
+ root = os.path.abspath(".")
703
+ break
704
+ root = parent
705
+ shown = calkit.latex.find_shown(
706
+ os.path.relpath(os.path.abspath(tex_file), root), wdir=root
707
+ )
708
+ if shown:
709
+ from calkit.provenance import (
710
+ find_project_provenance_gaps,
711
+ how_to_define_origin,
712
+ )
713
+
714
+ for gap in find_project_provenance_gaps(shown, wdir=root):
715
+ msg = str(gap)
716
+ how = (
717
+ "give its URL, DOI, or Git repo in imported_from"
718
+ if gap.unchecked
719
+ else how_to_define_origin(gap.path)
720
+ )
721
+ warn(f"{msg[0].upper()}{msg[1:]}; {how}")
675
722
  if not os.path.isfile(os.path.join(pdf_dir, stem + ".pdf")):
676
723
  fdb_dir = aux_dir if aux_dir is not None else tex_dir
677
724
  fdb_fpath = os.path.join(fdb_dir, stem + ".fdb_latexmk")
@@ -693,11 +740,9 @@ def build(
693
740
  # file's directory; convert the (current-directory-relative) Calkit paths
694
741
  # into that frame.
695
742
  if output_dir is not None:
696
- rel = Path(os.path.relpath(output_dir, tex_dir)).as_posix()
697
- latexmk_cmd.append(f"-outdir={rel}")
743
+ latexmk_cmd.append(f"-outdir={rel_to_tex_dir(output_dir)}")
698
744
  if aux_dir is not None:
699
- rel = Path(os.path.relpath(aux_dir, tex_dir)).as_posix()
700
- latexmk_cmd.append(f"-auxdir={rel}")
745
+ latexmk_cmd.append(f"-auxdir={rel_to_tex_dir(aux_dir)}")
701
746
  latexmk_cmd.append("-interaction=nonstopmode")
702
747
  latexmk_cmd.append(f"-usepretex={calkit.latex.PDF_PRETEX}")
703
748
  # User pass-through args come last so they can override Calkit's defaults.
calkit/cli/main/core.py CHANGED
@@ -42,6 +42,7 @@ from calkit.cli import (
42
42
  raise_error,
43
43
  run_cmd,
44
44
  warn,
45
+ warn_app_control,
45
46
  )
46
47
  from calkit.cli.check import (
47
48
  check_app,
@@ -55,6 +56,7 @@ from calkit.cli.config import config_app
55
56
  from calkit.cli.delete import delete_app
56
57
  from calkit.cli.describe import describe_app
57
58
  from calkit.cli.dev import dev_app
59
+ from calkit.cli.export import export_app
58
60
  from calkit.cli.fix import (
59
61
  OursOpt,
60
62
  PreferOpt,
@@ -95,6 +97,11 @@ app.add_typer(show_app, name="show", help="Show Calkit objects.")
95
97
  app.add_typer(describe_app, name="describe|desc", help="Describe things.")
96
98
  app.add_typer(import_app, name="import", help="Import objects.")
97
99
  app.add_typer(office_app, name="office", help="Work with Microsoft Office.")
100
+ app.add_typer(
101
+ export_app,
102
+ name="export",
103
+ help="Export data and artifacts from various applications.",
104
+ )
98
105
  app.add_typer(update_app, name="update", help="Update objects.")
99
106
  app.add_typer(check_app, name="check", help="Check things.")
100
107
  app.add_typer(fix_app, name="fix", help="Fix things.")
@@ -942,11 +949,22 @@ def get_status(
942
949
  return
943
950
  elif pipeline_status and pipeline_status.is_stale:
944
951
  typer.echo("Stale stages:")
952
+ ck_stages = (ck_info.get("pipeline") or {}).get("stages") or {}
945
953
  for stage_name in pipeline_status.stale_stage_names:
946
954
  stale_stage = pipeline_status.stale_stages.get(stage_name)
947
955
  if stale_stage is None:
948
956
  continue
949
957
  typer.echo(f" {typer.style(stage_name, fg='yellow')}:")
958
+ ck_stage = ck_stages.get(stage_name)
959
+ if (
960
+ isinstance(ck_stage, dict)
961
+ and ck_stage.get("kind") == "manual-step"
962
+ ):
963
+ warn(
964
+ " is a manual step: "
965
+ f"{ck_stage.get('message')}",
966
+ prefix="",
967
+ )
950
968
  if stale_stage.cannot_run_here:
951
969
  warn(
952
970
  " can't run on this machine: "
@@ -3052,6 +3070,7 @@ def run(
3052
3070
  stage_names=stage_names,
3053
3071
  system_info=system_info,
3054
3072
  interactive=False if spinner else None,
3073
+ manual=can_ask,
3055
3074
  )
3056
3075
  # A machine that couldn't be reached may only need asking about,
3057
3076
  # e.g., for its host
@@ -3918,6 +3937,27 @@ def run(
3918
3937
  }
3919
3938
  if isinstance(props.get("memory_gb"), (int, float)):
3920
3939
  entry["system"]["memory_gb"] = round(props["memory_gb"], 1)
3940
+ # What the stage itself says to record, e.g., the version of an
3941
+ # app it runs, read here, so only for a run on this machine
3942
+ base_name = lock_name.split("@")[0]
3943
+ ck_stage = (
3944
+ run_stages.get(base_name)
3945
+ if lock_dir is None and machine_info[outer] is None
3946
+ else None
3947
+ )
3948
+ if isinstance(ck_stage, dict):
3949
+ from calkit.models.pipeline import Pipeline
3950
+
3951
+ try:
3952
+ model = Pipeline.model_validate(
3953
+ {"stages": {base_name: ck_stage}}
3954
+ ).stages[base_name]
3955
+ extra = model.run_properties()
3956
+ except Exception as e:
3957
+ warn(f"Couldn't read what {stage_name} records: {e}")
3958
+ extra = {}
3959
+ if extra:
3960
+ entry.setdefault("system", {}).update(extra)
3921
3961
  stage_runs[lock_dir][lock_name] = entry
3922
3962
  # Replacing a record made from here, from before a subproject's
3923
3963
  # were kept with it
@@ -3951,6 +3991,7 @@ def run(
3951
3991
  calkit.dvc.restore_output_ignores()
3952
3992
  except Exception as e:
3953
3993
  warn(f"Failed to re-ignore pipeline outputs: {e}")
3994
+ warn_app_control(output=log_content)
3954
3995
  raise_error("Pipeline failed")
3955
3996
  elif skipped_stale:
3956
3997
  for addressing, reason in skipped_stale.items():
@@ -3994,6 +4035,25 @@ def manual_step(
3994
4035
  cmd: Annotated[
3995
4036
  str | None, typer.Option("--cmd", help="Command to run.")
3996
4037
  ] = None,
4038
+ app: Annotated[
4039
+ str | None,
4040
+ typer.Option("--app", help="App to open, e.g., 'Inkscape'."),
4041
+ ] = None,
4042
+ open_path: Annotated[
4043
+ str | None,
4044
+ typer.Option("--open", help="File to open with the app."),
4045
+ ] = None,
4046
+ outputs: Annotated[
4047
+ list[str],
4048
+ typer.Option(
4049
+ "--output",
4050
+ "-o",
4051
+ help=(
4052
+ "File the step writes, which must change for it to be "
4053
+ "considered done."
4054
+ ),
4055
+ ),
4056
+ ] = [],
3997
4057
  show_stdout: Annotated[
3998
4058
  bool, typer.Option("--show-stdout", help="Show stdout.")
3999
4059
  ] = False,
@@ -4001,6 +4061,53 @@ def manual_step(
4001
4061
  bool, typer.Option("--show-stderr", help="Show stderr.")
4002
4062
  ] = False,
4003
4063
  ) -> None:
4064
+ import textwrap
4065
+
4066
+ from calkit.provenance import hash_path
4067
+
4068
+ def ask() -> str:
4069
+ # Windows calls an empty stdin a terminal, so its end says so too
4070
+ try:
4071
+ return input()
4072
+ except EOFError:
4073
+ raise_error(
4074
+ f"Needs a manual step, with nobody here to do it: {message}"
4075
+ )
4076
+
4077
+ # Waiting for someone who isn't there would hang, e.g., in CI
4078
+ if not sys.stdin.isatty():
4079
+ raise_error(
4080
+ f"Needs a manual step, with nobody here to do it: {message}"
4081
+ )
4082
+ # The app is named rather than left to the default for the file, which
4083
+ # differs between machines, e.g., Illustrator rather than Inkscape
4084
+ if open_path is not None and app is None:
4085
+ raise_error("Name the app to open it with, with --app")
4086
+ if open_path is not None and not os.path.exists(open_path):
4087
+ raise_error(f"{open_path} doesn't exist")
4088
+ before = {path: hash_path(path) for path in outputs}
4089
+ if app is not None:
4090
+ args = [] if open_path is None else [open_path]
4091
+ typer.echo(f"Opening {open_path or app} with {app}")
4092
+ # macOS finds apps by name wherever they're installed
4093
+ exe = shutil.which(app) or shutil.which(app.lower())
4094
+ opened = False
4095
+ if sys.platform == "darwin":
4096
+ opened = (
4097
+ subprocess.run(
4098
+ ["open", "-a", app, *args], capture_output=True
4099
+ ).returncode
4100
+ == 0
4101
+ )
4102
+ elif exe is not None:
4103
+ subprocess.Popen(
4104
+ [exe, *args],
4105
+ stdout=subprocess.DEVNULL,
4106
+ stderr=subprocess.DEVNULL,
4107
+ )
4108
+ opened = True
4109
+ if not opened:
4110
+ warn(f"Can't find {app} here; open it yourself")
4004
4111
  if cmd is not None:
4005
4112
  typer.echo(f"Running command: {cmd}")
4006
4113
  subprocess.Popen(
@@ -4009,7 +4116,38 @@ def manual_step(
4009
4116
  stdout=subprocess.PIPE if not show_stdout else None,
4010
4117
  shell=True,
4011
4118
  )
4012
- input(message + " (press enter to confirm): ")
4119
+ # Each prompt ends its line, since 'calkit run' passes stage output on
4120
+ # a line at a time, so a prompt waiting on its line wouldn't show
4121
+ while True:
4122
+ prompt = f"{message.rstrip('.')}. Press Enter when done."
4123
+ # Paths aren't split, so they can still be copied
4124
+ print(
4125
+ textwrap.fill(
4126
+ prompt,
4127
+ width=80,
4128
+ break_long_words=False,
4129
+ break_on_hyphens=False,
4130
+ ),
4131
+ flush=True,
4132
+ )
4133
+ ask()
4134
+ missing = [path for path in outputs if not os.path.exists(path)]
4135
+ if missing:
4136
+ typer.echo(f"Not there yet: {', '.join(missing)}")
4137
+ continue
4138
+ # Unchanged is fine when it was already done, but is asked about,
4139
+ # since otherwise it's an old output recorded as a new one
4140
+ unchanged = [
4141
+ path for path in outputs if hash_path(path) == before[path]
4142
+ ]
4143
+ if unchanged:
4144
+ print(
4145
+ f"Unchanged: {', '.join(unchanged)}. Keep as is? [y/N]",
4146
+ flush=True,
4147
+ )
4148
+ if ask().strip().lower() not in ("y", "yes"):
4149
+ continue
4150
+ break
4013
4151
  typer.echo("Done")
4014
4152
 
4015
4153
 
@@ -4429,7 +4567,8 @@ def run_in_env(
4429
4567
  typer.echo(f"Running command: {cmd}")
4430
4568
  try:
4431
4569
  subprocess.check_call(cmd, cwd=wdir)
4432
- except subprocess.CalledProcessError:
4570
+ except subprocess.CalledProcessError as e:
4571
+ warn_app_control(returncode=e.returncode)
4433
4572
  raise_error("Failed to run in Conda environment")
4434
4573
  elif env["kind"] == "pixi":
4435
4574
  env_cmd = []
@@ -4443,7 +4582,8 @@ def run_in_env(
4443
4582
  typer.echo(f"Running command: {cmd}")
4444
4583
  try:
4445
4584
  subprocess.check_call(cmd, cwd=wdir)
4446
- except subprocess.CalledProcessError:
4585
+ except subprocess.CalledProcessError as e:
4586
+ warn_app_control(returncode=e.returncode)
4447
4587
  raise_error("Failed to run in Pixi environment")
4448
4588
  elif env["kind"] == "uv":
4449
4589
  env_dir = os.path.dirname(os.path.abspath(env["path"]))
@@ -4452,7 +4592,8 @@ def run_in_env(
4452
4592
  typer.echo(f"Running command: {cmd}")
4453
4593
  try:
4454
4594
  subprocess.check_call(cmd, cwd=wdir)
4455
- except subprocess.CalledProcessError:
4595
+ except subprocess.CalledProcessError as e:
4596
+ warn_app_control(returncode=e.returncode)
4456
4597
  raise_error("Failed to run in uv environment")
4457
4598
  elif (kind := env["kind"]) in ["uv-venv", "venv"]:
4458
4599
  if "path" not in env:
@@ -4490,7 +4631,8 @@ def run_in_env(
4490
4631
  typer.echo(f"Running command: {cmd}")
4491
4632
  try:
4492
4633
  subprocess.check_call(cmd, shell=True, cwd=wdir)
4493
- except subprocess.CalledProcessError:
4634
+ except subprocess.CalledProcessError as e:
4635
+ warn_app_control(returncode=e.returncode)
4494
4636
  raise_error(f"Failed to run in {kind}")
4495
4637
  elif env["kind"] == "julia":
4496
4638
  if not no_check: