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/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()
|