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