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/quarto.py ADDED
@@ -0,0 +1,163 @@
1
+ """Generating ``_quarto.yml`` files for a project, as the R package does."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ from dataclasses import dataclass, field
9
+ from datetime import date, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from framework.errors import FrameworkError
14
+ from framework.settings import settings_read
15
+
16
+ _HTML_DEFAULTS: dict[str, Any] = {
17
+ "theme": "default",
18
+ "toc": True,
19
+ "toc_depth": 3,
20
+ "code_fold": False,
21
+ "code_tools": False,
22
+ "embed_resources": True,
23
+ "highlight_style": "github",
24
+ }
25
+ _REVEALJS_DEFAULTS: dict[str, Any] = {
26
+ "theme": "default",
27
+ "incremental": False,
28
+ "slide_number": True,
29
+ "transition": "slide",
30
+ "background_transition": "fade",
31
+ "controls": True,
32
+ "progress": True,
33
+ "center": True,
34
+ "highlight_style": "github",
35
+ }
36
+
37
+
38
+ def _value(value: Any) -> str:
39
+ if isinstance(value, bool):
40
+ return "true" if value else "false"
41
+ return str(value)
42
+
43
+
44
+ def quarto_yml(
45
+ settings: dict[str, Any] | None,
46
+ format: str = "html",
47
+ project_root: bool = False,
48
+ output_dir: str | None = None,
49
+ ) -> str:
50
+ """The text of one ``_quarto.yml`` file."""
51
+ if format not in ("html", "revealjs"):
52
+ raise FrameworkError("Format must be 'html' or 'revealjs'")
53
+ defaults = _HTML_DEFAULTS if format == "html" else _REVEALJS_DEFAULTS
54
+ given = (settings or {}).get(format)
55
+ options = {**defaults, **(given if isinstance(given, dict) else {})}
56
+
57
+ lines = [
58
+ f"# Auto-generated by Framework on {date.today():%Y-%m-%d}",
59
+ "# This file is yours to customize - Framework will not modify it after creation",
60
+ "# To regenerate: run framework.quarto_regenerate() (WARNING: overwrites!)",
61
+ "",
62
+ ]
63
+ if project_root or output_dir:
64
+ lines.append("project:")
65
+ if project_root:
66
+ lines.append(" type: default")
67
+ if output_dir:
68
+ lines.append(f" output-dir: {output_dir}")
69
+ lines.append("")
70
+ lines.append("format:")
71
+ lines.append(f" {format}:")
72
+ for key in defaults:
73
+ if key == "format":
74
+ continue
75
+ lines.append(f" {key.replace('_', '-')}: {_value(options[key])}")
76
+ return "\n".join(lines) + "\n"
77
+
78
+
79
+ def render_format(dir_key: str, project_type: str | None = None) -> str:
80
+ """Slides render as revealjs, everything else as HTML."""
81
+ if re.search(r"slides?", dir_key, re.IGNORECASE):
82
+ return "revealjs"
83
+ if project_type == "presentation" and dir_key == "root":
84
+ return "revealjs"
85
+ return "html"
86
+
87
+
88
+ def quarto_generate_all(
89
+ project_path: str | Path,
90
+ project_type: str,
91
+ render_dirs: dict[str, str] | None = None,
92
+ quarto_settings: dict[str, Any] | None = None,
93
+ directories: dict[str, str] | None = None,
94
+ ) -> list[Path]:
95
+ """Write the root ``_quarto.yml`` and one per render folder. Returns the files written."""
96
+ root = Path(project_path)
97
+ if not root.is_dir():
98
+ raise FrameworkError(f"Project path does not exist: {root}")
99
+ written = []
100
+ root_format = "revealjs" if project_type == "presentation" else "html"
101
+ root_file = root / "_quarto.yml"
102
+ root_file.write_text(
103
+ quarto_yml(quarto_settings, root_format, project_root=True), encoding="utf-8"
104
+ )
105
+ written.append(root_file)
106
+
107
+ for key, target in (render_dirs or {}).items():
108
+ if not target:
109
+ continue
110
+ source = (directories or {}).get(key, key)
111
+ if not source:
112
+ continue
113
+ source_path = root / source
114
+ source_path.mkdir(parents=True, exist_ok=True)
115
+ output_dir = Path(os.path.relpath(root / target, source_path)).as_posix()
116
+ path = source_path / "_quarto.yml"
117
+ path.write_text(
118
+ quarto_yml(quarto_settings, render_format(key, project_type), output_dir=output_dir),
119
+ encoding="utf-8",
120
+ )
121
+ written.append(path)
122
+ return written
123
+
124
+
125
+ @dataclass
126
+ class RegenerateResult:
127
+ regenerated: list[Path]
128
+ backed_up: list[str] = field(default_factory=list)
129
+ backup_location: Path | None = None
130
+
131
+
132
+ def quarto_regenerate(project_path: str | Path, backup: bool = True) -> RegenerateResult:
133
+ """Rewrite every ``_quarto.yml`` from the project's settings, backing up the old ones."""
134
+ root = Path(project_path)
135
+ settings_file = next(
136
+ (root / name for name in ("settings.yml", "config.yml") if (root / name).is_file()), None
137
+ )
138
+ if settings_file is None:
139
+ raise FrameworkError("Project settings.yml/config.yml not found")
140
+ config = settings_read(settings_file)
141
+
142
+ backed_up: list[str] = []
143
+ location = None
144
+ if backup:
145
+ location = root / ".quarto_backups" / f"{datetime.now():%Y%m%d_%H%M%S}"
146
+ for existing in root.rglob("_quarto.yml"):
147
+ if ".quarto_backups" in existing.parts:
148
+ continue
149
+ relative = existing.relative_to(root)
150
+ target = location / relative
151
+ target.parent.mkdir(parents=True, exist_ok=True)
152
+ shutil.copy2(existing, target)
153
+ backed_up.append(relative.as_posix())
154
+
155
+ quarto = config.get("quarto") if isinstance(config.get("quarto"), dict) else None
156
+ written = quarto_generate_all(
157
+ root,
158
+ str(config.get("project_type") or "project"),
159
+ config.get("render_dirs") or {},
160
+ quarto,
161
+ config.get("directories") or {},
162
+ )
163
+ return RegenerateResult(written, backed_up, location)
framework/scaffold.py ADDED
@@ -0,0 +1,269 @@
1
+ """Starting work in a project: ``scaffold()``, ``status()``, and ``project_info()``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ import runpy
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import pandas as pd
17
+
18
+ from framework import db, dotenv
19
+ from framework._log import inform, show_messages
20
+ from framework._paths import in_root
21
+ from framework.errors import ProjectNotFoundError, SettingsError
22
+ from framework.project import find_settings_file
23
+ from framework.settings import settings_read
24
+
25
+ _DEFAULT_SEED = 123
26
+
27
+
28
+ def _section(config: dict[str, Any], name: str) -> dict[str, Any]:
29
+ value = config.get(name)
30
+ return value if isinstance(value, dict) else {}
31
+
32
+
33
+ def _first(config: dict[str, Any], key: str) -> Any:
34
+ """A scaffold option from ``scaffold:``, then ``options:``, then the top level."""
35
+ for section in (_section(config, "scaffold"), _section(config, "options"), config):
36
+ if section.get(key) is not None:
37
+ return section[key]
38
+ return None
39
+
40
+
41
+ def _load_dotenv(config: dict[str, Any], root: Path) -> None:
42
+ location = config.get("dotenv_location")
43
+ if location is None:
44
+ return
45
+ path = in_root(str(location), root)
46
+ if path.is_dir():
47
+ path = path / ".env"
48
+ if not path.is_file():
49
+ raise SettingsError(f"Dotenv file not found at '{path}'")
50
+ dotenv.load(path)
51
+
52
+
53
+ def _mark_scaffolded(root: Path) -> dict[str, str]:
54
+ """Record the first and latest scaffold times in framework.db, as R does."""
55
+ stamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
56
+ history: dict[str, str] = {}
57
+ raw = db.get_meta("scaffold_history", root)
58
+ if raw:
59
+ try:
60
+ history = json.loads(raw)
61
+ except json.JSONDecodeError:
62
+ history = {}
63
+ updated = {"first": history.get("first") or stamp, "last": stamp}
64
+ db.set_meta("scaffold_history", json.dumps(updated, separators=(",", ":")), root)
65
+ return updated
66
+
67
+
68
+ def _as_int(value: Any) -> int | None:
69
+ try:
70
+ return int(str(value).strip())
71
+ except ValueError:
72
+ return None
73
+
74
+
75
+ def _set_seed(config: dict[str, Any]) -> int | None:
76
+ """Seed ``random`` and NumPy with R's rules.
77
+
78
+ An explicit ``seed_on_scaffold: false`` skips seeding even when
79
+ ``FW_SEED`` is set. Without a project setting, ``FW_SEED`` turns seeding
80
+ on. The project's seed wins over ``FW_SEED``, and the fallback is 123.
81
+ """
82
+ scaffold, options = _section(config, "scaffold"), _section(config, "options")
83
+ enabled = next(
84
+ (
85
+ s["seed_on_scaffold"]
86
+ for s in (scaffold, options, config)
87
+ if s.get("seed_on_scaffold") is not None
88
+ ),
89
+ None,
90
+ )
91
+ project_seed = next(
92
+ (_as_int(s["seed"]) for s in (scaffold, config, options) if s.get("seed") is not None),
93
+ None,
94
+ )
95
+ global_seed = _as_int(os.environ.get("FW_SEED", "x"))
96
+ if enabled is False or (enabled is None and global_seed is None):
97
+ return None
98
+ value = project_seed if project_seed is not None else global_seed
99
+ if value is None:
100
+ value = _DEFAULT_SEED
101
+ random.seed(value)
102
+ try:
103
+ import numpy as np
104
+
105
+ np.random.seed(value)
106
+ except ImportError: # pragma: no cover (numpy comes with pandas)
107
+ pass
108
+ inform(f"Random seed set to {value}.")
109
+ return value
110
+
111
+
112
+ def _add_function_folders(config: dict[str, Any], root: Path) -> list[Path]:
113
+ """Put the project's function folders on the import path."""
114
+ options = _section(config, "options")
115
+ if options.get("source_all_functions") is False:
116
+ return []
117
+ folders = options.get("functions_dir") or _section(config, "directories").get("functions")
118
+ if folders is None:
119
+ folders = "functions"
120
+ added = []
121
+ for folder in folders if isinstance(folders, list) else [folders]:
122
+ path = in_root(str(folder), root).resolve()
123
+ if path.is_dir() and str(path) not in sys.path:
124
+ sys.path.insert(0, str(path))
125
+ added.append(path)
126
+ inform(f"Added {folder} to the import path")
127
+ return added
128
+
129
+
130
+ def scaffold(settings_file: str | Path | None = None) -> Path:
131
+ """Prepare the current Python session for work in the project.
132
+
133
+ Call it at the top of every notebook and script. It:
134
+
135
+ - finds the project root and loads its ``.env`` and settings,
136
+ - creates framework.db if needed and records the scaffold time,
137
+ - sets the random seed when ``seed_on_scaffold`` is on (or ``FW_SEED`` is set),
138
+ - puts the project's ``functions`` folder on the import path, and
139
+ - runs ``scaffold.py`` from the project root, if there is one.
140
+
141
+ The working directory is not changed: Framework resolves project paths
142
+ from the root wherever the notebook runs.
143
+
144
+ Returns:
145
+ The project root.
146
+ """
147
+ show_messages()
148
+ path = Path(settings_file) if settings_file is not None else find_settings_file()
149
+ if path is None or not path.is_file():
150
+ raise ProjectNotFoundError(
151
+ "Could not locate a Framework project: no settings.yml or config.yml in "
152
+ f"{Path.cwd()} or its parent folders. Create one with framework.new()."
153
+ )
154
+ root = path.resolve().parent
155
+ config = settings_read(path)
156
+ _load_dotenv(config, root)
157
+ db.ensure_db(root)
158
+ _mark_scaffolded(root)
159
+ _set_seed(config)
160
+ _add_function_folders(config, root)
161
+
162
+ script = root / "scaffold.py"
163
+ if script.is_file():
164
+ runpy.run_path(str(script), run_name="__scaffold__")
165
+ return root
166
+
167
+
168
+ def _git(*args: str) -> str | None:
169
+ if shutil.which("git") is None:
170
+ return None
171
+ result = subprocess.run(["git", *args], capture_output=True, text=True, check=False)
172
+ return result.stdout.strip() if result.returncode == 0 else None
173
+
174
+
175
+ def status() -> None:
176
+ """Print an overview of the project: settings, git, AI files, and folders."""
177
+ from framework import __version__
178
+
179
+ settings_file = find_settings_file()
180
+ if settings_file is None:
181
+ raise ProjectNotFoundError(
182
+ "Not in a Framework project directory (settings.yml or config.yml not found)"
183
+ )
184
+ root = settings_file.parent
185
+ config = settings_read(settings_file)
186
+ lines = ["Framework project status", "", "Framework:", f" Version: {__version__}", ""]
187
+
188
+ lines.append("Project:")
189
+ if config.get("project_type"):
190
+ lines.append(f" Type: {config['project_type']}")
191
+ author = _section(config, "author").get("name")
192
+ if author:
193
+ lines.append(f" Author: {author}")
194
+ lines.append("")
195
+
196
+ lines.append("Git:")
197
+ if shutil.which("git") is None:
198
+ lines.append(" Git not installed")
199
+ elif _git("-C", str(root), "rev-parse", "--git-dir") is None:
200
+ lines.append(" Not a git repository")
201
+ elif _git("-C", str(root), "rev-parse", "HEAD") is None:
202
+ lines.append(" No commits yet")
203
+ else:
204
+ lines.append(f" Branch: {_git('-C', str(root), 'branch', '--show-current')}")
205
+ lines.append(f" Commits: {_git('-C', str(root), 'rev-list', '--count', 'HEAD')}")
206
+ changes = (_git("-C", str(root), "status", "--porcelain") or "").splitlines()
207
+ lines.append(
208
+ f" Uncommitted changes: {len(changes)}" if changes else " Working tree clean"
209
+ )
210
+ lines.append("")
211
+
212
+ ai_files = [
213
+ name
214
+ for name in ("AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md")
215
+ if (root / name).is_file()
216
+ ]
217
+ lines.append("AI Assistants:")
218
+ lines.append(
219
+ f" Files: {', '.join(ai_files)}" if ai_files else " No AI instruction files found"
220
+ )
221
+ lines.append("")
222
+
223
+ lines.append("Package Management:")
224
+ if (root / "uv.lock").is_file():
225
+ lines.append(" uv: uv.lock present")
226
+ elif (root / "pyproject.toml").is_file():
227
+ lines.append(" pyproject.toml present (no lock file)")
228
+ else:
229
+ lines.append(" No pyproject.toml")
230
+ lines.append("")
231
+
232
+ directories = _section(config, "directories")
233
+ if directories:
234
+ lines.append("Directories:")
235
+ for key, value in directories.items():
236
+ if isinstance(value, str):
237
+ mark = "ok" if in_root(value, root).is_dir() else "x "
238
+ lines.append(f" {mark} {key} -> {value}")
239
+ print("\n".join(lines))
240
+
241
+
242
+ def project_info(verbose: bool = False) -> pd.DataFrame:
243
+ """The project's configured folders and whether each exists yet.
244
+
245
+ Folders that do not exist are created the first time something is saved
246
+ there. With ``verbose``, the project name and type are printed too.
247
+
248
+ Returns:
249
+ A DataFrame with columns key, path, and exists. Its ``attrs`` hold the
250
+ project ``name`` and ``type``.
251
+ """
252
+ settings_file = find_settings_file()
253
+ if settings_file is None:
254
+ raise ProjectNotFoundError(
255
+ "No project configuration found. Are you in a Framework project?"
256
+ )
257
+ root = settings_file.parent
258
+ config = settings_read(settings_file)
259
+ rows = [
260
+ {"key": key, "path": value, "exists": in_root(value, root).is_dir()}
261
+ for key, value in _section(config, "directories").items()
262
+ if isinstance(value, str)
263
+ ]
264
+ frame = pd.DataFrame(rows, columns=["key", "path", "exists"])
265
+ frame.attrs["name"] = config.get("project_name") or root.name
266
+ frame.attrs["type"] = config.get("project_type") or "project"
267
+ if verbose:
268
+ print(f"Project: {frame.attrs['name']}\nType: {frame.attrs['type']}")
269
+ return frame
framework/scratch.py ADDED
@@ -0,0 +1,108 @@
1
+ """Scratch files: quick captures of objects for a look outside Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pickle
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, TypeVar, cast
9
+
10
+ import pandas as pd
11
+
12
+ from framework import _yaml
13
+ from framework._log import inform
14
+ from framework._paths import in_root
15
+ from framework.errors import FrameworkError
16
+ from framework.project import project_root
17
+ from framework.settings import settings_read
18
+
19
+ T = TypeVar("T")
20
+
21
+ _FORMATS = {"text": ".txt", "csv": ".csv", "tsv": ".tsv", "pickle": ".pkl"}
22
+ _FROM_EXTENSION = {".txt": "text", ".csv": "csv", ".tsv": "tsv", ".pkl": "pickle"}
23
+
24
+
25
+ def scratch_dir() -> Path:
26
+ """The scratch folder: ``directories.scratch`` in the settings."""
27
+ config = settings_read()
28
+ directories = config.get("directories") or {}
29
+ options = config.get("options") or {}
30
+ data_options = options.get("data") if isinstance(options, dict) else None
31
+ folder = (
32
+ (directories.get("scratch") if isinstance(directories, dict) else None)
33
+ or (data_options.get("scratch_dir") if isinstance(data_options, dict) else None)
34
+ or "outputs/private/scratch"
35
+ )
36
+ return in_root(folder, project_root())
37
+
38
+
39
+ def _text(value: Any) -> str:
40
+ if isinstance(value, list | tuple | set) and all(
41
+ not isinstance(item, dict | list | tuple | set) for item in value
42
+ ):
43
+ return "".join(f"{item}\n" for item in value)
44
+ if isinstance(value, dict | list | tuple):
45
+ return f"# {type(value).__name__} with {len(value)} items\n" + _yaml.dump(value)
46
+ return f"{value}\n"
47
+
48
+
49
+ def scratch_capture(
50
+ x: T,
51
+ name: str | None = None,
52
+ to: str | None = None,
53
+ location: str | Path | None = None,
54
+ n: int | None = None,
55
+ ) -> T:
56
+ """Write an object to the scratch folder and return it unchanged.
57
+
58
+ Args:
59
+ x: The object. DataFrames are written as TSV by default, anything
60
+ else as text.
61
+ name: File name. An extension (.txt, .csv, .tsv, .pkl) sets the format.
62
+ Default: ``capture_<timestamp>``.
63
+ to: ``"text"``, ``"csv"``, ``"tsv"``, or ``"pickle"``.
64
+ location: Folder to write to. Default: the scratch folder.
65
+ n: For a DataFrame, write only the first ``n`` rows.
66
+ """
67
+ folder = Path(location) if location is not None else scratch_dir()
68
+ folder.mkdir(parents=True, exist_ok=True)
69
+ if name is None:
70
+ name = f"capture_{datetime.now():%Y%m%d_%H%M%S}"
71
+ suffix = Path(name).suffix
72
+ if suffix in _FROM_EXTENSION:
73
+ to = _FROM_EXTENSION[suffix]
74
+ name = Path(name).stem
75
+ is_frame = isinstance(x, pd.DataFrame)
76
+ to = to or ("tsv" if is_frame else "text")
77
+ if to not in _FORMATS:
78
+ raise FrameworkError(f"'to' must be one of: {', '.join(_FORMATS)}")
79
+
80
+ path = folder / f"{name}{_FORMATS[to]}"
81
+ if to == "pickle":
82
+ with path.open("wb") as handle:
83
+ pickle.dump(x, handle)
84
+ elif is_frame:
85
+ if to == "text":
86
+ raise FrameworkError("DataFrames can be saved as 'csv', 'tsv', or 'pickle'")
87
+ frame = cast(pd.DataFrame, x)
88
+ if n is not None:
89
+ frame = frame.head(n)
90
+ frame.to_csv(path, sep="," if to == "csv" else "\t", index=False, lineterminator="\n")
91
+ elif to == "text":
92
+ path.write_text(_text(x), encoding="utf-8")
93
+ else:
94
+ raise FrameworkError("Only DataFrames can be saved as 'csv' or 'tsv'")
95
+ inform(f"Captured to {path}")
96
+ return x
97
+
98
+
99
+ def scratch_clean() -> None:
100
+ """Delete the files in the scratch folder. Subfolders are left alone, as in R."""
101
+ folder = scratch_dir()
102
+ if not folder.is_dir():
103
+ inform(f"Scratch directory does not exist: {folder}")
104
+ return
105
+ for path in folder.iterdir():
106
+ if path.is_file() or path.is_symlink():
107
+ path.unlink()
108
+ inform(f"Scratch directory cleaned: {folder}")