calkit-python 0.21.8__tar.gz → 0.22.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 (149) hide show
  1. {calkit_python-0.21.8 → calkit_python-0.22.0}/PKG-INFO +1 -1
  2. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/__init__.py +1 -1
  3. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/config.py +30 -10
  4. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/core.py +2 -2
  5. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/main.py +15 -3
  6. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/new.py +18 -9
  7. calkit_python-0.22.0/calkit/cli/overleaf.py +451 -0
  8. calkit_python-0.22.0/calkit/config.py +217 -0
  9. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/core.py +1 -1
  10. calkit_python-0.22.0/calkit/tests/cli/test_config.py +99 -0
  11. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/test_core.py +2 -0
  12. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/examples.md +10 -0
  13. calkit_python-0.22.0/docs/overleaf.md +84 -0
  14. {calkit_python-0.21.8 → calkit_python-0.22.0}/mkdocs.yml +1 -0
  15. {calkit_python-0.21.8 → calkit_python-0.22.0}/pyproject.toml +4 -0
  16. calkit_python-0.22.0/uv.lock +4859 -0
  17. calkit_python-0.21.8/calkit/config.py +0 -110
  18. calkit_python-0.21.8/uv.lock +0 -4688
  19. {calkit_python-0.21.8 → calkit_python-0.22.0}/.github/FUNDING.yml +0 -0
  20. {calkit_python-0.21.8 → calkit_python-0.22.0}/.github/workflows/docs.yml +0 -0
  21. {calkit_python-0.21.8 → calkit_python-0.22.0}/.github/workflows/format.yml +0 -0
  22. {calkit_python-0.21.8 → calkit_python-0.22.0}/.github/workflows/publish-test.yml +0 -0
  23. {calkit_python-0.21.8 → calkit_python-0.22.0}/.github/workflows/publish.yml +0 -0
  24. {calkit_python-0.21.8 → calkit_python-0.22.0}/.github/workflows/test.yml +0 -0
  25. {calkit_python-0.21.8 → calkit_python-0.22.0}/.gitignore +0 -0
  26. {calkit_python-0.21.8 → calkit_python-0.22.0}/.pre-commit-config.yaml +0 -0
  27. {calkit_python-0.21.8 → calkit_python-0.22.0}/CONTRIBUTING.md +0 -0
  28. {calkit_python-0.21.8 → calkit_python-0.22.0}/LICENSE +0 -0
  29. {calkit_python-0.21.8 → calkit_python-0.22.0}/Makefile +0 -0
  30. {calkit_python-0.21.8 → calkit_python-0.22.0}/README.md +0 -0
  31. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/__main__.py +0 -0
  32. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/calc.py +0 -0
  33. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/check.py +0 -0
  34. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/__init__.py +0 -0
  35. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/check.py +0 -0
  36. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/cloud.py +0 -0
  37. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/import_.py +0 -0
  38. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/list.py +0 -0
  39. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/notebooks.py +0 -0
  40. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/office.py +0 -0
  41. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cli/update.py +0 -0
  42. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/cloud.py +0 -0
  43. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/conda.py +0 -0
  44. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/datasets.py +0 -0
  45. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/docker.py +0 -0
  46. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/dvc.py +0 -0
  47. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/git.py +0 -0
  48. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/github.py +0 -0
  49. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/gui.py +0 -0
  50. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/jupyter.py +0 -0
  51. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/magics.py +0 -0
  52. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/models.py +0 -0
  53. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/office.py +0 -0
  54. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/ops.py +0 -0
  55. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/releases.py +0 -0
  56. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/server.py +0 -0
  57. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/templates/__init__.py +0 -0
  58. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/templates/core.py +0 -0
  59. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/templates/latex/__init__.py +0 -0
  60. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/templates/latex/article/paper.tex +0 -0
  61. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/templates/latex/core.py +0 -0
  62. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/templates/latex/jfm/jfm.bst +0 -0
  63. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/templates/latex/jfm/jfm.cls +0 -0
  64. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/templates/latex/jfm/lineno-FLM.sty +0 -0
  65. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/templates/latex/jfm/paper.tex +0 -0
  66. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/templates/latex/jfm/upmath.sty +0 -0
  67. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/__init__.py +0 -0
  68. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/cli/__init__.py +0 -0
  69. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/cli/test_list.py +0 -0
  70. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/cli/test_main.py +0 -0
  71. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/cli/test_new.py +0 -0
  72. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/test_calc.py +0 -0
  73. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/test_check.py +0 -0
  74. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/test_conda.py +0 -0
  75. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/test_dvc.py +0 -0
  76. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/test_jupyter.py +0 -0
  77. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/test_magics.py +0 -0
  78. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/tests/test_templates.py +0 -0
  79. {calkit_python-0.21.8 → calkit_python-0.22.0}/calkit/zenodo.py +0 -0
  80. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/CNAME +0 -0
  81. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/apps.md +0 -0
  82. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/calculations.md +0 -0
  83. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/calkit-yaml.md +0 -0
  84. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/cli-reference.md +0 -0
  85. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/cloud-integration.md +0 -0
  86. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/datasets.md +0 -0
  87. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/environments.md +0 -0
  88. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/help.md +0 -0
  89. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/img/c-to-the-k-white.svg +0 -0
  90. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/img/calkit-no-bg.png +0 -0
  91. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/img/connect-zenodo.png +0 -0
  92. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/index.md +0 -0
  93. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/installation.md +0 -0
  94. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/local-server.md +0 -0
  95. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/pipeline/index.md +0 -0
  96. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/pipeline/manual-steps.md +0 -0
  97. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/references.md +0 -0
  98. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/releases.md +0 -0
  99. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/adding-latex-pub-docker.md +0 -0
  100. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/conda-envs.md +0 -0
  101. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/existing-project.md +0 -0
  102. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/first-project.md +0 -0
  103. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/building-codespace.png +0 -0
  104. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/codespaces-secrets-2.png +0 -0
  105. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/editor-split.png +0 -0
  106. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/go-to-linked-code.png +0 -0
  107. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/issue-from-selection.png +0 -0
  108. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/new-project.png +0 -0
  109. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/new-pub-2.png +0 -0
  110. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/new-token.png +0 -0
  111. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/paper.tex.png +0 -0
  112. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/project-home-3.png +0 -0
  113. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/push.png +0 -0
  114. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/latex-codespaces/stage.png +0 -0
  115. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/anakin-excel.jpg +0 -0
  116. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/chart-more-rows.png +0 -0
  117. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/create-project.png +0 -0
  118. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/elsevier-research-data-guidelines.png +0 -0
  119. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/excel-chart.png +0 -0
  120. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/excel-data.png +0 -0
  121. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/insert-link-to-file.png +0 -0
  122. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/needs-clone.png +0 -0
  123. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/new-stage.png +0 -0
  124. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/phd-comics-version-control.webp +0 -0
  125. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/pipeline-out-of-date.png +0 -0
  126. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/status-more-rows.png +0 -0
  127. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/uncommitted-changes.png +0 -0
  128. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/untracked-data.png +0 -0
  129. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/updated-publication.png +0 -0
  130. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/word-to-pdf-stage-2.png +0 -0
  131. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/office/workflow-page.png +0 -0
  132. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/openfoam/clone.png +0 -0
  133. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/openfoam/create-project.png +0 -0
  134. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/openfoam/datasets-page.png +0 -0
  135. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/openfoam/figure-on-website-updated.png +0 -0
  136. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/openfoam/figure-on-website.png +0 -0
  137. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/openfoam/new-token.png +0 -0
  138. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/openfoam/reclone.png +0 -0
  139. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/openfoam/status-after-import-dataset.png +0 -0
  140. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/img/run-proc.png +0 -0
  141. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/index.md +0 -0
  142. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/latex-codespaces.md +0 -0
  143. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/matlab.md +0 -0
  144. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/notebook-pipeline.md +0 -0
  145. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/office.md +0 -0
  146. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/openfoam.md +0 -0
  147. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/tutorials/procedures.md +0 -0
  148. {calkit_python-0.21.8 → calkit_python-0.22.0}/docs/version-control.md +0 -0
  149. {calkit_python-0.21.8 → calkit_python-0.22.0}/test/pipeline.ipynb +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calkit-python
