calkit-python 0.47.4__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 (41) 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 +57 -15
  4. calkit/cli/main/core.py +2 -1
  5. calkit/cli/new.py +20 -11
  6. calkit/config.py +50 -10
  7. calkit/core.py +15 -1
  8. calkit/environments.py +3 -1
  9. calkit/install.py +267 -29
  10. calkit/models/core.py +9 -1
  11. calkit/models/pipeline.py +8 -1
  12. calkit/questions.py +230 -18
  13. calkit/resources/devcontainer/base.json +1 -1
  14. calkit/resources/devcontainer/devcontainer.json +1 -1
  15. calkit/templates/core.py +129 -12
  16. calkit/tests/cli/test_check.py +34 -17
  17. calkit/tests/cli/test_list.py +28 -2
  18. calkit/tests/cli/test_new.py +31 -0
  19. calkit/tests/test_config.py +28 -0
  20. calkit/tests/test_core.py +25 -0
  21. calkit/tests/test_install.py +124 -0
  22. calkit/tests/test_questions.py +155 -1
  23. calkit/tests/test_templates.py +50 -0
  24. {calkit_python-0.47.4.dist-info → calkit_python-0.47.6.dist-info}/METADATA +132 -31
  25. {calkit_python-0.47.4.dist-info → calkit_python-0.47.6.dist-info}/RECORD +41 -41
  26. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  27. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  28. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  29. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  30. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  31. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  32. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  33. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  34. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  35. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  36. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  37. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  38. {calkit_python-0.47.4.data → calkit_python-0.47.6.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  39. {calkit_python-0.47.4.dist-info → calkit_python-0.47.6.dist-info}/WHEEL +0 -0
  40. {calkit_python-0.47.4.dist-info → calkit_python-0.47.6.dist-info}/entry_points.txt +0 -0
  41. {calkit_python-0.47.4.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,
@@ -333,21 +335,44 @@ def list_environments(
333
335
 
334
336
  @list_app.command(name="templates")
335
337
  def list_templates(
338
+ kind: Annotated[
339
+ str | None,
340
+ typer.Option("--kind", "-k", help="Only show templates of one kind."),
341
+ ] = None,
336
342
  json_output: Annotated[
337
343
  bool, typer.Option("--json", help="Output result as JSON.")
338
344
  ] = False,
339
345
  ):
340
- """List all available Calkit templates."""
341
- names = [
342
- f"{kind}/{name}"
343
- for kind, tpl_dict in calkit.templates.TEMPLATES.items()
344
- for name in tpl_dict
345
- ]
346
+ """List all available Calkit templates, grouped by kind.
347
+
348
+ A template is named by its kind and name, except a project template,
349
+ which names a project on a hub and so is ``owner/project``.
350
+ """
351
+ try:
352
+ templates = calkit.templates.get_templates(kind=kind)
353
+ except ValueError as e:
354
+ raise_error(str(e))
355
+ groups: dict[str, list[dict]] = {}
356
+ for template in templates:
357
+ groups.setdefault(template.kind, []).append(
358
+ {
359
+ "name": template.ref,
360
+ "title": template.title,
361
+ "description": template.description,
362
+ }
363
+ )
346
364
  if json_output:
347
- echo_json(names)
365
+ echo_json(groups)
348
366
  return
349
- for name in names:
350
- typer.echo(name)
367
+ for i, (group, entries) in enumerate(groups.items()):
368
+ if i:
369
+ typer.echo()
370
+ typer.echo(f"{group}:")
371
+ for entry in entries:
372
+ typer.echo(f" {entry['name']}")
373
+ for key in ("title", "description"):
374
+ if entry[key]:
375
+ typer.echo(f" {entry[key]}")
351
376
 
352
377
 
353
378
  @list_app.command(name="installers")
@@ -368,17 +393,30 @@ def list_installers(
368
393
  groups: dict[int, list[str]] = {}
369
394
  for name, entry in calkit.install.INSTALLERS.items():
370
395
  groups.setdefault(id(entry), []).append(name)
396
+ # What Calkit has already installed here, so the listing doubles as a
397
+ # record of changes it made to this machine
398
+ installed = {
399
+ rec["app"]: rec["installed_at"]
400
+ for rec in calkit.install.read_install_log()
401
+ }
371
402
  result: list[dict] = []
372
403
  for names in groups.values():
373
404
  names.sort()
374
405
  entry = calkit.install.INSTALLERS[names[0]]
375
406
  scripts = {}
376
- for platform in ("unix", "windows"):
407
+ for platform in ("unix", "mac", "linux", "windows"):
377
408
  ins = entry.get(platform) # type: ignore[call-overload]
378
409
  if ins is not None:
379
410
  scripts[platform] = ins["script"]
380
411
  result.append(
381
- {"name": names[0], "aliases": names[1:], "scripts": scripts}
412
+ {
413
+ "name": names[0],
414
+ "aliases": names[1:],
415
+ "scripts": scripts,
416
+ "installed_by_calkit": next(
417
+ (installed[n] for n in names if n in installed), None
418
+ ),
419
+ }
382
420
  )
383
421
  if json_output:
384
422
  echo_json(result)
@@ -388,6 +426,10 @@ def list_installers(
388
426
  header = installer["name"] + (
389
427
  f" (aliases: {aliases})" if aliases else ""
390
428
  )
429
+ if installer["installed_by_calkit"]:
430
+ header += (
431
+ f" [installed by Calkit {installer['installed_by_calkit']}]"
432
+ )
391
433
  typer.echo(header)
392
434
  for platform, script in installer["scripts"].items():
393
435
  typer.echo(f" {platform}: {script}")
calkit/cli/main/core.py CHANGED
@@ -2936,10 +2936,11 @@ def run(
2936
2936
  in_main_thread = threading.current_thread() is threading.main_thread()
2937
2937
  old_handler = None
2938
2938
  handler_set = False
2939
- with open(log_fpath, "a", encoding="utf-8") as log_f:
2939
+ with open(log_fpath, "a", encoding="utf-8", errors="replace") as log_f:
2940
2940
  log_f.write(STAGE_OUTPUT_START + "\n")
2941
2941
  log_f.flush()
2942
2942
  try:
2943
+ kwargs.setdefault("errors", "replace")
2943
2944
  p = subprocess.Popen(exec_cmd, **kwargs)
2944
2945
  if in_main_thread:
2945
2946
  old_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
calkit/cli/new.py CHANGED
@@ -434,17 +434,26 @@ def new_project(
434
434
  )
435
435
  if template_git_url is None:
436
436
  project = "/".join(template_name.split("/")[:2])
437
- typer.echo(f"Fetching Git repo URL for {project} from the hub")
438
- try:
439
- template_git_url = calkit.hub.get(f"/projects/{project}")[
440
- "git_repo_url"
441
- ]
442
- except Exception as e:
443
- raise_error(
444
- f"Could not fetch project {project} from the hub ({e}); "
445
- "for a repo not on the hub, pass its URL, e.g., "
446
- f"https://github.com/{template_name}"
447
- )
437
+ # A template this package knows about carries its own repo URL,
438
+ # so the common case needs no hub: no request, no login, and it
439
+ # still works offline once the repo is reachable.
440
+ known = calkit.templates.find_project_template(project)
441
+ if known is not None:
442
+ if verbose:
443
+ typer.echo(f"Using known template {project}")
444
+ template_git_url = known.git_repo_url
445
+ else:
446
+ typer.echo(f"Fetching Git repo URL for {project} from the hub")
447
+ try:
448
+ template_git_url = calkit.hub.get(f"/projects/{project}")[
449
+ "git_repo_url"
450
+ ]
451
+ except Exception as e:
452
+ raise_error(
453
+ f"Could not fetch project {project} from the hub "
454
+ f"({e}); for a repo not on the hub, pass its URL, "
455
+ f"e.g., https://github.com/{template_name}"
456
+ )
448
457
  if template_subdir is None:
449
458
  # Now clone it
450
459
  subprocess.run(["git", "clone", template_git_url, abs_path])
calkit/config.py CHANGED
@@ -5,6 +5,8 @@ from __future__ import annotations
5
5
  import os
6
6
  import platform
7
7
  import warnings
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
8
10
  from typing import Any, Literal
9
11
  from typing import get_args as get_type_args
10
12
 
@@ -26,7 +28,8 @@ def _probe_keyring() -> bool:
26
28
  """
27
29
  try:
28
30
  # Attempt to get a password (this will trigger backend initialization)
29
- keyring.get_password("test_service", "test_user")
31
+ with _keyring_home():
32
+ keyring.get_password("test_service", "test_user")
30
33
  return True
31
34
  except keyring.errors.NoKeyringError:
32
35
  return False
@@ -142,6 +145,40 @@ def _get_project_hub() -> str | None:
142
145
  return hub
143
146
 
144
147
 
148
+ # Where set_env_vars keeps the user's own home directory when a project's
149
+ # env_vars change it, e.g., to /tmp for a tool a stage runs, so Calkit still
150
+ # finds its config, credentials, and caches
151
+ USER_HOME_ENV_VAR = "CALKIT_USER_HOME"
152
+
153
+
154
+ def get_user_home() -> str:
155
+ """The home directory Calkit keeps its own files in."""
156
+ return os.environ.get(USER_HOME_ENV_VAR) or os.path.expanduser("~")
157
+
158
+
159
+ @contextmanager
160
+ def _keyring_home() -> Iterator[None]:
161
+ """Point HOME at the user's own home for a keyring call.
162
+
163
+ The macOS keychain finds the login keychain through HOME when it's
164
+ called, so a project that points HOME elsewhere would otherwise hide
165
+ every stored secret, including hub credentials.
166
+ """
167
+ user_home = os.environ.get(USER_HOME_ENV_VAR)
168
+ old_home = os.environ.get("HOME")
169
+ if user_home is None or user_home == old_home:
170
+ yield
171
+ return
172
+ os.environ["HOME"] = user_home
173
+ try:
174
+ yield
175
+ finally:
176
+ if old_home is None:
177
+ os.environ.pop("HOME", None)
178
+ else:
179
+ os.environ["HOME"] = old_home
180
+
181
+
145
182
  def _get_default_hub() -> str | None:
146
183
  """Read ``default_hub`` from the base (unsuffixed) config file.
147
184
 
@@ -150,7 +187,7 @@ def _get_default_hub() -> str | None:
150
187
  """
151
188
  import yaml
152
189
 
153
- fpath = os.path.join(os.path.expanduser("~"), ".calkit", "config.yaml")
190
+ fpath = os.path.join(get_user_home(), ".calkit", "config.yaml")
154
191
  try:
155
192
  with open(fpath) as f:
156
193
  data = yaml.safe_load(f) or {}
@@ -275,7 +312,7 @@ def get_local_config_path() -> str:
275
312
 
276
313
  def get_config_yaml_fpath() -> str:
277
314
  return os.path.join(
278
- os.path.expanduser("~"),
315
+ get_user_home(),
279
316
  ".calkit",
280
317
  f"config{get_env_suffix()}.yaml",
281
318
  )
@@ -291,11 +328,12 @@ def set_secret(key: str, value: str) -> None:
291
328
  """Sets a secret using keyring, handling byte conversion for Linux."""
292
329
  service_name = get_app_name()
293
330
  username = _keyring_username(key)
294
- if platform.system() == "Linux":
295
- value_bytes = value.encode("utf-8")
296
- keyring.set_password(service_name, username, value_bytes) # type: ignore
297
- else:
298
- keyring.set_password(service_name, username, value)
331
+ with _keyring_home():
332
+ if platform.system() == "Linux":
333
+ value_bytes = value.encode("utf-8")
334
+ keyring.set_password(service_name, username, value_bytes) # type: ignore
335
+ else:
336
+ keyring.set_password(service_name, username, value)
299
337
  _secret_cache[(service_name, username)] = value
300
338
 
301
339
 
@@ -306,7 +344,8 @@ def get_secret(key: str) -> str | None:
306
344
  cache_key = (service_name, username)
307
345
  if cache_key in _secret_cache:
308
346
  return _secret_cache[cache_key]
309
- password = keyring.get_password(service_name, username)
347
+ with _keyring_home():
348
+ password = keyring.get_password(service_name, username)
310
349
  if platform.system() == "Linux" and isinstance(password, bytes):
311
350
  password = password.decode("utf-8")
312
351
  _secret_cache[cache_key] = password
@@ -318,7 +357,8 @@ def delete_secret(key: str) -> None:
318
357
  service_name = get_app_name()
319
358
  username = _keyring_username(key)
320
359
  _secret_cache.pop((service_name, username), None)
321
- keyring.delete_password(service_name, username)
360
+ with _keyring_home():
361
+ keyring.delete_password(service_name, username)
322
362
 
323
363
 
324
364
  class KeyringOptionalSecret(str):
calkit/core.py CHANGED
@@ -945,7 +945,13 @@ def check_requirements(
945
945
  described_as=described_as,
946
946
  )
947
947
  continue
948
- raise ValueError(f"app '{dep_name}' not found on {described_as}")
948
+ # Some apps have no installer here but a known way in, e.g., a
949
+ # system package manager on Linux
950
+ hint = _install.get_unsupported_message(dep_name)
951
+ raise ValueError(
952
+ f"app '{dep_name}' not found on {described_as}"
953
+ + (f"; {hint}" if hint else "")
954
+ )
949
955
  for dep in buckets.get("_other", []):
950
956
  dep_name = dep["name"]
951
957
  dep_kind = dep["kind"]
@@ -1449,5 +1455,13 @@ def set_env_vars(ck_info: dict, cli: bool = True) -> None:
1449
1455
  raise_error(msg)
1450
1456
  else:
1451
1457
  raise ValueError(msg)
1458
+ # A project that points the home directory elsewhere, e.g., at /tmp for
1459
+ # a tool a stage runs, would otherwise hide the user's Calkit config,
1460
+ # and with it their credentials, from every Calkit command it runs
1461
+ import calkit.config
1462
+
1463
+ home = os.path.expanduser("~")
1452
1464
  for k, v in env_vars.items():
1453
1465
  os.environ[str(k)] = str(v)
1466
+ if os.path.expanduser("~") != home:
1467
+ os.environ.setdefault(calkit.config.USER_HOME_ENV_VAR, home)
calkit/environments.py CHANGED
@@ -930,8 +930,10 @@ def write_system_env_lock(
930
930
 
931
931
 
932
932
  def get_cache_db(name="cache") -> SqliteDict:
933
+ from calkit.config import get_user_home
934
+
933
935
  env_check_cache_dir = os.path.join(
934
- os.path.expanduser("~"), ".calkit", "env-checks"
936
+ get_user_home(), ".calkit", "env-checks"
935
937
  )
936
938
  os.makedirs(env_check_cache_dir, exist_ok=True)
937
939
  env_check_cache_path = os.path.join(env_check_cache_dir, f"{name}.sqlite")