calkit-python 0.16.1__tar.gz → 0.16.3__tar.gz

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 (130) hide show
  1. {calkit_python-0.16.1 → calkit_python-0.16.3}/.gitignore +1 -0
  2. {calkit_python-0.16.1 → calkit_python-0.16.3}/PKG-INFO +2 -1
  3. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/__init__.py +1 -1
  4. calkit_python-0.16.3/calkit/cli/check.py +196 -0
  5. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/cli/main.py +9 -127
  6. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/cli/new.py +1 -37
  7. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/conda.py +12 -3
  8. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/cli/test_main.py +28 -11
  9. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/environments.md +15 -0
  10. calkit_python-0.16.3/docs/tutorials/conda-envs.md +57 -0
  11. {calkit_python-0.16.1 → calkit_python-0.16.3}/pyproject.toml +1 -0
  12. calkit_python-0.16.3/uv.lock +3735 -0
  13. calkit_python-0.16.1/calkit/cli/check.py +0 -48
  14. calkit_python-0.16.1/docs/tutorials/conda-envs.md +0 -85
  15. {calkit_python-0.16.1 → calkit_python-0.16.3}/.github/FUNDING.yml +0 -0
  16. {calkit_python-0.16.1 → calkit_python-0.16.3}/.github/workflows/docs.yml +0 -0
  17. {calkit_python-0.16.1 → calkit_python-0.16.3}/.github/workflows/publish-test.yml +0 -0
  18. {calkit_python-0.16.1 → calkit_python-0.16.3}/.github/workflows/publish.yml +0 -0
  19. {calkit_python-0.16.1 → calkit_python-0.16.3}/LICENSE +0 -0
  20. {calkit_python-0.16.1 → calkit_python-0.16.3}/README.md +0 -0
  21. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/calc.py +0 -0
  22. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/check.py +0 -0
  23. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/cli/__init__.py +0 -0
  24. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/cli/config.py +0 -0
  25. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/cli/core.py +0 -0
  26. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/cli/import_.py +0 -0
  27. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/cli/list.py +0 -0
  28. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/cli/notebooks.py +0 -0
  29. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/cli/office.py +0 -0
  30. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/cli/update.py +0 -0
  31. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/cloud.py +0 -0
  32. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/config.py +0 -0
  33. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/core.py +0 -0
  34. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/datasets.py +0 -0
  35. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/docker.py +0 -0
  36. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/dvc.py +0 -0
  37. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/git.py +0 -0
  38. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/gui.py +0 -0
  39. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/jupyter.py +0 -0
  40. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/magics.py +0 -0
  41. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/models.py +0 -0
  42. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/office.py +0 -0
  43. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/ops.py +0 -0
  44. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/server.py +0 -0
  45. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/templates/__init__.py +0 -0
  46. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/templates/core.py +0 -0
  47. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/templates/latex/__init__.py +0 -0
  48. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/templates/latex/article/paper.tex +0 -0
  49. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/templates/latex/core.py +0 -0
  50. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/templates/latex/jfm/jfm.bst +0 -0
  51. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/templates/latex/jfm/jfm.cls +0 -0
  52. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/templates/latex/jfm/lineno-FLM.sty +0 -0
  53. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/templates/latex/jfm/paper.tex +0 -0
  54. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/templates/latex/jfm/upmath.sty +0 -0
  55. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/__init__.py +0 -0
  56. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/cli/__init__.py +0 -0
  57. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/cli/test_list.py +0 -0
  58. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/cli/test_new.py +0 -0
  59. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/test_calc.py +0 -0
  60. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/test_check.py +0 -0
  61. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/test_conda.py +0 -0
  62. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/test_core.py +0 -0
  63. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/test_dvc.py +0 -0
  64. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/test_jupyter.py +0 -0
  65. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/test_magics.py +0 -0
  66. {calkit_python-0.16.1 → calkit_python-0.16.3}/calkit/tests/test_templates.py +0 -0
  67. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/CNAME +0 -0
  68. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/apps.md +0 -0
  69. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/calculations.md +0 -0
  70. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/calkit-yaml.md +0 -0
  71. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/cli-reference.md +0 -0
  72. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/cloud-integration.md +0 -0
  73. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/examples.md +0 -0
  74. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/help.md +0 -0
  75. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/img/c-to-the-k-white.svg +0 -0
  76. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/img/calkit-no-bg.png +0 -0
  77. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/index.md +0 -0
  78. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/installation.md +0 -0
  79. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/pipeline/index.md +0 -0
  80. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/pipeline/manual-steps.md +0 -0
  81. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/references.md +0 -0
  82. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/adding-latex-pub-docker.md +0 -0
  83. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/first-project.md +0 -0
  84. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/building-codespace.png +0 -0
  85. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/codespaces-secrets-2.png +0 -0
  86. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/editor-split.png +0 -0
  87. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/go-to-linked-code.png +0 -0
  88. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/issue-from-selection.png +0 -0
  89. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/new-project.png +0 -0
  90. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/new-pub-2.png +0 -0
  91. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/new-token.png +0 -0
  92. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/paper.tex.png +0 -0
  93. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/project-home-3.png +0 -0
  94. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/push.png +0 -0
  95. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/latex-codespaces/stage.png +0 -0
  96. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/anakin-excel.jpg +0 -0
  97. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/chart-more-rows.png +0 -0
  98. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/create-project.png +0 -0
  99. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/elsevier-research-data-guidelines.png +0 -0
  100. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/excel-chart.png +0 -0
  101. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/excel-data.png +0 -0
  102. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/insert-link-to-file.png +0 -0
  103. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/needs-clone.png +0 -0
  104. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/new-stage.png +0 -0
  105. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/phd-comics-version-control.webp +0 -0
  106. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/pipeline-out-of-date.png +0 -0
  107. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/status-more-rows.png +0 -0
  108. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/uncommitted-changes.png +0 -0
  109. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/untracked-data.png +0 -0
  110. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/updated-publication.png +0 -0
  111. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/word-to-pdf-stage-2.png +0 -0
  112. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/office/workflow-page.png +0 -0
  113. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/openfoam/clone.png +0 -0
  114. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/openfoam/create-project.png +0 -0
  115. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/openfoam/datasets-page.png +0 -0
  116. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/openfoam/figure-on-website-updated.png +0 -0
  117. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/openfoam/figure-on-website.png +0 -0
  118. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/openfoam/new-token.png +0 -0
  119. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/openfoam/reclone.png +0 -0
  120. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/openfoam/status-after-import-dataset.png +0 -0
  121. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/img/run-proc.png +0 -0
  122. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/latex-codespaces.md +0 -0
  123. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/matlab.md +0 -0
  124. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/notebook-pipeline.md +0 -0
  125. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/office.md +0 -0
  126. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/openfoam.md +0 -0
  127. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/tutorials/procedures.md +0 -0
  128. {calkit_python-0.16.1 → calkit_python-0.16.3}/docs/version-control.md +0 -0
  129. {calkit_python-0.16.1 → calkit_python-0.16.3}/mkdocs.yml +0 -0
  130. {calkit_python-0.16.1 → calkit_python-0.16.3}/test/pipeline.ipynb +0 -0
