framework-py 0.1.0.dev0__py3-none-any.whl
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.
- framework/__init__.py +250 -0
- framework/__main__.py +89 -0
- framework/_assets/hooks/pre-commit +59 -0
- framework/_assets/init.sql +63 -0
- framework/_assets/schema/layer.v1.json +605 -0
- framework/_assets/schema/layers.v1.json +791 -0
- framework/_assets/schema/spec.v1.json +604 -0
- framework/_log.py +29 -0
- framework/_paths.py +45 -0
- framework/_secretservice.py +69 -0
- framework/_time.py +11 -0
- framework/_wincred.py +105 -0
- framework/_yaml.py +90 -0
- framework/ai.py +204 -0
- framework/bootstrap.py +206 -0
- framework/cache.py +335 -0
- framework/catalog.py +193 -0
- framework/cloud.py +899 -0
- framework/config_home.py +194 -0
- framework/connections.py +442 -0
- framework/console.py +41 -0
- framework/create.py +579 -0
- framework/data.py +519 -0
- framework/db.py +222 -0
- framework/dotenv.py +79 -0
- framework/errors.py +39 -0
- framework/global_config.py +238 -0
- framework/hooks.py +364 -0
- framework/integrity.py +22 -0
- framework/notebooks.py +384 -0
- framework/outputs.py +324 -0
- framework/packages.py +276 -0
- framework/project.py +52 -0
- framework/py.typed +0 -0
- framework/quarto.py +163 -0
- framework/scaffold.py +269 -0
- framework/scratch.py +108 -0
- framework/security.py +646 -0
- framework/settings.py +439 -0
- framework/spec/__init__.py +0 -0
- framework/spec/check.py +211 -0
- framework/spec/merge.py +195 -0
- framework/spec/resolve.py +254 -0
- framework/storage.py +516 -0
- framework/templates/defaults.yml +144 -0
- framework/templates/gitignore/course +120 -0
- framework/templates/gitignore/presentation +114 -0
- framework/templates/gitignore/project +108 -0
- framework/templates/gitignore/sensitive +139 -0
- framework/templates/skills/framework-data/SKILL.md +47 -0
- framework/templates/skills/framework-outputs/SKILL.md +68 -0
- framework/templates/skills/framework-packages/SKILL.md +33 -0
- framework/templates/skills/framework-sensitive-data/SKILL.md +38 -0
- framework/templates/skills/framework-workflow/SKILL.md +58 -0
- framework/templates/stubs/notebook-default.ipynb +34 -0
- framework/templates/stubs/notebook-default.qmd +43 -0
- framework/templates/stubs/notebook-minimal.qmd +15 -0
- framework/templates/stubs/notebook-revealjs.qmd +34 -0
- framework/templates/stubs/script-default.py +18 -0
- framework_py-0.1.0.dev0.dist-info/METADATA +111 -0
- framework_py-0.1.0.dev0.dist-info/RECORD +63 -0
- framework_py-0.1.0.dev0.dist-info/WHEEL +4 -0
- framework_py-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
framework/outputs.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Saving results: tables, figures, models, reports, and rendered notebooks.
|
|
2
|
+
|
|
3
|
+
Each save writes the file to the project's output folder for its kind and
|
|
4
|
+
records it in the ``results`` table of framework.db (named by file name, with
|
|
5
|
+
the file's hash), the same way R does. Sensitive projects keep public and
|
|
6
|
+
private outputs in separate folders.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pickle
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
from framework import db
|
|
22
|
+
from framework._log import inform
|
|
23
|
+
from framework._paths import output_directory
|
|
24
|
+
from framework.errors import FrameworkError, UnsupportedFormatError
|
|
25
|
+
from framework.integrity import file_hash
|
|
26
|
+
from framework.settings import settings_read
|
|
27
|
+
|
|
28
|
+
_TABLE_FORMATS = {"csv": ".csv", "parquet": ".parquet", "xlsx": ".xlsx"}
|
|
29
|
+
_FIGURE_FORMATS = ("png", "pdf", "svg", "jpg", "jpeg", "tiff")
|
|
30
|
+
_NOTEBOOK_FORMATS = {"html": ".html", "pdf": ".pdf", "docx": ".docx"}
|
|
31
|
+
_RESULT_TYPES = ("table", "figure", "model", "report", "notebook")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _folder(kind: str, public: bool) -> Path:
|
|
35
|
+
try:
|
|
36
|
+
config = settings_read()
|
|
37
|
+
except FrameworkError:
|
|
38
|
+
config = {}
|
|
39
|
+
folder = output_directory(config, kind, public)
|
|
40
|
+
if not folder.is_dir():
|
|
41
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
inform(f"Creating {kind} directory: {folder}")
|
|
43
|
+
return folder
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _check_overwrite(path: Path, overwrite: bool) -> None:
|
|
47
|
+
if path.exists() and not overwrite:
|
|
48
|
+
raise FrameworkError(f"File already exists: {path}. Use overwrite=True to replace.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _record(path: Path, type: str, public: bool) -> None:
|
|
52
|
+
"""Record a saved output in the results table, as R's ``.save_result()`` does."""
|
|
53
|
+
name = path.name
|
|
54
|
+
digest = file_hash(path) if path.is_file() else None
|
|
55
|
+
now = db.timestamp()
|
|
56
|
+
with db.connect() as connection:
|
|
57
|
+
if connection.execute("SELECT id FROM results WHERE name = ?", (name,)).fetchone():
|
|
58
|
+
connection.execute(
|
|
59
|
+
"UPDATE results SET type = ?, public = ?, comment = ?, hash = ?, updated_at = ? "
|
|
60
|
+
"WHERE name = ?",
|
|
61
|
+
(type, int(public), None, digest, now, name),
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
connection.execute(
|
|
65
|
+
"INSERT INTO results (name, type, public, blind, comment, hash, created_at, "
|
|
66
|
+
"updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
67
|
+
(name, type, int(public), 0, None, digest, now, now),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def save_table(
|
|
72
|
+
data: pd.DataFrame,
|
|
73
|
+
name: str,
|
|
74
|
+
format: str = "csv",
|
|
75
|
+
public: bool = False,
|
|
76
|
+
overwrite: bool = True,
|
|
77
|
+
**kwargs: Any,
|
|
78
|
+
) -> Path:
|
|
79
|
+
"""Save a table to the tables output folder.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
data: The table.
|
|
83
|
+
name: File name without extension.
|
|
84
|
+
format: ``"csv"``, ``"parquet"``, or ``"xlsx"`` (needs the ``excel`` extra).
|
|
85
|
+
public: In a sensitive project, save to the public tables folder.
|
|
86
|
+
overwrite: Replace an existing file.
|
|
87
|
+
**kwargs: Passed to the pandas writer.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The saved file.
|
|
91
|
+
"""
|
|
92
|
+
if format == "rds":
|
|
93
|
+
raise UnsupportedFormatError(".rds tables are R-only. Use csv, parquet, or xlsx.")
|
|
94
|
+
if format not in _TABLE_FORMATS:
|
|
95
|
+
raise FrameworkError(f"format must be one of {', '.join(_TABLE_FORMATS)}")
|
|
96
|
+
path = _folder("tables", public) / f"{name}{_TABLE_FORMATS[format]}"
|
|
97
|
+
_check_overwrite(path, overwrite)
|
|
98
|
+
if format == "csv":
|
|
99
|
+
data.to_csv(path, index=False, **{"lineterminator": "\n", **kwargs})
|
|
100
|
+
elif format == "parquet":
|
|
101
|
+
data.to_parquet(path, index=False, **kwargs)
|
|
102
|
+
else:
|
|
103
|
+
try:
|
|
104
|
+
import openpyxl # noqa: F401
|
|
105
|
+
except ImportError as error:
|
|
106
|
+
raise FrameworkError(
|
|
107
|
+
"xlsx tables need openpyxl. Install with: pip install framework-py[excel]"
|
|
108
|
+
) from error
|
|
109
|
+
data.to_excel(path, index=False, **kwargs)
|
|
110
|
+
inform(f"Saved table to {path}")
|
|
111
|
+
_record(path, "table", public)
|
|
112
|
+
return path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _current_figure() -> Any:
|
|
116
|
+
try:
|
|
117
|
+
import matplotlib.pyplot as plt
|
|
118
|
+
except ImportError as error:
|
|
119
|
+
raise FrameworkError(
|
|
120
|
+
"save_figure(None, ...) saves the current matplotlib figure, "
|
|
121
|
+
"but matplotlib is not installed."
|
|
122
|
+
) from error
|
|
123
|
+
return plt.gcf()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def save_figure(
|
|
127
|
+
plot: Any,
|
|
128
|
+
name: str,
|
|
129
|
+
format: str = "png",
|
|
130
|
+
width: float = 8,
|
|
131
|
+
height: float = 6,
|
|
132
|
+
dpi: int = 300,
|
|
133
|
+
public: bool = False,
|
|
134
|
+
overwrite: bool = True,
|
|
135
|
+
**kwargs: Any,
|
|
136
|
+
) -> Path:
|
|
137
|
+
"""Save a figure to the figures output folder.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
plot: A matplotlib or seaborn figure (anything with ``savefig()``), a
|
|
141
|
+
plotnine plot (anything with ``save()``), or None for the current
|
|
142
|
+
matplotlib figure.
|
|
143
|
+
name: File name without extension.
|
|
144
|
+
format: png, pdf, svg, jpg, jpeg, or tiff.
|
|
145
|
+
width: Width in inches.
|
|
146
|
+
height: Height in inches.
|
|
147
|
+
dpi: Resolution for raster formats.
|
|
148
|
+
public: In a sensitive project, save to the public figures folder.
|
|
149
|
+
overwrite: Replace an existing file.
|
|
150
|
+
**kwargs: Passed to ``savefig()`` or ``save()``.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The saved file.
|
|
154
|
+
"""
|
|
155
|
+
if format not in _FIGURE_FORMATS:
|
|
156
|
+
raise FrameworkError(f"format must be one of {', '.join(_FIGURE_FORMATS)}")
|
|
157
|
+
if width < 0.1 or height < 0.1 or dpi < 1:
|
|
158
|
+
raise FrameworkError("width and height must be at least 0.1 and dpi at least 1")
|
|
159
|
+
figure = _current_figure() if plot is None else plot
|
|
160
|
+
path = _folder("figures", public) / f"{name}.{format}"
|
|
161
|
+
_check_overwrite(path, overwrite)
|
|
162
|
+
|
|
163
|
+
if hasattr(figure, "savefig"):
|
|
164
|
+
target = getattr(figure, "figure", figure)
|
|
165
|
+
if hasattr(target, "set_size_inches"):
|
|
166
|
+
target.set_size_inches(width, height)
|
|
167
|
+
figure.savefig(path, dpi=dpi, format=format, **kwargs)
|
|
168
|
+
elif hasattr(figure, "save"):
|
|
169
|
+
figure.save(path, width=width, height=height, dpi=dpi, units="in", verbose=False, **kwargs)
|
|
170
|
+
else:
|
|
171
|
+
raise FrameworkError(
|
|
172
|
+
"plot must have a savefig() method (matplotlib, seaborn) or a save() method "
|
|
173
|
+
"(plotnine), or be None to save the current matplotlib figure"
|
|
174
|
+
)
|
|
175
|
+
inform(f"Saved figure to {path}")
|
|
176
|
+
_record(path, "figure", public)
|
|
177
|
+
return path
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def save_model(model: Any, name: str, public: bool = False, overwrite: bool = True) -> Path:
|
|
181
|
+
"""Save a fitted model to the models output folder with pickle (``<name>.pkl``)."""
|
|
182
|
+
path = _folder("models", public) / f"{name}.pkl"
|
|
183
|
+
_check_overwrite(path, overwrite)
|
|
184
|
+
with path.open("wb") as handle:
|
|
185
|
+
pickle.dump(model, handle)
|
|
186
|
+
inform(f"Saved model to {path}")
|
|
187
|
+
_record(path, "model", public)
|
|
188
|
+
return path
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def save_report(
|
|
192
|
+
file: str | Path,
|
|
193
|
+
name: str | None = None,
|
|
194
|
+
public: bool = False,
|
|
195
|
+
overwrite: bool = True,
|
|
196
|
+
move: bool = False,
|
|
197
|
+
) -> Path:
|
|
198
|
+
"""Copy (or move) a finished report into the reports output folder.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
file: The report file.
|
|
202
|
+
name: New file name without extension. Default: keep the name.
|
|
203
|
+
public: In a sensitive project, save to the public reports folder.
|
|
204
|
+
overwrite: Replace an existing file.
|
|
205
|
+
move: Move the file instead of copying it.
|
|
206
|
+
"""
|
|
207
|
+
source = Path(file)
|
|
208
|
+
if not source.is_file():
|
|
209
|
+
raise FrameworkError(f"File not found: {source}")
|
|
210
|
+
folder = _folder("reports", public)
|
|
211
|
+
destination = folder / (source.name if name is None else f"{name}{source.suffix}")
|
|
212
|
+
_check_overwrite(destination, overwrite)
|
|
213
|
+
if move:
|
|
214
|
+
shutil.move(source, destination)
|
|
215
|
+
inform(f"Moved report to {destination}")
|
|
216
|
+
else:
|
|
217
|
+
shutil.copyfile(source, destination)
|
|
218
|
+
inform(f"Saved report to {destination}")
|
|
219
|
+
_record(destination, "report", public)
|
|
220
|
+
return destination
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def save_notebook(
|
|
224
|
+
file: str | Path,
|
|
225
|
+
name: str | None = None,
|
|
226
|
+
format: str = "html",
|
|
227
|
+
public: bool = False,
|
|
228
|
+
overwrite: bool = True,
|
|
229
|
+
embed_resources: bool = True,
|
|
230
|
+
quarto_args: Sequence[str] = (),
|
|
231
|
+
) -> Path:
|
|
232
|
+
"""Render a Quarto notebook or Jupyter notebook and save the result.
|
|
233
|
+
|
|
234
|
+
Rendering uses the ``quarto`` command, which must be installed.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
file: A ``.qmd`` or ``.ipynb`` file.
|
|
238
|
+
name: Output file name without extension. Default: the notebook's name.
|
|
239
|
+
format: ``"html"``, ``"pdf"``, or ``"docx"``.
|
|
240
|
+
public: In a sensitive project, save to the public notebooks folder.
|
|
241
|
+
overwrite: Replace an existing file.
|
|
242
|
+
embed_resources: For HTML, embed images and styles in one file.
|
|
243
|
+
quarto_args: Extra arguments for ``quarto render``.
|
|
244
|
+
"""
|
|
245
|
+
source = Path(file)
|
|
246
|
+
if not source.is_file() or source.suffix.lower() not in (".qmd", ".ipynb"):
|
|
247
|
+
raise FrameworkError(f"save_notebook() needs an existing .qmd or .ipynb file: {source}")
|
|
248
|
+
if format not in _NOTEBOOK_FORMATS:
|
|
249
|
+
raise FrameworkError(f"format must be one of {', '.join(_NOTEBOOK_FORMATS)}")
|
|
250
|
+
quarto = shutil.which("quarto")
|
|
251
|
+
if quarto is None:
|
|
252
|
+
raise FrameworkError(
|
|
253
|
+
"Quarto not found. Install it from https://quarto.org/docs/get-started/"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
folder = _folder("notebooks", public)
|
|
257
|
+
destination = folder / f"{name or source.stem}{_NOTEBOOK_FORMATS[format]}"
|
|
258
|
+
_check_overwrite(destination, overwrite)
|
|
259
|
+
|
|
260
|
+
with tempfile.TemporaryDirectory(prefix="save_notebook_") as temp:
|
|
261
|
+
command = [quarto, "render", str(source.resolve()), "--output-dir", temp, "--to", format]
|
|
262
|
+
if embed_resources and format == "html":
|
|
263
|
+
command.append("--embed-resources")
|
|
264
|
+
command.extend(quarto_args)
|
|
265
|
+
inform(f"Rendering {source.name}...")
|
|
266
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
267
|
+
if result.returncode != 0:
|
|
268
|
+
raise FrameworkError(f"Quarto render failed\n{result.stdout}{result.stderr}")
|
|
269
|
+
outputs = sorted(Path(temp).glob(f"*{_NOTEBOOK_FORMATS[format]}"))
|
|
270
|
+
if not outputs:
|
|
271
|
+
raise FrameworkError(f"No {format} output found after rendering")
|
|
272
|
+
shutil.copyfile(outputs[0], destination)
|
|
273
|
+
inform(f"Saved notebook to {destination}")
|
|
274
|
+
_record(destination, "notebook", public)
|
|
275
|
+
return destination
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def result_list(type: str | None = None, public: bool | None = None) -> pd.DataFrame:
|
|
279
|
+
"""Saved results, newest first.
|
|
280
|
+
|
|
281
|
+
Columns: name, type, public, comment, hash, created_at, and updated_at.
|
|
282
|
+
Timestamps are local ``%Y-%m-%d %H:%M:%S`` strings, as in R.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
type: Only this kind: table, figure, model, report, or notebook.
|
|
286
|
+
public: Only public (True) or private (False) results.
|
|
287
|
+
"""
|
|
288
|
+
if type is not None and type not in _RESULT_TYPES:
|
|
289
|
+
raise FrameworkError(f"type must be one of {', '.join(_RESULT_TYPES)}")
|
|
290
|
+
columns = ["name", "type", "public", "comment", "hash", "created_at", "updated_at"]
|
|
291
|
+
query = (
|
|
292
|
+
"SELECT name, type, public, comment, hash, created_at, updated_at "
|
|
293
|
+
"FROM results WHERE deleted_at IS NULL"
|
|
294
|
+
)
|
|
295
|
+
params: list[Any] = []
|
|
296
|
+
if type is not None:
|
|
297
|
+
query += " AND type = ?"
|
|
298
|
+
params.append(type)
|
|
299
|
+
if public is not None:
|
|
300
|
+
query += " AND public = ?"
|
|
301
|
+
params.append(int(public))
|
|
302
|
+
with db.connect() as connection:
|
|
303
|
+
rows = connection.execute(query, params).fetchall()
|
|
304
|
+
|
|
305
|
+
records = []
|
|
306
|
+
for row in rows:
|
|
307
|
+
created = db.parse_timestamp(row["created_at"])
|
|
308
|
+
updated = db.parse_timestamp(row["updated_at"])
|
|
309
|
+
records.append(
|
|
310
|
+
{
|
|
311
|
+
"name": row["name"],
|
|
312
|
+
"type": row["type"],
|
|
313
|
+
"public": bool(row["public"]),
|
|
314
|
+
"comment": row["comment"],
|
|
315
|
+
"hash": row["hash"],
|
|
316
|
+
"created_at": created.strftime("%Y-%m-%d %H:%M:%S") if created else None,
|
|
317
|
+
"updated_at": updated.strftime("%Y-%m-%d %H:%M:%S") if updated else None,
|
|
318
|
+
"_sort": updated.timestamp() if updated else float("-inf"),
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
records.sort(key=lambda record: record["_sort"], reverse=True)
|
|
322
|
+
for record in records:
|
|
323
|
+
del record["_sort"]
|
|
324
|
+
return pd.DataFrame(records, columns=columns)
|
framework/packages.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Project packages, kept in ``pyproject.toml`` and locked with uv.
|
|
2
|
+
|
|
3
|
+
R's ``packages_*()`` functions manage renv. In Python the dependencies live in
|
|
4
|
+
``pyproject.toml``, ``uv.lock`` pins them, and these functions call uv. When
|
|
5
|
+
uv is not installed, ``package_add()`` and ``packages_install()`` fall back to
|
|
6
|
+
pip in the current Python environment. Locking needs uv.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tomllib
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from framework._log import inform, show_messages
|
|
24
|
+
from framework.errors import FrameworkError
|
|
25
|
+
from framework.project import project_root
|
|
26
|
+
|
|
27
|
+
_NAME = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run(args: list[str], root: Path) -> int:
|
|
31
|
+
"""Run a command in the project folder, showing its output. Returns its exit status."""
|
|
32
|
+
return subprocess.run(args, cwd=root, check=False).returncode
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _uv() -> str | None:
|
|
36
|
+
return shutil.which("uv")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _require_uv(task: str) -> str:
|
|
40
|
+
uv = _uv()
|
|
41
|
+
if uv is None:
|
|
42
|
+
raise FrameworkError(
|
|
43
|
+
f"{task} needs uv, which is not installed. "
|
|
44
|
+
"Install it from https://docs.astral.sh/uv/ and try again."
|
|
45
|
+
)
|
|
46
|
+
return uv
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _pyproject(root: Path) -> Path:
|
|
50
|
+
path = root / "pyproject.toml"
|
|
51
|
+
if not path.is_file():
|
|
52
|
+
raise FrameworkError(f"No pyproject.toml in {root}. Python projects list packages there.")
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _dependencies(root: Path) -> list[str]:
|
|
57
|
+
data = tomllib.loads(_pyproject(root).read_text(encoding="utf-8"))
|
|
58
|
+
dependencies = (data.get("project") or {}).get("dependencies") or []
|
|
59
|
+
return [str(item) for item in dependencies]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _package_name(requirement: str) -> str:
|
|
63
|
+
match = _NAME.match(requirement)
|
|
64
|
+
if match is None:
|
|
65
|
+
raise ValueError(f"Not a package requirement: {requirement!r}")
|
|
66
|
+
return match.group(1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _normalize(name: str) -> str:
|
|
70
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _installed_version(name: str) -> str | None:
|
|
74
|
+
try:
|
|
75
|
+
return version(name)
|
|
76
|
+
except PackageNotFoundError:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _check(status: int, what: str) -> None:
|
|
81
|
+
if status != 0:
|
|
82
|
+
raise FrameworkError(f"{what} failed (exit status {status})")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _add_to_pyproject(path: Path, requirement: str) -> bool:
|
|
86
|
+
"""Add a requirement to ``[project] dependencies`` without uv. Returns whether it changed.
|
|
87
|
+
|
|
88
|
+
The file is edited as text so comments and layout stay as they are.
|
|
89
|
+
"""
|
|
90
|
+
text = path.read_text(encoding="utf-8")
|
|
91
|
+
name = _normalize(_package_name(requirement))
|
|
92
|
+
if any(_normalize(_package_name(d)) == name for d in _dependencies(path.parent)):
|
|
93
|
+
return False
|
|
94
|
+
project = re.search(r"(?ms)^\[project\]\s*$(.*?)(?=^\[|\Z)", text)
|
|
95
|
+
block = re.search(r"(?ms)^dependencies\s*=\s*\[(.*?)\]", project.group(1)) if project else None
|
|
96
|
+
if project is None or block is None:
|
|
97
|
+
raise FrameworkError(
|
|
98
|
+
"Could not find the dependencies list under [project] in pyproject.toml. "
|
|
99
|
+
f"Add {requirement!r} there yourself, or install uv."
|
|
100
|
+
)
|
|
101
|
+
start = project.start(1) + block.start(1)
|
|
102
|
+
end = project.start(1) + block.end(1)
|
|
103
|
+
items = block.group(1)
|
|
104
|
+
entry = json.dumps(requirement)
|
|
105
|
+
if items.strip():
|
|
106
|
+
body = items.rstrip()
|
|
107
|
+
separator = "" if body.endswith(",") else ","
|
|
108
|
+
updated = f"{body}{separator}\n {entry},\n"
|
|
109
|
+
else:
|
|
110
|
+
updated = f"\n {entry},\n"
|
|
111
|
+
path.write_text(text[:start] + updated + text[end:], encoding="utf-8")
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Public API -----------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def package_add(requirement: str, *, install: bool = True, dev: bool = False) -> None:
|
|
119
|
+
"""Add a package to the project's dependencies, and install it.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
requirement: A package name, optionally with a version, such as
|
|
123
|
+
``"pandas"`` or ``"statsmodels>=0.14"``.
|
|
124
|
+
install: Install it now. With uv, False only updates
|
|
125
|
+
``pyproject.toml`` and ``uv.lock``.
|
|
126
|
+
dev: Add it to the ``dev`` dependency group (uv only).
|
|
127
|
+
"""
|
|
128
|
+
show_messages()
|
|
129
|
+
root = project_root()
|
|
130
|
+
path = _pyproject(root)
|
|
131
|
+
_package_name(requirement)
|
|
132
|
+
uv = _uv()
|
|
133
|
+
if uv is not None:
|
|
134
|
+
args = [uv, "add", requirement]
|
|
135
|
+
if dev:
|
|
136
|
+
args.append("--dev")
|
|
137
|
+
if not install:
|
|
138
|
+
args.append("--no-sync")
|
|
139
|
+
_check(_run(args, root), f"uv add {requirement}")
|
|
140
|
+
inform(f"✓ Added {requirement}")
|
|
141
|
+
return
|
|
142
|
+
if dev:
|
|
143
|
+
raise FrameworkError("Dev dependencies need uv. Install it from https://docs.astral.sh/uv/")
|
|
144
|
+
if _add_to_pyproject(path, requirement):
|
|
145
|
+
inform(f"✓ Added {requirement} to pyproject.toml")
|
|
146
|
+
else:
|
|
147
|
+
inform(f"{_package_name(requirement)} is already in pyproject.toml")
|
|
148
|
+
if install:
|
|
149
|
+
_check(_run([sys.executable, "-m", "pip", "install", requirement], root), "pip install")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def packages_list() -> pd.DataFrame:
|
|
153
|
+
"""The project's dependencies from ``pyproject.toml``, with installed versions.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
A DataFrame with columns package, requirement, and installed (None
|
|
157
|
+
when the package is not installed in the running environment).
|
|
158
|
+
"""
|
|
159
|
+
root = project_root()
|
|
160
|
+
rows = [
|
|
161
|
+
{
|
|
162
|
+
"package": _package_name(requirement),
|
|
163
|
+
"requirement": requirement,
|
|
164
|
+
"installed": _installed_version(_package_name(requirement)),
|
|
165
|
+
}
|
|
166
|
+
for requirement in _dependencies(root)
|
|
167
|
+
]
|
|
168
|
+
return pd.DataFrame(rows, columns=["package", "requirement", "installed"])
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def packages_install() -> None:
|
|
172
|
+
"""Install the project's dependencies: ``uv sync``, or pip without uv."""
|
|
173
|
+
show_messages()
|
|
174
|
+
root = project_root()
|
|
175
|
+
uv = _uv()
|
|
176
|
+
if uv is not None:
|
|
177
|
+
_check(_run([uv, "sync"], root), "uv sync")
|
|
178
|
+
return
|
|
179
|
+
dependencies = _dependencies(root)
|
|
180
|
+
if dependencies:
|
|
181
|
+
_check(_run([sys.executable, "-m", "pip", "install", *dependencies], root), "pip install")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def packages_restore() -> None:
|
|
185
|
+
"""Install exactly the versions in ``uv.lock`` (``uv sync --frozen``)."""
|
|
186
|
+
show_messages()
|
|
187
|
+
root = project_root()
|
|
188
|
+
uv = _require_uv("Restoring packages")
|
|
189
|
+
if not (root / "uv.lock").is_file():
|
|
190
|
+
raise FrameworkError("No uv.lock to restore from. Run packages_snapshot() first.")
|
|
191
|
+
_check(_run([uv, "sync", "--frozen"], root), "uv sync --frozen")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def packages_snapshot() -> None:
|
|
195
|
+
"""Record the resolved versions in ``uv.lock`` (``uv lock``)."""
|
|
196
|
+
show_messages()
|
|
197
|
+
root = project_root()
|
|
198
|
+
_pyproject(root)
|
|
199
|
+
_check(_run([_require_uv("Locking packages"), "lock"], root), "uv lock")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def packages_update(packages: str | list[str] | None = None) -> None:
|
|
203
|
+
"""Upgrade locked versions, all of them or the named ones, and install them."""
|
|
204
|
+
show_messages()
|
|
205
|
+
root = project_root()
|
|
206
|
+
uv = _require_uv("Updating packages")
|
|
207
|
+
args = [uv, "lock"]
|
|
208
|
+
if packages is None:
|
|
209
|
+
args.append("--upgrade")
|
|
210
|
+
else:
|
|
211
|
+
for name in [packages] if isinstance(packages, str) else packages:
|
|
212
|
+
args += ["--upgrade-package", name]
|
|
213
|
+
_check(_run(args, root), "uv lock")
|
|
214
|
+
_check(_run([uv, "sync"], root), "uv sync")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class PackagesStatus:
|
|
219
|
+
"""Whether the lock file and the environment match ``pyproject.toml``.
|
|
220
|
+
|
|
221
|
+
``lock_current`` and ``synced`` are None when uv is not installed.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
lock_current: bool | None
|
|
225
|
+
synced: bool | None
|
|
226
|
+
missing: list[str] = field(default_factory=list)
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def ok(self) -> bool:
|
|
230
|
+
return self.lock_current is not False and self.synced is not False and not self.missing
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def packages_status() -> PackagesStatus:
|
|
234
|
+
"""Check that ``uv.lock`` is current and the environment matches it.
|
|
235
|
+
|
|
236
|
+
``missing`` lists dependencies that are not installed in the running
|
|
237
|
+
Python environment.
|
|
238
|
+
"""
|
|
239
|
+
root = project_root()
|
|
240
|
+
missing = [
|
|
241
|
+
_package_name(requirement)
|
|
242
|
+
for requirement in _dependencies(root)
|
|
243
|
+
if _installed_version(_package_name(requirement)) is None
|
|
244
|
+
]
|
|
245
|
+
uv = _uv()
|
|
246
|
+
lock_current = synced = None
|
|
247
|
+
if uv is not None:
|
|
248
|
+
|
|
249
|
+
def quiet(*args: str) -> bool:
|
|
250
|
+
completed = subprocess.run(
|
|
251
|
+
[uv, *args],
|
|
252
|
+
cwd=root,
|
|
253
|
+
check=False,
|
|
254
|
+
stdout=subprocess.DEVNULL,
|
|
255
|
+
stderr=subprocess.DEVNULL,
|
|
256
|
+
)
|
|
257
|
+
return completed.returncode == 0
|
|
258
|
+
|
|
259
|
+
if (root / "uv.lock").is_file():
|
|
260
|
+
lock_current = quiet("lock", "--check")
|
|
261
|
+
synced = quiet("sync", "--check", "--frozen")
|
|
262
|
+
else:
|
|
263
|
+
lock_current = False
|
|
264
|
+
status = PackagesStatus(lock_current=lock_current, synced=synced, missing=missing)
|
|
265
|
+
lines = [
|
|
266
|
+
f"Lock file: {_describe(lock_current, 'current', 'out of date (run packages_snapshot())')}",
|
|
267
|
+
f"Environment: {_describe(synced, 'in sync', 'out of sync (run packages_restore())')}",
|
|
268
|
+
]
|
|
269
|
+
if missing:
|
|
270
|
+
lines.append(f"Not installed here: {', '.join(missing)}")
|
|
271
|
+
print("\n".join(lines))
|
|
272
|
+
return status
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _describe(value: bool | None, good: str, bad: str) -> str:
|
|
276
|
+
return "unknown (uv is not installed)" if value is None else good if value else bad
|
framework/project.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Finding the project root and its settings file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from framework.errors import ProjectNotFoundError
|
|
8
|
+
|
|
9
|
+
SETTINGS_FILES = ("settings.yml", "config.yml")
|
|
10
|
+
|
|
11
|
+
# R stops after ten parent folders, so Python does too.
|
|
12
|
+
_MAX_DEPTH = 10
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _settings_in(folder: Path) -> Path | None:
|
|
16
|
+
for name in SETTINGS_FILES:
|
|
17
|
+
candidate = folder / name
|
|
18
|
+
if candidate.is_file():
|
|
19
|
+
return candidate
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_settings_file(start: str | Path | None = None) -> Path | None:
|
|
24
|
+
"""The project's settings file, searching ``start`` and up to ten parent folders.
|
|
25
|
+
|
|
26
|
+
``settings.yml`` is preferred over the older ``config.yml``.
|
|
27
|
+
"""
|
|
28
|
+
current = Path(start or Path.cwd()).resolve()
|
|
29
|
+
for _ in range(_MAX_DEPTH + 1):
|
|
30
|
+
found = _settings_in(current)
|
|
31
|
+
if found is not None:
|
|
32
|
+
return found
|
|
33
|
+
if current.parent == current:
|
|
34
|
+
return None
|
|
35
|
+
current = current.parent
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def project_root(start: str | Path | None = None) -> Path:
|
|
40
|
+
"""The root folder of the Framework project containing ``start`` (default: the current folder).
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ProjectNotFoundError: No settings.yml or config.yml was found.
|
|
44
|
+
"""
|
|
45
|
+
found = find_settings_file(start)
|
|
46
|
+
if found is None:
|
|
47
|
+
where = Path(start or Path.cwd()).resolve()
|
|
48
|
+
raise ProjectNotFoundError(
|
|
49
|
+
f"No Framework project found at or above {where}. "
|
|
50
|
+
"A project has settings.yml (or config.yml) in its root folder."
|
|
51
|
+
)
|
|
52
|
+
return found.parent
|
framework/py.typed
ADDED
|
File without changes
|