calkit-python 0.10.1__tar.gz → 0.11.1__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 (67) hide show
  1. {calkit_python-0.10.1 → calkit_python-0.11.1}/.gitignore +1 -0
  2. {calkit_python-0.10.1 → calkit_python-0.11.1}/PKG-INFO +37 -8
  3. {calkit_python-0.10.1 → calkit_python-0.11.1}/README.md +35 -7
  4. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/__init__.py +3 -1
  5. calkit_python-0.11.1/calkit/calc.py +255 -0
  6. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/check.py +1 -1
  7. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cli/config.py +4 -2
  8. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cli/core.py +6 -2
  9. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cli/main.py +39 -0
  10. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cli/new.py +258 -1
  11. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/conda.py +37 -30
  12. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/core.py +10 -0
  13. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/dvc.py +13 -7
  14. calkit_python-0.11.1/calkit/git.py +22 -0
  15. calkit_python-0.11.1/calkit/tests/test_calc.py +69 -0
  16. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/test_conda.py +38 -1
  17. {calkit_python-0.10.1 → calkit_python-0.11.1}/pyproject.toml +1 -0
  18. calkit_python-0.10.1/calkit/git.py +0 -17
  19. {calkit_python-0.10.1 → calkit_python-0.11.1}/.github/FUNDING.yml +0 -0
  20. {calkit_python-0.10.1 → calkit_python-0.11.1}/.github/workflows/publish-test.yml +0 -0
  21. {calkit_python-0.10.1 → calkit_python-0.11.1}/.github/workflows/publish.yml +0 -0
  22. {calkit_python-0.10.1 → calkit_python-0.11.1}/LICENSE +0 -0
  23. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cli/__init__.py +0 -0
  24. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cli/check.py +0 -0
  25. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cli/import_.py +0 -0
  26. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cli/list.py +0 -0
  27. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cli/notebooks.py +0 -0
  28. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cli/office.py +0 -0
  29. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cli/update.py +0 -0
  30. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/cloud.py +0 -0
  31. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/config.py +0 -0
  32. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/data.py +0 -0
  33. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/docker.py +0 -0
  34. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/gui.py +0 -0
  35. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/jupyter.py +0 -0
  36. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/magics.py +0 -0
  37. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/models.py +0 -0
  38. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/office.py +0 -0
  39. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/server.py +0 -0
  40. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/templates/__init__.py +0 -0
  41. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/templates/core.py +0 -0
  42. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/templates/latex/__init__.py +0 -0
  43. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/templates/latex/article/paper.tex +0 -0
  44. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/templates/latex/core.py +0 -0
  45. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/templates/latex/jfm/jfm.bst +0 -0
  46. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/templates/latex/jfm/jfm.cls +0 -0
  47. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/templates/latex/jfm/lineno-FLM.sty +0 -0
  48. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/templates/latex/jfm/paper.tex +0 -0
  49. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/templates/latex/jfm/upmath.sty +0 -0
  50. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/__init__.py +0 -0
  51. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/cli/__init__.py +0 -0
  52. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/cli/test_list.py +0 -0
  53. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/cli/test_main.py +0 -0
  54. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/cli/test_new.py +0 -0
  55. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/test_check.py +0 -0
  56. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/test_core.py +0 -0
  57. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/test_dvc.py +0 -0
  58. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/test_jupyter.py +0 -0
  59. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/test_magics.py +0 -0
  60. {calkit_python-0.10.1 → calkit_python-0.11.1}/calkit/tests/test_templates.py +0 -0
  61. {calkit_python-0.10.1 → calkit_python-0.11.1}/docs/img/calkit-no-bg.png +0 -0
  62. {calkit_python-0.10.1 → calkit_python-0.11.1}/docs/tutorials/adding-latex-pub-docker.md +0 -0
  63. {calkit_python-0.10.1 → calkit_python-0.11.1}/docs/tutorials/conda-envs.md +0 -0
  64. {calkit_python-0.10.1 → calkit_python-0.11.1}/docs/tutorials/img/run-proc.png +0 -0
  65. {calkit_python-0.10.1 → calkit_python-0.11.1}/docs/tutorials/notebook-pipeline.md +0 -0
  66. {calkit_python-0.10.1 → calkit_python-0.11.1}/docs/tutorials/procedures.md +0 -0
  67. {calkit_python-0.10.1 → calkit_python-0.11.1}/test/pipeline.ipynb +0 -0
@@ -1,3 +1,4 @@
1
1
  dev.ipynb
2
2
  *.pyc
3
3
  .DS_Store
4
+ .vscode
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: calkit-python
3
- Version: 0.10.1
3
+ Version: 0.11.1
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
@@ -10,6 +10,7 @@ Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.8
13
+ Requires-Dist: arithmeval
13
14
  Requires-Dist: docx2pdf
14
15
  Requires-Dist: dvc
15
16
  Requires-Dist: eval-type-backport; python_version < '3.10'
@@ -90,23 +91,51 @@ management interface and a DVC remote for easily storing all versions of your
90
91
  data/code/figures/publications, interacting with your collaborators,
91
92
  reusing others' research artifacts, etc.
92
93
 
