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.
- calkit/cli/check.py +7 -1
- calkit/cli/core.py +32 -0
- calkit/cli/export.py +139 -0
- calkit/cli/latex.py +49 -4
- calkit/cli/main/core.py +147 -5
- calkit/config.py +266 -57
- calkit/core.py +23 -4
- calkit/docker.py +2 -0
- calkit/export.py +169 -0
- calkit/latex.py +78 -0
- calkit/models/pipeline.py +183 -11
- calkit/pipeline.py +45 -14
- calkit/provenance.py +278 -6
- calkit/reproducibility.py +63 -1
- calkit/tests/cli/main/test_core.py +184 -0
- calkit/tests/cli/test_core.py +33 -0
- calkit/tests/cli/test_export.py +152 -0
- calkit/tests/cli/test_latex.py +63 -0
- calkit/tests/models/test_pipeline.py +76 -0
- calkit/tests/test_config.py +238 -48
- calkit/tests/test_core.py +24 -0
- calkit/tests/test_docker.py +6 -0
- calkit/tests/test_export.py +85 -0
- calkit/tests/test_latex.py +60 -0
- calkit/tests/test_pipeline.py +91 -0
- calkit/tests/test_provenance.py +186 -0
- calkit/tests/test_reproducibility.py +88 -4
- calkit/tests/test_upgrade.py +9 -1
- calkit/tests/test_windows.py +56 -0
- calkit/upgrade.py +7 -0
- calkit/windows.py +59 -0
- {calkit_python-0.50.3.dist-info → calkit_python-0.50.4.dist-info}/METADATA +56 -1
- {calkit_python-0.50.3.dist-info → calkit_python-0.50.4.dist-info}/RECORD +49 -43
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.50.3.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.50.3.dist-info → calkit_python-0.50.4.dist-info}/WHEEL +0 -0
- {calkit_python-0.50.3.dist-info → calkit_python-0.50.4.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|