calkit-python 0.47.5__py3-none-any.whl → 0.47.6__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 (25) hide show
  1. calkit/agent_skills/build-paper-pipeline/SKILL.md +5 -2
  2. calkit/agent_skills/check-questions/SKILL.md +44 -4
  3. calkit/cli/list.py +6 -4
  4. calkit/models/core.py +9 -1
  5. calkit/questions.py +230 -18
  6. calkit/tests/cli/test_check.py +34 -17
  7. calkit/tests/test_questions.py +155 -1
  8. {calkit_python-0.47.5.dist-info → calkit_python-0.47.6.dist-info}/METADATA +1 -1
  9. {calkit_python-0.47.5.dist-info → calkit_python-0.47.6.dist-info}/RECORD +25 -25
  10. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  11. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  12. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  13. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  14. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  15. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  16. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  17. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  18. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  19. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  20. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  21. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  22. {calkit_python-0.47.5.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  23. {calkit_python-0.47.5.dist-info → calkit_python-0.47.6.dist-info}/WHEEL +0 -0
  24. {calkit_python-0.47.5.dist-info → calkit_python-0.47.6.dist-info}/entry_points.txt +0 -0
  25. {calkit_python-0.47.5.dist-info → calkit_python-0.47.6.dist-info}/licenses/LICENSE +0 -0
@@ -84,8 +84,11 @@ building a pipeline from scratch.
84
84
  `calkit.yaml`: the `question` as the paper poses it, the `answer` as the
85
85
  paper states it with `{name}` placeholders where numbers go, and
86
86
  `evidence` naming the results file and key, the figure, and the
87
- publication section that carries the argument. Run
88
- `calkit check questions` and fix what it reports.
87
+ publication section that carries the argument. The results file must be
88
+ one a stage from step 3 writes, never one written by hand. Where the
89
+ claim hinges on a threshold, write a conditional answer; the
90
+ `check-questions` skill covers both. Run `calkit check questions` and
91
+ fix what it reports.
89
92
 
90
93
  6. **Run the pipeline, then check your own work.** Run `calkit run`, then
91
94
  `calkit check repro`, which reads the manuscript back and reports any
@@ -21,12 +21,17 @@ Deterministic, done by `calkit check questions` — never re-derive by hand:
21
21
  - every evidence path exists;
22
22
  - every `value` key resolves in its file, and every `{name}` placeholder in
23
23
  the prose resolves and formats;
24
+ - every clause of a conditional answer parses, names only evidence, and
25
+ renders, including the clauses the current values don't select;
24
26
  - every publication `label` still exists in the LaTeX source;
25
27
  - no evidence has changed (Git history for Git-tracked outputs, `dvc.lock`
26
28
  for DVC-tracked ones) since the commit that last edited the question;
27
- - each evidence path is produced by a pipeline stage, or declared with
28
- `imported_from` or `created_by`. This one is advisory: it is reported as
29
- `unattributed` and does not fail the check.
29
+ - each `value` entry reads a file a pipeline stage produces, or one
30
+ declared with `imported_from`; anything else is an error, since a number
31
+ nothing computes is a magic number;
32
+ - each other evidence path is produced by a pipeline stage, or declared
33
+ with `imported_from` or `created_by`. This one is advisory: it is
34
+ reported as `unattributed` and does not fail the check.
30
35
 
31
36
  Judgment, done here — the check reads paths and hashes, and cannot read a
32
37
  sentence:
@@ -35,6 +40,8 @@ sentence:
35
40
  this skill exists;
36
41
  - does the answer still follow from the evidence, given what changed;
37
42
  - are numbers retyped into the prose that should be `{name}` placeholders;
43
+ - does a claim resting on a threshold use a conditional answer, with a
44
+ threshold chosen before the value was known;
38
45
  - is the answer concise, and does it point at the publication section that
39
46
  carries the argument rather than repeating it.
40
47
 
@@ -104,7 +111,8 @@ evidence without showing them both.
104
111
  from. If a stage should produce it, that is a pipeline gap worth
105
112
  reporting. If it was imported or made by hand, declare it under
106
113
  `figures`, `datasets`, or `publications` with `imported_from` or
107
- `created_by` so the project says so.
114
+ `created_by` so the project says so. A `value` entry with no stage is
115
+ reported as an error rather than `unattributed`: give it a stage.
108
116
  4. For each **error**, fix the reference: a missing path means the pipeline
109
117
  has not been run or pulled; a bad key or placeholder means a results
110
118
  file was restructured; a missing label means the publication was
@@ -128,6 +136,38 @@ project, not a tidy-up.
128
136
  what it means; leave the reasoning to the publication.
129
137
  - Numbers come from `value` evidence via placeholders, formatted to the
130
138
  precision the claim needs: `{ratio:.1f}x`, `{error:.0%}`.
139
+ - A `value` entry must read a file a pipeline stage writes. A placeholder
140
+ over a results file written by hand, including one you write, is a
141
+ retyped number with extra steps, and the check fails it. If no stage
142
+ produces the number, add one (`/calkit:add-pipeline-stage`) instead of
143
+ writing the file. Never declare a file `imported_from` or `created_by`
144
+ to clear that error unless it really came from there, and never edit a
145
+ results file to change what an answer says.
146
+ - When the claim itself depends on a value, not just the number in it,
147
+ e.g., significant or not, which method wins, write a conditional answer
148
+ so the wording follows the evidence on a rerun:
149
+
150
+ ```yaml
151
+ answer:
152
+ if p < 0.05: "The closure cuts error by {improvement:.1f}x."
153
+ else: "The closure does not measurably reduce error."
154
+ evidence:
155
+ - kind: value
156
+ path: results/closure.json
157
+ key: p-value
158
+ name: p
159
+ - kind: value
160
+ path: results/closure.json
161
+ key: improvement
162
+ ```
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
166
+ claim the evidence would support if it held. The threshold is part of
167
+ the claim: take it from the field's convention or the user, never pick
168
+ it to make the current branch hold, and don't use branches to hedge a
169
+ claim that should simply be weakened.
170
+
131
171
  - Point at the publication with a `publication` evidence entry carrying
132
172
  `section` (for the reader) and `label` (for the check), instead of an
133
173
  `explanation` that restates the argument.
calkit/cli/list.py CHANGED
@@ -243,7 +243,7 @@ def list_questions(
243
243
  render_question,
244
244
  )
245
245
 
246
- def _texts(question: dict) -> list[str]:
246
+ def _texts(question: dict) -> list[str | dict]:
247
247
  evidence = question.get("evidence") or []
248
248
  return [question.get(f) or "" for f in TEMPLATED_FIELDS] + [
249
249
  ev.get("explanation") or ""
@@ -260,16 +260,18 @@ def list_questions(
260
260
  # let a fresh clone read as a project that types its braces. Any
261
261
  # placeholder left standing counts, whether it names evidence that
262
262
  # could not be read or names nothing at all: a brace meant to stay
263
- # in the text is written '{{' and never reaches here.
263
+ # in the text is written '{{' and never reaches here. A conditional
264
+ # answer still in clauses is one whose conditions could not be read.
264
265
  unfilled = any(
265
- placeholders(text)
266
+ isinstance(text, dict) or placeholders(text)
266
267
  for q in rendered
267
268
  if isinstance(q, dict)
268
269
  for text in _texts(q)
269
270
  )
270
271
  if unfilled:
271
272
  warn(
272
- "Some placeholders could not be filled from the evidence. "
273
+ "Some placeholders or conditions could not be filled from "
274
+ "the evidence. "
273
275
  "Run 'calkit check questions' to see why; 'calkit pull' if "
274
276
  "the results files are not here yet.",
275
277
  err=json_output,
calkit/models/core.py CHANGED
@@ -1841,7 +1841,15 @@ class Question(BaseModel):
1841
1841
 
1842
1842
  question: str
1843
1843
  hypothesis: str | None = None
1844
- answer: str | None = None
1844
+ answer: str | dict[str, str] | None = Field(
1845
+ default=None,
1846
+ description=(
1847
+ "The claim the evidence supports. A mapping keyed by 'if "
1848
+ "<condition>', 'elif <condition>' and 'else' picks its wording "
1849
+ "from the evidence, so an answer resting on a threshold states "
1850
+ "the other outcome instead of going stale when a value moves."
1851
+ ),
1852
+ )
1845
1853
  notes: str | None = Field(
1846
1854
  default=None,
1847
1855
  description=(
calkit/questions.py CHANGED
@@ -32,13 +32,15 @@ updates itself.
32
32
 
33
33
  from __future__ import annotations
34
34
 
35
+ import ast
35
36
  import glob
36
37
  import io
37
38
  import json
39
+ import operator
38
40
  import os
39
41
  import re
40
42
  import string
41
- from typing import Any, Literal
43
+ from typing import Any, Literal, TypeGuard
42
44
 
43
45
  from pydantic import BaseModel, Field
44
46
 
@@ -245,14 +247,208 @@ def placeholders(text: str) -> list[str]:
245
247
  return [m.group(1) for m in _PLACEHOLDER.finditer(text or "")]
246
248
 
247
249
 
248
- def render(text: str | None, values: dict[str, Any]) -> str | None:
250
+ def _placeholder_problems(text: str, values: dict[str, Any]) -> list[str]:
251
+ if "{" not in text:
252
+ return []
253
+ try:
254
+ _FORMATTER.vformat(text, (), values)
255
+ except KeyError as e:
256
+ return [
257
+ f"placeholder {{{e.args[0]}}} names no evidence; write "
258
+ "'{{' and '}}' for braces meant to stay in the text"
259
+ ]
260
+ except (ValueError, IndexError) as e:
261
+ return [f"cannot render {text[:40]!r}...: {e}"]
262
+ return []
263
+
264
+
265
+ _IF_KEY = re.compile(r"^\s*(if|elif)\s+(.+?)\s*$")
266
+ _ELSE_KEY = re.compile(r"^\s*else\s*$")
267
+ _COMPARISONS = {
268
+ ast.Lt: operator.lt,
269
+ ast.LtE: operator.le,
270
+ ast.Gt: operator.gt,
271
+ ast.GtE: operator.ge,
272
+ ast.Eq: operator.eq,
273
+ ast.NotEq: operator.ne,
274
+ }
275
+
276
+
277
+ def is_conditional(value: Any) -> TypeGuard[dict]:
278
+ """Whether a value picks its wording with ``if``/``elif``/``else``."""
279
+ return isinstance(value, dict)
280
+
281
+
282
+ def parse_conditional(clauses: dict) -> list[tuple[str | None, str]]:
283
+ """Read a conditional's keys into ordered ``(condition, wording)``.
284
+
285
+ The condition is ``None`` for ``else``. Keys are read in the order
286
+ they appear in the file, so the clauses are tried in the order they
287
+ were written.
288
+ """
289
+ parsed: list[tuple[str | None, str]] = []
290
+ for position, (key, wording) in enumerate(clauses.items()):
291
+ opened = _IF_KEY.match(str(key))
292
+ if opened:
293
+ keyword, condition = opened.groups()
294
+ if keyword == "if" and position:
295
+ raise ValueError("only the first clause may be 'if'")
296
+ if keyword == "elif" and not parsed:
297
+ raise ValueError("'elif' with no 'if' before it")
298
+ parsed.append((condition, str(wording)))
299
+ continue
300
+ if _ELSE_KEY.match(str(key)):
301
+ if not parsed:
302
+ raise ValueError("'else' with no 'if' before it")
303
+ parsed.append((None, str(wording)))
304
+ continue
305
+ raise ValueError(f"expected 'if', 'elif' or 'else', got {key!r}")
306
+ if not parsed:
307
+ raise ValueError("a conditional needs at least an 'if' clause")
308
+ for condition, _ in parsed[:-1]:
309
+ if condition is None:
310
+ raise ValueError("'else' must be the last clause")
311
+ return parsed
312
+
313
+
314
+ def _operand(node: ast.AST, values: dict[str, Any]) -> Any:
315
+ """One side of a comparison, resolved against the evidence values.
316
+
317
+ Names and literals are read directly; anything else is arithmetic and
318
+ goes to the same evaluator the calculations use, so there is one
319
+ audited path for arithmetic rather than two.
320
+ """
321
+ if isinstance(node, ast.Constant):
322
+ return node.value
323
+ if isinstance(node, ast.Name):
324
+ if node.id not in values:
325
+ raise KeyError(node.id)
326
+ return values[node.id]
327
+ for inner in ast.walk(node):
328
+ if isinstance(inner, ast.Name) and inner.id not in values:
329
+ raise KeyError(inner.id)
330
+ import arithmetic_eval # type: ignore[import-untyped]
331
+
332
+ return arithmetic_eval.evaluate(ast.unparse(node), values)
333
+
334
+
335
+ def _truth(node: ast.AST, values: dict[str, Any]) -> bool:
336
+ if isinstance(node, ast.BoolOp):
337
+ # Not short-circuited, so a misspelled name is an error whatever
338
+ # the current values are
339
+ outcomes = [_truth(v, values) for v in node.values]
340
+ if isinstance(node.op, ast.And):
341
+ return all(outcomes)
342
+ return any(outcomes)
343
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
344
+ return not _truth(node.operand, values)
345
+ if isinstance(node, ast.Compare):
346
+ left = _operand(node.left, values)
347
+ for op, comparator in zip(node.ops, node.comparators):
348
+ right = _operand(comparator, values)
349
+ compare = _COMPARISONS.get(type(op))
350
+ if compare is None:
351
+ raise ValueError(
352
+ f"{type(op).__name__} is not a supported comparison"
353
+ )
354
+ if not compare(left, right):
355
+ return False
356
+ left = right
357
+ return True
358
+ raise ValueError("a condition must compare values, e.g., 'p < 0.05'")
359
+
360
+
361
+ def evaluate_condition(expression: str, values: dict[str, Any]) -> bool:
362
+ """Evaluate one ``if``/``elif`` condition against the evidence values.
363
+
364
+ Only comparisons, ``and``/``or``/``not`` and arithmetic are allowed, so
365
+ a condition read from ``calkit.yaml`` cannot call anything.
366
+ """
367
+ try:
368
+ tree = ast.parse(expression, mode="eval")
369
+ except SyntaxError as e:
370
+ raise ValueError(f"cannot parse condition {expression!r}: {e}") from e
371
+ try:
372
+ return bool(_truth(tree.body, values))
373
+ except KeyError:
374
+ # A name like 'paired-gain.vawt-8' reads as arithmetic and
375
+ # attribute access, so the failure would otherwise name a
376
+ # fragment of it and look like missing evidence.
377
+ unusable = [
378
+ name
379
+ for name in values
380
+ if not name.isidentifier() and name in expression
381
+ ]
382
+ if unusable:
383
+ raise ValueError(
384
+ f"condition {expression!r} refers to {unusable[0]!r}, which "
385
+ "cannot be read as a variable; give that evidence a 'name' "
386
+ "that is a valid Python identifier"
387
+ ) from None
388
+ raise
389
+ except ValueError:
390
+ raise
391
+ except Exception as e:
392
+ # E.g., comparing a string to a number, or syntax arithmetic_eval
393
+ # refuses, so callers only have to handle one kind of bad condition
394
+ raise ValueError(
395
+ f"cannot evaluate condition {expression!r}: {e}"
396
+ ) from e
397
+
398
+
399
+ def check_conditional(clauses: dict, values: dict[str, Any]) -> list[str]:
400
+ """Problems with every clause of a conditional, not just the one chosen.
401
+
402
+ The clauses the current values don't select are the ones a rerun will
403
+ reach, so a typo in them is an error now rather than after the rerun.
404
+ """
405
+ try:
406
+ parsed = parse_conditional(clauses)
407
+ except ValueError as e:
408
+ return [f"conditional answer: {e}"]
409
+ messages: list[str] = []
410
+ held = False
411
+ for condition, wording in parsed:
412
+ if condition is None:
413
+ held = True
414
+ else:
415
+ try:
416
+ held = evaluate_condition(condition, values) or held
417
+ except KeyError as e:
418
+ messages.append(
419
+ f"condition {condition!r} names no evidence {e.args[0]!r}"
420
+ )
421
+ except ValueError as e:
422
+ messages.append(str(e))
423
+ messages += _placeholder_problems(wording, values)
424
+ if not messages and not held:
425
+ messages.append(
426
+ "no condition of the conditional answer holds and there is no "
427
+ "'else' clause"
428
+ )
429
+ return messages
430
+
431
+
432
+ def select_branch(clauses: dict, values: dict[str, Any]) -> str:
433
+ """The wording whose condition holds, for a conditional answer."""
434
+ for condition, wording in parse_conditional(clauses):
435
+ if condition is None or evaluate_condition(condition, values):
436
+ return wording
437
+ raise ValueError("no condition held and there is no 'else' clause")
438
+
439
+
440
+ def render(text: str | dict | None, values: dict[str, Any]) -> str | None:
249
441
  """Fill a question text's placeholders from its evidence values.
250
442
 
251
443
  Raises ``KeyError`` for a name with no evidence and ``ValueError`` for
252
444
  a format spec the value cannot satisfy, so a template that cannot be
253
445
  rendered is an error rather than a silently unfilled sentence.
254
446
  """
255
- if text is None or "{" not in text:
447
+ if text is None:
448
+ return text
449
+ if isinstance(text, dict):
450
+ text = select_branch(text, values)
451
+ if "{" not in text:
256
452
  return text
257
453
  return _FORMATTER.vformat(text, (), values)
258
454
 
@@ -596,7 +792,11 @@ def _check_publication_label(
596
792
 
597
793
 
598
794
  def _is_attributed(
599
- path: str, stage: str | None, ck_info: dict, wdir: str
795
+ path: str,
796
+ stage: str | None,
797
+ ck_info: dict,
798
+ wdir: str,
799
+ computed: bool = False,
600
800
  ) -> bool:
601
801
  """Whether the project says where an evidence path came from.
602
802
 
@@ -605,7 +805,9 @@ def _is_attributed(
605
805
  may be declared as an artifact that records an import or a person,
606
806
  which is what :func:`calkit.provenance.has_provenance` reads---an
607
807
  imported dataset or a hand-drawn schematic is accounted for even
608
- though there is nothing upstream to point at.
808
+ though there is nothing upstream to point at. With ``computed``, a
809
+ person doesn't count, since a number someone typed into a file is
810
+ still a magic number.
609
811
  """
610
812
  from calkit.provenance import has_provenance
611
813
 
@@ -624,7 +826,12 @@ def _is_attributed(
624
826
  if (
625
827
  isinstance(artifact, dict)
626
828
  and artifact.get("path") == path
627
- and has_provenance(artifact)
829
+ and (
830
+ artifact.get("stage") is not None
831
+ or artifact.get("imported_from") is not None
832
+ if computed
833
+ else has_provenance(artifact)
834
+ )
628
835
  ):
629
836
  return True
630
837
  return False
@@ -808,7 +1015,19 @@ def check_evidence(
808
1015
  ],
809
1016
  )
810
1017
  )
811
- if out.status == "ok" and not _is_attributed(
1018
+ if (
1019
+ out.status in ("ok", "changed")
1020
+ and is_value_evidence(ev)
1021
+ and not _is_attributed(path, out.stage, ck_info, wdir, computed=True)
1022
+ ):
1023
+ out.status = "error"
1024
+ out.message = (
1025
+ "no pipeline stage computes this value, so nothing can show "
1026
+ "where the number came from or keep it current; produce the file "
1027
+ "with a stage, or declare it with 'imported_from' if another "
1028
+ "project computed it"
1029
+ )
1030
+ elif out.status == "ok" and not _is_attributed(
812
1031
  path, out.stage, ck_info, wdir
813
1032
  ):
814
1033
  out.status = "unattributed"
@@ -874,17 +1093,10 @@ def check_question(
874
1093
  ev.get("explanation") for ev in evidence
875
1094
  ]
876
1095
  for t in texts:
877
- if not t or "{" not in t:
878
- continue
879
- try:
880
- render(t, values)
881
- except KeyError as e:
882
- messages.append(
883
- f"placeholder {{{e.args[0]}}} names no evidence; write "
884
- "'{{' and '}}' for braces meant to stay in the text"
885
- )
886
- except (ValueError, IndexError) as e:
887
- messages.append(f"cannot render {t[:40]!r}...: {e}")
1096
+ if is_conditional(t):
1097
+ messages += check_conditional(t, values)
1098
+ elif t:
1099
+ messages += _placeholder_problems(t, values)
888
1100
  # Worst first, matching what the hub shows against each question: an
889
1101
  # answer resting on nothing anyone can find is worse off than one
890
1102
  # resting on something merely out of date.
@@ -1182,11 +1182,24 @@ def test_check_questions(tmp_dir):
1182
1182
  calkit.ryaml.dump(ck_info, f)
1183
1183
  subprocess.check_call(["git", "add", "-A"])
1184
1184
  subprocess.check_call(["git", "commit", "-q", "-m", "Answer"])
1185
+ # No stage computes the value, so it is a magic number and fails
1186
+ proc = subprocess.run(
1187
+ ["calkit", "check", "questions"], capture_output=True, text=True
1188
+ )
1189
+ assert proc.returncode != 0
1190
+ assert "no pipeline stage computes this value" in proc.stdout
1191
+ # Another project computed it, which is traceable
1192
+ ck_info["datasets"] = [
1193
+ {
1194
+ "path": "results/findings.json",
1195
+ "imported_from": {"project": "someone/else"},
1196
+ }
1197
+ ]
1198
+ with open("calkit.yaml", "w") as f:
1199
+ calkit.ryaml.dump(ck_info, f)
1200
+ subprocess.check_call(["git", "commit", "-q", "-am", "Import it"])
1185
1201
  out = subprocess.check_output(["calkit", "check", "questions"], text=True)
1186
1202
  assert "Answers backed by current evidence: 1/1" in out
1187
- # Nothing in the project says where the results file came from, which
1188
- # is advice rather than a failure: the answer may be perfectly good
1189
- assert "Evidence with nothing recorded behind it: 1" in out
1190
1203
  # Listing renders the placeholder from the results file
1191
1204
  out = subprocess.check_output(["calkit", "list", "questions"], text=True)
1192
1205
  assert "answer: 8 of eight do." in out
@@ -1252,19 +1265,6 @@ def test_check_questions(tmp_dir):
1252
1265
  subprocess.check_call(["git", "commit", "-q", "-am", "Review"])
1253
1266
  out = subprocess.check_output(["calkit", "check", "questions"], text=True)
1254
1267
  assert "changed after the answer was written" not in out
1255
- # Declaring where the results file came from clears the advice
1256
- ck_info = calkit.load_calkit_info()
1257
- ck_info["datasets"] = [
1258
- {
1259
- "path": "results/findings.json",
1260
- "created_by": {"name": "A person"},
1261
- }
1262
- ]
1263
- with open("calkit.yaml", "w") as f:
1264
- calkit.ryaml.dump(ck_info, f)
1265
- subprocess.check_call(["git", "commit", "-q", "-am", "Declare"])
1266
- out = subprocess.check_output(["calkit", "check", "questions"], text=True)
1267
- assert "nothing recorded behind it" not in out
1268
1268
  # A console that cannot encode a check mark gets a '?' rather than a
1269
1269
  # UnicodeEncodeError, which on Windows would kill the command with no
1270
1270
  # output at all
@@ -1289,8 +1289,25 @@ def test_check_questions(tmp_dir):
1289
1289
  with open("calkit.yaml", "w") as f:
1290
1290
  calkit.ryaml.dump(ck_info, f)
1291
1291
  out = subprocess.check_output(["calkit", "list", "questions"], text=True)
1292
- assert "placeholders could not be filled" in out
1292
+ assert "could not be filled" in out
1293
1293
  assert "It is {missing} of them." in out
1294
+ # A conditional answer is listed as the branch that holds, and one with
1295
+ # no branch holding is listed as written, with the same warning
1296
+ ck_info["questions"][0]["answer"] = {
1297
+ "if n_top > 4": "Most do.",
1298
+ "else": "{n_top} do.",
1299
+ }
1300
+ with open("calkit.yaml", "w") as f:
1301
+ calkit.ryaml.dump(ck_info, f)
1302
+ out = subprocess.check_output(["calkit", "list", "questions"], text=True)
1303
+ assert "answer: 0 do." in out
1304
+ assert "could not be filled" not in out
1305
+ del ck_info["questions"][0]["answer"]["else"]
1306
+ with open("calkit.yaml", "w") as f:
1307
+ calkit.ryaml.dump(ck_info, f)
1308
+ out = subprocess.check_output(["calkit", "list", "questions"], text=True)
1309
+ assert "could not be filled" in out
1310
+ assert "if n_top > 4: Most do." in out
1294
1311
  # A project with no questions says so rather than printing nothing
1295
1312
  os.remove("calkit.yaml")
1296
1313
  with open("calkit.yaml", "w") as f:
@@ -14,11 +14,14 @@ from calkit.questions import (
14
14
  QuestionsStatus,
15
15
  check_question,
16
16
  check_questions,
17
+ evaluate_condition,
17
18
  format_status,
19
+ parse_conditional,
18
20
  placeholders,
19
21
  render,
20
22
  render_question,
21
23
  resolve_key,
24
+ select_branch,
22
25
  )
23
26
 
24
27
 
@@ -92,6 +95,16 @@ def test_history_is_read_once_for_all_questions(tmp_dir):
92
95
  with open("results/findings.json", "w") as f:
93
96
  json.dump({f"k{i}": i for i in range(4)}, f)
94
97
  ck_info = {
98
+ "pipeline": {
99
+ "stages": {
100
+ "summarize": {
101
+ "kind": "python-script",
102
+ "environment": "py",
103
+ "script_path": "s.py",
104
+ "outputs": ["results/findings.json"],
105
+ }
106
+ }
107
+ },
95
108
  "questions": [
96
109
  {
97
110
  "question": f"Q{i}?",
@@ -106,7 +119,7 @@ def test_history_is_read_once_for_all_questions(tmp_dir):
106
119
  ],
107
120
  }
108
121
  for i in range(4)
109
- ]
122
+ ],
110
123
  }
111
124
  with open("calkit.yaml", "w") as f:
112
125
  calkit.ryaml.dump(ck_info, f)
@@ -235,6 +248,32 @@ def test_check_questions(tmp_dir):
235
248
  # makes it and it is not declared with an import or a person
236
249
  assert q4.evidence[4].status == "unattributed"
237
250
  assert [ev.path for ev in status.unattributed] == ["figures/plot.png"]
251
+ # A value no stage computes is a magic number, so it fails rather than
252
+ # being advice; an import is traceable, a person typing it in is not
253
+ with open("results/typed.json", "w") as f:
254
+ json.dump({"n": 3}, f)
255
+ typed = {
256
+ "question": "Typed?",
257
+ "answer": "{n}",
258
+ "evidence": [
259
+ {"kind": "value", "path": "results/typed.json", "key": "n"}
260
+ ],
261
+ }
262
+ for declared, expected in [
263
+ (None, "error"),
264
+ ({"created_by": "someone"}, "error"),
265
+ ({"imported_from": {"project": "a/b"}}, "ok"),
266
+ ]:
267
+ info = dict(ck_info)
268
+ if declared:
269
+ info["datasets"] = [{"path": "results/typed.json"} | declared]
270
+ checked = check_question(5, typed, info, ".")
271
+ assert checked.status == expected, declared
272
+ if expected == "error":
273
+ assert "no pipeline stage computes" in (
274
+ checked.evidence[0].message or ""
275
+ )
276
+ os.remove("results/typed.json")
238
277
  rendered = render_question(ck_info["questions"][3], ck_info, ".")
239
278
  assert rendered["answer"] == "8 of eight do, a 5.1x gain."
240
279
  assert rendered["evidence"][2]["explanation"] == "The best is a."
@@ -530,3 +569,118 @@ def test_check_questions_pipeline_and_pins(tmp_dir):
530
569
  assert checked.status == "ok"
531
570
  assert checked.evidence[0].current == 2
532
571
  assert checked.evidence[0].git_ref == sha
572
+
573
+
574
+ def test_conditional_answers(tmp_dir):
575
+ values = {"p": 0.007, "rho": 0.8373, "n": 17, "leader": "turns-max"}
576
+ # Comparisons, chaining, boolean operators, strings, and arithmetic
577
+ assert evaluate_condition("p < 0.05", values)
578
+ assert not evaluate_condition("p >= 0.05", values)
579
+ assert evaluate_condition("0.0 <= p < 0.05", values)
580
+ assert evaluate_condition("p < 0.05 and rho > 0.8", values)
581
+ assert evaluate_condition("p > 0.5 or rho > 0.8", values)
582
+ assert evaluate_condition("not p > 0.5", values)
583
+ assert evaluate_condition("leader == 'turns-max'", values)
584
+ assert evaluate_condition("n / 2 > 8", values)
585
+ # A name with no evidence is an error, not a false condition, and
586
+ # nothing may be called or used as a bare value
587
+ with pytest.raises(KeyError):
588
+ evaluate_condition("missing < 1", values)
589
+ with pytest.raises(Exception):
590
+ evaluate_condition("len(leader) > 1", values)
591
+ with pytest.raises(ValueError):
592
+ evaluate_condition("p", values)
593
+ # A comparison the values can't make is a ValueError like the rest
594
+ with pytest.raises(ValueError, match="cannot evaluate"):
595
+ evaluate_condition("leader < 0.5", values)
596
+ # A name that is not a valid identifier cannot be read as a variable,
597
+ # and says so rather than reporting a fragment of itself as missing
598
+ with pytest.raises(ValueError, match="valid Python identifier"):
599
+ evaluate_condition("paired-gain > 0.1", {"paired-gain": 0.25})
600
+ # Clauses are tried in the order written, with None marking the else
601
+ clauses = {
602
+ "if p < 0.05": "strong, rho {rho:.2f}",
603
+ "elif p < 0.1": "weak",
604
+ "else": "none",
605
+ }
606
+ assert [c for c, _ in parse_conditional(clauses)] == [
607
+ "p < 0.05",
608
+ "p < 0.1",
609
+ None,
610
+ ]
611
+ assert select_branch(clauses, {"p": 0.007}) == "strong, rho {rho:.2f}"
612
+ assert select_branch(clauses, {"p": 0.08}) == "weak"
613
+ assert select_branch(clauses, {"p": 0.9}) == "none"
614
+ # Malformed clause sets are errors rather than silent misreadings
615
+ with pytest.raises(ValueError):
616
+ parse_conditional({"elif p < 1": "x"})
617
+ with pytest.raises(ValueError):
618
+ parse_conditional({"else": "x"})
619
+ with pytest.raises(ValueError):
620
+ parse_conditional({"when p < 1": "x"})
621
+ with pytest.raises(ValueError):
622
+ parse_conditional({"if p < 1": "x", "else": "y", "elif p < 2": "z"})
623
+ with pytest.raises(ValueError):
624
+ parse_conditional({"if p < 1": "x", "if p < 2": "y"})
625
+ # Nothing holding with no else is an error rather than a blank answer
626
+ with pytest.raises(ValueError):
627
+ select_branch({"if p < 0.05": "strong"}, {"p": 0.9})
628
+ # Rendering picks the branch, then fills its placeholders
629
+ answer = {
630
+ "if p < 0.05": "{leader} predicts it (rho {rho:+.2f})",
631
+ "else": "no feature predicts it",
632
+ }
633
+ assert render(answer, values) == "turns-max predicts it (rho +0.84)"
634
+ assert render(answer, values | {"p": 0.2}) == "no feature predicts it"
635
+ # Plain strings are untouched by the conditional path
636
+ assert render("if and only if", values) == "if and only if"
637
+ # Checking reports every clause's problems, including the clauses the
638
+ # current values don't select, rather than crashing or passing
639
+ with open("r.json", "w") as f:
640
+ json.dump({"p": 0.2, "leader": "x"}, f)
641
+ evidence = [
642
+ {"kind": "value", "path": "r.json", "key": "p"},
643
+ {"kind": "value", "path": "r.json", "key": "leader"},
644
+ ]
645
+ cases = [
646
+ ({"if p < 0.5": "{leader}", "else": "no"}, []),
647
+ (
648
+ {"if p < 0.05": "x"},
649
+ ["no condition of the conditional answer holds"],
650
+ ),
651
+ ({"when p < 1": "x"}, ["conditional answer: expected 'if'"]),
652
+ (
653
+ {"if leader < 0.05": "x", "else": "y"},
654
+ ["cannot evaluate condition 'leader < 0.05'"],
655
+ ),
656
+ (
657
+ {"if p > 0.1": "yes", "elif typo < 1": "x", "else": "{rhoo:.2f}"},
658
+ [
659
+ "condition 'typo < 1' names no evidence 'typo'",
660
+ "placeholder {rhoo} names no evidence",
661
+ ],
662
+ ),
663
+ ]
664
+ ck_info = {
665
+ "pipeline": {
666
+ "stages": {
667
+ "scan": {
668
+ "kind": "python-script",
669
+ "environment": "py",
670
+ "script_path": "s.py",
671
+ "outputs": ["r.json"],
672
+ }
673
+ }
674
+ }
675
+ }
676
+ for answer, expected in cases:
677
+ checked = check_question(
678
+ 1,
679
+ {"question": "q", "answer": answer, "evidence": evidence},
680
+ ck_info,
681
+ ".",
682
+ )
683
+ messages = checked.message or ""
684
+ assert (checked.status == "error") == bool(expected), answer
685
+ for fragment in expected:
686
+ assert fragment in messages, (answer, messages)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: calkit-python
3
- Version: 0.47.5
3
+ Version: 0.47.6
4
4
  Summary: Reproducibility simplified.
5
5
  Project-URL: Homepage, https://calkit.org
6
6
  Project-URL: Issues, https://github.com/calkit/calkit/issues
@@ -31,7 +31,7 @@ calkit/overleaf.py,sha256=ycCX6G28CTd8IprCAh4o9ihtgECi4dR1hwwgfUnBZmw,53210
31
31
  calkit/pipeline.py,sha256=2y6NsgncyGXBFVcyCQxXjVajA28fAdGUOMlfFTjzBfw,105006
32
32
  calkit/procedures.py,sha256=ZZtoWWmq_H0dyuUb6M-g_y77GRsJzxLMovT_X0RtYrM,2860
33
33
  calkit/provenance.py,sha256=tnj_2yWkVk929GEKr76lacO2jCzGCzpz8_Ygr6Mds_Q,33863
34
- calkit/questions.py,sha256=ZpxPjqFP5Aik9cbPyV4MB5HnbpGDzuqBk62QVXV2dOo,37276
34
+ calkit/questions.py,sha256=7VZ2KTe9vvpKRQAJADYbXxkhjl1y46C5aPJzYTX3CMQ,45142
35
35
  calkit/releases.py,sha256=RDURVbJJ1M0UNmOvlEs1-eNof6H6fPplxeKT9BcfcLw,32277
36
36
  calkit/reproducibility.py,sha256=sNsstw1tWyLPQoZMKldRD8NCOpvU988zSMhhQBXIvGE,34354
37
37
  calkit/schema.py,sha256=grCxoUc7JEPhntow__ibS45hdSCqpdAHBwd2dmtiwEM,2704
@@ -47,7 +47,7 @@ calkit/cli/dev.py,sha256=cu4QsfDiA_KurGx4WJcgA2qgfghMoZutE8kl4IZbUcM,1070
47
47
  calkit/cli/hub.py,sha256=ZBOlL7-mV57q_ynr4hPuhV7NlkKEkINSRksXD-jjiOg,2972
48
48
  calkit/cli/import_.py,sha256=QOZEfKUmqMntle47PRZhN8cs7gfWCBte_smPZqksl54,25563
49
49
  calkit/cli/latex.py,sha256=XtahLrNaYdqtqt_mMoP_wfHXIEpdecGlSW6jPR_yulA,51907
50
- calkit/cli/list.py,sha256=KoTZNR8r_4ewvWjh4ATGbmcrt8HrflDJXFoOf2VhtY8,20463
50
+ calkit/cli/list.py,sha256=DCWIX9LpTU6KpE5KKcT_DDjoRz5xPxQgzeixMsp36tM,20620
51
51
  calkit/cli/new.py,sha256=VUgPE5iOpflnuJxUNcy-GzAR-IIs9KRDwwkQtblWfoQ,158690
52
52
  calkit/cli/notebooks.py,sha256=aR4I0qatOtkrAxEG5PVLej20SBLKo8qr-lxDSzgljUQ,38836
53
53
  calkit/cli/office.py,sha256=jVSTvbl91TCzlglST5O2b8hdApZA_QHw_UiEQFJqxdc,1754
@@ -64,7 +64,7 @@ calkit/dvc/zip.py,sha256=nf4EzOyc5nI_OA3M6S7wwZpL7N4EQz9yCf8FbnPjlxM,21857
64
64
  calkit/jupyterlab/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
65
65
  calkit/jupyterlab/routes.py,sha256=sARrFjWnHVV-LunrgqO4DCt_x7XLhVPAqA1eaUFf7HQ,51443
66
66
  calkit/models/__init__.py,sha256=vGYF4MZBGzDk1BPBzpp1bRb59RCJ2Bj2kzHuVVRacug,120
67
- calkit/models/core.py,sha256=U3C3UzCj19je5K0G_Kt2NnwsopZ1zDplE_nNqLAvGvw,77280
67
+ calkit/models/core.py,sha256=3Z0o6_vLOQ-CKix6iFFmz2FsAr6w1nSv93Xmi1INcjw,77660
68
68
  calkit/models/docx.py,sha256=rgR1Y5s_yEomOeC6YlhHvsMDC9TmZp3rPN1wEt_93ds,2539
69
69
  calkit/models/io.py,sha256=0nup-gDz3FbiD8AT5pTpxji6XN_rW5AsijErsIFdpbg,2330
70
70
  calkit/models/iteration.py,sha256=_QajYhWVXV9ge9naX8ZkT1PBNzfc75cpl2BR2f8VPOo,2945
@@ -124,7 +124,7 @@ calkit/tests/test_overleaf.py,sha256=Itc4K3mYInHi36ZWhqH7drCHLCDbH1SXk3z_fB0-W8o
124
124
  calkit/tests/test_pipeline.py,sha256=27BfaE9oCwj9BfDdLQWr-Yco_mpJ1mvULTgajbfLOEI,135627
125
125
  calkit/tests/test_procedures.py,sha256=NJUIDy-gkou1BBABeWjazm__-8ZxHwvzzjkLm6kbviI,11359
126
126
  calkit/tests/test_provenance.py,sha256=bkL6a7E2Q-JINS8oLS5hs5x-2IzRQkzKJ8HWxSeiemo,23818
127
- calkit/tests/test_questions.py,sha256=CNdl6T0OjQRZFfOnVzjnsI1O5h7OPVwlaxI1bUZ2QWY,20563
127
+ calkit/tests/test_questions.py,sha256=XSKwPfJpOY8HmhpY0uwf33rZU2gbJnCZz57f35m6GL8,26790
128
128
  calkit/tests/test_releases.py,sha256=tiQvrAYMxhg0mHzhcRGi0avAu_rJGhPdhK_lCg2Sb9k,19224
129
129
  calkit/tests/test_reproducibility.py,sha256=QIkUugP_1zqUDlgYZsEnkO144FqIQpamt6v7aDPabnE,10273
130
130
  calkit/tests/test_resources.py,sha256=Wm2hr_Vy-Dk8rSElYbIzlZnCbomJInpTvUMOxT-mg04,6746
@@ -132,7 +132,7 @@ calkit/tests/test_schema.py,sha256=3h7R23bjqQntfuygrKzGwQfWPBZxX9fdBhnu6AmBqp8,8
132
132
  calkit/tests/test_templates.py,sha256=yWln6iro64_vqCgJElhSFOthA7xrHXE1GXDJJ9JEBMQ,2613
133
133
  calkit/tests/test_workspace.py,sha256=l1txfs2Oa71vbC9dEHrlcSCXinUeV19SwMZlWqdSpro,60163
134
134
  calkit/tests/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
135
- calkit/tests/cli/test_check.py,sha256=Pv7s-ccmjz6tFf8wwwlZiutyJbfsDc8KbPtwUYQNsgk,51642
135
+ calkit/tests/cli/test_check.py,sha256=gcix6EcPCQdPNx_VcdnYNU3So5PE4oIqGDguz80fJnk,52303
136
136
  calkit/tests/cli/test_config.py,sha256=AAz-cwCgYM_XTLZdv58XAax1GmvAWgDfksEpn7iiezs,5393
137
137
  calkit/tests/cli/test_core.py,sha256=eMEzdL7YKp6Ss5ey4HVtzvjWzqk9KtVJegFjCfhGDu8,1640
138
138
  calkit/tests/cli/test_delete.py,sha256=qIbgwKc8nBjmkynzyVuVAjrQB96XSmGRet_jfxs19_0,750
@@ -163,26 +163,26 @@ calkit/tests/models/test_core.py,sha256=iDi0mvgZDBj2n7bo-cyKd8dvRZHl5ywWbcab4BPs
163
163
  calkit/tests/models/test_iteration.py,sha256=vjuDKwyYQAV4w1qF9VJWQEHksq3KRd6gtT39_a-RhnI,644
164
164
  calkit/tests/models/test_pipeline.py,sha256=6oNoJV7ookVKpeQcgs0sOSVaO99PvX_xbb-cToTc3ZQ,33446
165
165
  calkit/agent_skills/add-pipeline-stage/SKILL.md,sha256=kpYYb-rJsS4B9p1Ub3gL21irCG5ZZ4DvWZpdJeEwPZk,3772
166
- calkit/agent_skills/build-paper-pipeline/SKILL.md,sha256=gAvaTeFj58ZaMj1Qt7dd17KOJfg9bUfpkjxHoeFZjxs,6628
167
- calkit/agent_skills/check-questions/SKILL.md,sha256=WlQWWuS-TgsoDU504WdLR6UW3GrcjYNdvqELfX2rdf4,7633
166
+ calkit/agent_skills/build-paper-pipeline/SKILL.md,sha256=aV9t86FxQNkeXyswM03fb5LW3ns4pcsWQyYNZ33niLI,6829
167
+ calkit/agent_skills/check-questions/SKILL.md,sha256=6-NVWX2O_soAL0vg38NsBgA_h3roUT3pHwKQ5cwDxQw,9576
168
168
  calkit/agent_skills/check-reproducibility/SKILL.md,sha256=5SGmtnbq8bSJW0Zwav_aVzfuhilHwBkkdxGuZvPHxGU,4699
169
169
  calkit/agent_skills/conventions/SKILL.md,sha256=kK0Sa54uIORAslE3U53sXJUG-TMdM-Ie72hCMIzzbxQ,8909
170
170
  calkit/agent_skills/create-pipeline/SKILL.md,sha256=2FGw1iDQVRk5FUq79GP3lPdgvgof2Wi1O9O-abGGtgs,5422
171
- calkit_python-0.47.5.data/data/etc/jupyter/jupyter_server_config.d/calkit.json,sha256=CWrZP--JDGz8fsvbAlKr_duTL1vDriGT48SL1YCtkY0,81
172
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/package.json,sha256=0hnHqhCly59Wg7urFGahc2mSupCWwiPfwKWCYfnbB2E,6202
173
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig,sha256=egkbkHmJYzDARH3_4qpoF75_Svpj_dfPaIwRU-PNjW4,6064
174
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json,sha256=YSpIrwpwB-lQBk9Mwv-npedWTOOZreO6nlWZhqlWyGI,840
175
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js,sha256=0PxdmgdZvLeh6RBcfgN1pcriNO8oCeTK5WJvUIEA3MY,112384
176
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png,sha256=svHD7-cMtTnRITFwugwsVaB9nZ-h8A61ose8z32HiRE,24850
177
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js,sha256=d0rHq7cqAjj1GNAMLOpl8dyY8yyKUQcLPCOUr-wJGdQ,8129
178
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js,sha256=4E0sK8x-ldVkL-1sM07wgJGS1vtVD9Uly7VtoFTi23c,221
179
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js,sha256=RNTIItG9k-A_Ip54fRR3QMCjiffkY0bo8zQ2Xh7TyOY,57096
180
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js,sha256=Cmp0KWM5YDZ-zcsJWWgqCMIF6adeHQH80rxz1sas45E,51183
181
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js,sha256=4JavPLcn703qVSC2E1_MD1thrJNL4N-eITjnfNhSDSM,15066
182
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/static/style.js,sha256=LTlA3M58hwjqmhomdcrNRO9GM58zZcf0V76WX9HI6EQ,146
183
- calkit_python-0.47.5.data/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json,sha256=2BGdItLJwO3fAopLl4eJvp26TEwuscYC0-yFaF-LaLU,13683
184
- calkit_python-0.47.5.dist-info/METADATA,sha256=lscQ40SyBEjeYn_HTruIytM3ImK_zYKsdyF_rQWL5eQ,16997
185
- calkit_python-0.47.5.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
186
- calkit_python-0.47.5.dist-info/entry_points.txt,sha256=TtpHpcv2vFVAVYxFVp0XrwCV_z7FWIOguEiypXRDoOw,131
187
- calkit_python-0.47.5.dist-info/licenses/LICENSE,sha256=9ZamCaSUTZk9rcrnf-sWFKLOHr3ws-S_dgKMegW4nw8,1056
188
- calkit_python-0.47.5.dist-info/RECORD,,
171
+ calkit_python-0.47.6.data/data/etc/jupyter/jupyter_server_config.d/calkit.json,sha256=CWrZP--JDGz8fsvbAlKr_duTL1vDriGT48SL1YCtkY0,81
172
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/package.json,sha256=0hnHqhCly59Wg7urFGahc2mSupCWwiPfwKWCYfnbB2E,6202
173
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig,sha256=egkbkHmJYzDARH3_4qpoF75_Svpj_dfPaIwRU-PNjW4,6064
174
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json,sha256=YSpIrwpwB-lQBk9Mwv-npedWTOOZreO6nlWZhqlWyGI,840
175
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js,sha256=0PxdmgdZvLeh6RBcfgN1pcriNO8oCeTK5WJvUIEA3MY,112384
176
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png,sha256=svHD7-cMtTnRITFwugwsVaB9nZ-h8A61ose8z32HiRE,24850
177
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js,sha256=d0rHq7cqAjj1GNAMLOpl8dyY8yyKUQcLPCOUr-wJGdQ,8129
178
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js,sha256=4E0sK8x-ldVkL-1sM07wgJGS1vtVD9Uly7VtoFTi23c,221
179
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js,sha256=RNTIItG9k-A_Ip54fRR3QMCjiffkY0bo8zQ2Xh7TyOY,57096
180
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js,sha256=Cmp0KWM5YDZ-zcsJWWgqCMIF6adeHQH80rxz1sas45E,51183
181
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js,sha256=4JavPLcn703qVSC2E1_MD1thrJNL4N-eITjnfNhSDSM,15066
182
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/static/style.js,sha256=LTlA3M58hwjqmhomdcrNRO9GM58zZcf0V76WX9HI6EQ,146
183
+ calkit_python-0.47.6.data/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json,sha256=2BGdItLJwO3fAopLl4eJvp26TEwuscYC0-yFaF-LaLU,13683
184
+ calkit_python-0.47.6.dist-info/METADATA,sha256=OeP79x--jBv_sZbbCf6uUoALyK6Dr6821tDz2vgR9sA,16997
185
+ calkit_python-0.47.6.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
186
+ calkit_python-0.47.6.dist-info/entry_points.txt,sha256=TtpHpcv2vFVAVYxFVp0XrwCV_z7FWIOguEiypXRDoOw,131
187
+ calkit_python-0.47.6.dist-info/licenses/LICENSE,sha256=9ZamCaSUTZk9rcrnf-sWFKLOHr3ws-S_dgKMegW4nw8,1056
188
+ calkit_python-0.47.6.dist-info/RECORD,,