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.
Files changed (36) hide show
  1. calkit/agent_skills/check-questions/SKILL.md +9 -9
  2. calkit/cli/latex.py +195 -45
  3. calkit/cli/list.py +12 -3
  4. calkit/cli/main/core.py +13 -2
  5. calkit/cli/sync.py +5 -1
  6. calkit/models/core.py +84 -5
  7. calkit/models/pipeline.py +60 -1
  8. calkit/notebooks.py +5 -0
  9. calkit/pipeline.py +30 -2
  10. calkit/questions.py +309 -37
  11. calkit/tests/cli/main/test_core.py +14 -0
  12. calkit/tests/cli/test_latex.py +134 -4
  13. calkit/tests/cli/test_list.py +12 -0
  14. calkit/tests/cli/test_notebooks.py +34 -0
  15. calkit/tests/cli/test_sync.py +11 -1
  16. calkit/tests/models/test_pipeline.py +2 -1
  17. calkit/tests/test_pipeline.py +27 -0
  18. calkit/tests/test_questions.py +260 -4
  19. {calkit_python-0.47.6.dist-info → calkit_python-0.47.8.dist-info}/METADATA +1 -1
  20. {calkit_python-0.47.6.dist-info → calkit_python-0.47.8.dist-info}/RECORD +36 -36
  21. {calkit_python-0.47.6.dist-info → calkit_python-0.47.8.dist-info}/WHEEL +1 -1
  22. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  23. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  24. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  25. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  26. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  27. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  28. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  29. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  30. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  31. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  32. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  33. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  34. {calkit_python-0.47.6.data → calkit_python-0.47.8.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  35. {calkit_python-0.47.6.dist-info → calkit_python-0.47.8.dist-info}/entry_points.txt +0 -0
  36. {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: value
157
+ - kind: result
156
158
  path: results/closure.json
157
- key: p-value
158
- name: p
159
- - kind: value
160
- path: results/closure.json
161
- key: improvement
159
+ values:
160
+ p: p-value
161
+ improvement: improvement
162
162
  ```
163
163
 
164
- Conditions name `value` evidence, so those names must be valid Python
165
- identifiers; give the entry a `name` otherwise. Every branch must be a
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="Path to a latexmkrc file to build the marked-up document with.",
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="Keep the generated diff .tex file for inspection.",
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
- return calkit.latex.detect_inputs(tex_file, wdir=root) + pointed
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
- base_tex=sides["base"],
733
- head_tex=sides["head"],
734
- tex_file=tex_file,
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
- base_tex: str,
792
- head_tex: str,
793
- tex_file: str,
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(tex_file) or "."
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(tex_file).stem
813
- diff_tex = os.path.join(build_dir, f"{stem}-diff.tex")
814
- # Where --keep-tex leaves it: beside the document, since a checkout is
815
- # removed afterwards
816
- kept_tex = os.path.normpath(os.path.join(tex_dir, f"{stem}-diff.tex"))
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
- # A checkout's verbatim inputs named by macro parameters are already
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 (base_tex, head_tex)
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
- if any(
833
- os.path.isfile(path)
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 + [base_tex, head_tex],
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
- marked_up = subprocess.check_output(cmd)
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
- with open(diff_tex, "wb") as f:
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(diff_tex)
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
- raise_error(
960
- "latexmk failed on the marked-up document with exit status "
961
- f"{e.returncode}; rerun with --keep-tex to inspect it"
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
- with open(state_path, "w") as f:
974
- f.write(digest)
1111
+ Path(state_path).write_text(digest)
975
1112
  typer.echo(f"Wrote {output}")
976
1113
  finally:
977
- if os.path.isfile(diff_tex):
978
- in_place = os.path.abspath(diff_tex) == os.path.abspath(kept_tex)
979
- if keep_tex and not in_place:
980
- shutil.copy(diff_tex, kept_tex)
981
- if not keep_tex or not in_place:
982
- os.remove(diff_tex)
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
- typer.echo(f" - {k1}: {v1}")
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" {k1}: {v1}")
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 Exception:
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 Exception:
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 Exception:
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. For one value inside such a file, use
1707
- ``value`` evidence, which can be templated into the answer.
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. A key present at the top "
1731
- "level is used as-is; otherwise it is split on dots and walked into "
1732
- "nested objects, with integers indexing lists."
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