@@ -4,3 +4,4 @@ dev.ipynb
4
4
  .vscode
5
5
  site
6
6
  .env
7
+ .venv
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calkit-python
3
- Version: 0.16.1
3
+ Version: 0.16.3
4
4
  Summary: Reproducibility simplified.
5
5
  Project-URL: Homepage, https://calkit.org
6
6
  Project-URL: Issues, https://github.com/calkit/calkit/issues
@@ -12,6 +12,7 @@ Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Requires-Python: >=3.8
14
14
  Requires-Dist: arithmeval
15
+ Requires-Dist: checksumdir
15
16
  Requires-Dist: docx2pdf
16
17
  Requires-Dist: dvc
17
18
  Requires-Dist: eval-type-backport; python_version < '3.10'
@@ -1,4 +1,4 @@
1
- __version__ = "0.16.1"
1
+ __version__ = "0.16.3"
2
2
 
3
3
  from .core import *
4
4
  from . import git
@@ -0,0 +1,196 @@
1
+ """CLI for checking things."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import subprocess
10
+ from typing import Annotated
11
+
12
+ import checksumdir
13
+ import typer
14
+
15
+ import calkit
16
+ from calkit.check import check_reproducibility
17
+ from calkit.cli import raise_error
18
+
19
+ check_app = typer.Typer(no_args_is_help=True)
20
+
21
+
22
+ @check_app.command(name="repro")
23
+ def check_repro(
24
+ wdir: Annotated[
25
+ str, typer.Option("--wdir", help="Project working directory.")
26
+ ] = ".",
27
+ ):
28
+ """Check the reproducibility of a project."""
29
+ res = check_reproducibility(wdir=wdir, log_func=typer.echo)
30
+ typer.echo(res.to_pretty().encode("utf-8", errors="replace"))
31
+
32
+
33
+ @check_app.command(name="call")
34
+ def check_call(
35
+ cmd: Annotated[str, typer.Argument(help="Command to check.")],
36
+ if_error: Annotated[
37
+ str,
38
+ typer.Option(
39
+ "--if-error", help="Command to run if there is an error."
40
+ ),
41
+ ],
42
+ ):
43
+ """Check that a command succeeds and run an alternate if not."""
44
+ try:
45
+ subprocess.check_call(cmd, shell=True)
46
+ typer.echo("Command succeeded")
47
+ except subprocess.CalledProcessError:
48
+ typer.echo("Command failed")
49
+ try:
50
+ typer.echo("Attempting fallback call")
51
+ subprocess.check_call(if_error, shell=True)
52
+ typer.echo("Fallback call succeeded")
53
+ except subprocess.CalledProcessError:
54
+ raise_error("Fallback call failed")
55
+
56
+
57
+ @check_app.command(
58
+ name="docker-env",
59
+ help="Check that Docker image is up-to-date.",
60
+ )
61
+ def check_docker_env(
62
+ tag: Annotated[str, typer.Argument(help="Image tag.")],
63
+ fpath: Annotated[
64
+ str, typer.Option("-i", "--input", help="Path to input Dockerfile.")
65
+ ] = "Dockerfile",
66
+ platform: Annotated[
67
+ str, typer.Option("--platform", help="Which platform(s) to build for.")
68
+ ] = None,
69
+ deps: Annotated[
70
+ list[str],
71
+ typer.Option(
72
+ "--dep",
73
+ "-d",
74
+ help="Declare an explicit dependency for this Docker image.",
75
+ ),
76
+ ] = [],
77
+ quiet: Annotated[
78
+ bool, typer.Option("--quiet", "-q", help="Be quiet.")
79
+ ] = False,
80
+ ):
81
+ def get_docker_inspect():
82
+ out = json.loads(
83
+ subprocess.check_output(["docker", "inspect", tag]).decode()
84
+ )
85
+ # Remove some keys that can change without the important aspects of
86
+ # the image changing
87
+ _ = out[0].pop("Id")
88
+ _ = out[0].pop("RepoDigests")
89
+ _ = out[0].pop("Metadata")
90
+ _ = out[0].pop("DockerVersion")
91
+ return out
92
+
93
+ def get_md5(path: str, exclude_files: list[str] | None = None) -> str:
94
+ if os.path.isdir(path):
95
+ return checksumdir.dirhash(dep, excluded_files=exclude_files)
96
+ else:
97
+ with open(path) as f:
98
+ content = f.read()
99
+ return hashlib.md5(content.encode()).hexdigest()
100
+
101
+ outfile = open(os.devnull, "w") if quiet else None
102
+ typer.echo(f"Checking for existing image with tag {tag}", file=outfile)
103
+ # First call Docker inspect
104
+ try:
105
+ inspect = get_docker_inspect()
106
+ except subprocess.CalledProcessError:
107
+ typer.echo(f"No image with tag {tag} found locally", file=outfile)
108
+ inspect = []
109
+ typer.echo(f"Reading Dockerfile from {fpath}", file=outfile)
110
+ dockerfile_md5 = get_md5(fpath)
111
+ lock_fpath = fpath + "-lock.json"
112
+ # Compute MD5s of any dependencies
113
+ deps_md5s = {}
114
+ for dep in deps:
115
+ deps_md5s[dep] = get_md5(dep, exclude_files=lock_fpath)
116
+ rebuild = True
117
+ if os.path.isfile(lock_fpath):
118
+ typer.echo(f"Reading lock file: {lock_fpath}", file=outfile)
119
+ with open(lock_fpath) as f:
120
+ lock = json.load(f)
121
+ else:
122
+ typer.echo(f"Lock file ({lock_fpath}) does not exist", file=outfile)
123
+ lock = None
124
+ if inspect and lock:
125
+ typer.echo(
126
+ "Checking image and Dockerfile against lock file", file=outfile
127
+ )
128
+ rebuild = inspect[0]["RootFS"]["Layers"] != lock[0]["RootFS"][
129
+ "Layers"
130
+ ] or dockerfile_md5 != lock[0].get("DockerfileMD5")
131
+ if not rebuild:
132
+ for dep, md5 in deps_md5s.items():
133
+ if md5 != lock[0].get("DepsMD5s", {}).get(dep):
134
+ typer.echo(f"Found modified dependency: {dep}")
135
+ rebuild = True
136
+ break
137
+ if rebuild:
138
+ wdir, fname = os.path.split(fpath)
139
+ if not wdir:
140
+ wdir = None
141
+ cmd = ["docker", "build", "-t", tag, "-f", fname]
142
+ if platform is not None:
143
+ cmd += ["--platform", platform]
144
+ cmd.append(".")
145
+ subprocess.check_output(cmd, cwd=wdir)
146
+ # Write the lock file
147
+ inspect = get_docker_inspect()
148
+ inspect[0]["DockerfileMD5"] = dockerfile_md5
149
+ inspect[0]["DepsMD5s"] = deps_md5s
150
+ with open(lock_fpath, "w") as f:
151
+ json.dump(inspect, f, indent=4)
152
+
153
+
154
+ @check_app.command(
155
+ name="conda-env",
156
+ help="Check a conda environment and rebuild if necessary.",
157
+ )
158
+ def check_conda_env(
159
+ env_fpath: Annotated[
160
+ str,
161
+ typer.Option(
162
+ "--file", "-f", help="Path to conda environment YAML file."
163
+ ),
164
+ ] = "environment.yml",
165
+ output_fpath: Annotated[
166
+ str,
167
+ typer.Option(
168
+ "--output",
169
+ "-o",
170
+ help=(
171
+ "Path to which existing environment should be exported. "
172
+ "If not specified, will have the same filename with '-lock' "
173
+ "appended to it, keeping the same extension."
174
+ ),
175
+ ),
176
+ ] = None,
177
+ relaxed: Annotated[
178
+ bool,
179
+ typer.Option(
180
+ "--relaxed", help="Treat conda and pip dependencies as equivalent."
181
+ ),
182
+ ] = False,
183
+ quiet: Annotated[
184
+ bool, typer.Option("--quiet", "-q", help="Be quiet.")
185
+ ] = False,
186
+ ):
187
+ if quiet:
188
+ log_func = functools.partial(typer.echo, file=open(os.devnull, "w"))
189
+ else:
190
+ log_func = typer.echo
191
+ calkit.conda.check_env(
192
+ env_fpath=env_fpath,
193
+ output_fpath=output_fpath,
194
+ log_func=log_func,
195
+ relaxed=relaxed,
196
+ )
@@ -3,9 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import csv
6
- import functools
7
- import hashlib
8
- import json
9
6
  import os
10
7
  import platform as _platform
11
8
  import subprocess
@@ -22,7 +19,7 @@ from typing_extensions import Annotated, Optional
22
19
 
23
20
  import calkit
24
21
  from calkit.cli import print_sep, raise_error, run_cmd, warn
25
- from calkit.cli.check import check_app
22
+ from calkit.cli.check import check_app, check_conda_env, check_docker_env
26
23
  from calkit.cli.config import config_app
27
24
  from calkit.cli.import_ import import_app
28
25
  from calkit.cli.list import list_app
@@ -60,7 +57,8 @@ def _to_shell_cmd(cmd: list[str]) -> str:
60
57
  """