93
- After signing up, visit the [settings](https://calkit.io/settings) page
94
- and create a token.
94
+ After signing up, visit the
95
+ [settings](https://calkit.io/settings?tab=tokens)
96
+ page and create a token for use with the API.
95
97
  Then run
96
98
 
97
99
  ```sh
98
100
  calkit config set token ${YOUR_TOKEN_HERE}
99
101
  ```
100
102
 
101
- Then, inside a project repo you'd like to connect to the cloud, run
103
+ ## Quickstart
104
+
105
+ After installing Calkit and setting your token as described above, run:
106
+
107
+ ```sh
108
+ calkit new project calkit-project-1 \
109
+ --title "My first Calkit project" \
110
+ --template calkit/example-basic \
111
+ --cloud \
112
+ --public
113
+ ```
114
+
115
+ This will create a new project from the
116
+ [`calkit/example-basic`](https://github.com/calkit/example-basic)
117
+ template,
118
+ creating it in the cloud and cloning to `calkit-project-1`.
119
+ You should now be able to run:
120
+
121
+ ```sh
122
+ cd calkit-project-1
123
+ calkit run
124
+ ```
125
+
126
+ This will reproduce the project's pipeline.
127
+ Next, you can start adding stages to the pipeline,
128
+ modifying the Python environments and scripts,
129
+ and editing the paper.
130
+ All will be kept in sync with the `calkit run` command.
131
+
132
+ To back up all of your work, execute:
102
133
 
103
134
  ```sh
104
- calkit config setup-remote
135
+ calkit save -am "Run pipeline"
105
136
  ```
106
137
 
107
- This will setup the Calkit DVC remote, such that commands like `dvc push` will
108
- allow you to push versions of your data or pipeline outputs to the cloud
109
- for safe storage and sharing with your collaborators.
138
+ This will commit and push to both GitHub and the Calkit Cloud.
110
139
 
111
140
  ## Tutorials
112
141
 
@@ -58,23 +58,51 @@ management interface and a DVC remote for easily storing all versions of your
58
58
  data/code/figures/publications, interacting with your collaborators,
59
59
  reusing others' research artifacts, etc.
60
60
 
61
- After signing up, visit the [settings](https://calkit.io/settings) page
62
- and create a token.
61
+ After signing up, visit the
62
+ [settings](https://calkit.io/settings?tab=tokens)
63
+ page and create a token for use with the API.
63
64
  Then run
64
65
 
65
66
  ```sh
66
67
  calkit config set token ${YOUR_TOKEN_HERE}
67
68
  ```
68
69
 
69
- Then, inside a project repo you'd like to connect to the cloud, run
70
+ ## Quickstart
71
+
72
+ After installing Calkit and setting your token as described above, run:
73
+
74
+ ```sh
75
+ calkit new project calkit-project-1 \
76
+ --title "My first Calkit project" \
77
+ --template calkit/example-basic \
78
+ --cloud \
79
+ --public
80
+ ```
81
+
82
+ This will create a new project from the
83
+ [`calkit/example-basic`](https://github.com/calkit/example-basic)
84
+ template,
85
+ creating it in the cloud and cloning to `calkit-project-1`.
86
+ You should now be able to run:
87
+
88
+ ```sh
89
+ cd calkit-project-1
90
+ calkit run
91
+ ```
92
+
93
+ This will reproduce the project's pipeline.
94
+ Next, you can start adding stages to the pipeline,
95
+ modifying the Python environments and scripts,
96
+ and editing the paper.
97
+ All will be kept in sync with the `calkit run` command.
98
+
99
+ To back up all of your work, execute:
70
100
 
71
101
  ```sh
72
- calkit config setup-remote
102
+ calkit save -am "Run pipeline"
73
103
  ```
74
104
 
75
- This will setup the Calkit DVC remote, such that commands like `dvc push` will
76
- allow you to push versions of your data or pipeline outputs to the cloud
77
- for safe storage and sharing with your collaborators.
105
+ This will commit and push to both GitHub and the Calkit Cloud.
78
106
 
79
107
  ## Tutorials
80
108
 
@@ -1,4 +1,4 @@
1
- __version__ = "0.10.1"
1
+ __version__ = "0.11.1"
2
2
 
3
3
  from .core import *
4
4
  from . import git
@@ -10,3 +10,5 @@ from . import models
10
10
  from . import office
11
11
  from . import templates
12
12
  from . import conda
13
+ from . import calc
14
+ from . import check
@@ -0,0 +1,255 @@
1
+ """Functionality for calculations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ import arithmetic_eval
8
+ import requests
9
+ from pydantic import BaseModel, model_validator
10
+
11
+ DTYPES = {"int": int, "float": float, "str": str}
12
+ DEFAULT_IN_TYPE = "float"
13
+ DEFAULT_OUT_TYPE = "float"
14
+
15
+
16
+ class Input(BaseModel):
17
+ name: str
18
+ description: str | None = None
19
+ dtype: Literal["int", "float", "str"] = DEFAULT_IN_TYPE
20
+ min: int | float | None = None
21
+ max: int | float | None = None
22
+
23
+
24
+ class Output(BaseModel):
25
+ name: str
26
+ description: str | None = None
27
+ dtype: Literal["int", "float", "str"] = DEFAULT_OUT_TYPE
28
+ template: str | None = None
29
+
30
+
31
+ class Calculation(BaseModel):
32
+ kind: str
33
+ params: dict = {}
34
+ name: str | None = None
35
+ description: str | None = None
36
+ inputs: list[Input] | list[str]
37
+ output: Output | str
38
+
39
+ @model_validator(mode="after")
40
+ def validate_model(self) -> Calculation:
41
+ input_names = self.input_names
42
+ if self.output_name in input_names:
43
+ raise ValueError("Output name must not overlap with input names")
44
+ if len(set(input_names)) != len(input_names):
45
+ raise ValueError("Input names must be unique")
46
+ return self
47
+
48
+ @property
49
+ def input_names(self) -> list[str]:
50
+ input_names = []
51
+ for i in self.inputs:
52
+ if isinstance(i, Input):
53
+ input_names.append(i.name)
54
+ else:
55
+ input_names.append(i)
56
+ return input_names
57
+
58
+ @property
59
+ def inputs_dict(self) -> dict[str, Input]:
60
+ res = {}
61
+ for i in self.inputs:
62
+ if isinstance(i, str):
63
+ res[i] = Input(name=i)
64
+ else:
65
+ res[i.name] = i
66
+ return res
67
+
68
+ @property
69
+ def output_name(self) -> str:
70
+ if isinstance(self.output, Output):
71
+ return self.output.name
72
+ return self.output
73
+
74
+ def check_inputs(self, **inputs) -> dict:
75
+ """Check that the supplied inputs match those declared and do type
76
+ coercion.
77
+ """
78
+ inputs_dict = self.inputs_dict
79
+ for k in inputs_dict:
80
+ if k not in inputs:
81
+ raise ValueError(f"Missing input {k}")
82
+ for k, v in inputs.items():
83
+ if k not in inputs_dict:
84
+ raise ValueError(f"{k} is not in declared inputs")
85
+ input_def = inputs_dict[k]
86
+ v = DTYPES[input_def.dtype](v)
87
+ inputs[k] = v
88
+ if input_def.min is not None and v < input_def.min:
89
+ raise ValueError(f"Input value {k} = {v} it too small")
90
+ if input_def.max is not None and v > input_def.max:
91
+ raise ValueError(f"Input value {k} = {v} is too large")
92
+ return inputs
93
+
94
+ def calculate(self, **inputs):
95
+ """This is the method to override to implement custom logic.
96
+
97
+ Input and output type coercion will be handled outside.
98
+ """
99
+ raise NotImplementedError
100
+
101
+ def evaluate(self, **inputs):
102
+ inputs = self.check_inputs(**inputs)
103
+ out = self.calculate(**inputs)
104
+ return self.coerce_output(out)
105
+
106
+ def coerce_output(self, val):
107
+ if isinstance(self.output, Output):
108
+ return DTYPES[self.output.dtype](val)
109
+ else:
110
+ return DTYPES[DEFAULT_OUT_TYPE](val)
111
+
112
+ def evaluate_and_format(self, **inputs) -> str:
113
+ res = self.evaluate(**inputs)
114
+ if isinstance(self.output, Output):
115
+ out_name = self.output.name
116
+ template = self.output.template
117
+ else:
118
+ out_name = self.output
119
+ template = None
120
+ if template is None:
121
+ template = "For input "
122
+ for input_name in inputs:
123
+ template += input_name + "={" + input_name + "}, "
124
+ template += "the output is "
125
+ template += out_name + "={" + out_name + "}."
126
+ return template.format(**(inputs | {out_name: res}))
127
+
128
+
129
+ class FormulaParams(BaseModel):
130
+ formula: str
131
+
132
+
133
+ class Formula(Calculation):
134
+ kind: str = "formula"
135
+ params: FormulaParams
136
+
137
+ def calculate(self, **inputs):
138
+ inputs = self.check_inputs(**inputs)
139
+ return arithmetic_eval.evaluate(self.params.formula, inputs)
140
+
141
+
142
+ class LinearParams(BaseModel):
143
+ coeffs: dict[str, float]
144
+ offset: float = 0.0
145
+
146
+
147
+ class Linear(Calculation):
148
+ """Calculation for a simple linear relationship."""
149
+
150
+ kind: str = "linear"
151
+ params: LinearParams
152
+
153
+ @model_validator(mode="after")
154
+ def validate_model(self) -> Linear:
155
+ if set(self.input_names) != set(self.params.coeffs.keys()):
156
+ raise ValueError("Coefficients must have same keys as input names")
157
+ return self
158
+
159
+ def calculate(self, **inputs):
160
+ val = self.params.offset
161
+ for input_name, input_val in inputs.items():
162
+ val += self.params.coeffs[input_name] * input_val
163
+ return val
164
+
165
+
166
+ class LookupTableParams(BaseModel):
167
+ x_values: list[float]
168
+ y_values: list[float]
169
+ method: Literal["floor", "ceil", "round", "interpolate"] = "interpolate"
170
+
171
+
172
+ class LookupTable(Calculation):
173
+ """A 1-D lookup table."""
174
+
175
+ kind: str = "lookup-table"
176
+ params: LookupTableParams
177
+
178
+
179
+ class HttpRequestParams(BaseModel):
180
+ url: str
181
+ inputs_as_params: bool = True # Otherwise, use body
182
+ method: Literal["get", "post", "put"] = "get"
183
+ as_json: bool = True # Otherwise, return raw text
184
+
185
+
186
+ class HttpRequest(Calculation):
187
+ """Make an HTTP request and return the result.
188
+
189
+ This should not be run on a web server since it can be insecure.
190
+ For example, it could make requests to private services and return
191
+ sensitive data.
192
+ """
193
+
194
+ kind: str = "http"
195
+ params: HttpRequestParams
196
+
197
+ def calculate(self, **inputs):
198
+ func = getattr(requests, self.params.method)
199
+ if self.params.inputs_as_params:
200
+ kws = {"params": inputs}
201
+ else:
202
+ kws = {"json", inputs}
203
+ resp: requests.Response = func(url=self.params.url, **kws)
204
+ resp.raise_for_status()
205
+ if self.params.as_json:
206
+ return resp.json()
207
+ else:
208
+ return resp.text
209
+
210
+
211
+ class XGBoostModelParams(BaseModel):
212
+ path: str
213
+ type: Literal["classifier", "regressor"]
214
+
215
+
216
+ class XGBoostModel(Calculation):
217
+ """Make predictions with an XGBoost model saved as JSON.
218
+
219
+ This is currently just a prototype and should not be expected to work.
220
+
221
+ One input, ``data``, should be defined to be passed to the model's
222
+ ``predict`` method.
223
+ """
224
+
225
+ kind: str = "xgboost"
226
+ params: XGBoostModelParams
227
+
228
+ def calculate(self, **inputs):
229
+ # Load model from JSON
230
+ import xgboost
231
+
232
+ # Convert model path to something that can be loaded if running on the
233
+ # Calkit Cloud
234
+ types = {
235
+ "classifier": xgboost.XGBClassifier,
236
+ "regressor": xgboost.XGBRegressor,
237
+ }
238
+ model = types[self.params.type]().load_model(self.params.path)
239
+ return model.predict(**inputs)
240
+
241
+
242
+ def parse(data: dict) -> Calculation:
243
+ if isinstance(data, BaseModel):
244
+ data = data.model_dump()
245
+ # Automatically take keys not in the `kind` and move them into `params`?
246
+ kinds = {"formula": Formula, "lookup-table": LookupTable, "linear": Linear}
247
+ return kinds[data["kind"]].model_validate(data)
248
+
249
+
250
+ def evaluate(calc_def: dict | Calculation, **inputs) -> dict:
251
+ return parse(calc_def).evaluate(**inputs)
252
+
253
+
254
+ def evaluate_and_format(calc_def: dict | Calculation, **inputs) -> str:
255
+ return parse(calc_def).evaluate_and_format(**inputs)
@@ -68,7 +68,7 @@ class ReproCheck(BaseModel):
68
68
  if not self.n_dvc_remotes:
69
69
  return (
70
70
  "No DVC remotes have been defined. "
71
- "Run `calkit config setup-remote` or `dvc remote add` next."
71
+ "Run `calkit config remote` or `dvc remote add` next."
72
72
  )
73
73
  if not self.has_pipeline:
74
74
  return (
@@ -42,7 +42,8 @@ def get_config_value(key: str) -> None:
42
42
  print()
43
43
 
44
44
 
45
- @config_app.command(name="setup-remote")
45
+ @config_app.command(name="setup-remote", help="Alias for 'remote'.")
46
+ @config_app.command(name="remote")
46
47
  def setup_remote():
47
48
  """Setup the Calkit cloud as the default DVC remote and store a token in
48
49
  the local config.
@@ -56,7 +57,8 @@ def setup_remote():
56
57
  raise_error("Current directory is not a Git repository")
57
58
 
58
59
 
59
- @config_app.command(name="setup-remote-auth")
60
+ @config_app.command(name="setup-remote-auth", help="Alias for 'remote-auth'.")
61
+ @config_app.command(name="remote-auth")
60
62
  def setup_remote_auth():
61
63
  """Store a Calkit cloud token in the local DVC config for all Calkit
62
64
  remotes.
@@ -23,6 +23,10 @@ def run_cmd(cmd: list[str]):
23
23
  pty.spawn(cmd, lambda fd: os.read(fd, 1024))
24
24
 
25
25
 
26
- def raise_error(txt):
27
- typer.echo(typer.style(txt, fg="red"), err=txt)
26
+ def raise_error(txt: str):
27
+ typer.echo(typer.style("Error: " + txt, fg="red"), err=txt)
28
28
  raise typer.Exit(1)
29
+
30
+
31
+ def warn(txt: str):
32
+ typer.echo(typer.style("Warning: " + txt, fg="yellow"))
@@ -978,3 +978,42 @@ def check_conda_env(
978
978
  log_func=log_func,
979
979
  relaxed=relaxed,
980
980
  )
981
+
982
+
983
+ @app.command(name="calc")
984
+ def run_calculation(
985
+ name: Annotated[str, typer.Argument(help="Calculation name.")],
986
+ inputs: Annotated[
987
+ list[str],
988
+ typer.Option(
989
+ "--input", "-i", help="Inputs defined like x=1 (with no spaces.)"
990
+ ),
991
+ ] = [],
992
+ no_formatting: Annotated[
993
+ bool,
994
+ typer.Option(
995
+ "--no-format", help="Do not format output before printing"
996
+ ),
997
+ ] = False,
998
+ ):
999
+ """Run a project's calculation."""
1000
+ ck_info = calkit.load_calkit_info()
1001
+ calcs = ck_info.get("calculations", {})
1002
+ if name not in calcs:
1003
+ raise_error(f"Calculation '{name}' not defined in calkit.yaml")
1004
+ try:
1005
+ calc = calkit.calc.parse(calcs[name])
1006
+ except Exception as e:
1007
+ raise_error(f"Invalid calculation: {e}")
1008
+ # Parse inputs
1009
+ parsed_inputs = {}
1010
+ for i in inputs:
1011
+ iname, ival = i.split("=")
1012
+ parsed_inputs[iname] = ival
1013
+ try:
1014
+ if no_formatting:
1015
+ typer.echo(calc.evaluate(**parsed_inputs))
1016
+ else:
1017
+ typer.echo(calc.evaluate_and_format(**parsed_inputs))
1018
+ except Exception as e:
1019
+ raise_error(f"Calculation failed: {e}")
@@ -3,21 +3,277 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import re
6
7
  import shutil
7
8
  import subprocess
8
9
 
9
10
  import git
10
11
  import typer
12
+ from git.exc import InvalidGitRepositoryError
11
13
  from typing_extensions import Annotated
12
14
 
13
15
  import calkit
14
- from calkit.cli import raise_error
16
+ from calkit.cli import raise_error, warn
17
+ from calkit.cli.update import update_devcontainer
15
18
  from calkit.core import ryaml
16
19
  from calkit.docker import LAYERS
17
20
 
18
21
  new_app = typer.Typer(no_args_is_help=True)
19
22
 
20
23
 
24
+ @new_app.command(name="project")
25
+ def new_project(
26
+ path: Annotated[str, typer.Argument(help="Where to create the project.")],
27
+ name: Annotated[
28
+ str,
29
+ typer.Option(
30
+ "--name",
31
+ "-n",
32
+ help=(
33
+ "Project name. Will be inferred as kebab-cased directory "
34
+ "name if not provided."
35
+ ),
36
+ ),
37
+ ] = None,
38
+ title: Annotated[
39
+ str, typer.Option("--title", help="Project title.")
40
+ ] = None,
41
+ description: Annotated[
42
+ str, typer.Option("--description", help="Project description.")
43
+ ] = None,
44
+ cloud: Annotated[
45
+ bool,
46
+ typer.Option(
47
+ "--cloud",
48
+ help=("Create this project in the cloud (Calkit and GitHub.)"),
49
+ ),
50
+ ] = False,
51
+ public: Annotated[
52
+ bool,
53
+ typer.Option(
54
+ "--public",
55
+ help="Create as a public project if --cloud is selected.",
56
+ ),
57
+ ] = False,
58
+ git_repo_url: Annotated[
59
+ str,
60
+ typer.Option(
61
+ "--git-url",
62
+ help=(
63
+ "Git repo URL. "
64
+ "Usually https://github.com/{your_name}/{project_name}."
65
+ ),
66
+ ),
67
+ ] = None,
68
+ template: Annotated[
69
+ str,
70
+ typer.Option(
71
+ "--template",
72
+ "-t",
73
+ help=(
74
+ "Template from which to derive the project, e.g., "
75
+ "'calkit/example-basic'."
76
+ ),
77
+ ),
78
+ ] = None,
79
+ no_commit: Annotated[
80
+ bool, typer.Option("--no-commit", help="Do not commit changes to Git.")
81
+ ] = None,
82
+ overwrite: Annotated[
83
+ bool,
84
+ typer.Option(
85
+ "--overwrite",
86
+ "-f",
87
+ help="Overwrite project if one already exists.",
88
+ ),
89
+ ] = False,
90
+ ):
91
+ """Create a new project."""
92
+ # TODO: Update this when there is a real docs site up
93
+ docs_url = "https://github.com/calkit/calkit?tab=readme-ov-file#tutorials"
94
+ success_message = (
95
+ "\nCongrats on creating your new Calkit project!\n\n"
96
+ "Next, you'll probably want to start building your pipeline.\n\n"
97
+ f"Check out the docs at {docs_url}."
98
+ )
99
+ abs_path = os.path.abspath(path)
100
+ if (cloud or template) and os.path.exists(abs_path):
101
+ raise_error(
102
+ "Must specify a new directory if using --cloud or --template"
103
+ )
104
+ ck_info_fpath = os.path.join(abs_path, "calkit.yaml")
105
+ if os.path.isfile(ck_info_fpath) and not overwrite:
106
+ raise_error(
107
+ "Destination is already a Calkit project; "
108
+ "use --overwrite to continue"
109
+ )
110
+ if os.path.isdir(abs_path) and os.listdir(abs_path):
111
+ warn(f"{abs_path} is not empty")
112
+ if name is None:
113
+ name = re.sub(r"[-_,\.\ ]", "-", os.path.basename(abs_path).lower())
114
+ if " " in name:
115
+ warn("Invalid name; replacing spaces with hyphens")
116
+ name = name.replace(" ", "-")
117
+ typer.echo(f"Creating project {name}")
118
+ if title is None:
119
+ title = typer.prompt("Enter a title (ex: 'My research project')")
120
+ typer.echo(f"Using title: {title}")
121
+ if cloud:
122
+ # Cloud should allow None, which will allow us to post just the name
123
+ # NOTE: This will fail if the user hasn't logged into GitHub in a
124
+ # while, and their token stored in the Calkit cloud is expired
125
+ try:
126
+ resp = calkit.cloud.post(
127
+ "/projects",
128
+ json=dict(
129
+ name=name,
130
+ title=title,
131
+ description=description,
132
+ git_repo_url=git_repo_url,
133
+ is_public=public,
134
+ template=template,
135
+ ),
136
+ )
137
+ except Exception as e:
138
+ raise_error(f"Posting new project to cloud failed: {e}")
139
+ # Now clone here and that's about
140
+ subprocess.run(["git", "clone", resp["git_repo_url"], abs_path])
141
+ try:
142
+ calkit.dvc.set_remote_auth(wdir=abs_path)
143
+ except Exception:
144
+ warn("Failed to setup Calkit DVC remote auth")
145
+ prj = calkit.git.detect_project_name(path=abs_path)
146
+ add_msg = f"\n\nYou can view your project at https://calkit.io/{prj}"
147
+ typer.echo(success_message + add_msg)
148
+ return
149
+ # If using a template, clone it first
150
+ if template:
151
+ # TODO: If the template is not a Git repo URL, make a request to the
152
+ # Calkit Cloud to get it?
153
+ # For now, assume consistency between Calkit Cloud projects and
154
+ # GitHub repo URLs
155
+ if "github.com" in template:
156
+ template_git_url = template
157
+ template_name = template.split("github.com")[-1][1:].removesuffix(
158
+ ".git"
159
+ )
160
+ else:
161
+ template_name = template
162
+ template_git_url = f"https://github.com/{template}"
163
+ # Now clone it
164
+ subprocess.run(["git", "clone", template_git_url, abs_path])
165
+ # Templates should always have DVC initialized, so no need to do that
166
+ repo = git.Repo(abs_path)
167
+ git_rev = repo.git.rev_parse("HEAD")
168
+ # Rename origin remote as upstream
169
+ typer.echo("Renaming template remote as upstream")
170
+ repo.git.remote(["rename", "origin", "upstream"])
171
+ # Set git repo URL if provided
172
+ if git_repo_url:
173
+ typer.echo("Setting origin remote URL")
174
+ repo.git.remote(["add", "origin", git_repo_url])
175
+ # Update Calkit info in this project
176
+ ck_info = calkit.load_calkit_info(wdir=abs_path)
177
+ ck_info = ck_info | dict(
178
+ name=name,
179
+ title=title,
180
+ description=description,
181
+ git_repo_url=git_repo_url,
182
+ derived_from=dict(
183
+ project=template_name,
184
+ git_repo_url=template_git_url,
185
+ git_rev=git_rev,
186
+ ),
187
+ )
188
+ # Remove questions and owner if they're there
189
+ _ = ck_info.pop("questions", None)
190
+ _ = ck_info.pop("owner", None)
191
+ # Write Calkit info
192
+ with open(os.path.join(abs_path, "calkit.yaml"), "w") as f:
193
+ ryaml.dump(ck_info, f)
194
+ # Update README
195
+ readme_fpath = os.path.join(abs_path, "README.md")
196
+ typer.echo("Generating README.md")
197
+ readme_txt = calkit.make_readme_content(
198
+ project_name=name,
199
+ project_title=title,
200
+ project_description=description,
201
+ )
202
+ with open(readme_fpath, "w") as f:
203
+ f.write(readme_txt)
204
+ # Update DVC remote
205
+ # TODO: This will fail because we don't know this user's account name
206
+ typer.echo("Updating Calkit DVC remote")
207
+ try:
208
+ calkit.dvc.configure_remote(wdir=abs_path)
209
+ except ValueError:
210
+ warn(
211
+ "Could not update Calkit DVC remote since "
212
+ "no Git repo URL was provided"
213
+ )
214
+ warn(
215
+ "You will need to manually run `git remote add origin` "
216
+ "and `calkit config remote`"
217
+ )
218
+ subprocess.call(
219
+ ["dvc", "remote", "remove", "calkit", "-q"], cwd=abs_path
220
+ )
221
+ try:
222
+ calkit.dvc.set_remote_auth(wdir=abs_path)
223
+ except Exception:
224
+ warn("Could not set authentication for Calkit DVC remote")
225
+ # Commit this stuff to Git
226
+ repo.git.add(".")
227
+ if repo.git.diff("--staged"):
228
+ repo.git.commit(["-m", f"Create new project from {template_name}"])
229
+ typer.echo(success_message)
230
+ return
231
+ os.makedirs(abs_path, exist_ok=True)
232
+ try:
233
+ repo = git.Repo(abs_path)
234
+ except InvalidGitRepositoryError:
235
+ typer.echo("Initializing Git repository")
236
+ subprocess.run(["git", "init", "-q"], cwd=abs_path)
237
+ repo = git.Repo(abs_path)
238
+ if not os.path.isfile(os.path.join(abs_path, ".dvc", "config")):
239
+ typer.echo("Initializing DVC repository")
240
+ subprocess.run(["dvc", "init", "-q"], cwd=abs_path)
241
+ # Create calkit.yaml file
242
+ ck_info = calkit.load_calkit_info(wdir=abs_path)
243
+ ck_info = dict(name=name, title=title, description=description) | ck_info
244
+ with open(os.path.join(abs_path, "calkit.yaml"), "w") as f:
245
+ ryaml.dump(ck_info, f)
246
+ # Create dev container spec
247
+ update_devcontainer(wdir=abs_path)
248
+ # Create README
249
+ readme_fpath = os.path.join(abs_path, "README.md")
250
+ if os.path.isfile(readme_fpath) and not overwrite:
251
+ warn("README.md already exists; not modifying")
252
+ else:
253
+ typer.echo("Generating README.md")
254
+ readme_txt = calkit.make_readme_content(
255
+ project_name=name,
256
+ project_title=title,
257
+ project_description=description,
258
+ )
259
+ with open(readme_fpath, "w") as f:
260
+ f.write(readme_txt)
261
+ if git_repo_url and not repo.remotes:
262
+ typer.echo(f"Adding Git remote {git_repo_url}")
263
+ repo.git.remote(["add", "origin", git_repo_url])
264
+ elif not git_repo_url and not repo.remotes:
265
+ warn("No Git remotes are configured")
266
+ # Setup Calkit Cloud DVC remote
267
+ if repo.remotes:
268
+ typer.echo("Setting up Calkit Cloud DVC remote")
269
+ calkit.dvc.configure_remote(wdir=abs_path)
270
+ calkit.dvc.set_remote_auth(wdir=abs_path)
271
+ repo.git.add(".")
272
+ if repo.git.diff("--staged") and not no_commit:
273
+ repo.git.commit(["-m", "Initialize Calkit project"])
274
+ typer.echo(success_message)
275
+
276
+
21
277
  @new_app.command(name="figure")
22
278
  def new_figure(
23
279
  path: str,
@@ -533,6 +789,7 @@ def new_publication(
533
789
  str,
534
790
  typer.Option(
535
791
  "--template",
792
+ "-t",
536
793
  help=(
537
794
  "Template with which to create the source files. "
538
795
  "Should be in the format {type}/{name}."
@@ -2,14 +2,43 @@
2
2
 
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import subprocess
6
7
 
8
+ from packaging.specifiers import SpecifierSet
9
+ from packaging.version import Version
7
10
  from pydantic import BaseModel
8
11
 
9
12
  import calkit
10
13
  from calkit import ryaml
11
14
 
12
15
 
16
+ def _check_single(req: str, actual: str, conda: bool = False) -> bool:
17
+ """Helper function for checking actual versions against requirements."""
18
+ req_name = re.split("[=<>]", req)[0]
19
+ req_spec = req.removeprefix(req_name)
20
+ if conda and req_spec.startswith("="):
21
+ req_spec = "=" + req_spec
22
+ if not req_spec.endswith(".*"):
23
+ req_spec += ".*"
24
+ actual_name, actual_vers = re.split("[=<>]+", actual, maxsplit=1)
25
+ if actual_name != req_name:
26
+ return False
27
+ actual_spec = actual.removeprefix(actual_name)
28
+ if conda and actual_spec.startswith("="):
29
+ actual_spec = "=" + actual_spec
30
+ version = Version(actual_vers)
31
+ spec = SpecifierSet(req_spec)
32
+ return spec.contains(version)
33
+
34
+
35
+ def _check_list(req: str, actual: list[str], conda: bool = False) -> bool:
36
+ for installed in actual:
37
+ if _check_single(req, installed, conda=conda):
38
+ return True
39
+ return False
40
+
41
+
13
42
  class EnvCheckResult(BaseModel):
14
43
  env_exists: bool | None = None
15
44
  env_needs_export: bool | None = None
@@ -127,45 +156,23 @@ def check_env(
127
156
  required_conda_deps.append(dep.replace("==", "="))
128
157
  log_func("Checking conda dependencies")
129
158
  for dep in required_conda_deps:
130
- dep_split = dep.split("=")
131
- package = dep_split[0]
132
- if len(dep_split) > 1:
133
- version = dep_split[1]
134
- else:
135
- version = None
136
- if version is not None and dep not in existing_conda_deps:
159
+ is_okay = _check_list(
160
+ req=dep, actual=existing_conda_deps, conda=True
161
+ )
162
+ if not is_okay:
137
163
  log_func(f"Found missing dependency: {dep}")
138
164
  env_needs_rebuild = True
139
165
  break
140
- elif version is None:
141
- # TODO: This does not handle specification of only major or
142
- # major+minor version
143
- if package not in [
144
- d.split("=")[0] for d in existing_conda_deps
145
- ]:
146
- log_func(f"Found missing dependency: {dep}")
147
- env_needs_rebuild = True
148
- break
149
166
  if not env_needs_rebuild and not relaxed:
150
167
  log_func("Checking pip dependencies")
151
168
  for dep in required_pip_deps:
152
- dep_split = dep.split("==")
153
- package = dep_split[0]
154
- if len(dep_split) > 1:
155
- version = dep_split[1]
156
- else:
157
- version = None
158
- if version is not None and dep not in existing_pip_deps:
169
+ is_okay = _check_list(
170
+ req=dep, actual=existing_pip_deps, conda=False
171
+ )
172
+ if not is_okay:
159
173
  env_needs_rebuild = True
160
174
  log_func(f"Found missing dependency: {dep}")
161
175
  break
162
- elif version is None:
163
- if package not in [
164
- d.split("==")[0] for d in existing_pip_deps
165
- ]:
166
- log_func(f"Found missing dependency: {dep}")
167
- env_needs_rebuild = True
168
- break
169
176
  if env_needs_rebuild:
170
177
  res.env_needs_rebuild = True
171
178
  log_func(f"Rebuilding {env_name} since it does not match spec")
@@ -209,3 +209,13 @@ def save_notebook_stage_out(
209
209
  obj.write_parquet(fpath)
210
210
  else:
211
211
  raise ValueError(f"Unsupported format '{fmt}' for engine '{engine}'")
212
+
213
+
214
+ def make_readme_content(
215
+ project_name: str, project_title: str, project_description: str | None
216
+ ) -> str:
217
+ """Create Markdown content for a Calkit project README."""
218
+ txt = f"# {project_title}\n\n"
219
+ if project_description is not None:
220
+ txt += f"\n{project_description}\n"
221
+ return txt
@@ -13,19 +13,23 @@ logger = logging.getLogger(__package__)
13
13
  logger.setLevel(logging.INFO)
14
14
 
15
15
 
16
- def configure_remote():
17
- project_name = calkit.git.detect_project_name()
16
+ def configure_remote(wdir: str = None):
17
+ project_name = calkit.git.detect_project_name(path=wdir)
18
18
  base_url = calkit.cloud.get_base_url()
19
19
  remote_url = f"{base_url}/projects/{project_name}/dvc"
20
20
  subprocess.check_call(
21
- ["dvc", "remote", "add", "-d", "-f", get_app_name(), remote_url]
21
+ ["dvc", "remote", "add", "-d", "-f", get_app_name(), remote_url],
22
+ cwd=wdir,
22
23
  )
23
24
  subprocess.check_call(
24
- ["dvc", "remote", "modify", get_app_name(), "auth", "custom"]
25
+ ["dvc", "remote", "modify", get_app_name(), "auth", "custom"],
26
+ cwd=wdir,
25
27
  )
26
28
 
27
29
 
28
- def set_remote_auth(remote_name: str = None, always_auth: bool = False):
30
+ def set_remote_auth(
31
+ remote_name: str = None, always_auth: bool = False, wdir: str = None
32
+ ):
29
33
  """Get a token and set it in the local DVC config so we can interact with
30
34
  the cloud as an HTTP remote.
31
35
  """
@@ -48,7 +52,8 @@ def set_remote_auth(remote_name: str = None, always_auth: bool = False):
48
52
  remote_name,
49
53
  "custom_auth_header",
50
54
  "Authorization",
51
- ]
55
+ ],
56
+ cwd=wdir,
52
57
  )
53
58
  subprocess.check_call(
54
59
  [
@@ -59,7 +64,8 @@ def set_remote_auth(remote_name: str = None, always_auth: bool = False):
59
64
  remote_name,
60
65
  "password",
61
66
  f"Bearer {settings.dvc_token}",
62
- ]
67
+ ],
68
+ cwd=wdir,
63
69
  )
64
70
 
65
71
 
@@ -0,0 +1,22 @@
1
+ """Git-related functionality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import git
6
+
7
+ import calkit
8
+
9
+
10
+ def detect_project_name(path: str = None) -> str:
11
+ """Read the project owner and name from the remote."""
12
+ ck_info = calkit.load_calkit_info(wdir=path)
13
+ name = ck_info.get("name")
14
+ owner = ck_info.get("owner")
15
+ url = git.Repo(path=path).remote().url
16
+ from_url = url.split("github.com")[-1][1:].removesuffix(".git")
17
+ owner_name, project_name = from_url.split("/")
18
+ if name is None:
19
+ name = project_name
20
+ if owner is None:
21
+ owner = owner_name
22
+ return f"{owner}/{name}"
@@ -0,0 +1,69 @@
1
+ """Tests for ``calkit.calc``."""
2
+
3
+ import pytest
4
+
5
+ import calkit
6
+
7
+
8
+ def test_formula():
9
+ calc = calkit.calc.Formula(
10
+ params=dict(formula="0.2151 * x + y**2"),
11
+ inputs=["x", "y"],
12
+ output=calkit.calc.Output(
13
+ name="z", description="The value", template="The value is {z:.1f}."
14
+ ),
15
+ )
16
+ assert calc.evaluate(x=10.2, y=0.1) == 2.20402
17
+ res = calkit.calc.evaluate_and_format(calc, x=5, y=1)
18
+ assert res == "The value is 2.1."
19
+ with pytest.raises(ValueError):
20
+ calc.evaluate(x=5)
21
+ with pytest.raises(ValueError):
22
+ calc.evaluate(z=5)
23
+ with pytest.raises(ValueError):
24
+ calc = calkit.calc.Formula(
25
+ params=dict(formula="0.2151 * x + y**2"),
26
+ inputs=["x", "x"],
27
+ output=calkit.calc.Output(
28
+ name="z",
29
+ description="The value",
30
+ template="The value is {z:.1f}.",
31
+ ),
32
+ )
33
+ with pytest.raises(ValueError):
34
+ calc = calkit.calc.Formula(
35
+ params=dict(formula="0.2151 * x + y**2"),
36
+ inputs=["x", "y"],
37
+ output=calkit.calc.Output(
38
+ name="x",
39
+ description="The value",
40
+ template="The value is {z:.1f}.",
41
+ ),
42
+ )
43
+
44
+
45
+ def test_lookuptable():
46
+ calc = calkit.calc.LookupTable(
47
+ inputs=["x"],
48
+ output="something",
49
+ params=calkit.calc.LookupTableParams(
50
+ x_values=[1, 2, 3], y_values=[4, 5, 6]
51
+ ),
52
+ )
53
+ # TODO: Implement this
54
+
55
+
56
+ def test_linear():
57
+ calc = calkit.calc.Linear(
58
+ params=calkit.calc.LinearParams(
59
+ coeffs=dict(input_voltage=1.1), offset=0.01
60
+ ),
61
+ inputs=[{"name": "input_voltage", "dtype": "float"}],
62
+ output="load_lbf",
63
+ )
64
+ res = calc.evaluate(input_voltage=1.534)
65
+ assert round(res, ndigits=3) == 1.697
66
+ res2 = calkit.calc.evaluate_and_format(calc, input_voltage=-0.5)
67
+ assert (
68
+ res2 == "For input input_voltage=-0.5, the output is load_lbf=-0.54."
69
+ )
@@ -5,7 +5,25 @@ import uuid
5
5
 
6
6
  import pytest
7
7
 
8
- from calkit.conda import check_env
8
+ from calkit.conda import _check_list, _check_single, check_env
9
+
10
+
11
+ def test_check_single():
12
+ assert _check_single("python=3.12", "python=3.12.18", conda=True)
13
+ assert _check_single("python=3", "python=3.12.18", conda=True)
14
+ assert _check_single("python=3.12.18", "python=3.12.18", conda=True)
15
+ assert _check_single("python>=3.12,<3.13", "python==3.12.18", conda=False)
16
+
17
+
18
+ def test_check_list():
19
+ installed = ["python=3.12.1", "numpy=1.0.11"]
20
+ assert _check_list("python=3", installed, conda=True)
21
+ assert _check_list("numpy", installed, conda=True)
22
+ assert not _check_list("pandas", installed, conda=True)
23
+ installed = ["python==3.12.1", "numpy==1.0.11"]
24
+ assert _check_list("python>=3", installed, conda=False)
25
+ assert _check_list("numpy", installed, conda=False)
26
+ assert not _check_list("pandas", installed, conda=False)
9
27
 
10
28
 
11
29
  def delete_env(name: str):
@@ -124,3 +142,22 @@ def test_check_env(tmp_dir, env_name):
124
142
  )
125
143
  res = check_env(relaxed=True)
126
144
  assert not res.env_needs_rebuild
145
+ # Make sure we can handle other ways of specifying versions
146
+ subprocess.check_call(
147
+ [
148
+ "calkit",
149
+ "new",
150
+ "conda-env",
151
+ "--overwrite",
152
+ "-n",
153
+ env_name,
154
+ "python=3.12",
155
+ "--pip",
156
+ "numpy>=1",
157
+ ]
158
+ )
159
+ res = check_env()
160
+ assert res.env_needs_rebuild
161
+ res = check_env()
162
+ assert not res.env_needs_export
163
+ assert not res.env_needs_rebuild
@@ -12,6 +12,7 @@ classifiers = [
12
12
  "Operating System :: OS Independent",
13
13
  ]
14
14
  dependencies = [
15
+ "arithmeval",
15
16
  "docx2pdf",
16
17
  "dvc",
17
18
  "eval-type-backport; python_version < '3.10'",
@@ -1,17 +0,0 @@
1
- """Git-related functionality."""
2
-
3
- from __future__ import annotations
4
-
5
- import git
6
-
7
-
8
- def detect_project_name(path=None) -> str:
9
- """Read the project owner and name from the remote.
10
-
11
- TODO: Currently only works with GitHub remotes where the GitHub repo
12
- name is identical to the Calkit project name, which is not guaranteed.
13
- We should probably look inside ``calkit.yaml`` at ``name``
14
- first, and fallback to the GitHub remote URL if we can't find that.
15
- """
16
- url = git.Repo(path=path).remote().url
17
- return url.split("github.com")[-1][1:].removesuffix(".git")
File without changes