calkit-python 0.47.11__py3-none-any.whl → 0.48.0__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 (39) hide show
  1. calkit/__init__.py +2 -0
  2. calkit/cli/check.py +4 -0
  3. calkit/cli/dev.py +23 -0
  4. calkit/cli/main/core.py +43 -41
  5. calkit/config.py +3 -0
  6. calkit/core.py +59 -3
  7. calkit/docker.py +29 -0
  8. calkit/dvc/core.py +113 -0
  9. calkit/latex.py +1 -1
  10. calkit/pipeline.py +89 -68
  11. calkit/questions.py +71 -7
  12. calkit/resources/devcontainer/devcontainer.json +1 -1
  13. calkit/resources/vscode/settings.json +1 -1
  14. calkit/tests/cli/main/test_core.py +3 -1
  15. calkit/tests/cli/main/test_subprojects.py +9 -0
  16. calkit/tests/dvc/test_core.py +60 -0
  17. calkit/tests/test_core.py +13 -0
  18. calkit/tests/test_docker.py +24 -0
  19. calkit/tests/test_questions.py +21 -0
  20. calkit/tests/test_upgrade.py +226 -0
  21. calkit/upgrade.py +493 -0
  22. {calkit_python-0.47.11.dist-info → calkit_python-0.48.0.dist-info}/METADATA +28 -1
  23. {calkit_python-0.47.11.dist-info → calkit_python-0.48.0.dist-info}/RECORD +39 -37
  24. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  25. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  26. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  27. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  28. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  29. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  30. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  31. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  32. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  33. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  34. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  35. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  36. {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  37. {calkit_python-0.47.11.dist-info → calkit_python-0.48.0.dist-info}/WHEEL +0 -0
  38. {calkit_python-0.47.11.dist-info → calkit_python-0.48.0.dist-info}/entry_points.txt +0 -0
  39. {calkit_python-0.47.11.dist-info → calkit_python-0.48.0.dist-info}/licenses/LICENSE +0 -0
calkit/__init__.py CHANGED
@@ -48,6 +48,7 @@ if TYPE_CHECKING:
48
48
  schema,
49
49
  server,
50
50
  templates,
51
+ upgrade,
51
52
  workspace,
52
53
  )
53
54
 
@@ -94,6 +95,7 @@ _SUBMODULES = {
94
95
  "schema",
95
96
  "server",
96
97
  "templates",
98
+ "upgrade",
97
99
  "workspace",
98
100
  }
99
101
 