3
- Version: 0.21.8
3
+ Version: 0.22.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.21.8"
1
+ __version__ = "0.22.0"
2
2
 
3
3
  from .core import *
4
4
  from . import git
@@ -20,31 +20,51 @@ config_app = typer.Typer(no_args_is_help=True)
20
20
  @config_app.command(name="set")
21
21
  def set_config_value(key: str, value: str):
22
22
  """Set a value in the config."""
23
+ keys = config.Settings.model_fields.keys()
24
+ if key not in keys:
25
+ raise_error(
26
+ f"Invalid config key: '{key}'; Valid keys are: {list(keys)}"
27
+ )
23
28
  try:
24
29
  cfg = config.read()
25
30
  cfg = config.Settings.model_validate(cfg.model_dump() | {key: value})
26
- # Kind of a hack for setting the password computed field
27
- # Types have been validated above, so this won't hurt to do again
28
- setattr(cfg, key, value)
29
- except FileNotFoundError:
30
- # TODO: This fails if we try to set password before any config has
31
- # been written
32
- # Username is fine
33
- cfg = config.Settings.model_validate({key: value})
31
+ except Exception as e:
32
+ raise_error(f"Failed to set {key} in config: {e}")
34
33
  cfg.write()
35
34
 
36
35
 
37
36
  @config_app.command(name="get")
