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/spec/merge.py
ADDED
|
@@ -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)
|