calkit-python 0.48.2__py3-none-any.whl → 0.48.3__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/calc.py +15 -9
- calkit/cli/check.py +92 -2
- calkit/cli/describe.py +4 -1
- calkit/cli/latex.py +25 -3
- calkit/cli/main/core.py +59 -17
- calkit/cli/update.py +233 -0
- calkit/docx.py +7 -1
- calkit/dvc/core.py +210 -0
- calkit/environments.py +6 -0
- calkit/git.py +79 -6
- calkit/models/base.py +13 -0
- calkit/models/core.py +44 -44
- calkit/models/io.py +8 -6
- calkit/models/iteration.py +6 -4
- calkit/models/pipeline.py +11 -10
- calkit/pipeline.py +49 -3
- calkit/questions.py +61 -34
- calkit/tests/cli/main/test_core.py +97 -1
- calkit/tests/cli/test_check.py +85 -0
- calkit/tests/cli/test_latex.py +3 -1
- calkit/tests/cli/test_update.py +170 -0
- calkit/tests/dvc/test_core.py +93 -0
- calkit/tests/test_docx.py +69 -6
- calkit/tests/test_environments.py +10 -0
- calkit/tests/test_pipeline.py +10 -0
- calkit/tests/test_questions.py +60 -0
- {calkit_python-0.48.2.dist-info → calkit_python-0.48.3.dist-info}/METADATA +2 -1
- {calkit_python-0.48.2.dist-info → calkit_python-0.48.3.dist-info}/RECORD +44 -43
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.48.2.dist-info → calkit_python-0.48.3.dist-info}/WHEEL +0 -0
- {calkit_python-0.48.2.dist-info → calkit_python-0.48.3.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.48.2.dist-info → calkit_python-0.48.3.dist-info}/licenses/LICENSE +0 -0
calkit/calc.py
CHANGED
|
@@ -4,14 +4,20 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Annotated, Literal
|
|
6
6
|
|
|
7
|
-
from pydantic import BaseModel, Discriminator, model_validator
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Discriminator, model_validator
|
|
8
8
|
|
|
9
9
|
DTYPES = {"int": int, "float": float, "str": str}
|
|
10
10
|
DEFAULT_IN_TYPE = "float"
|
|
11
11
|
DEFAULT_OUT_TYPE = "float"
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
class
|
|
14
|
+
class _Model(BaseModel):
|
|
15
|
+
# As calkit.models.base.Model, which importing here would import
|
|
16
|
+
# calkit.models, which imports this
|
|
17
|
+
model_config = ConfigDict(defer_build=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Input(_Model):
|
|
15
21
|
name: str
|
|
16
22
|
description: str | None = None
|
|
17
23
|
dtype: Literal["int", "float", "str"] = DEFAULT_IN_TYPE
|
|
@@ -19,14 +25,14 @@ class Input(BaseModel):
|
|
|
19
25
|
max: int | float | None = None
|
|
20
26
|
|
|
21
27
|
|
|
22
|
-
class Output(
|
|
28
|
+
class Output(_Model):
|
|
23
29
|
name: str
|
|
24
30
|
description: str | None = None
|
|
25
31
|
dtype: Literal["int", "float", "str"] = DEFAULT_OUT_TYPE
|
|
26
32
|
template: str | None = None
|
|
27
33
|
|
|
28
34
|
|
|
29
|
-
class Calculation(
|
|
35
|
+
class Calculation(_Model):
|
|
30
36
|
kind: str
|
|
31
37
|
params: dict = {}
|
|
32
38
|
name: str | None = None
|
|
@@ -124,7 +130,7 @@ class Calculation(BaseModel):
|
|
|
124
130
|
return template.format(**(inputs | {out_name: res}))
|
|
125
131
|
|
|
126
132
|
|
|
127
|
-
class FormulaParams(
|
|
133
|
+
class FormulaParams(_Model):
|
|
128
134
|
formula: str
|
|
129
135
|
|
|
130
136
|
|
|
@@ -139,7 +145,7 @@ class Formula(Calculation):
|
|
|
139
145
|
return arithmetic_eval.evaluate(self.params.formula, inputs)
|
|
140
146
|
|
|
141
147
|
|
|
142
|
-
class LinearParams(
|
|
148
|
+
class LinearParams(_Model):
|
|
143
149
|
coeffs: dict[str, float]
|
|
144
150
|
offset: float = 0.0
|
|
145
151
|
|
|
@@ -163,7 +169,7 @@ class Linear(Calculation):
|
|
|
163
169
|
return val
|
|
164
170
|
|
|
165
171
|
|
|
166
|
-
class LookupTableParams(
|
|
172
|
+
class LookupTableParams(_Model):
|
|
167
173
|
x_values: list[float]
|
|
168
174
|
y_values: list[float]
|
|
169
175
|
method: Literal["floor", "ceil", "round", "interpolate"] = "interpolate"
|
|
@@ -176,7 +182,7 @@ class LookupTable(Calculation):
|
|
|
176
182
|
params: LookupTableParams
|
|
177
183
|
|
|
178
184
|
|
|
179
|
-
class HttpRequestParams(
|
|
185
|
+
class HttpRequestParams(_Model):
|
|
180
186
|
url: str
|
|
181
187
|
inputs_as_params: bool = True # Otherwise, use body
|
|
182
188
|
method: Literal["get", "post", "put"] = "get"
|
|
@@ -210,7 +216,7 @@ class HttpRequest(Calculation):
|
|
|
210
216
|
return resp.text
|
|
211
217
|
|
|
212
218
|
|
|
213
|
-
class XGBoostModelParams(
|
|
219
|
+
class XGBoostModelParams(_Model):
|
|
214
220
|
path: str
|
|
215
221
|
type: Literal["classifier", "regressor"]
|
|
216
222
|
|
calkit/cli/check.py
CHANGED
|
@@ -2026,6 +2026,95 @@ def check_pipeline(
|
|
|
2026
2026
|
)
|
|
2027
2027
|
|
|
2028
2028
|
|
|
2029
|
+
@check_app.command(name="storage")
|
|
2030
|
+
def check_storage(
|
|
2031
|
+
fix: Annotated[
|
|
2032
|
+
bool,
|
|
2033
|
+
typer.Option(
|
|
2034
|
+
"--fix", help="Move each path to where it's declared to be stored."
|
|
2035
|
+
),
|
|
2036
|
+
] = False,
|
|
2037
|
+
as_json: Annotated[
|
|
2038
|
+
bool, typer.Option("--json", help="Output the problems as JSON.")
|
|
2039
|
+
] = False,
|
|
2040
|
+
) -> None:
|
|
2041
|
+
"""Check that paths are tracked where they're declared to be stored.
|
|
2042
|
+
|
|
2043
|
+
That is, nothing is tracked by both Git and DVC, no pipeline output
|
|
2044
|
+
stored in Git is ignored by it, and nothing in Git is too large for it.
|
|
2045
|
+
With --fix, the pipeline's declarations, or a file's .dvc file, decide
|
|
2046
|
+
where each path belongs, and the changes are staged.
|
|
2047
|
+
"""
|
|
2048
|
+
import calkit.dvc
|
|
2049
|
+
import calkit.git
|
|
2050
|
+
import calkit.pipeline
|
|
2051
|
+
|
|
2052
|
+
if fix and as_json:
|
|
2053
|
+
raise_error("--json can't be used with --fix")
|
|
2054
|
+
problems = calkit.dvc.get_storage_problems()
|
|
2055
|
+
if as_json:
|
|
2056
|
+
typer.echo(json.dumps(problems, indent=2))
|
|
2057
|
+
if not fix:
|
|
2058
|
+
if not as_json:
|
|
2059
|
+
for problem in problems:
|
|
2060
|
+
warn(problem["message"])
|
|
2061
|
+
if any(p["kind"] != "large" for p in problems):
|
|
2062
|
+
if not as_json:
|
|
2063
|
+
typer.echo("Run 'calkit check storage --fix' to fix these")
|
|
2064
|
+
raise typer.Exit(1)
|
|
2065
|
+
if not problems and not as_json:
|
|
2066
|
+
calkit.echo("✅ Every path is stored where it's declared to be")
|
|
2067
|
+
return
|
|
2068
|
+
repo = calkit.git.get_repo()
|
|
2069
|
+
gitignores = calkit.git.get_dirty_gitignores(repo)
|
|
2070
|
+
fixed = []
|
|
2071
|
+
unfixed = []
|
|
2072
|
+
# Pipeline outputs are recorded in DVC, which records their stages'
|
|
2073
|
+
# inputs too, so only for stages that are up to date
|
|
2074
|
+
to_record = []
|
|
2075
|
+
for problem in problems:
|
|
2076
|
+
path = os.path.abspath(problem["path"])
|
|
2077
|
+
if problem["store_in"] == "dvc":
|
|
2078
|
+
repo.git.rm("-r", "-q", "--cached", "--", path)
|
|
2079
|
+
calkit.git.ensure_path_is_ignored(repo, path)
|
|
2080
|
+
if problem["kind"] == "both":
|
|
2081
|
+
to_record.append(problem)
|
|
2082
|
+
elif problem["store_in"] == "git":
|
|
2083
|
+
calkit.git.ensure_path_is_not_ignored(repo, path)
|
|
2084
|
+
repo.git.add("--", path)
|
|
2085
|
+
else:
|
|
2086
|
+
unfixed.append(problem)
|
|
2087
|
+
continue
|
|
2088
|
+
fixed.append(problem)
|
|
2089
|
+
typer.echo(f"Fixed: {problem['message']}")
|
|
2090
|
+
# None when it can't be told which are stale, so none are recorded
|
|
2091
|
+
stale: set[str] | None = set()
|
|
2092
|
+
if any(p["stage"] for p in to_record):
|
|
2093
|
+
status = calkit.pipeline.get_status(
|
|
2094
|
+
check_environments=False, clean_notebooks=False
|
|
2095
|
+
)
|
|
2096
|
+
if not status.errors:
|
|
2097
|
+
stale = {n.split("@")[0] for n in status.stale_stage_names}
|
|
2098
|
+
else:
|
|
2099
|
+
stale = None
|
|
2100
|
+
for problem in to_record:
|
|
2101
|
+
stage = problem["stage"]
|
|
2102
|
+
if stage and (stale is None or stage.split("@")[0] in stale):
|
|
2103
|
+
typer.echo(f"Run '{stage}' to record {problem['path']} in DVC")
|
|
2104
|
+
continue
|
|
2105
|
+
target = problem["dvc_file"] or stage
|
|
2106
|
+
if calkit.dvc.run_dvc_command(["commit", "-f", "-q", target]) != 0:
|
|
2107
|
+
warn(f"Couldn't record {problem['path']} in DVC")
|
|
2108
|
+
for message in calkit.git.stage_gitignores(repo, gitignores):
|
|
2109
|
+
warn(message)
|
|
2110
|
+
for problem in unfixed:
|
|
2111
|
+
warn(problem["message"])
|
|
2112
|
+
if fixed:
|
|
2113
|
+
typer.echo("Commit the staged changes")
|
|
2114
|
+
if any(p["kind"] != "large" for p in unfixed):
|
|
2115
|
+
raise typer.Exit(1)
|
|
2116
|
+
|
|
2117
|
+
|
|
2029
2118
|
@check_app.command(name="call")
|
|
2030
2119
|
def check_call(
|
|
2031
2120
|
cmd: Annotated[str, typer.Argument(help="Command to check.")],
|
|
@@ -2082,8 +2171,9 @@ def check_questions(
|
|
|
2082
2171
|
pushed, or pinned to a Git ref that doesn't exist); broken references
|
|
2083
2172
|
(a key that doesn't resolve, a placeholder that names no evidence, a
|
|
2084
2173
|
label missing from the LaTeX); evidence the pipeline would rebuild;
|
|
2085
|
-
and evidence from a frozen stage, or downstream of one,
|
|
2086
|
-
|
|
2174
|
+
and evidence from a frozen stage, or downstream of one, that no longer
|
|
2175
|
+
matches dvc.lock, which won't be rerun, unless the citation pins a
|
|
2176
|
+
git_ref. Subprojects' questions are checked too.
|
|
2087
2177
|
|
|
2088
2178
|
Evidence pinned with a git_ref is checked at that ref rather than in
|
|
2089
2179
|
the working tree. Exits with an error if any answered question is
|
calkit/cli/describe.py
CHANGED
|
@@ -9,7 +9,6 @@ import typer
|
|
|
9
9
|
|
|
10
10
|
import calkit
|
|
11
11
|
from calkit.cli import AliasGroup, echo_json, raise_error
|
|
12
|
-
from calkit.environments import get_env_lock_fpath
|
|
13
12
|
|
|
14
13
|
describe_app = typer.Typer(cls=AliasGroup, no_args_is_help=True)
|
|
15
14
|
|
|
@@ -20,6 +19,10 @@ def _describe_env(env: dict, env_name: str) -> dict:
|
|
|
20
19
|
The keys here are a stable contract for tools consuming ``--json``, so
|
|
21
20
|
they are always present, even if their value is null.
|
|
22
21
|
"""
|
|
22
|
+
# Here rather than at the top, since every command imports this module;
|
|
23
|
+
# what that saves is a question in decisions/calkit.yaml
|
|
24
|
+
from calkit.environments import get_env_lock_fpath
|
|
25
|
+
|
|
23
26
|
return {
|
|
24
27
|
"kind": env.get("kind"),
|
|
25
28
|
"spec_path": env.get("path"),
|
calkit/cli/latex.py
CHANGED
|
@@ -328,8 +328,10 @@ def from_markdown(
|
|
|
328
328
|
if "://" not in hub:
|
|
329
329
|
hub = "https://" + hub
|
|
330
330
|
project_url = f"{hub}/{ck_info['owner']}/{ck_info['name']}"
|
|
331
|
-
# A local version's commit is the project's own, which isn't Calkit's
|
|
332
|
-
|
|
331
|
+
# A local version's commit is the project's own, which isn't Calkit's,
|
|
332
|
+
# and a development build's count of commits since the last release
|
|
333
|
+
# changes with every commit, which would change the PDF on every rebuild
|
|
334
|
+
version = re.sub(r"\.dev\d+$", ".dev", calkit.__version__.split("+")[0])
|
|
333
335
|
with open(md_path, encoding="utf-8") as f:
|
|
334
336
|
text = f.read()
|
|
335
337
|
try:
|
|
@@ -2008,6 +2010,11 @@ def to_docx(
|
|
|
2008
2010
|
return out
|
|
2009
2011
|
|
|
2010
2012
|
existing = listing() - {job + ".bbl"}
|
|
2013
|
+
# Dated like the PDF, so \today matches it and a rebuild of an
|
|
2014
|
+
# unchanged source doesn't change
|
|
2015
|
+
tex_env_vars = _tex_env_vars(
|
|
2016
|
+
calkit.latex.get_source_date_epoch(source)
|
|
2017
|
+
)
|
|
2011
2018
|
# TeX can loop forever, e.g., older TeX4ht on a cases environment,
|
|
2012
2019
|
# so a run gets a time limit, and loses its LaTeX child with it
|
|
2013
2020
|
import time
|
|
@@ -2021,6 +2028,7 @@ def to_docx(
|
|
|
2021
2028
|
proc = subprocess.Popen(
|
|
2022
2029
|
m4h,
|
|
2023
2030
|
cwd=src_dir,
|
|
2031
|
+
env=os.environ | tex_env_vars,
|
|
2024
2032
|
stdout=subprocess.PIPE,
|
|
2025
2033
|
stderr=subprocess.STDOUT,
|
|
2026
2034
|
start_new_session=sys.platform != "win32",
|
|
@@ -2053,6 +2061,7 @@ def to_docx(
|
|
|
2053
2061
|
no_check=True,
|
|
2054
2062
|
verbose=False,
|
|
2055
2063
|
dep="make4ht",
|
|
2064
|
+
env_vars=tex_env_vars,
|
|
2056
2065
|
),
|
|
2057
2066
|
stdout=subprocess.PIPE,
|
|
2058
2067
|
stderr=subprocess.STDOUT,
|
|
@@ -2571,7 +2580,20 @@ def to_docx(
|
|
|
2571
2580
|
dirty = any(repo.is_dirty(path=p) for p in {ln.path for ln in lines})
|
|
2572
2581
|
except Exception:
|
|
2573
2582
|
pass
|
|
2574
|
-
|
|
2583
|
+
# At random, so each export can be told apart, unless the build is asked
|
|
2584
|
+
# to be reproducible, in which case it comes from what was exported
|
|
2585
|
+
if os.environ.get("SOURCE_DATE_EPOCH"):
|
|
2586
|
+
doc.save()
|
|
2587
|
+
export_id = str(
|
|
2588
|
+
uuid.uuid5(
|
|
2589
|
+
uuid.NAMESPACE_URL,
|
|
2590
|
+
json.dumps(
|
|
2591
|
+
[calkit.get_md5(output), rev, source, original, equations]
|
|
2592
|
+
),
|
|
2593
|
+
)
|
|
2594
|
+
)
|
|
2595
|
+
else:
|
|
2596
|
+
export_id = str(uuid.uuid4())
|
|
2575
2597
|
doc.write_original(
|
|
2576
2598
|
calkit.docx.Original(
|
|
2577
2599
|
export_id,
|
calkit/cli/main/core.py
CHANGED
|
@@ -589,6 +589,7 @@ def get_status(
|
|
|
589
589
|
clean_notebooks=True,
|
|
590
590
|
compile_to_dvc=True,
|
|
591
591
|
with_data_status="dvc" in categories,
|
|
592
|
+
with_storage_problems="dvc" in categories,
|
|
592
593
|
)
|
|
593
594
|
if pipeline_status.failed_environment_checks:
|
|
594
595
|
warn(
|
|
@@ -629,6 +630,17 @@ def get_status(
|
|
|
629
630
|
frozen_stages=frozen_stages,
|
|
630
631
|
check_history=False,
|
|
631
632
|
)
|
|
633
|
+
# Paths tracked where they shouldn't be, found with the pipeline's status,
|
|
634
|
+
# which builds the DVC index this needs anyway, unless it stopped short
|
|
635
|
+
storage_problems = None
|
|
636
|
+
if "dvc" in categories and running_status is None:
|
|
637
|
+
if pipeline_status is not None:
|
|
638
|
+
storage_problems = pipeline_status.storage_problems
|
|
639
|
+
if storage_problems is None:
|
|
640
|
+
try:
|
|
641
|
+
storage_problems = calkit.dvc.get_storage_problems()
|
|
642
|
+
except Exception:
|
|
643
|
+
pass
|
|
632
644
|
if as_json:
|
|
633
645
|
status_dict: dict[str, Any] = {}
|
|
634
646
|
if "project" in categories:
|
|
@@ -694,6 +706,7 @@ def get_status(
|
|
|
694
706
|
)
|
|
695
707
|
except Exception as e:
|
|
696
708
|
status_dict["dvc"] = {"error": f"{e.__class__.__name__}: {e}"}
|
|
709
|
+
status_dict["storage"] = storage_problems
|
|
697
710
|
if "pipeline" in categories or "dvc" in categories:
|
|
698
711
|
if running_status is not None:
|
|
699
712
|
status_dict["pipeline"] = running_status
|
|
@@ -760,6 +773,10 @@ def get_status(
|
|
|
760
773
|
raw.pop("git", None)
|
|
761
774
|
raw = calkit.dvc.data_status_as_posix(raw)
|
|
762
775
|
typer.echo(_format_dvc_data_status(raw, zip_path_map))
|
|
776
|
+
for problem in storage_problems or []:
|
|
777
|
+
warn(problem["message"])
|
|
778
|
+
if storage_problems:
|
|
779
|
+
typer.echo("Run 'calkit check storage' for detail.\n")
|
|
763
780
|
if "pipeline" in categories or "dvc" in categories:
|
|
764
781
|
print_sep("Pipeline")
|
|
765
782
|
if running_status is not None:
|
|
@@ -1011,6 +1028,22 @@ def add(
|
|
|
1011
1028
|
)
|
|
1012
1029
|
repo.git.add(".dvc/config")
|
|
1013
1030
|
dvc_paths = [] if dvc_repo is None else calkit.dvc.list_paths()
|
|
1031
|
+
pipeline_output_storage = calkit.pipeline.get_output_storage_map()
|
|
1032
|
+
# An ignore rule left on an output stored in Git, e.g., by an older
|
|
1033
|
+
# Calkit, would hide its new files from everything below
|
|
1034
|
+
for out in calkit.git.get_ignored_by_rule(
|
|
1035
|
+
repo,
|
|
1036
|
+
*[
|
|
1037
|
+
out
|
|
1038
|
+
for out, storage in pipeline_output_storage.items()
|
|
1039
|
+
if storage == "git" and os.path.exists(out)
|
|
1040
|
+
],
|
|
1041
|
+
):
|
|
1042
|
+
if dry_run:
|
|
1043
|
+
typer.echo(f"Would un-ignore {out} since it's stored in Git")
|
|
1044
|
+
else:
|
|
1045
|
+
typer.echo(f"Un-ignoring {out} since it's stored in Git")
|
|
1046
|
+
calkit.git.ensure_path_is_not_ignored(repo, out)
|
|
1014
1047
|
untracked_git_files = repo.untracked_files
|
|
1015
1048
|
if auto_commit_message:
|
|
1016
1049
|
# See if this path is in the repo already
|
|
@@ -1118,12 +1151,31 @@ def add(
|
|
|
1118
1151
|
]:
|
|
1119
1152
|
paths.append(changed_file)
|
|
1120
1153
|
zip_path_map = calkit.dvc.zip.get_zip_path_map()
|
|
1121
|
-
pipeline_output_storage = calkit.pipeline.get_output_storage_map()
|
|
1122
1154
|
lock_out_paths = calkit.dvc.get_lock_out_paths()
|
|
1155
|
+
cached_out_paths: set[str] | None = None
|
|
1123
1156
|
dvc_scm = None
|
|
1124
1157
|
for path in paths:
|
|
1125
1158
|
# Check if this path is already registered as a zip
|
|
1126
1159
|
posix_path = Path(path).as_posix()
|
|
1160
|
+
# A file inside an output stored in Git, e.g., one written to a
|
|
1161
|
+
# persistent output directory, goes to Git like the output does
|
|
1162
|
+
output_storage = pipeline_output_storage.get(posix_path)
|
|
1163
|
+
if output_storage is None and any(
|
|
1164
|
+
posix_path.startswith(out + "/")
|
|
1165
|
+
for out, storage in pipeline_output_storage.items()
|
|
1166
|
+
if storage == "git"
|
|
1167
|
+
):
|
|
1168
|
+
output_storage = "git"
|
|
1169
|
+
locked_out = next(
|
|
1170
|
+
(
|
|
1171
|
+
out
|
|
1172
|
+
for out in lock_out_paths
|
|
1173
|
+
if posix_path == out or posix_path.startswith(out + "/")
|
|
1174
|
+
),
|
|
1175
|
+
None,
|
|
1176
|
+
)
|
|
1177
|
+
if locked_out is not None and cached_out_paths is None:
|
|
1178
|
+
cached_out_paths = calkit.dvc.get_cached_out_paths()
|
|
1127
1179
|
if posix_path in zip_path_map:
|
|
1128
1180
|
if dry_run:
|
|
1129
1181
|
typer.echo(f"Would add {path} via DVC zip")
|
|
@@ -1155,10 +1207,9 @@ def add(
|
|
|
1155
1207
|
)
|
|
1156
1208
|
calkit.git.ensure_dvc_pointer_is_not_ignored(repo, path)
|
|
1157
1209
|
calkit.dvc.run_dvc_command(["add", path])
|
|
1158
|
-
elif
|
|
1210
|
+
elif output_storage is not None:
|
|
1159
1211
|
# Respect storage explicitly set in the pipeline definition
|
|
1160
|
-
|
|
1161
|
-
if pipeline_storage == "git":
|
|
1212
|
+
if output_storage == "git":
|
|
1162
1213
|
if dry_run:
|
|
1163
1214
|
typer.echo(
|
|
1164
1215
|
f"Would add {path} to Git "
|
|
@@ -1169,7 +1220,7 @@ def add(
|
|
|
1169
1220
|
f"Adding {path} to Git per pipeline output storage"
|
|
1170
1221
|
)
|
|
1171
1222
|
git_add(path)
|
|
1172
|
-
elif
|
|
1223
|
+
elif output_storage == "dvc-zip":
|
|
1173
1224
|
if dry_run:
|
|
1174
1225
|
typer.echo(
|
|
1175
1226
|
f"Would add {path} as a DVC zip "
|
|
@@ -1193,22 +1244,13 @@ def add(
|
|
|
1193
1244
|
f"({path} is a DVC pipeline output)"
|
|
1194
1245
|
)
|
|
1195
1246
|
git_add("dvc.lock")
|
|
1196
|
-
elif (
|
|
1197
|
-
locked_out := next(
|
|
1198
|
-
(
|
|
1199
|
-
out
|
|
1200
|
-
for out in lock_out_paths
|
|
1201
|
-
if posix_path == out
|
|
1202
|
-
or posix_path.startswith(out + "/")
|
|
1203
|
-
),
|
|
1204
|
-
None,
|
|
1205
|
-
)
|
|
1206
|
-
) is not None:
|
|
1247
|
+
elif locked_out in (cached_out_paths or set()):
|
|
1207
1248
|
# DVC already caches this, and it's only showing up at all
|
|
1208
1249
|
# because its ignore entry went missing, e.g., DVC removes
|
|
1209
1250
|
# every entry a failed repro added, including those for the
|
|
1210
1251
|
# stages that succeeded. Sized up like a new file, a small
|
|
1211
|
-
# one would go to Git alongside DVC's copy.
|
|
1252
|
+
# one would go to Git alongside DVC's copy. Outputs DVC
|
|
1253
|
+
# doesn't cache were never ignored, so they don't count.
|
|
1212
1254
|
if dry_run:
|
|
1213
1255
|
typer.echo(
|
|
1214
1256
|
f"Would ignore {path} ({path} is a DVC pipeline output)"
|
calkit/cli/update.py
CHANGED
|
@@ -1356,6 +1356,239 @@ def update_figure(
|
|
|
1356
1356
|
calkit.save_calkit_info(ck_info)
|
|
1357
1357
|
|
|
1358
1358
|
|
|
1359
|
+
@update_app.command(name="path-storage")
|
|
1360
|
+
def update_path_storage(
|
|
1361
|
+
paths: Annotated[
|
|
1362
|
+
list[str],
|
|
1363
|
+
typer.Argument(help="Files or directories to move, from the project."),
|
|
1364
|
+
],
|
|
1365
|
+
to: Annotated[
|
|
1366
|
+
str,
|
|
1367
|
+
typer.Option("--to", "-t", help="Where to store them: git or dvc."),
|
|
1368
|
+
],
|
|
1369
|
+
) -> None:
|
|
1370
|
+
"""Move paths between Git and DVC storage without rerunning anything.
|
|
1371
|
+
|
|
1372
|
+
A pipeline output's storage is changed where the pipeline declares it,
|
|
1373
|
+
e.g., an output's 'storage' or a LaTeX stage's 'pdf_storage', and a file
|
|
1374
|
+
added on its own is added again with the other system. Either way it's
|
|
1375
|
+
untracked from where it was, so it isn't left in both. The tracking
|
|
1376
|
+
changes are staged, and calkit.yaml and dvc.yaml are left to commit.
|
|
1377
|
+
"""
|
|
1378
|
+
from pathlib import Path
|
|
1379
|
+
|
|
1380
|
+
from ruamel.yaml.comments import CommentedMap
|
|
1381
|
+
|
|
1382
|
+
import calkit.dvc
|
|
1383
|
+
import calkit.dvc.zip
|
|
1384
|
+
import calkit.git
|
|
1385
|
+
import calkit.pipeline
|
|
1386
|
+
from calkit.models.pipeline import Pipeline
|
|
1387
|
+
|
|
1388
|
+
def storage_key(stage_name: str, stage: dict, path: str) -> str | None:
|
|
1389
|
+
# The key in a stage's definition that sets this path's storage, if
|
|
1390
|
+
# the stage produces it, whether declared or implied by its kind
|
|
1391
|
+
def here(stage_path: str) -> str:
|
|
1392
|
+
# A stage's paths are relative to its working directory
|
|
1393
|
+
joined = os.path.join(stage.get("wdir") or "", stage_path)
|
|
1394
|
+
return Path(os.path.normpath(joined)).as_posix()
|
|
1395
|
+
|
|
1396
|
+
for n, out in enumerate(stage.get("outputs") or []):
|
|
1397
|
+
out_path = out if isinstance(out, str) else out.get("path")
|
|
1398
|
+
if out_path is None or here(out_path) != path:
|
|
1399
|
+
continue
|
|
1400
|
+
if isinstance(out, dict) and out.get("storage") == "dvc-zip":
|
|
1401
|
+
raise_error(f"{path} is stored zipped, which can't move yet")
|
|
1402
|
+
return f"outputs.{n}"
|
|
1403
|
+
try:
|
|
1404
|
+
model = Pipeline.model_validate(
|
|
1405
|
+
{"stages": {stage_name: stage}}
|
|
1406
|
+
).stages[stage_name]
|
|
1407
|
+
except Exception:
|
|
1408
|
+
return None
|
|
1409
|
+
log = model.scheduler_log_output
|
|
1410
|
+
if log is not None and here(log.path) == path:
|
|
1411
|
+
raise_error(
|
|
1412
|
+
f"{path} is the scheduler log of '{stage_name}', which can't "
|
|
1413
|
+
"move yet; set its log_storage in calkit.yaml"
|
|
1414
|
+
)
|
|
1415
|
+
implied = {
|
|
1416
|
+
"pdf_path": "pdf_storage",
|
|
1417
|
+
"cleaned_notebook_path": "cleaned_ipynb_storage",
|
|
1418
|
+
"executed_notebook_path": "executed_ipynb_storage",
|
|
1419
|
+
"html_path": "html_storage",
|
|
1420
|
+
"output_dir": "output_storage",
|
|
1421
|
+
}
|
|
1422
|
+
for attr, key in implied.items():
|
|
1423
|
+
value = getattr(model, attr, None)
|
|
1424
|
+
if (
|
|
1425
|
+
key in type(model).model_fields
|
|
1426
|
+
and isinstance(value, str)
|
|
1427
|
+
and here(value) == path
|
|
1428
|
+
):
|
|
1429
|
+
return key
|
|
1430
|
+
return None
|
|
1431
|
+
|
|
1432
|
+
def set_storage(stage_name: str, stage: dict, key: str) -> None:
|
|
1433
|
+
# Back to the default rather than spelled out where it can be
|
|
1434
|
+
if key.startswith("outputs."):
|
|
1435
|
+
n = int(key.split(".")[1])
|
|
1436
|
+
out = stage["outputs"][n]
|
|
1437
|
+
if isinstance(out, str):
|
|
1438
|
+
out = CommentedMap(path=out)
|
|
1439
|
+
out["storage"] = to
|
|
1440
|
+
if to == "dvc" and set(out) <= {"path", "storage"}:
|
|
1441
|
+
out = out["path"]
|
|
1442
|
+
stage["outputs"][n] = out
|
|
1443
|
+
return
|
|
1444
|
+
model = Pipeline.model_validate({"stages": {stage_name: stage}})
|
|
1445
|
+
field = type(model.stages[stage_name]).model_fields[key]
|
|
1446
|
+
if field.default == to:
|
|
1447
|
+
stage.pop(key, None)
|
|
1448
|
+
else:
|
|
1449
|
+
stage[key] = to
|
|
1450
|
+
|
|
1451
|
+
# Git runs from the repo root, which may be above the project, so it's
|
|
1452
|
+
# given absolute paths
|
|
1453
|
+
def in_git(path: str) -> bool:
|
|
1454
|
+
return bool(repo.git.ls_files("--", os.path.abspath(path)).strip())
|
|
1455
|
+
|
|
1456
|
+
def dvc(*args: str) -> None:
|
|
1457
|
+
if calkit.dvc.run_dvc_command(list(args)) != 0:
|
|
1458
|
+
raise_error(f"'dvc {' '.join(args)}' failed")
|
|
1459
|
+
|
|
1460
|
+
if to not in ["git", "dvc"]:
|
|
1461
|
+
raise_error(f"Storage must be git or dvc, not '{to}'")
|
|
1462
|
+
repo = calkit.git.get_repo()
|
|
1463
|
+
ck_info = calkit.load_calkit_info()
|
|
1464
|
+
stages = (ck_info.get("pipeline") or {}).get("stages") or {}
|
|
1465
|
+
# Each path with the stage and key that declare its storage, if any
|
|
1466
|
+
outputs: dict[str, tuple[str, str]] = {}
|
|
1467
|
+
others: list[str] = []
|
|
1468
|
+
zipped = calkit.dvc.zip.get_zip_path_map()
|
|
1469
|
+
# What DVC tracks, and the stage producing each, if any, to catch a path
|
|
1470
|
+
# inside one, or from a stage whose storage calkit.yaml doesn't declare
|
|
1471
|
+
dvc_repo = calkit.dvc.get_dvc_repo()
|
|
1472
|
+
dvc_outs = [
|
|
1473
|
+
(
|
|
1474
|
+
Path(out.fs_path).resolve(),
|
|
1475
|
+
None if out.stage.is_data_source else out.stage.addressing,
|
|
1476
|
+
)
|
|
1477
|
+
for out in dvc_repo.index.outs
|
|
1478
|
+
]
|
|
1479
|
+
dvc_repo.close()
|
|
1480
|
+
for raw in paths:
|
|
1481
|
+
path = Path(os.path.normpath(raw)).as_posix()
|
|
1482
|
+
if not os.path.exists(path):
|
|
1483
|
+
raise_error(f"{path} isn't here; run 'calkit pull' first")
|
|
1484
|
+
for stage_name, stage in stages.items():
|
|
1485
|
+
key = (
|
|
1486
|
+
storage_key(stage_name, stage, path)
|
|
1487
|
+
if isinstance(stage, dict)
|
|
1488
|
+
else None
|
|
1489
|
+
)
|
|
1490
|
+
if key is not None:
|
|
1491
|
+
if stage.get("iterate_over"):
|
|
1492
|
+
raise_error(
|
|
1493
|
+
f"{path} comes from '{stage_name}', which iterates; "
|
|
1494
|
+
"set its storage in calkit.yaml instead"
|
|
1495
|
+
)
|
|
1496
|
+
outputs[path] = (stage_name, key)
|
|
1497
|
+
break
|
|
1498
|
+
else:
|
|
1499
|
+
if path in zipped:
|
|
1500
|
+
raise_error(f"{path} is stored zipped, which can't move yet")
|
|
1501
|
+
abs_path = Path(path).resolve()
|
|
1502
|
+
for out_path, producer in dvc_outs:
|
|
1503
|
+
out_rel = Path(os.path.relpath(out_path)).as_posix()
|
|
1504
|
+
if abs_path == out_path and producer is not None:
|
|
1505
|
+
raise_error(
|
|
1506
|
+
f"{path} comes from DVC stage '{producer}', whose "
|
|
1507
|
+
"storage can't be set here"
|
|
1508
|
+
)
|
|
1509
|
+
if abs_path != out_path and (
|
|
1510
|
+
abs_path.is_relative_to(out_path)
|
|
1511
|
+
or out_path.is_relative_to(abs_path)
|
|
1512
|
+
):
|
|
1513
|
+
raise_error(
|
|
1514
|
+
f"{path} overlaps {out_rel}, which DVC tracks as a "
|
|
1515
|
+
"whole; move that instead"
|
|
1516
|
+
)
|
|
1517
|
+
others.append(path)
|
|
1518
|
+
gitignores = calkit.git.get_dirty_gitignores(repo)
|
|
1519
|
+
# Recording an output in DVC records its stage's inputs too, which would
|
|
1520
|
+
# pass off a stage that's out of date as current, so the pipeline as
|
|
1521
|
+
# calkit.yaml has it now must be known to be up to date
|
|
1522
|
+
if outputs and to == "dvc":
|
|
1523
|
+
status = calkit.pipeline.get_status(
|
|
1524
|
+
ck_info=ck_info, check_environments=False, clean_notebooks=False
|
|
1525
|
+
)
|
|
1526
|
+
if status.errors:
|
|
1527
|
+
raise_error(
|
|
1528
|
+
"Couldn't tell whether the stages producing these are up "
|
|
1529
|
+
"to date: " + "; ".join(status.errors)
|
|
1530
|
+
)
|
|
1531
|
+
stale = {name.split("@")[0] for name in status.stale_stage_names}
|
|
1532
|
+
for path, (stage_name, _) in outputs.items():
|
|
1533
|
+
if stage_name in stale:
|
|
1534
|
+
raise_error(
|
|
1535
|
+
f"Stage '{stage_name}', which produces {path}, is out "
|
|
1536
|
+
"of date; run it first"
|
|
1537
|
+
)
|
|
1538
|
+
if outputs:
|
|
1539
|
+
for path, (stage_name, key) in outputs.items():
|
|
1540
|
+
set_storage(stage_name, stages[stage_name], key)
|
|
1541
|
+
calkit.save_calkit_info(ck_info)
|
|
1542
|
+
calkit.pipeline.to_dvc(ck_info=ck_info, write=True)
|
|
1543
|
+
for path, (stage_name, _) in outputs.items():
|
|
1544
|
+
if to == "dvc":
|
|
1545
|
+
if in_git(path):
|
|
1546
|
+
repo.git.rm(
|
|
1547
|
+
"-r", "-q", "--cached", "--", os.path.abspath(path)
|
|
1548
|
+
)
|
|
1549
|
+
else:
|
|
1550
|
+
calkit.git.ensure_path_is_not_ignored(repo, os.path.abspath(path))
|
|
1551
|
+
repo.git.add("--", os.path.abspath(path))
|
|
1552
|
+
if to == "dvc":
|
|
1553
|
+
# Into the cache, and ignored by Git, without running anything
|
|
1554
|
+
for stage_name in dict.fromkeys(s for s, _ in outputs.values()):
|
|
1555
|
+
dvc("commit", "-f", "-q", stage_name)
|
|
1556
|
+
for path in others:
|
|
1557
|
+
pointer = f"{path}.dvc"
|
|
1558
|
+
if to == "dvc":
|
|
1559
|
+
# Which also repairs a file DVC tracks that Git does too
|
|
1560
|
+
if in_git(path):
|
|
1561
|
+
repo.git.rm(
|
|
1562
|
+
"-r", "-q", "--cached", "--", os.path.abspath(path)
|
|
1563
|
+
)
|
|
1564
|
+
if os.path.isfile(pointer):
|
|
1565
|
+
calkit.git.ensure_path_is_ignored(repo, os.path.abspath(path))
|
|
1566
|
+
else:
|
|
1567
|
+
dvc("add", "-q", path)
|
|
1568
|
+
repo.git.add("--", os.path.abspath(pointer))
|
|
1569
|
+
else:
|
|
1570
|
+
if os.path.isfile(pointer):
|
|
1571
|
+
tracked = in_git(pointer)
|
|
1572
|
+
dvc("remove", "-q", pointer)
|
|
1573
|
+
if tracked:
|
|
1574
|
+
repo.git.add("-A", "--", os.path.abspath(pointer))
|
|
1575
|
+
calkit.git.ensure_path_is_not_ignored(repo, os.path.abspath(path))
|
|
1576
|
+
repo.git.add("--", os.path.abspath(path))
|
|
1577
|
+
for message in calkit.git.stage_gitignores(repo, gitignores):
|
|
1578
|
+
warn(message)
|
|
1579
|
+
system = "Git" if to == "git" else "DVC"
|
|
1580
|
+
for path in [*outputs, *others]:
|
|
1581
|
+
typer.echo(f"{path} is now stored in {system}")
|
|
1582
|
+
if outputs:
|
|
1583
|
+
typer.echo(
|
|
1584
|
+
"Commit calkit.yaml and dvc.yaml along with the staged changes"
|
|
1585
|
+
)
|
|
1586
|
+
if to == "dvc":
|
|
1587
|
+
typer.echo(
|
|
1588
|
+
"Then 'calkit push' to send them to the project's DVC remote"
|
|
1589
|
+
)
|
|
1590
|
+
|
|
1591
|
+
|
|
1359
1592
|
@update_app.command(name="dataset")
|
|
1360
1593
|
def update_dataset(
|
|
1361
1594
|
path: Annotated[str, typer.Argument(help="Path to the dataset file.")],
|
calkit/docx.py
CHANGED
|
@@ -191,7 +191,13 @@ class Document:
|
|
|
191
191
|
path or self.path, "w", zipfile.ZIP_DEFLATED
|
|
192
192
|
) as z:
|
|
193
193
|
for name, data in self.parts.items():
|
|
194
|
-
|
|
194
|
+
# Undated, as Word writes them, so the same content makes
|
|
195
|
+
# the same file
|
|
196
|
+
info = zipfile.ZipInfo(name)
|
|
197
|
+
# As written on Unix, which Windows would otherwise mark
|
|
198
|
+
info.create_system = 3
|
|
199
|
+
info.external_attr = 0o600 << 16
|
|
200
|
+
z.writestr(info, data, zipfile.ZIP_DEFLATED)
|
|
195
201
|
|
|
196
202
|
@property
|
|
197
203
|
def paragraphs(self) -> list[Paragraph]:
|