calkit-python 0.47.6__py3-none-any.whl → 0.47.8__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/check-questions/SKILL.md +9 -9
- calkit/cli/latex.py +195 -45
- calkit/cli/list.py +12 -3
- calkit/cli/main/core.py +13 -2
- calkit/cli/sync.py +5 -1
- calkit/models/core.py +84 -5
- calkit/models/pipeline.py +60 -1
- calkit/notebooks.py +5 -0
- calkit/pipeline.py +30 -2
- calkit/questions.py +309 -37
- calkit/tests/cli/main/test_core.py +14 -0
- calkit/tests/cli/test_latex.py +134 -4
- calkit/tests/cli/test_list.py +12 -0
- calkit/tests/cli/test_notebooks.py +34 -0
- calkit/tests/cli/test_sync.py +11 -1
- calkit/tests/models/test_pipeline.py +2 -1
- calkit/tests/test_pipeline.py +27 -0
- calkit/tests/test_questions.py +260 -4
- {calkit_python-0.47.6.dist-info → calkit_python-0.47.8.dist-info}/METADATA +1 -1
- {calkit_python-0.47.6.dist-info → calkit_python-0.47.8.dist-info}/RECORD +36 -36
- {calkit_python-0.47.6.dist-info → calkit_python-0.47.8.dist-info}/WHEEL +1 -1
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.47.6.dist-info → calkit_python-0.47.8.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.47.6.dist-info → calkit_python-0.47.8.dist-info}/licenses/LICENSE +0 -0
|
@@ -135,7 +135,9 @@ project, not a tidy-up.
|
|
|
135
135
|
- One claim per question, two to four sentences. Say what was found and
|
|
136
136
|
what it means; leave the reasoning to the publication.
|
|
137
137
|
- Numbers come from `value` evidence via placeholders, formatted to the
|
|
138
|
-
precision the claim needs: `{ratio:.1f}x`, `{error:.0%}`.
|
|
138
|
+
precision the claim needs: `{ratio:.1f}x`, `{error:.0%}`. When several
|
|
139
|
+
come from one file, e.g., the outputs of one calculation, name them
|
|
140
|
+
together in one `result` entry's `values`, a map of name to key.
|
|
139
141
|
- A `value` entry must read a file a pipeline stage writes. A placeholder
|
|
140
142
|
over a results file written by hand, including one you write, is a
|
|
141
143
|
retyped number with extra steps, and the check fails it. If no stage
|
|
@@ -152,17 +154,15 @@ project, not a tidy-up.
|
|
|
152
154
|
if p < 0.05: "The closure cuts error by {improvement:.1f}x."
|
|
153
155
|
else: "The closure does not measurably reduce error."
|
|
154
156
|
evidence:
|
|
155
|
-
- kind:
|
|
157
|
+
- kind: result
|
|
156
158
|
path: results/closure.json
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
path: results/closure.json
|
|
161
|
-
key: improvement
|
|
159
|
+
values:
|
|
160
|
+
p: p-value
|
|
161
|
+
improvement: improvement
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
-
Conditions
|
|
165
|
-
identifiers
|
|
164
|
+
Conditions use the names of values, so those names must be valid Python
|
|
165
|
+
identifiers. Every branch must be a
|
|
166
166
|
claim the evidence would support if it held. The threshold is part of
|
|
167
167
|
the claim: take it from the field's convention or the user, never pick
|
|
168
168
|
it to make the current branch hold, and don't use branches to hedge a
|
calkit/cli/latex.py
CHANGED
|
@@ -137,6 +137,43 @@ def from_json(
|
|
|
137
137
|
json2latex.dump(cmd_name, formatted, f)
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
@latex_app.command(name="from-questions")
|
|
141
|
+
def from_questions(
|
|
142
|
+
output_fpaths: Annotated[
|
|
143
|
+
list[str],
|
|
144
|
+
typer.Option("--output", "-o", help="Output LaTeX file path(s)."),
|
|
145
|
+
],
|
|
146
|
+
command_name: Annotated[
|
|
147
|
+
str,
|
|
148
|
+
typer.Option("--command", help="Command name to use in LaTeX output."),
|
|
149
|
+
] = "questions",
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Write the project's questions and answers as a LaTeX command.
|
|
152
|
+
|
|
153
|
+
Each question's text, hypothesis, answer, and notes are rendered from
|
|
154
|
+
their evidence and exposed as, e.g., ``\\questions[staging.answer]``,
|
|
155
|
+
keyed by the question's ``name`` or its 1-based position.
|
|
156
|
+
"""
|
|
157
|
+
import json2latex
|
|
158
|
+
|
|
159
|
+
import calkit.questions
|
|
160
|
+
|
|
161
|
+
for out_path in output_fpaths:
|
|
162
|
+
if not out_path.endswith(".tex"):
|
|
163
|
+
raise_error("Output file must be a .tex file")
|
|
164
|
+
ck_info = calkit.load_calkit_info()
|
|
165
|
+
try:
|
|
166
|
+
values = calkit.questions.latex_values(ck_info)
|
|
167
|
+
except ValueError as e:
|
|
168
|
+
raise_error(str(e))
|
|
169
|
+
for out_path in output_fpaths:
|
|
170
|
+
outdir = os.path.dirname(out_path)
|
|
171
|
+
if outdir:
|
|
172
|
+
os.makedirs(outdir, exist_ok=True)
|
|
173
|
+
with open(out_path, "w") as f:
|
|
174
|
+
json2latex.dump(command_name, values, f)
|
|
175
|
+
|
|
176
|
+
|
|
140
177
|
def _tex_cmd(
|
|
141
178
|
tex_cmd: list[str],
|
|
142
179
|
environment: str | None,
|
|
@@ -308,6 +345,9 @@ _VERBATIM_PARAM_RE = re.compile(
|
|
|
308
345
|
r"(\\(?:verbatiminput\*?|lstinputlisting))"
|
|
309
346
|
r"(?=\s*(?:\[[^\]\n]*\])?\s*\{[^}\n]*#[0-9])"
|
|
310
347
|
)
|
|
348
|
+
_INPUT_WRAPPER_RE = re.compile(
|
|
349
|
+
r"\\([A-Za-z@]+)\*?\s*(?:\[[^\]]*\]\s*)*\{\s*\\(?:input|include)\s*\{"
|
|
350
|
+
)
|
|
311
351
|
get_diff_path = calkit.latex.get_diff_path
|
|
312
352
|
_default_base_ref = calkit.latex.default_base_ref
|
|
313
353
|
|
|
@@ -372,7 +412,9 @@ def diff(
|
|
|
372
412
|
typer.Option(
|
|
373
413
|
"--latexmk-rc",
|
|
374
414
|
"-r",
|
|
375
|
-
help=
|
|
415
|
+
help=(
|
|
416
|
+
"Path to a latexmkrc file to build the marked-up document with."
|
|
417
|
+
),
|
|
376
418
|
),
|
|
377
419
|
] = None,
|
|
378
420
|
latexmk_args: Annotated[
|
|
@@ -436,7 +478,11 @@ def diff(
|
|
|
436
478
|
bool,
|
|
437
479
|
typer.Option(
|
|
438
480
|
"--keep-tex",
|
|
439
|
-
help=
|
|
481
|
+
help=(
|
|
482
|
+
"Keep the old, new, and diff .tex files beside the diff "
|
|
483
|
+
"PDF for inspection, e.g., "
|
|
484
|
+
".calkit/latex-diffs/v1/paper/main-old.tex."
|
|
485
|
+
),
|
|
440
486
|
),
|
|
441
487
|
] = False,
|
|
442
488
|
no_check: Annotated[
|
|
@@ -471,6 +517,53 @@ def diff(
|
|
|
471
517
|
the comparison shows its own.
|
|
472
518
|
"""
|
|
473
519
|
|
|
520
|
+
def stage_path(stage: dict, path: str) -> str:
|
|
521
|
+
"""A stage's path in the project's frame rather than its wdir's."""
|
|
522
|
+
return Path(
|
|
523
|
+
os.path.normpath(os.path.join(stage.get("wdir") or "", path))
|
|
524
|
+
).as_posix()
|
|
525
|
+
|
|
526
|
+
def find_latex_stage() -> tuple[str | None, dict | None]:
|
|
527
|
+
"""The pipeline stage that builds the document, if any."""
|
|
528
|
+
ck_info = calkit.load_calkit_info()
|
|
529
|
+
stages = (ck_info.get("pipeline") or {}).get("stages") or {}
|
|
530
|
+
target = Path(os.path.normpath(tex_file)).as_posix()
|
|
531
|
+
for name, stage in stages.items():
|
|
532
|
+
if (
|
|
533
|
+
isinstance(stage, dict)
|
|
534
|
+
and stage.get("kind") == "latex"
|
|
535
|
+
and stage_path(stage, stage.get("target_path") or "") == target
|
|
536
|
+
):
|
|
537
|
+
return name, stage
|
|
538
|
+
return None, None
|
|
539
|
+
|
|
540
|
+
def stage_inputs(name: str, stage: dict) -> list[str]:
|
|
541
|
+
"""What a stage's diffs fetch at each revision.
|
|
542
|
+
|
|
543
|
+
Its dependencies in the compiled pipeline, which include other
|
|
544
|
+
stages' outputs it reads, found there since calkit.yaml only names
|
|
545
|
+
the stages they come from.
|
|
546
|
+
"""
|
|
547
|
+
from calkit.models.pipeline import LatexStage
|
|
548
|
+
|
|
549
|
+
try:
|
|
550
|
+
dvc_stage = calkit.ryaml.load(Path("dvc.yaml").read_text())[
|
|
551
|
+
"stages"
|
|
552
|
+
][name]
|
|
553
|
+
deps = [
|
|
554
|
+
dep if isinstance(dep, str) else next(iter(dep))
|
|
555
|
+
for dep in dvc_stage.get("deps") or []
|
|
556
|
+
]
|
|
557
|
+
except Exception:
|
|
558
|
+
deps = LatexStage.model_validate(stage | {"name": name}).dvc_deps
|
|
559
|
+
skip = {tex_file, latexmk_rc_path}
|
|
560
|
+
return [
|
|
561
|
+
path
|
|
562
|
+
for dep in deps
|
|
563
|
+
if (path := stage_path(stage, dep)) not in skip
|
|
564
|
+
and not path.startswith(".calkit/")
|
|
565
|
+
]
|
|
566
|
+
|
|
474
567
|
def fetch_dvc_inputs(root: str, rev: str, paths: list[str]) -> list[str]:
|
|
475
568
|
"""Fetch the DVC-tracked content of ``paths`` at ``rev`` into ``root``.
|
|
476
569
|
|
|
@@ -592,7 +685,9 @@ def diff(
|
|
|
592
685
|
Detection finds a file by its own name or pointer, but a file in a
|
|
593
686
|
directory DVC tracks as a whole has only the directory's pointer,
|
|
594
687
|
which can't say what's inside, so those directories beside the
|
|
595
|
-
document are included whole.
|
|
688
|
+
document are included whole. Neither finds another stage's output
|
|
689
|
+
DVC doesn't cache, so the pipeline's inputs for the document are
|
|
690
|
+
added too.
|
|
596
691
|
"""
|
|
597
692
|
doc_dir = Path(root, os.path.dirname(tex_file))
|
|
598
693
|
pointed = [
|
|
@@ -600,7 +695,8 @@ def diff(
|
|
|
600
695
|
for p in sorted(doc_dir.rglob("*.dvc"))
|
|
601
696
|
if p.is_file()
|
|
602
697
|
]
|
|
603
|
-
|
|
698
|
+
detected = calkit.latex.detect_inputs(tex_file, wdir=root)
|
|
699
|
+
return detected + pointed + pipeline_inputs
|
|
604
700
|
|
|
605
701
|
def point_changed_figures_at_base(base_root: str, head_root: str) -> None:
|
|
606
702
|
"""Make the older side's changed figures refer to its own copies.
|
|
@@ -661,6 +757,20 @@ def diff(
|
|
|
661
757
|
from_ref = _default_base_ref(repo)
|
|
662
758
|
if to_ref is None and not os.path.isfile(tex_file):
|
|
663
759
|
raise_error(f"{tex_file} does not exist")
|
|
760
|
+
# A document the pipeline builds is diffed the way it's built, with its
|
|
761
|
+
# stage's environment, settings, and inputs, unless told otherwise
|
|
762
|
+
stage_name, stage = find_latex_stage()
|
|
763
|
+
pipeline_inputs: list[str] = []
|
|
764
|
+
if stage is not None:
|
|
765
|
+
if environment is None:
|
|
766
|
+
environment = stage.get("environment")
|
|
767
|
+
if latexmk_rc_path is None and stage.get("latexmkrc_path"):
|
|
768
|
+
latexmk_rc_path = stage_path(stage, stage["latexmkrc_path"])
|
|
769
|
+
latexmk_args = latexmk_args or list(stage.get("latexmk_args") or [])
|
|
770
|
+
latexdiff_args = latexdiff_args or list(
|
|
771
|
+
stage.get("latexdiff_args") or []
|
|
772
|
+
)
|
|
773
|
+
pipeline_inputs = stage_inputs(str(stage_name), stage)
|
|
664
774
|
if output is None:
|
|
665
775
|
output = get_diff_path(
|
|
666
776
|
tex_file,
|
|
@@ -729,9 +839,9 @@ def diff(
|
|
|
729
839
|
break_verbatim_params(root)
|
|
730
840
|
point_changed_figures_at_base(checkouts["base"], head_root)
|
|
731
841
|
_build_diff(
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
842
|
+
base_tex_fpath=sides["base"],
|
|
843
|
+
head_tex_fpath=sides["head"],
|
|
844
|
+
tex_file_fpath=tex_file,
|
|
735
845
|
head_root=head_root,
|
|
736
846
|
output=output,
|
|
737
847
|
environment=environment,
|
|
@@ -788,9 +898,9 @@ def _remove_worktree(path: str) -> None:
|
|
|
788
898
|
|
|
789
899
|
|
|
790
900
|
def _build_diff(
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
901
|
+
base_tex_fpath: str,
|
|
902
|
+
head_tex_fpath: str,
|
|
903
|
+
tex_file_fpath: str,
|
|
794
904
|
head_root: str,
|
|
795
905
|
output: str,
|
|
796
906
|
environment: str | None,
|
|
@@ -803,39 +913,63 @@ def _build_diff(
|
|
|
803
913
|
force: bool,
|
|
804
914
|
verbose: bool,
|
|
805
915
|
) -> None:
|
|
806
|
-
"""Mark up one document against another and build the result.
|
|
916
|
+
"""Mark up one document against another and build the result.
|
|
917
|
+
|
|
918
|
+
base_tex_fpath is the older revision's document, head_tex_fpath the
|
|
919
|
+
newer revision's, and tex_file_fpath the document as named on the
|
|
920
|
+
command line. head_root is where the newer side lives: a checkout,
|
|
921
|
+
or the working tree, beside which the marked-up document is built
|
|
922
|
+
so its relative inputs resolve.
|
|
923
|
+
"""
|
|
807
924
|
# Built beside the newer side, so \graphicspath, \bibliography, and
|
|
808
925
|
# relative \includegraphics resolve the way they do for the real thing,
|
|
809
926
|
# against that revision's own files
|
|
810
|
-
tex_dir = os.path.dirname(
|
|
927
|
+
tex_dir = os.path.dirname(tex_file_fpath) or "."
|
|
811
928
|
build_dir = os.path.normpath(os.path.join(head_root, tex_dir))
|
|
812
|
-
stem = Path(
|
|
813
|
-
|
|
814
|
-
# Where --keep-tex leaves
|
|
815
|
-
# removed afterwards
|
|
816
|
-
|
|
929
|
+
stem = Path(tex_file_fpath).stem
|
|
930
|
+
diff_tex_fpath = os.path.join(build_dir, f"{stem}-diff.tex")
|
|
931
|
+
# Where --keep-tex leaves its copies: beside the diff PDF, since a
|
|
932
|
+
# checkout is removed afterwards. The old and new files are what
|
|
933
|
+
# latexdiff saw, after verbatim fixes and figure repointing, so a
|
|
934
|
+
# --flatten or macro expansion failure can be inspected.
|
|
935
|
+
kept_stem = os.path.splitext(output)[0]
|
|
936
|
+
kept_diff_fpath = f"{kept_stem}-diff.tex"
|
|
937
|
+
kept_old_fpath = f"{kept_stem}-old.tex"
|
|
938
|
+
kept_new_fpath = f"{kept_stem}-new.tex"
|
|
939
|
+
marked_up: bytes | None = None
|
|
817
940
|
aux_dir = os.path.join(build_dir, calkit.latex.DIFF_AUX_DIRNAME)
|
|
818
941
|
try:
|
|
819
942
|
# --flatten pulls \input and \include files into one document on
|
|
820
943
|
# each side, so a multi-file paper compares as a whole
|
|
821
944
|
latexdiff_cmd = ["latexdiff", "--flatten", "--encoding=utf8"]
|
|
822
|
-
|
|
823
|
-
# broken, so this only finds the working tree's, which can't be
|
|
824
|
-
# edited. A filter breaks those instead, though latexdiff's
|
|
825
|
-
# --filter-script mangles anything outside Latin-1.
|
|
826
|
-
sources = [base_tex, head_tex] + [
|
|
945
|
+
sources = [base_tex_fpath, head_tex_fpath] + [
|
|
827
946
|
path
|
|
828
|
-
for side in (
|
|
947
|
+
for side in (base_tex_fpath, head_tex_fpath)
|
|
829
948
|
for path in calkit.latex.detect_inputs(side)
|
|
830
949
|
if Path(path).suffix in calkit.latex._SOURCE_EXTS
|
|
831
950
|
]
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
and _VERBATIM_PARAM_RE.search(
|
|
835
|
-
Path(path).read_text(encoding="utf-8", errors="replace")
|
|
836
|
-
)
|
|
951
|
+
texts = [
|
|
952
|
+
Path(path).read_text(encoding="utf-8", errors="replace")
|
|
837
953
|
for path in sources
|
|
838
|
-
|
|
954
|
+
if os.path.isfile(path)
|
|
955
|
+
]
|
|
956
|
+
# A macro wrapping an \input or \include, e.g., one making an
|
|
957
|
+
# appendix single column, gets the whole flattened file as its
|
|
958
|
+
# argument, which latexdiff would otherwise mark up as one token
|
|
959
|
+
wrappers = sorted(
|
|
960
|
+
{
|
|
961
|
+
name
|
|
962
|
+
for text in texts
|
|
963
|
+
for name in _INPUT_WRAPPER_RE.findall(text)
|
|
964
|
+
}
|
|
965
|
+
)
|
|
966
|
+
if wrappers:
|
|
967
|
+
latexdiff_cmd.append("--append-textcmd=" + ";".join(wrappers))
|
|
968
|
+
# A checkout's verbatim inputs named by macro parameters are already
|
|
969
|
+
# broken, so this only finds the working tree's, which can't be
|
|
970
|
+
# edited. A filter breaks those instead, though latexdiff's
|
|
971
|
+
# --filter-script mangles anything outside Latin-1.
|
|
972
|
+
if any(_VERBATIM_PARAM_RE.search(text) for text in texts):
|
|
839
973
|
filter_path = Path(DIFF_TMP_DIR, "verbatim-param-filter.pl")
|
|
840
974
|
os.makedirs(filter_path.parent, exist_ok=True)
|
|
841
975
|
# latexdiff appends a newline to what it sends, so drop it
|
|
@@ -859,7 +993,7 @@ def _build_diff(
|
|
|
859
993
|
# defaults
|
|
860
994
|
latexdiff_cmd += latexdiff_args
|
|
861
995
|
cmd = _tex_cmd(
|
|
862
|
-
latexdiff_cmd + [
|
|
996
|
+
latexdiff_cmd + [base_tex_fpath, head_tex_fpath],
|
|
863
997
|
environment=environment,
|
|
864
998
|
no_check=no_check,
|
|
865
999
|
verbose=verbose,
|
|
@@ -867,7 +1001,9 @@ def _build_diff(
|
|
|
867
1001
|
)
|
|
868
1002
|
typer.echo("Marking up the document with latexdiff")
|
|
869
1003
|
try:
|
|
870
|
-
|
|
1004
|
+
# No stdin, so an environment's container isn't given a TTY,
|
|
1005
|
+
# which would merge latexdiff's warnings into the document
|
|
1006
|
+
marked_up = subprocess.check_output(cmd, stdin=subprocess.DEVNULL)
|
|
871
1007
|
except FileNotFoundError:
|
|
872
1008
|
raise_error(
|
|
873
1009
|
"latexdiff was not found; it ships with TeX Live, so a "
|
|
@@ -907,8 +1043,7 @@ def _build_diff(
|
|
|
907
1043
|
):
|
|
908
1044
|
typer.echo(f"{output} is up to date")
|
|
909
1045
|
return
|
|
910
|
-
|
|
911
|
-
f.write(marked_up)
|
|
1046
|
+
Path(diff_tex_fpath).write_bytes(marked_up)
|
|
912
1047
|
os.makedirs(aux_dir, exist_ok=True)
|
|
913
1048
|
rel_aux = calkit.latex.DIFF_AUX_DIRNAME
|
|
914
1049
|
latexmk_cmd = ["latexmk"]
|
|
@@ -928,7 +1063,7 @@ def _build_diff(
|
|
|
928
1063
|
# User pass-through args come last so they can override Calkit's
|
|
929
1064
|
# defaults
|
|
930
1065
|
latexmk_cmd += latexmk_args
|
|
931
|
-
latexmk_cmd.append(
|
|
1066
|
+
latexmk_cmd.append(diff_tex_fpath)
|
|
932
1067
|
cmd = _tex_cmd(
|
|
933
1068
|
latexmk_cmd,
|
|
934
1069
|
environment=environment,
|
|
@@ -956,10 +1091,13 @@ def _build_diff(
|
|
|
956
1091
|
if excerpt:
|
|
957
1092
|
typer.echo(f"From {log_path.as_posix()}:", err=True)
|
|
958
1093
|
typer.echo("\n".join(excerpt), err=True)
|
|
959
|
-
|
|
960
|
-
"latexmk failed on the
|
|
961
|
-
f"{e.returncode}
|
|
1094
|
+
msg = (
|
|
1095
|
+
"latexmk failed on the diff document with exit code "
|
|
1096
|
+
f"{e.returncode}"
|
|
962
1097
|
)
|
|
1098
|
+
if not keep_tex:
|
|
1099
|
+
msg += "; rerun with --keep-tex to inspect"
|
|
1100
|
+
raise_error(msg)
|
|
963
1101
|
built = os.path.join(aux_dir, f"{stem}-diff.pdf")
|
|
964
1102
|
if not os.path.isfile(built):
|
|
965
1103
|
raise_error("latexmk did not produce a PDF")
|
|
@@ -970,16 +1108,28 @@ def _build_diff(
|
|
|
970
1108
|
if os.path.abspath(head_root) == os.path.abspath("."):
|
|
971
1109
|
shutil.rmtree(aux_dir, ignore_errors=True)
|
|
972
1110
|
os.makedirs(os.path.dirname(state_path), exist_ok=True)
|
|
973
|
-
|
|
974
|
-
f.write(digest)
|
|
1111
|
+
Path(state_path).write_text(digest)
|
|
975
1112
|
typer.echo(f"Wrote {output}")
|
|
976
1113
|
finally:
|
|
977
|
-
if
|
|
978
|
-
|
|
979
|
-
if
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1114
|
+
if keep_tex:
|
|
1115
|
+
os.makedirs(os.path.dirname(output) or ".", exist_ok=True)
|
|
1116
|
+
if marked_up is not None:
|
|
1117
|
+
Path(kept_diff_fpath).write_bytes(marked_up)
|
|
1118
|
+
for src, kept_fpath in (
|
|
1119
|
+
(base_tex_fpath, kept_old_fpath),
|
|
1120
|
+
(head_tex_fpath, kept_new_fpath),
|
|
1121
|
+
):
|
|
1122
|
+
if os.path.isfile(src):
|
|
1123
|
+
shutil.copy(src, kept_fpath)
|
|
1124
|
+
for kept_fpath in (
|
|
1125
|
+
kept_old_fpath,
|
|
1126
|
+
kept_new_fpath,
|
|
1127
|
+
kept_diff_fpath,
|
|
1128
|
+
):
|
|
1129
|
+
if os.path.isfile(kept_fpath):
|
|
1130
|
+
typer.echo(f"Kept {kept_fpath} for inspection")
|
|
1131
|
+
if os.path.isfile(diff_tex_fpath):
|
|
1132
|
+
os.remove(diff_tex_fpath)
|
|
983
1133
|
|
|
984
1134
|
|
|
985
1135
|
@latex_app.command(name="to-docx")
|
calkit/cli/list.py
CHANGED
|
@@ -197,7 +197,10 @@ def _echo_question(n: int, question: str | dict) -> None:
|
|
|
197
197
|
question = dict(question)
|
|
198
198
|
text = question.pop("question", "")
|
|
199
199
|
typer.echo(f"{n}. question: {text}")
|
|
200
|
+
# Rendering fills every field, so leave out the ones that aren't set
|
|
200
201
|
for k, v in question.items():
|
|
202
|
+
if v is None:
|
|
203
|
+
continue
|
|
201
204
|
if isinstance(v, dict):
|
|
202
205
|
typer.echo(f" {k}:")
|
|
203
206
|
for k1, v1 in v.items():
|
|
@@ -206,11 +209,17 @@ def _echo_question(n: int, question: str | dict) -> None:
|
|
|
206
209
|
typer.echo(f" {k}:")
|
|
207
210
|
for item in v:
|
|
208
211
|
if isinstance(item, dict):
|
|
212
|
+
item = {
|
|
213
|
+
k1: v1 for k1, v1 in item.items() if v1 is not None
|
|
214
|
+
}
|
|
209
215
|
for n1, (k1, v1) in enumerate(item.items()):
|
|
210
|
-
if n1 == 0
|
|
211
|
-
|
|
216
|
+
prefix = " - " if n1 == 0 else " "
|
|
217
|
+
if isinstance(v1, dict):
|
|
218
|
+
typer.echo(f"{prefix}{k1}:")
|
|
219
|
+
for k2, v2 in v1.items():
|
|
220
|
+
typer.echo(f" {k2}: {v2}")
|
|
212
221
|
else:
|
|
213
|
-
typer.echo(f"
|
|
222
|
+
typer.echo(f"{prefix}{k1}: {v1}")
|
|
214
223
|
else:
|
|
215
224
|
typer.echo(f" - {item}")
|
|
216
225
|
else:
|
calkit/cli/main/core.py
CHANGED
|
@@ -684,10 +684,16 @@ def get_status(
|
|
|
684
684
|
typer.echo()
|
|
685
685
|
if "dvc" in categories:
|
|
686
686
|
print_sep("DVC")
|
|
687
|
+
from dvc.exceptions import NotDvcRepoError
|
|
688
|
+
|
|
687
689
|
try:
|
|
688
690
|
calkit.dvc.get_dvc_repo()
|
|
689
|
-
except
|
|
691
|
+
except NotDvcRepoError:
|
|
690
692
|
typer.echo("This is not a DVC repository.\n")
|
|
693
|
+
except Exception as e:
|
|
694
|
+
typer.echo(
|
|
695
|
+
f"Failed to open DVC repo: {e.__class__.__name__}: {e}\n"
|
|
696
|
+
)
|
|
691
697
|
else:
|
|
692
698
|
zip_path_map = calkit.dvc.zip.get_zip_path_map()
|
|
693
699
|
dvc_repo = calkit.dvc.get_dvc_repo()
|
|
@@ -2699,14 +2705,19 @@ def run(
|
|
|
2699
2705
|
os.environ.pop("CALKIT_PIPELINE_RUNNING", None)
|
|
2700
2706
|
raise_error(f"Pipeline compilation failed: {e}")
|
|
2701
2707
|
# Initialize DVC repo if necessary
|
|
2708
|
+
from dvc.exceptions import NotDvcRepoError
|
|
2709
|
+
|
|
2702
2710
|
try:
|
|
2703
2711
|
calkit.dvc.get_dvc_repo()
|
|
2704
|
-
except
|
|
2712
|
+
except NotDvcRepoError:
|
|
2705
2713
|
if not quiet:
|
|
2706
2714
|
typer.echo("Initializing DVC repo")
|
|
2707
2715
|
result = calkit.dvc.init()
|
|
2708
2716
|
if result != 0:
|
|
2709
2717
|
raise_error("Failed to initialize DVC repo")
|
|
2718
|
+
except Exception as e:
|
|
2719
|
+
# E.g., DVC's site cache dir isn't writable, which 'dvc init' can't fix
|
|
2720
|
+
raise_error(f"Failed to open DVC repo: {e.__class__.__name__}: {e}")
|
|
2710
2721
|
# Convert deps into target stage names
|
|
2711
2722
|
# TODO: This could probably be merged back upstream into DVC
|
|
2712
2723
|
if dvc_stages is None:
|
calkit/cli/sync.py
CHANGED
|
@@ -39,12 +39,16 @@ def sync_dvc(
|
|
|
39
39
|
no_check_auth: Annotated[bool, typer.Option("--no-check-auth")] = False,
|
|
40
40
|
) -> None:
|
|
41
41
|
"""Sync the DVC repository by pulling and then pushing."""
|
|
42
|
+
from dvc.exceptions import NotDvcRepoError
|
|
43
|
+
|
|
42
44
|
from calkit.cli.main.core import pull, push
|
|
43
45
|
|
|
44
46
|
try:
|
|
45
47
|
calkit.dvc.get_dvc_repo()
|
|
46
|
-
except
|
|
48
|
+
except NotDvcRepoError:
|
|
47
49
|
raise_error("No DVC repository found. Run 'calkit init' first.")
|
|
50
|
+
except Exception as e:
|
|
51
|
+
raise_error(f"Failed to open DVC repo: {e.__class__.__name__}: {e}")
|
|
48
52
|
if not calkit.dvc.get_remotes():
|
|
49
53
|
raise_error(
|
|
50
54
|
"No DVC remotes configured. Add a remote with "
|
calkit/models/core.py
CHANGED
|
@@ -1703,8 +1703,12 @@ class FigureEvidence(BaseModel):
|
|
|
1703
1703
|
|
|
1704
1704
|
class ResultsEvidence(BaseModel):
|
|
1705
1705
|
"""Evidence in the form of a results file: a set of values, a table, a
|
|
1706
|
-
map, whatever the pipeline wrote.
|
|
1707
|
-
|
|
1706
|
+
map, whatever the pipeline wrote.
|
|
1707
|
+
|
|
1708
|
+
``values`` names related values within it, like the fields of a struct
|
|
1709
|
+
or object,
|
|
1710
|
+
so each can be templated into the answer as ``value`` evidence would be
|
|
1711
|
+
without an entry per value.
|
|
1708
1712
|
"""
|
|
1709
1713
|
|
|
1710
1714
|
kind: Literal["result"] = "result"
|
|
@@ -1716,6 +1720,14 @@ class ResultsEvidence(BaseModel):
|
|
|
1716
1720
|
"results file."
|
|
1717
1721
|
),
|
|
1718
1722
|
)
|
|
1723
|
+
values: dict[str, str] | None = Field(
|
|
1724
|
+
default=None,
|
|
1725
|
+
description=(
|
|
1726
|
+
"Values within the results file, mapping each name, under which "
|
|
1727
|
+
"it can be templated into the question's text, to its key. Names "
|
|
1728
|
+
"must be unique within the question."
|
|
1729
|
+
),
|
|
1730
|
+
)
|
|
1719
1731
|
explanation: str | None = None
|
|
1720
1732
|
git_ref: str | None = Field(
|
|
1721
1733
|
default=None,
|
|
@@ -1725,11 +1737,17 @@ class ResultsEvidence(BaseModel):
|
|
|
1725
1737
|
),
|
|
1726
1738
|
)
|
|
1727
1739
|
|
|
1740
|
+
@model_validator(mode="after")
|
|
1741
|
+
def _key_or_values(self) -> ResultsEvidence:
|
|
1742
|
+
if self.key is not None and self.values is not None:
|
|
1743
|
+
raise ValueError("a result takes 'values' or 'key', not both")
|
|
1744
|
+
return self
|
|
1745
|
+
|
|
1728
1746
|
|
|
1729
1747
|
_KEY_DESCRIPTION = (
|
|
1730
|
-
"Key of the value within the results file.
|
|
1731
|
-
"
|
|
1732
|
-
"
|
|
1748
|
+
"Key of the value within the results file. It is split on dots and "
|
|
1749
|
+
"walked into nested objects, taking the longest run of parts that names "
|
|
1750
|
+
"a key at each level, with integers indexing lists."
|
|
1733
1751
|
)
|
|
1734
1752
|
|
|
1735
1753
|
|
|
@@ -1817,6 +1835,34 @@ class PublicationEvidence(BaseModel):
|
|
|
1817
1835
|
)
|
|
1818
1836
|
|
|
1819
1837
|
|
|
1838
|
+
class DocumentEvidence(BaseModel):
|
|
1839
|
+
"""Evidence in the form of a document cited by path, e.g., a Markdown
|
|
1840
|
+
write-up, without declaring it as a publication.
|
|
1841
|
+
|
|
1842
|
+
The document must be built by a pipeline stage, usually a Markdown
|
|
1843
|
+
stage, so its numbers are injected from the results and go stale with
|
|
1844
|
+
them; one written by hand is an error, like a typed-in value.
|
|
1845
|
+
"""
|
|
1846
|
+
|
|
1847
|
+
kind: Literal["document"] = "document"
|
|
1848
|
+
path: str
|
|
1849
|
+
section: str | None = Field(
|
|
1850
|
+
default=None,
|
|
1851
|
+
description=(
|
|
1852
|
+
"Section of the document where the evidence is presented, as a "
|
|
1853
|
+
"reader would find it, e.g., '4.2' or 'Results'."
|
|
1854
|
+
),
|
|
1855
|
+
)
|
|
1856
|
+
explanation: str | None = None
|
|
1857
|
+
git_ref: str | None = Field(
|
|
1858
|
+
default=None,
|
|
1859
|
+
description=(
|
|
1860
|
+
"Git reference (branch, tag, or commit hash) pointing to the "
|
|
1861
|
+
"version of the repository where the document can be found."
|
|
1862
|
+
),
|
|
1863
|
+
)
|
|
1864
|
+
|
|
1865
|
+
|
|
1820
1866
|
class Question(BaseModel):
|
|
1821
1867
|
"""A question the project hopes to answer.
|
|
1822
1868
|
|
|
@@ -1839,6 +1885,15 @@ class Question(BaseModel):
|
|
|
1839
1885
|
new evidence is how to say it still holds.
|
|
1840
1886
|
"""
|
|
1841
1887
|
|
|
1888
|
+
name: str | None = Field(
|
|
1889
|
+
default=None,
|
|
1890
|
+
description=(
|
|
1891
|
+
"Name for the question, e.g., for quoting its answer in a "
|
|
1892
|
+
"document through 'calkit latex from-questions'. Unlike its "
|
|
1893
|
+
"position in the list, it survives questions being added or "
|
|
1894
|
+
"reordered. Must be unique among the project's questions."
|
|
1895
|
+
),
|
|
1896
|
+
)
|
|
1842
1897
|
question: str
|
|
1843
1898
|
hypothesis: str | None = None
|
|
1844
1899
|
answer: str | dict[str, str] | None = Field(
|
|
@@ -1866,10 +1921,23 @@ class Question(BaseModel):
|
|
|
1866
1921
|
| ValueEvidence
|
|
1867
1922
|
| TableEvidence
|
|
1868
1923
|
| PublicationEvidence
|
|
1924
|
+
| DocumentEvidence
|
|
1869
1925
|
]
|
|
1870
1926
|
| None
|
|
1871
1927
|
) = None
|
|
1872
1928
|
|
|
1929
|
+
@field_validator("name")
|
|
1930
|
+
@classmethod
|
|
1931
|
+
def check_name_not_a_position(cls, v: str | None) -> str | None:
|
|
1932
|
+
# A question is also addressable by its position, so an all-digit
|
|
1933
|
+
# name would be ambiguous with some other question's number
|
|
1934
|
+
if v is not None and v.isdigit():
|
|
1935
|
+
raise ValueError(
|
|
1936
|
+
f"Question name {v!r} can't be a number, since questions "
|
|
1937
|
+
"are also addressed by position"
|
|
1938
|
+
)
|
|
1939
|
+
return v
|
|
1940
|
+
|
|
1873
1941
|
|
|
1874
1942
|
class ProjectInfo(BaseModel):
|
|
1875
1943
|
"""All of the project's information or metadata, written to the
|
|
@@ -2074,3 +2142,14 @@ class ProjectInfo(BaseModel):
|
|
|
2074
2142
|
description="Overleaf sync configuration, keyed by the path of the "
|
|
2075
2143
|
"synced directory.",
|
|
2076
2144
|
)
|
|
2145
|
+
|
|
2146
|
+
@field_validator("questions")
|
|
2147
|
+
@classmethod
|
|
2148
|
+
def check_question_names_unique(
|
|
2149
|
+
cls, v: list[str | Question]
|
|
2150
|
+
) -> list[str | Question]:
|
|
2151
|
+
names = [q.name for q in v if isinstance(q, Question) and q.name]
|
|
2152
|
+
dupes = sorted({n for n in names if names.count(n) > 1})
|
|
2153
|
+
if dupes:
|
|
2154
|
+
raise ValueError(f"Question names must be unique: {dupes}")
|
|
2155
|
+
return v
|