calkit-python 0.0.6__tar.gz → 0.0.8__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 (36) hide show
  1. {calkit_python-0.0.6 → calkit_python-0.0.8}/PKG-INFO +1 -1
  2. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/__init__.py +1 -1
  3. calkit_python-0.0.8/calkit/cli/__init__.py +6 -0
  4. calkit_python-0.0.8/calkit/cli/config.py +42 -0
  5. calkit_python-0.0.8/calkit/cli/core.py +22 -0
  6. calkit_python-0.0.8/calkit/cli/main.py +358 -0
  7. calkit_python-0.0.8/calkit/cli/new.py +107 -0
  8. calkit_python-0.0.8/calkit/cli/notebooks.py +76 -0
  9. calkit_python-0.0.6/calkit/cli.py +0 -392
  10. {calkit_python-0.0.6 → calkit_python-0.0.8}/.github/FUNDING.yml +0 -0
  11. {calkit_python-0.0.6 → calkit_python-0.0.8}/.github/workflows/publish-test.yml +0 -0
  12. {calkit_python-0.0.6 → calkit_python-0.0.8}/.github/workflows/publish.yml +0 -0
  13. {calkit_python-0.0.6 → calkit_python-0.0.8}/.gitignore +0 -0
  14. {calkit_python-0.0.6 → calkit_python-0.0.8}/LICENSE +0 -0
  15. {calkit_python-0.0.6 → calkit_python-0.0.8}/README.md +0 -0
  16. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/cloud.py +0 -0
  17. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/config.py +0 -0
  18. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/core.py +0 -0
  19. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/data.py +0 -0
  20. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/dvc.py +0 -0
  21. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/git.py +0 -0
  22. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/gui.py +0 -0
  23. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/jupyter.py +0 -0
  24. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/models.py +0 -0
  25. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/server.py +0 -0
  26. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/tests/__init__.py +0 -0
  27. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/tests/test_core.py +0 -0
  28. {calkit_python-0.0.6 → calkit_python-0.0.8}/calkit/tests/test_jupyter.py +0 -0
  29. {calkit_python-0.0.6 → calkit_python-0.0.8}/examples/cfd-study/README.md +0 -0
  30. {calkit_python-0.0.6 → calkit_python-0.0.8}/examples/cfd-study/calkit.yaml +0 -0
  31. {calkit_python-0.0.6 → calkit_python-0.0.8}/examples/cfd-study/config/simulations/runs.csv +0 -0
  32. {calkit_python-0.0.6 → calkit_python-0.0.8}/examples/cfd-study/notebook.ipynb +0 -0
  33. {calkit_python-0.0.6 → calkit_python-0.0.8}/examples/ms-office/.gitignore +0 -0
  34. {calkit_python-0.0.6 → calkit_python-0.0.8}/examples/ms-office/README.md +0 -0
  35. {calkit_python-0.0.6 → calkit_python-0.0.8}/examples/ms-office/calkit.yaml +0 -0
  36. {calkit_python-0.0.6 → calkit_python-0.0.8}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: calkit-python
3
- Version: 0.0.6
3
+ Version: 0.0.8
4
4
  Summary: Reproducibility simplified.
5
5
  Project-URL: Homepage, https://github.com/calkit/calkit
6
6
  Project-URL: Issues, https://github.com/calkit/calkit/issues
@@ -1,4 +1,4 @@
1
- __version__ = "0.0.6"
1
+ __version__ = "0.0.8"
2
2
 
3
3
  from .core import *
4
4
  from . import git