61
58
  quoted_cmd = []
62
59
  for part in cmd:
63
- if " " in part or '"' in part or "'" in part:
60
+ # Find quotes within quotes and escape them
61
+ if " " in part or '"' in part[1:-1] or "'" in part[1:-1]:
64
62
  part = part.replace('"', r"\"")
65
63
  quoted_cmd.append(f'"{part}"')
66
64
  else:
@@ -711,12 +709,11 @@ def run_in_env(
711
709
  if env_name not in envs:
712
710
  raise_error(f"Environment '{env_name}' does not exist")
713
711
  env = envs[env_name]
714
- if wdir is not None:
715
- cwd = os.path.abspath(wdir)
716
- else:
717
- cwd = os.getcwd()
718
712
  image_name = env.get("image", env_name)
719
713
  docker_wdir = env.get("wdir", "/work")
714
+ docker_wdir_mount = docker_wdir
715
+ if wdir is not None:
716
+ docker_wdir = os.path.join(docker_wdir, wdir)
720
717
  shell = env.get("shell", "sh")
721
718
  platform = env.get("platform")
722
719
  if env["kind"] == "docker":
@@ -727,6 +724,7 @@ def run_in_env(
727
724
  tag=env["image"],
728
725
  fpath=env["path"],
729
726
  platform=env.get("platform"),
727
+ deps=env.get("deps", []),
730
728
  quiet=True,
731
729
  )
732
730
  shell_cmd = _to_shell_cmd(cmd)
@@ -736,13 +734,14 @@ def run_in_env(
736
734
  ]
737
735
  if platform:
738
736
  docker_cmd += ["--platform", platform]
737
+ docker_cmd += env.get("args", [])
739
738
  docker_cmd += [
740
739
  "-it" if sys.stdin.isatty() else "-i",
741
740
  "--rm",
742
741
  "-w",
743
742
  docker_wdir,
744
743
  "-v",
745
- f"{cwd}:{docker_wdir}",
744
+ f"{os.getcwd()}:{docker_wdir_mount}",
746
745
  image_name,
747
746
  shell,
748
747
  "-c",
@@ -846,78 +845,6 @@ def run_in_env(
846
845
  raise_error("Environment kind not supported")
847
846
 
848
847
 
849
- @app.command(
850
- name="build-docker",
851
- help="Build Docker image if missing or different from lock file.",
852
- )
853
- def check_docker_env(
854
- tag: Annotated[str, typer.Argument(help="Image tag.")],
855
- fpath: Annotated[
856
- str, typer.Option("-i", "--input", help="Path to input Dockerfile.")
857
- ] = "Dockerfile",
858
- platform: Annotated[
859
- str, typer.Option("--platform", help="Which platform(s) to build for.")
860
- ] = None,
861
- quiet: Annotated[
862
- bool, typer.Option("--quiet", "-q", help="Be quiet.")
863
- ] = False,
864
- ):
865
- def get_docker_inspect():
866
- out = json.loads(
867
- subprocess.check_output(["docker", "inspect", tag]).decode()
868
- )
869
- # Remove some keys that can change without the important aspects of
870
- # the image changing
871
- _ = out[0].pop("Id")
872
- _ = out[0].pop("RepoDigests")
873
- _ = out[0].pop("Metadata")
874
- _ = out[0].pop("DockerVersion")
875
- return out
876
-
877
- outfile = open(os.devnull, "w") if quiet else None
878
- typer.echo(f"Checking for existing image with tag {tag}", file=outfile)
879
- # First call Docker inspect
880
- try:
881
- inspect = get_docker_inspect()
882
- except subprocess.CalledProcessError:
883
- typer.echo(f"No image with tag {tag} found locally", file=outfile)
884
- inspect = []
885
- typer.echo(f"Reading Dockerfile from {fpath}", file=outfile)
886
- with open(fpath) as f:
887
- dockerfile = f.read()
888
- dockerfile_md5 = hashlib.md5(dockerfile.encode()).hexdigest()
889
- lock_fpath = fpath + "-lock.json"
890
- rebuild = True
891
- if os.path.isfile(lock_fpath):
892
- typer.echo(f"Reading lock file: {lock_fpath}", file=outfile)
893
- with open(lock_fpath) as f:
894
- lock = json.load(f)
895
- else:
896
- typer.echo(f"Lock file ({lock_fpath}) does not exist", file=outfile)
897
- lock = None
898
- if inspect and lock:
899
- typer.echo(
900
- "Checking image and Dockerfile against lock file", file=outfile
901
- )
902
- rebuild = inspect[0]["RootFS"]["Layers"] != lock[0]["RootFS"][
903
- "Layers"
904
- ] or dockerfile_md5 != lock[0].get("DockerfileMD5")
905
- if rebuild:
906
- wdir, fname = os.path.split(fpath)
907
- if not wdir:
908
- wdir = None
909
- cmd = ["docker", "build", "-t", tag, "-f", fname]
910
- if platform is not None:
911
- cmd += ["--platform", platform]
912
- cmd.append(".")
913
- subprocess.check_call(cmd, cwd=wdir)
914
- # Write the lock file
915
- inspect = get_docker_inspect()
916
- inspect[0]["DockerfileMD5"] = dockerfile_md5
917
- with open(lock_fpath, "w") as f:
918
- json.dump(inspect, f, indent=4)
919
-
920
-
921
848
  @app.command(name="runproc", help="Execute a procedure (alias for 'xproc').")
922
849
  @app.command(name="xproc", help="Execute a procedure.")
923
850
  def run_procedure(
@@ -1059,51 +986,6 @@ def run_procedure(
1059
986
  wait(step.wait_after_s)
1060
987
 
1061
988
 
1062
- @app.command(
1063
- name="check-conda-env",
1064
- help="Check a conda environment and rebuild if necessary.",
1065
- )
1066
- def check_conda_env(
1067
- env_fpath: Annotated[
1068
- str,
1069
- typer.Option(
1070
- "--file", "-f", help="Path to conda environment YAML file."
1071
- ),
1072
- ] = "environment.yml",
1073
- output_fpath: Annotated[
1074
- str,
1075
- typer.Option(
1076
- "--output",
1077
- "-o",
1078
- help=(
1079
- "Path to which existing environment should be exported. "
1080
- "If not specified, will have the same filename with '-lock' "
1081
- "appended to it, keeping the same extension."
1082
- ),
1083
- ),
1084
- ] = None,
1085
- relaxed: Annotated[
1086
- bool,
1087
- typer.Option(
1088
- "--relaxed", help="Treat conda and pip dependencies as equivalent."
1089
- ),
1090
- ] = False,
1091
- quiet: Annotated[
1092
- bool, typer.Option("--quiet", "-q", help="Be quiet.")
1093
- ] = False,
1094
- ):
1095
- if quiet:
1096
- log_func = functools.partial(typer.echo, file=open(os.devnull, "w"))
1097
- else:
1098
- log_func = typer.echo
1099
- calkit.conda.check_env(
1100
- env_fpath=env_fpath,
1101
- output_fpath=output_fpath,
1102
- log_func=log_func,
1103
- relaxed=relaxed,
1104
- )
1105
-
1106
-
1107
989
  @app.command(name="calc")
1108
990
  def run_calculation(
1109
991
  name: Annotated[str, typer.Argument(help="Calculation name.")],
@@ -469,10 +469,6 @@ def new_docker_env(
469
469
  ),
470
470
  ),
471
471
  ] = None,
472
- stage: Annotated[
473
- str,
474
- typer.Option("--stage", help="DVC pipeline stage name, deprecated."),
475
- ] = None,
476
472
  layers: Annotated[
477
473
  list[str],
478
474
  typer.Option(
@@ -505,8 +501,6 @@ def new_docker_env(
505
501
  path = "Dockerfile"
506
502
  if base and os.path.isfile(path) and not overwrite:
507
503
  raise_error("Output path already exists (use -f to overwrite)")
508
- if stage and not base:
509
- raise_error("--from must be specified when creating a build stage")
510
504
  if image_name is None:
511
505
  typer.echo("No image name specified; using environment name")
512
506
  image_name = name
@@ -543,8 +537,6 @@ def new_docker_env(
543
537
  )
544
538
  if base is not None or path is not None:
545
539
  env["path"] = path
546
- if stage is not None:
547
- env["stage"] = stage
548
540
  if description is not None:
549
541
  env["description"] = description
550
542
  if layers:
@@ -555,35 +547,7 @@ def new_docker_env(
555
547
  ck_info["environments"] = envs
556
548
  with open("calkit.yaml", "w") as f:
557
549
  ryaml.dump(ck_info, f)
558
- # If we're creating a stage, do so with DVC
559
- if stage:
560
- warn("--stage is deprecated since envs are checked at run time")
561
- typer.echo(f"Creating DVC stage {stage}")
562
- if not os.path.isfile(".dvc/config"):
563
- typer.echo(f"Running dvc init")
564
- subprocess.check_call(["dvc", "init"])
565
- ck_cmd = f"calkit build-docker {image_name} -i {path}"
566
- if platform:
567
- ck_cmd += f" --platform {platform}"
568
- subprocess.check_call(
569
- [
570
- "dvc",
571
- "stage",
572
- "add",
573
- "-f",
574
- "-n",
575
- stage,
576
- "--always-changed",
577
- "-d",
578
- path,
579
- "--outs-persist-no-cache",
580
- f"{path}-lock.json",
581
- ck_cmd,
582
- ]
583
- )
584
550
  repo.git.add("calkit.yaml")
585
- if stage:
586
- repo.git.add("dvc.yaml")
587
551
  if not no_commit and repo.git.diff("--staged"):
588
552
  repo.git.commit(["-m", f"Add Docker environment {name}"])
589
553
 
@@ -1016,7 +980,7 @@ def new_conda_env(
1016
980
  if stage:
1017
981
  typer.echo(f"Creating DVC stage {stage}")
1018
982
  if not os.path.isfile(".dvc/config"):
1019
- typer.echo(f"Running dvc init")
983
+ typer.echo("Running dvc init")
1020
984
  subprocess.check_call(["dvc", "init"])
1021
985
  ck_cmd = f"calkit check-conda-env -f {path}"
1022
986
  fname, ext = os.path.splitext(path)
@@ -4,9 +4,10 @@ import json
4
4
  import os
5
5
  import re
6
6
  import subprocess
7
+ import warnings
7
8
 
8
9
  from packaging.specifiers import SpecifierSet
9
- from packaging.version import Version
10
+ from packaging.version import InvalidVersion, Version
10
11
  from pydantic import BaseModel
11
12
 
12
13
  import calkit
@@ -20,14 +21,22 @@ def _check_single(req: str, actual: str, conda: bool = False) -> bool:
20
21
  if conda and req_spec.startswith("="):
21
22
  req_spec = "=" + req_spec
22
23
  if not req_spec.endswith(".*"):
23
- req_spec += ".*"
24
+ if len(req_spec.split(".")) < 3:
25
+ req_spec += ".*"
24
26
  actual_name, actual_vers = re.split("[=<>]+", actual, maxsplit=1)
25
27
  if actual_name != req_name:
26
28
  return False
27
29
  actual_spec = actual.removeprefix(actual_name)
28
30
  if conda and actual_spec.startswith("="):
29
31
  actual_spec = "=" + actual_spec
30
- version = Version(actual_vers)
32
+ try:
33
+ version = Version(actual_vers)
34
+ except InvalidVersion:
35
+ warnings.warn(
36
+ f"Cannot properly check {actual_name} version {actual_vers}"
37
+ )
38
+ # TODO: Check exact version only
39
+ return True
31
40
  spec = SpecifierSet(req_spec)
32
41
  return spec.contains(version)
33
42
 
@@ -11,7 +11,6 @@ from git.exc import InvalidGitRepositoryError
11
11
 
12
12
  import calkit
13
13
  from calkit.cli.main import _to_shell_cmd
14
- from calkit.core import ryaml
15
14
 
16
15
 
17
16
  def test_run_in_env(tmp_dir):
@@ -21,13 +20,11 @@ def test_run_in_env(tmp_dir):
21
20
  subprocess.check_call(
22
21
  "calkit new docker-env "
23
22
  "--name my-image "
24
- "--stage build-image "
25
23
  "--from ubuntu "
26
24
  "--add-layer miniforge "
27
25
  "--description 'This is a test image'",
28
26
  shell=True,
29
27
  )
30
- subprocess.check_call("calkit run", shell=True)
31
28
  out = (
32
29
  subprocess.check_output("calkit xenv echo sup", shell=True)
33
30
  .decode()
@@ -40,7 +37,6 @@ def test_run_in_env(tmp_dir):
40
37
  "calkit new docker-env "
41
38
  "-n env2 "
42
39
  "--image my-image-2 "
43
- "--stage build-image-2 "
44
40
  "--path Dockerfile.2 "
45
41
  "--from ubuntu "
46
42
  "--add-layer miniforge "
@@ -48,12 +44,6 @@ def test_run_in_env(tmp_dir):
48
44
  "--description 'This is a test image 2'",
49
45
  shell=True,
50
46
  )
51
- with open("dvc.yaml") as f:
52
- pipeline = ryaml.load(f)
53
- stg = pipeline["stages"]["build-image-2"]
54
- cmd = stg["cmd"]
55
- assert "-i Dockerfile.2" in cmd
56
- subprocess.check_call("calkit run", shell=True)
57
47
  with pytest.raises(subprocess.CalledProcessError):
58
48
  out = (
59
49
  subprocess.check_output("calkit xenv echo sup", shell=True)
@@ -95,6 +85,26 @@ def test_run_in_env(tmp_dir):
95
85
  ck_info = calkit.load_calkit_info()
96
86
  env = ck_info["environments"]["py3.10"]
97
87
  assert env.get("path") is None
88
+ # Test that we can run a command that changes directory first
89
+ os.makedirs("my-new-dir/another", exist_ok=True)
90
+ out = (
91
+ subprocess.check_output(
92
+ "calkit xenv -n py3.10 --wdir my-new-dir -- ls",
93
+ shell=True,
94
+ )
95
+ .decode()
96
+ .strip()
97
+ )
98
+ assert out == "another"
99
+ out = (
100
+ subprocess.check_output(
101
+ "calkit xenv -n py3.10 --wdir my-new-dir -- ls ..",
102
+ shell=True,
103
+ )
104
+ .decode()
105
+ .strip()
106
+ )
107
+ assert "my-new-dir" in out.split("\n")
98
108
 
99
109
 
100
110
  def test_run_in_venv(tmp_dir):
@@ -178,7 +188,7 @@ def test_run_in_venv(tmp_dir):
178
188
  )
179
189
  ck_info = calkit.load_calkit_info(as_pydantic=True)
180
190
  envs = ck_info.environments
181
- env = envs["my-pixi"]
191
+ assert "my-pixi" in envs
182
192
  out = (
183
193
  subprocess.check_output(
184
194
  [
@@ -216,6 +226,13 @@ def test_to_shell_cmd():
216
226
  shell_cmd = _to_shell_cmd(cmd)
217
227
  assert shell_cmd == 'python -c "print(\\"hello world\\")"'
218
228
  subprocess.check_call(shell_cmd, shell=True)
229
+ cmd = [
230
+ "sh",
231
+ "-c",
232
+ "cd dir1 && ls",
233
+ ]
234
+ good_shell_cmd = 'sh -c "cd dir1 && ls"'
235
+ assert _to_shell_cmd(cmd) == good_shell_cmd
219
236
 
220
237
 
221
238
  def test_add(tmp_dir):
@@ -154,6 +154,21 @@ and another call to `calkit xenv -n foam2` will kick off a rebuild
154
154
  automatically,
155
155
  since the lock file will no longer match the Dockerfile.
156
156
 
157
+ If you're copying local files into the Docker image,
158
+ you can declare these
159
+ dependencies in the environment definition so the content of those will be
160
+ tracked as well:
161
+
162
+ ```yaml
163
+ # In calkit.yaml
164
+ environments:
165
+ foam2:
166
+ kind: docker
167
+ image: foam2
168
+ deps:
169
+ - src/mySolver.C
170
+ ```
171
+
157
172
  This highlights Calkit's declarative design philosophy.
158
173
  Simply declare the environment and use it in a pipeline stage
159
174
  and Calkit will ensure it is built and up to date.