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.
- copier_ui-0.6.8/PKG-INFO +40 -0
- copier_ui-0.6.8/README.md +22 -0
- copier_ui-0.6.8/pyproject.toml +34 -0
- copier_ui-0.6.8/setup.cfg +4 -0
- copier_ui-0.6.8/src/copier_ui/__init__.py +37 -0
- copier_ui-0.6.8/src/copier_ui/adapter.py +314 -0
- copier_ui-0.6.8/src/copier_ui/api.py +137 -0
- copier_ui-0.6.8/src/copier_ui/engine.py +124 -0
- copier_ui-0.6.8/src/copier_ui/errors.py +33 -0
- copier_ui-0.6.8/src/copier_ui/model.py +140 -0
- copier_ui-0.6.8/src/copier_ui.egg-info/PKG-INFO +40 -0
- copier_ui-0.6.8/src/copier_ui.egg-info/SOURCES.txt +13 -0
- copier_ui-0.6.8/src/copier_ui.egg-info/dependency_links.txt +1 -0
- copier_ui-0.6.8/src/copier_ui.egg-info/requires.txt +1 -0
- copier_ui-0.6.8/src/copier_ui.egg-info/top_level.txt +1 -0
copier_ui-0.6.8/PKG-INFO
ADDED
|
@@ -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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
copier>=9.6
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
copier_ui
|