calkit-python 0.19.0__tar.gz → 0.20.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 (137) hide show
  1. {calkit_python-0.19.0 → calkit_python-0.20.0}/PKG-INFO +3 -2
  2. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/__init__.py +4 -1
  3. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cli/main.py +10 -0
  4. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cli/new.py +447 -0
  5. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/config.py +9 -0
  6. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/dvc.py +9 -2
  7. calkit_python-0.20.0/calkit/github.py +70 -0
  8. calkit_python-0.20.0/calkit/releases.py +159 -0
  9. calkit_python-0.20.0/calkit/zenodo.py +82 -0
  10. calkit_python-0.20.0/docs/img/connect-zenodo.png +0 -0
  11. calkit_python-0.20.0/docs/releases.md +86 -0
  12. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/existing-project.md +74 -28
  13. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/version-control.md +6 -1
  14. {calkit_python-0.19.0 → calkit_python-0.20.0}/mkdocs.yml +1 -0
  15. {calkit_python-0.19.0 → calkit_python-0.20.0}/pyproject.toml +2 -1
  16. {calkit_python-0.19.0 → calkit_python-0.20.0}/.github/FUNDING.yml +0 -0
  17. {calkit_python-0.19.0 → calkit_python-0.20.0}/.github/workflows/docs.yml +0 -0
  18. {calkit_python-0.19.0 → calkit_python-0.20.0}/.github/workflows/publish-test.yml +0 -0
  19. {calkit_python-0.19.0 → calkit_python-0.20.0}/.github/workflows/publish.yml +0 -0
  20. {calkit_python-0.19.0 → calkit_python-0.20.0}/.gitignore +0 -0
  21. {calkit_python-0.19.0 → calkit_python-0.20.0}/LICENSE +0 -0
  22. {calkit_python-0.19.0 → calkit_python-0.20.0}/README.md +0 -0
  23. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/__main__.py +0 -0
  24. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/calc.py +0 -0
  25. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/check.py +0 -0
  26. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cli/__init__.py +0 -0
  27. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cli/check.py +0 -0
  28. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cli/config.py +0 -0
  29. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cli/core.py +0 -0
  30. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cli/import_.py +0 -0
  31. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cli/list.py +0 -0
  32. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cli/notebooks.py +0 -0
  33. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cli/office.py +0 -0
  34. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cli/update.py +0 -0
  35. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/cloud.py +0 -0
  36. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/conda.py +0 -0
  37. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/core.py +0 -0
  38. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/datasets.py +0 -0
  39. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/docker.py +0 -0
  40. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/git.py +0 -0
  41. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/gui.py +0 -0
  42. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/jupyter.py +0 -0
  43. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/magics.py +0 -0
  44. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/models.py +0 -0
  45. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/office.py +0 -0
  46. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/ops.py +0 -0
  47. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/server.py +0 -0
  48. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/templates/__init__.py +0 -0
  49. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/templates/core.py +0 -0
  50. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/templates/latex/__init__.py +0 -0
  51. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/templates/latex/article/paper.tex +0 -0
  52. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/templates/latex/core.py +0 -0
  53. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/templates/latex/jfm/jfm.bst +0 -0
  54. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/templates/latex/jfm/jfm.cls +0 -0
  55. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/templates/latex/jfm/lineno-FLM.sty +0 -0
  56. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/templates/latex/jfm/paper.tex +0 -0
  57. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/templates/latex/jfm/upmath.sty +0 -0
  58. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/__init__.py +0 -0
  59. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/cli/__init__.py +0 -0
  60. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/cli/test_list.py +0 -0
  61. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/cli/test_main.py +0 -0
  62. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/cli/test_new.py +0 -0
  63. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/test_calc.py +0 -0
  64. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/test_check.py +0 -0
  65. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/test_conda.py +0 -0
  66. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/test_core.py +0 -0
  67. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/test_dvc.py +0 -0
  68. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/test_jupyter.py +0 -0
  69. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/test_magics.py +0 -0
  70. {calkit_python-0.19.0 → calkit_python-0.20.0}/calkit/tests/test_templates.py +0 -0
  71. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/CNAME +0 -0
  72. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/apps.md +0 -0
  73. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/calculations.md +0 -0
  74. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/calkit-yaml.md +0 -0
  75. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/cli-reference.md +0 -0
  76. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/cloud-integration.md +0 -0
  77. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/datasets.md +0 -0
  78. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/environments.md +0 -0
  79. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/examples.md +0 -0
  80. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/help.md +0 -0
  81. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/img/c-to-the-k-white.svg +0 -0
  82. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/img/calkit-no-bg.png +0 -0
  83. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/index.md +0 -0
  84. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/installation.md +0 -0
  85. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/local-server.md +0 -0
  86. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/pipeline/index.md +0 -0
  87. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/pipeline/manual-steps.md +0 -0
  88. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/references.md +0 -0
  89. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/adding-latex-pub-docker.md +0 -0
  90. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/conda-envs.md +0 -0
  91. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/first-project.md +0 -0
  92. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/building-codespace.png +0 -0
  93. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/codespaces-secrets-2.png +0 -0
  94. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/editor-split.png +0 -0
  95. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/go-to-linked-code.png +0 -0
  96. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/issue-from-selection.png +0 -0
  97. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/new-project.png +0 -0
  98. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/new-pub-2.png +0 -0
  99. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/new-token.png +0 -0
  100. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/paper.tex.png +0 -0
  101. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/project-home-3.png +0 -0
  102. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/push.png +0 -0
  103. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/latex-codespaces/stage.png +0 -0
  104. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/anakin-excel.jpg +0 -0
  105. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/chart-more-rows.png +0 -0
  106. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/create-project.png +0 -0
  107. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/elsevier-research-data-guidelines.png +0 -0
  108. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/excel-chart.png +0 -0
  109. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/excel-data.png +0 -0
  110. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/insert-link-to-file.png +0 -0
  111. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/needs-clone.png +0 -0
  112. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/new-stage.png +0 -0
  113. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/phd-comics-version-control.webp +0 -0
  114. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/pipeline-out-of-date.png +0 -0
  115. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/status-more-rows.png +0 -0
  116. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/uncommitted-changes.png +0 -0
  117. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/untracked-data.png +0 -0
  118. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/updated-publication.png +0 -0
  119. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/word-to-pdf-stage-2.png +0 -0
  120. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/office/workflow-page.png +0 -0
  121. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/openfoam/clone.png +0 -0
  122. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/openfoam/create-project.png +0 -0
  123. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/openfoam/datasets-page.png +0 -0
  124. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/openfoam/figure-on-website-updated.png +0 -0
  125. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/openfoam/figure-on-website.png +0 -0
  126. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/openfoam/new-token.png +0 -0
  127. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/openfoam/reclone.png +0 -0
  128. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/openfoam/status-after-import-dataset.png +0 -0
  129. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/img/run-proc.png +0 -0
  130. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/latex-codespaces.md +0 -0
  131. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/matlab.md +0 -0
  132. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/notebook-pipeline.md +0 -0
  133. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/office.md +0 -0
  134. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/openfoam.md +0 -0
  135. {calkit_python-0.19.0 → calkit_python-0.20.0}/docs/tutorials/procedures.md +0 -0
  136. {calkit_python-0.19.0 → calkit_python-0.20.0}/test/pipeline.ipynb +0 -0
  137. {calkit_python-0.19.0 → calkit_python-0.20.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calkit-python
3
- Version: 0.19.0
3
+ Version: 0.20.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,9 +12,10 @@ 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: bibtexparser
15
16
  Requires-Dist: checksumdir
16
17
  Requires-Dist: docx2pdf
17
- Requires-Dist: dvc
18
+ Requires-Dist: dvc>=3.59.0
18
19
  Requires-Dist: eval-type-backport; python_version < '3.10'
19
20
  Requires-Dist: fastapi
20
21
  Requires-Dist: gitpython
@@ -1,4 +1,4 @@
1
- __version__ = "0.19.0"
1
+ __version__ = "0.20.0"
2
2
 
3
3
  from .core import *
4
4
  from . import git
@@ -12,3 +12,6 @@ from . import templates
12
12
  from . import conda
13
13
  from . import calc
14
14
  from . import check
15
+ from . import github
16
+ from . import zenodo
17
+ from . import releases
@@ -536,6 +536,16 @@ def push(
536
536
  raise_error("DVC push failed")
537
537
 
538
538
 
539
+ @app.command(name="sync")
540
+ def sync(
541
+ no_check_auth: Annotated[bool, typer.Option("--no-check-auth")] = False,
542
+ ):
543
+ """Sync the project repo by pulling and then pushing."""
544
+ # TODO: Walk users through merge conflicts if they arise
545
+ pull(no_check_auth=no_check_auth)
546
+ push(no_check_auth=no_check_auth)
547
+
548
+
539
549
  @app.command(name="ignore")
540
550
  def ignore(
541
551
  path: Annotated[str, typer.Argument(help="Path to ignore.")],
@@ -6,9 +6,13 @@ import csv
6
6
  import os
7
7
  import shutil
8
8
  import subprocess
9
+ import zipfile
9
10
  from enum import Enum
10
11
 
12
+ import bibtexparser
13
+ import dotenv
11
14
  import git
15
+ import requests
12
16
  import typer
13
17
  from git.exc import GitCommandError, InvalidGitRepositoryError
14
18
  from typing_extensions import Annotated
@@ -1434,3 +1438,446 @@ def new_stage(
1434
1438
  repo.git.commit(
1435
1439
  ["dvc.yaml", "-m", f"Add {kind.value} pipeline stage '{name}'"]
1436
1440
  )
1441
+
1442
+
1443
+ @new_app.command(name="release")
1444
+ def new_release(
1445
+ name: Annotated[
1446
+ str,
1447
+ typer.Option(
1448
+ "--name",
1449
+ "-n",
1450
+ help=(
1451
+ "A name for the release, typically kebab-case. "
1452
+ "Will be used for the Git tag and GitHub release title."
1453
+ ),
1454
+ ),
1455
+ ],
1456
+ release_type: Annotated[
1457
+ str, typer.Option("--kind", help="What kind of release to create.")
1458
+ ] = "project",
1459
+ path: Annotated[
1460
+ str,
1461
+ typer.Argument(help="The path to release; '.' for a project release."),
1462
+ ] = ".",
1463
+ description: Annotated[
1464
+ str,
1465
+ typer.Option(
1466
+ "--description",
1467
+ "--desc",
1468
+ help=(
1469
+ "A description of the release. "
1470
+ "Will be auto-generated if not provided."
1471
+ ),
1472
+ ),
1473
+ ] = None,
1474
+ release_date: Annotated[
1475
+ str,
1476
+ typer.Option("--date", help="Release date. Will default to today."),
1477
+ ] = None,
1478
+ dry_run: Annotated[
1479
+ bool,
1480
+ typer.Option(
1481
+ "--dry-run",
1482
+ help="Only print actions that would be taken but don't take them.",
1483
+ ),
1484
+ ] = False,
1485
+ no_commit: Annotated[
1486
+ bool,
1487
+ typer.Option(
1488
+ "--no-commit",
1489
+ help="Do not commit changes to Git repo.",
1490
+ ),
1491
+ ] = False,
1492
+ no_push: Annotated[
1493
+ bool,
1494
+ typer.Option(
1495
+ "--no-push",
1496
+ help="Do not push to Git remote.",
1497
+ ),
1498
+ ] = False,
1499
+ ):
1500
+ """Create a new release."""
1501
+ if release_type not in [
1502
+ "project",
1503
+ "publication",
1504
+ "figure",
1505
+ "dataset",
1506
+ "software",
1507
+ ]:
1508
+ raise_error(f"Unknown release type '{release_type}'")
1509
+ # TODO: Check path is consistent with release type
1510
+ dotenv.load_dotenv()
1511
+ # First see if we have a Zenodo token
1512
+ typer.echo("Checking for Zenodo token")
1513
+ try:
1514
+ token = calkit.zenodo.get_token()
1515
+ except Exception as e:
1516
+ raise_error(e)
1517
+ ck_info = calkit.load_calkit_info()
1518
+ releases = ck_info.get("releases", {})
1519
+ # TODO: Enable resuming a release if upload failed part-way?
1520
+ if name in releases:
1521
+ raise_error(f"Release with name '{name}' already exists")
1522
+ repo = git.Repo()
1523
+ if name in repo.tags:
1524
+ raise_error(f"Git tag with name '{name}' already exists")
1525
+ release_dir = f".calkit/releases/{name}"
1526
+ release_files_dir = release_dir + "/files"
1527
+ os.makedirs(release_files_dir, exist_ok=True)
1528
+ # Ignore release files dir
1529
+ typer.echo(f"Ignoring {release_files_dir}")
1530
+ gitignore_path = release_dir + "/.gitignore"
1531
+ with open(gitignore_path, "w") as f:
1532
+ f.write("/files\n")
1533
+ if not dry_run:
1534
+ repo.git.add(gitignore_path)
1535
+ if release_date is None:
1536
+ release_date = str(calkit.utcnow().date())
1537
+ typer.echo(f"Using release date: {release_date}")
1538
+ # Gather up the list of files to upload
1539
+ if path == ".":
1540
+ zip_path = release_files_dir + "/archive.zip"
1541
+ all_paths = calkit.releases.ls_files()
1542
+ typer.echo(f"Adding files to {zip_path}")
1543
+ with zipfile.ZipFile(zip_path, "w") as zipf:
1544
+ for fpath in all_paths:
1545
+ zipf.write(fpath)
1546
+ if description is None:
1547
+ description = "An archive of all project files."
1548
+ title = ck_info.get("title")
1549
+ if title is None:
1550
+ warn("Project has no title")
1551
+ title = typer.prompt("Enter a title for the project")
1552
+ ck_info["title"] = title
1553
+ else:
1554
+ # TODO: Handle directories, e.g., datasets
1555
+ if not os.path.isfile(path):
1556
+ raise_error("Single artifact releases must be a single file")
1557
+ typer.echo(f"Copying {path} into {release_files_dir}")
1558
+ shutil.copy2(path, release_files_dir)
1559
+ if description is None:
1560
+ description = f"Release {release_type} at {path}."
1561
+ # Check that this artifact actually exists
1562
+ artifact_key = (
1563
+ release_type + "s" if release_type != "software" else release_type
1564
+ )
1565
+ artifacts = ck_info.get(artifact_key, [])
1566
+ title = None
1567
+ artifact = None
1568
+ for a in artifacts:
1569
+ if a.get("path") == path:
1570
+ artifact = a
1571
+ title = artifact.get("title")
1572
+ break
1573
+ if artifact is None:
1574
+ raise_error(f"{release_type} at {path} not defined in calkit.yaml")
1575
+ if title is None:
1576
+ raise_error(f"{release_type} at {path} has no title")
1577
+ # Save a metadata file with each DVC file's MD5 checksum
1578
+ dvc_md5s = calkit.releases.make_dvc_md5s(
1579
+ zipfile="archive.zip" if path == "." else None,
1580
+ paths=None if path == "." else [path],
1581
+ )
1582
+ dvc_md5s_path = release_dir + "/dvc-md5s.yaml"
1583
+ typer.echo(f"Saving DVC MD5 info to {dvc_md5s_path}")
1584
+ with open(dvc_md5s_path, "w") as f:
1585
+ calkit.ryaml.dump(dvc_md5s, f)
1586
+ if not dry_run:
1587
+ repo.git.add(dvc_md5s_path)
1588
+ # Create a README for the Zenodo release
1589
+ readme_txt = f"# {title}\n"
1590
+ git_rev = repo.git.rev_parse(["--short", "HEAD"])
1591
+ readme_txt += (
1592
+ f"\nThis is a {release_type} release ({name}) generated with "
1593
+ f"Calkit from Git rev {git_rev}.\n"
1594
+ )
1595
+ readme_path = release_files_dir + "/README.md"
1596
+ with open(readme_path, "w") as f:
1597
+ f.write(readme_txt)
1598
+ # Check size of files dir
1599
+ size = calkit.get_size(release_files_dir)
1600
+ typer.echo(f"Release size: {(size / 1e6):.1f} MB")
1601
+ if size >= 50e9:
1602
+ raise_error("Release is too large (>50 GB) to upload to Zenodo")
1603
+ # Upload to Zenodo
1604
+ # Is there already a deposition for this release, which indicates we should
1605
+ # create a new version?
1606
+ zenodo_dep_id = None
1607
+ project_name = calkit.git.detect_project_name()
1608
+ zenodo_metadata = dict(
1609
+ title=title,
1610
+ description=description,
1611
+ notes=f"Created from Calkit project {project_name} release {name}.",
1612
+ publication_date=release_date,
1613
+ )
1614
+ # Determine creators from authors, adding to project if not present
1615
+ authors = ck_info.get("authors", [])
1616
+ if not authors:
1617
+ warn("No authors defined for the project")
1618
+ still_entering_authors = True
1619
+ n = 0
1620
+ while still_entering_authors:
1621
+ n += 1
1622
+ author = dict()
1623
+ author["first_name"] = typer.prompt(
1624
+ f"Enter the first name of author {n}"
1625
+ )
1626
+ author["last_name"] = typer.prompt(
1627
+ f"Enter the last name of author {n}"
1628
+ )
1629
+ author["affiliation"] = typer.prompt(
1630
+ f"Enter the affiliation of author {n}"
1631
+ )
1632
+ has_orchid = typer.confirm(
1633
+ f"Does author {n} have an ORCID?", default=False
1634
+ )
1635
+ if has_orchid:
1636
+ author["orcid"] = typer.prompt(
1637
+ f"Enter the ORCID of author {n}"
1638
+ )
1639
+ authors.append(author)
1640
+ still_entering_authors = typer.confirm(
1641
+ "Are there more authors to enter?", default=True
1642
+ )
1643
+ ck_info["authors"] = authors
1644
+ zenodo_creators = []
1645
+ for author in authors:
1646
+ creator = dict(
1647
+ name=f"{author['last_name']}, {author['first_name']}",
1648
+ affiliation=author["affiliation"],
1649
+ )
1650
+ if "orcid" in author:
1651
+ creator["orcid"] = author["orcid"]
1652
+ zenodo_creators.append(creator)
1653
+ zenodo_metadata["creators"] = zenodo_creators
1654
+ if release_type == "project":
1655
+ zenodo_metadata["upload_type"] = "other"
1656
+ elif release_type == "publication":
1657
+ pubtype = artifact.get("kind")
1658
+ if pubtype == "journal-article":
1659
+ zenodo_metadata["upload_type"] = "publication"
1660
+ zenodo_metadata["publication_type"] = "article"
1661
+ elif pubtype == "presentation":
1662
+ zenodo_metadata["upload_type"] = "presentation"
1663
+ elif pubtype == "poster":
1664
+ zenodo_metadata["upload_type"] = "poster"
1665
+ else:
1666
+ zenodo_metadata["upload_type"] = "other"
1667
+ elif release_type in ["dataset", "software"]:
1668
+ zenodo_metadata["upload_type"] = release_type
1669
+ elif release_type == "figure":
1670
+ zenodo_metadata["upload_type"] = "image"
1671
+ zenodo_metadata["image_type"] = "figure"
1672
+ else:
1673
+ zenodo_metadata["upload_type"] = "other"
1674
+ doi = None
1675
+ url = None
1676
+ for existing_name, existing_release in releases.items():
1677
+ if (
1678
+ existing_release.get("kind") == release_type
1679
+ and existing_release.get("path") == path
1680
+ and existing_release.get("publisher") == "zenodo.org"
1681
+ ):
1682
+ zenodo_dep_id = existing_release.get("zenodo_dep_id")
1683
+ typer.echo(
1684
+ f"Found existing Zenodo deposition ID {zenodo_dep_id} "
1685
+ f"in release {existing_name} to create new version for"
1686
+ )
1687
+ break
1688
+ if not dry_run:
1689
+ typer.echo("Uploading to Zenodo")
1690
+ if zenodo_dep_id is not None:
1691
+ # Create a new version of the existing deposit
1692
+ # TODO: This might fail if a new version is in progress, in which
1693
+ # case we should discard that
1694
+ zenodo_dep = calkit.zenodo.post(
1695
+ f"/deposit/depositions/{zenodo_dep_id}/actions/newversion",
1696
+ json=dict(metadata=zenodo_metadata),
1697
+ )
1698
+ typer.echo("Created new version deposition")
1699
+ typer.echo("Fetching latest draft")
1700
+ zenodo_dep = requests.get(
1701
+ zenodo_dep["links"]["latest_draft"],
1702
+ params=dict(access_token=token),
1703
+ ).json()
1704
+ zenodo_dep_id = zenodo_dep["id"]
1705
+ typer.echo(
1706
+ f"Fetched latest draft with deposition ID: {zenodo_dep_id} "
1707
+ )
1708
+ # Now update that draft with the metadata
1709
+ typer.echo("Updating latest draft metadata")
1710
+ calkit.zenodo.put(
1711
+ f"/deposit/depositions/{zenodo_dep_id}",
1712
+ json=dict(metadata=zenodo_metadata),
1713
+ )
1714
+ else:
1715
+ zenodo_dep = calkit.zenodo.post(
1716
+ "/deposit/depositions", json=dict(metadata=zenodo_metadata)
1717
+ )
1718
+ zenodo_dep_id = zenodo_dep["id"]
1719
+ bucket_url = zenodo_dep["links"]["bucket"]
1720
+ files = os.listdir(release_files_dir)
1721
+ for filename in files:
1722
+ typer.echo(f"Uploading {filename}")
1723
+ fpath = os.path.join(release_files_dir, filename)
1724
+ with open(fpath, "rb") as f:
1725
+ resp = requests.put(
1726
+ f"{bucket_url}/{filename}",
1727
+ data=f,
1728
+ params={"access_token": token},
1729
+ )
1730
+ typer.echo(f"Status code: {resp.status_code}")
1731
+ resp.raise_for_status()
1732
+ # Now publish the new deposition
1733
+ typer.echo(f"Publishing Zenodo deposition ID {zenodo_dep_id}")
1734
+ zenodo_dep = calkit.zenodo.post(
1735
+ f"/deposit/depositions/{zenodo_dep_id}/actions/publish"
1736
+ )
1737
+ zenodo_dep_id = zenodo_dep["id"]
1738
+ doi = zenodo_dep["doi"]
1739
+ url = zenodo_dep["doi_url"]
1740
+ typer.echo(f"Published to Zenodo with DOI: {doi}")
1741
+ else:
1742
+ typer.echo(f"Would have posted Zenodo deposition: {zenodo_metadata}")
1743
+ # If this is a project release, add Zenodo badge to project README if
1744
+ # it doesn't exist
1745
+ doi_md = None
1746
+ if release_type == "project" and doi is not None:
1747
+ typer.echo("Adding DOI badge to README.md")
1748
+ doi_md = (
1749
+ f"[![DOI](https://zenodo.org/badge/DOI/{doi}.svg)]"
1750
+ f"(https://handle.stage.datacite.org/{doi})"
1751
+ )
1752
+ if os.path.isfile("README.md"):
1753
+ with open("README.md") as f:
1754
+ readme_txt = f.read()
1755
+ else:
1756
+ readme_txt = f"# {title}\n"
1757
+ existing_lines = readme_txt.split("\n")
1758
+ new_lines = []
1759
+ first_content_line_index = None
1760
+ for n, line in enumerate(existing_lines):
1761
+ if line.startswith(doi_md[:6]):
1762
+ pass # Skip DOI lines
1763
+ else:
1764
+ if (
1765
+ n != 0
1766
+ and line.strip()
1767
+ and first_content_line_index is None
1768
+ ):
1769
+ first_content_line_index = len(new_lines)
1770
+ new_lines.append(line)
1771
+ # Ensure first 3 lines are title, blank, DOI lines
1772
+ new_lines = (
1773
+ [new_lines[0]]
1774
+ + ["", doi_md, ""]
1775
+ + new_lines[first_content_line_index:]
1776
+ )
1777
+ readme_txt = "\n".join(new_lines)
1778
+ with open("README.md", "w") as f:
1779
+ f.write(readme_txt)
1780
+ if not dry_run:
1781
+ repo.git.add("README.md")
1782
+ # Create Git tag
1783
+ if not dry_run:
1784
+ repo.git.tag(["-a", name, "-m", description])
1785
+ else:
1786
+ typer.echo(
1787
+ f"Would have created Git tag {name} with message: {description}"
1788
+ )
1789
+ # Save release in Calkit info
1790
+ release = dict(
1791
+ kind=release_type,
1792
+ path=path,
1793
+ git_rev=git_rev,
1794
+ date=release_date,
1795
+ publisher="zenodo.org",
1796
+ zenodo_dep_id=zenodo_dep_id,
1797
+ doi=doi,
1798
+ url=url,
1799
+ description=description,
1800
+ )
1801
+ releases[name] = release
1802
+ ck_info["releases"] = releases
1803
+ # Create CITATION.cff file
1804
+ if release_type == "project":
1805
+ typer.echo("Writing CITATION.cff")
1806
+ cff = calkit.releases.create_citation_cff(
1807
+ ck_info=ck_info, release_name=name, release_date=release_date
1808
+ )
1809
+ with open("CITATION.cff", "w") as f:
1810
+ calkit.ryaml.dump(cff, f)
1811
+ if not dry_run:
1812
+ repo.git.add("CITATION.cff")
1813
+ # Add to references so it can be cited
1814
+ typer.echo("Adding BibTeX entry to references")
1815
+ reference_collections = ck_info.get("references", [])
1816
+ if len(reference_collections) > 1:
1817
+ warn("Multiple references collections; writing to first")
1818
+ if not reference_collections:
1819
+ references = dict(path="references.bib")
1820
+ ck_info["references"] = [references]
1821
+ else:
1822
+ references = reference_collections[0]
1823
+ ref_path = references.get("path", "references.bib")
1824
+ try:
1825
+ if os.path.isfile(ref_path):
1826
+ with open(ref_path) as f:
1827
+ reflib = bibtexparser.load(f)
1828
+ else:
1829
+ reflib = bibtexparser.bibdatabase.BibDatabase()
1830
+ zenodo_bibtex = calkit.releases.create_bibtex(
1831
+ authors=authors,
1832
+ release_date=release_date,
1833
+ title=title,
1834
+ doi=doi,
1835
+ dep_id=zenodo_dep_id,
1836
+ )
1837
+ new_entry = bibtexparser.loads(zenodo_bibtex).entries[0]
1838
+ # Search through entries for one with the same DOI, and replace if
1839
+ # there is a match
1840
+ existing_index = None
1841
+ for n, entry in enumerate(reflib.entries):
1842
+ if entry.get("doi") == doi:
1843
+ typer.echo("Found matching DOI in existing references")
1844
+ existing_index = n
1845
+ if existing_index is not None:
1846
+ _ = reflib.entries.pop(existing_index)
1847
+ reflib.entries.append(new_entry)
1848
+ with open(ref_path, "w") as f:
1849
+ bibtexparser.dump(reflib, f)
1850
+ if not dry_run:
1851
+ repo.git.add(ref_path)
1852
+ except Exception as e:
1853
+ warn(f"Failed to add to references: {e}")
1854
+ # Write out Calkit metadata
1855
+ if not dry_run:
1856
+ typer.echo("Writing to calkit.yaml")
1857
+ with open("calkit.yaml", "w") as f:
1858
+ calkit.ryaml.dump(ck_info, f)
1859
+ repo.git.add("calkit.yaml")
1860
+ else:
1861
+ typer.echo(f"Would have created release:\n{release}")
1862
+ # Commit with Git
1863
+ if not dry_run and calkit.git.get_staged_files() and not no_commit:
1864
+ repo.git.commit(["-m", f"Create new {release_type} release {name}"])
1865
+ # Push with Git
1866
+ if not dry_run and not no_push and not no_commit:
1867
+ repo.git.push(["origin", repo.active_branch.name, "--tags"])
1868
+ # Now create GitHub release
1869
+ typer.echo("Creating GitHub release")
1870
+ release_body = ""
1871
+ if doi_md is not None:
1872
+ release_body += doi_md + "\n\n"
1873
+ release_body += description
1874
+ resp = calkit.cloud.post(
1875
+ f"/projects/{project_name}/github-releases",
1876
+ json=dict(
1877
+ tag_name=name,
1878
+ body=release_body,
1879
+ ),
1880
+ )
1881
+ typer.echo(f"Created GitHub release at: {resp['url']}")
1882
+ # TODO: Upload assets for GitHub release if they're not too big?
1883
+ typer.echo(f"New {release_type} release {name} successfully created")
@@ -37,6 +37,10 @@ def get_app_name() -> str:
37
37
  return __package__ + get_env_suffix()
38
38
 
39
39
 
40
+ def get_local_config_path() -> str:
41
+ return os.path.join(".calkit", "config.yaml")
42
+
43
+
40
44
  class Settings(BaseSettings):
41
45
  model_config = SettingsConfigDict(
42
46
  yaml_file=os.path.join(
@@ -46,12 +50,16 @@ class Settings(BaseSettings):
46
50
  ),
47
51
  extra="ignore",
48
52
  env_prefix="CALKIT" + get_env_suffix(sep="_") + "_",
53
+ env_file=".env",
54
+ env_file_encoding="utf-8",
49
55
  )
50
56
  username: str | None = None
51
57
  email: str | None = None
52
58
  token: str | None = None
53
59
  dvc_token: str | None = None
54
60
  dataframe_engine: Literal["pandas", "polars"] = "pandas"
61
+ github_token: str | None = None
62
+ zenodo_token: str | None = None
55
63
 
56
64
  @classmethod
57
65
  def settings_customise_sources(
@@ -65,6 +73,7 @@ class Settings(BaseSettings):
65
73
  return (
66
74
  init_settings,
67
75
  env_settings,
76
+ dotenv_settings,
68
77
  YamlConfigSettingsSource(settings_cls),
69
78
  )
70
79
 
@@ -126,10 +126,17 @@ def get_remotes(wdir: str = None) -> dict[str, str]:
126
126
  return resp
127
127
 
128
128
 
129
- def list_paths(wdir: str = None) -> list[str]:
129
+ def list_paths(wdir: str = None, recursive=False) -> list[str]:
130
130
  """List paths tracked with DVC."""
131
+ return [p.get("path") for p in list_files(wdir=wdir, recursive=recursive)]
132
+
133
+
134
+ def list_files(wdir: str = None, recursive=True) -> list[dict]:
135
+ """Return a list with all files in DVC, including their path and md5
136
+ checksum.
137
+ """
131
138
  dvc_repo = dvc.repo.Repo(wdir)
132
- return [p.get("path") for p in dvc_repo.ls(".", dvc_only=True)]
139
+ return dvc_repo.ls(".", dvc_only=True, recursive=recursive)
133
140
 
134
141
 
135
142
  def get_output_revisions(path: str):
@@ -0,0 +1,70 @@
1
+ """Functionality for working with GitHub."""
2
+
3
+ import os
4
+ from functools import partial
5
+ from typing import Literal
6
+
7
+ import dotenv
8
+ import requests
9
+ from requests.exceptions import HTTPError
10
+
11
+ import calkit
12
+
13
+
14
+ def get_token() -> str:
15
+ dotenv.load_dotenv()
16
+ token = calkit.config.read().github_token
17
+ if token is None:
18
+ token = os.getenv("GITHUB_TOKEN")
19
+ if token is None:
20
+ token = calkit.cloud.get("/user/github-token")["access_token"]
21
+ return token
22
+
23
+
24
+ def get_base_url() -> str:
25
+ return "https://api.github.com"
26
+
27
+
28
+ def _request(
29
+ kind: Literal["get", "post", "put", "patch", "delete"],
30
+ path: str,
31
+ params: dict | None = None,
32
+ json: dict | None = None,
33
+ data: dict | None = None,
34
+ headers: dict | None = None,
35
+ as_json=True,
36
+ **kwargs,
37
+ ):
38
+ if headers is None:
39
+ headers = {}
40
+ if "Authorization" not in headers:
41
+ headers = headers | {"Authorization": f"Bearer {get_token()}"}
42
+ func = getattr(requests, kind)
43
+ resp = func(
44
+ get_base_url() + path,
45
+ params=params,
46
+ json=json,
47
+ data=data,
48
+ headers=headers,
49
+ **kwargs,
50
+ )
51
+ if resp.status_code >= 400:
52
+ resp_json = resp.json()
53
+ msg = f"{resp.status_code}: "
54
+ if "message" in resp_json:
55
+ msg += resp_json["message"]
56
+ if "errors" in resp_json:
57
+ msg += f"\nErrors:\n{resp_json['errors']}"
58
+ raise HTTPError(msg)
59
+ resp.raise_for_status()
60
+ if as_json:
61
+ return resp.json()
62
+ else:
63
+ return resp
64
+
65
+
66
+ get = partial(_request, "get")
67
+ post = partial(_request, "post")
68
+ patch = partial(_request, "patch")
69
+ put = partial(_request, "put")
70
+ delete = partial(_request, "delete")