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/db.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""The project database, ``framework.db``.
|
|
2
|
+
|
|
3
|
+
The schema comes from the R package's ``init.sql``, so R and Python read and
|
|
4
|
+
write the same tables. Values follow what R stores:
|
|
5
|
+
|
|
6
|
+
- Timestamps in the ``data``, ``cache``, and ``meta`` tables are seconds since
|
|
7
|
+
the Unix epoch, stored as REAL. RSQLite writes R's date-times that way.
|
|
8
|
+
- Booleans are stored as 0 and 1.
|
|
9
|
+
|
|
10
|
+
Readers accept both epoch seconds and date strings, because older rows and
|
|
11
|
+
the ``results`` table hold strings.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import sqlite3
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from importlib import resources
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from framework.project import find_settings_file
|
|
27
|
+
|
|
28
|
+
DB_NAME = "framework.db"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _schema_statements() -> dict[str, str]:
|
|
32
|
+
"""CREATE TABLE statements from init.sql, keyed by table name."""
|
|
33
|
+
sql = resources.files("framework").joinpath("_assets/init.sql").read_text(encoding="utf-8")
|
|
34
|
+
statements: dict[str, str] = {}
|
|
35
|
+
for statement in sql.split(";"):
|
|
36
|
+
statement = statement.strip()
|
|
37
|
+
match = re.match(r"CREATE TABLE\s+(\w+)", statement, re.IGNORECASE)
|
|
38
|
+
if match:
|
|
39
|
+
statements[match.group(1)] = re.sub(
|
|
40
|
+
r"^CREATE TABLE\s+", "CREATE TABLE IF NOT EXISTS ", statement, flags=re.IGNORECASE
|
|
41
|
+
)
|
|
42
|
+
return statements
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def db_path(root: str | Path | None = None) -> Path:
|
|
46
|
+
"""Where framework.db is: the project root, or the current folder outside a project."""
|
|
47
|
+
if root is not None:
|
|
48
|
+
return Path(root) / DB_NAME
|
|
49
|
+
found = find_settings_file()
|
|
50
|
+
return (found.parent if found else Path.cwd()) / DB_NAME
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_db(root: str | Path | None = None) -> Path:
|
|
54
|
+
"""Create framework.db, or add any tables it is missing. Returns its path."""
|
|
55
|
+
path = db_path(root)
|
|
56
|
+
connection = sqlite3.connect(path)
|
|
57
|
+
try:
|
|
58
|
+
existing = {
|
|
59
|
+
row[0]
|
|
60
|
+
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
61
|
+
}
|
|
62
|
+
for table, statement in _schema_statements().items():
|
|
63
|
+
if table not in existing:
|
|
64
|
+
connection.execute(statement)
|
|
65
|
+
connection.commit()
|
|
66
|
+
finally:
|
|
67
|
+
connection.close()
|
|
68
|
+
return path
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@contextmanager
|
|
72
|
+
def connect(root: str | Path | None = None) -> Iterator[sqlite3.Connection]:
|
|
73
|
+
"""A connection to framework.db that commits on success and always closes."""
|
|
74
|
+
path = ensure_db(root)
|
|
75
|
+
connection = sqlite3.connect(path)
|
|
76
|
+
connection.row_factory = sqlite3.Row
|
|
77
|
+
try:
|
|
78
|
+
with connection:
|
|
79
|
+
yield connection
|
|
80
|
+
finally:
|
|
81
|
+
connection.close()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def timestamp() -> float:
|
|
85
|
+
"""The current time as R stores it: seconds since the Unix epoch."""
|
|
86
|
+
return time.time()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_timestamp(value: object) -> datetime | None:
|
|
90
|
+
"""A stored timestamp as an aware datetime in local time, or None.
|
|
91
|
+
|
|
92
|
+
Accepts epoch seconds and the date strings R and Python have written.
|
|
93
|
+
"""
|
|
94
|
+
if value is None or value == "":
|
|
95
|
+
return None
|
|
96
|
+
if isinstance(value, int | float):
|
|
97
|
+
return datetime.fromtimestamp(value, tz=UTC).astimezone()
|
|
98
|
+
text = str(value)
|
|
99
|
+
try:
|
|
100
|
+
return datetime.fromtimestamp(float(text), tz=UTC).astimezone()
|
|
101
|
+
except ValueError:
|
|
102
|
+
pass
|
|
103
|
+
for pattern in ("%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S%z"):
|
|
104
|
+
try:
|
|
105
|
+
return datetime.strptime(text, pattern).astimezone()
|
|
106
|
+
except ValueError:
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
# A string without an offset is local time, as R writes it.
|
|
110
|
+
return datetime.fromisoformat(text).astimezone()
|
|
111
|
+
except ValueError:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# The data table -------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True)
|
|
119
|
+
class DataRecord:
|
|
120
|
+
"""A row of the ``data`` table: what Framework knows about one data file."""
|
|
121
|
+
|
|
122
|
+
name: str
|
|
123
|
+
path: str | None
|
|
124
|
+
type: str | None
|
|
125
|
+
delimiter: str | None
|
|
126
|
+
locked: bool
|
|
127
|
+
hash: str | None
|
|
128
|
+
last_read_at: datetime | None
|
|
129
|
+
created_at: datetime | None
|
|
130
|
+
updated_at: datetime | None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_data_record(name: str, root: str | Path | None = None) -> DataRecord | None:
|
|
134
|
+
with connect(root) as connection:
|
|
135
|
+
row = connection.execute(
|
|
136
|
+
"SELECT name, path, type, delimiter, locked, hash, last_read_at, created_at, "
|
|
137
|
+
"updated_at FROM data WHERE name = ?",
|
|
138
|
+
(name,),
|
|
139
|
+
).fetchone()
|
|
140
|
+
if row is None:
|
|
141
|
+
return None
|
|
142
|
+
return DataRecord(
|
|
143
|
+
name=row["name"],
|
|
144
|
+
path=row["path"],
|
|
145
|
+
type=row["type"],
|
|
146
|
+
delimiter=row["delimiter"],
|
|
147
|
+
locked=bool(row["locked"]),
|
|
148
|
+
hash=row["hash"],
|
|
149
|
+
last_read_at=parse_timestamp(row["last_read_at"]),
|
|
150
|
+
created_at=parse_timestamp(row["created_at"]),
|
|
151
|
+
updated_at=parse_timestamp(row["updated_at"]),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def set_data_record(
|
|
156
|
+
name: str,
|
|
157
|
+
*,
|
|
158
|
+
path: str | None,
|
|
159
|
+
type: str | None,
|
|
160
|
+
delimiter: str | None,
|
|
161
|
+
locked: bool,
|
|
162
|
+
hash: str | None,
|
|
163
|
+
root: str | Path | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Insert or update a ``data`` row, as R's ``.set_data()`` does."""
|
|
166
|
+
now = timestamp()
|
|
167
|
+
with connect(root) as connection:
|
|
168
|
+
exists = connection.execute("SELECT 1 FROM data WHERE name = ?", (name,)).fetchone()
|
|
169
|
+
if exists:
|
|
170
|
+
connection.execute(
|
|
171
|
+
"UPDATE data SET path = ?, type = ?, delimiter = ?, locked = ?, hash = ?, "
|
|
172
|
+
"last_read_at = ?, updated_at = ? WHERE name = ?",
|
|
173
|
+
(path, type, delimiter, int(locked), hash, now, now, name),
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
connection.execute(
|
|
177
|
+
"INSERT INTO data (name, path, type, delimiter, locked, hash, last_read_at, "
|
|
178
|
+
"created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
179
|
+
(name, path, type, delimiter, int(locked), hash, now, now, now),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def update_data_hash(name: str, hash: str, root: str | Path | None = None) -> None:
|
|
184
|
+
"""Record a new hash for a data file, as R's ``.update_data_with_hash()`` does."""
|
|
185
|
+
now = timestamp()
|
|
186
|
+
with connect(root) as connection:
|
|
187
|
+
exists = connection.execute("SELECT 1 FROM data WHERE name = ?", (name,)).fetchone()
|
|
188
|
+
if exists:
|
|
189
|
+
connection.execute(
|
|
190
|
+
"UPDATE data SET hash = ?, last_read_at = ?, updated_at = ? WHERE name = ?",
|
|
191
|
+
(hash, now, now, name),
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
connection.execute(
|
|
195
|
+
"INSERT INTO data (name, hash, last_read_at, created_at, updated_at) "
|
|
196
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
197
|
+
(name, hash, now, now, now),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# The meta table -------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def get_meta(key: str, root: str | Path | None = None) -> str | None:
|
|
205
|
+
with connect(root) as connection:
|
|
206
|
+
row = connection.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
|
207
|
+
return None if row is None else row["value"]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def set_meta(key: str, value: str, root: str | Path | None = None) -> None:
|
|
211
|
+
now = timestamp()
|
|
212
|
+
with connect(root) as connection:
|
|
213
|
+
exists = connection.execute("SELECT 1 FROM meta WHERE key = ?", (key,)).fetchone()
|
|
214
|
+
if exists:
|
|
215
|
+
connection.execute(
|
|
216
|
+
"UPDATE meta SET value = ?, updated_at = ? WHERE key = ?", (value, now, key)
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
connection.execute(
|
|
220
|
+
"INSERT INTO meta (key, value, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
|
221
|
+
(key, value, now, now),
|
|
222
|
+
)
|
framework/dotenv.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Reading and writing ``.env`` files with the same rules as R's dotenv package.
|
|
2
|
+
|
|
3
|
+
The rules, from dotenv 1.0:
|
|
4
|
+
|
|
5
|
+
- Lines that start with ``#`` and blank lines are skipped. A ``#`` later in a
|
|
6
|
+
line is part of the value.
|
|
7
|
+
- Each other line is ``[export ]KEY=VALUE``. The key is everything before the
|
|
8
|
+
first ``=``, kept exactly as written.
|
|
9
|
+
- A value wrapped in matching single or double quotes loses the quotes.
|
|
10
|
+
Nothing inside the quotes is unescaped.
|
|
11
|
+
- A line that does not match is an error.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from framework.errors import SettingsError
|
|
22
|
+
|
|
23
|
+
_LINE = re.compile(r"^\s*(?:export\s+)?(?P<key>[^=]+)=(?P<q>['\"]?)(?P<value>.*)(?P=q)\s*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse(text: str) -> dict[str, str]:
|
|
27
|
+
"""Parse ``.env`` text into a dict, in file order. Later keys win."""
|
|
28
|
+
values: dict[str, str] = {}
|
|
29
|
+
for line in text.splitlines():
|
|
30
|
+
if line.startswith("#") or not line.strip():
|
|
31
|
+
continue
|
|
32
|
+
match = _LINE.match(line)
|
|
33
|
+
if match is None:
|
|
34
|
+
raise SettingsError(f"Cannot parse .env line: {line[:40]}")
|
|
35
|
+
values[match["key"]] = match["value"]
|
|
36
|
+
return values
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read(path: str | Path) -> dict[str, str]:
|
|
40
|
+
"""Read a ``.env`` file. A missing file reads as empty."""
|
|
41
|
+
file = Path(path)
|
|
42
|
+
if not file.is_file():
|
|
43
|
+
return {}
|
|
44
|
+
return parse(file.read_text(encoding="utf-8"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load(path: str | Path) -> dict[str, str]:
|
|
48
|
+
"""Read a ``.env`` file and set its values in ``os.environ``.
|
|
49
|
+
|
|
50
|
+
Values replace variables that are already set, as R's ``load_dot_env()``
|
|
51
|
+
does, so a project behaves the same from R and Python.
|
|
52
|
+
"""
|
|
53
|
+
values = read(path)
|
|
54
|
+
os.environ.update(values)
|
|
55
|
+
return values
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def value(path: str | Path, key: str) -> str | None:
|
|
59
|
+
"""One value from a ``.env`` file, or None when it is missing or empty."""
|
|
60
|
+
found = read(path).get(key, "")
|
|
61
|
+
return found or None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def add_missing(path: str | Path, values: Mapping[str, str]) -> list[str]:
|
|
65
|
+
"""Append ``KEY=value`` lines for keys the file does not already define.
|
|
66
|
+
|
|
67
|
+
Returns the keys that were added. Existing lines are never changed.
|
|
68
|
+
"""
|
|
69
|
+
file = Path(path)
|
|
70
|
+
text = file.read_text(encoding="utf-8") if file.exists() else ""
|
|
71
|
+
present = parse(text)
|
|
72
|
+
added = [key for key in values if key not in present]
|
|
73
|
+
if not added:
|
|
74
|
+
return []
|
|
75
|
+
if text and not text.endswith("\n"):
|
|
76
|
+
text += "\n"
|
|
77
|
+
text += "".join(f"{key}={values[key]}\n" for key in added)
|
|
78
|
+
file.write_text(text, encoding="utf-8")
|
|
79
|
+
return added
|
framework/errors.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Exceptions raised by Framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FrameworkError(Exception):
|
|
7
|
+
"""Base class for every error Framework raises."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProjectNotFoundError(FrameworkError):
|
|
11
|
+
"""No Framework project (a folder with settings.yml or config.yml) was found."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SettingsError(FrameworkError):
|
|
15
|
+
"""A settings file is missing, cannot be parsed, or holds an invalid value."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DataNotFoundError(FrameworkError):
|
|
19
|
+
"""A data catalog name or file path does not resolve to a data file."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnsupportedFormatError(FrameworkError):
|
|
23
|
+
"""A file format Framework cannot read or write from Python."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class IntegrityError(FrameworkError):
|
|
27
|
+
"""A locked data file no longer matches the hash recorded in framework.db."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FrameworkWarning(UserWarning):
|
|
31
|
+
"""Base class for warnings Framework issues."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DataChangedWarning(FrameworkWarning):
|
|
35
|
+
"""An unlocked data file changed since Framework last recorded its hash."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CacheWarning(FrameworkWarning):
|
|
39
|
+
"""A cache entry could not be used, so it was treated as a miss."""
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Global settings, the settings catalog, and user templates.
|
|
2
|
+
|
|
3
|
+
The global settings live in ``settings.yml`` in ``config_dir()``. Python
|
|
4
|
+
keeps its own config folder, separate from R's; ``config_sync()`` copies R's
|
|
5
|
+
settings over.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from framework import _yaml
|
|
16
|
+
from framework._log import inform, show_messages
|
|
17
|
+
from framework.config_home import (
|
|
18
|
+
_global_settings_path,
|
|
19
|
+
_save_global_settings,
|
|
20
|
+
config_dir,
|
|
21
|
+
global_settings,
|
|
22
|
+
)
|
|
23
|
+
from framework.errors import FrameworkError, SettingsError
|
|
24
|
+
from framework.settings import modify_list
|
|
25
|
+
from framework.spec.check import secret_env_values
|
|
26
|
+
from framework.spec.resolve import TYPES
|
|
27
|
+
|
|
28
|
+
# Settings ---------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
# Sections a new value replaces instead of merging into, as in R.
|
|
31
|
+
_REPLACED = (("defaults", "packages"), ("defaults", "connections"), ("defaults", "env"))
|
|
32
|
+
_TYPE_REPLACED = ("directories", "extra_directories", "directories_enabled")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _validate(settings: dict[str, Any]) -> None:
|
|
36
|
+
author = settings.get("author")
|
|
37
|
+
if author is not None:
|
|
38
|
+
if not isinstance(author, dict):
|
|
39
|
+
raise SettingsError("author must be a mapping of name, email, and affiliation")
|
|
40
|
+
for key in ("name", "email", "affiliation", "orcid", "url"):
|
|
41
|
+
if author.get(key) is not None and not isinstance(author[key], str):
|
|
42
|
+
raise SettingsError(f"author.{key} must be text")
|
|
43
|
+
for key in ("defaults", "project_types", "git", "privacy", "global"):
|
|
44
|
+
if settings.get(key) is not None and not isinstance(settings[key], dict):
|
|
45
|
+
raise SettingsError(f"{key} must be a mapping")
|
|
46
|
+
for root in (
|
|
47
|
+
settings.get("projects_root"),
|
|
48
|
+
(settings.get("global") or {}).get("projects_root"),
|
|
49
|
+
):
|
|
50
|
+
if root is not None and not isinstance(root, str):
|
|
51
|
+
raise SettingsError("projects_root must be a folder path")
|
|
52
|
+
types = settings.get("project_types") or {}
|
|
53
|
+
unknown = sorted(set(types) - set(TYPES))
|
|
54
|
+
if unknown:
|
|
55
|
+
raise SettingsError(f"Unknown project types: {', '.join(unknown)}")
|
|
56
|
+
# R stores defaults.env as .env text, or as {raw, variables}. A plain
|
|
57
|
+
# mapping of names to values is read as the variables.
|
|
58
|
+
env = (settings.get("defaults") or {}).get("env")
|
|
59
|
+
raw: Any
|
|
60
|
+
variables: Any
|
|
61
|
+
if isinstance(env, str):
|
|
62
|
+
raw, variables = env, None
|
|
63
|
+
elif isinstance(env, dict) and env and set(env) <= {"raw", "variables"}:
|
|
64
|
+
raw, variables = env.get("raw"), env.get("variables")
|
|
65
|
+
elif isinstance(env, dict) or env is None:
|
|
66
|
+
raw, variables = None, env
|
|
67
|
+
else:
|
|
68
|
+
raise SettingsError("defaults.env must be .env text or a mapping")
|
|
69
|
+
secrets = secret_env_values(raw if isinstance(raw, str) else None, variables)
|
|
70
|
+
if secrets:
|
|
71
|
+
raise SettingsError(
|
|
72
|
+
f"defaults.env: secrets must not be stored in settings ({', '.join(secrets)}). "
|
|
73
|
+
"Leave the values empty and put them in each project's .env file."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _tilde(path: str) -> str:
|
|
78
|
+
expanded = Path(path).expanduser()
|
|
79
|
+
home = Path.home()
|
|
80
|
+
try:
|
|
81
|
+
return "~/" + expanded.relative_to(home).as_posix()
|
|
82
|
+
except ValueError:
|
|
83
|
+
return path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def configure_global(
|
|
87
|
+
settings: dict[str, Any] | None = None, *, validate: bool = True
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Read or update the global settings.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
settings: Values to merge into the global settings, such as
|
|
93
|
+
``{"author": {"name": "Ada Lovelace"}}``. Nested mappings merge,
|
|
94
|
+
except ``defaults.packages``, ``defaults.connections``,
|
|
95
|
+
``defaults.env``, and each project type's directories, which are
|
|
96
|
+
replaced. Without ``settings``, nothing changes.
|
|
97
|
+
validate: Check the values before saving. Secrets in
|
|
98
|
+
``defaults.env`` are refused.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The global settings after the update.
|
|
102
|
+
"""
|
|
103
|
+
current = global_settings()
|
|
104
|
+
if settings is None:
|
|
105
|
+
return current
|
|
106
|
+
if not isinstance(settings, dict):
|
|
107
|
+
raise TypeError("settings must be a dict")
|
|
108
|
+
updated: dict[str, Any] = modify_list(copy.deepcopy(current), copy.deepcopy(settings))
|
|
109
|
+
for section, key in _REPLACED:
|
|
110
|
+
value = (settings.get(section) or {}).get(key)
|
|
111
|
+
if value is not None:
|
|
112
|
+
updated.setdefault(section, {})[key] = copy.deepcopy(value)
|
|
113
|
+
for type_name, type_settings in (settings.get("project_types") or {}).items():
|
|
114
|
+
for key in _TYPE_REPLACED:
|
|
115
|
+
value = (type_settings or {}).get(key)
|
|
116
|
+
if value is None:
|
|
117
|
+
continue
|
|
118
|
+
if key == "directories" and isinstance(value, dict):
|
|
119
|
+
value = {k: v for k, v in value.items() if v}
|
|
120
|
+
updated["project_types"][type_name][key] = copy.deepcopy(value)
|
|
121
|
+
if validate:
|
|
122
|
+
_validate(updated)
|
|
123
|
+
for holder in (updated, updated.get("global") or {}):
|
|
124
|
+
if "projects_root" in holder:
|
|
125
|
+
if holder["projects_root"]:
|
|
126
|
+
holder["projects_root"] = _tilde(holder["projects_root"])
|
|
127
|
+
else:
|
|
128
|
+
del holder["projects_root"]
|
|
129
|
+
_save_global_settings(updated)
|
|
130
|
+
show_messages()
|
|
131
|
+
inform(f"✓ Global settings updated in {_global_settings_path()}")
|
|
132
|
+
return updated
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# The settings catalog ---------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
_GITIGNORE = {
|
|
138
|
+
"project": "project",
|
|
139
|
+
"project_sensitive": "sensitive",
|
|
140
|
+
"course": "course",
|
|
141
|
+
"presentation": "presentation",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_settings_catalog(include_user: bool = True, validate: bool = True) -> dict[str, Any]:
|
|
146
|
+
"""The defaults new Python projects start from.
|
|
147
|
+
|
|
148
|
+
The catalog has ``defaults`` (Framework's package defaults, layer 0 of
|
|
149
|
+
``spec_resolve()``) and ``project_types``, each with its ``.gitignore``
|
|
150
|
+
text. With ``include_user``, ``settings-catalog.yml`` in ``config_dir()``
|
|
151
|
+
is merged over it.
|
|
152
|
+
"""
|
|
153
|
+
shipped = files("framework").joinpath("templates")
|
|
154
|
+
defaults = _yaml.load(shipped.joinpath("defaults.yml").read_text(encoding="utf-8")) or {}
|
|
155
|
+
catalog: dict[str, Any] = {
|
|
156
|
+
"meta": {"version": 2, "language": "python"},
|
|
157
|
+
"defaults": defaults,
|
|
158
|
+
"project_types": {
|
|
159
|
+
name: {
|
|
160
|
+
"gitignore": shipped.joinpath(f"gitignore/{_GITIGNORE[name]}").read_text(
|
|
161
|
+
encoding="utf-8"
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
for name in _GITIGNORE
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
user_path = config_dir() / "settings-catalog.yml"
|
|
168
|
+
if include_user and user_path.is_file():
|
|
169
|
+
user = _yaml.read(user_path)
|
|
170
|
+
if isinstance(user, dict):
|
|
171
|
+
catalog = modify_list(catalog, user)
|
|
172
|
+
if validate:
|
|
173
|
+
for key in ("defaults", "project_types"):
|
|
174
|
+
if not isinstance(catalog.get(key), dict):
|
|
175
|
+
raise SettingsError(f"Settings catalog is missing '{key}'")
|
|
176
|
+
return catalog
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# User templates ---------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
# Template names and the stub files they replace. make_notebook(),
|
|
182
|
+
# make_script(), and make_presentation() use a user template when there is
|
|
183
|
+
# no project stub of the same name.
|
|
184
|
+
TEMPLATES = {
|
|
185
|
+
"notebook": "notebook-default.qmd",
|
|
186
|
+
"script": "script-default.py",
|
|
187
|
+
"presentation": "notebook-revealjs.qmd",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def templates_dir() -> Path:
|
|
192
|
+
"""Where your templates are kept: ``templates/`` in ``config_dir()``."""
|
|
193
|
+
return config_dir() / "templates"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _template_file(name: str) -> str:
|
|
197
|
+
if name not in TEMPLATES:
|
|
198
|
+
hint = (
|
|
199
|
+
" .gitignore content comes from the project type and from layers; "
|
|
200
|
+
"add rules with a facet."
|
|
201
|
+
if name.startswith("gitignore")
|
|
202
|
+
else ""
|
|
203
|
+
)
|
|
204
|
+
raise FrameworkError(
|
|
205
|
+
f"Unknown template: {name}. Templates are {', '.join(TEMPLATES)}.{hint}"
|
|
206
|
+
)
|
|
207
|
+
return TEMPLATES[name]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def framework_template_path(name: str) -> Path:
|
|
211
|
+
"""The file your version of a template is saved in (it may not exist yet)."""
|
|
212
|
+
return templates_dir() / _template_file(name)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def read_framework_template(name: str) -> str:
|
|
216
|
+
"""A template's text: yours when you saved one, otherwise Framework's."""
|
|
217
|
+
path = framework_template_path(name)
|
|
218
|
+
if path.is_file():
|
|
219
|
+
return path.read_text(encoding="utf-8")
|
|
220
|
+
shipped = files("framework").joinpath(f"templates/stubs/{_template_file(name)}")
|
|
221
|
+
return shipped.read_text(encoding="utf-8")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def write_framework_template(name: str, contents: str) -> Path:
|
|
225
|
+
"""Save your version of a template. Returns its path."""
|
|
226
|
+
if not isinstance(contents, str):
|
|
227
|
+
raise TypeError("contents must be text")
|
|
228
|
+
path = framework_template_path(name)
|
|
229
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
path.write_text(contents if contents.endswith("\n") else contents + "\n", encoding="utf-8")
|
|
231
|
+
return path
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def reset_framework_template(name: str) -> Path:
|
|
235
|
+
"""Remove your version of a template, so Framework's is used again."""
|
|
236
|
+
path = framework_template_path(name)
|
|
237
|
+
path.unlink(missing_ok=True)
|
|
238
|
+
return path
|