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.
Files changed (63) hide show
  1. framework/__init__.py +250 -0
  2. framework/__main__.py +89 -0
  3. framework/_assets/hooks/pre-commit +59 -0
  4. framework/_assets/init.sql +63 -0
  5. framework/_assets/schema/layer.v1.json +605 -0
  6. framework/_assets/schema/layers.v1.json +791 -0
  7. framework/_assets/schema/spec.v1.json +604 -0
  8. framework/_log.py +29 -0
  9. framework/_paths.py +45 -0
  10. framework/_secretservice.py +69 -0
  11. framework/_time.py +11 -0
  12. framework/_wincred.py +105 -0
  13. framework/_yaml.py +90 -0
  14. framework/ai.py +204 -0
  15. framework/bootstrap.py +206 -0
  16. framework/cache.py +335 -0
  17. framework/catalog.py +193 -0
  18. framework/cloud.py +899 -0
  19. framework/config_home.py +194 -0
  20. framework/connections.py +442 -0
  21. framework/console.py +41 -0
  22. framework/create.py +579 -0
  23. framework/data.py +519 -0
  24. framework/db.py +222 -0
  25. framework/dotenv.py +79 -0
  26. framework/errors.py +39 -0
  27. framework/global_config.py +238 -0
  28. framework/hooks.py +364 -0
  29. framework/integrity.py +22 -0
  30. framework/notebooks.py +384 -0
  31. framework/outputs.py +324 -0
  32. framework/packages.py +276 -0
  33. framework/project.py +52 -0
  34. framework/py.typed +0 -0
  35. framework/quarto.py +163 -0
  36. framework/scaffold.py +269 -0
  37. framework/scratch.py +108 -0
  38. framework/security.py +646 -0
  39. framework/settings.py +439 -0
  40. framework/spec/__init__.py +0 -0
  41. framework/spec/check.py +211 -0
  42. framework/spec/merge.py +195 -0
  43. framework/spec/resolve.py +254 -0
  44. framework/storage.py +516 -0
  45. framework/templates/defaults.yml +144 -0
  46. framework/templates/gitignore/course +120 -0
  47. framework/templates/gitignore/presentation +114 -0
  48. framework/templates/gitignore/project +108 -0
  49. framework/templates/gitignore/sensitive +139 -0
  50. framework/templates/skills/framework-data/SKILL.md +47 -0
  51. framework/templates/skills/framework-outputs/SKILL.md +68 -0
  52. framework/templates/skills/framework-packages/SKILL.md +33 -0
  53. framework/templates/skills/framework-sensitive-data/SKILL.md +38 -0
  54. framework/templates/skills/framework-workflow/SKILL.md +58 -0
  55. framework/templates/stubs/notebook-default.ipynb +34 -0
  56. framework/templates/stubs/notebook-default.qmd +43 -0
  57. framework/templates/stubs/notebook-minimal.qmd +15 -0
  58. framework/templates/stubs/notebook-revealjs.qmd +34 -0
  59. framework/templates/stubs/script-default.py +18 -0
  60. framework_py-0.1.0.dev0.dist-info/METADATA +111 -0
  61. framework_py-0.1.0.dev0.dist-info/RECORD +63 -0
  62. framework_py-0.1.0.dev0.dist-info/WHEEL +4 -0
  63. 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