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/cache.py ADDED
@@ -0,0 +1,335 @@
1
+ """Caching expensive results between runs.
2
+
3
+ Values are stored as files in the project's cache folder and tracked in the
4
+ ``cache`` table of framework.db with a hash and an optional expiry. DataFrames
5
+ are stored as Parquet and other values with pickle. R stores its cache values
6
+ as ``.rds``, so a name R cached is a miss in Python (with a warning) and is
7
+ left in place for R.
8
+
9
+ Only load caches you trust: reading a pickle can run code.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import functools
15
+ import os
16
+ import pickle
17
+ import re
18
+ import warnings
19
+ from collections.abc import Callable
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+ from typing import Any, TypeVar, overload
23
+
24
+ import pandas as pd
25
+
26
+ from framework import db
27
+ from framework._log import inform
28
+ from framework._paths import in_root, relative_to_root
29
+ from framework.errors import CacheWarning, FrameworkError
30
+ from framework.integrity import file_hash
31
+ from framework.project import project_root
32
+ from framework.settings import settings_read
33
+
34
+ T = TypeVar("T")
35
+
36
+ _FORMATS = (".parquet", ".pickle")
37
+ _R_FORMAT = ".rds"
38
+ _UNITS = {
39
+ **dict.fromkeys(("hour", "hours", "hr", "hrs", "h"), 1),
40
+ **dict.fromkeys(("day", "days", "d"), 24),
41
+ **dict.fromkeys(("week", "weeks", "w"), 24 * 7),
42
+ }
43
+ _DURATION = re.compile(r"^([0-9]*\.?[0-9]+)\s*(hour|hours|hr|hrs|h|day|days|d|week|weeks|w)$")
44
+
45
+
46
+ def normalize_expire_after(value: float | str | None, default: float | None = None) -> float | None:
47
+ """Hours until expiry, from a number of hours or a string like ``"2 days"``."""
48
+ if value is None or (isinstance(value, str) and not value.strip()):
49
+ return default
50
+ if isinstance(value, str):
51
+ match = _DURATION.match(value.strip().lower())
52
+ if match is None:
53
+ raise FrameworkError(
54
+ "Could not parse expire_after value. Use numbers (hours) or strings like "
55
+ "'1 day', '2 hours', '1 week'."
56
+ )
57
+ return float(match.group(1)) * _UNITS[match.group(2)]
58
+ if value < 0:
59
+ raise FrameworkError("expire_after must not be negative")
60
+ return float(value)
61
+
62
+
63
+ def cache_dir() -> Path:
64
+ """The cache folder: ``FW_CACHE_DIR``, then ``directories.cache`` in the settings."""
65
+ custom = os.environ.get("FW_CACHE_DIR")
66
+ root = project_root()
67
+ if custom:
68
+ return in_root(custom, root)
69
+ config = settings_read()
70
+ directories = config.get("directories") or {}
71
+ options = config.get("options") or {}
72
+ data_options = options.get("data") if isinstance(options, dict) else None
73
+ folder = (directories.get("cache") if isinstance(directories, dict) else None) or (
74
+ data_options.get("cache_dir") if isinstance(data_options, dict) else None
75
+ )
76
+ return in_root(folder or "outputs/cache", root)
77
+
78
+
79
+ def _default_expire() -> float | None:
80
+ options = settings_read().get("options") or {}
81
+ data_options = options.get("data") if isinstance(options, dict) else None
82
+ value = data_options.get("cache_default_expire") if isinstance(data_options, dict) else None
83
+ return normalize_expire_after(value)
84
+
85
+
86
+ def _row(name: str) -> dict[str, Any] | None:
87
+ with db.connect() as connection:
88
+ row = connection.execute(
89
+ "SELECT name, file_path, hash, expire_at FROM cache WHERE name = ?", (name,)
90
+ ).fetchone()
91
+ return dict(row) if row is not None else None
92
+
93
+
94
+ def _candidates(name: str, file: str | Path | None, row: dict[str, Any] | None) -> list[Path]:
95
+ if file is not None:
96
+ return [Path(file)]
97
+ root = project_root()
98
+ paths = []
99
+ if row and row.get("file_path"):
100
+ paths.append(in_root(str(row["file_path"]), root))
101
+ folder = cache_dir()
102
+ paths.extend(folder / f"{name}{suffix}" for suffix in (*_FORMATS, _R_FORMAT))
103
+ return paths
104
+
105
+
106
+ def _remove(name: str, file: str | Path | None) -> None:
107
+ for path in _candidates(name, file, _row(name)):
108
+ path.unlink(missing_ok=True)
109
+ with db.connect() as connection:
110
+ connection.execute("DELETE FROM cache WHERE name = ?", (name,))
111
+
112
+
113
+ def cache(
114
+ name: str, value: T, file: str | Path | None = None, expire_after: float | str | None = None
115
+ ) -> T:
116
+ """Store a value in the cache and return it.
117
+
118
+ Args:
119
+ name: The cache name.
120
+ value: The value. DataFrames are stored as Parquet, anything else with pickle.
121
+ file: Store the value in this file instead of the cache folder.
122
+ expire_after: Hours until the value expires, or a string like
123
+ ``"2 days"``. Default: ``options.data.cache_default_expire`` in the
124
+ settings, else never.
125
+ """
126
+ is_frame = isinstance(value, pd.DataFrame)
127
+ path = (
128
+ Path(file) if file is not None else cache_dir() / f"{name}{_FORMATS[0 if is_frame else 1]}"
129
+ )
130
+ if not path.parent.is_dir():
131
+ path.parent.mkdir(parents=True, exist_ok=True)
132
+ inform(f"Creating cache directory: {path.parent}")
133
+
134
+ if is_frame:
135
+ value.to_parquet(path) # type: ignore[attr-defined]
136
+ else:
137
+ with path.open("wb") as handle:
138
+ pickle.dump(value, handle)
139
+
140
+ hours = normalize_expire_after(expire_after, _default_expire())
141
+ now = db.timestamp()
142
+ expire_at = now + hours * 3600 if hours is not None else None
143
+ root = project_root()
144
+ record = (relative_to_root(path, root), file_hash(path), expire_at, now)
145
+ with db.connect(root) as connection:
146
+ if connection.execute("SELECT 1 FROM cache WHERE name = ?", (name,)).fetchone():
147
+ connection.execute(
148
+ "UPDATE cache SET file_path = ?, hash = ?, expire_at = ?, last_read_at = ?, "
149
+ "updated_at = ? WHERE name = ?",
150
+ (*record, now, name),
151
+ )
152
+ else:
153
+ connection.execute(
154
+ "INSERT INTO cache (name, file_path, hash, expire_at, last_read_at, created_at, "
155
+ "updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
156
+ (name, *record, now, now),
157
+ )
158
+ return value
159
+
160
+
161
+ _MISSING: Any = object()
162
+
163
+
164
+ def _lookup(name: str, file: str | Path | None) -> Any:
165
+ row = _row(name)
166
+ if row is None:
167
+ inform(f"Cache '{name}' not found")
168
+ return _MISSING
169
+
170
+ path = next((p for p in _candidates(name, file, row) if p.is_file()), None)
171
+ if path is None:
172
+ inform(f"Cache '{name}' not found")
173
+ return _MISSING
174
+ if path.suffix == _R_FORMAT:
175
+ warnings.warn(
176
+ f"Cache '{name}' was saved by R (.rds), so Python treats it as a miss. "
177
+ "R's entry is left in place.",
178
+ CacheWarning,
179
+ stacklevel=3,
180
+ )
181
+ return _MISSING
182
+
183
+ expire_at = db.parse_timestamp(row["expire_at"])
184
+ if expire_at is not None and datetime.now().astimezone() > expire_at:
185
+ inform(f"Cache '{name}' has expired")
186
+ _remove(name, file)
187
+ return _MISSING
188
+
189
+ if row["hash"] and file_hash(path) != row["hash"]:
190
+ warnings.warn(
191
+ f"Hash mismatch for cache '{name}' - cache may be corrupted", CacheWarning, stacklevel=3
192
+ )
193
+ return _MISSING
194
+
195
+ try:
196
+ if path.suffix == ".parquet":
197
+ value = pd.read_parquet(path)
198
+ else:
199
+ with path.open("rb") as handle:
200
+ value = pickle.load(handle)
201
+ except Exception as error:
202
+ warnings.warn(f"Failed to read cache '{name}': {error}", CacheWarning, stacklevel=3)
203
+ return _MISSING
204
+
205
+ with db.connect() as connection:
206
+ connection.execute(
207
+ "UPDATE cache SET last_read_at = ? WHERE name = ?", (db.timestamp(), name)
208
+ )
209
+ return value
210
+
211
+
212
+ def cache_get(name: str, file: str | Path | None = None, default: Any = None) -> Any:
213
+ """A cached value, or ``default`` when it is missing, expired, or unreadable."""
214
+ value = _lookup(name, file)
215
+ return default if value is _MISSING else value
216
+
217
+
218
+ @overload
219
+ def cache_remember(
220
+ name: str,
221
+ compute: Callable[[], T],
222
+ *,
223
+ file: str | Path | None = ...,
224
+ expire_after: float | str | None = ...,
225
+ refresh: bool | Callable[[], bool] = ...,
226
+ ) -> T: ...
227
+
228
+
229
+ @overload
230
+ def cache_remember(
231
+ name: str,
232
+ compute: None = ...,
233
+ *,
234
+ file: str | Path | None = ...,
235
+ expire_after: float | str | None = ...,
236
+ refresh: bool | Callable[[], bool] = ...,
237
+ ) -> Callable[[Callable[[], T]], Callable[[], T]]: ...
238
+
239
+
240
+ def cache_remember(
241
+ name: str,
242
+ compute: Callable[[], T] | None = None,
243
+ *,
244
+ file: str | Path | None = None,
245
+ expire_after: float | str | None = None,
246
+ refresh: bool | Callable[[], bool] = False,
247
+ ) -> T | Callable[[Callable[[], T]], Callable[[], T]]:
248
+ """Return the cached value, or compute it, cache it, and return it.
249
+
250
+ Call it with a function that takes no arguments::
251
+
252
+ model = fw.cache_remember("model", lambda: fit(data), expire_after="1 week")
253
+
254
+ or use it as a decorator, which caches the function's result::
255
+
256
+ @fw.cache_remember("survey_clean")
257
+ def survey_clean():
258
+ return fw.data_read("inputs.raw.survey").dropna()
259
+
260
+ Args:
261
+ name: The cache name.
262
+ compute: Computes the value on a miss.
263
+ file: Store the value in this file instead of the cache folder.
264
+ expire_after: Hours until the value expires, or a string like ``"2 days"``.
265
+ refresh: Recompute even when cached. A function is called to decide.
266
+ """
267
+ if compute is None:
268
+
269
+ def decorator(function: Callable[[], T]) -> Callable[[], T]:
270
+ @functools.wraps(function)
271
+ def wrapper() -> T:
272
+ return cache_remember(
273
+ name, function, file=file, expire_after=expire_after, refresh=refresh
274
+ )
275
+
276
+ return wrapper
277
+
278
+ return decorator
279
+
280
+ should_refresh = refresh() if callable(refresh) else refresh
281
+ if not isinstance(should_refresh, bool):
282
+ warnings.warn("refresh must return a single boolean value", CacheWarning, stacklevel=2)
283
+ should_refresh = False
284
+ if should_refresh:
285
+ inform(f"Cache '{name}' refresh requested")
286
+ _remove(name, file)
287
+ else:
288
+ value = _lookup(name, file)
289
+ if value is not _MISSING:
290
+ return value # type: ignore[no-any-return]
291
+
292
+ computed = compute()
293
+ try:
294
+ cache(name, computed, file, expire_after)
295
+ except Exception as error:
296
+ warnings.warn(f"Failed to cache value: {error}", CacheWarning, stacklevel=2)
297
+ return computed
298
+
299
+
300
+ def cache_forget(name: str, file: str | Path | None = None) -> None:
301
+ """Remove a cached value and its record, whichever language saved it."""
302
+ _remove(name, file)
303
+
304
+
305
+ def cache_flush() -> None:
306
+ """Remove every cached value (from R and Python) and every cache record."""
307
+ folder = cache_dir()
308
+ if folder.is_dir():
309
+ for path in folder.iterdir():
310
+ if path.is_file() and path.suffix in (*_FORMATS, _R_FORMAT):
311
+ path.unlink()
312
+ with db.connect() as connection:
313
+ connection.execute("DELETE FROM cache")
314
+
315
+
316
+ def cache_list() -> pd.DataFrame:
317
+ """Every cache record, with its timestamps and status (``active`` or ``expired``)."""
318
+ columns = ["name", "expire_at", "created_at", "updated_at", "last_read_at", "status"]
319
+ with db.connect() as connection:
320
+ rows = connection.execute(
321
+ "SELECT name, expire_at, created_at, updated_at, last_read_at FROM cache ORDER BY name"
322
+ ).fetchall()
323
+ now = datetime.now().astimezone()
324
+ records = []
325
+ for row in rows:
326
+ record = {key: db.parse_timestamp(row[key]) for key in columns[1:5]}
327
+ expire_at = record["expire_at"]
328
+ records.append(
329
+ {
330
+ "name": row["name"],
331
+ **record,
332
+ "status": "expired" if expire_at is not None and now > expire_at else "active",
333
+ }
334
+ )
335
+ return pd.DataFrame(records, columns=columns)
framework/catalog.py ADDED
@@ -0,0 +1,193 @@
1
+ """The data catalog: names like ``inputs.raw.survey`` that resolve to data files.
2
+
3
+ The catalog is the ``data`` section of the settings. An entry is either a
4
+ path string or a mapping with ``path`` and optional ``type``, ``delimiter``,
5
+ ``locked``, ``description``, and other fields. Nested mappings without a
6
+ ``path`` group entries, and their keys form the dot-notation name.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import pandas as pd
17
+
18
+ from framework.errors import DataNotFoundError
19
+ from framework.project import find_settings_file
20
+ from framework.settings import settings_read
21
+
22
+ # Extension -> (type, delimiter), in the order R checks them. Anything else
23
+ # is read as delimited text with the delimiter guessed at read time.
24
+ _TYPES: tuple[tuple[str, str, str | None], ...] = (
25
+ (r"\.rds$", "rds", None),
26
+ (r"\.tsv$", "csv", "tab"),
27
+ (r"\.csv$", "csv", "comma"),
28
+ (r"\.(xlsx|xls)$", "excel", None),
29
+ (r"\.parquet$", "parquet", None),
30
+ (r"\.dta$", "stata", None),
31
+ (r"\.(sav|zsav)$", "spss", None),
32
+ (r"\.por$", "spss_por", None),
33
+ (r"\.(sas7bdat|sas7bcat)$", "sas", None),
34
+ (r"\.xpt$", "sas_xpt", None),
35
+ )
36
+
37
+ _KNOWN_FIELDS = frozenset({"path", "type", "delimiter", "locked", "private", "description"})
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class DataInfo:
42
+ """How to read one data file.
43
+
44
+ Attributes:
45
+ path: Absolute path to the file.
46
+ type: File type: csv, parquet, rds, excel, stata, spss, spss_por, sas,
47
+ or sas_xpt.
48
+ delimiter: For delimited text: comma, tab, semicolon, or space. None
49
+ means it is guessed from the extension.
50
+ locked: Whether the file's hash is checked against framework.db on read.
51
+ private: Whether the file is in a folder named ``private``.
52
+ description: Shown when the data is read.
53
+ name: The catalog name, when the file was found by name.
54
+ extra: Any other fields of the catalog entry.
55
+ """
56
+
57
+ path: Path
58
+ type: str
59
+ delimiter: str | None = None
60
+ locked: bool = False
61
+ private: bool = False
62
+ description: str | None = None
63
+ name: str | None = None
64
+ extra: dict[str, Any] = field(default_factory=dict)
65
+
66
+
67
+ def type_from_extension(path: str | Path) -> tuple[str, str | None]:
68
+ """The file type and delimiter for a path, from its extension."""
69
+ text = str(path)
70
+ for pattern, file_type, delimiter in _TYPES:
71
+ if re.search(pattern, text, re.IGNORECASE):
72
+ return file_type, delimiter
73
+ return "csv", None
74
+
75
+
76
+ def _is_absolute(path: str) -> bool:
77
+ return Path(path).is_absolute() or bool(re.match(r"^[A-Za-z]:[/\\]", path))
78
+
79
+
80
+ def _build(path: str, base: Path, entry: dict[str, Any] | None, name: str | None) -> DataInfo:
81
+ full = Path(path) if _is_absolute(path) else base / path
82
+ full = full.resolve()
83
+ file_type, delimiter = type_from_extension(full)
84
+ fields: dict[str, Any] = {
85
+ "type": file_type,
86
+ "delimiter": delimiter,
87
+ "locked": False,
88
+ "private": full.parent.name == "private",
89
+ "description": None,
90
+ }
91
+ extra: dict[str, Any] = {}
92
+ for key, value in (entry or {}).items():
93
+ if key == "path" or value is None:
94
+ continue
95
+ if key in _KNOWN_FIELDS:
96
+ fields[key] = value
97
+ else:
98
+ extra[key] = value
99
+ return DataInfo(
100
+ path=full,
101
+ type=str(fields["type"]),
102
+ delimiter=fields["delimiter"],
103
+ locked=bool(fields["locked"]),
104
+ private=bool(fields["private"]),
105
+ description=fields["description"] or None,
106
+ name=name,
107
+ extra=extra,
108
+ )
109
+
110
+
111
+ def catalog_names(data: Any, prefix: str = "") -> list[str]:
112
+ """Every dot-notation name in a catalog section, in file order."""
113
+ names: list[str] = []
114
+ if not isinstance(data, dict):
115
+ return names
116
+ for key, item in data.items():
117
+ full = f"{prefix}.{key}" if prefix else str(key)
118
+ if isinstance(item, str) or (isinstance(item, dict) and "path" in item):
119
+ names.append(full)
120
+ elif isinstance(item, dict):
121
+ names.extend(catalog_names(item, full))
122
+ return names
123
+
124
+
125
+ def _lookup(name: str, data: Any) -> Any:
126
+ current = data
127
+ for part in name.split("."):
128
+ if not isinstance(current, dict) or current.get(part) is None:
129
+ return None
130
+ current = current[part]
131
+ return current
132
+
133
+
134
+ def _not_found(name: str, data: Any) -> DataNotFoundError:
135
+ message = f"No data specification found for path: {name}"
136
+ names = catalog_names(data)
137
+ if names:
138
+ shown = names[:20]
139
+ if len(names) > 20:
140
+ shown.append(f"... and {len(names) - 20} more")
141
+ message += "\n\nAvailable data paths:\n " + "\n ".join(shown)
142
+ message += (
143
+ "\n\nTip: Use settings('data') to explore the full catalog, "
144
+ "or narrow it down with settings('data.inputs')"
145
+ )
146
+ return DataNotFoundError(message)
147
+
148
+
149
+ def data_info(path: str) -> DataInfo:
150
+ """How to read a data file, given a catalog name or a file path.
151
+
152
+ ``path`` is a dot-notation catalog name (``"inputs.raw.survey"``), a path
153
+ relative to the current folder (anything with a slash), or an absolute
154
+ path. Catalog paths are relative to the project root.
155
+
156
+ Raises:
157
+ DataNotFoundError: The name is not in the catalog.
158
+ """
159
+ if _is_absolute(path):
160
+ return _build(path, Path.cwd(), None, None)
161
+ if "/" in path or "\\" in path:
162
+ return _build(path, Path.cwd(), None, None)
163
+
164
+ settings_file = find_settings_file()
165
+ base = settings_file.parent if settings_file else Path.cwd()
166
+ data = settings_read(settings_file).get("data")
167
+ entry = _lookup(path, data)
168
+ if entry is None:
169
+ raise _not_found(path, data)
170
+ if isinstance(entry, str):
171
+ return _build(entry, base, None, path)
172
+ if isinstance(entry, dict) and isinstance(entry.get("path"), str):
173
+ return _build(entry["path"], base, entry, path)
174
+ raise DataNotFoundError(f"Catalog entry '{path}' has no path")
175
+
176
+
177
+ def data_list() -> pd.DataFrame:
178
+ """Every catalog entry, with columns name, path, type, locked, and description."""
179
+ data = settings_read().get("data")
180
+ rows = []
181
+ for name in catalog_names(data):
182
+ entry = _lookup(name, data)
183
+ fields = {"path": entry} if isinstance(entry, str) else entry
184
+ rows.append(
185
+ {
186
+ "name": name,
187
+ "path": fields.get("path"),
188
+ "type": fields.get("type"),
189
+ "locked": bool(fields.get("locked", False)),
190
+ "description": fields.get("description"),
191
+ }
192
+ )
193
+ return pd.DataFrame(rows, columns=["name", "path", "type", "locked", "description"])