calkit-python 0.17.6__tar.gz → 0.19.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 (132) hide show
  1. {calkit_python-0.17.6 → calkit_python-0.19.0}/PKG-INFO +1 -1
  2. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/__init__.py +1 -1
  3. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cli/config.py +4 -3
  4. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cli/core.py +4 -1
  5. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cli/main.py +28 -0
  6. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cli/new.py +212 -9
  7. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/core.py +32 -0
  8. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/dvc.py +26 -1
  9. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/git.py +7 -3
  10. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/models.py +7 -1
  11. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/cli/test_main.py +22 -0
  12. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/cli/test_new.py +175 -0
  13. calkit_python-0.19.0/docs/tutorials/existing-project.md +552 -0
  14. {calkit_python-0.17.6 → calkit_python-0.19.0}/mkdocs.yml +1 -0
  15. {calkit_python-0.17.6 → calkit_python-0.19.0}/.github/FUNDING.yml +0 -0
  16. {calkit_python-0.17.6 → calkit_python-0.19.0}/.github/workflows/docs.yml +0 -0
  17. {calkit_python-0.17.6 → calkit_python-0.19.0}/.github/workflows/publish-test.yml +0 -0
  18. {calkit_python-0.17.6 → calkit_python-0.19.0}/.github/workflows/publish.yml +0 -0
  19. {calkit_python-0.17.6 → calkit_python-0.19.0}/.gitignore +0 -0
  20. {calkit_python-0.17.6 → calkit_python-0.19.0}/LICENSE +0 -0
  21. {calkit_python-0.17.6 → calkit_python-0.19.0}/README.md +0 -0
  22. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/__main__.py +0 -0
  23. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/calc.py +0 -0
  24. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/check.py +0 -0
  25. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cli/__init__.py +0 -0
  26. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cli/check.py +0 -0
  27. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cli/import_.py +0 -0
  28. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cli/list.py +0 -0
  29. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cli/notebooks.py +0 -0
  30. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cli/office.py +0 -0
  31. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cli/update.py +0 -0
  32. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/cloud.py +0 -0
  33. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/conda.py +0 -0
  34. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/config.py +0 -0
  35. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/datasets.py +0 -0
  36. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/docker.py +0 -0
  37. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/gui.py +0 -0
  38. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/jupyter.py +0 -0
  39. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/magics.py +0 -0
  40. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/office.py +0 -0
  41. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/ops.py +0 -0
  42. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/server.py +0 -0
  43. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/templates/__init__.py +0 -0
  44. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/templates/core.py +0 -0
  45. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/templates/latex/__init__.py +0 -0
  46. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/templates/latex/article/paper.tex +0 -0
  47. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/templates/latex/core.py +0 -0
  48. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/templates/latex/jfm/jfm.bst +0 -0
  49. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/templates/latex/jfm/jfm.cls +0 -0
  50. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/templates/latex/jfm/lineno-FLM.sty +0 -0
  51. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/templates/latex/jfm/paper.tex +0 -0
  52. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/templates/latex/jfm/upmath.sty +0 -0
  53. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/__init__.py +0 -0
  54. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/cli/__init__.py +0 -0
  55. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/cli/test_list.py +0 -0
  56. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/test_calc.py +0 -0
  57. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/test_check.py +0 -0
  58. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/test_conda.py +0 -0
  59. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/test_core.py +0 -0
  60. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/test_dvc.py +0 -0
  61. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/test_jupyter.py +0 -0
  62. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/test_magics.py +0 -0
  63. {calkit_python-0.17.6 → calkit_python-0.19.0}/calkit/tests/test_templates.py +0 -0
  64. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/CNAME +0 -0
  65. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/apps.md +0 -0
  66. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/calculations.md +0 -0
  67. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/calkit-yaml.md +0 -0
  68. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/cli-reference.md +0 -0
  69. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/cloud-integration.md +0 -0
  70. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/datasets.md +0 -0
  71. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/environments.md +0 -0
  72. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/examples.md +0 -0
  73. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/help.md +0 -0
  74. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/img/c-to-the-k-white.svg +0 -0
  75. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/img/calkit-no-bg.png +0 -0
  76. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/index.md +0 -0
  77. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/installation.md +0 -0
  78. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/local-server.md +0 -0
  79. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/pipeline/index.md +0 -0
  80. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/pipeline/manual-steps.md +0 -0
  81. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/references.md +0 -0
  82. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/adding-latex-pub-docker.md +0 -0
  83. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/conda-envs.md +0 -0
  84. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/first-project.md +0 -0
  85. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/building-codespace.png +0 -0
  86. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/codespaces-secrets-2.png +0 -0
  87. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/editor-split.png +0 -0
  88. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/go-to-linked-code.png +0 -0
  89. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/issue-from-selection.png +0 -0
  90. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/new-project.png +0 -0
  91. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/new-pub-2.png +0 -0
  92. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/new-token.png +0 -0
  93. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/paper.tex.png +0 -0
  94. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/project-home-3.png +0 -0
  95. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/push.png +0 -0
  96. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/latex-codespaces/stage.png +0 -0
  97. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/anakin-excel.jpg +0 -0
  98. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/chart-more-rows.png +0 -0
  99. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/create-project.png +0 -0
  100. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/elsevier-research-data-guidelines.png +0 -0
  101. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/excel-chart.png +0 -0
  102. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/excel-data.png +0 -0
  103. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/insert-link-to-file.png +0 -0
  104. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/needs-clone.png +0 -0
  105. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/new-stage.png +0 -0
  106. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/phd-comics-version-control.webp +0 -0
  107. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/pipeline-out-of-date.png +0 -0
  108. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/status-more-rows.png +0 -0
  109. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/uncommitted-changes.png +0 -0
  110. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/untracked-data.png +0 -0
  111. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/updated-publication.png +0 -0
  112. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/word-to-pdf-stage-2.png +0 -0
  113. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/office/workflow-page.png +0 -0
  114. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/openfoam/clone.png +0 -0
  115. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/openfoam/create-project.png +0 -0
  116. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/openfoam/datasets-page.png +0 -0
  117. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/openfoam/figure-on-website-updated.png +0 -0
  118. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/openfoam/figure-on-website.png +0 -0
  119. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/openfoam/new-token.png +0 -0
  120. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/openfoam/reclone.png +0 -0
  121. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/openfoam/status-after-import-dataset.png +0 -0
  122. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/img/run-proc.png +0 -0
  123. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/latex-codespaces.md +0 -0
  124. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/matlab.md +0 -0
  125. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/notebook-pipeline.md +0 -0
  126. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/office.md +0 -0
  127. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/openfoam.md +0 -0
  128. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/tutorials/procedures.md +0 -0
  129. {calkit_python-0.17.6 → calkit_python-0.19.0}/docs/version-control.md +0 -0
  130. {calkit_python-0.17.6 → calkit_python-0.19.0}/pyproject.toml +0 -0
  131. {calkit_python-0.17.6 → calkit_python-0.19.0}/test/pipeline.ipynb +0 -0
  132. {calkit_python-0.17.6 → calkit_python-0.19.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calkit-python
3
- Version: 0.17.6
3
+ Version: 0.19.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.17.6"
1
+ __version__ = "0.19.0"
2
2
 
3
3
  from .core import *
4
4
  from . import git
@@ -65,13 +65,14 @@ def setup_remote(
65
65
  raise_error("DVC remote config failed; have you run `dvc init`?")
66
66
  except InvalidGitRepositoryError:
67
67
  raise_error("Current directory is not a Git repository")
68
+ except ValueError as e:
69
+ raise_error(e)
68
70
  if not no_commit:
69
71
  repo = git.Repo()
70
- repo.git.reset()
71
72
  repo.git.add(".dvc/config")
72
- if calkit.git.get_staged_files():
73
+ if ".dvc/config" in calkit.git.get_staged_files():
73
74
  typer.echo("Committing changes to DVC config")
74
- repo.git.commit(["-m", "Set DVC remote"])
75
+ repo.git.commit([".dvc/config", "-m", "Set DVC remote"])
75
76
 
76
77
 
77
78
  @config_app.command(name="setup-remote-auth", help="Alias for 'remote-auth'.")
@@ -11,7 +11,10 @@ def print_sep(name: str):
11
11
  txt_width = len(name) + 2
12
12
  buffer_width = (width - txt_width) // 2
13
13
  buffer = "-" * buffer_width
14
- typer.echo(f"{buffer} {name} {buffer}")
14
+ line = f"{buffer} {name} {buffer}"
15
+ if len(line) == (width - 1):
16
+ line += "-"
17
+ typer.echo(line)
15
18
 
16
19
 
17
20
  def run_cmd(cmd: list[str]):
@@ -208,6 +208,23 @@ def clone(
208
208
  @app.command(name="status")
209
209
  def get_status():
210
210
  """Get a unified Git and DVC status."""
211
+ print_sep("Project")
212
+ # Print latest status
213
+ status = calkit.get_latest_project_status()
214
+ if status is not None:
215
+ ts = status.timestamp.strftime("%Y-%m-%d %H:%M:%S")
216
+ colors = {
217
+ "in-progress": "blue",
218
+ "on-hold": "yellow",
219
+ "completed": "green",
220
+ }
221
+ status_txt = typer.style(status.status, fg=colors.get(status.status))
222
+ typer.echo(f"Current status: {status_txt} (updated {ts} UTC)")
223
+ else:
224
+ typer.echo(
225
+ 'Project status not set. Use "calkit new status" to update.'
226
+ )
227
+ typer.echo()
211
228
  print_sep("Code (Git)")
212
229
  run_cmd(["git", "status"])
213
230
  typer.echo()
@@ -1047,6 +1064,17 @@ def run_in_env(
1047
1064
  jobs[job_key] = job
1048
1065
  with open(jobs_fpath, "w") as f:
1049
1066
  calkit.ryaml.dump(jobs, f)
1067
+ elif env["kind"] == "renv":
1068
+ try:
1069
+ subprocess.check_call(
1070
+ ["Rscript", "-e", "'renv::restore()'"], cwd=wdir
1071
+ )
1072
+ except subprocess.CalledProcessError:
1073
+ raise_error("Failed to check renv")
1074
+ try:
1075
+ subprocess.check_call(cmd, cwd=wdir)
1076
+ except subprocess.CalledProcessError:
1077
+ raise_error("Failed to run in renv")
1050
1078
  else:
1051
1079
  raise_error("Environment kind not supported")
1052
1080
 
@@ -2,13 +2,15 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import csv
5
6
  import os
6
7
  import shutil
7
8
  import subprocess
9
+ from enum import Enum
8
10
 
9
11
  import git
10
12
  import typer
11
- from git.exc import InvalidGitRepositoryError
13
+ from git.exc import GitCommandError, InvalidGitRepositoryError
12
14
  from typing_extensions import Annotated
13
15
 
14
16
  import calkit
@@ -95,10 +97,10 @@ def new_project(
95
97
  f"Check out the docs at {docs_url}."
96
98
  )
97
99
  abs_path = os.path.abspath(path)
98
- if (cloud or template) and os.path.exists(abs_path):
99
- raise_error(
100
- "Must specify a new directory if using --cloud or --template"
101
- )
100
+ if template and os.path.exists(abs_path):
101
+ raise_error("Must specify a new directory if using --template")
102
+ if cloud and os.path.isdir(os.path.join(abs_path, ".git")):
103
+ raise_error("Must not already be a Git repo to use --cloud")
102
104
  ck_info_fpath = os.path.join(abs_path, "calkit.yaml")
103
105
  if os.path.isfile(ck_info_fpath) and not overwrite:
104
106
  raise_error(
@@ -118,8 +120,8 @@ def new_project(
118
120
  typer.echo(f"Using title: {title}")
119
121
  if cloud:
120
122
  # Cloud should allow None, which will allow us to post just the name
121
- # NOTE: This will fail if the user hasn't logged into GitHub in a
122
- # while, and their token stored in the Calkit cloud is expired
123
+ # NOTE: This will fail if the user hasn't logged into the Calkit Cloud
124
+ # in 6 months, since their GitHub refresh token stored is expired
123
125
  try:
124
126
  resp = calkit.cloud.post(
125
127
  "/projects",
@@ -134,8 +136,21 @@ def new_project(
134
136
  )
135
137
  except Exception as e:
136
138
  raise_error(f"Posting new project to cloud failed: {e}")
137
- # Now clone here and that's about
138
- subprocess.run(["git", "clone", resp["git_repo_url"], abs_path])
139
+ # Now clone here
140
+ if not os.path.isdir(abs_path):
141
+ subprocess.run(["git", "clone", resp["git_repo_url"], abs_path])
142
+ else:
143
+ typer.echo("Fetching from newly create Git repo")
144
+ repo = git.Repo.init(abs_path, initial_branch="main")
145
+ repo.git.remote(["add", "origin", resp["git_repo_url"]])
146
+ repo.git.fetch()
147
+ checkout_cmd = ["-t", "origin/main"]
148
+ if overwrite:
149
+ checkout_cmd.append("--force")
150
+ try:
151
+ repo.git.checkout(checkout_cmd)
152
+ except GitCommandError as e:
153
+ raise_error(f"Failed to check out main branch: {e}")
139
154
  try:
140
155
  calkit.dvc.set_remote_auth(wdir=abs_path)
141
156
  except Exception:
@@ -1231,3 +1246,191 @@ def new_pixi_env(
1231
1246
  repo.git.add("calkit.yaml")
1232
1247
  if not no_commit and repo.git.diff("--staged"):
1233
1248
  repo.git.commit(["-m", f"Add pixi env {name}"])
1249
+
1250
+
1251
+ class Status(str, Enum):
1252
+ in_progress = "in-progress"
1253
+ on_hold = "on-hold"
1254
+ completed = "completed"
1255
+
1256
+
1257
+ @new_app.command(name="status")
1258
+ def new_status(
1259
+ status: Annotated[
1260
+ Status,
1261
+ typer.Argument(help="Current status of the project."),
1262
+ ],
1263
+ message: Annotated[
1264
+ str,
1265
+ typer.Option(
1266
+ "--message",
1267
+ "-m",
1268
+ help="Optional message describing the status.",
1269
+ ),
1270
+ ] = "",
1271
+ no_commit: Annotated[
1272
+ bool,
1273
+ typer.Option(
1274
+ "--no-commit", help="Do not commit changes to the status log."
1275
+ ),
1276
+ ] = False,
1277
+ ):
1278
+ """Add a new project status to the log."""
1279
+ typer.echo(f"Adding {status.value} status log entry")
1280
+ fpath = os.path.join(".calkit", "status.csv")
1281
+ os.makedirs(".calkit", exist_ok=True)
1282
+ now = calkit.utcnow(remove_tz=False)
1283
+ # Append to end of CSV
1284
+ write_header = not os.path.isfile(fpath)
1285
+ with open(fpath, "a") as f:
1286
+ writer = csv.writer(f)
1287
+ if write_header:
1288
+ writer.writerow(["timestamp", "status", "message"])
1289
+ writer.writerow([now.isoformat(), status.value, message])
1290
+ if not no_commit:
1291
+ typer.echo("Committing")
1292
+ repo = git.Repo()
1293
+ repo.git.add(fpath)
1294
+ repo.git.commit([fpath, "-m", f"Add {status.value} status log entry"])
1295
+
1296
+
1297
+ class StageKind(str, Enum):
1298
+ python_script = "python-script"
1299
+ latex = "latex"
1300
+ r_script = "r-script"
1301
+ sh_script = "sh-script"
1302
+ bash_script = "bash-script"
1303
+ zsh_script = "zsh-script"
1304
+ matlab_script = "matlab-script"
1305
+
1306
+
1307
+ @new_app.command(name="stage")
1308
+ def new_stage(
1309
+ name: Annotated[
1310
+ str,
1311
+ typer.Option("--name", "-n", help="Stage name, typically kebab-case."),
1312
+ ],
1313
+ kind: Annotated[
1314
+ StageKind, typer.Option("--kind", help="What kind of stage to create.")
1315
+ ],
1316
+ target: Annotated[
1317
+ str,
1318
+ typer.Option(
1319
+ "--target", "-t", help="Target file, e.g., the script to run."
1320
+ ),
1321
+ ],
1322
+ environment: Annotated[
1323
+ str,
1324
+ typer.Option(
1325
+ "--environment", "-e", help="Environment to use to run the stage."
1326
+ ),
1327
+ ] = None,
1328
+ deps: Annotated[
1329
+ list[str],
1330
+ typer.Option("--dep", "-d", help="A path on which the stage depends."),
1331
+ ] = [],
1332
+ outs: Annotated[
1333
+ list[str],
1334
+ typer.Option(
1335
+ "--out", "-o", help="A path that is produced by the stage."
1336
+ ),
1337
+ ] = [],
1338
+ outs_persist: Annotated[
1339
+ list[str],
1340
+ typer.Option(
1341
+ "--out-persist",
1342
+ help="An output that should not be deleted before running.",
1343
+ ),
1344
+ ] = [],
1345
+ outs_no_cache: Annotated[
1346
+ list[str],
1347
+ typer.Option(
1348
+ "--out-git",
1349
+ help="An output that should be tracked with Git instead of DVC.",
1350
+ ),
1351
+ ] = [],
1352
+ outs_persist_no_cache: Annotated[
1353
+ list[str],
1354
+ typer.Option(
1355
+ "--out-git-persist",
1356
+ help=(
1357
+ "An output that should be tracked with Git instead of DVC, "
1358
+ "and also should not be deleted before running stage."
1359
+ ),
1360
+ ),
1361
+ ] = [],
1362
+ overwrite: Annotated[
1363
+ bool,
1364
+ typer.Option(
1365
+ "--overwrite",
1366
+ "--force",
1367
+ "-f",
1368
+ help="Overwrite an existing stage with this name if necessary.",
1369
+ ),
1370
+ ] = False,
1371
+ no_commit: Annotated[
1372
+ bool, typer.Option("--no-commit", help="Do not commit changes to Git.")
1373
+ ] = False,
1374
+ ):
1375
+ """Create a new pipeline stage."""
1376
+ ck_info = calkit.load_calkit_info()
1377
+ if environment is None:
1378
+ warn("No environment is specified")
1379
+ cmd = ""
1380
+ else:
1381
+ if environment not in ck_info["environments"]:
1382
+ raise_error(f"Environment '{environment}' does not exist")
1383
+ cmd = f"calkit xenv -n {environment} -- "
1384
+ # Add environment path as a dependency
1385
+ env_path = ck_info["environments"][environment].get("path")
1386
+ if env_path is not None:
1387
+ deps = [env_path] + deps
1388
+ if not os.path.exists(target):
1389
+ raise_error(f"Target '{target}' does not exist")
1390
+ if kind.value == "python-script":
1391
+ cmd += f"python {target}"
1392
+ elif kind.value == "latex":
1393
+ cmd += f"latexmk -cd -interaction=nonstopmode -pdf {target}"
1394
+ out_target = target.removesuffix(".tex") + ".pdf"
1395
+ if out_target not in (
1396
+ outs + outs_no_cache + outs_persist + outs_persist_no_cache
1397
+ ):
1398
+ outs = [out_target] + outs
1399
+ elif kind.value == "matlab-script":
1400
+ cmd += f"matlab -noFigureWindows -batch \"run('{target}');\""
1401
+ elif kind.value == "sh-script":
1402
+ cmd += f"sh {target}"
1403
+ elif kind.value == "bash-script":
1404
+ cmd += f"bash {target}"
1405
+ elif kind.value == "zsh-script":
1406
+ cmd += f"zsh {target}"
1407
+ elif kind.value == "r-script":
1408
+ cmd += f"Rscript {target}"
1409
+ add_cmd = ["dvc", "stage", "add", "-n", name]
1410
+ for dep in [target] + deps:
1411
+ add_cmd += ["-d", dep]
1412
+ for out in outs:
1413
+ add_cmd += ["-o", out]
1414
+ for out in outs_no_cache:
1415
+ add_cmd += ["--outs-no-cache", out]
1416
+ for out in outs_persist:
1417
+ add_cmd += ["--outs-persist", out]
1418
+ for out in outs_persist_no_cache:
1419
+ add_cmd += ["--outs-persist-no-cache", out]
1420
+ if overwrite:
1421
+ add_cmd.append("-f")
1422
+ add_cmd.append(cmd)
1423
+ try:
1424
+ subprocess.check_call(add_cmd)
1425
+ except subprocess.CalledProcessError:
1426
+ raise_error("Failed to create stage")
1427
+ if not no_commit:
1428
+ try:
1429
+ repo = git.Repo()
1430
+ except InvalidGitRepositoryError:
1431
+ raise_error("Can't commit because this is not a Git repo")
1432
+ repo.git.add("dvc.yaml")
1433
+ if "dvc.yaml" in calkit.git.get_staged_files():
1434
+ repo.git.commit(
1435
+ ["dvc.yaml", "-m", f"Add {kind.value} pipeline stage '{name}'"]
1436
+ )
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import base64
6
+ import csv
6
7
  import glob
7
8
  import json
8
9
  import logging
@@ -11,6 +12,8 @@ import pickle
11
12
  import re
12
13
  import subprocess
13
14
 
15
+ from calkit.models import ProjectStatus
16
+
14
17
  import requests
15
18
 
16
19
  try:
@@ -385,3 +388,32 @@ def get_size(path: str):
385
388
  def to_kebab_case(str) -> str:
386
389
  """Convert a string to kebab-case."""
387
390
  return re.sub(r"[-_,\.\ ]", "-", str.lower())
391
+
392
+
393
+ def get_project_status_history(
394
+ wdir: str = None, as_pydantic=True
395
+ ) -> list[ProjectStatus] | list[dict]:
396
+ statuses = []
397
+ fpath = os.path.join(".calkit", "status.csv")
398
+ if wdir is not None:
399
+ fpath = os.path.join(wdir, fpath)
400
+ if os.path.isfile(fpath):
401
+ with open(fpath) as f:
402
+ reader = csv.reader(f)
403
+ next(reader, None) # Skip header row
404
+ for line in reader:
405
+ ts, status, message = line
406
+ ts = datetime.fromisoformat(ts)
407
+ obj = ProjectStatus(
408
+ timestamp=ts, status=status, message=message
409
+ )
410
+ if not as_pydantic:
411
+ obj = obj.model_dump()
412
+ statuses.append(obj)
413
+ return statuses
414
+
415
+
416
+ def get_latest_project_status(wdir: str = None) -> ProjectStatus | None:
417
+ statuses = get_project_status_history(wdir=wdir)
418
+ if statuses:
419
+ return statuses[-1]
@@ -7,8 +7,10 @@ import os
7
7
  import subprocess
8
8
 
9
9
  import dvc.repo
10
+ import git
10
11
 
11
12
  import calkit
13
+ from calkit.cli import warn
12
14
  from calkit.config import get_app_name
13
15
 
14
16
  logger = logging.getLogger(__package__)
@@ -16,7 +18,25 @@ logger.setLevel(logging.INFO)
16
18
 
17
19
 
18
20
  def configure_remote(wdir: str = None):
19
- project_name = calkit.git.detect_project_name(path=wdir)
21
+ try:
22
+ project_name = calkit.git.detect_project_name(path=wdir)
23
+ except ValueError as e:
24
+ raise ValueError(f"Can't detect project name: {e}")
25
+ # If Git origin is not set, set that
26
+ repo = git.Repo(wdir)
27
+ try:
28
+ repo.remote()
29
+ except ValueError:
30
+ warn("No Git remote defined; querying Calkit Cloud")
31
+ # Try to fetch Git repo URL from Calkit cloud
32
+ try:
33
+ project = calkit.cloud.get(f"/projects/{project_name}")
34
+ url = project["git_repo_url"]
35
+ except Exception as e:
36
+ raise ValueError(f"Could not fetch project info: {e}")
37
+ if not url.endswith(".git"):
38
+ url += ".git"
39
+ repo.git.remote(["add", "origin", url])
20
40
  base_url = calkit.cloud.get_base_url()
21
41
  remote_url = f"{base_url}/projects/{project_name}/dvc"
22
42
  subprocess.check_call(
@@ -110,3 +130,8 @@ def list_paths(wdir: str = None) -> list[str]:
110
130
  """List paths tracked with DVC."""
111
131
  dvc_repo = dvc.repo.Repo(wdir)
112
132
  return [p.get("path") for p in dvc_repo.ls(".", dvc_only=True)]
133
+
134
+
135
+ def get_output_revisions(path: str):
136
+ """Get all revisions of a pipeline output."""
137
+ pass
@@ -12,9 +12,13 @@ def detect_project_name(path: str = None) -> str:
12
12
  ck_info = calkit.load_calkit_info(wdir=path)
13
13
  name = ck_info.get("name")
14
14
  owner = ck_info.get("owner")
15
- url = git.Repo(path=path).remote().url
16
- from_url = url.split("github.com")[-1][1:].removesuffix(".git")
17
- owner_name, project_name = from_url.split("/")
15
+ if name is None or owner is None:
16
+ try:
17
+ url = git.Repo(path=path).remote().url
18
+ except ValueError:
19
+ raise ValueError("No Git remote set with name 'origin'")
20
+ from_url = url.split("github.com")[-1][1:].removesuffix(".git")
21
+ owner_name, project_name = from_url.split("/")
18
22
  if name is None:
19
23
  name = project_name
20
24
  if owner is None:
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from datetime import timedelta
5
+ from datetime import datetime, timedelta
6
6
  from typing import Literal
7
7
 
8
8
  from pydantic import BaseModel
@@ -225,6 +225,12 @@ class DerivedFromProject(BaseModel):
225
225
  git_rev: str
226
226
 
227
227
 
228
+ class ProjectStatus(BaseModel):
229
+ timestamp: datetime
230
+ status: Literal["in-progress", "on-hold", "completed"]
231
+ message: str | None = None
232
+
233
+
228
234
  class ProjectInfo(BaseModel):
229
235
  """All of the project's information or metadata, written to the
230
236
  ``calkit.yaml`` file.
@@ -296,3 +296,25 @@ def test_add(tmp_dir):
296
296
  f.write(os.urandom(2_000_000))
297
297
  subprocess.check_call(["calkit", "add", "data2", "-M"])
298
298
  assert repo.head.commit.message.strip() == "Add data2"
299
+
300
+
301
+ def test_status(tmp_dir):
302
+ subprocess.check_call(["calkit", "status"])
303
+ subprocess.check_call(["calkit", "init"])
304
+ subprocess.check_call(["calkit", "new", "status", "in-progress"])
305
+ subprocess.check_call(["calkit", "status"])
306
+ status = calkit.get_latest_project_status()
307
+ assert status.status == "in-progress"
308
+ assert not status.message
309
+ subprocess.check_call(
310
+ ["calkit", "new", "status", "completed", "-m", "We're done."]
311
+ )
312
+ subprocess.check_call(["calkit", "status"])
313
+ status = calkit.get_latest_project_status()
314
+ assert status.status == "completed"
315
+ assert status.message == "We're done."
316
+ history = calkit.get_project_status_history()
317
+ assert history[-1] == status
318
+ calkit.get_project_status_history(as_pydantic=False)
319
+ with pytest.raises(subprocess.CalledProcessError):
320
+ subprocess.check_call(["calkit", "new", "status", "very-cool"])
@@ -228,3 +228,178 @@ def test_new_project(tmp_dir):
228
228
  assert repo.git.ls_files(".devcontainer")
229
229
  ck_info = calkit.load_calkit_info()
230
230
  assert ck_info["title"] == "My new project"
231
+
232
+
233
+ def test_new_stage(tmp_dir):
234
+ subprocess.check_call(["calkit", "init"])
235
+ subprocess.check_call(
236
+ [
237
+ "calkit",
238
+ "new",
239
+ "docker-env",
240
+ "--name",
241
+ "tex",
242
+ "--image",
243
+ "texlive/texlive:latest-full",
244
+ ]
245
+ )
246
+ subprocess.check_call(
247
+ [
248
+ "calkit",
249
+ "new",
250
+ "uv-venv",
251
+ "--name",
252
+ "py",
253
+ "requests",
254
+ ]
255
+ )
256
+ with open("plot.py", "w") as f:
257
+ f.write("print('hi')")
258
+ with open("paper.tex", "w") as f:
259
+ f.write("Hello")
260
+ with open("data.csv", "w") as f:
261
+ f.write("data")
262
+ # Create a Python script stage
263
+ subprocess.check_call(
264
+ [
265
+ "calkit",
266
+ "new",
267
+ "stage",
268
+ "--name",
269
+ "plot",
270
+ "--kind",
271
+ "python-script",
272
+ "--environment",
273
+ "py",
274
+ "--target",
275
+ "plot.py",
276
+ "--dep",
277
+ "data.csv",
278
+ "--out",
279
+ "plot1.png",
280
+ "-o",
281
+ "plot2.png",
282
+ ]
283
+ )
284
+ pipeline = calkit.dvc.read_pipeline()
285
+ assert (
286
+ pipeline["stages"]["plot"]["cmd"]
287
+ == "calkit xenv -n py -- python plot.py"
288
+ )
289
+ assert set(pipeline["stages"]["plot"]["deps"]) == set(
290
+ ["plot.py", "data.csv", "requirements.txt"]
291
+ )
292
+ assert set(pipeline["stages"]["plot"]["outs"]) == set(
293
+ ["plot1.png", "plot2.png"]
294
+ )
295
+ # Create a LaTeX stage
296
+ subprocess.check_call(
297
+ [
298
+ "calkit",
299
+ "new",
300
+ "stage",
301
+ "--name",
302
+ "build-paper",
303
+ "--kind",
304
+ "latex",
305
+ "--environment",
306
+ "tex",
307
+ "--target",
308
+ "paper.tex",
309
+ "--dep",
310
+ "plot1.png",
311
+ "-d",
312
+ "plot2.png",
313
+ "--out",
314
+ "paper.pdf",
315
+ ]
316
+ )
317
+ pipeline = calkit.dvc.read_pipeline()
318
+ assert pipeline["stages"]["build-paper"]["cmd"] == (
319
+ "calkit xenv -n tex -- "
320
+ "latexmk -cd -interaction=nonstopmode -pdf paper.tex"
321
+ )
322
+ assert set(pipeline["stages"]["build-paper"]["deps"]) == set(
323
+ [
324
+ "paper.tex",
325
+ "plot1.png",
326
+ "plot2.png",
327
+ ]
328
+ )
329
+ assert pipeline["stages"]["build-paper"]["outs"] == ["paper.pdf"]
330
+ # Check that we can create a MATLAB script with no environment
331
+ with open("script.m", "w") as f:
332
+ f.write("script")
333
+ subprocess.check_call(
334
+ [
335
+ "calkit",
336
+ "new",
337
+ "stage",
338
+ "--name",
339
+ "plot",
340
+ "-f",
341
+ "--kind",
342
+ "matlab-script",
343
+ "--target",
344
+ "script.m",
345
+ "--dep",
346
+ "data.csv",
347
+ "--out",
348
+ "plot1.png",
349
+ "-o",
350
+ "plot2.png",
351
+ ]
352
+ )
353
+ pipeline = calkit.dvc.read_pipeline()
354
+ assert (
355
+ pipeline["stages"]["plot"]["cmd"]
356
+ == "matlab -noFigureWindows -batch \"run('script.m');\""
357
+ )
358
+ assert set(pipeline["stages"]["plot"]["deps"]) == set(
359
+ ["script.m", "data.csv"]
360
+ )
361
+ assert set(pipeline["stages"]["plot"]["outs"]) == set(
362
+ ["plot1.png", "plot2.png"]
363
+ )
364
+ # Check that we fail for a nonexistent target
365
+ with pytest.raises(subprocess.CalledProcessError):
366
+ subprocess.check_call(
367
+ [
368
+ "calkit",
369
+ "new",
370
+ "stage",
371
+ "--name",
372
+ "plot",
373
+ "-f",
374
+ "--kind",
375
+ "matlab-script",
376
+ "--target",
377
+ "script2.m",
378
+ "--out",
379
+ "plot1.png",
380
+ "-o",
381
+ "plot2.png",
382
+ ]
383
+ )
384
+ # Check that we fail to create a stage with a non-existent environment
385
+ with pytest.raises(subprocess.CalledProcessError):
386
+ subprocess.check_call(
387
+ [
388
+ "calkit",
389
+ "new",
390
+ "stage",
391
+ "--name",
392
+ "plot",
393
+ "-f",
394
+ "--kind",
395
+ "python-script",
396
+ "--target",
397
+ "plot.py",
398
+ "--out",
399
+ "plot1.png",
400
+ "-o",
401
+ "plot2.png",
402
+ "-e",
403
+ "nonexistent-env",
404
+ ]
405
+ )