calkit/cli/check.py CHANGED
@@ -1458,12 +1458,16 @@ def check_docker_env(
1458
1458
  # Leaving the tag would fake a registry digest on the
1459
1459
  # image, making every later push look unnecessary
1460
1460
  ck_docker.untag_image(remote_ref)
1461
+ hint = ck_docker.registry_login_hint(
1462
+ remote_ref, push_output
1463
+ )
1461
1464
  warn(
1462
1465
  f"Failed to push image to {remote_ref}; its lock "
1463
1466
  "file will record no digest, so anyone else who "
1464
1467
  "uses this project will rebuild this image rather "
1465
1468
  "than pull it\n"
1466
1469
  + textwrap.indent(push_output.strip()[-500:], " ")
1470
+ + (f"\n{hint}" if hint else "")
1467
1471
  )
1468
1472
  identity = dict(identity, RepoDigests=remote_digests)
1469
1473
  elif fpath is None:
calkit/cli/dev.py CHANGED
@@ -45,3 +45,26 @@ def run_ipython(
45
45
  from IPython import start_ipython
46
46
 
47
47
  start_ipython(argv=sys.argv[3:], user_ns={"calkit": calkit})
48
+
49
+
50
+ @dev_app.command(name="upgrade")
51
+ def upgrade(
52
+ branch: Annotated[
53
+ str | None,
54
+ typer.Argument(
55
+ help=(
56
+ "Branch to install from: the worktree that has it checked "
57
+ "out, or else the install's own, if it has no changes. "
58
+ "Defaults to the current branch."
59
+ )
60
+ ),
61
+ ] = None,
62
+ ) -> None:
63
+ """Update a dev (editable) install: pull the branch and reinstall."""
64
+ from calkit.cli.core import raise_error
65
+ from calkit.upgrade import get_dev_upgrade_cmds, run_upgrade_cmds
66
+
67
+ try:
68
+ run_upgrade_cmds(get_dev_upgrade_cmds(branch))
69
+ except Exception as e:
70
+ raise_error(str(e))
calkit/cli/main/core.py CHANGED
@@ -125,6 +125,7 @@ def _to_shell_cmd(cmd: list[str]) -> str:
125
125
 
126
126
  @app.callback()
127
127
  def main(
128
+ ctx: typer.Context,
128
129
  version: Annotated[
129
130
  bool,
130
131
  typer.Option("--version", help="Show version and exit."),
@@ -169,6 +170,7 @@ def main(
169
170
  # didn't, so a CALKIT_HUB in .env sent the two to different hubs.
170
171
  dotenv.load_dotenv(dotenv_path=".env")
171
172
  _warn_on_stale_calkit_env()
173
+ calkit.upgrade.check(ctx.invoked_subcommand)
172
174
 
173
175
 
174
176
  def _warn_on_stale_calkit_env() -> None:
@@ -510,8 +512,18 @@ def get_status(
510
512
  import git
511
513
  from git.exc import InvalidGitRepositoryError
512
514
 
515
+ def get_dvc_data_status() -> dict:
516
+ # Reuse what the pipeline status computed under the same DVC lock
517
+ if (
518
+ pipeline_status is not None
519
+ and pipeline_status.dvc_data_status is not None
520
+ ):
521
+ return dict(pipeline_status.dvc_data_status)
522
+ return dict(calkit.dvc.get_dvc_repo().data_status())
523
+
513
524
  dotenv.load_dotenv(dotenv_path=".env")
514
- ck_info = calkit.load_calkit_info()
525
+ # Status only reads calkit.yaml, so skip the slower round-trip parser
526
+ ck_info = calkit.load_calkit_info(read_only=True)
515
527
  # Status is usually the first command someone runs on a project that
516
528
  # isn't theirs, which is exactly when the variables it declares are
517
529
  # missing. Ask while there's somebody to answer -- the environment
@@ -576,6 +588,7 @@ def get_status(
576
588
  check_environments=not no_check_envs,
577
589
  clean_notebooks=True,
578
590
  compile_to_dvc=True,
591
+ with_data_status="dvc" in categories,
579
592
  )
580
593
  if pipeline_status.failed_environment_checks:
581
594
  warn(
@@ -608,10 +621,13 @@ def get_status(
608
621
  frozen_stages = frozen_tainted_stage_names(ck_info=ck_info)
609
622
  except Exception:
610
623
  frozen_stages = set()
624
+ # The summary doesn't say what changed since an answer was edited,
625
+ # which is the only part that needs history, so skip reading it
611
626
  questions_status = check_questions(
612
627
  ck_info=ck_info,
613
628
  stale_stages=stale_stages,
614
629
  frozen_stages=frozen_stages,
630
+ check_history=False,
615
631
  )
616
632
  if as_json:
617
633
  status_dict: dict[str, Any] = {}
@@ -671,8 +687,7 @@ def get_status(
671
687
  status_dict["git"] = {"error": "Not a Git repository"}
672
688
  if "dvc" in categories:
673
689
  try:
674
- dvc_repo = calkit.dvc.get_dvc_repo()
675
- data_status = dict(dvc_repo.data_status())
690
+ data_status = get_dvc_data_status()
676
691
  data_status.pop("git", None)
677
692
  status_dict["dvc"] = calkit.dvc.data_status_as_posix(
678
693
  data_status
@@ -741,8 +756,7 @@ def get_status(
741
756
  )
742
757
  else:
743
758
  zip_path_map = calkit.dvc.zip.get_zip_path_map()
744
- dvc_repo = calkit.dvc.get_dvc_repo()
745
- raw = dict(dvc_repo.data_status())
759
+ raw = get_dvc_data_status()
746
760
  raw.pop("git", None)
747
761
  raw = calkit.dvc.data_status_as_posix(raw)
748
762
  typer.echo(_format_dvc_data_status(raw, zip_path_map))
@@ -1780,9 +1794,13 @@ def push(
1780
1794
  if not success:
1781
1795
  # Leaving the tag would fake a registry digest on the image
1782
1796
  calkit.docker.untag_image(remote_ref)
1797
+ hint = calkit.docker.registry_login_hint(
1798
+ remote_ref, push_output
1799
+ )
1783
1800
  warn(
1784
1801
  f"Failed to push image to {remote_ref}\n"
1785
1802
  + textwrap.indent(push_output.strip()[-500:], " ")
1803
+ + (f"\n{hint}" if hint else "")
1786
1804
  )
1787
1805
  if "git" in selected:
1788
1806
  typer.echo("Pushing to Git remote")
@@ -4323,42 +4341,26 @@ def upgrade(
4323
4341
  bool, typer.Option("--skills", help="Upgrade agent skills as well.")
4324
4342
  ] = False,
4325
4343
  ) -> None:
4326
- """Upgrade Calkit."""
4327
- # First detect how Calkit is installed
4328
- # If installed with uv tool, calkit will be located at something like
4329
- # ~/.local/bin/calkit
4330
- which_calkit = shutil.which("calkit")
4331
- if which_calkit is None:
4332
- raise_error("Calkit is not installed")
4333
- split_path = os.path.normpath(str(which_calkit)).split(os.sep)
4334
- if (
4335
- ".local" in split_path
4336
- and "bin" in split_path
4337
- and calkit.check_dep_exists("uv")
4338
- ):
4339
- # This is a uv tool install
4340
- cmd = [
4341
- "uv",
4342
- "tool",
4343
- "install",
4344
- "--upgrade",
4345
- "calkit-python",
4346
- ]
4347
- elif "pipx" in split_path and calkit.check_dep_exists("pipx"):
4348
- cmd = ["pipx", "upgrade", "calkit-python"]
4349
- else:
4350
- cmd = [
4351
- sys.executable,
4352
- "-m",
4353
- "pip",
4354
- "install",
4355
- "--upgrade",
4356
- "calkit-python",
4357
- ]
4358
- res = subprocess.run(cmd)
4359
- if res.returncode != 0:
4360
- raise_error("Upgrade failed")
4361
- typer.echo("Success!")
4344
+ """Upgrade Calkit.
4345
+
4346
+ A dev (editable) install is updated from the branch it's on, as with
4347
+ 'calkit dev upgrade'.
4348
+ """
4349
+ from calkit.upgrade import (
4350
+ get_dev_upgrade_cmds,
4351
+ get_editable_path,
4352
+ get_upgrade_cmd,
4353
+ run_upgrade_cmds,
4354
+ )
4355
+
4356
+ try:
4357
+ if get_editable_path() is not None:
4358
+ cmds = get_dev_upgrade_cmds()
4359
+ else:
4360
+ cmds = [get_upgrade_cmd()]
4361
+ run_upgrade_cmds(cmds)
4362
+ except Exception as e:
4363
+ raise_error(str(e))
4362
4364
  if skills:
4363
4365
  from calkit.cli.update import update_agent_skills
4364
4366
 
calkit/config.py CHANGED
@@ -439,6 +439,9 @@ class Settings(BaseSettings):
439
439
  # 'calkit describe system' reports the ID actually in effect.
440
440
  machine_id: str | None = None
441
441
  dataframe_engine: Literal["pandas", "polars"] = "pandas"
442
+ # Upgrade Calkit when a daily check finds a new version; when false,
443
+ # the check only notifies, and 'no-check' turns the check off
444
+ auto_upgrade: bool | Literal["no-check"] = True
442
445
  run_history_length: int = 10
443
446
  github_token: KeyringOptionalSecret | None = None
444
447
  # Kept apart from github_token, since pushing to the GitHub Container
calkit/core.py CHANGED
@@ -125,9 +125,65 @@ try:
125
125
  # PyYAML ships prebuilt wheels with libyaml on every platform we target,
126
126
  # so this is the normal path; the fallback keeps a source install or an
127
127
  # exotic build working, just slower.
128
- from yaml import CSafeLoader as _YamlLoader
128
+ from yaml import CSafeLoader as _BaseYamlLoader
129
129
  except ImportError: # pragma: no cover - depends on the libyaml build
130
- from yaml import SafeLoader as _YamlLoader
130
+ from yaml import SafeLoader as _BaseYamlLoader
131
+
132
+
133
+ class _YamlLoader(_BaseYamlLoader): # type: ignore[no-any-unimported]
134
+ """The C loader, reading scalars as YAML 1.2 like ``ryaml`` does.
135
+
136
+ PyYAML follows YAML 1.1, where ``no`` and ``on`` are booleans, ``1:30``
137
+ is 90, and ``010`` is octal, so the same file would load differently
138
+ depending on which parser read it.
139
+ """
140
+
141
+
142
+ def _construct_yaml12_int(loader: Any, node: Any) -> int:
143
+ value = str(loader.construct_scalar(node)).replace("_", "")
144
+ sign = -1 if value.startswith("-") else 1
145
+ value = value.lstrip("+-")
146
+ for prefix, base in (("0b", 2), ("0o", 8), ("0x", 16)):
147
+ if value.startswith(prefix):
148
+ return sign * int(value[2:], base)
149
+ return sign * int(value)
150
+
151
+
152
+ # ruamel.yaml's YAML 1.2 resolvers for the scalars that differ from 1.1
153
+ _YAML12_RESOLVERS = {
154
+ "tag:yaml.org,2002:bool": (
155
+ r"^(?:true|True|TRUE|false|False|FALSE)$",
156
+ "tTfF",
157
+ ),
158
+ "tag:yaml.org,2002:float": (
159
+ r"""^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+]?[0-9]+)?
160
+ |[-+]?(?:[0-9][0-9_]*)(?:[eE][-+]?[0-9]+)
161
+ |[-+]?\.[0-9_]+(?:[eE][-+][0-9]+)?
162
+ |[-+]?\.(?:inf|Inf|INF)
163
+ |\.(?:nan|NaN|NAN))$""",
164
+ "-+0123456789.",
165
+ ),
166
+ "tag:yaml.org,2002:int": (
167
+ r"""^(?:[-+]?0b[0-1_]+
168
+ |[-+]?0o?[0-7_]+
169
+ |[-+]?[0-9_]+
170
+ |[-+]?0x[0-9a-fA-F_]+)$""",
171
+ "-+0123456789",
172
+ ),
173
+ }
174
+ _YamlLoader.yaml_implicit_resolvers = {
175
+ first: [
176
+ (tag, regexp)
177
+ for tag, regexp in resolvers
178
+ if tag not in _YAML12_RESOLVERS
179
+ ]
180
+ for first, resolvers in _BaseYamlLoader.yaml_implicit_resolvers.items()
181
+ }
182
+ for _tag, (_pattern, _first) in _YAML12_RESOLVERS.items():
183
+ _YamlLoader.add_implicit_resolver(
184
+ _tag, re.compile(_pattern, re.X), list(_first)
185
+ )
186
+ _YamlLoader.add_constructor("tag:yaml.org,2002:int", _construct_yaml12_int)
131
187
 
132
188
 
133
189
  def _load_yaml_readonly(stream: Any) -> Any:
@@ -1157,7 +1213,7 @@ def detect_project_name(
1157
1213
  If ``prepend_owner`` is False, fall back to the working directory name
1158
1214
  if there is no Git remote or name specified in ``calkit.yaml``.
1159
1215
  """
1160
- ck_info = load_calkit_info(wdir=wdir)
1216
+ ck_info = load_calkit_info(wdir=wdir, read_only=True)
1161
1217
  name = ck_info.get("name")
1162
1218
  if name is not None and not prepend_owner:
1163
1219
  return name
calkit/docker.py CHANGED
@@ -1144,6 +1144,35 @@ def is_auth_error(output: str) -> bool:
1144
1144
  return any(marker in lowered for marker in _AUTH_ERROR_MARKERS)
1145
1145
 
1146
1146
 
1147
+ def registry_login_hint(ref: str, output: str) -> str:
1148
+ """Return how to log in to a registry that refused a push, or ''.
1149
+
1150
+ Calkit walks the user through creating a token itself when it can
1151
+ prompt, so this is for when it can't: a push from a pipeline, a script
1152
+ or an agent would otherwise end on the registry's bare "unauthorized",
1153
+ which says nothing about what to do next.
1154
+ """
1155
+ if not is_auth_error(output):
1156
+ return ""
1157
+ host = ref.split("/", 1)[0]
1158
+ if host != "ghcr.io":
1159
+ return (
1160
+ f"{host} refused the push; log Docker in to it with "
1161
+ f"'docker login {host}' and push again"
1162
+ )
1163
+ return (
1164
+ "The GitHub Container Registry refused the push: no credential "
1165
+ "Calkit has can write packages. To fix this, either:\n"
1166
+ " - run 'calkit push docker' in a terminal, which walks you "
1167
+ "through creating a token and remembers it; or\n"
1168
+ " - create a token with the 'write:packages' scope at\n"
1169
+ f" {GITHUB_PACKAGES_TOKEN_URL}\n"
1170
+ " log Docker in with it, pasting the token as the password:\n"
1171
+ f" docker login ghcr.io -u {get_github_username()}\n"
1172
+ " and push again with 'calkit push docker'"
1173
+ )
1174
+
1175
+
1147
1176
  def get_github_token_scopes(token: str) -> set[str]:
1148
1177
  """Return the OAuth scopes a GitHub token carries.
1149
1178
 
calkit/dvc/core.py CHANGED
@@ -8,6 +8,8 @@ import json
8
8
  import logging
9
9
  import os
10
10
  import sys
11
+ import threading
12
+ from collections.abc import Iterator
11
13
  from itertools import groupby
12
14
  from pathlib import Path
13
15
  from typing import Any, Literal
@@ -209,6 +211,117 @@ def dvc_lock_timeout(seconds: float):
209
211
  dvc.lock.DEFAULT_TIMEOUT = previous
210
212
 
211
213
 
214
+ _memoized_hashes_lock = threading.RLock()
215
+
216
+
217
+ @contextlib.contextmanager
218
+ def memoized_hashes() -> Iterator[None]:
219
+ """Hash each output or dependency path only once while active.
220
+
221
+ DVC hashes a directory again for every stage that depends on it, walking
222
+ every file each time, so a dataset that feeds twenty stages is walked
223
+ twenty times by one ``status``. Its ``data_status`` then walks the outputs
224
+ once more, for nothing but their directory hashes unless asked for a
225
+ granular status. Nothing on disk changes during a status check, so the
226
+ first answer stands for the rest.
227
+ """
228
+ import importlib
229
+
230
+ import dvc.repo.data
231
+ import dvc.repo.index
232
+ from dvc.output import Output
233
+
234
+ # By name, since dvc_data.index exports a build function that shadows it
235
+ index_build: Any = importlib.import_module("dvc_data.index.build")
236
+
237
+ # The patches are process-wide, so threads take turns rather than
238
+ # capturing and restoring each other's
239
+ with _memoized_hashes_lock:
240
+ original_get_hash_meta = Output._get_hash_meta
241
+ original_diff = dvc.repo.data._diff_index_to_wtree
242
+ original_build_index = dvc.repo.index.build_data_index
243
+ original_build_entries = index_build.build_entries
244
+ memo: dict[tuple, Any] = {}
245
+ # Local directories already hashed, as (meta, hash_info) by repo,
246
+ # path, and hash name, and whether the walk in progress only needs
247
+ # those
248
+ dirs: dict[tuple[int | None, str, str], Any] = {}
249
+ shallow = False
250
+ walking: int | None = None
251
+ skipped: dict[str, Any] = {}
252
+
253
+ def _get_hash_meta(self: Any) -> Any:
254
+ key = (
255
+ id(self.repo),
256
+ self.fs.protocol,
257
+ self.fs_path,
258
+ self.hash_name,
259
+ self.use_cache,
260
+ )
261
+ if key not in memo:
262
+ memo[key] = original_get_hash_meta(self)
263
+ meta, hash_info = memo[key]
264
+ if (
265
+ self.fs.protocol == "local"
266
+ and hash_info
267
+ and hash_info.isdir
268
+ ):
269
+ dirs[(id(self.repo), self.fs_path, self.hash_name)] = memo[
270
+ key
271
+ ]
272
+ return memo[key]
273
+
274
+ def _diff_index_to_wtree(*args: Any, **kwargs: Any) -> Any:
275
+ nonlocal shallow
276
+ shallow = not kwargs.get("granular", False)
277
+ try:
278
+ return original_diff(*args, **kwargs)
279
+ finally:
280
+ shallow = False
281
+
282
+ def build_entries(
283
+ path: str, fs: Any, *args: Any, **kwargs: Any
284
+ ) -> Any:
285
+ key = (walking, path, kwargs.get("hash_name", "md5"))
286
+ if key in dirs:
287
+ skipped[path] = dirs[key]
288
+ return iter(())
289
+ return original_build_entries(path, fs, *args, **kwargs)
290
+
291
+ def build_data_index(
292
+ index: Any, path: str, fs: Any, *args: Any, **kwargs: Any
293
+ ) -> Any:
294
+ nonlocal walking
295
+ if not (shallow and kwargs.get("compute_hash")):
296
+ return original_build_index(index, path, fs, *args, **kwargs)
297
+ walking = id(index.repo)
298
+ skipped.clear()
299
+ try:
300
+ data = original_build_index(index, path, fs, *args, **kwargs)
301
+ finally:
302
+ walking = None
303
+ # Outputs whose walk was skipped get the hashes already computed
304
+ # rather than those of their now-empty listing
305
+ for dir_path, (meta, hash_info) in skipped.items():
306
+ entry = data.get(fs.relparts(dir_path, path))
307
+ if entry is not None:
308
+ entry.meta = meta
309
+ entry.hash_info = hash_info
310
+ return data
311
+
312
+ Output._get_hash_meta = _get_hash_meta
313
+ dvc.repo.data._diff_index_to_wtree = _diff_index_to_wtree
314
+ dvc.repo.index.build_data_index = build_data_index
315
+ index_build.build_entries = build_entries
316
+ try:
317
+ yield
318
+ finally:
319
+ Output._get_hash_meta = original_get_hash_meta
320
+ dvc.repo.data._diff_index_to_wtree = original_diff
321
+ dvc.repo.index.build_data_index = original_build_index
322
+ index_build.build_entries = original_build_entries
323
+
324
+
212
325
  class CalkitDVCFileSystem(ObjectFileSystem):
213
326
  """DVC-facing filesystem wrapper for the ``ck://`` scheme."""
214
327
 
calkit/latex.py CHANGED
@@ -55,7 +55,7 @@ WORKING_NAME = "working"
55
55
  # of a full TeX Live image. Pinned to an exact tag rather than :latest so
56
56
  # a document keeps building against the same TeX until this is moved
57
57
  # deliberately; what it carries is recorded in images/latex/README.md.
58
- DEFAULT_LATEX_IMAGE = "ghcr.io/calkit/latex:0.1.4"
58
+ DEFAULT_LATEX_IMAGE = "ghcr.io/calkit/latex:0.1.5"
59
59
  # The environment created for a document that doesn't have one, wherever
60
60
  # that happens: a new publication, an Overleaf import, or a stage whose
61
61
  # environment is worked out from what it runs. Copied where it's used,