@@ -0,0 +1,6 @@
1
+ from .core import *
2
+
3
+
4
+ def run() -> None:
5
+ from .main import app
6
+ app()
@@ -0,0 +1,42 @@
1
+ """Config CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from calkit import config
8
+ from calkit.dvc import configure_remote, set_remote_auth
9
+
10
+ config_app = typer.Typer(no_args_is_help=True)
11
+
12
+
13
+ @config_app.command(name="set")
14
+ def set_config_value(key: str, value: str):
15
+ try:
16
+ cfg = config.read()
17
+ cfg = config.Settings.model_validate(cfg.model_dump() | {key: value})
18
+ # Kind of a hack for setting the password computed field
19
+ # Types have been validated above, so this won't hurt to do again
20
+ setattr(cfg, key, value)
21
+ except FileNotFoundError:
22
+ # TODO: This fails if we try to set password before any config has
23
+ # been written
24
+ # Username is fine
25
+ cfg = config.Settings.model_validate({key: value})
26
+ cfg.write()
27
+
28
+
29
+ @config_app.command(name="get")
30
+ def get_config_value(key: str) -> None:
31
+ cfg = config.read()
32
+ val = getattr(cfg, key)
33
+ if val is not None:
34
+ print(val)
35
+ else:
36
+ print()
37
+
38
+
39
+ @config_app.command(name="setup-remote")
40
+ def setup_remote():
41
+ configure_remote()
42
+ set_remote_auth()
@@ -0,0 +1,22 @@
1
+ """Core CLI functionality."""
2
+
3
+ import os
4
+ import pty
5
+ import subprocess
6
+
7
+ import typer
8
+
9
+
10
+ def print_sep(name: str):
11
+ width = 66
12
+ txt_width = len(name) + 2
13
+ buffer_width = (width - txt_width) // 2
14
+ buffer = "-" * buffer_width
15
+ typer.echo(f"{buffer} {name} {buffer}")
16
+
17
+
18
+ def run_cmd(cmd: list[str]):
19
+ if os.name == "nt":
20
+ subprocess.call(cmd)
21
+ else:
22
+ pty.spawn(cmd, lambda fd: os.read(fd, 1024))
@@ -0,0 +1,358 @@
1
+ """Main CLI app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+
8
+ import typer
9
+ from typing_extensions import Annotated, Optional
10
+
11
+ import calkit
12
+ from calkit.cli import print_sep, run_cmd
13
+ from calkit.cli.config import config_app
14
+ from calkit.cli.new import new_app
15
+ from calkit.cli.notebooks import notebooks_app
16
+
17
+ app = typer.Typer(
18
+ invoke_without_command=True,
19
+ no_args_is_help=True,
20
+ context_settings=dict(help_option_names=["-h", "--help"]),
21
+ pretty_exceptions_show_locals=False,
22
+ )
23
+ app.add_typer(config_app, name="config", help="Configure Calkit.")
24
+ app.add_typer(
25
+ new_app, name="new", help="Add new Calkit object (to calkit.yaml)."
26
+ )
27
+ app.add_typer(notebooks_app, name="nb", help="Work with Jupyter notebooks.")
28
+
29
+
30
+ @app.callback()
31
+ def main(
32
+ version: Annotated[
33
+ bool,
34
+ typer.Option("--version", help="Show version and exit."),
35
+ ] = False,
36
+ ):
37
+ if version:
38
+ typer.echo(calkit.__version__)
39
+ raise typer.Exit()
40
+
41
+
42
+ @app.command(name="status")
43
+ def get_status():
44
+ """Get a unified Git and DVC status."""
45
+ print_sep("Code (Git)")
46
+ run_cmd(["git", "status"])
47
+ typer.echo()
48
+ print_sep("Data (DVC)")
49
+ run_cmd(["dvc", "data", "status"])
50
+ typer.echo()
51
+ print_sep("Pipeline (DVC)")
52
+ run_cmd(["dvc", "status"])
53
+
54
+
55
+ @app.command(name="diff")
56
+ def diff():
57
+ """Get a unified Git and DVC diff."""
58
+ print_sep("Code (Git)")
59
+ run_cmd(["git", "diff"])
60
+ print_sep("Pipeline (DVC)")
61
+ run_cmd(["dvc", "diff"])
62
+
63
+
64
+ @app.command(name="add")
65
+ def add(
66
+ paths: list[str],
67
+ commit_message: Annotated[
68
+ str,
69
+ typer.Option(
70
+ "-m",
71
+ "--commit-message",
72
+ help="Automatically commit and use this as a message.",
73
+ ),
74
+ ] = None,
75
+ push_commit: Annotated[
76
+ bool, typer.Option("--push", help="Push after committing.")
77
+ ] = False,
78
+ to: Annotated[
79
+ str,
80
+ typer.Option(
81
+ "--to", "-t", help="System with which to add (git or dvc)."
82
+ ),
83
+ ] = None,
84
+ ):
85
+ """Add paths to the repo.
86
+
87
+ Code will be added to Git and data will be added to DVC.
88
+
89
+ Note: This will enable the 'autostage' feature of DVC, automatically
90
+ adding any .dvc files to Git when adding to DVC.
91
+ """
92
+ if to is not None and to not in ["git", "dvc"]:
93
+ typer.echo(f"Invalid option for 'to': {to}")
94
+ raise typer.Exit(1)
95
+ # Ensure autostage is enabled for DVC
96
+ subprocess.call(["dvc", "config", "core.autostage", "true"])
97
+ subprocess.call(["git", "add", ".dvc/config"])
98
+ if to is not None:
99
+ subprocess.call([to, "add"] + paths)
100
+ else:
101
+ dvc_extensions = [
102
+ ".png",
103
+ ".h5",
104
+ ".parquet",
105
+ ".pickle",
106
+ ".mp4",
107
+ ".avi",
108
+ ".webm",
109
+ ".pdf",
110
+ ]
111
+ dvc_size_thresh_bytes = 1_000_000
112
+ if "." in paths and to is None:
113
+ typer.echo("Cannot add '.' with calkit; use git or dvc")
114
+ raise typer.Exit(1)
115
+ if to is None:
116
+ for path in paths:
117
+ if os.path.isdir(path):
118
+ typer.echo("Cannot auto-add directories; use git or dvc")
119
+ raise typer.Exit(1)
120
+ for path in paths:
121
+ # Detect if this file should be tracked with Git or DVC
122
+ if os.path.splitext(path)[-1] in dvc_extensions:
123
+ typer.echo(f"Adding {path} to DVC per its extension")
124
+ subprocess.call(["dvc", "add", path])
125
+ continue
126
+ if os.path.getsize(path) > dvc_size_thresh_bytes:
127
+ typer.echo(
128
+ f"Adding {path} to DVC since it's greater than 1 MB"
129
+ )
130
+ subprocess.call(["dvc", "add", path])
131
+ continue
132
+ typer.echo(f"Adding {path} to Git")
133
+ subprocess.call(["git", "add", path])
134
+ if commit_message is not None:
135
+ subprocess.call(["git", "commit", "-m", commit_message])
136
+ if push_commit:
137
+ push()
138
+
139
+
140
+ @app.command(name="commit")
141
+ def commit(
142
+ all: Annotated[
143
+ Optional[bool],
144
+ typer.Option(
145
+ "--all", "-a", help="Automatically stage all changed files."
146
+ ),
147
+ ] = False,
148
+ message: Annotated[
149
+ Optional[str], typer.Option("--message", "-m", help="Commit message.")
150
+ ] = None,
151
+ push_commit: Annotated[
152
+ bool,
153
+ typer.Option(
154
+ "--push", help="Push to both Git and DVC after committing."
155
+ ),
156
+ ] = False,
157
+ ):
158
+ """Commit a change to the repo."""
159
+ cmd = ["git", "commit"]
160
+ if all:
161
+ cmd.append("-a")
162
+ if message:
163
+ cmd += ["-m", message]
164
+ subprocess.call(cmd)
165
+ if push_commit:
166
+ push()
167
+
168
+
169
+ @app.command(name="pull", help="Pull with both Git and DVC.")
170
+ def pull():
171
+ typer.echo("Git pulling")
172
+ subprocess.call(["git", "pull"])
173
+ typer.echo("DVC pulling")
174
+ subprocess.call(["dvc", "pull"])
175
+
176
+
177
+ @app.command(name="push", help="Push with both Git and DVC.")
178
+ def push():
179
+ typer.echo("Pushing to Git remote")
180
+ subprocess.call(["git", "push"])
181
+ typer.echo("Pushing to DVC remote")
182
+ subprocess.call(["dvc", "push"])
183
+
184
+
185
+ @app.command(name="server", help="Run the local server.")
186
+ def run_server():
187
+ import uvicorn
188
+
189
+ uvicorn.run(
190
+ "calkit.server:app",
191
+ port=8866,
192
+ host="localhost",
193
+ reload=True,
194
+ reload_dirs=[os.path.dirname(__file__)],
195
+ )
196
+
197
+
198
+ @app.command(
199
+ name="run",
200
+ add_help_option=False,
201
+ help="Run DVC pipeline (a wrapper for `dvc repro`).",
202
+ )
203
+ def run_dvc_repro(
204
+ targets: Optional[list[str]] = typer.Argument(default=None),
205
+ help: Annotated[bool, typer.Option("-h", "--help")] = False,
206
+ quiet: Annotated[bool, typer.Option("-q", "--quiet")] = False,
207
+ verbose: Annotated[bool, typer.Option("-v", "--verbose")] = False,
208
+ force: Annotated[bool, typer.Option("-f", "--force")] = False,
209
+ interactive: Annotated[bool, typer.Option("-i", "--interactive")] = False,
210
+ single_item: Annotated[bool, typer.Option("-s", "--single-item")] = False,
211
+ pipeline: Annotated[
212
+ Optional[str], typer.Option("-p", "--pipeline")
213
+ ] = None,
214
+ all_pipelines: Annotated[
215
+ bool, typer.Option("-P", "--all-pipelines")
216
+ ] = False,
217
+ recursive: Annotated[bool, typer.Option("-R", "--recursive")] = False,
218
+ downstream: Annotated[
219
+ Optional[list[str]], typer.Option("--downstream")
220
+ ] = None,
221
+ force_downstream: Annotated[
222
+ bool, typer.Option("--force-downstream")
223
+ ] = False,
224
+ pull: Annotated[bool, typer.Option("--pull")] = False,
225
+ allow_missing: Annotated[bool, typer.Option("--allow-missing")] = False,
226
+ dry: Annotated[bool, typer.Option("--dry")] = False,
227
+ keep_going: Annotated[bool, typer.Option("--keep-going", "-k")] = False,
228
+ ignore_errors: Annotated[bool, typer.Option("--ignore-errors")] = False,
229
+ glob: Annotated[bool, typer.Option("--glob")] = False,
230
+ no_commit: Annotated[bool, typer.Option("--no-commit")] = False,
231
+ no_run_cache: Annotated[bool, typer.Option("--no-run-cache")] = False,
232
+ ):
233
+ """A simple wrapper for ``dvc repro`` that will automatically create any
234
+ necessary Calkit objects from stage metadata.
235
+ """
236
+ if targets is None:
237
+ targets = []
238
+ args = targets
239
+ # Extract any boolean args
240
+ for name in [
241
+ "help",
242
+ "quiet",
243
+ "verbose",
244
+ "force",
245
+ "interactive",
246
+ "single-item",
247
+ "all-pipelines",
248
+ "recursive",
249
+ "pull",
250
+ "allow-missing",
251
+ "dry",
252
+ "keep-going",
253
+ "force-downstream",
254
+ "glob",
255
+ "no-commit",
256
+ "no-run-cache",
257
+ ]:
258
+ if locals()[name.replace("-", "_")]:
259
+ args.append("--" + name)
260
+ if pipeline is not None:
261
+ args += ["--pipeline", pipeline]
262
+ if downstream is not None:
263
+ args += downstream
264
+ subprocess.call(["dvc", "repro"] + args)
265
+ # Now parse stage metadata for calkit objects
266
+ if not os.path.isfile("dvc.yaml"):
267
+ typer.echo("No dvc.yaml file found")
268
+ raise typer.Exit(1)
269
+ objects = []
270
+ with open("dvc.yaml") as f:
271
+ pipeline = calkit.ryaml.load(f)
272
+ for stage_name, stage_info in pipeline.get("stages", {}).items():
273
+ ckmeta = stage_info.get("meta", {}).get("calkit")
274
+ if ckmeta is not None:
275
+ if not isinstance(ckmeta, dict):
276
+ typer.echo(
277
+ f"Calkit metadata for {stage_name} is not a dictionary"
278
+ )
279
+ typer.Exit(1)
280
+ # Stage must have a single output
281
+ outs = stage_info.get("outs", [])
282
+ if len(outs) != 1:
283
+ typer.echo(
284
+ f"Stage {stage_name} does not have exactly one output"
285
+ )
286
+ raise typer.Exit(1)
287
+ cktype = ckmeta.get("type")
288
+ if cktype not in ["figure", "dataset", "publication"]:
289
+ typer.echo(f"Invalid Calkit output type '{cktype}'")
290
+ raise typer.Exit(1)
291
+ objects.append(
292
+ dict(path=outs[0]) | ckmeta | dict(stage=stage_name)
293
+ )
294
+ # Now that we've extracted Calkit objects from stage metadata, we can put
295
+ # them into the calkit.yaml file, overwriting objects with the same path
296
+ if os.path.isfile("calkit.yaml"):
297
+ with open("calkit.yaml") as f:
298
+ ck_info = calkit.ryaml.load(f)
299
+ else:
300
+ ck_info = {}
301
+ for obj in objects:
302
+ cktype = obj.pop("type")
303
+ cktype_plural = cktype + "s"
304
+ existing = ck_info.get(cktype_plural, [])
305
+ new = []
306
+ added = False
307
+ for ex_obj in existing:
308
+ if ex_obj.get("path") == obj["path"]:
309
+ typer.echo(f"Updating {cktype} {ex_obj['path']}")
310
+ new.append(obj)
311
+ added = True
312
+ else:
313
+ new.append(ex_obj)
314
+ if not added:
315
+ typer.echo(f"Adding new {cktype} {obj['path']}")
316
+ new.append(obj)
317
+ ck_info[cktype_plural] = new
318
+ if not dry:
319
+ with open("calkit.yaml", "w") as f:
320
+ calkit.ryaml.dump(ck_info, f)
321
+ run_cmd(["git", "add", "calkit.yaml"])
322
+
323
+
324
+ @app.command(name="manual-step", help="Execute a manual step.")
325
+ def manual_step(
326
+ message: Annotated[
327
+ str,
328
+ typer.Option(
329
+ "--message",
330
+ "-m",
331
+ help="Message to display as a prompt.",
332
+ ),
333
+ ],
334
+ cmd: Annotated[str, typer.Option("--cmd", help="Command to run.")] = None,
335
+ shell: Annotated[
336
+ bool,
337
+ typer.Option(
338
+ "--shell",
339
+ help="Whether or not to execute the command in shell mode.",
340
+ ),
341
+ ] = False,
342
+ show_stdout: Annotated[
343
+ bool, typer.Option("--show-stdout", help="Show stdout.")
344
+ ] = False,
345
+ show_stderr: Annotated[
346
+ bool, typer.Option("--show-stderr", help="Show stderr.")
347
+ ] = False,
348
+ ) -> None:
349
+ if cmd is not None:
350
+ typer.echo(f"Running command: {cmd}")
351
+ subprocess.Popen(
352
+ cmd.split() if not shell else cmd,
353
+ stderr=subprocess.PIPE if not show_stderr else None,
354
+ stdout=subprocess.PIPE if not show_stdout else None,
355
+ shell=shell,
356
+ )
357
+ input(message + " (press enter to confirm): ")
358
+ typer.echo("Done")
@@ -0,0 +1,107 @@
1
+ """CLI for creating new objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import git
8
+ import typer
9
+ from typing_extensions import Annotated
10
+
11
+ from calkit.core import ryaml
12
+
13
+ new_app = typer.Typer(no_args_is_help=True)
14
+
15
+
16
+ @new_app.command(name="figure")
17
+ def new_figure(
18
+ path: str,
19
+ title: Annotated[str, typer.Option("--title")],
20
+ description: Annotated[str, typer.Option("--desc")] = None,
21
+ commit: Annotated[bool, typer.Option("--commit")] = False,
22
+ ):
23
+ """Add a new figure."""
24
+ if os.path.isfile("calkit.yaml"):
25
+ with open("calkit.yaml") as f:
26
+ ck_info = ryaml.load(f)
27
+ else:
28
+ ck_info = {}
29
+ figures = ck_info.get("figures", [])
30
+ paths = [f.get("path") for f in figures]
31
+ if path in paths:
32
+ raise ValueError(f"Figure at path {path} already exists")
33
+ obj = dict(path=path, title=title)
34
+ if description is not None:
35
+ obj["description"] = description
36
+ figures.append(obj)
37
+ ck_info["figures"] = figures
38
+ with open("calkit.yaml", "w") as f:
39
+ ryaml.dump(ck_info, f)
40
+ if commit:
41
+ repo = git.Repo()
42
+ repo.git.add("calkit.yaml")
43
+ repo.git.commit(["-m", f"Add figure {path}"])
44
+
45
+
46
+ @new_app.command("question")
47
+ def new_question(
48
+ question: str,
49
+ commit: Annotated[bool, typer.Option("--commit")] = False,
50
+ ):
51
+ """Add a new question."""
52
+ if os.path.isfile("calkit.yaml"):
53
+ with open("calkit.yaml") as f:
54
+ ck_info = ryaml.load(f)
55
+ else:
56
+ ck_info = {}
57
+ questions = ck_info.get("questions", [])
58
+ if question in questions:
59
+ raise ValueError("Question already exists")
60
+ if not question.endswith("?"):
61
+ raise ValueError("Questions must end with a question mark")
62
+ questions.append(question)
63
+ ck_info["questions"] = questions
64
+ with open("calkit.yaml", "w") as f:
65
+ ryaml.dump(ck_info, f)
66
+ if commit:
67
+ repo = git.Repo()
68
+ repo.git.add("calkit.yaml")
69
+ repo.git.commit(["-m", "Add question"])
70
+
71
+
72
+ @new_app.command("notebook")
73
+ def new_notebook(
74
+ path: Annotated[str, typer.Argument(help="Notebook path (relative)")],
75
+ title: Annotated[str, typer.Option("--title")],
76
+ description: Annotated[str, typer.Option("--desc")] = None,
77
+ commit: Annotated[bool, typer.Option("--commit")] = False,
78
+ ):
79
+ """Add a new notebook."""
80
+ if os.path.isabs(path):
81
+ raise ValueError("Path must be relative")
82
+ if not os.path.isfile(path):
83
+ raise ValueError("Path is not a file")
84
+ if not path.endswith(".ipynb"):
85
+ raise ValueError("Path does not have .ipynb extension")
86
+ # TODO: Add option to create stages that run `calkit nb clean` and
87
+ # `calkit nb execute`
88
+ if os.path.isfile("calkit.yaml"):
89
+ with open("calkit.yaml") as f:
90
+ ck_info = ryaml.load(f)
91
+ else:
92
+ ck_info = {}
93
+ notebooks = ck_info.get("notebooks", [])
94
+ paths = [f.get("path") for f in notebooks]
95
+ if path in paths:
96
+ raise ValueError(f"Notebook at path {path} already exists")
97
+ obj = dict(path=path, title=title)
98
+ if description is not None:
99
+ obj["description"] = description
100
+ notebooks.append(obj)
101
+ ck_info["notebooks"] = notebooks
102
+ with open("calkit.yaml", "w") as f:
103
+ ryaml.dump(ck_info, f)
104
+ if commit:
105
+ repo = git.Repo()
106
+ repo.git.add("calkit.yaml")
107
+ repo.git.commit(["-m", f"Add notebook {path}"])
@@ -0,0 +1,76 @@
1
+ """Notebooks CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+
8
+ import typer
9
+ from typing_extensions import Annotated
10
+
11
+ notebooks_app = typer.Typer(no_args_is_help=True)
12
+
13
+
14
+ @notebooks_app.command("clean")
15
+ def clean_notebook_outputs(path: str):
16
+ """Clean notebook and place a copy in the cleaned notebooks directory.
17
+
18
+ This can be useful to use as a preprocessing DVC stage to use a clean
19
+ notebook as a dependency for a stage that caches and executed notebook.
20
+ """
21
+ if os.path.isabs(path):
22
+ raise ValueError("Path must be relative")
23
+ fpath_out = os.path.join(".calkit", "notebooks", "cleaned", path)
24
+ folder = os.path.dirname(fpath_out)
25
+ os.makedirs(folder, exist_ok=True)
26
+ subprocess.call(
27
+ [
28
+ "jupyter",
29
+ "nbconvert",
30
+ path,
31
+ "--clear-output",
32
+ "--to",
33
+ "notebook",
34
+ "--output",
35
+ fpath_out,
36
+ ]
37
+ )
38
+
39
+
40
+ @notebooks_app.command("execute")
41
+ def execute_notebook(
42
+ path: str,
43
+ to: Annotated[
44
+ str, typer.Option("--to", help="Output format ('html' or 'notebook').")
45
+ ] = "notebook",
46
+ ):
47
+ """Execute notebook and place a copy in the relevant directory.
48
+
49
+ This can be useful to use as a preprocessing DVC stage to use a clean
50
+ notebook as a dependency for a stage that caches and executed notebook.
51
+ """
52
+ if os.path.isabs(path):
53
+ raise ValueError("Path must be relative")
54
+ if to == "html":
55
+ subdir = "html"
56
+ fname_out = path.removesuffix(".ipynb") + ".html"
57
+ elif to == "notebook":
58
+ subdir = "executed"
59
+ fname_out = path
60
+ else:
61
+ raise ValueError(f"Invalid output format: '{to}'")
62
+ fpath_out = os.path.join(".calkit", "notebooks", subdir, fname_out)
63
+ folder = os.path.dirname(fpath_out)
64
+ os.makedirs(folder, exist_ok=True)
65
+ subprocess.call(
66
+ [
67
+ "jupyter",
68
+ "nbconvert",
69
+ path,
70
+ "--execute",
71
+ "--to",
72
+ to,
73
+ "--output",
74
+ fpath_out,
75
+ ]
76
+ )
@@ -1,392 +0,0 @@
1
- """The command line interface."""
2
-
3
- from __future__ import annotations
4
-
5
- import os
6
- import pty
7
- import subprocess
8
- import sys
9
-
10
- import git
11
- import typer
12
- from typing_extensions import Annotated, Optional
13
-
14
- import calkit
15
- from calkit.core import ryaml
16
-
17
- from . import config
18
- from .dvc import configure_remote, set_remote_auth
19
-
20
- app = typer.Typer(
21
- invoke_without_command=True,
22
- no_args_is_help=True,
23
- context_settings=dict(help_option_names=["-h", "--help"]),
24
- )
25
- config_app = typer.Typer(no_args_is_help=True)
26
- new_app = typer.Typer(no_args_is_help=True)
27
- notebooks_app = typer.Typer(no_args_is_help=True)
28
- app.add_typer(config_app, name="config", help="Configure Calkit.")
29
- app.add_typer(
30
- new_app, name="new", help="Add new Calkit object (to calkit.yaml)."
31
- )
32
- app.add_typer(notebooks_app, name="nb", help="Work with Jupyter notebooks.")
33
-
34
-
35
- @app.callback()
36
- def main(
37
- version: Annotated[
38
- bool,
39
- typer.Option("--version", help="Show version and exit."),
40
- ] = False,
41
- ):
42
- if version:
43
- typer.echo(calkit.__version__)
44
- raise typer.Exit()
45
-
46
-
47
- @config_app.command(name="set")
48
- def set_config_value(key: str, value: str):
49
- try:
50
- cfg = config.read()
51
- cfg = config.Settings.model_validate(cfg.model_dump() | {key: value})
52
- # Kind of a hack for setting the password computed field
53
- # Types have been validated above, so this won't hurt to do again
54
- setattr(cfg, key, value)
55
- except FileNotFoundError:
56
- # TODO: This fails if we try to set password before any config has
57
- # been written
58
- # Username is fine
59
- cfg = config.Settings.model_validate({key: value})
60
- cfg.write()
61
-
62
-
63
- @config_app.command(name="get")
64
- def get_config_value(key: str) -> None:
65
- cfg = config.read()
66
- val = getattr(cfg, key)
67
- if val is not None:
68
- print(val)
69
- else:
70
- print()
71
-
72
-
73
- @config_app.command(name="setup-remote")
74
- def setup_remote():
75
- configure_remote()
76
- set_remote_auth()
77
-
78
-
79
- @app.command(name="status")
80
- def get_status():
81
- """Get a unified Git and DVC status."""
82
-
83
- def print_sep(name: str):
84
- width = 66
85
- txt_width = len(name) + 2
86
- buffer_width = (width - txt_width) // 2
87
- buffer = "-" * buffer_width
88
- typer.echo(f"{buffer} {name} {buffer}")
89
-
90
- def run_cmd(cmd: list[str]):
91
- if os.name == "nt":
92
- subprocess.call(cmd)
93
- else:
94
- pty.spawn(cmd, lambda fd: os.read(fd, 1024))
95
-
96
- print_sep("Code (Git)")
97
- run_cmd(["git", "status"])
98
- typer.echo()
99
- print_sep("Data (DVC)")
100
- run_cmd(["dvc", "data", "status"])
101
- typer.echo()
102
- print_sep("Pipeline (DVC)")
103
- run_cmd(["dvc", "status"])
104
-
105
-
106
- @app.command(name="add")
107
- def add(paths: list[str]):
108
- """Add paths to the repo.
109
-
110
- Code will be added to Git and data will be added to DVC.
111
- """
112
- dvc_extensions = [".png", ".h5", ".parquet", ".pickle"]
113
- dvc_size_thresh_bytes = 1_000_000
114
- dvc_folders = ["data", "figures"]
115
- if "." in paths:
116
- print("ERROR: Cannot add '.' with calkit; use git or dvc")
117
- sys.exit(1)
118
- for path in paths:
119
- if os.path.isdir(path):
120
- print("ERROR: Cannot add directories with calkit; use git or dvc")
121
- sys.exit(1)
122
- # Detect if this file should be tracked with Git or DVC
123
- # TODO: Add to whatever
124
-
125
-
126
- @app.command(name="commit")
127
- def commit(
128
- all: Annotated[Optional[bool], typer.Option("--all", "-a")] = False,
129
- message: Annotated[Optional[str], typer.Option("--message", "-m")] = None,
130
- ):
131
- """Commit a change to the repo."""
132
- print(all, message)
133
- raise NotImplementedError
134
-
135
-
136
- @app.command(name="pull", help="Pull with both Git and DVC.")
137
- def pull():
138
- typer.echo("Git pulling")
139
- subprocess.call(["git", "pull"])
140
- typer.echo("DVC pulling")
141
- subprocess.call(["dvc", "pull"])
142
-
143
-
144
- @app.command(name="push", help="Push with both Git and DVC.")
145
- def push():
146
- typer.echo("Pushing to Git remote")
147
- subprocess.call(["git", "push"])
148
- typer.echo("Pushing to DVC remote")
149
- subprocess.call(["dvc", "push"])
150
-
151
-
152
- @new_app.command(name="figure")
153
- def new_figure(
154
- path: str,
155
- title: Annotated[str, typer.Option("--title")],
156
- description: Annotated[str, typer.Option("--desc")] = None,
157
- commit: Annotated[bool, typer.Option("--commit")] = False,
158
- ):
159
- """Add a new figure."""
160
- if os.path.isfile("calkit.yaml"):
161
- with open("calkit.yaml") as f:
162
- ck_info = ryaml.load(f)
163
- else:
164
- ck_info = {}
165
- figures = ck_info.get("figures", [])
166
- paths = [f.get("path") for f in figures]
167
- if path in paths:
168
- raise ValueError(f"Figure at path {path} already exists")
169
- obj = dict(path=path, title=title)
170
- if description is not None:
171
- obj["description"] = description
172
- figures.append(obj)
173
- ck_info["figures"] = figures
174
- with open("calkit.yaml", "w") as f:
175
- ryaml.dump(ck_info, f)
176
- if commit:
177
- repo = git.Repo()
178
- repo.git.add("calkit.yaml")
179
- repo.git.commit(["-m", f"Add figure {path}"])
180
-
181
-
182
- @new_app.command("question")
183
- def new_question(
184
- question: str,
185
- commit: Annotated[bool, typer.Option("--commit")] = False,
186
- ):
187
- """Add a new question."""
188
- if os.path.isfile("calkit.yaml"):
189
- with open("calkit.yaml") as f:
190
- ck_info = ryaml.load(f)
191
- else:
192
- ck_info = {}
193
- questions = ck_info.get("questions", [])
194
- if question in questions:
195
- raise ValueError("Question already exists")
196
- if not question.endswith("?"):
197
- raise ValueError("Questions must end with a question mark")
198
- questions.append(question)
199
- ck_info["questions"] = questions
200
- with open("calkit.yaml", "w") as f:
201
- ryaml.dump(ck_info, f)
202
- if commit:
203
- repo = git.Repo()
204
- repo.git.add("calkit.yaml")
205
- repo.git.commit(["-m", "Add question"])
206
-
207
-
208
- @new_app.command("notebook")
209
- def new_notebook(
210
- path: Annotated[str, typer.Argument(help="Notebook path (relative)")],
211
- title: Annotated[str, typer.Option("--title")],
212
- description: Annotated[str, typer.Option("--desc")] = None,
213
- commit: Annotated[bool, typer.Option("--commit")] = False,
214
- ):
215
- """Add a new notebook."""
216
- if os.path.isabs(path):
217
- raise ValueError("Path must be relative")
218
- if not os.path.isfile(path):
219
- raise ValueError("Path is not a file")
220
- if not path.endswith(".ipynb"):
221
- raise ValueError("Path does not have .ipynb extension")
222
- # TODO: Add option to create stages that run `calkit nb clean` and
223
- # `calkit nb execute`
224
- if os.path.isfile("calkit.yaml"):
225
- with open("calkit.yaml") as f:
226
- ck_info = ryaml.load(f)
227
- else:
228
- ck_info = {}
229
- notebooks = ck_info.get("notebooks", [])
230
- paths = [f.get("path") for f in notebooks]
231
- if path in paths:
232
- raise ValueError(f"Notebook at path {path} already exists")
233
- obj = dict(path=path, title=title)
234
- if description is not None:
235
- obj["description"] = description
236
- notebooks.append(obj)
237
- ck_info["notebooks"] = notebooks
238
- with open("calkit.yaml", "w") as f:
239
- ryaml.dump(ck_info, f)
240
- if commit:
241
- repo = git.Repo()
242
- repo.git.add("calkit.yaml")
243
- repo.git.commit(["-m", f"Add notebook {path}"])
244
-
245
-
246
- @notebooks_app.command("clean")
247
- def clean_notebook_outputs(path: str):
248
- """Clean notebook and place a copy in the cleaned notebooks directory.
249
-
250
- This can be useful to use as a preprocessing DVC stage to use a clean
251
- notebook as a dependency for a stage that caches and executed notebook.
252
- """
253
- if os.path.isabs(path):
254
- raise ValueError("Path must be relative")
255
- fpath_out = os.path.join(".calkit", "notebooks", "cleaned", path)
256
- folder = os.path.dirname(fpath_out)
257
- os.makedirs(folder, exist_ok=True)
258
- subprocess.call(
259
- [
260
- "jupyter",
261
- "nbconvert",
262
- path,
263
- "--clear-output",
264
- "--to",
265
- "notebook",
266
- "--output",
267
- fpath_out,
268
- ]
269
- )
270
-
271
-
272
- @notebooks_app.command("execute")
273
- def execute_notebook(
274
- path: str,
275
- to: Annotated[
276
- str, typer.Option("--to", help="Output format ('html' or 'notebook').")
277
- ] = "notebook",
278
- ):
279
- """Execute notebook and place a copy in the relevant directory.
280
-
281
- This can be useful to use as a preprocessing DVC stage to use a clean
282
- notebook as a dependency for a stage that caches and executed notebook.
283
- """
284
- if os.path.isabs(path):
285
- raise ValueError("Path must be relative")
286
- if to == "html":
287
- subdir = "html"
288
- fname_out = path.removesuffix(".ipynb") + ".html"
289
- elif to == "notebook":
290
- subdir = "executed"
291
- fname_out = path
292
- else:
293
- raise ValueError(f"Invalid output format: '{to}'")
294
- fpath_out = os.path.join(".calkit", "notebooks", subdir, fname_out)
295
- folder = os.path.dirname(fpath_out)
296
- os.makedirs(folder, exist_ok=True)
297
- subprocess.call(
298
- [
299
- "jupyter",
300
- "nbconvert",
301
- path,
302
- "--execute",
303
- "--to",
304
- to,
305
- "--output",
306
- fpath_out,
307
- ]
308
- )
309
-
310
-
311
- @app.command(name="server", help="Run the local server.")
312
- def run_server():
313
- import uvicorn
314
-
315
- uvicorn.run(
316
- "calkit.server:app",
317
- port=8866,
318
- host="localhost",
319
- reload=True,
320
- reload_dirs=[os.path.dirname(__file__)],
321
- )
322
-
323
-
324
- @app.command(
325
- name="run",
326
- add_help_option=False,
327
- help="Run DVC pipeline (a wrapper for `dvc repro`).",
328
- )
329
- def run_dvc_repro(
330
- targets: Optional[list[str]] = typer.Argument(default=None),
331
- help: Annotated[bool, typer.Option("-h", "--help")] = False,
332
- quiet: Annotated[bool, typer.Option("-q", "--quiet")] = False,
333
- verbose: Annotated[bool, typer.Option("-v", "--verbose")] = False,
334
- force: Annotated[bool, typer.Option("-f", "--force")] = False,
335
- interactive: Annotated[bool, typer.Option("-i", "--interactive")] = False,
336
- single_item: Annotated[bool, typer.Option("-s", "--single-item")] = False,
337
- pipeline: Annotated[
338
- Optional[str], typer.Option("-p", "--pipeline")
339
- ] = None,
340
- all_pipelines: Annotated[
341
- bool, typer.Option("-P", "--all-pipelines")
342
- ] = False,
343
- recursive: Annotated[bool, typer.Option("-R", "--recursive")] = False,
344
- downstream: Annotated[
345
- Optional[list[str]], typer.Option("--downstream")
346
- ] = None,
347
- force_downstream: Annotated[
348
- bool, typer.Option("--force-downstream")
349
- ] = False,
350
- pull: Annotated[bool, typer.Option("--pull")] = False,
351
- allow_missing: Annotated[bool, typer.Option("--allow-missing")] = False,
352
- dry: Annotated[bool, typer.Option("--dry")] = False,
353
- keep_going: Annotated[bool, typer.Option("--keep-going", "-k")] = False,
354
- ignore_errors: Annotated[bool, typer.Option("--ignore-errors")] = False,
355
- glob: Annotated[bool, typer.Option("--glob")] = False,
356
- no_commit: Annotated[bool, typer.Option("--no-commit")] = False,
357
- no_run_cache: Annotated[bool, typer.Option("--no-run-cache")] = False,
358
- ):
359
- """A simple wrapper for ``dvc repro``."""
360
- if targets is None:
361
- targets = []
362
- args = targets
363
- # Extract any boolean args
364
- for name in [
365
- "help",
366
- "quiet",
367
- "verbose",
368
- "force",
369
- "interactive",
370
- "single-item",
371
- "all-pipelines",
372
- "recursive",
373
- "pull",
374
- "allow-missing",
375
- "dry",
376
- "keep-going",
377
- "force-downstream",
378
- "glob",
379
- "no-commit",
380
- "no-run-cache",
381
- ]:
382
- if locals()[name.replace("-", "_")]:
383
- args.append("--" + name)
384
- if pipeline is not None:
385
- args += ["--pipeline", pipeline]
386
- if downstream is not None:
387
- args += downstream
388
- subprocess.call(["dvc", "repro"] + args)
389
-
390
-
391
- def run() -> None:
392
- app()
File without changes
File without changes
File without changes