copier-ui 0.6.8__tar.gz

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.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: copier-ui
3
+ Version: 0.6.8
4
+ Summary: UI-neutral abstraction over a copier template survey: normalised schema, dependency graph, visibility and validation
5
+ Author-email: Stellars Henson <konrad.jelen+github@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/stellarshenson/copier-tui
8
+ Project-URL: Repository, https://github.com/stellarshenson/copier-tui
9
+ Project-URL: Issues, https://github.com/stellarshenson/copier-tui/issues
10
+ Keywords: copier,template,scaffolding,questionnaire,ui
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Code Generators
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: copier>=9.6
18
+
19
+ # copier-ui
20
+
21
+ UI-neutral abstraction over a [copier](https://copier.readthedocs.io) template survey.
22
+
23
+ `copier-ui` reads a template's questions and turns them into a normalised schema with a
24
+ dependency graph, visibility state and validation. It owns semantics and renders nothing, so
25
+ the same core drives a terminal UI, a web UI, an HTTP API or a test.
26
+
27
+ It never imports a display library and never requires an event loop.
28
+
29
+ ```python
30
+ from copier_ui import TemplateUI
31
+
32
+ # unsafe=True is copier's own trust gate: this template declares Jinja extensions and tasks,
33
+ # and copier_ui refuses to load such a template before importing anything unless you say so
34
+ ui = TemplateUI.from_template("gh:stellarshenson/copier-data-science", unsafe=True)
35
+ ui.set("dataset_storage", "s3")
36
+ ui.state().fields["s3_bucket"].visible # True
37
+ ui.render("./my-project")
38
+ ```
39
+
40
+ Part of the [copier-tui](https://github.com/stellarshenson/copier-tui) project.
@@ -0,0 +1,22 @@
1
+ # copier-ui
2
+
3
+ UI-neutral abstraction over a [copier](https://copier.readthedocs.io) template survey.
4
+
5
+ `copier-ui` reads a template's questions and turns them into a normalised schema with a
6
+ dependency graph, visibility state and validation. It owns semantics and renders nothing, so
7
+ the same core drives a terminal UI, a web UI, an HTTP API or a test.
8
+
9
+ It never imports a display library and never requires an event loop.
10
+
11
+ ```python
12
+ from copier_ui import TemplateUI
13
+
14
+ # unsafe=True is copier's own trust gate: this template declares Jinja extensions and tasks,
15
+ # and copier_ui refuses to load such a template before importing anything unless you say so
16
+ ui = TemplateUI.from_template("gh:stellarshenson/copier-data-science", unsafe=True)
17
+ ui.set("dataset_storage", "s3")
18
+ ui.state().fields["s3_bucket"].visible # True
19
+ ui.render("./my-project")
20
+ ```
21
+
22
+ Part of the [copier-tui](https://github.com/stellarshenson/copier-tui) project.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "copier-ui"
7
+ version = "0.6.8"
8
+ description = "UI-neutral abstraction over a copier template survey: normalised schema, dependency graph, visibility and validation"
9
+ authors = [
10
+ { name = "Stellars Henson", email = "konrad.jelen+github@gmail.com" },
11
+ ]
12
+ license = "MIT"
13
+ readme = "README.md"
14
+ requires-python = ">=3.11"
15
+ keywords = ["copier", "template", "scaffolding", "questionnaire", "ui"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Code Generators",
21
+ ]
22
+
23
+ dependencies = [
24
+ "copier>=9.6",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/stellarshenson/copier-tui"
29
+ Repository = "https://github.com/stellarshenson/copier-tui"
30
+ Issues = "https://github.com/stellarshenson/copier-tui/issues"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+ include = ["copier_ui*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,37 @@
1
+ """UI-neutral abstraction over a copier template survey."""
2
+
3
+ from copier_ui.api import TemplateUI
4
+ from copier_ui.errors import (
5
+ CircularDependencyError,
6
+ CopierUIError,
7
+ RenderRefusedError,
8
+ TemplateLoadError,
9
+ UnknownFieldError,
10
+ )
11
+ from copier_ui.model import (
12
+ Choice,
13
+ Evaluation,
14
+ FieldState,
15
+ Kind,
16
+ Operation,
17
+ Question,
18
+ Schema,
19
+ State,
20
+ )
21
+
22
+ __all__ = [
23
+ "Choice",
24
+ "CircularDependencyError",
25
+ "CopierUIError",
26
+ "Evaluation",
27
+ "FieldState",
28
+ "Kind",
29
+ "Operation",
30
+ "Question",
31
+ "RenderRefusedError",
32
+ "Schema",
33
+ "State",
34
+ "TemplateLoadError",
35
+ "TemplateUI",
36
+ "UnknownFieldError",
37
+ ]
@@ -0,0 +1,314 @@
1
+ """Copier-facing layer.
2
+
3
+ This is the ONLY module in copier_ui or copier_tui allowed to import copier. Everything it
4
+ uses beyond `run_copy`, `run_recopy`, `run_update` and `Phase` is a copier internal that may
5
+ move between copier releases; copier 9.17.2 is the version this was written against. Nothing
6
+ outside this module may import `copier`.
7
+
8
+ Internals to use, all documented in docs/design-notes.md:
9
+
10
+ - `copier._main.Worker` - constructed, never run; owns the fetch, the Jinja env and the context
11
+ - `Worker.template.questions_data`, `Worker.template.local_abspath`, `Worker.jinja_env`
12
+ - `Worker.unsafe` and `Worker._check_unsafe(operation)` - the trust gate, config reads only,
13
+ called before `jinja_env` because that access imports every `_jinja_extensions` entry
14
+ - `Worker.answers` (assign a fresh `copier._user_data.AnswersMap`) and `Worker._render_context()`
15
+ - `copier._user_data.Question` - built fresh per evaluation; `_formatted_choices` is cached
16
+ - `copier._types.MISSING` - the "no default" sentinel returned by `Question.get_default()`
17
+ - `copier._subproject.Subproject.last_answers` - the destination's answers file
18
+ - `copier.Phase.use(Phase.PROMPT)` - wraps every evaluation
19
+ - `copier.run_copy` / `run_recopy` / `run_update` - the only public entry points
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Iterator, Mapping
25
+ from pathlib import Path
26
+ import re
27
+ from typing import Any
28
+
29
+ from copier import Phase, run_copy, run_recopy, run_update
30
+ from copier._main import Worker
31
+ from copier._types import MISSING
32
+ from copier._user_data import AnswersMap
33
+ from copier._user_data import Question as CopierQuestion
34
+ from jinja2 import TemplateSyntaxError, meta
35
+
36
+ from copier_ui.errors import TemplateLoadError
37
+ from copier_ui.model import Choice, Evaluation, Kind, Operation, Question
38
+
39
+ _KIND_BY_TYPE = {
40
+ "str": Kind.STRING,
41
+ "bool": Kind.BOOL,
42
+ "int": Kind.INTEGER,
43
+ "float": Kind.FLOAT,
44
+ "path": Kind.PATH,
45
+ "json": Kind.STRUCTURED,
46
+ "yaml": Kind.STRUCTURED,
47
+ }
48
+
49
+
50
+ class TemplateAdapter:
51
+ """Loads a copier template and evaluates its questions against an answer set."""
52
+
53
+ def __init__(self, worker: Worker, operation: Operation) -> None:
54
+ """Wrap a constructed but unrun copier Worker."""
55
+ self._worker = worker
56
+ self._operation = operation
57
+ self._questions: tuple[Question, ...] | None = None
58
+
59
+ @classmethod
60
+ def open(
61
+ cls,
62
+ src: str | Path | None,
63
+ dst: Path,
64
+ *,
65
+ vcs_ref: str | None = None,
66
+ answers_file: Path | None = None,
67
+ operation: Operation = "copy",
68
+ unsafe: bool = False,
69
+ ) -> TemplateAdapter:
70
+ """Fetch the template, gate its unsafe features, and check it carries a copier config."""
71
+ worker = Worker(
72
+ src_path=None if src is None else str(src),
73
+ dst_path=Path(dst),
74
+ vcs_ref=vcs_ref,
75
+ answers_file=answers_file,
76
+ unsafe=unsafe,
77
+ )
78
+ adapter = cls(worker, operation)
79
+ try:
80
+ root = worker.template.local_abspath
81
+ if not _config_paths(root):
82
+ raise TemplateLoadError(f"No copier configuration file in {root}")
83
+ worker._check_unsafe("update" if operation == "update" else "copy")
84
+ _ = worker.jinja_env
85
+ adapter.questions()
86
+ except TemplateLoadError:
87
+ worker._cleanup()
88
+ raise
89
+ except Exception as error:
90
+ worker._cleanup()
91
+ raise TemplateLoadError(str(error)) from error
92
+ return adapter
93
+
94
+ def questions(self) -> tuple[Question, ...]:
95
+ """Normalised questions in copier.yml declaration order."""
96
+ if self._questions is None:
97
+ self._questions = tuple(
98
+ self._normalise(id, details)
99
+ for id, details in self._worker.template.questions_data.items()
100
+ )
101
+ return self._questions
102
+
103
+ def last_answers(self) -> dict[str, Any]:
104
+ """The destination's answers file, with every underscore-prefixed key dropped."""
105
+ return {
106
+ key: value
107
+ for key, value in self._worker.subproject.last_answers.items()
108
+ if not key.startswith("_")
109
+ }
110
+
111
+ def evaluate(self, id: str, answers: Mapping[str, Any]) -> Evaluation:
112
+ """Resolve one question's visibility, default and choices against the given answers."""
113
+ load_error = self._load_error(id)
114
+ if load_error is not None:
115
+ return Evaluation(
116
+ visible=True, default=None, has_default=False, choices=(), error=load_error
117
+ )
118
+ details = dict(self._worker.template.questions_data[id], validator="")
119
+ with Phase.use(Phase.PROMPT):
120
+ try:
121
+ question = self._copier_question(id, answers, details)
122
+ visible = question.get_when()
123
+ choices = _choices_of(question)
124
+ default = question.get_default()
125
+ except Exception as error: # noqa: BLE001 - a failed expression is a value here
126
+ return Evaluation(
127
+ visible=True,
128
+ default=None,
129
+ has_default=False,
130
+ choices=(),
131
+ error=_redact(str(error), self.questions(), answers),
132
+ )
133
+ if default is MISSING:
134
+ return Evaluation(
135
+ visible=visible, default=None, has_default=False, choices=choices, error=None
136
+ )
137
+ return Evaluation(
138
+ visible=visible, default=default, has_default=True, choices=choices, error=None
139
+ )
140
+
141
+ def validate(self, id: str, value: Any, answers: Mapping[str, Any]) -> tuple[str, ...]:
142
+ """Coerce and validate one value, returning messages instead of raising."""
143
+ load_error = self._load_error(id)
144
+ if load_error is not None:
145
+ return (load_error,)
146
+ with Phase.use(Phase.PROMPT):
147
+ try:
148
+ question = self._copier_question(id, answers)
149
+ question.validate_answer(question.parse_answer(value))
150
+ except Exception as error: # noqa: BLE001 - user input problems are values
151
+ return (_redact(str(error), self.questions(), answers),)
152
+ return ()
153
+
154
+ def run(self, dst: Path, data: Mapping[str, Any], **copier_kwargs: Any) -> None:
155
+ """Dispatch to run_copy, run_recopy or run_update with the answers as data."""
156
+ kwargs: dict[str, Any] = {
157
+ "vcs_ref": self._worker.vcs_ref,
158
+ "answers_file": self._worker.answers_file,
159
+ **copier_kwargs,
160
+ }
161
+ if self._operation == "copy":
162
+ run_copy(str(self._worker.template.url), dst, dict(data), **kwargs)
163
+ elif self._operation == "recopy":
164
+ run_recopy(dst, dict(data), **kwargs)
165
+ else:
166
+ run_update(dst, dict(data), **kwargs)
167
+
168
+ def close(self) -> None:
169
+ """Drop the template's temporary clone."""
170
+ self._worker._cleanup()
171
+
172
+ def _copier_question(
173
+ self,
174
+ id: str,
175
+ answers: Mapping[str, Any],
176
+ details: Mapping[str, Any] | None = None,
177
+ ) -> CopierQuestion:
178
+ """Build a fresh copier Question bound to a render context for these answers."""
179
+ if details is None:
180
+ details = self._worker.template.questions_data[id]
181
+ self._worker.answers = AnswersMap(user=dict(answers))
182
+ return CopierQuestion(
183
+ var_name=id,
184
+ answers=self._worker.answers,
185
+ context=self._worker._render_context(),
186
+ jinja_env=self._worker.jinja_env,
187
+ settings=self._worker.settings,
188
+ **details,
189
+ )
190
+
191
+ def _load_error(self, id: str) -> str | None:
192
+ """The load error of a question, or None when it was normalised cleanly."""
193
+ for question in self.questions():
194
+ if question.id == id:
195
+ return question.load_error
196
+ return None
197
+
198
+ def _normalise(self, id: str, details: Mapping[str, Any]) -> Question:
199
+ """Turn one raw copier.yml question block into a model Question."""
200
+ try:
201
+ with Phase.use(Phase.PROMPT):
202
+ question = self._copier_question(id, {}, details)
203
+ kind = _kind_of(
204
+ question.get_type_name(),
205
+ choices=bool(question.choices),
206
+ multiselect=question.multiselect,
207
+ secret=question.secret,
208
+ )
209
+ multiline = question.get_multiline()
210
+ placeholder = question.get_placeholder()
211
+ except Exception as error: # noqa: BLE001 - a broken question is reported, not raised
212
+ return Question(
213
+ id=id,
214
+ kind=Kind.STRING,
215
+ label=id,
216
+ help="",
217
+ secret=False,
218
+ multiselect=False,
219
+ multiline=False,
220
+ placeholder="",
221
+ default_source=None,
222
+ choices_source=None,
223
+ when_source=True,
224
+ validator_source="",
225
+ dependencies=(),
226
+ load_error=f"{id}: {error}",
227
+ )
228
+ return Question(
229
+ id=id,
230
+ kind=kind,
231
+ label=id,
232
+ help=str(details.get("help", "")),
233
+ secret=question.secret,
234
+ multiselect=question.multiselect,
235
+ multiline=multiline,
236
+ placeholder=placeholder,
237
+ default_source=None if question.secret else details.get("default"),
238
+ choices_source=details.get("choices"),
239
+ when_source=details.get("when", True),
240
+ validator_source=str(details.get("validator", "")),
241
+ dependencies=tuple(
242
+ dependency
243
+ for dependency in self._dependencies(
244
+ details.get("when"), details.get("default"), details.get("choices")
245
+ )
246
+ if dependency != id
247
+ ),
248
+ load_error=None,
249
+ )
250
+
251
+ def _dependencies(self, *sources: Any) -> tuple[str, ...]:
252
+ """Question ids referenced by the given Jinja sources, via jinja2.meta over the AST."""
253
+ ids = set(self._worker.template.questions_data)
254
+ found: set[str] = set()
255
+ for source in _templates(sources):
256
+ try:
257
+ ast = self._worker.jinja_env.parse(source)
258
+ except TemplateSyntaxError:
259
+ continue
260
+ found |= meta.find_undeclared_variables(ast) & ids
261
+ return tuple(sorted(found))
262
+
263
+
264
+ def _redact(message: str, questions: tuple[Question, ...], answers: Mapping[str, Any]) -> str:
265
+ """Replace every secret answer's string form in a message with three asterisks."""
266
+ for question in questions:
267
+ if question.secret:
268
+ secret = str(answers.get(question.id, ""))
269
+ if secret:
270
+ message = message.replace(secret, "***")
271
+ return message
272
+
273
+
274
+ def _config_paths(root: Path) -> list[Path]:
275
+ """The template's copier.yml / copier.yaml files, matching copier's own glob."""
276
+ return [
277
+ path
278
+ for path in root.glob("copier.*")
279
+ if path.is_file() and re.match(r"\.ya?ml", path.suffix, re.IGNORECASE)
280
+ ]
281
+
282
+
283
+ def _templates(source: Any) -> Iterator[str]:
284
+ """Every string inside a raw copier.yml value, however deeply nested."""
285
+ if isinstance(source, str):
286
+ yield source
287
+ elif isinstance(source, Mapping):
288
+ for key, value in source.items():
289
+ yield from _templates(key)
290
+ yield from _templates(value)
291
+ elif isinstance(source, (list, tuple)):
292
+ for item in source:
293
+ yield from _templates(item)
294
+
295
+
296
+ def _kind_of(type_name: str, *, choices: bool, multiselect: bool, secret: bool) -> Kind:
297
+ """Map a copier type name plus its modifiers to exactly one Kind."""
298
+ if multiselect:
299
+ return Kind.MULTISELECT
300
+ if choices:
301
+ return Kind.CHOICE
302
+ if secret:
303
+ return Kind.SECRET
304
+ return _KIND_BY_TYPE[type_name]
305
+
306
+
307
+ def _choices_of(question: CopierQuestion) -> tuple[Choice, ...]:
308
+ """Render copier's formatted choices into ordered label/value pairs."""
309
+ if not question.choices:
310
+ return ()
311
+ return tuple(
312
+ Choice(label=str(choice.title), value=choice.value)
313
+ for choice in question._formatted_choices
314
+ )
@@ -0,0 +1,137 @@
1
+ """TemplateUI, the synchronous facade every frontend talks to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from types import TracebackType
8
+ from typing import Any, Self
9
+
10
+ from copier_ui.adapter import TemplateAdapter
11
+ from copier_ui.engine import compute_state, evaluation_order, validate_state, visible_answers
12
+ from copier_ui.errors import RenderRefusedError
13
+ from copier_ui.model import Operation, Schema, State
14
+
15
+
16
+ class TemplateUI:
17
+ """A loaded template, its schema, its answers and its state."""
18
+
19
+ def __init__(
20
+ self,
21
+ adapter: TemplateAdapter,
22
+ schema: Schema,
23
+ order: tuple[str, ...],
24
+ dst: Path,
25
+ ) -> None:
26
+ """Hold the adapter, the schema and the evaluation order; start with no answers."""
27
+ self._adapter = adapter
28
+ self._schema = schema
29
+ self._order = order
30
+ self._dst = dst
31
+ self._explicit: dict[str, Any] = {}
32
+ self._preset: set[str] = set()
33
+ self._extra: dict[str, Any] = {}
34
+ self._state = compute_state(schema, order, {}, frozenset(), adapter.evaluate)
35
+
36
+ @classmethod
37
+ def from_template(
38
+ cls,
39
+ src: str | Path | None,
40
+ *,
41
+ dst: str | Path = ".",
42
+ operation: Operation = "copy",
43
+ vcs_ref: str | None = None,
44
+ answers_file: str | Path | None = None,
45
+ data: Mapping[str, Any] | None = None,
46
+ unsafe: bool = False,
47
+ ) -> TemplateUI:
48
+ """Load a local path or git URL, seed answers, and compute the first state.
49
+
50
+ A template declaring `_jinja_extensions` or `_tasks` is refused with a
51
+ `TemplateLoadError` unless `unsafe` is true or the template is trusted in copier's own
52
+ settings, matching what plain copier does before it imports anything.
53
+ """
54
+ adapter = TemplateAdapter.open(
55
+ src,
56
+ Path(dst),
57
+ vcs_ref=vcs_ref,
58
+ answers_file=None if answers_file is None else Path(answers_file),
59
+ operation=operation,
60
+ unsafe=unsafe,
61
+ )
62
+ try:
63
+ schema = Schema(questions=adapter.questions())
64
+ ui = cls(adapter, schema, evaluation_order(schema), Path(dst))
65
+ ids = schema.ids()
66
+ if operation in ("update", "recopy"):
67
+ for id, value in adapter.last_answers().items():
68
+ if id in ids:
69
+ ui._explicit[id] = value
70
+ for id, value in (data or {}).items():
71
+ if id in ids:
72
+ ui._explicit[id] = value
73
+ ui._preset.add(id)
74
+ else:
75
+ ui._extra[id] = value
76
+ ui._recompute()
77
+ except Exception:
78
+ adapter.close()
79
+ raise
80
+ return ui
81
+
82
+ def schema(self) -> Schema:
83
+ """The ordered questions."""
84
+ return self._schema
85
+
86
+ def set(self, id: str, value: Any) -> None:
87
+ """Record an explicit answer and recompute; raises UnknownFieldError on an unknown id."""
88
+ self._schema.by_id(id)
89
+ self._explicit[id] = value
90
+ self._recompute()
91
+
92
+ def state(self) -> State:
93
+ """The current field state."""
94
+ return self._state
95
+
96
+ def answers(self) -> dict[str, Any]:
97
+ """Visible answers, JSON-compatible, ready to hand to copier as data."""
98
+ return visible_answers(self._state)
99
+
100
+ def validate(self) -> dict[str, list[str]]:
101
+ """Per-field error messages; an empty dict means valid."""
102
+ return validate_state(self._schema, self._state, self._adapter.validate)
103
+
104
+ def render(self, dst: str | Path | None = None, **copier_kwargs: Any) -> None:
105
+ """Run copier with the current answers; raises RenderRefusedError when invalid."""
106
+ errors = self.validate()
107
+ if errors:
108
+ raise RenderRefusedError(errors)
109
+ target = self._dst if dst is None else Path(dst)
110
+ self._adapter.run(target, {**self._extra, **self.answers()}, **copier_kwargs)
111
+
112
+ def close(self) -> None:
113
+ """Release the template's temporary clone."""
114
+ self._adapter.close()
115
+
116
+ def __enter__(self) -> Self:
117
+ """Enter a context that closes the template on exit."""
118
+ return self
119
+
120
+ def __exit__(
121
+ self,
122
+ exc_type: type[BaseException] | None,
123
+ exc: BaseException | None,
124
+ tb: TracebackType | None,
125
+ ) -> None:
126
+ """Close the template."""
127
+ self.close()
128
+
129
+ def _recompute(self) -> None:
130
+ """Re-evaluate visibility, defaults and choices for every question in one pass."""
131
+ self._state = compute_state(
132
+ self._schema,
133
+ self._order,
134
+ self._explicit,
135
+ self._preset,
136
+ self._adapter.evaluate,
137
+ )
@@ -0,0 +1,124 @@
1
+ """State algorithms: evaluation order, state computation, validation, answer extraction.
2
+
3
+ Pure functions over the model. Evaluation arrives as callables, so this module needs neither
4
+ copier nor a template on disk.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping, Sequence
10
+ from collections.abc import Set as AbstractSet
11
+ import heapq
12
+ from typing import Any
13
+
14
+ from copier_ui.errors import CircularDependencyError
15
+ from copier_ui.model import Evaluator, FieldState, FieldValidator, Schema, State
16
+
17
+
18
+ def evaluation_order(schema: Schema) -> tuple[str, ...]:
19
+ """Topological order of the dependency graph, ties broken by declaration order."""
20
+ ids = schema.ids()
21
+ index = {id: position for position, id in enumerate(ids)}
22
+ deps = {q.id: tuple(d for d in q.dependencies if d in index) for q in schema.questions}
23
+ dependents: dict[str, list[str]] = {id: [] for id in ids}
24
+ pending = {}
25
+ for id, sources in deps.items():
26
+ unique = set(sources)
27
+ pending[id] = len(unique)
28
+ for source in unique:
29
+ dependents[source].append(id)
30
+
31
+ ready = [index[id] for id, count in pending.items() if count == 0]
32
+ heapq.heapify(ready)
33
+ order: list[str] = []
34
+ while ready:
35
+ id = ids[heapq.heappop(ready)]
36
+ order.append(id)
37
+ for dependent in dependents[id]:
38
+ pending[dependent] -= 1
39
+ if pending[dependent] == 0:
40
+ heapq.heappush(ready, index[dependent])
41
+
42
+ if len(order) < len(ids):
43
+ stuck = {id for id in ids if pending[id] > 0}
44
+ raise CircularDependencyError(_find_cycle(ids, deps, stuck))
45
+ return tuple(order)
46
+
47
+
48
+ def _find_cycle(
49
+ ids: Sequence[str],
50
+ deps: Mapping[str, Sequence[str]],
51
+ stuck: AbstractSet[str],
52
+ ) -> tuple[str, ...]:
53
+ """Walk the unresolved questions until one repeats, and return that cycle."""
54
+ path: list[str] = []
55
+ seen: dict[str, int] = {}
56
+ id = next(candidate for candidate in ids if candidate in stuck)
57
+ while id not in seen:
58
+ seen[id] = len(path)
59
+ path.append(id)
60
+ id = next(dep for dep in deps[id] if dep in stuck)
61
+ return tuple(path[seen[id] :])
62
+
63
+
64
+ def compute_state(
65
+ schema: Schema,
66
+ order: Sequence[str],
67
+ explicit: Mapping[str, Any],
68
+ preset: AbstractSet[str],
69
+ evaluate: Evaluator,
70
+ ) -> State:
71
+ """Evaluate every question once, threading each resolved value into the next evaluation."""
72
+ answers: dict[str, Any] = {}
73
+ resolved: dict[str, FieldState] = {}
74
+ for id in order:
75
+ question = schema.by_id(id)
76
+ evaluation = evaluate(id, answers)
77
+ if id in explicit:
78
+ value = explicit[id]
79
+ is_default = False
80
+ else:
81
+ value = evaluation.default if evaluation.has_default else None
82
+ is_default = True
83
+ answers[id] = value
84
+ resolved[id] = FieldState(
85
+ id=id,
86
+ value=value,
87
+ visible=evaluation.visible,
88
+ enabled=question.load_error is None and evaluation.error is None,
89
+ is_default=is_default,
90
+ preset=id in preset,
91
+ secret=question.secret,
92
+ choices=evaluation.choices,
93
+ errors=() if evaluation.error is None else (evaluation.error,),
94
+ )
95
+
96
+ fields = {id: resolved[id] for id in schema.ids()}
97
+ return State(
98
+ fields=fields,
99
+ visible_ids=tuple(id for id, field in fields.items() if field.visible),
100
+ )
101
+
102
+
103
+ def validate_state(
104
+ schema: Schema,
105
+ state: State,
106
+ validate_field: FieldValidator,
107
+ ) -> dict[str, list[str]]:
108
+ """Per-field error messages for visible fields; an empty dict means valid."""
109
+ answers = {id: field.value for id, field in state.fields.items()}
110
+ errors: dict[str, list[str]] = {}
111
+ for id in state.visible_ids:
112
+ field = state.fields[id]
113
+ if not field.enabled:
114
+ errors[id] = list(field.errors)
115
+ continue
116
+ messages = list(field.errors) + list(validate_field(id, field.value, answers))
117
+ if messages:
118
+ errors[id] = messages
119
+ return errors
120
+
121
+
122
+ def visible_answers(state: State) -> dict[str, Any]:
123
+ """Answers of visible fields only, JSON-compatible."""
124
+ return {id: state.fields[id].value for id in state.visible_ids}
@@ -0,0 +1,33 @@
1
+ """Exception hierarchy for copier_ui."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CopierUIError(Exception):
7
+ """Base class for every copier_ui error."""
8
+
9
+
10
+ class TemplateLoadError(CopierUIError):
11
+ """The template could not be fetched, or its configuration could not be read."""
12
+
13
+
14
+ class CircularDependencyError(TemplateLoadError):
15
+ """Question expressions form a dependency cycle."""
16
+
17
+ def __init__(self, cycle: tuple[str, ...]) -> None:
18
+ """Record the ids taking part in the cycle."""
19
+ super().__init__("Circular dependency between questions: " + " -> ".join(cycle))
20
+ self.cycle = cycle
21
+
22
+
23
+ class UnknownFieldError(CopierUIError, KeyError):
24
+ """A question id that is not in the schema."""
25
+
26
+
27
+ class RenderRefusedError(CopierUIError):
28
+ """Rendering was refused because the state has validation errors."""
29
+
30
+ def __init__(self, errors: dict[str, list[str]]) -> None:
31
+ """Record the per-field error messages that blocked the render."""
32
+ super().__init__("Invalid answers for: " + ", ".join(sorted(errors)))
33
+ self.errors = errors
@@ -0,0 +1,140 @@
1
+ """Frozen data model shared by every copier_ui layer and by any frontend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+ from typing import Any, Literal
9
+
10
+ from copier_ui.errors import UnknownFieldError
11
+
12
+ Operation = Literal["copy", "update", "recopy"]
13
+
14
+
15
+ class Kind(StrEnum):
16
+ """The widget-selecting kind of a question."""
17
+
18
+ STRING = "string"
19
+ BOOL = "bool"
20
+ INTEGER = "integer"
21
+ FLOAT = "float"
22
+ PATH = "path"
23
+ STRUCTURED = "structured"
24
+ CHOICE = "choice"
25
+ MULTISELECT = "multiselect"
26
+ SECRET = "secret"
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Choice:
31
+ """One selectable option, in copier.yml order."""
32
+
33
+ label: str
34
+ value: Any
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Question:
39
+ """One normalised question: declared data only, independent of any answer."""
40
+
41
+ id: str
42
+ kind: Kind
43
+ label: str
44
+ help: str
45
+ secret: bool
46
+ multiselect: bool
47
+ multiline: bool
48
+ placeholder: str
49
+ default_source: Any
50
+ choices_source: Any
51
+ when_source: str | bool
52
+ validator_source: str
53
+ dependencies: tuple[str, ...]
54
+ load_error: str | None
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class Schema:
59
+ """The template's questions in copier.yml declaration order."""
60
+
61
+ questions: tuple[Question, ...]
62
+
63
+ def ids(self) -> tuple[str, ...]:
64
+ """Question ids in declaration order."""
65
+ return tuple(question.id for question in self.questions)
66
+
67
+ def by_id(self, id: str) -> Question:
68
+ """Look a question up, raising UnknownFieldError when it is absent."""
69
+ for question in self.questions:
70
+ if question.id == id:
71
+ return question
72
+ raise UnknownFieldError(id)
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class Evaluation:
77
+ """One question resolved against one answer set."""
78
+
79
+ visible: bool
80
+ default: Any
81
+ has_default: bool
82
+ choices: tuple[Choice, ...]
83
+ error: str | None
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class FieldState:
88
+ """One question's live state."""
89
+
90
+ id: str
91
+ value: Any
92
+ visible: bool
93
+ enabled: bool
94
+ is_default: bool
95
+ preset: bool
96
+ secret: bool
97
+ choices: tuple[Choice, ...]
98
+ errors: tuple[str, ...]
99
+
100
+ def __repr__(self) -> str:
101
+ """Render the state, masking the value of a secret field."""
102
+ value = "'***'" if self.secret else repr(self.value)
103
+ return (
104
+ f"FieldState(id={self.id!r}, value={value}, visible={self.visible}, "
105
+ f"enabled={self.enabled}, is_default={self.is_default}, preset={self.preset}, "
106
+ f"secret={self.secret}, choices={self.choices!r}, errors={self.errors!r})"
107
+ )
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class State:
112
+ """The whole survey's live state."""
113
+
114
+ fields: Mapping[str, FieldState]
115
+ visible_ids: tuple[str, ...]
116
+
117
+ def to_dict(self) -> dict[str, Any]:
118
+ """JSON-compatible dump with secret values replaced by None."""
119
+ return {
120
+ "fields": {
121
+ id: {
122
+ "value": None if field.secret else field.value,
123
+ "visible": field.visible,
124
+ "enabled": field.enabled,
125
+ "is_default": field.is_default,
126
+ "preset": field.preset,
127
+ "secret": field.secret,
128
+ "choices": [
129
+ {"label": choice.label, "value": choice.value} for choice in field.choices
130
+ ],
131
+ "errors": list(field.errors),
132
+ }
133
+ for id, field in self.fields.items()
134
+ },
135
+ "visible_ids": list(self.visible_ids),
136
+ }
137
+
138
+
139
+ Evaluator = Callable[[str, Mapping[str, Any]], Evaluation]
140
+ FieldValidator = Callable[[str, Any, Mapping[str, Any]], tuple[str, ...]]
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: copier-ui
3
+ Version: 0.6.8
4
+ Summary: UI-neutral abstraction over a copier template survey: normalised schema, dependency graph, visibility and validation
5
+ Author-email: Stellars Henson <konrad.jelen+github@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/stellarshenson/copier-tui
8
+ Project-URL: Repository, https://github.com/stellarshenson/copier-tui
9
+ Project-URL: Issues, https://github.com/stellarshenson/copier-tui/issues
10
+ Keywords: copier,template,scaffolding,questionnaire,ui
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Code Generators
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: copier>=9.6
18
+
19
+ # copier-ui
20
+
21
+ UI-neutral abstraction over a [copier](https://copier.readthedocs.io) template survey.
22
+
23
+ `copier-ui` reads a template's questions and turns them into a normalised schema with a
24
+ dependency graph, visibility state and validation. It owns semantics and renders nothing, so
25
+ the same core drives a terminal UI, a web UI, an HTTP API or a test.
26
+
27
+ It never imports a display library and never requires an event loop.
28
+
29
+ ```python
30
+ from copier_ui import TemplateUI
31
+
32
+ # unsafe=True is copier's own trust gate: this template declares Jinja extensions and tasks,
33
+ # and copier_ui refuses to load such a template before importing anything unless you say so
34
+ ui = TemplateUI.from_template("gh:stellarshenson/copier-data-science", unsafe=True)
35
+ ui.set("dataset_storage", "s3")
36
+ ui.state().fields["s3_bucket"].visible # True
37
+ ui.render("./my-project")
38
+ ```
39
+
40
+ Part of the [copier-tui](https://github.com/stellarshenson/copier-tui) project.
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/copier_ui/__init__.py
4
+ src/copier_ui/adapter.py
5
+ src/copier_ui/api.py
6
+ src/copier_ui/engine.py
7
+ src/copier_ui/errors.py
8
+ src/copier_ui/model.py
9
+ src/copier_ui.egg-info/PKG-INFO
10
+ src/copier_ui.egg-info/SOURCES.txt
11
+ src/copier_ui.egg-info/dependency_links.txt
12
+ src/copier_ui.egg-info/requires.txt
13
+ src/copier_ui.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ copier>=9.6
@@ -0,0 +1 @@
1
+ copier_ui