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.
Files changed (44) hide show
  1. calkit/calc.py +15 -9
  2. calkit/cli/check.py +92 -2
  3. calkit/cli/describe.py +4 -1
  4. calkit/cli/latex.py +25 -3
  5. calkit/cli/main/core.py +59 -17
  6. calkit/cli/update.py +233 -0
  7. calkit/docx.py +7 -1
  8. calkit/dvc/core.py +210 -0
  9. calkit/environments.py +6 -0
  10. calkit/git.py +79 -6
  11. calkit/models/base.py +13 -0
  12. calkit/models/core.py +44 -44
  13. calkit/models/io.py +8 -6
  14. calkit/models/iteration.py +6 -4
  15. calkit/models/pipeline.py +11 -10
  16. calkit/pipeline.py +49 -3
  17. calkit/questions.py +61 -34
  18. calkit/tests/cli/main/test_core.py +97 -1
  19. calkit/tests/cli/test_check.py +85 -0
  20. calkit/tests/cli/test_latex.py +3 -1
  21. calkit/tests/cli/test_update.py +170 -0
  22. calkit/tests/dvc/test_core.py +93 -0
  23. calkit/tests/test_docx.py +69 -6
  24. calkit/tests/test_environments.py +10 -0
  25. calkit/tests/test_pipeline.py +10 -0
  26. calkit/tests/test_questions.py +60 -0
  27. {calkit_python-0.48.2.dist-info → calkit_python-0.48.3.dist-info}/METADATA +2 -1
  28. {calkit_python-0.48.2.dist-info → calkit_python-0.48.3.dist-info}/RECORD +44 -43
  29. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  30. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  31. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  32. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  33. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  34. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  35. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  36. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  37. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  38. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  39. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  40. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  41. {calkit_python-0.48.2.data → calkit_python-0.48.3.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  42. {calkit_python-0.48.2.dist-info → calkit_python-0.48.3.dist-info}/WHEEL +0 -0
  43. {calkit_python-0.48.2.dist-info → calkit_python-0.48.3.dist-info}/entry_points.txt +0 -0
  44. {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 Input(BaseModel):
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(BaseModel):
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(BaseModel):
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(BaseModel):
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(BaseModel):
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(BaseModel):
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(BaseModel):
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(BaseModel):
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, which nothing
2086
- will ever report out of date unless the citation pins a git_ref.
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
- version = calkit.__version__.split("+")[0]
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
- export_id = str(uuid.uuid4())
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 posix_path in pipeline_output_storage:
1210
+ elif output_storage is not None:
1159
1211
  # Respect storage explicitly set in the pipeline definition
1160
- pipeline_storage = pipeline_output_storage[posix_path]
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 pipeline_storage == "dvc-zip":
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
- z.writestr(name, data)
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]: