calkit-python 0.16.2__tar.gz → 0.17.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 (128) hide show
  1. {calkit_python-0.16.2 → calkit_python-0.17.0}/.gitignore +1 -0
  2. {calkit_python-0.16.2 → calkit_python-0.17.0}/PKG-INFO +2 -1
  3. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/__init__.py +1 -1
  4. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cli/check.py +30 -4
  5. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cli/main.py +133 -11
  6. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/conda.py +12 -3
  7. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/models.py +15 -1
  8. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/cli/test_main.py +27 -0
  9. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/environments.md +73 -0
  10. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/examples.md +11 -0
  11. {calkit_python-0.16.2 → calkit_python-0.17.0}/pyproject.toml +1 -0
  12. calkit_python-0.17.0/uv.lock +3735 -0
  13. {calkit_python-0.16.2 → calkit_python-0.17.0}/.github/FUNDING.yml +0 -0
  14. {calkit_python-0.16.2 → calkit_python-0.17.0}/.github/workflows/docs.yml +0 -0
  15. {calkit_python-0.16.2 → calkit_python-0.17.0}/.github/workflows/publish-test.yml +0 -0
  16. {calkit_python-0.16.2 → calkit_python-0.17.0}/.github/workflows/publish.yml +0 -0
  17. {calkit_python-0.16.2 → calkit_python-0.17.0}/LICENSE +0 -0
  18. {calkit_python-0.16.2 → calkit_python-0.17.0}/README.md +0 -0
  19. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/calc.py +0 -0
  20. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/check.py +0 -0
  21. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cli/__init__.py +0 -0
  22. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cli/config.py +0 -0
  23. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cli/core.py +0 -0
  24. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cli/import_.py +0 -0
  25. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cli/list.py +0 -0
  26. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cli/new.py +0 -0
  27. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cli/notebooks.py +0 -0
  28. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cli/office.py +0 -0
  29. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cli/update.py +0 -0
  30. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/cloud.py +0 -0
  31. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/config.py +0 -0
  32. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/core.py +0 -0
  33. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/datasets.py +0 -0
  34. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/docker.py +0 -0
  35. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/dvc.py +0 -0
  36. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/git.py +0 -0
  37. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/gui.py +0 -0
  38. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/jupyter.py +0 -0
  39. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/magics.py +0 -0
  40. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/office.py +0 -0
  41. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/ops.py +0 -0
  42. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/server.py +0 -0
  43. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/templates/__init__.py +0 -0
  44. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/templates/core.py +0 -0
  45. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/templates/latex/__init__.py +0 -0
  46. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/templates/latex/article/paper.tex +0 -0
  47. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/templates/latex/core.py +0 -0
  48. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/templates/latex/jfm/jfm.bst +0 -0
  49. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/templates/latex/jfm/jfm.cls +0 -0
  50. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/templates/latex/jfm/lineno-FLM.sty +0 -0
  51. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/templates/latex/jfm/paper.tex +0 -0
  52. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/templates/latex/jfm/upmath.sty +0 -0
  53. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/__init__.py +0 -0
  54. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/cli/__init__.py +0 -0
  55. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/cli/test_list.py +0 -0
  56. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/cli/test_new.py +0 -0
  57. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/test_calc.py +0 -0
  58. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/test_check.py +0 -0
  59. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/test_conda.py +0 -0
  60. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/test_core.py +0 -0
  61. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/test_dvc.py +0 -0
  62. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/test_jupyter.py +0 -0
  63. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/test_magics.py +0 -0
  64. {calkit_python-0.16.2 → calkit_python-0.17.0}/calkit/tests/test_templates.py +0 -0
  65. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/CNAME +0 -0
  66. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/apps.md +0 -0
  67. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/calculations.md +0 -0
  68. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/calkit-yaml.md +0 -0
  69. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/cli-reference.md +0 -0
  70. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/cloud-integration.md +0 -0
  71. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/help.md +0 -0
  72. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/img/c-to-the-k-white.svg +0 -0
  73. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/img/calkit-no-bg.png +0 -0
  74. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/index.md +0 -0
  75. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/installation.md +0 -0
  76. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/pipeline/index.md +0 -0
  77. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/pipeline/manual-steps.md +0 -0
  78. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/references.md +0 -0
  79. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/adding-latex-pub-docker.md +0 -0
  80. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/conda-envs.md +0 -0
  81. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/first-project.md +0 -0
  82. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/building-codespace.png +0 -0
  83. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/codespaces-secrets-2.png +0 -0
  84. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/editor-split.png +0 -0
  85. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/go-to-linked-code.png +0 -0
  86. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/issue-from-selection.png +0 -0
  87. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/new-project.png +0 -0
  88. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/new-pub-2.png +0 -0
  89. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/new-token.png +0 -0
  90. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/paper.tex.png +0 -0
  91. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/project-home-3.png +0 -0
  92. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/push.png +0 -0
  93. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/latex-codespaces/stage.png +0 -0
  94. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/anakin-excel.jpg +0 -0
  95. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/chart-more-rows.png +0 -0
  96. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/create-project.png +0 -0
  97. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/elsevier-research-data-guidelines.png +0 -0
  98. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/excel-chart.png +0 -0
  99. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/excel-data.png +0 -0
  100. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/insert-link-to-file.png +0 -0
  101. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/needs-clone.png +0 -0
  102. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/new-stage.png +0 -0
  103. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/phd-comics-version-control.webp +0 -0
  104. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/pipeline-out-of-date.png +0 -0
  105. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/status-more-rows.png +0 -0
  106. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/uncommitted-changes.png +0 -0
  107. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/untracked-data.png +0 -0
  108. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/updated-publication.png +0 -0
  109. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/word-to-pdf-stage-2.png +0 -0
  110. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/office/workflow-page.png +0 -0
  111. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/openfoam/clone.png +0 -0
  112. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/openfoam/create-project.png +0 -0
  113. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/openfoam/datasets-page.png +0 -0
  114. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/openfoam/figure-on-website-updated.png +0 -0
  115. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/openfoam/figure-on-website.png +0 -0
  116. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/openfoam/new-token.png +0 -0
  117. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/openfoam/reclone.png +0 -0
  118. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/openfoam/status-after-import-dataset.png +0 -0
  119. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/img/run-proc.png +0 -0
  120. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/latex-codespaces.md +0 -0
  121. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/matlab.md +0 -0
  122. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/notebook-pipeline.md +0 -0
  123. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/office.md +0 -0
  124. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/openfoam.md +0 -0
  125. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/tutorials/procedures.md +0 -0
  126. {calkit_python-0.16.2 → calkit_python-0.17.0}/docs/version-control.md +0 -0
  127. {calkit_python-0.16.2 → calkit_python-0.17.0}/mkdocs.yml +0 -0
  128. {calkit_python-0.16.2 → calkit_python-0.17.0}/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.2
