calkit-python 0.29.2__tar.gz → 0.30.0__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 (181) hide show
  1. {calkit_python-0.29.2 → calkit_python-0.30.0}/PKG-INFO +1 -1
  2. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/__init__.py +1 -1
  3. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/check.py +2 -17
  4. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/main.py +32 -6
  5. calkit_python-0.30.0/calkit/cli/slurm.py +344 -0
  6. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/config.py +10 -5
  7. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/core.py +14 -0
  8. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/models/pipeline.py +63 -0
  9. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/models/test_pipeline.py +37 -4
  10. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/test_pipeline.py +37 -0
  11. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/environments.md +17 -2
  12. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/pipeline/index.md +34 -1
  13. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/pipeline/running-and-logging.md +1 -0
  14. calkit_python-0.30.0/docs/pipeline/slurm.md +63 -0
  15. {calkit_python-0.29.2 → calkit_python-0.30.0}/mkdocs.yml +1 -0
  16. {calkit_python-0.29.2 → calkit_python-0.30.0}/.github/FUNDING.yml +0 -0
  17. {calkit_python-0.29.2 → calkit_python-0.30.0}/.github/workflows/docs.yml +0 -0
  18. {calkit_python-0.29.2 → calkit_python-0.30.0}/.github/workflows/format.yml +0 -0
  19. {calkit_python-0.29.2 → calkit_python-0.30.0}/.github/workflows/publish-test.yml +0 -0
  20. {calkit_python-0.29.2 → calkit_python-0.30.0}/.github/workflows/publish.yml +0 -0
  21. {calkit_python-0.29.2 → calkit_python-0.30.0}/.github/workflows/test.yml +0 -0
  22. {calkit_python-0.29.2 → calkit_python-0.30.0}/.gitignore +0 -0
  23. {calkit_python-0.29.2 → calkit_python-0.30.0}/.pre-commit-config.yaml +0 -0
  24. {calkit_python-0.29.2 → calkit_python-0.30.0}/.python-version +0 -0
  25. {calkit_python-0.29.2 → calkit_python-0.30.0}/CITATION.cff +0 -0
  26. {calkit_python-0.29.2 → calkit_python-0.30.0}/CONTRIBUTING.md +0 -0
  27. {calkit_python-0.29.2 → calkit_python-0.30.0}/LICENSE +0 -0
  28. {calkit_python-0.29.2 → calkit_python-0.30.0}/Makefile +0 -0
  29. {calkit_python-0.29.2 → calkit_python-0.30.0}/README.md +0 -0
  30. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/__main__.py +0 -0
  31. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/calc.py +0 -0
  32. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/check.py +0 -0
  33. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/__init__.py +0 -0
  34. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/cloud.py +0 -0
  35. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/config.py +0 -0
  36. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/core.py +0 -0
  37. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/describe.py +0 -0
  38. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/import_.py +0 -0
  39. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/list.py +0 -0
  40. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/new.py +0 -0
  41. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/notebooks.py +0 -0
  42. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/office.py +0 -0
  43. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/overleaf.py +0 -0
  44. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cli/update.py +0 -0
  45. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/cloud.py +0 -0
  46. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/conda.py +0 -0
  47. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/datasets.py +0 -0
  48. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/docker.py +0 -0
  49. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/dvc.py +0 -0
  50. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/environments.py +0 -0
  51. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/git.py +0 -0
  52. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/github.py +0 -0
  53. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/gui.py +0 -0
  54. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/jupyter.py +0 -0
  55. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/magics.py +0 -0
  56. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/matlab.py +0 -0
  57. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/models/__init__.py +0 -0
  58. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/models/core.py +0 -0
  59. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/models/io.py +0 -0
  60. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/models/iteration.py +0 -0
  61. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/notebooks.py +0 -0
  62. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/office.py +0 -0
  63. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/ops.py +0 -0
  64. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/pipeline.py +0 -0
  65. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/releases.py +0 -0
  66. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/server.py +0 -0
  67. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/templates/__init__.py +0 -0
  68. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/templates/core.py +0 -0
  69. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/templates/latex/__init__.py +0 -0
  70. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/templates/latex/article/paper.tex +0 -0
  71. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/templates/latex/core.py +0 -0
  72. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/templates/latex/jfm/jfm.bst +0 -0
  73. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/templates/latex/jfm/jfm.cls +0 -0
  74. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/templates/latex/jfm/lineno-FLM.sty +0 -0
  75. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/templates/latex/jfm/paper.tex +0 -0
  76. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/templates/latex/jfm/upmath.sty +0 -0
  77. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/__init__.py +0 -0
  78. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/cli/__init__.py +0 -0
  79. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/cli/test_check.py +0 -0
  80. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/cli/test_config.py +0 -0
  81. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/cli/test_list.py +0 -0
  82. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/cli/test_main.py +0 -0
  83. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/cli/test_new.py +0 -0
  84. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/cli/test_notebooks.py +0 -0
  85. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/models/__init__.py +0 -0
  86. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/models/test_iteration.py +0 -0
  87. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/test_calc.py +0 -0
  88. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/test_check.py +0 -0
  89. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/test_conda.py +0 -0
  90. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/test_core.py +0 -0
  91. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/test_dvc.py +0 -0
  92. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/test_jupyter.py +0 -0
  93. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/test_magics.py +0 -0
  94. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/test_notebooks.py +0 -0
  95. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/tests/test_templates.py +0 -0
  96. {calkit_python-0.29.2 → calkit_python-0.30.0}/calkit/zenodo.py +0 -0
  97. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/CNAME +0 -0
  98. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/apps.md +0 -0
  99. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/calculations.md +0 -0
  100. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/calkit-yaml.md +0 -0
  101. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/cli-reference.md +0 -0
  102. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/cloud-integration.md +0 -0
  103. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/datasets.md +0 -0
  104. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/dependencies.md +0 -0
  105. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/examples.md +0 -0
  106. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/help.md +0 -0
  107. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/img/c-to-the-k-white.svg +0 -0
  108. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/img/calkit-no-bg.png +0 -0
  109. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/img/connect-zenodo.png +0 -0
  110. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/img/jupyterlab-params.png +0 -0
  111. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/img/vscode-nb-params.png +0 -0
  112. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/index.md +0 -0
  113. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/installation.md +0 -0
  114. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/local-server.md +0 -0
  115. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/notebooks.md +0 -0
  116. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/overleaf.md +0 -0
  117. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/pipeline/manual-steps.md +0 -0
  118. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/quickstart.md +0 -0
  119. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/references.md +0 -0
  120. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/releases.md +0 -0
  121. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/adding-latex-pub-docker.md +0 -0
  122. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/conda-envs.md +0 -0
  123. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/existing-project.md +0 -0
  124. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/first-project.md +0 -0
  125. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/github-actions.md +0 -0
  126. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/actions-repo-secrets.png +0 -0
  127. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/building-codespace.png +0 -0
  128. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/codespaces-secrets-2.png +0 -0
  129. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/editor-split.png +0 -0
  130. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/go-to-linked-code.png +0 -0
  131. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/issue-from-selection.png +0 -0
  132. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/new-project.png +0 -0
  133. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/new-pub-2.png +0 -0
  134. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/new-token.png +0 -0
  135. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/paper.tex.png +0 -0
  136. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/project-home-3.png +0 -0
  137. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/push.png +0 -0
  138. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/latex-codespaces/stage.png +0 -0
  139. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/anakin-excel.jpg +0 -0
  140. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/chart-more-rows.png +0 -0
  141. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/create-project.png +0 -0
  142. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/elsevier-research-data-guidelines.png +0 -0
  143. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/excel-chart.png +0 -0
  144. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/excel-data.png +0 -0
  145. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/insert-link-to-file.png +0 -0
  146. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/needs-clone.png +0 -0
  147. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/new-stage.png +0 -0
  148. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/phd-comics-version-control.webp +0 -0
  149. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/pipeline-out-of-date.png +0 -0
  150. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/status-more-rows.png +0 -0
  151. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/uncommitted-changes.png +0 -0
  152. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/untracked-data.png +0 -0
  153. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/updated-publication.png +0 -0
  154. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/word-to-pdf-stage-2.png +0 -0
  155. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/office/workflow-page.png +0 -0
  156. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/openfoam/clone.png +0 -0
  157. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/openfoam/create-project.png +0 -0
  158. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/openfoam/datasets-page.png +0 -0
  159. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/openfoam/figure-on-website-updated.png +0 -0
  160. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/openfoam/figure-on-website.png +0 -0
  161. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/openfoam/new-token.png +0 -0
  162. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/openfoam/reclone.png +0 -0
  163. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/openfoam/status-after-import-dataset.png +0 -0
  164. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/quick-actions.png +0 -0
  165. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/img/run-proc.png +0 -0
  166. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/index.md +0 -0
  167. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/latex-codespaces.md +0 -0
  168. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/matlab.md +0 -0
  169. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/notebook-pipeline.md +0 -0
  170. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/office.md +0 -0
  171. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/openfoam.md +0 -0
  172. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/tutorials/procedures.md +0 -0
  173. {calkit_python-0.29.2 → calkit_python-0.30.0}/docs/version-control.md +0 -0
  174. {calkit_python-0.29.2 → calkit_python-0.30.0}/pyproject.toml +0 -0
  175. {calkit_python-0.29.2 → calkit_python-0.30.0}/scripts/install.ps1 +0 -0
  176. {calkit_python-0.29.2 → calkit_python-0.30.0}/scripts/install.sh +0 -0
  177. {calkit_python-0.29.2 → calkit_python-0.30.0}/test/nb-params.ipynb +0 -0
  178. {calkit_python-0.29.2 → calkit_python-0.30.0}/test/nb-subdir.ipynb +0 -0
  179. {calkit_python-0.29.2 → calkit_python-0.30.0}/test/pipeline.ipynb +0 -0
  180. {calkit_python-0.29.2 → calkit_python-0.30.0}/test/test-log.log +0 -0
  181. {calkit_python-0.29.2 → calkit_python-0.30.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calkit-python
3
- Version: 0.29.2
3
+ Version: 0.30.0
4
4
  Summary: Reproducibility simplified.
5
5
  Project-URL: Homepage, https://calkit.org
6
6
  Project-URL: Issues, https://github.com/calkit/calkit/issues
@@ -1,4 +1,4 @@
1
- __version__ = "0.29.2"
1
+ __version__ = "0.30.0"
2
2
 
3
3
  from .core import * # noqa: F403, I001
4
4
  from . import git # noqa: F401
@@ -3,21 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import functools
6
- import hashlib
7
6
  import json
8
7
  import os
9
8
  import platform as _platform
10
9
  import subprocess
11
- import warnings
12
10
  from typing import Annotated
13
11
 
14
- from calkit.environments import get_env_lock_fpath
15
-
16
- # See https://github.com/calkit/calkit/issues/346
17
- with warnings.catch_warnings():
18
- warnings.filterwarnings("ignore", category=UserWarning)
19
- import checksumdir
20
-
21
12
  import dotenv
22
13
  import git
23
14
  import typer
@@ -27,6 +18,8 @@ import calkit.matlab
27
18
  import calkit.pipeline
28
19
  from calkit.check import check_reproducibility
29
20
  from calkit.cli import raise_error, warn
21
+ from calkit.core import get_md5
22
+ from calkit.environments import get_env_lock_fpath
30
23
 
31
24
  check_app = typer.Typer(no_args_is_help=True)
32
25
 
@@ -288,14 +281,6 @@ def check_docker_env(
288
281
  resp[key] = out[0].get(key)
289
282
  return resp
290
283
 
291
- def get_md5(path: str, exclude_files: list[str] | None = None) -> str:
292
- if os.path.isdir(path):
293
- return checksumdir.dirhash(dep, excluded_files=exclude_files)
294
- else:
295
- with open(path) as f:
296
- content = f.read()
297
- return hashlib.md5(content.encode()).hexdigest()
298
-
299
284
  outfile = open(os.devnull, "w") if quiet else None
300
285
  typer.echo(f"Checking for existing image with tag {tag}", file=outfile)
301
286
  # First call Docker inspect
@@ -51,6 +51,7 @@ from calkit.cli.new import new_app
51
51
  from calkit.cli.notebooks import notebooks_app
52
52
  from calkit.cli.office import office_app
53
53
  from calkit.cli.overleaf import overleaf_app
54
+ from calkit.cli.slurm import slurm_app
54
55
  from calkit.cli.update import update_app
55
56
  from calkit.environments import get_env_lock_fpath
56
57
  from calkit.models import Procedure
@@ -77,6 +78,7 @@ app.add_typer(update_app, name="update", help="Update objects.")
77
78
  app.add_typer(check_app, name="check", help="Check things.")
78
79
  app.add_typer(overleaf_app, name="overleaf", help="Interact with Overleaf.")
79
80
  app.add_typer(cloud_app, name="cloud", help="Interact with a Calkit Cloud.")
81
+ app.add_typer(slurm_app, name="slurm", help="Work with SLURM.")
80
82
 
81
83
  # Constants for version control auto-ignore
82
84
  AUTO_IGNORE_SUFFIXES = [".DS_Store", ".env", ".pyc", ".synctex.gz"]
@@ -187,8 +189,11 @@ def clone(
187
189
  no_dvc_pull: Annotated[
188
190
  bool, typer.Option("--no-dvc-pull", help="Do not pull DVC objects.")
189
191
  ] = False,
190
- recursive: Annotated[
191
- bool, typer.Option("--recursive", help="Recursively clone submodules.")
192
+ non_recursive: Annotated[
193
+ bool,
194
+ typer.Option(
195
+ "--no-recursive", help="Do not recursively clone submodules."
196
+ ),
192
197
  ] = False,
193
198
  ):
194
199
  """Clone a Git repo and by default configure and pull from the DVC
@@ -219,7 +224,7 @@ def clone(
219
224
  url = url.replace("https://github.com/", "git@github.com:")
220
225
  # Git clone
221
226
  cmd = ["git", "clone", url]
222
- if recursive:
227
+ if not non_recursive:
223
228
  cmd.append("--recursive")
224
229
  if location is not None:
225
230
  cmd.append(location)
@@ -552,6 +557,9 @@ def save(
552
557
  help="Additional DVC args to pass when pushing.",
553
558
  ),
554
559
  ] = [],
560
+ no_recursive: Annotated[
561
+ bool, typer.Option("--no-recursive", help="Do not push to submodules.")
562
+ ] = False,
555
563
  verbose: Annotated[
556
564
  bool, typer.Option("--verbose", "-v", help="Print verbose output.")
557
565
  ] = False,
@@ -606,7 +614,10 @@ def save(
606
614
  if verbose and not any_dvc:
607
615
  typer.echo("Not pushing to DVC since no DVC files were staged")
608
616
  push(
609
- no_dvc=not any_dvc, git_args=git_push_args, dvc_args=dvc_push_args
617
+ no_dvc=not any_dvc,
618
+ git_args=git_push_args,
619
+ dvc_args=dvc_push_args,
620
+ no_recursive=no_recursive,
610
621
  )
611
622
 
612
623
 
@@ -629,6 +640,12 @@ def pull(
629
640
  help="Force pull, potentially overwriting local changes.",
630
641
  ),
631
642
  ] = False,
643
+ no_recursive: Annotated[
644
+ bool,
645
+ typer.Option(
646
+ "--no-recursive", help="Do not recursively pull from submodules."
647
+ ),
648
+ ] = False,
632
649
  ):
633
650
  """Pull with both Git and DVC."""
634
651
  typer.echo("Git pulling")
@@ -638,7 +655,10 @@ def pull(
638
655
  if "-f" not in dvc_args and "--force" not in dvc_args:
639
656
  dvc_args.append("-f")
640
657
  try:
641
- subprocess.check_call(["git", "pull"] + git_args)
658
+ git_cmd = ["git", "pull"]
659
+ if not no_recursive and "--recurse-submodules" not in git_args:
660
+ git_cmd.append("--recurse-submodules")
661
+ subprocess.check_call(git_cmd + git_args)
642
662
  except subprocess.CalledProcessError:
643
663
  raise_error("Git pull failed")
644
664
  typer.echo("DVC pulling")
@@ -667,11 +687,17 @@ def push(
667
687
  list[str],
668
688
  typer.Option("--dvc-arg", help="Additional DVC args."),
669
689
  ] = [],
690
+ no_recursive: Annotated[
691
+ bool, typer.Option("--no-recursive", help="Do not push to submodules.")
692
+ ] = False,
670
693
  ):
671
694
  """Push with both Git and DVC."""
672
695
  typer.echo("Pushing to Git remote")
673
696
  try:
674
- subprocess.check_call(["git", "push"] + git_args)
697
+ git_cmd = ["git", "push"]
698
+ if not no_recursive and "--recurse-submodules" not in git_args:
699
+ git_cmd.append("--recurse-submodules=on-demand")
700
+ subprocess.check_call(git_cmd + git_args)
675
701
  except subprocess.CalledProcessError:
676
702
  raise_error("Git push failed")
677
703
  if not no_dvc:
@@ -0,0 +1,344 @@
1
+ """CLI for working with SLURM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import socket
9
+ import subprocess
10
+ import time
11
+
12
+ import typer
13
+ from typing_extensions import Annotated
14
+
15
+ import calkit
16
+ from calkit.cli import raise_error
17
+
18
+ slurm_app = typer.Typer(no_args_is_help=True)
19
+
20
+
21
+ @slurm_app.command(name="batch")
22
+ def run_sbatch(
23
+ name: Annotated[
24
+ str,
25
+ typer.Option("--name", "-n", help="Job name."),
26
+ ],
27
+ script: Annotated[
28
+ str,
29
+ typer.Argument(help="Path to the SLURM script to run."),
30
+ ],
31
+ environment: Annotated[
32
+ str,
33
+ typer.Option(
34
+ "--environment",
35
+ "-e",
36
+ help="Calkit (slurm) environment to use for the job.",
37
+ ),
38
+ ],
39
+ args: Annotated[
40
+ list[str] | None,
41
+ typer.Argument(
42
+ help=(
43
+ "Arguments for sbatch, the first of which should be the "
44
+ "script."
45
+ )
46
+ ),
47
+ ] = None,
48
+ deps: Annotated[
49
+ list[str],
50
+ typer.Option(
51
+ "--dep",
52
+ "-d",
53
+ help=(
54
+ "Additional dependencies to track, which if changed signify"
55
+ " a job is invalid."
56
+ ),
57
+ ),
58
+ ] = [],
59
+ outs: Annotated[
60
+ list[str],
61
+ typer.Option(
62
+ "--out",
63
+ "-o",
64
+ help=(
65
+ "Non-persistent output files or directories produced by the "
66
+ "job, which will be deleted before submitting a new job."
67
+ ),
68
+ ),
69
+ ] = [],
70
+ sbatch_opts: Annotated[
71
+ list[str],
72
+ typer.Option(
73
+ "--sbatch-option",
74
+ "-s",
75
+ help="Additional options to pass to sbatch (no spaces allowed).",
76
+ ),
77
+ ] = [],
78
+ ) -> None:
79
+ """Submit a SLURM batch job for the project.
80
+
81
+ Duplicates are not allowed, so if one is already running or queued with
82
+ the same name, we'll wait for it to finish. The only exception is if the
83
+ dependencies have changed, in which case any queued or running jobs will
84
+ be cancelled and a new one submitted.
85
+ """
86
+
87
+ def check_job_running_or_queued(job_id: str) -> bool:
88
+ p = subprocess.run(
89
+ ["squeue", "--job", job_id], capture_output=True, text=True
90
+ )
91
+ if p.returncode != 0:
92
+ return False
93
+ return len(p.stdout.strip().split("\n")) > 1
94
+
95
+ def cancel_job(job_id: str, reason: str) -> None:
96
+ typer.echo(f"{reason}; canceling existing job ID {job_id}")
97
+ p = subprocess.run(
98
+ ["scancel", job_id], capture_output=True, text=True, check=False
99
+ )
100
+ if p.returncode != 0:
101
+ raise_error(
102
+ f"Failed to cancel existing job ID {job_id}: {p.stderr}"
103
+ )
104
+
105
+ if args is None:
106
+ args = []
107
+ cmd = (
108
+ [
109
+ "sbatch",
110
+ "--parsable",
111
+ "--job-name",
112
+ name,
113
+ "-o",
114
+ ".calkit/slurm/logs/%j.out",
115
+ ]
116
+ + sbatch_opts
117
+ + [script]
118
+ + args
119
+ )
120
+ if not os.path.isfile(script):
121
+ raise_error(f"SLURM script '{script}' does not exist")
122
+ if environment != "_system":
123
+ ck_info = calkit.load_calkit_info()
124
+ env = ck_info.get("environments", {}).get(environment, {})
125
+ env_kind = env.get("kind")
126
+ if env_kind != "slurm":
127
+ raise_error(
128
+ f"Environment '{environment}' is not a slurm environment"
129
+ )
130
+ # Check host matches
131
+ env_host = env.get("host", "localhost")
132
+ if env_host != "localhost" and env_host != socket.gethostname():
133
+ raise_error(
134
+ f"Environment '{environment}' is for host '{env_host}', but "
135
+ f"this is '{socket.gethostname()}'"
136
+ )
137
+ deps = [script] + deps
138
+ slurm_dir = os.path.join(".calkit", "slurm")
139
+ logs_dir = os.path.join(slurm_dir, "logs")
140
+ os.makedirs(logs_dir, exist_ok=True)
141
+ jobs_path = os.path.join(slurm_dir, "jobs.json")
142
+ if os.path.isfile(jobs_path):
143
+ with open(jobs_path, "r") as f:
144
+ jobs = json.load(f)
145
+ else:
146
+ jobs = {}
147
+ typer.echo("Computing MD5s for dependencies")
148
+ current_dep_md5s = {}
149
+ for dep in deps:
150
+ if not os.path.exists(dep):
151
+ raise_error(f"Dependency path '{dep}' does not exist.")
152
+ current_dep_md5s[dep] = calkit.get_md5(dep)
153
+ # See if there is a job with this name
154
+ if name in jobs:
155
+ job_info = jobs[name]
156
+ job_id = job_info["job_id"]
157
+ job_deps = job_info["deps"]
158
+ job_args = job_info.get("args", [])
159
+ running_or_queued = check_job_running_or_queued(job_id)
160
+ should_wait = True
161
+ if running_or_queued:
162
+ typer.echo(
163
+ f"Job '{name}' with is already running or queued with ID "
164
+ f"{job_id}"
165
+ )
166
+ # Check if args have changed
167
+ if job_args != args:
168
+ should_wait = False
169
+ cancel_job(
170
+ job_id=job_id,
171
+ reason=f"Arguments for job '{name}' have changed",
172
+ )
173
+ # Check if dependency paths have changed
174
+ if set(job_deps) != set(deps):
175
+ should_wait = False
176
+ cancel_job(
177
+ job_id=job_id,
178
+ reason=f"Dependencies for job '{name}' have changed",
179
+ )
180
+ # Check dependency hashes
181
+ job_dep_md5s = job_info.get("dep_md5s", {})
182
+ for dep_path, md5 in current_dep_md5s.items():
183
+ job_md5 = job_dep_md5s.get(dep_path)
184
+ if md5 != job_md5:
185
+ should_wait = False
186
+ cancel_job(
187
+ job_id=job_id,
188
+ reason=(
189
+ f"Dependency '{dep_path}' for job '{name}' has "
190
+ "changed"
191
+ ),
192
+ )
193
+ break
194
+ # Wait for the job to finish if it's running or queued and valid
195
+ if should_wait:
196
+ typer.echo("Waiting for job to finish")
197
+ while running_or_queued and should_wait:
198
+ running_or_queued = check_job_running_or_queued(job_id)
199
+ time.sleep(1)
200
+ if should_wait:
201
+ raise typer.Exit(0)
202
+ # Job is not running or queued, so we can submit
203
+ # But first, delete any non-persistent outputs
204
+ for out in outs:
205
+ if os.path.exists(out):
206
+ typer.echo(f"Deleting output path '{out}'")
207
+ try:
208
+ if os.path.isfile(out):
209
+ os.remove(out)
210
+ else:
211
+ shutil.rmtree(out)
212
+ except Exception as e:
213
+ raise_error(f"Error deleting '{out}': {e}")
214
+ p = subprocess.run(cmd, capture_output=True, check=False, text=True)
215
+ if p.returncode != 0:
216
+ raise_error(f"Failed to submit new job: {p.stderr}")
217
+ job_id = p.stdout.strip()
218
+ typer.echo(f"Submitted job with ID: {job_id}")
219
+ new_job = {
220
+ "job_id": job_id,
221
+ "deps": deps,
222
+ "args": args,
223
+ "dep_md5s": current_dep_md5s,
224
+ }
225
+ jobs[name] = new_job
226
+ with open(jobs_path, "w") as f:
227
+ json.dump(jobs, f, indent=4)
228
+ # Now wait for job to finish
229
+ typer.echo("Waiting for job to finish")
230
+ running_or_queued = True
231
+ while running_or_queued:
232
+ running_or_queued = check_job_running_or_queued(job_id)
233
+ time.sleep(1)
234
+
235
+
236
+ @slurm_app.command(name="queue")
237
+ def get_queue() -> None:
238
+ """List SLURM jobs submitted via Calkit."""
239
+ slurm_dir = os.path.join(".calkit", "slurm")
240
+ jobs_path = os.path.join(slurm_dir, "jobs.json")
241
+ if os.path.isfile(jobs_path):
242
+ with open(jobs_path, "r") as f:
243
+ jobs = json.load(f)
244
+ else:
245
+ jobs = {}
246
+ if len(jobs) == 0:
247
+ typer.echo("No jobs found for this project")
248
+ raise typer.Exit(0)
249
+ job_ids = [j["job_id"] for j in jobs.values()]
250
+ subprocess.run(
251
+ ["squeue", "-j", ",".join(job_ids)],
252
+ capture_output=False,
253
+ text=True,
254
+ )
255
+
256
+
257
+ @slurm_app.command(name="cancel")
258
+ def cancel_jobs(
259
+ names: Annotated[
260
+ list[str],
261
+ typer.Argument(help="Names of jobs to cancel."),
262
+ ],
263
+ ) -> None:
264
+ """Cancel SLURM jobs by their name in the project."""
265
+ slurm_dir = os.path.join(".calkit", "slurm")
266
+ jobs_path = os.path.join(slurm_dir, "jobs.json")
267
+ if os.path.isfile(jobs_path):
268
+ with open(jobs_path, "r") as f:
269
+ jobs = json.load(f)
270
+ else:
271
+ jobs = {}
272
+ if len(jobs) == 0:
273
+ typer.echo("No jobs found for this project")
274
+ raise typer.Exit(0)
275
+ # Get any job IDs that are actually running or queued
276
+ job_ids = [j["job_id"] for j in jobs.values()]
277
+ p = subprocess.run(
278
+ ["squeue", "-h", "-o", "%A", "-j", ",".join(job_ids)],
279
+ capture_output=True,
280
+ text=True,
281
+ )
282
+ running_or_queued_ids = p.stdout.strip().split("\n")
283
+ running_or_queued_ids = [j for j in running_or_queued_ids if j]
284
+ for name in names:
285
+ if name not in jobs:
286
+ typer.echo(f"No job named '{name}' found for this project")
287
+ continue
288
+ job_info = jobs[name]
289
+ job_id = job_info["job_id"]
290
+ if job_id not in running_or_queued_ids:
291
+ typer.echo(
292
+ f"Job '{name}' (last submitted ID: {job_id}) is not "
293
+ "running or queued"
294
+ )
295
+ continue
296
+ p = subprocess.run(
297
+ ["scancel", job_id], capture_output=True, text=True, check=False
298
+ )
299
+ if p.returncode != 0:
300
+ raise_error(f"Failed to cancel job ID {job_id}: {p.stderr}")
301
+ typer.echo(f"Cancelled job '{name}' with ID {job_id}")
302
+
303
+
304
+ @slurm_app.command(name="logs")
305
+ def get_logs(
306
+ name: Annotated[
307
+ str,
308
+ typer.Argument(help="Name of the job to get logs for."),
309
+ ],
310
+ follow: Annotated[
311
+ bool,
312
+ typer.Option(
313
+ "--follow", "-f", help="Follow the log output like tail -f."
314
+ ),
315
+ ] = False,
316
+ ) -> None:
317
+ """Get the logs for a SLURM job by its name in the project."""
318
+ slurm_dir = os.path.join(".calkit", "slurm")
319
+ jobs_path = os.path.join(slurm_dir, "jobs.json")
320
+ if os.path.isfile(jobs_path):
321
+ with open(jobs_path, "r") as f:
322
+ jobs = json.load(f)
323
+ else:
324
+ jobs = {}
325
+ if len(jobs) == 0:
326
+ typer.echo("No jobs found for this project")
327
+ raise typer.Exit(0)
328
+ if name not in jobs:
329
+ raise_error(f"No job named '{name}' found for this project")
330
+ job_info = jobs[name]
331
+ job_id = job_info["job_id"]
332
+ log_path = os.path.join(slurm_dir, "logs", f"{job_id}.out")
333
+ if not os.path.isfile(log_path):
334
+ raise_error(f"No log file found for job '{name}' with ID {job_id}")
335
+ if follow:
336
+ p = subprocess.Popen(["tail", "-f", log_path])
337
+ try:
338
+ p.wait()
339
+ except KeyboardInterrupt:
340
+ p.terminate()
341
+ raise typer.Exit(0)
342
+ else:
343
+ with open(log_path, "r") as f:
344
+ typer.echo(f.read())
@@ -56,8 +56,11 @@ def supports_keyring() -> bool:
56
56
  KEYRING_SUPPORTED = supports_keyring()
57
57
 
58
58
 
59
- def get_env() -> Literal["local", "staging", "production"]:
60
- return os.getenv("CALKIT_ENV", "production")
59
+ def get_env() -> Literal["test", "local", "staging", "production"]:
60
+ env = os.getenv("CALKIT_ENV", "production")
61
+ if env not in ["test", "local", "staging", "production"]:
62
+ raise ValueError(f"{env} is not a valid environment name")
63
+ return env # type: ignore
61
64
 
62
65
 
63
66
  def set_env(name: Literal["local", "staging", "production"]) -> None:
@@ -184,10 +187,10 @@ class Settings(BaseSettings):
184
187
  dotenv_settings,
185
188
  YamlConfigSettingsSource(settings_cls),
186
189
  KeyringSecretsSource(settings_cls),
187
- )
190
+ ) # type: ignore
188
191
 
189
192
  def write(self) -> None:
190
- base_dir = os.path.dirname(self.model_config["yaml_file"])
193
+ base_dir = os.path.dirname(self.model_config["yaml_file"]) # type: ignore
191
194
  os.makedirs(base_dir, exist_ok=True)
192
195
  cfg = self.model_dump()
193
196
  # Remove anything that should be in the keyring
@@ -205,8 +208,10 @@ class Settings(BaseSettings):
205
208
  except keyring.errors.KeyringError:
206
209
  # Ignore errors when deleting secrets
207
210
  pass
208
- with open(self.model_config["yaml_file"], "w") as f:
211
+ with open(self.model_config["yaml_file"], "w") as f: # type: ignore
209
212
  yaml.safe_dump(cfg, f)
213
+ # Ensure permissions are user read/write only
214
+ os.chmod(self.model_config["yaml_file"], 0o600) # type: ignore
210
215
 
211
216
 
212
217
  def read() -> Settings:
@@ -15,7 +15,12 @@ import re
15
15
  import socket
16
16
  import subprocess
17
17
  import uuid
18
+ import warnings
18
19
 
20
+ # See https://github.com/calkit/calkit/issues/346
21
+ with warnings.catch_warnings():
22
+ warnings.filterwarnings("ignore", category=UserWarning)
23
+ import checksumdir
19
24
  import psutil
20
25
  import requests
21
26
 
@@ -572,3 +577,12 @@ def get_system_info() -> dict:
572
577
  system_info_str = json.dumps(system_info, sort_keys=True).encode()
573
578
  system_info["id"] = hashlib.sha1(system_info_str).hexdigest()
574
579
  return system_info
580
+
581
+
582
+ def get_md5(path: str, exclude_files: list[str] | None = None) -> str:
583
+ if os.path.isdir(path):
584
+ return checksumdir.dirhash(path, excluded_files=exclude_files)
585
+ else:
586
+ with open(path) as f:
587
+ content = f.read()
588
+ return hashlib.md5(content.encode()).hexdigest()
@@ -13,6 +13,7 @@ from pydantic import (
13
13
  Discriminator,
14
14
  ValidationError,
15
15
  field_validator,
16
+ model_validator,
16
17
  )
17
18
  from typing_extensions import Annotated
18
19
 
@@ -109,6 +110,7 @@ class StageIteration(BaseModel):
109
110
  class Stage(BaseModel):
110
111
  """A stage in the pipeline."""
111
112
 
113
+ name: str | None = None
112
114
  kind: Literal[
113
115
  "python-script",
114
116
  "latex",
@@ -366,6 +368,54 @@ class JuliaCommandStage(Stage):
366
368
  return cmd
367
369
 
368
370
 
371
+ class SBatchStage(Stage):
372
+ kind: Literal["sbatch"] = "sbatch"
373
+ script_path: str
374
+ args: list[str] = []
375
+ sbatch_options: list[str] = []
376
+
377
+ @property
378
+ def dvc_deps(self) -> list[str]:
379
+ return [self.script_path] + super().dvc_deps
380
+
381
+ @property
382
+ def dvc_outs(self) -> list[str | dict]:
383
+ # All outputs must be persistent, since ``calkit slurm batch``
384
+ # handles deletion
385
+ outs = super().dvc_outs
386
+ final_outs = []
387
+ for out in outs:
388
+ if isinstance(out, str):
389
+ final_outs.append({out: {"persist": True}})
390
+ elif isinstance(out, dict):
391
+ k = list(out.keys())[0]
392
+ v = out[k]
393
+ v["persist"] = True
394
+ final_outs.append({k: v})
395
+ return final_outs
396
+
397
+ @property
398
+ def dvc_cmd(self) -> str:
399
+ cmd = f"calkit slurm batch --name {self.name}"
400
+ if self.environment != "_system":
401
+ cmd += f" --environment {self.environment}"
402
+ for dep in self.dvc_deps:
403
+ if dep != self.script_path:
404
+ cmd += f" --dep {dep}"
405
+ for out in self.outputs:
406
+ # Determine if this is a non-persistent output
407
+ if isinstance(out, str):
408
+ cmd += f" --out {out}"
409
+ elif isinstance(out, PathOutput) and out.delete_before_run:
410
+ cmd += f" --out {out.path}"
411
+ for opt in self.sbatch_options:
412
+ cmd += f" -s {opt}"
413
+ cmd += f" -- {self.script_path}"
414
+ for arg in self.args:
415
+ cmd += f" {arg}"
416
+ return cmd
417
+
418
+
369
419
  class JupyterNotebookStage(Stage):
370
420
  """A stage that runs a Jupyter notebook.
371
421
 
@@ -565,9 +615,22 @@ class Pipeline(BaseModel):
565
615
  | JupyterNotebookStage
566
616
  | JuliaScriptStage
567
617
  | JuliaCommandStage
618
+ | SBatchStage
568
619
  ),
569
620
  Discriminator("kind"),
570
621
  ],
571
622
  ]
572
623
  # Do not allow extra keys
573
624
  model_config = ConfigDict(extra="forbid")
625
+
626
+ @model_validator(mode="after")
627
+ def set_stage_names(self):
628
+ """Set the name field of each stage to match its key in the dict."""
629
+ for stage_name, stage in self.stages.items():
630
+ if stage.name is not None and stage.name != stage_name:
631
+ raise ValueError(
632
+ f"Stage name '{stage.name}' does not match key "
633
+ f"'{stage_name}'"
634
+ )
635
+ stage.name = stage_name
636
+ return self