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.
- calkit/agent_skills/conventions/SKILL.md +26 -0
- calkit/cli/config.py +9 -1
- calkit/cli/latex.py +188 -7
- calkit/cli/main/core.py +192 -8
- calkit/cli/new.py +7 -8
- calkit/cli/overleaf.py +8 -7
- calkit/cli/update.py +251 -0
- calkit/docker.py +129 -5
- calkit/dvc/core.py +39 -0
- calkit/environments.py +4 -6
- calkit/install.py +7 -7
- calkit/latex.py +95 -1
- calkit/models/pipeline.py +19 -4
- calkit/pipeline.py +146 -6
- calkit/questions.py +31 -4
- calkit/resources/devcontainer/devcontainer.json +1 -1
- calkit/resources/vscode/settings.json +1 -1
- calkit/tests/cli/main/test_core.py +186 -0
- calkit/tests/cli/main/test_xr.py +1 -0
- calkit/tests/cli/test_check.py +20 -9
- calkit/tests/cli/test_latex.py +113 -0
- calkit/tests/cli/test_new.py +3 -2
- calkit/tests/cli/test_overleaf.py +2 -1
- calkit/tests/cli/test_update.py +73 -0
- calkit/tests/models/test_pipeline.py +15 -0
- calkit/tests/test_conda.py +3 -0
- calkit/tests/test_docker.py +61 -0
- calkit/tests/test_environments.py +2 -1
- calkit/tests/test_pipeline.py +94 -0
- calkit/tests/test_questions.py +37 -0
- {calkit_python-0.47.8.dist-info → calkit_python-0.47.9.dist-info}/METADATA +190 -58
- {calkit_python-0.47.8.dist-info → calkit_python-0.47.9.dist-info}/RECORD +48 -48
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.47.8.data → calkit_python-0.47.9.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.47.8.dist-info → calkit_python-0.47.9.dist-info}/WHEEL +0 -0
- {calkit_python-0.47.8.dist-info → calkit_python-0.47.9.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1244
|
+
git_add(path)
|
|
1149
1245
|
if not dry_run:
|
|
1150
1246
|
if commit_message is not None:
|
|
1151
|
-
|
|
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"):
|