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/hooks.py ADDED
@@ -0,0 +1,364 @@
1
+ """Git hooks: ``git_hooks_install()`` and friends, and the pre-commit checks.
2
+
3
+ The installed hook is the shell script shared with R and ``fw``. It only finds
4
+ a runtime and calls ``python -m framework hook pre-commit`` (or R's
5
+ equivalent), which runs the checks enabled under ``git: hooks:`` in the
6
+ settings:
7
+
8
+ - ``data_security``: ``git_security_audit()`` must not fail.
9
+ - ``check_sensitive_dirs`` (formerly ``warn_unignored_sensitive``): warn about
10
+ folders named like private, confidential, sensitive, cache, or scratch that
11
+ git does not ignore, and ask before committing when a terminal is attached.
12
+ - ``ai_sync`` is gone: projects keep AI instructions in AGENTS.md and skills,
13
+ so there is nothing to regenerate. An ``ai_sync`` setting in an older
14
+ project is ignored.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import fnmatch
20
+ import os
21
+ import shutil
22
+ import stat
23
+ import subprocess
24
+ import sys
25
+ from importlib.resources import files
26
+ from pathlib import Path
27
+
28
+ import pandas as pd
29
+
30
+ from framework import _yaml
31
+ from framework._log import inform, show_messages
32
+ from framework.errors import FrameworkError
33
+ from framework.project import find_settings_file
34
+ from framework.settings import _has_environment_sections, settings_read
35
+
36
+ HOOKS = ("data_security", "check_sensitive_dirs")
37
+ _ALIASES = {"warn_unignored_sensitive": "check_sensitive_dirs"}
38
+ _DESCRIPTIONS = {
39
+ "data_security": "Check for private data and secrets in git",
40
+ "check_sensitive_dirs": "Warn about unignored sensitive directories",
41
+ }
42
+ # Headers of the shared hook and of R 1.x's generated hook.
43
+ _MARKERS = ("Framework pre-commit hook", "Framework Git Hooks")
44
+ _SENSITIVE_PATTERNS = ("*private*", "*confidential*", "*sensitive*", "*cache*", "*scratch*")
45
+
46
+
47
+ def _say(verbose: bool, message: str) -> None:
48
+ if verbose:
49
+ show_messages()
50
+ inform(message)
51
+
52
+
53
+ def hook_script() -> str:
54
+ """The shared pre-commit script Framework installs (synced from the R package)."""
55
+ return files("framework").joinpath("_assets/hooks/pre-commit").read_text(encoding="utf-8")
56
+
57
+
58
+ # Settings -------------------------------------------------------------------
59
+
60
+
61
+ def _settings_path(settings_file: str | Path | None) -> Path:
62
+ path = Path(settings_file) if settings_file is not None else find_settings_file()
63
+ if path is None or not path.is_file():
64
+ raise FrameworkError("Settings file not found (expected settings.yml or config.yml)")
65
+ return path.resolve()
66
+
67
+
68
+ def _hook_settings(path: Path) -> dict[str, bool]:
69
+ git = settings_read(path).get("git")
70
+ hooks = git.get("hooks") if isinstance(git, dict) else None
71
+ hooks = hooks if isinstance(hooks, dict) else {}
72
+ enabled = {name: hooks.get(name) is True for name in HOOKS}
73
+ for alias, name in _ALIASES.items():
74
+ enabled[name] = enabled[name] or hooks.get(alias) is True
75
+ return enabled
76
+
77
+
78
+ def _set_hook_setting(path: Path, name: str, value: bool) -> None:
79
+ """Set ``git: hooks: <name>`` in the settings file, or in its split git file."""
80
+ raw = _yaml.read(path) or {}
81
+ env = raw.setdefault("default", {}) if _has_environment_sections(raw) else raw
82
+ if env is None:
83
+ env = raw["default"] = {}
84
+ reference = env.get("git")
85
+ if isinstance(reference, str) and reference.startswith("settings/"):
86
+ split_path = path.parent / reference
87
+ split = (_yaml.read(split_path) if split_path.is_file() else None) or {}
88
+ git = split.setdefault("git", {}) if "git" in split or not split else split
89
+ git.setdefault("hooks", {})[name] = value
90
+ _yaml.write(split_path, split)
91
+ return
92
+ git = env.get("git") if isinstance(env.get("git"), dict) else None
93
+ if git is None:
94
+ git = env["git"] = {}
95
+ hooks = git.get("hooks") if isinstance(git.get("hooks"), dict) else None
96
+ if hooks is None:
97
+ hooks = git["hooks"] = {}
98
+ hooks[name] = value
99
+ _yaml.write(path, raw)
100
+
101
+
102
+ def _hook_name(name: str) -> str:
103
+ if name == "ai_sync":
104
+ raise ValueError(
105
+ "The ai_sync hook was removed: AI instructions live in AGENTS.md and skills, "
106
+ "so there is nothing to sync."
107
+ )
108
+ valid = (*HOOKS, *_ALIASES)
109
+ if name not in valid:
110
+ raise ValueError(f"Invalid hook name. Must be one of: {', '.join(valid)}")
111
+ return _ALIASES.get(name, name)
112
+
113
+
114
+ # Git ------------------------------------------------------------------------
115
+
116
+
117
+ def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
118
+ return subprocess.run(
119
+ ["git", "-C", str(root), *args], capture_output=True, text=True, check=False
120
+ )
121
+
122
+
123
+ def _hook_path(root: Path) -> Path | None:
124
+ """Where git looks for the pre-commit hook, or None outside a repository."""
125
+ if shutil.which("git") is None:
126
+ return None
127
+ result = _git(root, "rev-parse", "--git-path", "hooks/pre-commit")
128
+ if result.returncode != 0:
129
+ return None
130
+ path = Path(result.stdout.strip())
131
+ return path if path.is_absolute() else root / path
132
+
133
+
134
+ def _is_framework_hook(path: Path) -> bool:
135
+ try:
136
+ head = path.read_text(encoding="utf-8", errors="replace")[:500]
137
+ except OSError:
138
+ return False
139
+ return any(marker in head for marker in _MARKERS)
140
+
141
+
142
+ # Public API -----------------------------------------------------------------
143
+
144
+
145
+ def git_hooks_install(
146
+ settings_file: str | Path | None = None, *, force: bool = False, verbose: bool = True
147
+ ) -> bool:
148
+ """Install Framework's pre-commit hook when a hook is enabled in the settings.
149
+
150
+ A pre-commit hook Framework wrote earlier (by R or Python) is replaced. Any
151
+ other pre-commit hook is kept unless ``force`` is True.
152
+
153
+ Returns:
154
+ Whether the hook was installed.
155
+ """
156
+ path = _settings_path(settings_file)
157
+ root = path.parent
158
+ hook = _hook_path(root)
159
+ if hook is None:
160
+ _say(verbose, "✗ Not a git repository")
161
+ return False
162
+ enabled = _hook_settings(path)
163
+ if not any(enabled.values()):
164
+ _say(verbose, "No hooks enabled in settings.yml")
165
+ _say(verbose, ' Enable one with: git_hooks_enable("data_security")')
166
+ return False
167
+ if hook.exists() and not force and not _is_framework_hook(hook):
168
+ _say(verbose, "✗ A pre-commit hook that Framework did not write already exists")
169
+ _say(verbose, f" Use force=True to replace it, or edit {hook} yourself")
170
+ return False
171
+ hook.parent.mkdir(parents=True, exist_ok=True)
172
+ hook.write_bytes(hook_script().encode("utf-8"))
173
+ hook.chmod(hook.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
174
+ _say(verbose, "✓ Installed pre-commit hook")
175
+ for name, on in enabled.items():
176
+ if on:
177
+ _say(verbose, f" • {_DESCRIPTIONS[name]}")
178
+ return True
179
+
180
+
181
+ def git_hooks_uninstall(settings_file: str | Path | None = None, *, verbose: bool = True) -> bool:
182
+ """Remove Framework's pre-commit hook. A hook Framework did not write is kept.
183
+
184
+ Returns:
185
+ Whether a hook was removed.
186
+ """
187
+ root = _settings_path(settings_file).parent if settings_file else _root_or_cwd()
188
+ hook = _hook_path(root)
189
+ if hook is None or not hook.exists():
190
+ _say(verbose, "No pre-commit hook found")
191
+ return False
192
+ if not _is_framework_hook(hook):
193
+ _say(verbose, "⚠ The pre-commit hook was not written by Framework, so it was kept")
194
+ return False
195
+ hook.unlink()
196
+ _say(verbose, "✓ Removed pre-commit hook")
197
+ return True
198
+
199
+
200
+ def _root_or_cwd() -> Path:
201
+ found = find_settings_file()
202
+ return found.parent if found is not None else Path.cwd()
203
+
204
+
205
+ def git_hooks_enable(
206
+ hook_name: str, settings_file: str | Path | None = None, *, verbose: bool = True
207
+ ) -> bool:
208
+ """Turn on one hook check in the settings and (re)install the hook.
209
+
210
+ Returns:
211
+ Whether the hook is installed.
212
+ """
213
+ name = _hook_name(hook_name)
214
+ path = _settings_path(settings_file)
215
+ _set_hook_setting(path, name, True)
216
+ _say(verbose, f"✓ Enabled {name} hook")
217
+ return git_hooks_install(path, force=False, verbose=verbose)
218
+
219
+
220
+ def git_hooks_disable(
221
+ hook_name: str, settings_file: str | Path | None = None, *, verbose: bool = True
222
+ ) -> bool:
223
+ """Turn off one hook check. The hook is removed when no check is left.
224
+
225
+ ``"ai_sync"`` is accepted so older settings can be cleaned up: it is set
226
+ to false.
227
+
228
+ Returns:
229
+ Whether the hook is still installed.
230
+ """
231
+ name = "ai_sync" if hook_name == "ai_sync" else _hook_name(hook_name)
232
+ path = _settings_path(settings_file)
233
+ _set_hook_setting(path, name, False)
234
+ if name == "check_sensitive_dirs":
235
+ raw_git = settings_read(path).get("git") or {}
236
+ if isinstance(raw_git, dict) and (raw_git.get("hooks") or {}).get(
237
+ "warn_unignored_sensitive"
238
+ ):
239
+ _set_hook_setting(path, "warn_unignored_sensitive", False)
240
+ _say(verbose, f"✓ Disabled {name} hook")
241
+ if not any(_hook_settings(path).values()):
242
+ git_hooks_uninstall(path, verbose=verbose)
243
+ return False
244
+ return git_hooks_install(path, force=False, verbose=verbose)
245
+
246
+
247
+ def git_hooks_list(settings_file: str | Path | None = None) -> pd.DataFrame:
248
+ """The hook checks and whether each is enabled.
249
+
250
+ Returns:
251
+ A DataFrame with columns hook, enabled, and description. Its
252
+ ``attrs["installed"]`` says whether the pre-commit hook is installed.
253
+ """
254
+ path = _settings_path(settings_file)
255
+ enabled = _hook_settings(path)
256
+ hook = _hook_path(path.parent)
257
+ frame = pd.DataFrame(
258
+ {
259
+ "hook": list(HOOKS),
260
+ "enabled": [enabled[name] for name in HOOKS],
261
+ "description": [_DESCRIPTIONS[name] for name in HOOKS],
262
+ }
263
+ )
264
+ frame.attrs["installed"] = bool(hook and hook.exists() and _is_framework_hook(hook))
265
+ return frame
266
+
267
+
268
+ # Running the hook -----------------------------------------------------------
269
+
270
+
271
+ def _unignored_sensitive_dirs(root: Path) -> list[str]:
272
+ found: list[str] = []
273
+ for current, dirnames, _ in os.walk(root):
274
+ dirnames[:] = [d for d in dirnames if d not in (".git", ".venv", "node_modules")]
275
+ for name in dirnames:
276
+ if any(fnmatch.fnmatch(name.lower(), pattern) for pattern in _SENSITIVE_PATTERNS):
277
+ found.append((Path(current) / name).relative_to(root).as_posix())
278
+ if not found:
279
+ return []
280
+ # A folder is covered when it or an ancestor is ignored.
281
+ candidates = {
282
+ ancestor
283
+ for folder in found
284
+ for ancestor in [folder, *(p.as_posix() for p in Path(folder).parents if str(p) != ".")]
285
+ }
286
+ # -z separates paths with NUL, since text pipes on Windows turn "\n" into "\r\n".
287
+ result = subprocess.run(
288
+ ["git", "-C", str(root), "check-ignore", "--no-index", "--stdin", "-z"],
289
+ input="\0".join(sorted(candidates)),
290
+ capture_output=True,
291
+ text=True,
292
+ encoding="utf-8",
293
+ errors="replace",
294
+ check=False,
295
+ )
296
+ ignored = {path for path in result.stdout.split("\0") if path}
297
+ return [
298
+ folder
299
+ for folder in found
300
+ if not ({folder} | {p.as_posix() for p in Path(folder).parents}) & ignored
301
+ ]
302
+
303
+
304
+ def _confirm(question: str) -> bool | None:
305
+ """Ask on the terminal, even inside a git hook. None when there is no terminal."""
306
+ device = "CON" if sys.platform == "win32" else "/dev/tty"
307
+ try:
308
+ with open(device, "r+", encoding="utf-8") as tty: # noqa: PTH123
309
+ tty.write(question)
310
+ tty.flush()
311
+ return tty.readline().strip().lower() in ("y", "yes")
312
+ except OSError:
313
+ return None
314
+
315
+
316
+ def _check_sensitive_dirs(root: Path) -> bool:
317
+ folders = _unignored_sensitive_dirs(root)
318
+ if not folders:
319
+ return True
320
+ lines = ["⚠ Warning: Found sensitive directories that are NOT gitignored:"]
321
+ lines += [f" - {folder}" for folder in folders]
322
+ lines += [
323
+ "",
324
+ "These directories may contain sensitive data and should be added to .gitignore.",
325
+ "Pattern-based .gitignore entries like '**/private/**' will automatically catch them.",
326
+ "",
327
+ ]
328
+ print("\n".join(lines), file=sys.stderr)
329
+ answer = _confirm("Continue with commit anyway? (y/N) ")
330
+ if answer is None:
331
+ print("(Non-interactive mode: continuing with warning)", file=sys.stderr)
332
+ return True
333
+ if not answer:
334
+ print("Commit aborted. Add directories to .gitignore and try again.", file=sys.stderr)
335
+ return answer
336
+
337
+
338
+ def hook_run(name: str, settings_file: str | Path | None = None) -> int:
339
+ """Run the checks for one git hook. Returns the exit status for git.
340
+
341
+ Only ``pre-commit`` exists. A project without a settings file passes, so
342
+ the hook never blocks commits in a folder that is not a Framework project.
343
+ """
344
+ if name != "pre-commit":
345
+ print(f"Framework hook: unknown hook '{name}'", file=sys.stderr)
346
+ return 1
347
+ try:
348
+ path = _settings_path(settings_file)
349
+ except FrameworkError:
350
+ return 0
351
+ root = path.parent
352
+ enabled = _hook_settings(path)
353
+
354
+ if enabled["check_sensitive_dirs"] and not _check_sensitive_dirs(root):
355
+ return 1
356
+ if enabled["data_security"]:
357
+ from framework.security import _report, git_security_audit
358
+
359
+ result = git_security_audit(path, verbose=False)
360
+ if not result.passed:
361
+ print(_report(result), file=sys.stderr)
362
+ print("✗ Data security check failed", file=sys.stderr)
363
+ return 1
364
+ return 0
framework/integrity.py ADDED
@@ -0,0 +1,22 @@
1
+ """File hashes for data integrity tracking.
2
+
3
+ The hash is the lowercase hex SHA-256 of the file's bytes, the same value R
4
+ records with ``openssl::sha256()``, so either language can verify a file the
5
+ other saved.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from pathlib import Path
12
+
13
+ _CHUNK = 1024 * 1024
14
+
15
+
16
+ def file_hash(path: str | Path) -> str:
17
+ """The SHA-256 of a file, as lowercase hex."""
18
+ digest = hashlib.sha256()
19
+ with Path(path).open("rb") as handle:
20
+ while chunk := handle.read(_CHUNK):
21
+ digest.update(chunk)
22
+ return digest.hexdigest()