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.
- calkit/__init__.py +2 -0
- calkit/cli/check.py +4 -0
- calkit/cli/dev.py +23 -0
- calkit/cli/main/core.py +43 -41
- calkit/config.py +3 -0
- calkit/core.py +59 -3
- calkit/docker.py +29 -0
- calkit/dvc/core.py +113 -0
- calkit/latex.py +1 -1
- calkit/pipeline.py +89 -68
- calkit/questions.py +71 -7
- calkit/resources/devcontainer/devcontainer.json +1 -1
- calkit/resources/vscode/settings.json +1 -1
- calkit/tests/cli/main/test_core.py +3 -1
- calkit/tests/cli/main/test_subprojects.py +9 -0
- calkit/tests/dvc/test_core.py +60 -0
- calkit/tests/test_core.py +13 -0
- calkit/tests/test_docker.py +24 -0
- calkit/tests/test_questions.py +21 -0
- calkit/tests/test_upgrade.py +226 -0
- calkit/upgrade.py +493 -0
- {calkit_python-0.47.11.dist-info → calkit_python-0.48.0.dist-info}/METADATA +28 -1
- {calkit_python-0.47.11.dist-info → calkit_python-0.48.0.dist-info}/RECORD +39 -37
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.47.11.data → calkit_python-0.48.0.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.47.11.dist-info → calkit_python-0.48.0.dist-info}/WHEEL +0 -0
- {calkit_python-0.47.11.dist-info → calkit_python-0.48.0.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.47.11.dist-info → calkit_python-0.48.0.dist-info}/licenses/LICENSE +0 -0
calkit/__init__.py
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
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
|
|
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
|
|
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.
|
|
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,
|