38
37
  def get_config_value(key: str) -> None:
39
38
  """Get and print a value from the config."""
40
- cfg = config.read()
41
- val = getattr(cfg, key)
39
+ cfg = config.read().model_dump()
40
+ if key not in cfg:
41
+ raise_error(
42
+ f"Invalid config key: '{key}'; Valid keys are: {list(cfg.keys())}"
43
+ )
44
+ val = cfg[key]
42
45
  if val is not None:
43
46
  print(val)
44
47
  else:
45
48
  print()
46
49
 
47
50
 
51
+ @config_app.command(name="unset")
52
+ def unset_config_value(key: str):
53
+ """Unset a value in the config, returning it to default."""
54
+ model_fields = config.Settings.model_fields
55
+ if key not in model_fields:
56
+ raise_error(
57
+ f"Invalid config key: '{key}'; "
58
+ f"Valid keys: {list(model_fields.keys())}"
59
+ )
60
+ try:
61
+ cfg = config.read()
62
+ setattr(cfg, key, model_fields[key].default)
63
+ except Exception as e:
64
+ raise_error(f"Failed to unset {key} in config: {e}")
65
+ cfg.write()
66
+
67
+
48
68
  @config_app.command(name="setup-remote", help="Alias for 'remote'.")
49
69
  @config_app.command(name="remote")
