copier-tui 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_tui-0.6.8/PKG-INFO +38 -0
- copier_tui-0.6.8/README.md +16 -0
- copier_tui-0.6.8/pyproject.toml +41 -0
- copier_tui-0.6.8/setup.cfg +4 -0
- copier_tui-0.6.8/src/copier_tui/__init__.py +11 -0
- copier_tui-0.6.8/src/copier_tui/app.py +129 -0
- copier_tui-0.6.8/src/copier_tui/cli.py +161 -0
- copier_tui-0.6.8/src/copier_tui/errors.py +17 -0
- copier_tui-0.6.8/src/copier_tui/screens/__init__.py +7 -0
- copier_tui-0.6.8/src/copier_tui/screens/execution.py +146 -0
- copier_tui-0.6.8/src/copier_tui/screens/review.py +104 -0
- copier_tui-0.6.8/src/copier_tui/screens/survey.py +286 -0
- copier_tui-0.6.8/src/copier_tui/screenshots.py +91 -0
- copier_tui-0.6.8/src/copier_tui/theme.py +136 -0
- copier_tui-0.6.8/src/copier_tui/widgets.py +366 -0
- copier_tui-0.6.8/src/copier_tui.egg-info/PKG-INFO +38 -0
- copier_tui-0.6.8/src/copier_tui.egg-info/SOURCES.txt +19 -0
- copier_tui-0.6.8/src/copier_tui.egg-info/dependency_links.txt +1 -0
- copier_tui-0.6.8/src/copier_tui.egg-info/entry_points.txt +2 -0
- copier_tui-0.6.8/src/copier_tui.egg-info/requires.txt +4 -0
- copier_tui-0.6.8/src/copier_tui.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: copier-tui
|
|
3
|
+
Version: 0.6.8
|
|
4
|
+
Summary: A terminal UI for copier: the same CLI, a survey you can move back and forth through before instantiating the template
|
|
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,tui,textual,terminal
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: copier-ui==0.6.8
|
|
19
|
+
Requires-Dist: copier>=9.6
|
|
20
|
+
Requires-Dist: textual>=1.0
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
|
|
23
|
+
# copier-tui
|
|
24
|
+
|
|
25
|
+
A terminal UI for [copier](https://copier.readthedocs.io). Same CLI, better survey.
|
|
26
|
+
|
|
27
|
+
`copier-tui` takes copier's exact command line and replaces the prompt sequence with a
|
|
28
|
+
[Textual](https://textual.textualize.io) interface you can move back and forth through, review,
|
|
29
|
+
and only then instantiate. Templates need no changes.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install copier-tui
|
|
33
|
+
copier-tui copy gh:stellarshenson/copier-data-science ./my-project
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Built on [copier-ui](https://pypi.org/project/copier-ui/), which owns the survey semantics.
|
|
37
|
+
|
|
38
|
+
Full documentation at [github.com/stellarshenson/copier-tui](https://github.com/stellarshenson/copier-tui).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# copier-tui
|
|
2
|
+
|
|
3
|
+
A terminal UI for [copier](https://copier.readthedocs.io). Same CLI, better survey.
|
|
4
|
+
|
|
5
|
+
`copier-tui` takes copier's exact command line and replaces the prompt sequence with a
|
|
6
|
+
[Textual](https://textual.textualize.io) interface you can move back and forth through, review,
|
|
7
|
+
and only then instantiate. Templates need no changes.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install copier-tui
|
|
11
|
+
copier-tui copy gh:stellarshenson/copier-data-science ./my-project
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Built on [copier-ui](https://pypi.org/project/copier-ui/), which owns the survey semantics.
|
|
15
|
+
|
|
16
|
+
Full documentation at [github.com/stellarshenson/copier-tui](https://github.com/stellarshenson/copier-tui).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "copier-tui"
|
|
7
|
+
version = "0.6.8"
|
|
8
|
+
description = "A terminal UI for copier: the same CLI, a survey you can move back and forth through before instantiating the template"
|
|
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", "tui", "textual", "terminal"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Software Development :: Code Generators",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
dependencies = [
|
|
25
|
+
"copier-ui==0.6.8",
|
|
26
|
+
"copier>=9.6",
|
|
27
|
+
"textual>=1.0",
|
|
28
|
+
"rich>=13.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
copier-tui = "copier_tui.cli:main"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/stellarshenson/copier-tui"
|
|
36
|
+
Repository = "https://github.com/stellarshenson/copier-tui"
|
|
37
|
+
Issues = "https://github.com/stellarshenson/copier-tui/issues"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
include = ["copier_tui*"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Terminal renderer for a copier template survey."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
# WSL leaves COLORTERM unset, and the dark slates downsample to xterm teal without it.
|
|
7
|
+
os.environ.setdefault("COLORTERM", "truecolor")
|
|
8
|
+
|
|
9
|
+
__version__ = version("copier-tui")
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The Textual application and the survey entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, ClassVar
|
|
8
|
+
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from textual import events
|
|
11
|
+
from textual.app import App, SystemCommand
|
|
12
|
+
from textual.binding import Binding
|
|
13
|
+
from textual.geometry import Size
|
|
14
|
+
from textual.screen import Screen
|
|
15
|
+
from textual.widgets import Static
|
|
16
|
+
|
|
17
|
+
from copier_tui.errors import EXIT_CANCELLED, EXIT_FAILURE, EXIT_OK
|
|
18
|
+
from copier_tui.screens import ExecutionScreen, ReviewScreen, SurveyScreen
|
|
19
|
+
from copier_tui.screens.survey import askable_ids
|
|
20
|
+
from copier_tui.theme import BASE_CSS, HEADER_CSS, MIN_HEIGHT, MIN_WIDTH, THEME
|
|
21
|
+
from copier_ui import TemplateUI
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SurveyApp(App[int]):
|
|
25
|
+
"""Drives survey, review and execution, and returns a process exit code.
|
|
26
|
+
|
|
27
|
+
Review is pushed on top of the survey rather than replacing it, so coming back finds
|
|
28
|
+
the form exactly as it was left - same scroll offset, same focused field - while every
|
|
29
|
+
row re-reads the state on resume and shows whatever the new answers recomputed.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
CSS = HEADER_CSS + BASE_CSS
|
|
33
|
+
TITLE = "copier-tui"
|
|
34
|
+
|
|
35
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
36
|
+
Binding("ctrl+c", "cancel", "Cancel", priority=True, show=False),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
def __init__(self, ui: TemplateUI, dst: Path, copier_kwargs: dict[str, Any]) -> None:
|
|
40
|
+
"""Hold the loaded template UI, the destination and copier's own flags."""
|
|
41
|
+
super().__init__()
|
|
42
|
+
self.ui = ui
|
|
43
|
+
self.dst = dst
|
|
44
|
+
self.copier_kwargs = copier_kwargs
|
|
45
|
+
|
|
46
|
+
def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
|
|
47
|
+
"""copier-tui's palette is the brand; drop Textual's theme switcher."""
|
|
48
|
+
for command in super().get_system_commands(screen):
|
|
49
|
+
if command.title != "Change theme":
|
|
50
|
+
yield command
|
|
51
|
+
|
|
52
|
+
def on_mount(self) -> None:
|
|
53
|
+
"""Register the palette, then open the survey - or review when nothing is asked."""
|
|
54
|
+
self.register_theme(THEME)
|
|
55
|
+
self.theme = THEME.name
|
|
56
|
+
if self._has_questions():
|
|
57
|
+
self._push(SurveyScreen(self.ui))
|
|
58
|
+
else:
|
|
59
|
+
self._open_review()
|
|
60
|
+
|
|
61
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
62
|
+
"""Show the resize prompt below MIN_WIDTH or MIN_HEIGHT, hide it above.
|
|
63
|
+
|
|
64
|
+
The event carries the new size; App.size still reports the old one here.
|
|
65
|
+
"""
|
|
66
|
+
self._check_size(event.size)
|
|
67
|
+
|
|
68
|
+
def on_survey_screen_confirmed(self, message: SurveyScreen.Confirmed) -> None:
|
|
69
|
+
"""The survey is ready: stack review on top of it, leaving the form untouched."""
|
|
70
|
+
message.stop()
|
|
71
|
+
self._open_review()
|
|
72
|
+
|
|
73
|
+
def on_survey_screen_cancelled(self, message: SurveyScreen.Cancelled) -> None:
|
|
74
|
+
"""The survey was abandoned: leave without writing anything."""
|
|
75
|
+
message.stop()
|
|
76
|
+
self.exit(EXIT_CANCELLED)
|
|
77
|
+
|
|
78
|
+
def action_cancel(self) -> None:
|
|
79
|
+
"""Leave without writing anything."""
|
|
80
|
+
self.exit(EXIT_CANCELLED)
|
|
81
|
+
|
|
82
|
+
def _has_questions(self) -> bool:
|
|
83
|
+
"""True when at least one field is visible and not already answered by --data."""
|
|
84
|
+
return bool(askable_ids(self.ui.state()))
|
|
85
|
+
|
|
86
|
+
def _push(self, screen: Screen[Any], callback: Any = None) -> None:
|
|
87
|
+
"""Push a screen and re-check the terminal size once it has laid out."""
|
|
88
|
+
self.push_screen(screen, callback)
|
|
89
|
+
self.call_after_refresh(self._check_size)
|
|
90
|
+
|
|
91
|
+
def _open_review(self) -> None:
|
|
92
|
+
"""Stack the review screen over whatever is showing."""
|
|
93
|
+
self._push(ReviewScreen(self.ui, self.dst), self._after_review)
|
|
94
|
+
|
|
95
|
+
def _after_review(self, confirmed: bool | None) -> None:
|
|
96
|
+
"""Review confirmed starts the render; back just uncovers the survey again."""
|
|
97
|
+
if confirmed:
|
|
98
|
+
self._push(
|
|
99
|
+
ExecutionScreen(self.ui, self.dst, self.copier_kwargs), self._after_execution
|
|
100
|
+
)
|
|
101
|
+
elif not self._has_questions():
|
|
102
|
+
self.exit(EXIT_CANCELLED)
|
|
103
|
+
else:
|
|
104
|
+
self.call_after_refresh(self._check_size)
|
|
105
|
+
|
|
106
|
+
def _after_execution(self, ok: bool | None) -> None:
|
|
107
|
+
"""The render's verdict is the process exit code."""
|
|
108
|
+
self.exit(EXIT_OK if ok else EXIT_FAILURE)
|
|
109
|
+
|
|
110
|
+
def _check_size(self, size: Size | None = None) -> None:
|
|
111
|
+
"""Mount or drop the resize prompt on the screen currently on top."""
|
|
112
|
+
size = self.size if size is None else size
|
|
113
|
+
prompt = self.screen.query("#resize-prompt")
|
|
114
|
+
if size.width < MIN_WIDTH or size.height < MIN_HEIGHT:
|
|
115
|
+
if not prompt:
|
|
116
|
+
self.screen.mount(
|
|
117
|
+
Static(
|
|
118
|
+
Text(f"terminal too small\nresize to {MIN_WIDTH} x {MIN_HEIGHT}"),
|
|
119
|
+
id="resize-prompt",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
prompt.remove()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run_survey(ui: TemplateUI, dst: Path, copier_kwargs: dict[str, Any]) -> int:
|
|
127
|
+
"""Run the app to completion and return its exit code."""
|
|
128
|
+
exit_code = SurveyApp(ui, dst, copier_kwargs).run()
|
|
129
|
+
return EXIT_CANCELLED if exit_code is None else exit_code
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""copier's own command line, subclassed.
|
|
2
|
+
|
|
3
|
+
plumbum collects subcommands and switches across the MRO and lets a child class override a
|
|
4
|
+
parent's subcommand of the same name, so every flag, short form, help string and error message
|
|
5
|
+
below is copier's, inherited rather than restated. Only `main` is overridden, and only to open
|
|
6
|
+
the survey instead of copier's prompt sequence. `check-update` is inherited untouched.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from copier import VcsRef
|
|
16
|
+
from copier._cli import (
|
|
17
|
+
CopierApp,
|
|
18
|
+
CopierCopySubApp,
|
|
19
|
+
CopierRecopySubApp,
|
|
20
|
+
CopierUpdateSubApp,
|
|
21
|
+
)
|
|
22
|
+
from copier._tools import try_enum
|
|
23
|
+
from copier.errors import UnsafeTemplateError
|
|
24
|
+
from plumbum import colors
|
|
25
|
+
|
|
26
|
+
from copier_tui import __version__
|
|
27
|
+
from copier_tui.app import run_survey
|
|
28
|
+
from copier_tui.errors import EXIT_CANCELLED, EXIT_FAILURE, EXIT_UNSAFE, NotATerminalError
|
|
29
|
+
from copier_ui import CopierUIError, Operation, TemplateUI
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CopierTuiApp(CopierApp):
|
|
33
|
+
"""The copier-tui CLI application."""
|
|
34
|
+
|
|
35
|
+
PROGNAME = "copier-tui"
|
|
36
|
+
VERSION = __version__
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _TuiSubcommand:
|
|
40
|
+
"""Shared launch path for the subcommands that open a survey."""
|
|
41
|
+
|
|
42
|
+
def _headless(self) -> bool:
|
|
43
|
+
"""True when copier's own flags say the survey is not the one asking.
|
|
44
|
+
|
|
45
|
+
`--defaults` answers everything, and `--force` implies it. `--quiet` is not one of
|
|
46
|
+
them: it suppresses status output and still asks. `--ask` is - it asks its questions
|
|
47
|
+
again at render time whatever answers were supplied, so the survey would collect an
|
|
48
|
+
answer only for copier to prompt for it on the render thread with the terminal held.
|
|
49
|
+
A user who asked for copier's own prompting gets copier's own prompting.
|
|
50
|
+
"""
|
|
51
|
+
return bool(self.defaults or self.ask or getattr(self, "force", False))
|
|
52
|
+
|
|
53
|
+
def _copier_kwargs(self) -> dict[str, Any]:
|
|
54
|
+
"""The subcommand's flags, as keyword arguments for copier's run_* function."""
|
|
55
|
+
return {
|
|
56
|
+
"answers_file": self.answers_file,
|
|
57
|
+
"vcs_ref": try_enum(VcsRef, self.vcs_ref),
|
|
58
|
+
"exclude": self.exclude,
|
|
59
|
+
"use_prereleases": self.prereleases,
|
|
60
|
+
"skip_if_exists": self.skip,
|
|
61
|
+
"pretend": self.pretend,
|
|
62
|
+
"quiet": self.quiet,
|
|
63
|
+
"unsafe": self.unsafe,
|
|
64
|
+
"skip_tasks": self.skip_tasks,
|
|
65
|
+
"ask": self.ask,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def _launch(self, src: str | None, destination_path: str, operation: Operation) -> int:
|
|
69
|
+
"""Check for a tty, load the template, run the survey; report load failures and exit."""
|
|
70
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
71
|
+
message = "copier-tui needs a terminal; use --defaults or --force to run headless"
|
|
72
|
+
print(colors.red | str(NotATerminalError(message)), file=sys.stderr)
|
|
73
|
+
return EXIT_FAILURE
|
|
74
|
+
dst = Path(destination_path)
|
|
75
|
+
try:
|
|
76
|
+
ui = TemplateUI.from_template(
|
|
77
|
+
src,
|
|
78
|
+
vcs_ref=self.vcs_ref,
|
|
79
|
+
answers_file=self.answers_file,
|
|
80
|
+
dst=dst,
|
|
81
|
+
data=self.data,
|
|
82
|
+
operation=operation,
|
|
83
|
+
unsafe=self.unsafe,
|
|
84
|
+
)
|
|
85
|
+
except CopierUIError as error:
|
|
86
|
+
print(colors.red | str(error), file=sys.stderr)
|
|
87
|
+
if isinstance(error.__cause__, UnsafeTemplateError):
|
|
88
|
+
return EXIT_UNSAFE
|
|
89
|
+
return EXIT_FAILURE
|
|
90
|
+
with ui:
|
|
91
|
+
code = run_survey(ui, dst, self._copier_kwargs())
|
|
92
|
+
if code == EXIT_CANCELLED:
|
|
93
|
+
print(colors.yellow | f"cancelled - nothing written to {dst}", file=sys.stderr)
|
|
94
|
+
return code
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@CopierTuiApp.subcommand("copy")
|
|
98
|
+
class TuiCopySubApp(CopierCopySubApp, _TuiSubcommand):
|
|
99
|
+
"""The `copier-tui copy` subcommand."""
|
|
100
|
+
|
|
101
|
+
def _copier_kwargs(self) -> dict[str, Any]:
|
|
102
|
+
"""copier's own `copy` arguments, as `copier copy` assembles them."""
|
|
103
|
+
return {
|
|
104
|
+
**super()._copier_kwargs(),
|
|
105
|
+
"cleanup_on_error": self.cleanup_on_error,
|
|
106
|
+
"defaults": self.force or self.defaults,
|
|
107
|
+
"overwrite": self.force or self.overwrite,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def main(self, template_src: str, destination_path: str) -> int:
|
|
111
|
+
"""Survey the template, then copy it; headless flags fall through to copier."""
|
|
112
|
+
if self._headless():
|
|
113
|
+
return super().main(template_src, destination_path)
|
|
114
|
+
return self._launch(template_src, destination_path, "copy")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@CopierTuiApp.subcommand("recopy")
|
|
118
|
+
class TuiRecopySubApp(CopierRecopySubApp, _TuiSubcommand):
|
|
119
|
+
"""The `copier-tui recopy` subcommand."""
|
|
120
|
+
|
|
121
|
+
def _copier_kwargs(self) -> dict[str, Any]:
|
|
122
|
+
"""copier's own `recopy` arguments, as `copier recopy` assembles them."""
|
|
123
|
+
return {
|
|
124
|
+
**super()._copier_kwargs(),
|
|
125
|
+
"defaults": self.force or self.defaults,
|
|
126
|
+
"overwrite": self.force or self.overwrite,
|
|
127
|
+
"skip_answered": self.skip_answered,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def main(self, destination_path: str = ".") -> int:
|
|
131
|
+
"""Survey seeded from the destination's answers file, then recopy."""
|
|
132
|
+
if self._headless():
|
|
133
|
+
return super().main(destination_path)
|
|
134
|
+
return self._launch(None, destination_path, "recopy")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@CopierTuiApp.subcommand("update")
|
|
138
|
+
class TuiUpdateSubApp(CopierUpdateSubApp, _TuiSubcommand):
|
|
139
|
+
"""The `copier-tui update` subcommand."""
|
|
140
|
+
|
|
141
|
+
def _copier_kwargs(self) -> dict[str, Any]:
|
|
142
|
+
"""copier's own `update` arguments, as `copier update` assembles them."""
|
|
143
|
+
return {
|
|
144
|
+
**super()._copier_kwargs(),
|
|
145
|
+
"defaults": self.defaults,
|
|
146
|
+
"overwrite": True,
|
|
147
|
+
"conflict": self.conflict,
|
|
148
|
+
"context_lines": self.context_lines,
|
|
149
|
+
"skip_answered": self.skip_answered,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
def main(self, destination_path: str = ".") -> int:
|
|
153
|
+
"""Survey seeded from the destination's answers file, then update."""
|
|
154
|
+
if self._headless():
|
|
155
|
+
return super().main(destination_path)
|
|
156
|
+
return self._launch(None, destination_path, "update")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main() -> None:
|
|
160
|
+
"""Console script entry point."""
|
|
161
|
+
CopierTuiApp.run()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Errors and exit codes for the terminal frontend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
EXIT_OK = 0
|
|
6
|
+
EXIT_FAILURE = 1
|
|
7
|
+
EXIT_CANCELLED = 2
|
|
8
|
+
EXIT_UNSAFE = 0b100
|
|
9
|
+
"""copier's own code for a template refused for an unsafe feature."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TuiError(Exception):
|
|
13
|
+
"""Base class for every copier_tui error."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotATerminalError(TuiError):
|
|
17
|
+
"""Launched without a terminal on stdin or stdout."""
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Screens of the copier-tui survey."""
|
|
2
|
+
|
|
3
|
+
from copier_tui.screens.execution import ExecutionScreen
|
|
4
|
+
from copier_tui.screens.review import ReviewScreen
|
|
5
|
+
from copier_tui.screens.survey import SurveyScreen
|
|
6
|
+
|
|
7
|
+
__all__ = ["ExecutionScreen", "ReviewScreen", "SurveyScreen"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The execution screen: the copier run, its progress and its verdict."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual import events
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Vertical
|
|
13
|
+
from textual.screen import Screen
|
|
14
|
+
from textual.widgets import Footer, ProgressBar, Static
|
|
15
|
+
|
|
16
|
+
from copier_tui.theme import MINT, ORANGE, ROSE, SURFACE_BG, TEXT_MUTED, TEXT_SUBTLE
|
|
17
|
+
from copier_tui.widgets import HeaderBar
|
|
18
|
+
from copier_ui import TemplateUI
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExecutionScreen(Screen[bool]):
|
|
22
|
+
"""Runs copier in a worker thread and reports success or failure."""
|
|
23
|
+
|
|
24
|
+
DEFAULT_CSS = f"""
|
|
25
|
+
#exec-body {{
|
|
26
|
+
width: 100%;
|
|
27
|
+
height: 1fr;
|
|
28
|
+
padding: 1;
|
|
29
|
+
}}
|
|
30
|
+
#exec-status {{
|
|
31
|
+
height: 1;
|
|
32
|
+
color: {TEXT_MUTED};
|
|
33
|
+
}}
|
|
34
|
+
#exec-verdict {{
|
|
35
|
+
height: 1;
|
|
36
|
+
color: {TEXT_SUBTLE};
|
|
37
|
+
}}
|
|
38
|
+
#exec-progress {{
|
|
39
|
+
width: 100%;
|
|
40
|
+
}}
|
|
41
|
+
#exec-progress Bar {{
|
|
42
|
+
width: 1fr;
|
|
43
|
+
}}
|
|
44
|
+
#exec-progress Bar > .bar--bar {{
|
|
45
|
+
color: {ORANGE};
|
|
46
|
+
background: {SURFACE_BG};
|
|
47
|
+
}}
|
|
48
|
+
#exec-progress Bar > .bar--indeterminate {{
|
|
49
|
+
color: {ORANGE};
|
|
50
|
+
background: {SURFACE_BG};
|
|
51
|
+
}}
|
|
52
|
+
#exec-progress Bar > .bar--complete {{
|
|
53
|
+
color: {MINT};
|
|
54
|
+
background: {SURFACE_BG};
|
|
55
|
+
}}
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
59
|
+
Binding("enter", "close", "Close", priority=True),
|
|
60
|
+
Binding("escape", "close", "Close", priority=True, show=False),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
def __init__(self, ui: TemplateUI, dst: Path, copier_kwargs: dict[str, Any]) -> None:
|
|
64
|
+
"""Hold the template UI, the destination and copier's own flags."""
|
|
65
|
+
super().__init__(id="execution-screen")
|
|
66
|
+
self.ui = ui
|
|
67
|
+
self.dst = dst
|
|
68
|
+
self.copier_kwargs = copier_kwargs
|
|
69
|
+
self.pretend = bool(copier_kwargs.get("pretend"))
|
|
70
|
+
self._done = False
|
|
71
|
+
self._closed = False
|
|
72
|
+
self._ok = False
|
|
73
|
+
|
|
74
|
+
def compose(self) -> ComposeResult:
|
|
75
|
+
"""Header, the status line and the progress bar, the verdict line, footer.
|
|
76
|
+
|
|
77
|
+
The destination is named once, by the status line that narrates the run: the header
|
|
78
|
+
says which run it is, and repeating the path three times said nothing extra.
|
|
79
|
+
"""
|
|
80
|
+
verb = "checking" if self.pretend else "rendering"
|
|
81
|
+
yield HeaderBar("dry run" if self.pretend else "render")
|
|
82
|
+
yield Vertical(
|
|
83
|
+
Static(Text(f"{verb} the template"), id="exec-status"),
|
|
84
|
+
ProgressBar(total=None, show_percentage=False, show_eta=False, id="exec-progress"),
|
|
85
|
+
Static(id="exec-verdict"),
|
|
86
|
+
id="exec-body",
|
|
87
|
+
)
|
|
88
|
+
yield Footer()
|
|
89
|
+
|
|
90
|
+
def on_mount(self) -> None:
|
|
91
|
+
"""Start the render worker and the indeterminate progress bar."""
|
|
92
|
+
self.run_worker(self._run_copier, thread=True, name="render")
|
|
93
|
+
|
|
94
|
+
def on_key(self, event: events.Key) -> None:
|
|
95
|
+
"""Any key closes the finished run, not just the bound ones."""
|
|
96
|
+
if self._done:
|
|
97
|
+
event.stop()
|
|
98
|
+
self.action_close()
|
|
99
|
+
|
|
100
|
+
def action_close(self) -> None:
|
|
101
|
+
"""End the run once the verdict is in; idempotent, any key reaches it too."""
|
|
102
|
+
if not self._done or self._closed:
|
|
103
|
+
return
|
|
104
|
+
self._closed = True
|
|
105
|
+
self.dismiss(self._ok)
|
|
106
|
+
|
|
107
|
+
def _run_copier(self) -> None:
|
|
108
|
+
"""Call TemplateUI.render off the UI thread; report back with call_from_thread.
|
|
109
|
+
|
|
110
|
+
Not named `_render`: Widget._render is Textual's own, and shadowing it runs this
|
|
111
|
+
on the UI thread during layout.
|
|
112
|
+
"""
|
|
113
|
+
error: BaseException | None = None
|
|
114
|
+
try:
|
|
115
|
+
self.ui.render(self.dst, **self.copier_kwargs)
|
|
116
|
+
except Exception as exc: # noqa: BLE001 - any failure becomes the screen's verdict
|
|
117
|
+
error = exc
|
|
118
|
+
self.app.call_from_thread(self._finish, error)
|
|
119
|
+
|
|
120
|
+
def _finish(self, error: BaseException | None) -> None:
|
|
121
|
+
"""Put the verdict on the status line: mint on success, rose with copier's message.
|
|
122
|
+
|
|
123
|
+
A dry run says so - it reports what copier would write, and writes nothing. There is
|
|
124
|
+
no popup: the verdict belongs on the line that has been narrating the run, and the
|
|
125
|
+
destination is named once, by the status line, never also by the header.
|
|
126
|
+
"""
|
|
127
|
+
self._done = True
|
|
128
|
+
self._ok = error is None
|
|
129
|
+
self.query_one("#exec-progress", ProgressBar).update(total=1, progress=1)
|
|
130
|
+
status = self.query_one("#exec-status", Static)
|
|
131
|
+
if error is None:
|
|
132
|
+
done = (
|
|
133
|
+
"nothing written - this was a dry run"
|
|
134
|
+
if self.pretend
|
|
135
|
+
else f"written to {self.dst}"
|
|
136
|
+
)
|
|
137
|
+
status.update(Text(done, style=MINT))
|
|
138
|
+
else:
|
|
139
|
+
status.update(Text(f"failed - {_message(error)}", style=ROSE))
|
|
140
|
+
self.query_one("#exec-verdict", Static).update(Text("press any key to close"))
|
|
141
|
+
self.set_focus(None)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _message(error: BaseException) -> str:
|
|
145
|
+
"""copier's own message where there is one, the exception class otherwise."""
|
|
146
|
+
return str(error) or type(error).__name__
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The review screen: every answer, confirmed before anything is written."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual.app import ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import VerticalScroll
|
|
12
|
+
from textual.screen import Screen
|
|
13
|
+
from textual.widgets import Footer, Static
|
|
14
|
+
|
|
15
|
+
from copier_tui.theme import AMBER, CYAN_BRIGHT, LABEL_WIDTH, TEXT, TEXT_SUBTLE
|
|
16
|
+
from copier_tui.widgets import HeaderBar, display_value
|
|
17
|
+
from copier_ui import TemplateUI
|
|
18
|
+
|
|
19
|
+
UNSET = "not set"
|
|
20
|
+
"""Stands in for an answer with no value, so a blank line is never mistaken for one."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ReviewScreen(Screen[bool]):
|
|
24
|
+
"""Lists every answer and warns when the destination is not empty."""
|
|
25
|
+
|
|
26
|
+
DEFAULT_CSS = f"""
|
|
27
|
+
#review-list {{
|
|
28
|
+
width: 100%;
|
|
29
|
+
height: 1fr;
|
|
30
|
+
padding: 1 2 0 1;
|
|
31
|
+
scrollbar-size-vertical: 1;
|
|
32
|
+
}}
|
|
33
|
+
#review-warning {{
|
|
34
|
+
height: 1;
|
|
35
|
+
width: 100%;
|
|
36
|
+
padding: 0 1;
|
|
37
|
+
color: {AMBER};
|
|
38
|
+
}}
|
|
39
|
+
.review-answer {{
|
|
40
|
+
height: 1;
|
|
41
|
+
width: 100%;
|
|
42
|
+
}}
|
|
43
|
+
#review-empty {{
|
|
44
|
+
color: {TEXT_SUBTLE};
|
|
45
|
+
}}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
49
|
+
Binding("enter", "confirm", "Create", priority=True),
|
|
50
|
+
Binding("escape", "back", "Back", priority=True),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
def __init__(self, ui: TemplateUI, dst: Path) -> None:
|
|
54
|
+
"""Hold the template UI and the destination being reviewed."""
|
|
55
|
+
super().__init__(id="review-screen")
|
|
56
|
+
self.ui = ui
|
|
57
|
+
self.dst = dst
|
|
58
|
+
|
|
59
|
+
def compose(self) -> ComposeResult:
|
|
60
|
+
"""Header, one line per answer, the destination warning, footer."""
|
|
61
|
+
yield HeaderBar(f"review · {self.dst}")
|
|
62
|
+
yield VerticalScroll(*self._answer_lines(), id="review-list")
|
|
63
|
+
yield Static(self._destination_note(), id="review-warning")
|
|
64
|
+
yield Footer()
|
|
65
|
+
|
|
66
|
+
def action_confirm(self) -> None:
|
|
67
|
+
"""Dismiss with True to start the render."""
|
|
68
|
+
self.dismiss(True)
|
|
69
|
+
|
|
70
|
+
def action_back(self) -> None:
|
|
71
|
+
"""Dismiss with False to return to the survey."""
|
|
72
|
+
self.dismiss(False)
|
|
73
|
+
|
|
74
|
+
def _destination_note(self) -> Text:
|
|
75
|
+
"""Warn when the destination already holds files the render could overwrite."""
|
|
76
|
+
if _is_not_empty(self.dst):
|
|
77
|
+
return Text(f"{self.dst} is not empty - existing files may be overwritten")
|
|
78
|
+
return Text("")
|
|
79
|
+
|
|
80
|
+
def _answer_lines(self) -> list[Static]:
|
|
81
|
+
"""One static per visible answer, id gutter aligned, secrets masked."""
|
|
82
|
+
state = self.ui.state()
|
|
83
|
+
lines = []
|
|
84
|
+
for field_id in state.visible_ids:
|
|
85
|
+
field = state.fields[field_id]
|
|
86
|
+
value = display_value(field)
|
|
87
|
+
lines.append(
|
|
88
|
+
Static(
|
|
89
|
+
Text.assemble(
|
|
90
|
+
(field_id[: LABEL_WIDTH - 1].ljust(LABEL_WIDTH), f"bold {CYAN_BRIGHT}"),
|
|
91
|
+
(value, TEXT) if value else (UNSET, TEXT_SUBTLE),
|
|
92
|
+
),
|
|
93
|
+
classes="review-answer",
|
|
94
|
+
id=f"review-{field_id}",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
if not lines:
|
|
98
|
+
lines.append(Static(Text("this template asks nothing"), id="review-empty"))
|
|
99
|
+
return lines
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_not_empty(dst: Path) -> bool:
|
|
103
|
+
"""True when the destination directory already holds something."""
|
|
104
|
+
return dst.is_dir() and any(dst.iterdir())
|