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/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}")
|