calkit-python 0.50.2__py3-none-any.whl → 0.50.4__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 (51) hide show
  1. calkit/cli/check.py +7 -1
  2. calkit/cli/core.py +32 -0
  3. calkit/cli/export.py +139 -0
  4. calkit/cli/latex.py +909 -42
  5. calkit/cli/main/core.py +147 -5
  6. calkit/config.py +266 -57
  7. calkit/core.py +23 -4
  8. calkit/docker.py +2 -0
  9. calkit/docx.py +616 -2
  10. calkit/export.py +169 -0
  11. calkit/latex.py +439 -10
  12. calkit/models/pipeline.py +183 -11
  13. calkit/pipeline.py +45 -14
  14. calkit/provenance.py +278 -6
  15. calkit/reproducibility.py +63 -1
  16. calkit/tests/cli/main/test_core.py +184 -0
  17. calkit/tests/cli/test_core.py +33 -0
  18. calkit/tests/cli/test_export.py +152 -0
  19. calkit/tests/cli/test_latex.py +63 -0
  20. calkit/tests/models/test_pipeline.py +76 -0
  21. calkit/tests/test_config.py +238 -48
  22. calkit/tests/test_core.py +24 -0
  23. calkit/tests/test_docker.py +6 -0
  24. calkit/tests/test_docx.py +336 -8
  25. calkit/tests/test_export.py +85 -0
  26. calkit/tests/test_latex.py +184 -0
  27. calkit/tests/test_pipeline.py +91 -0
  28. calkit/tests/test_provenance.py +186 -0
  29. calkit/tests/test_reproducibility.py +88 -4
  30. calkit/tests/test_upgrade.py +9 -1
  31. calkit/tests/test_windows.py +56 -0
  32. calkit/upgrade.py +7 -0
  33. calkit/windows.py +59 -0
  34. {calkit_python-0.50.2.dist-info → calkit_python-0.50.4.dist-info}/METADATA +56 -1
  35. {calkit_python-0.50.2.dist-info → calkit_python-0.50.4.dist-info}/RECORD +51 -45
  36. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  37. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  38. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  39. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  40. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  41. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  42. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  43. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  44. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  45. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  46. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  47. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  48. {calkit_python-0.50.2.data → calkit_python-0.50.4.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  49. {calkit_python-0.50.2.dist-info → calkit_python-0.50.4.dist-info}/WHEEL +0 -0
  50. {calkit_python-0.50.2.dist-info → calkit_python-0.50.4.dist-info}/entry_points.txt +0 -0
  51. {calkit_python-0.50.2.dist-info → calkit_python-0.50.4.dist-info}/licenses/LICENSE +0 -0
calkit/cli/check.py CHANGED
@@ -16,7 +16,7 @@ import dotenv
16
16
  import typer
17
17
 
18
18
  import calkit
19
- from calkit.cli import AliasGroup, raise_error, warn
19
+ from calkit.cli import AliasGroup, raise_error, warn, warn_app_control
20
20
  from calkit.core import get_md5
21
21
 
22
22
 
@@ -605,6 +605,7 @@ def check_environment(
605
605
  try:
606
606
  subprocess.check_call(cmd)
607
607
  except subprocess.CalledProcessError:
608
+ warn_app_control(maybe=True)
608
609
  raise_error("Failed to check pixi environment")
609
610
  elif env["kind"] == "uv":
610
611
  cmd = ["uv", "sync"]
@@ -616,6 +617,7 @@ def check_environment(
616
617
  try:
617
618
  subprocess.check_call(cmd)
618
619
  except subprocess.CalledProcessError:
620
+ warn_app_control(maybe=True)
619
621
  raise_error("Failed to check uv environment")
620
622
  elif (kind := env["kind"]) in ["uv-venv", "venv"]:
621
623
  if "path" not in env:
@@ -1835,6 +1837,7 @@ def check_conda_env(
1835
1837
  verbose=not quiet,
1836
1838
  )
1837
1839
  except Exception as e:
1840
+ warn_app_control(output=str(e), maybe=True)
1838
1841
  raise_error(f"Failed to check conda environment: {e}")
1839
1842
 
1840
1843
 
@@ -1917,6 +1920,7 @@ def check_venv(
1917
1920
  try:
1918
1921
  subprocess.check_call(create_cmd + [prefix], cwd=wdir)
1919
1922
  except subprocess.CalledProcessError:
1923
+ warn_app_control(maybe=True)
1920
1924
  raise_error(f"Failed to create {kind} at {prefix}")
1921
1925
  # Put a gitignore file in the env dir if one doesn't exist
1922
1926
  gitignore_fpath = os.path.join(prefix_full_path, ".gitignore")
@@ -2117,6 +2121,7 @@ def check_venv(
2117
2121
  pip_install_and_freeze(dep_file_txt)
2118
2122
  except (subprocess.CalledProcessError, OSError):
2119
2123
  if lock_is_current:
2124
+ warn_app_control(maybe=True)
2120
2125
  raise_error(
2121
2126
  f"Failed to create {kind} at {prefix} from its lock file "
2122
2127
  f"({lock_fpath}), which matches the spec ({path}). "
@@ -2132,6 +2137,7 @@ def check_venv(
2132
2137
  try:
2133
2138
  pip_install_and_freeze(f"-r {path}")
2134
2139
  except subprocess.CalledProcessError:
2140
+ warn_app_control(maybe=True)
2135
2141
  raise_error(f"Failed to check {kind} from input file {path}")
2136
2142
 
2137
2143
 
calkit/cli/core.py CHANGED
@@ -184,3 +184,35 @@ def warn(txt: str, prefix: str = "Warning: ", err: bool = False):
184
184
  text = typer.style(prefix + encode_safe(str(txt), err=err), fg="yellow")
185
185
  if not _echo_above_spinner(text, err=err):
186
186
  typer.echo(text, err=err)
187
+
188
+
189
+ def warn_app_control(
190
+ output: str = "", returncode: int | None = None, maybe: bool = False
191
+ ) -> None:
192
+ """Explain a failure Windows App Control may have caused.
193
+
194
+ Smart App Control blocks unsigned executables and DLLs it doesn't trust,
195
+ which environments are full of, and the failure reads as unrelated.
196
+ Pass ``maybe`` for a failure whose output went straight to the terminal,
197
+ to warn whenever Smart App Control is on.
198
+ """
199
+ from calkit.windows import (
200
+ SMART_APP_CONTROL_DOCS_URL,
201
+ app_control_blocked,
202
+ smart_app_control_on,
203
+ )
204
+
205
+ if app_control_blocked(output=output, returncode=returncode):
206
+ warn(
207
+ "Windows blocked a file from running, which Smart App Control "
208
+ "does to unsigned files it doesn't trust; see "
209
+ f"{SMART_APP_CONTROL_DOCS_URL}",
210
+ err=True,
211
+ )
212
+ elif maybe and smart_app_control_on():
213
+ warn(
214
+ "Smart App Control is on, which can block files in environments "
215
+ "from running; if the output above says a file was blocked, see "
216
+ f"{SMART_APP_CONTROL_DOCS_URL}",
217
+ err=True,
218
+ )
calkit/cli/export.py ADDED
@@ -0,0 +1,139 @@
1
+ """CLI for exporting data and artifacts from various apps, e.g., a PNG
2
+ from an Inkscape SVG.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from typing import TYPE_CHECKING
9
+
10
+ import typer
11
+ from typing_extensions import Annotated
12
+
13
+ from calkit.cli import raise_error
14
+
15
+ if TYPE_CHECKING:
16
+ from calkit.export import Exporter
17
+
18
+ export_app = typer.Typer(no_args_is_help=True)
19
+
20
+
21
+ def _prepare(exporter: Exporter, source: str, output: str) -> str:
22
+ """Get ready to export, returning the executable to run."""
23
+ exporter.check(source, output)
24
+ if not os.path.isfile(source):
25
+ raise ValueError(f"'{source}' doesn't exist")
26
+ os.makedirs(os.path.dirname(output) or ".", exist_ok=True)
27
+ # What was there is from before, so it can't pass for a new export
28
+ if os.path.exists(output):
29
+ os.remove(output)
30
+ exe = exporter.find_executable()
31
+ if exe is None:
32
+ raise ValueError(
33
+ f"Exporting '{source}' needs {exporter.app} installed"
34
+ )
35
+ return exe
36
+
37
+
38
+ def inkscape(source: str, output: str, dpi: int | None = None) -> None:
39
+ """Export a drawing to the format ``output``'s extension names,
40
+ raising ``ValueError`` if it can't be.
41
+ """
42
+ import subprocess
43
+
44
+ from calkit.export import INKSCAPE
45
+
46
+ exe = _prepare(INKSCAPE, source, output)
47
+ cmd = [exe, source, f"--export-filename={output}"]
48
+ if dpi is not None:
49
+ cmd.append(f"--export-dpi={dpi}")
50
+ result = subprocess.run(cmd, capture_output=True, text=True)
51
+ if not os.path.isfile(output):
52
+ detail = (result.stderr or result.stdout or "").strip()
53
+ raise ValueError(
54
+ f"Inkscape didn't export '{output}' from '{source}'"
55
+ + (f": {detail.splitlines()[-1]}" if detail else "")
56
+ )
57
+
58
+
59
+ def gimp(source: str, output: str) -> None:
60
+ """Flatten a GIMP document into the format ``output``'s extension
61
+ names, raising ``ValueError`` if it can't be.
62
+
63
+ Runs GIMP 3's batch dialect, then GIMP 2's, which differ in their file
64
+ procedures. Neither reports a failed script in its exit code, so the
65
+ written file is what says it worked.
66
+ """
67
+ import subprocess
68
+
69
+ from calkit.export import GIMP
70
+
71
+ exe = _prepare(GIMP, source, output)
72
+ src = os.path.abspath(source).replace("\\", "\\\\").replace('"', '\\"')
73
+ dst = os.path.abspath(output).replace("\\", "\\\\").replace('"', '\\"')
74
+ v3 = (
75
+ f'(let* ((image (car (gimp-file-load RUN-NONINTERACTIVE "{src}"))))'
76
+ " (gimp-image-flatten image)"
77
+ f' (gimp-file-save RUN-NONINTERACTIVE image "{dst}" "{dst}")'
78
+ " (gimp-image-delete image)"
79
+ " (gimp-quit 0))"
80
+ )
81
+ v2 = (
82
+ "(let* ((image (car (gimp-file-load RUN-NONINTERACTIVE "
83
+ f'"{src}" "{src}")))'
84
+ " (drawable (car (gimp-image-flatten image))))"
85
+ f' (gimp-file-save RUN-NONINTERACTIVE image drawable "{dst}" '
86
+ f'"{dst}")'
87
+ " (gimp-image-delete image)"
88
+ " (gimp-quit 0))"
89
+ )
90
+ detail = ""
91
+ for cmd in [
92
+ [exe, "-i", "--batch-interpreter=plug-in-script-fu-eval", "-b", v3],
93
+ [exe, "-i", "-b", v2],
94
+ ]:
95
+ result = subprocess.run(cmd, capture_output=True, text=True)
96
+ if os.path.isfile(output):
97
+ return
98
+ detail = (result.stderr or result.stdout or "").strip()
99
+ raise ValueError(
100
+ f"GIMP didn't export '{output}' from '{source}'"
101
+ + (f": {detail.splitlines()[-1]}" if detail else "")
102
+ )
103
+
104
+
105
+ @export_app.command(name="inkscape", help="Export a figure from an SVG.")
106
+ def export_inkscape(
107
+ source: Annotated[str, typer.Argument(help="SVG to export from.")],
108
+ output: Annotated[
109
+ str,
110
+ typer.Option(
111
+ "--output", "-o", help="File to export to, e.g., a PNG or PDF."
112
+ ),
113
+ ],
114
+ dpi: Annotated[
115
+ int | None, typer.Option("--dpi", help="Resolution of a PNG.")
116
+ ] = None,
117
+ ) -> None:
118
+ try:
119
+ inkscape(source, output, dpi=dpi)
120
+ except ValueError as e:
121
+ raise_error(str(e))
122
+
123
+
124
+ @export_app.command(
125
+ name="gimp", help="Export a flattened figure from an XCF file."
126
+ )
127
+ def export_gimp(
128
+ source: Annotated[str, typer.Argument(help="XCF file to export from.")],
129
+ output: Annotated[
130
+ str,
131
+ typer.Option(
132
+ "--output", "-o", help="File to export to, e.g., a PNG or TIFF."
133
+ ),
134
+ ],
135
+ ) -> None:
136
+ try:
137
+ gimp(source, output)
138
+ except ValueError as e:
139
+ raise_error(str(e))