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
@@ -0,0 +1,195 @@
1
+ """The layer merge shared by every Framework engine.
2
+
3
+ This is the part of ``spec_resolve()`` that R, Python, and ``fw`` must agree
4
+ on exactly. The shared test cases are the conformance fixtures in the R
5
+ repository (``inst/conformance/merge``). Package defaults are not part of the
6
+ merge: each engine passes its own as the first layer.
7
+
8
+ Rules:
9
+
10
+ - Objects merge by key, and a later layer wins.
11
+ - A key set to null removes it.
12
+ - Entries of ``files``, ``skills``, ``extra_directories``,
13
+ ``packages.default_packages``, ``connections.databases``, and
14
+ ``connections.storage_buckets`` are replaced whole.
15
+ - An empty object or array changes nothing, but makes sure the section exists.
16
+ - ``git.gitignore_content`` is collected per layer instead of merged.
17
+ - A key set to different values by more than one layer (package defaults
18
+ excepted) is a conflict.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Mapping, Sequence
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ from framework.errors import FrameworkError
28
+
29
+ PACKAGE_DEFAULTS = "package defaults"
30
+
31
+ # Sections whose entries are replaced whole.
32
+ ATOMIC_MAPS = frozenset(
33
+ {
34
+ "files",
35
+ "skills",
36
+ "extra_directories",
37
+ "packages/default_packages",
38
+ "connections/databases",
39
+ "connections/storage_buckets",
40
+ }
41
+ )
42
+
43
+ # Sections that must be objects keyed by name.
44
+ MAP_PATHS: tuple[tuple[str, ...], ...] = (
45
+ ("directories",),
46
+ ("extra_directories",),
47
+ ("render_dirs",),
48
+ ("env",),
49
+ ("data_hooks",),
50
+ ("files",),
51
+ ("skills",),
52
+ ("packages", "default_packages"),
53
+ ("git", "hooks"),
54
+ ("ai", "assistants"),
55
+ ("connections", "databases"),
56
+ ("connections", "storage_buckets"),
57
+ )
58
+
59
+ _ENVELOPE_KEYS = frozenset({"kind", "key", "version", "layer"})
60
+ _REMOVED = "<removed>"
61
+
62
+
63
+ class SpecError(FrameworkError):
64
+ """A layer or spec is malformed."""
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Layer:
69
+ label: str
70
+ layer: dict[str, Any]
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class GitignoreBlock:
75
+ label: str
76
+ text: str
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Conflict:
81
+ path: str
82
+ layers: str
83
+
84
+
85
+ @dataclass
86
+ class MergeResult:
87
+ spec: dict[str, Any] = field(default_factory=dict)
88
+ gitignore_blocks: list[GitignoreBlock] = field(default_factory=list)
89
+ setters: dict[str, list[tuple[str, Any]]] = field(default_factory=dict)
90
+
91
+ @property
92
+ def conflicts(self) -> list[Conflict]:
93
+ return conflicts(self.setters)
94
+
95
+
96
+ def normalize_layers(layers: Sequence[Any]) -> list[Layer]:
97
+ """Label each layer. An envelope ``{kind, key, version, layer}`` is unwrapped."""
98
+ normalized = []
99
+ for index, entry in enumerate(layers, start=1):
100
+ if isinstance(entry, Layer):
101
+ normalized.append(entry)
102
+ continue
103
+ is_envelope = (
104
+ isinstance(entry, Mapping) and "layer" in entry and set(entry) <= _ENVELOPE_KEYS
105
+ )
106
+ if is_envelope:
107
+ parts = [str(entry.get("kind") or f"layer {index}")]
108
+ if entry.get("key") is not None:
109
+ parts.append(str(entry["key"]))
110
+ if entry.get("version") is not None:
111
+ parts.append(f"v{entry['version']}")
112
+ normalized.append(Layer(" ".join(parts), dict(entry.get("layer") or {})))
113
+ else:
114
+ normalized.append(Layer(f"layer {index}", dict(entry or {})))
115
+ return normalized
116
+
117
+
118
+ def _get(data: Mapping[str, Any], path: tuple[str, ...]) -> Any:
119
+ value: Any = data
120
+ for part in path:
121
+ if not isinstance(value, Mapping):
122
+ return None
123
+ value = value.get(part)
124
+ return value
125
+
126
+
127
+ def _merge(
128
+ base: dict[str, Any],
129
+ layer: Mapping[str, Any],
130
+ path: tuple[str, ...],
131
+ label: str,
132
+ setters: dict[str, list[tuple[str, Any]]],
133
+ ) -> dict[str, Any]:
134
+ for key, value in layer.items():
135
+ key_path = (*path, key)
136
+ path_str = "/".join(key_path)
137
+
138
+ if value is None:
139
+ base.pop(key, None)
140
+ setters.setdefault(path_str, []).append((label, _REMOVED))
141
+ continue
142
+
143
+ atomic = "/".join(path) in ATOMIC_MAPS
144
+ current = base.get(key)
145
+ if (
146
+ not atomic
147
+ and isinstance(value, Mapping)
148
+ and (current is None or isinstance(current, dict))
149
+ ):
150
+ base[key] = _merge(dict(current or {}), value, key_path, label, setters)
151
+ elif isinstance(value, list) and not value:
152
+ base.setdefault(key, {})
153
+ else:
154
+ base[key] = value
155
+ setters.setdefault(path_str, []).append((label, value))
156
+ return base
157
+
158
+
159
+ def merge_layers(layers: Sequence[Any]) -> MergeResult:
160
+ """Merge layers, bottom to top, with the shared rules."""
161
+ result = MergeResult()
162
+ for entry in normalize_layers(layers):
163
+ layer = dict(entry.layer)
164
+ for path in MAP_PATHS:
165
+ value = _get(layer, path)
166
+ if isinstance(value, list) and value:
167
+ raise SpecError(
168
+ f"Layer '{entry.label}': {'.'.join(path)} must be an object keyed by name, "
169
+ "not a list"
170
+ )
171
+ git = layer.get("git")
172
+ if isinstance(git, Mapping):
173
+ text = git.get("gitignore_content")
174
+ if isinstance(text, list):
175
+ text = "\n".join(str(line) for line in text)
176
+ if text:
177
+ result.gitignore_blocks.append(GitignoreBlock(entry.label, str(text)))
178
+ layer["git"] = {k: v for k, v in git.items() if k != "gitignore_content"}
179
+ result.spec = _merge(result.spec, layer, (), entry.label, result.setters)
180
+ return result
181
+
182
+
183
+ def conflicts(setters: Mapping[str, list[tuple[str, Any]]]) -> list[Conflict]:
184
+ """Keys set to different values by more than one layer, package defaults excepted."""
185
+ found = []
186
+ for path, entries in setters.items():
187
+ relevant = [(label, value) for label, value in entries if label != PACKAGE_DEFAULTS]
188
+ labels = list(dict.fromkeys(label for label, _ in relevant))
189
+ values: list[Any] = []
190
+ for _, value in relevant:
191
+ if value not in values:
192
+ values.append(value)
193
+ if len(labels) > 1 and len(values) > 1:
194
+ found.append(Conflict(path, ", ".join(labels)))
195
+ return found
@@ -0,0 +1,254 @@
1
+ """Resolving a project spec from layers, with Python's package defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from importlib import resources
8
+ from typing import Any
9
+
10
+ from framework import _yaml
11
+ from framework.spec.check import spec_check
12
+ from framework.spec.merge import (
13
+ MAP_PATHS,
14
+ PACKAGE_DEFAULTS,
15
+ Conflict,
16
+ GitignoreBlock,
17
+ Layer,
18
+ SpecError,
19
+ merge_layers,
20
+ normalize_layers,
21
+ )
22
+
23
+ TYPES = ("project", "project_sensitive", "course", "presentation", "bare")
24
+ LANGUAGES = ("r", "python", "stata")
25
+ LANGUAGE = "python"
26
+
27
+ _ORDER = (
28
+ "spec_version",
29
+ "name",
30
+ "type",
31
+ "language",
32
+ "author",
33
+ "directories",
34
+ "extra_directories",
35
+ "render_dirs",
36
+ "packages",
37
+ "git",
38
+ "ai",
39
+ "scaffold",
40
+ "quarto",
41
+ "connections",
42
+ "security",
43
+ "env",
44
+ "data_hooks",
45
+ "files",
46
+ "skills",
47
+ "source",
48
+ )
49
+ _AFFILIATION_FIELDS = ("name", "department", "city", "country")
50
+
51
+
52
+ def language_message(language: str) -> str:
53
+ return {
54
+ "r": "This is an R project. Create it with the framework R package or `fw new`.",
55
+ "stata": "This is a Stata project. Create it with `fw new` (the fw command-line tool).",
56
+ }.get(language, f"The Python package creates Python projects only (got language '{language}').")
57
+
58
+
59
+ def _template(name: str) -> str:
60
+ return resources.files("framework").joinpath(f"templates/{name}").read_text(encoding="utf-8")
61
+
62
+
63
+ def _defaults() -> dict[str, Any]:
64
+ data: dict[str, Any] = _yaml.load(_template("defaults.yml"))
65
+ return data
66
+
67
+
68
+ def package_defaults(type: str) -> dict[str, Any]:
69
+ """Python's package defaults for a project type: the bottom layer of every spec."""
70
+ defaults = _defaults()
71
+ type_config = defaults["types"][type]
72
+ skills: dict[str, Any] = {}
73
+ if type != "bare":
74
+ names = list(defaults["skills"])
75
+ if type == "project_sensitive":
76
+ names += defaults["sensitive_skills"]
77
+ skills = {name: {"package": True} for name in names}
78
+ quarto = dict(defaults["quarto"])
79
+ if type_config.get("render_dir") is not None:
80
+ quarto["render_dir"] = type_config["render_dir"]
81
+ return {
82
+ "author": dict(defaults["author"]),
83
+ "directories": dict(type_config["directories"]),
84
+ "extra_directories": {},
85
+ "render_dirs": dict(type_config["render_dirs"]),
86
+ "packages": defaults["packages"],
87
+ "git": defaults["git"],
88
+ "ai": defaults["ai"],
89
+ "scaffold": defaults["scaffold"],
90
+ "quarto": quarto,
91
+ "connections": defaults["connections"],
92
+ "security": {"sensitive": type == "project_sensitive"},
93
+ "env": defaults["env"],
94
+ "data_hooks": {},
95
+ "files": {},
96
+ "skills": skills,
97
+ }
98
+
99
+
100
+ # Authors ----------------------------------------------------------------------
101
+
102
+
103
+ def _text(value: Any) -> str:
104
+ return value if isinstance(value, str) else ""
105
+
106
+
107
+ def author_normalize(author: Mapping[str, Any] | None) -> dict[str, Any]:
108
+ """Known author fields with non-empty values, as R's ``.author_normalize()`` keeps them."""
109
+ author = author or {}
110
+ result: dict[str, Any] = {
111
+ "name": _text(author.get("name")),
112
+ "email": _text(author.get("email")),
113
+ "affiliation": _text(author.get("affiliation")),
114
+ }
115
+ for key in ("orcid", "url"):
116
+ if _text(author.get(key)):
117
+ result[key] = author[key]
118
+ affiliations = []
119
+ for entry in author.get("affiliations") or []:
120
+ if isinstance(entry, str):
121
+ entry = {"name": entry}
122
+ if not isinstance(entry, Mapping):
123
+ continue
124
+ kept = {k: entry[k] for k in _AFFILIATION_FIELDS if _text(entry.get(k))}
125
+ if kept.get("name"):
126
+ affiliations.append(kept)
127
+ if affiliations:
128
+ result["affiliations"] = affiliations
129
+ if not result["affiliation"]:
130
+ result["affiliation"] = affiliations[0]["name"]
131
+ return result
132
+
133
+
134
+ def author_merge(
135
+ base: Mapping[str, Any] | None, override: Mapping[str, Any] | None
136
+ ) -> dict[str, Any]:
137
+ """Non-empty override fields replace the base, as R's ``.author_merge()`` does."""
138
+ merged = author_normalize(base)
139
+ override = override or {}
140
+ for key in ("name", "email", "affiliation", "orcid", "url"):
141
+ if _text(override.get(key)):
142
+ merged[key] = override[key]
143
+ if override.get("affiliations") is not None:
144
+ merged["affiliations"] = override["affiliations"]
145
+ if not _text(override.get("affiliation")):
146
+ merged["affiliation"] = ""
147
+ return author_normalize(merged)
148
+
149
+
150
+ # Resolution -------------------------------------------------------------------
151
+
152
+
153
+ @dataclass
154
+ class ResolvedSpec:
155
+ """A complete project spec and the keys more than one layer set."""
156
+
157
+ spec: dict[str, Any]
158
+ conflicts: list[Conflict] = field(default_factory=list)
159
+
160
+
161
+ def _gitignore(type: str, sensitive: bool, blocks: Sequence[GitignoreBlock]) -> str:
162
+ template = (
163
+ "sensitive"
164
+ if sensitive
165
+ else {"course": "course", "presentation": "presentation"}.get(
166
+ type, "sensitive" if type == "project_sensitive" else "project"
167
+ )
168
+ )
169
+ base = _template(f"gitignore/{template}").rstrip("\n")
170
+ extra = [f"\n# --- from {block.label} ---\n{block.text}" for block in blocks]
171
+ return "\n".join([base, *extra])
172
+
173
+
174
+ def _mark_maps(spec: dict[str, Any]) -> None:
175
+ """Make sure every keyed section exists, as an empty object if unset."""
176
+ for path in MAP_PATHS:
177
+ node = spec
178
+ for part in path[:-1]:
179
+ node = node.setdefault(part, {})
180
+ if not node.get(path[-1]):
181
+ node[path[-1]] = {}
182
+
183
+
184
+ def _finalize(spec: dict[str, Any], blocks: Sequence[GitignoreBlock]) -> dict[str, Any]:
185
+ spec["spec_version"] = 1
186
+ security = spec.setdefault("security", {})
187
+ security["sensitive"] = security.get("sensitive") is True
188
+ skills = spec.setdefault("skills", {})
189
+ if (
190
+ security["sensitive"]
191
+ and spec["type"] != "bare"
192
+ and "framework-sensitive-data" not in skills
193
+ ):
194
+ skills["framework-sensitive-data"] = {"package": True}
195
+
196
+ ai = spec.setdefault("ai", {})
197
+ ai["assistants"] = {k: True for k, v in (ai.get("assistants") or {}).items() if v is True}
198
+ ai.setdefault("agents_md", "stub")
199
+
200
+ spec.setdefault("git", {})["gitignore_content"] = _gitignore(
201
+ spec["type"], security["sensitive"], blocks
202
+ )
203
+ _mark_maps(spec)
204
+ ordered = {key: spec[key] for key in _ORDER if key in spec}
205
+ ordered.update({key: value for key, value in spec.items() if key not in ordered})
206
+ return ordered
207
+
208
+
209
+ def spec_resolve(
210
+ layers: Sequence[Any] = (),
211
+ name: str = "",
212
+ author: Mapping[str, Any] | None = None,
213
+ check: bool = True,
214
+ ) -> ResolvedSpec:
215
+ """Merge layers over Python's package defaults into one complete project spec.
216
+
217
+ The project type comes from the last layer that sets ``type`` (default
218
+ ``"project"``), and the language from the last layer that sets
219
+ ``language`` (default ``"r"``, as in R). This package builds Python
220
+ projects only, so the language must resolve to ``"python"``.
221
+
222
+ Args:
223
+ layers: Layers, bottom to top. Each is a layer in spec shape, or an
224
+ envelope ``{kind, key, version, layer}`` as in a layers file.
225
+ name: The project name. It overrides any name in the layers.
226
+ author: Non-empty fields override the layers' author.
227
+ check: Raise ``SpecError`` when the result is invalid.
228
+ """
229
+ if not name:
230
+ raise SpecError("A project name is required")
231
+ entries = normalize_layers(layers)
232
+ type, language = "project", "r"
233
+ for entry in entries:
234
+ type = entry.layer.get("type") or type
235
+ language = entry.layer.get("language") or language
236
+ if type not in TYPES:
237
+ raise SpecError(f"Unknown project type '{type}'. Use one of: {', '.join(TYPES)}")
238
+ if language != LANGUAGE:
239
+ raise SpecError(language_message(language))
240
+
241
+ merged = merge_layers([Layer(PACKAGE_DEFAULTS, package_defaults(type)), *entries])
242
+ spec = merged.spec
243
+ spec["type"], spec["language"], spec["name"] = type, language, name
244
+ if author is not None:
245
+ spec["author"] = author_merge(spec.get("author"), author)
246
+ spec = _finalize(spec, merged.gitignore_blocks)
247
+
248
+ if check:
249
+ problems = spec_check(spec)
250
+ if problems:
251
+ raise SpecError(
252
+ "The resolved project spec is invalid:\n" + "\n".join(f" - {p}" for p in problems)
253
+ )
254
+ return ResolvedSpec(spec, merged.conflicts)