50
70
  def setup_remote(
@@ -33,5 +33,5 @@ def raise_error(txt: str):
33
33
  raise typer.Exit(1)
34
34
 
35
35
 
36
- def warn(txt: str):
37
- typer.echo(typer.style("Warning: " + str(txt), fg="yellow"))
36
+ def warn(txt: str, prefix: str = "Warning: "):
37
+ typer.echo(typer.style(prefix + str(txt), fg="yellow"))
@@ -29,6 +29,7 @@ from calkit.cli.list import list_app
29
29
  from calkit.cli.new import new_app
30
30
  from calkit.cli.notebooks import notebooks_app
31
31
  from calkit.cli.office import office_app
32
+ from calkit.cli.overleaf import overleaf_app
32
33
  from calkit.cli.update import update_app
33
34
  from calkit.models import Procedure
34
35
 
@@ -51,6 +52,7 @@ app.add_typer(import_app, name="import", help="Import objects.")
51
52
  app.add_typer(office_app, name="office", help="Work with Microsoft Office.")
52
53
  app.add_typer(update_app, name="update", help="Update objects.")
53
54
  app.add_typer(check_app, name="check", help="Check things.")
55
+ app.add_typer(overleaf_app, name="overleaf", help="Interact with Overleaf.")
54
56
  app.add_typer(cloud_app, name="cloud", help="Interact with a Calkit Cloud.")
55
57
 
56
58
  # Constants for version control auto-ignore
@@ -252,10 +254,20 @@ def get_status():
252
254
 
253
255
 
254
256
  @app.command(name="diff")
255
- def diff():
257
+ def diff(
258
+ staged: Annotated[
259
+ bool,
260
+ typer.Option(
261
+ "--staged", help="Show a diff from files staged with Git."
262
+ ),
263
+ ] = False,
264
+ ):
256
265
  """Get a unified Git and DVC diff."""
257
266
  print_sep("Code (Git)")
258
- run_cmd(["git", "diff"])
267
+ git_cmd = ["git", "diff"]
268
+ if staged:
269
+ git_cmd.append("--staged")
270
+ run_cmd(git_cmd)
259
271
  print_sep("Pipeline (DVC)")
260
272
  run_cmd([sys.executable, "-m", "dvc", "diff"])
261
273
 
@@ -622,7 +634,7 @@ def ignore(
622
634
  repo = git.Repo()
623
635
  if repo.ignored(path):
624
636
  typer.echo(f"{path} is already ignored")
625
- exit(0)
637
+ return
626
638
  typer.echo(f"Adding '{path}' to .gitignore")
627
639
  txt = "\n" + path + "\n"
628
640
  with open(".gitignore", "a") as f:
@@ -16,7 +16,7 @@ import dotenv
16
16
  import git
17
17
  import requests
18
18
  import typer
19
- from git.exc import GitCommandError, InvalidGitRepositoryError
19
+ from git.exc import GitCommandError, InvalidGitRepositoryError, NoSuchPathError
20
20
  from typing_extensions import Annotated
21
21
 
22
22
  import calkit
@@ -107,7 +107,7 @@ def new_project(
107
107
  raise_error("Must specify a new directory if using --template")
108
108
  try:
109
109
  repo = git.Repo(abs_path)
110
- except InvalidGitRepositoryError:
110
+ except (InvalidGitRepositoryError, NoSuchPathError):
111
111
  repo = None
112
112
  if repo is not None and git_repo_url is None:
113
113
  try:
@@ -1462,24 +1462,31 @@ def new_stage(
1462
1462
  help="Overwrite an existing stage with this name if necessary.",
1463
1463
  ),
1464
1464
  ] = False,
1465
+ no_check: Annotated[
1466
+ bool,
1467
+ typer.Option(
1468
+ "--no-check",
1469
+ help="Do not check if the target, deps, environment, etc., exist.",
1470
+ ),
1471
+ ] = False,
1465
1472
  no_commit: Annotated[
1466
1473
  bool, typer.Option("--no-commit", help="Do not commit changes to Git.")
1467
1474
  ] = False,
1468
1475
  ):
1469
1476
  """Create a new pipeline stage."""
1470
- ck_info = calkit.load_calkit_info()
1477
+ ck_info = calkit.load_calkit_info(process_includes="environments")
1471
1478
  if environment is None:
1472
1479
  warn("No environment is specified")
1473
1480
  cmd = ""
1474
1481
  else:
1475
- if environment not in ck_info["environments"]:
1482
+ if environment not in ck_info["environments"] and not no_check:
1476
1483
  raise_error(f"Environment '{environment}' does not exist")
1477
1484
  cmd = f"calkit xenv -n {environment} -- "
1478
- # Add environment path as a dependency
1479
- env_path = ck_info["environments"][environment].get("path")
1480
- if env_path is not None:
1485
+ # Add environment path as a dependency if applicable
1486
+ env_path = ck_info["environments"].get(environment, {}).get("path")
1487
+ if env_path is not None and env_path not in deps:
1481
1488
  deps = [env_path] + deps
1482
- if not os.path.exists(target):
1489
+ if not os.path.exists(target) and not no_check:
1483
1490
  raise_error(f"Target '{target}' does not exist")
1484
1491
  if kind.value == "python-script":
1485
1492
  cmd += f"python {target}"
@@ -1501,7 +1508,9 @@ def new_stage(
1501
1508
  elif kind.value == "r-script":
1502
1509
  cmd += f"Rscript {target}"
1503
1510
  add_cmd = [sys.executable, "-m", "dvc", "stage", "add", "-n", name]
1504
- for dep in [target] + deps:
1511
+ if target not in deps:
1512
+ deps = [target] + deps
1513
+ for dep in deps:
1505
1514
  add_cmd += ["-d", dep]
1506
1515
  for out in outs:
1507
1516
  add_cmd += ["-o", out]
@@ -0,0 +1,451 @@
1
+ """CLI for working with Overleaf."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+
9
+ import git
10
+ import typer
11
+ from typing_extensions import Annotated
12
+
13
+ import calkit
14
+ from calkit.cli import raise_error, warn
15
+
16
+ overleaf_app = typer.Typer(no_args_is_help=True)
17
+
18
+
19
+ @overleaf_app.command(name="import")
20
+ def import_publication(
21
+ src_url: Annotated[
22
+ str,
23
+ typer.Argument(
24
+ help=(
25
+ "Overleaf project URL, e.g., "
26
+ "https://www.overleaf.com/project/6800005973cb2e35."
27
+ )
28
+ ),
29
+ ],
30
+ dest_dir: Annotated[
31
+ str,
32
+ typer.Argument(
33
+ help="Directory at which to save in the project, e.g., 'paper'."
34
+ ),
35
+ ],
36
+ sync_paths: Annotated[
37
+ list[str],
38
+ typer.Option(
39
+ "--sync-path",
40
+ "-s",
41
+ help=(
42
+ "Paths to sync from the Overleaf project, e.g., 'main.tex'. "
43
+ "Note that multiple can be specified."
44
+ ),
45
+ ),
46
+ ],
47
+ title: Annotated[
48
+ str,
49
+ typer.Option(
50
+ "--title",
51
+ "-t",
52
+ help="Title of the publication.",
53
+ ),
54
+ ],
55
+ description: Annotated[
56
+ str,
57
+ typer.Option(
58
+ "--description",
59
+ "-d",
60
+ help="Description of the publication.",
61
+ ),
62
+ ] = None,
63
+ kind: Annotated[
64
+ str,
65
+ typer.Option(
66
+ "--kind",
67
+ help="What of the publication this is, e.g., 'journal-article'.",
68
+ ),
69
+ ] = None,
70
+ push_paths: Annotated[
71
+ list[str],
72
+ typer.Option(
73
+ "--push-path",
74
+ "-p",
75
+ help=(
76
+ "Paths to push to the Overleaf project, e.g., 'figures'. "
77
+ "Note that these are relative to the publication working "
78
+ "directory."
79
+ ),
80
+ ),
81
+ ] = [],
82
+ pdf_path: Annotated[
83
+ str,
84
+ typer.Option(
85
+ "--pdf-path",
86
+ "-o",
87
+ help=(
88
+ "PDF output file in the Overleaf project, e.g., 'main.pdf'. "
89
+ "If not provided, it will be determined from the first sync "
90
+ "path."
91
+ ),
92
+ ),
93
+ ] = None,
94
+ no_commit: Annotated[
95
+ bool,
96
+ typer.Option("--no-commit", help="Do not commit changes to repo."),
97
+ ] = False,
98
+ overwrite: Annotated[
99
+ bool,
100
+ typer.Option(
101
+ "--overwrite",
102
+ "-f",
103
+ help="Force adding the publication even if it already exists.",
104
+ ),
105
+ ] = False,
106
+ ):
107
+ """Import a publication from an Overleaf project."""
108
+ from calkit.cli.main import ignore as git_ignore
109
+ from calkit.cli.new import StageKind, new_stage
110
+
111
+ # First check that the user has an Overleaf token set
112
+ config = calkit.config.read()
113
+ overleaf_token = config.overleaf_token
114
+ if not overleaf_token:
115
+ warn("Overleaf token not set in config", prefix="")
116
+ typer.echo(
117
+ "One can be generated at:\n\n"
118
+ " https://www.overleaf.com/user/settings\n\n"
119
+ "under the 'Git Integration' section.\n"
120
+ )
121
+ overleaf_token = typer.prompt(
122
+ "Enter Overleaf Git authentication token", hide_input=True
123
+ )
124
+ typer.echo("Storing Overleaf token in Calkit config")
125
+ config.overleaf_token = overleaf_token
126
+ config.write()
127
+ if not src_url.startswith("https://www.overleaf.com/project/"):
128
+ raise_error(
129
+ "Invalid URL; must start with 'https://www.overleaf.com/project/'"
130
+ )
131
+ overleaf_project_id = src_url.split("/")[-1]
132
+ if not overleaf_project_id:
133
+ raise_error("Invalid Overleaf project ID")
134
+ ck_info = calkit.load_calkit_info(process_includes="environments")
135
+ pubs = ck_info.get("publications", [])
136
+ # TODO: Don't allow the same Overleaf project ID in multiple publications
137
+ # Determine the PDF output path
138
+ if pdf_path is None:
139
+ # Use the first sync path as the PDF path
140
+ pdf_path = sync_paths[0].removesuffix(".tex") + ".pdf"
141
+ typer.echo(f"Using PDF path: {pdf_path}")
142
+ tex_path = pdf_path.removesuffix(".pdf") + ".tex"
143
+ pub_path = os.path.join(dest_dir, pdf_path)
144
+ pub_paths = [pub.get("path") for pub in pubs]
145
+ if not overwrite and pub_path in pub_paths:
146
+ raise_error(
147
+ f"A publication already exists in this project at {pub_path}"
148
+ )
149
+ elif overwrite and pub_path in pub_paths:
150
+ # Note: This publication will go to the end of the list
151
+ pubs = [p for p in pubs if p.get("path") != pub_path]
152
+ repo = git.Repo()
153
+ # Clone the Overleaf project into .calkit/overleaf if it doesn't exist
154
+ # otherwise pull
155
+ overleaf_dir = os.path.join(".calkit", "overleaf")
156
+ os.makedirs(overleaf_dir, exist_ok=True)
157
+ git_ignore(overleaf_dir, no_commit=no_commit)
158
+ overleaf_project_dir = os.path.join(overleaf_dir, overleaf_project_id)
159
+ git_clone_url = (
160
+ f"https://git:{overleaf_token}@git.overleaf.com/{overleaf_project_id}"
161
+ )
162
+ if os.path.isdir(overleaf_project_dir):
163
+ warn("This Overleaf project has already been cloned; removing")
164
+ shutil.rmtree(overleaf_project_dir)
165
+ # Clone the Overleaf project
166
+ typer.echo("Cloning Overleaf project")
167
+ git.Repo.clone_from(
168
+ git_clone_url,
169
+ overleaf_project_dir,
170
+ depth=1,
171
+ )
172
+ # Check that we have a LaTeX environment
173
+ typer.echo("Checking that this project has a LaTeX environment")
174
+ envs = ck_info.get("environments", {})
175
+ tex_env_name = None
176
+ for name, env in envs.items():
177
+ if env.get("kind") == "docker" and "texlive" in env.get("image", ""):
178
+ tex_env_name = name
179
+ break
180
+ if tex_env_name is None:
181
+ typer.echo("Creating TeXlive Docker environment")
182
+ tex_env_name = "tex"
183
+ n = 1
184
+ while tex_env_name in envs:
185
+ tex_env_name = f"tex-{n}"
186
+ n += 1
187
+ envs[tex_env_name] = dict(
188
+ kind="docker",
189
+ image="texlive/texlive:latest-full",
190
+ description="TeXlive via Docker.",
191
+ )
192
+ ck_info["environments"] = envs
193
+ # Check that we have a build stage
194
+ typer.echo("Checking for a build stage in the pipeline")
195
+ stage_name = None
196
+ if os.path.isfile("dvc.yaml"):
197
+ with open("dvc.yaml", "r") as f:
198
+ dvc_info = calkit.ryaml.load(f)
199
+ stages = dvc_info.get("stages", {})
200
+ for stage_name_i, stage in stages.items():
201
+ if pub_path in stage.get("outs", []):
202
+ stage_name = stage_name_i
203
+ typer.echo(f"Found build stage '{stage_name}' in pipeline")
204
+ break
205
+ else:
206
+ stages = {}
207
+ if stage_name is None:
208
+ # Create a new stage
209
+ stage_name = calkit.to_kebab_case("build-" + dest_dir)
210
+ n = 1
211
+ while stage_name in stages:
212
+ stage_name = f"{stage_name}-{n}"
213
+ n += 1
214
+ typer.echo(f"Creating build stage '{stage_name}'")
215
+ new_stage(
216
+ name=stage_name,
217
+ environment=tex_env_name,
218
+ kind=StageKind.latex,
219
+ target=os.path.join(dest_dir, tex_path),
220
+ outs=[pub_path],
221
+ deps=[os.path.join(dest_dir, p) for p in sync_paths + push_paths],
222
+ no_check=True,
223
+ no_commit=True,
224
+ )
225
+ repo.git.add("dvc.yaml")
226
+ # Add to publications in calkit.yaml
227
+ typer.echo("Adding publication to calkit.yaml")
228
+ new_pub = dict(
229
+ path=pub_path,
230
+ title=title,
231
+ description=description,
232
+ kind=kind,
233
+ stage=stage_name,
234
+ overleaf=dict(
235
+ project_id=overleaf_project_id,
236
+ wdir=dest_dir,
237
+ sync_paths=sync_paths,
238
+ push_paths=push_paths,
239
+ last_sync_commit=None,
240
+ ),
241
+ )
242
+ pubs.append(new_pub)
243
+ ck_info["publications"] = pubs
244
+ with open("calkit.yaml", "w") as f:
245
+ calkit.ryaml.dump(ck_info, f)
246
+ repo.git.add("calkit.yaml")
247
+ if not no_commit:
248
+ # Commit any necessary changes
249
+ typer.echo("Committing changes")
250
+ repo.git.commit(
251
+ ["-m", f"Import Overleaf project ID {overleaf_project_id}"]
252
+ )
253
+ # Sync the project
254
+ sync(paths=[pub_path], no_commit=no_commit)
255
+
256
+
257
+ @overleaf_app.command(name="sync")
258
+ def sync(
259
+ paths: Annotated[
260
+ list[str],
261
+ typer.Argument(
262
+ help=(
263
+ "Paths to sync with Overleaf, e.g., 'paper/paper.pdf'. "
264
+ "If not provided, all Overleaf publications will be synced."
265
+ ),
266
+ ),
267
+ ] = None,
268
+ no_commit: Annotated[
269
+ bool,
270
+ typer.Option(
271
+ "--no-commit",
272
+ help="Do not commit the changes.",
273
+ ),
274
+ ] = False,
275
+ verbose: Annotated[
276
+ bool,
277
+ typer.Option(
278
+ "--verbose",
279
+ help="Enable verbose output.",
280
+ ),
281
+ ] = False,
282
+ ):
283
+ """Sync publications with Overleaf."""
284
+ # TODO: We should probably ensure the pipeline isn't stale
285
+ # Find all publications with Overleaf projects linked
286
+ ck_info = calkit.load_calkit_info()
287
+ pubs = ck_info.get("publications", [])
288
+ if paths is not None:
289
+ for path in paths:
290
+ if not any(pub.get("path") == path for pub in pubs):
291
+ raise_error(f"Publication with path '{path}' not found")
292
+ repo = git.Repo()
293
+ for pub in pubs:
294
+ overleaf_config = pub.get("overleaf", {})
295
+ if not overleaf_config:
296
+ continue
297
+ if paths is not None and pub.get("path") not in paths:
298
+ continue
299
+ overleaf_project_id = overleaf_config.get("project_id")
300
+ if not overleaf_project_id:
301
+ raise_error(
302
+ "No Overleaf project ID defined for this publication; "
303
+ "please set it in the publication's Overleaf config"
304
+ )
305
+ typer.echo(
306
+ f"Syncing {pub['path']} with "
307
+ f"Overleaf project ID {overleaf_project_id}"
308
+ )
309
+ wdir = pub["overleaf"].get("wdir")
310
+ if wdir is None:
311
+ raise_error(
312
+ "No working directory defined for this publication; "
313
+ "please set it in the publication's Overleaf config"
314
+ )
315
+ # Ensure we've cloned the Overleaf project
316
+ overleaf_project_dir = os.path.join(
317
+ ".calkit", "overleaf", overleaf_project_id
318
+ )
319
+ if not os.path.isdir(overleaf_project_dir):
320
+ calkit_config = calkit.config.read()
321
+ overleaf_token = calkit_config.overleaf_token
322
+ if not overleaf_token:
323
+ raise_error(
324
+ "Overleaf token not set; "
325
+ "Please set it using 'calkit config set overleaf_token'"
326
+ )
327
+ overleaf_clone_url = (
328
+ f"https://git:{overleaf_token}@git.overleaf.com/"
329
+ f"{overleaf_project_id}"
330
+ )
331
+ overleaf_repo = git.Repo.clone_from(
332
+ overleaf_clone_url, to_path=overleaf_project_dir
333
+ )
334
+ else:
335
+ overleaf_repo = git.Repo(overleaf_project_dir)
336
+ # Pull the latest version in the Overleaf project
337
+ typer.echo("Pulling the latest version from Overleaf")
338
+ overleaf_repo.git.pull()
339
+ last_sync_commit = pub["overleaf"].get("last_sync_commit")
340
+ # Determine which paths to sync and push
341
+ # TODO: Support glob patterns
342
+ sync_paths = pub["overleaf"].get("sync_paths", [])
343
+ push_paths = pub["overleaf"].get("push_paths", [])
344
+ sync_paths_in_project = [os.path.join(wdir, p) for p in sync_paths]
345
+ if not sync_paths:
346
+ warn("No sync paths defined in the publication's Overleaf config")
347
+ elif last_sync_commit:
348
+ # Compute a diff in the Overleaf project between HEAD and the last
349
+ # sync
350
+ diff = overleaf_repo.git.diff(
351
+ [last_sync_commit, "HEAD", "--"] + sync_paths
352
+ )
353
+ # Ensure the diff ends with a new line
354
+ if diff and not diff.endswith("\n"):
355
+ diff += "\n"
356
+ if verbose:
357
+ typer.echo(f"Git diff:\n{diff}")
358
+ if diff:
359
+ typer.echo("Applying to project repo")
360
+ process = subprocess.run(
361
+ ["git", "apply", "--directory", wdir, "-"],
362
+ input=diff,
363
+ text=True,
364
+ )
365
+ if process.returncode != 0:
366
+ raise_error("Failed to apply diff")
367
+ else:
368
+ typer.echo("No changes to apply")
369
+ else:
370
+ # Simply copy in all files
371
+ typer.echo(
372
+ "No last sync commit defined; "
373
+ "copying all files from Overleaf project"
374
+ )
375
+ for sync_path in sync_paths:
376
+ src = os.path.join(overleaf_project_dir, sync_path)
377
+ dst = os.path.join(wdir, sync_path)
378
+ if os.path.isdir(src):
379
+ # Copy the directory and its contents
380
+ shutil.copytree(src, dst, dirs_exist_ok=True)
381
+ elif os.path.isfile(src):
382
+ # Copy the file
383
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
384
+ shutil.copy2(src, dst)
385
+ else:
386
+ raise_error(
387
+ f"Source path {src} does not exist; "
388
+ "please check your Overleaf config"
389
+ )
390
+ # Copy our versions of sync and push paths into the Overleaf project
391
+ for sync_push_path in sync_paths + push_paths:
392
+ src = os.path.join(wdir, sync_push_path)
393
+ dst = os.path.join(overleaf_project_dir, sync_push_path)
394
+ if os.path.isdir(src):
395
+ # Remove destination directory if it exists
396
+ if os.path.isdir(dst):
397
+ shutil.rmtree(dst)
398
+ # Copy the directory and its contents
399
+ shutil.copytree(src, dst, dirs_exist_ok=True)
400
+ elif os.path.isfile(src):
401
+ # Copy the file
402
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
403
+ shutil.copy2(src, dst)
404
+ else:
405
+ raise_error(
406
+ f"Source path {src} does not exist; "
407
+ "please check your Overleaf config"
408
+ )
409
+ continue
410
+ # Stage the changes in the Overleaf project
411
+ overleaf_repo.git.add(sync_paths + push_paths)
412
+ if (
413
+ overleaf_repo.git.diff("--staged", sync_paths + push_paths)
414
+ and not no_commit
415
+ ):
416
+ commit_message = "Sync with Calkit project"
417
+ overleaf_repo.git.commit(
418
+ *(sync_paths + push_paths),
419
+ "-m",
420
+ commit_message,
421
+ )
422
+ # TODO: We should probably always push and pull to we can
423
+ # idempotently run this command
424
+ typer.echo("Pushing changes to Overleaf")
425
+ overleaf_repo.git.push()
426
+ # Update the last sync commit
427
+ last_overleaf_commit = overleaf_repo.head.commit.hexsha
428
+ typer.echo(f"Updating last sync commit as {last_overleaf_commit}")
429
+ pub["overleaf"]["last_sync_commit"] = last_overleaf_commit
430
+ # Write publications back to calkit.yaml
431
+ ck_info["publications"] = pubs
432
+ with open("calkit.yaml", "w") as f:
433
+ calkit.ryaml.dump(ck_info, f)
434
+ repo.git.add("calkit.yaml")
435
+ # Stage the changes in the project repo
436
+ repo.git.add(sync_paths_in_project)
437
+ if (
438
+ repo.git.diff("--staged", sync_paths_in_project + ["calkit.yaml"])
439
+ and not no_commit
440
+ ):
441
+ typer.echo("Committing changes to project repo")
442
+ commit_message = f"Sync {wdir} with Overleaf project"
443
+ repo.git.commit(
444
+ *(sync_paths_in_project + ["calkit.yaml"]),
445
+ "-m",
446
+ commit_message,
447
+ )
448
+ # Push to the project remote
449
+ typer.echo("Pushing changes to project Git remote")
450
+ repo.git.push()
451
+ # TODO: Add option to run the pipeline after?