3
+ Version: 0.17.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
@@ -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.2"
1
+ __version__ = "0.17.0"
2
2
 
3
3
  from .core import *
4
4
  from . import git
@@ -9,6 +9,7 @@ import os
9
9
  import subprocess
10
10
  from typing import Annotated
11
11
 
12
+ import checksumdir
12
13
  import typer
13
14
 
14
15
  import calkit
@@ -65,6 +66,14 @@ def check_docker_env(
65
66
  platform: Annotated[
66
67
  str, typer.Option("--platform", help="Which platform(s) to build for.")
67
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
+ ] = [],
68
77
  quiet: Annotated[
69
78
  bool, typer.Option("--quiet", "-q", help="Be quiet.")
70
79
  ] = False,
@@ -81,6 +90,14 @@ def check_docker_env(
81
90
  _ = out[0].pop("DockerVersion")
82
91
  return out
83
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
+
84
101
  outfile = open(os.devnull, "w") if quiet else None
85
102
  typer.echo(f"Checking for existing image with tag {tag}", file=outfile)
86
103
  # First call Docker inspect
@@ -90,10 +107,12 @@ def check_docker_env(
90
107
  typer.echo(f"No image with tag {tag} found locally", file=outfile)
91
108
  inspect = []
92
109
  typer.echo(f"Reading Dockerfile from {fpath}", file=outfile)
93
- with open(fpath) as f:
94
- dockerfile = f.read()
95
- dockerfile_md5 = hashlib.md5(dockerfile.encode()).hexdigest()
110
+ dockerfile_md5 = get_md5(fpath)
96
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)
97
116
  rebuild = True
98
117
  if os.path.isfile(lock_fpath):
99
118
  typer.echo(f"Reading lock file: {lock_fpath}", file=outfile)
@@ -109,6 +128,12 @@ def check_docker_env(
109
128
  rebuild = inspect[0]["RootFS"]["Layers"] != lock[0]["RootFS"][
110
129
  "Layers"
111
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
112
137
  if rebuild:
113
138
  wdir, fname = os.path.split(fpath)
114
139
  if not wdir:
@@ -117,10 +142,11 @@ def check_docker_env(
117
142
  if platform is not None:
118
143
  cmd += ["--platform", platform]
119
144
  cmd.append(".")
120
- subprocess.check_call(cmd, cwd=wdir)
145
+ subprocess.check_output(cmd, cwd=wdir)
121
146
  # Write the lock file
122
147
  inspect = get_docker_inspect()
123
148
  inspect[0]["DockerfileMD5"] = dockerfile_md5
149
+ inspect[0]["DepsMD5s"] = deps_md5s
124
150
  with open(lock_fpath, "w") as f:
125
151
  json.dump(inspect, f, indent=4)
126
152
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import csv
6
+ import glob
6
7
  import os
7
8
  import platform as _platform
8
9
  import subprocess
@@ -57,7 +58,8 @@ def _to_shell_cmd(cmd: list[str]) -> str:
57
58
  """
58
59
  quoted_cmd = []
59
60
  for part in cmd:
60
- if " " in part or '"' in part or "'" in part:
61
+ # Find quotes within quotes and escape them
62
+ if " " in part or '"' in part[1:-1] or "'" in part[1:-1]:
61
63
  part = part.replace('"', r"\"")
62
64
  quoted_cmd.append(f'"{part}"')
63
65
  else:
@@ -708,12 +710,11 @@ def run_in_env(
708
710
  if env_name not in envs:
709
711
  raise_error(f"Environment '{env_name}' does not exist")
710
712
  env = envs[env_name]
711
- if wdir is not None:
712
- cwd = os.path.abspath(wdir)
713
- else:
714
- cwd = os.getcwd()
715
713
  image_name = env.get("image", env_name)
716
714
  docker_wdir = env.get("wdir", "/work")
715
+ docker_wdir_mount = docker_wdir
716
+ if wdir is not None:
717
+ docker_wdir = os.path.join(docker_wdir, wdir)
717
718
  shell = env.get("shell", "sh")
718
719
  platform = env.get("platform")
719
720
  if env["kind"] == "docker":
@@ -724,6 +725,7 @@ def run_in_env(
724
725
  tag=env["image"],
725
726
  fpath=env["path"],
726
727
  platform=env.get("platform"),
728
+ deps=env.get("deps", []),
727
729
  quiet=True,
728
730
  )
729
731
  shell_cmd = _to_shell_cmd(cmd)
@@ -733,13 +735,14 @@ def run_in_env(
733
735
  ]
734
736
  if platform:
735
737
  docker_cmd += ["--platform", platform]
738
+ docker_cmd += env.get("args", [])
736
739
  docker_cmd += [
737
740
  "-it" if sys.stdin.isatty() else "-i",
738
741
  "--rm",
739
742
  "-w",
740
743
  docker_wdir,
741
744
  "-v",
742
- f"{cwd}:{docker_wdir}",
745
+ f"{os.getcwd()}:{docker_wdir_mount}",
743
746
  image_name,
744
747
  shell,
745
748
  "-c",
@@ -839,6 +842,117 @@ def run_in_env(
839
842
  subprocess.check_call(cmd, shell=True, cwd=wdir)
840
843
  except subprocess.CalledProcessError:
841
844
  raise_error(f"Failed to run in {kind}")
845
+ elif env["kind"] == "ssh":
846
+ try:
847
+ host = os.path.expandvars(env["host"])
848
+ user = os.path.expandvars(env["user"])
849
+ remote_wdir = env["wdir"]
850
+ except KeyError:
851
+ raise_error(
852
+ "Host, user, and wdir must be defined for ssh environments"
853
+ )
854
+ send_paths = env.get("send_paths")
855
+ get_paths = env.get("get_paths")
856
+ key = env.get("key")
857
+ if key is not None:
858
+ key = os.path.expanduser(os.path.expandvars(key))
859
+ remote_shell_cmd = _to_shell_cmd(cmd)
860
+ # Run with nohup so we can disconnect
861
+ # TODO: Should we collect output instead of send to /dev/null?
862
+ remote_cmd = (
863
+ f"cd '{remote_wdir}' ; nohup {remote_shell_cmd} "
864
+ "> /dev/null 2>&1 & echo $! "
865
+ )
866
+ key_cmd = ["-i", key] if key is not None else []
867
+ # Check to see if we've already submitted a job with this command
868
+ jobs_fpath = ".calkit/jobs.yaml"
869
+ job_key = f"{env_name}::{remote_shell_cmd}"
870
+ remote_pid = None
871
+ if os.path.isfile(jobs_fpath):
872
+ with open(jobs_fpath) as f:
873
+ jobs = calkit.ryaml.load(f)
874
+ if jobs is None:
875
+ jobs = {}
876
+ else:
877
+ jobs = {}
878
+ job = jobs.get(job_key, {})
879
+ remote_pid = job.get("remote_pid")
880
+ if remote_pid is None:
881
+ # First make sure the remote working dir exists
882
+ typer.echo("Ensuring remote working directory exists")
883
+ subprocess.check_call(
884
+ ["ssh"]
885
+ + key_cmd
886
+ + [f"{user}@{host}", f"mkdir -p {remote_wdir}"]
887
+ )
888
+ # Now send any necessary files
889
+ if send_paths:
890
+ typer.echo("Sending to remote directory")
891
+ # Accept glob patterns
892
+ paths = []
893
+ for p in send_paths:
894
+ paths += glob.glob(p)
895
+ scp_cmd = (
896
+ ["scp", "-r"]
897
+ + key_cmd
898
+ + paths
899
+ + [f"{user}@{host}:{remote_wdir}/"]
900
+ )
901
+ if verbose:
902
+ typer.echo(f"scp cmd: {scp_cmd}")
903
+ subprocess.check_call(scp_cmd)
904
+ # Now run the command
905
+ typer.echo(f"Running remote command: {remote_shell_cmd}")
906
+ if verbose:
907
+ typer.echo(f"Full command: {remote_cmd}")
908
+ remote_pid = (
909
+ subprocess.check_output(
910
+ ["ssh"] + key_cmd + [f"{user}@{host}", remote_cmd]
911
+ )
912
+ .decode()
913
+ .strip()
914
+ )
915
+ typer.echo(f"Running with remote PID: {remote_pid}")
916
+ # Save PID to jobs database so we can resume waiting
917
+ typer.echo("Updating jobs database")
918
+ os.makedirs(".calkit", exist_ok=True)
919
+ job["remote_pid"] = remote_pid
920
+ job["submitted"] = time.time()
921
+ job["finished"] = None
922
+ jobs[job_key] = job
923
+ with open(jobs_fpath, "w") as f:
924
+ calkit.ryaml.dump(jobs, f)
925
+ # Now wait for the job to complete
926
+ typer.echo(f"Waiting for remote PID {remote_pid} to finish")
927
+ ps_cmd = ["ssh"] + key_cmd + [f"{user}@{host}", "ps", "-p", remote_pid]
928
+ finished = False
929
+ while not finished:
930
+ try:
931
+ subprocess.check_output(ps_cmd)
932
+ finished = False
933
+ time.sleep(2)
934
+ except subprocess.CalledProcessError:
935
+ finished = True
936
+ typer.echo("Remote process finished")
937
+ # Now sync the files back
938
+ # TODO: Figure out how to do this in one command
939
+ # Getting the syntax right is troublesome since it appears to work
940
+ # differently on different platforms
941
+ if get_paths:
942
+ typer.echo("Copying files back from remote directory")
943
+ for src_path in get_paths:
944
+ src_path = remote_wdir + "/" + src_path
945
+ src = f"{user}@{host}:{src_path}"
946
+ scp_cmd = ["scp", "-r"] + key_cmd + [src, "."]
947
+ subprocess.check_call(scp_cmd)
948
+ # Now delete the remote PID from the jobs file
949
+ typer.echo("Updating jobs database")
950
+ os.makedirs(".calkit", exist_ok=True)
951
+ job["remote_pid"] = None
952
+ job["finished"] = time.time()
953
+ jobs[job_key] = job
954
+ with open(jobs_fpath, "w") as f:
955
+ calkit.ryaml.dump(jobs, f)
842
956
  else:
843
957
  raise_error("Environment kind not supported")
844
958
 
@@ -1041,9 +1155,17 @@ def set_env_var(
1041
1155
  @app.command(name="upgrade")
1042
1156
  def upgrade():
1043
1157
  """Upgrade Calkit."""
1044
- # See if uv is installed first
1045
- if calkit.check_dep_exists("uv"):
1046
- cmd = ["uv", "pip", "install", "--system"]
1158
+ if calkit.check_dep_exists("pipx"):
1159
+ cmd = ["pipx", "upgrade", "calkit-python"]
1160
+ elif calkit.check_dep_exists("uv"):
1161
+ cmd = [
1162
+ "uv",
1163
+ "pip",
1164
+ "install",
1165
+ "--system",
1166
+ "--upgrade",
1167
+ "calkit-python",
1168
+ ]
1047
1169
  else:
1048
- cmd = ["pip", "install"]
1049
- subprocess.run(cmd + ["--upgrade", "calkit-python"])
1170
+ cmd = ["pip", "install", "--upgrade", "calkit-python"]
1171
+ subprocess.run(cmd)
@@ -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
 
@@ -109,6 +109,16 @@ class REnvironment(Environment):
109
109
  prefix: str
110
110
 
111
111
 
112
+ class SSHEnvironment(BaseModel):
113
+ kind: Literal["ssh"]
114
+ host: str
115
+ user: str
116
+ wdir: str
117
+ key: str | None = None
118
+ send_paths: list[str] = ["./*"]
119
+ get_paths: list[str] = ["*"]
120
+
121
+
112
122
  class Software(BaseModel):
113
123
  title: str
114
124
  path: str
@@ -248,7 +258,11 @@ class ProjectInfo(BaseModel):
248
258
  references: list[ReferenceCollection] = []
249
259
  environments: dict[
250
260
  str,
251
- Environment | DockerEnvironment | VenvEnvironment | UvVenvEnvironment,
261
+ Environment
262
+ | DockerEnvironment
263
+ | VenvEnvironment
264
+ | UvVenvEnvironment
265
+ | SSHEnvironment,
252
266
  ] = {}
253
267
  software: list[Software] = []
254
268
  notebooks: list[Notebook] = []
@@ -85,6 +85,26 @@ def test_run_in_env(tmp_dir):
85
85
  ck_info = calkit.load_calkit_info()
86
86
  env = ck_info["environments"]["py3.10"]
87
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")
88
108
 
89
109
 
90
110
  def test_run_in_venv(tmp_dir):
@@ -206,6 +226,13 @@ def test_to_shell_cmd():
206
226
  shell_cmd = _to_shell_cmd(cmd)
207
227
  assert shell_cmd == 'python -c "print(\\"hello world\\")"'
208
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
209
236
 
210
237
 
211
238
  def test_add(tmp_dir):
@@ -23,6 +23,7 @@ Calkit supports defining and running code in these environment types:
23
23
  - [`uv`](https://docs.astral.sh/uv/) (both `venv` and project-based)
24
24
  - [Pixi](https://github.com/prefix-dev/pixi)
25
25
  - [`renv`](https://rstudio.github.io/renv/index.html)
26
+ - `ssh`
26
27
 
27
28
  Environment definitions live in the project's `calkit.yaml` file
28
29
  in the `environments` section.
@@ -154,6 +155,21 @@ and another call to `calkit xenv -n foam2` will kick off a rebuild
154
155
  automatically,
155
156
  since the lock file will no longer match the Dockerfile.
156
157
 
158
+ If you're copying local files into the Docker image,
159
+ you can declare these
160
+ dependencies in the environment definition so the content of those will be
161
+ tracked as well:
162
+
163
+ ```yaml
164
+ # In calkit.yaml
165
+ environments:
166
+ foam2:
167
+ kind: docker
168
+ image: foam2
169
+ deps:
170
+ - src/mySolver.C
171
+ ```
172
+
157
173
  This highlights Calkit's declarative design philosophy.
158
174
  Simply declare the environment and use it in a pipeline stage
159
175
  and Calkit will ensure it is built and up to date.
@@ -238,3 +254,60 @@ and an updated `environment-lock.yml` file will be created.
238
254
  Again this highlights Calkit's declarative design philosophy.
239
255
  Declare the environment and what command should be executed inside,
240
256
  and Calkit will handle the rest.
257
+
258
+ ### SSH
259
+
260
+ It's possible to define a remote environment that uses `ssh` to connect
261
+ and run commands,
262
+ and `scp` to copy files back and forth.
263
+ This could be useful, e.g.,
264
+ for running one or more pipeline stages on a high performance computing (HPC)
265
+ cluster,
266
+ or simply offloading some work to a virtual machine in the cloud
267
+ with specialized hardware like a more powerful GPU.
268
+
269
+ It is assumed that dependencies on the remote machine are managed separately.
270
+
271
+ An SSH environment defined in `calkit.yaml` looks like:
272
+
273
+ ```yaml
274
+ environments:
275
+ cluster:
276
+ kind: ssh
277
+ host: "10.225.22.25"
278
+ user: my-user-name
279
+ wdir: /home/my-user-name/calkit/example-ssh
280
+ key: ~/.ssh/id_ed25519
281
+ send_paths:
282
+ - script.sh
283
+ get_paths:
284
+ - results
285
+ ```
286
+
287
+ In the example above, we define an environment called `cluster`,
288
+ where we specify the host IP address, our username on that machine,
289
+ the working directory, the path to an SSH key on our local machine
290
+ (so we can connect without a password),
291
+ which paths we want to send before executing commands,
292
+ and which we want to copy back after they finish.
293
+ Wildcards in paths are supported, so the entire directory could be copied
294
+ if desired by specifying `*`.
295
+
296
+ To register an SSH key with the host, use `ssh-copy-id`. For example:
297
+
298
+ ```sh
299
+ ssh-copy-id -i ~/.ssh/id_ed25519 my-user-name@10.225.22.25
300
+ ```
301
+
302
+ To execute a command in this environment, we can add a stage like this
303
+ to our DVC pipeline in `dvc.yaml`:
304
+
305
+ ```yaml
306
+ stages:
307
+ run-simulation:
308
+ cmd: calkit xenv -n cluster bash script.sh
309
+ deps:
310
+ - script.sh
311
+ outs:
312
+ - results
313
+ ```
@@ -52,6 +52,7 @@ Features:
52
52
  - Environmental variable dependencies
53
53
  - A pipeline designed to be run periodically to accumulate new data
54
54
  - A project showcase with interactive Plotly figures
55
+ - A uv project-based environment and dedicated Python package
55
56
 
56
57
  ## OpenFOAM RANS boundary later validation
57
58
 
@@ -64,3 +65,13 @@ Features:
64
65
  - A LaTeX document built with a Docker container
65
66
  - A direct numerical simulation dataset for validation imported from a
66
67
  different project, derived from the Johns Hopkins Turbulence Database
68
+
69
+ ## SSH
70
+
71
+ [Project page](https://calkit.io/calkit/example-ssh) |
72
+ [GitHub repo](https://github.com/calkit/example-ssh)
73
+
74
+ Features:
75
+
76
+ - An SSH environment for running a remote command over SSH and copying back
77
+ results to the local machine
@@ -13,6 +13,7 @@ classifiers = [
13
13
  ]
14
14
  dependencies = [
15
15
  "arithmeval",
16
+ "checksumdir",
16
17
  "docx2pdf",
17
18
  "dvc",
18
19
  "eval-type-backport; python_version < '3.10'",