elva-cli 0.1.0__tar.gz → 0.2.0__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.
- {elva_cli-0.1.0 → elva_cli-0.2.0}/PKG-INFO +24 -6
- {elva_cli-0.1.0 → elva_cli-0.2.0}/README.md +23 -5
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/_version.py +2 -2
- elva_cli-0.2.0/src/elva_cli/commands/config.py +25 -0
- elva_cli-0.2.0/src/elva_cli/context.py +148 -0
- elva_cli-0.2.0/src/elva_cli/core/services/config.py +67 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/main.py +24 -4
- elva_cli-0.2.0/src/elva_cli/ui/console.py +33 -0
- elva_cli-0.2.0/src/elva_cli/ui/output.py +65 -0
- elva_cli-0.2.0/src/elva_cli/ui/prompts.py +91 -0
- elva_cli-0.2.0/src/elva_cli/ui/renderables/__init__.py +12 -0
- elva_cli-0.2.0/src/elva_cli/ui/renderables/base.py +37 -0
- elva_cli-0.2.0/src/elva_cli/ui/renderables/config.py +41 -0
- elva_cli-0.2.0/src/elva_cli/ui/theme.py +15 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/tests/cli/test_config_command.py +2 -2
- elva_cli-0.2.0/tests/cli/test_never_blocks.py +124 -0
- elva_cli-0.2.0/tests/cli/test_output_streams.py +110 -0
- elva_cli-0.2.0/tests/unit/test_boundary.py +107 -0
- elva_cli-0.2.0/tests/unit/test_interactivity.py +53 -0
- elva_cli-0.2.0/tests/unit/test_output.py +116 -0
- elva_cli-0.2.0/tests/unit/test_prompts.py +79 -0
- elva_cli-0.1.0/src/elva_cli/commands/config.py +0 -40
- elva_cli-0.1.0/src/elva_cli/context.py +0 -67
- elva_cli-0.1.0/src/elva_cli/ui/prompts.py +0 -9
- elva_cli-0.1.0/src/elva_cli/ui/renderables/__init__.py +0 -4
- {elva_cli-0.1.0 → elva_cli-0.2.0}/.gitignore +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/pyproject.toml +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/__init__.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/__main__.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/auth/__init__.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/commands/__init__.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/core/__init__.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/core/api/__init__.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/core/services/__init__.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/core/spec/__init__.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/errors.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/logging.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/registry.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/settings/__init__.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/settings/loader.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/settings/models.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/settings/paths.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/telemetry.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/ui/__init__.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/ui/views/__init__.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/src/elva_cli/update.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/tests/cli/test_cli_exit_codes.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/tests/cli/test_lazy_imports.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/tests/unit/test_context.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/tests/unit/test_error_boundary.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/tests/unit/test_errors.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/tests/unit/test_exit_codes.py +0 -0
- {elva_cli-0.1.0 → elva_cli-0.2.0}/tests/unit/test_settings_loader.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: elva-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Elva - CLI for Theneo Elva
|
|
5
5
|
Project-URL: Homepage, https://getelva.ai
|
|
6
6
|
Project-URL: Source, https://github.com/Theneo-Inc/theneo-elva-cli
|
|
@@ -136,14 +136,32 @@ elva config list # each value, and which layer set it
|
|
|
136
136
|
|
|
137
137
|
```
|
|
138
138
|
$ elva --profile work config list
|
|
139
|
-
collection
|
|
140
|
-
profile
|
|
141
|
-
timeout
|
|
142
|
-
workspace
|
|
139
|
+
collection work-api profile:work
|
|
140
|
+
profile work flag
|
|
141
|
+
timeout 30.0 default
|
|
142
|
+
workspace work-team profile:work
|
|
143
143
|
|
|
144
|
-
profiles
|
|
144
|
+
profiles: side, work
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
+
Every command also takes `--json` for scripting:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
$ elva --profile work --json config list
|
|
151
|
+
{
|
|
152
|
+
"profile": "work",
|
|
153
|
+
"settings": [
|
|
154
|
+
{ "key": "collection", "value": "work-api", "origin": "profile:work" },
|
|
155
|
+
{ "key": "workspace", "value": "work-team", "origin": "profile:work" }
|
|
156
|
+
],
|
|
157
|
+
"profiles": ["side", "work"]
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Data goes to stdout and everything else to stderr, so `elva --json ... | jq` is always
|
|
162
|
+
clean. `--quiet` drops hints and warnings but keeps data and errors. `--no-color` and
|
|
163
|
+
`NO_COLOR` turn off styling.
|
|
164
|
+
|
|
147
165
|
## Requirements
|
|
148
166
|
|
|
149
167
|
- Python 3.11 or newer (bundled automatically if you install via `uv tool`)
|
|
@@ -109,14 +109,32 @@ elva config list # each value, and which layer set it
|
|
|
109
109
|
|
|
110
110
|
```
|
|
111
111
|
$ elva --profile work config list
|
|
112
|
-
collection
|
|
113
|
-
profile
|
|
114
|
-
timeout
|
|
115
|
-
workspace
|
|
112
|
+
collection work-api profile:work
|
|
113
|
+
profile work flag
|
|
114
|
+
timeout 30.0 default
|
|
115
|
+
workspace work-team profile:work
|
|
116
116
|
|
|
117
|
-
profiles
|
|
117
|
+
profiles: side, work
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
+
Every command also takes `--json` for scripting:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
$ elva --profile work --json config list
|
|
124
|
+
{
|
|
125
|
+
"profile": "work",
|
|
126
|
+
"settings": [
|
|
127
|
+
{ "key": "collection", "value": "work-api", "origin": "profile:work" },
|
|
128
|
+
{ "key": "workspace", "value": "work-team", "origin": "profile:work" }
|
|
129
|
+
],
|
|
130
|
+
"profiles": ["side", "work"]
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Data goes to stdout and everything else to stderr, so `elva --json ... | jq` is always
|
|
135
|
+
clean. `--quiet` drops hints and warnings but keeps data and errors. `--no-color` and
|
|
136
|
+
`NO_COLOR` turn off styling.
|
|
137
|
+
|
|
120
138
|
## Requirements
|
|
121
139
|
|
|
122
140
|
- Python 3.11 or newer (bundled automatically if you install via `uv tool`)
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from elva_cli.context import get_ctx
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(name="config", help="Inspect resolved configuration.", no_args_is_help=True)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command("path")
|
|
11
|
+
def path(click_ctx: typer.Context) -> None:
|
|
12
|
+
"""Show every file the CLI reads configuration from."""
|
|
13
|
+
from elva_cli.core.services.config import describe_paths
|
|
14
|
+
|
|
15
|
+
ctx = get_ctx(click_ctx)
|
|
16
|
+
ctx.out.result(describe_paths(ctx.resolution))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command("list")
|
|
20
|
+
def list_(click_ctx: typer.Context) -> None:
|
|
21
|
+
"""Show each setting, its value, and which layer set it."""
|
|
22
|
+
from elva_cli.core.services.config import describe_values
|
|
23
|
+
|
|
24
|
+
ctx = get_ctx(click_ctx)
|
|
25
|
+
ctx.out.result(describe_values(ctx.resolution))
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""The Ctx object, built once in the root callback and passed to every command.
|
|
2
|
+
|
|
3
|
+
Commands read settings from here instead of touching the environment or the
|
|
4
|
+
filesystem themselves, so configuration is resolved in exactly one place.
|
|
5
|
+
|
|
6
|
+
Resolution is a cached_property because it pulls in pydantic. `elva --version`
|
|
7
|
+
must not pay for that.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from functools import cached_property
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from elva_cli.errors import ElvaError
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import typer
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
|
|
26
|
+
from elva_cli.settings.loader import Resolution
|
|
27
|
+
from elva_cli.settings.models import Settings
|
|
28
|
+
from elva_cli.ui.output import Output
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class GlobalOptions:
|
|
33
|
+
profile: str | None = None
|
|
34
|
+
base_url: str | None = None
|
|
35
|
+
workspace: str | None = None
|
|
36
|
+
collection: str | None = None
|
|
37
|
+
json_output: bool = False
|
|
38
|
+
quiet: bool = False
|
|
39
|
+
color: bool | None = None
|
|
40
|
+
assume_yes: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
CI_VARS = (
|
|
44
|
+
"CI",
|
|
45
|
+
"GITHUB_ACTIONS",
|
|
46
|
+
"GITLAB_CI",
|
|
47
|
+
"CIRCLECI",
|
|
48
|
+
"BUILDKITE",
|
|
49
|
+
"JENKINS_URL",
|
|
50
|
+
"TF_BUILD",
|
|
51
|
+
"TEAMCITY_VERSION",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_FALSEY = frozenset({"", "0", "false", "no", "off"})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Ctx:
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
options: GlobalOptions,
|
|
61
|
+
*,
|
|
62
|
+
cwd: Path,
|
|
63
|
+
env: Mapping[str, str],
|
|
64
|
+
tty: bool | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.options = options
|
|
67
|
+
self.cwd = cwd
|
|
68
|
+
self.env = env
|
|
69
|
+
self._tty = tty
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def assume_yes(self) -> bool:
|
|
73
|
+
return self.options.assume_yes
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def is_ci(self) -> bool:
|
|
77
|
+
return any(self.env.get(var, "").strip().lower() not in _FALSEY for var in CI_VARS)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def is_tty(self) -> bool:
|
|
81
|
+
"""Both streams, because a prompt needs to read and to be seen."""
|
|
82
|
+
if self._tty is not None:
|
|
83
|
+
return self._tty
|
|
84
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def interactive(self) -> bool:
|
|
88
|
+
"""Whether it is safe to ask the user a question.
|
|
89
|
+
|
|
90
|
+
The single place this is decided. --json means a machine is reading, so a
|
|
91
|
+
prompt would corrupt the output as well as hang. --quiet is not included:
|
|
92
|
+
it lowers the noise floor, it does not say nobody is there.
|
|
93
|
+
"""
|
|
94
|
+
if self.options.json_output or self.is_ci:
|
|
95
|
+
return False
|
|
96
|
+
return self.is_tty
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def color(self) -> bool | None:
|
|
100
|
+
"""NO_COLOR wins over everything except an explicit --color."""
|
|
101
|
+
if self.options.color is None and self.env.get("NO_COLOR"):
|
|
102
|
+
return False
|
|
103
|
+
return self.options.color
|
|
104
|
+
|
|
105
|
+
@cached_property
|
|
106
|
+
def consoles(self) -> tuple[Console, Console]:
|
|
107
|
+
from elva_cli.ui.console import build_consoles
|
|
108
|
+
|
|
109
|
+
return build_consoles(color=self.color, quiet=self.options.quiet)
|
|
110
|
+
|
|
111
|
+
@cached_property
|
|
112
|
+
def out(self) -> Output:
|
|
113
|
+
from elva_cli.ui.output import Output
|
|
114
|
+
|
|
115
|
+
stdout, stderr = self.consoles
|
|
116
|
+
return Output(
|
|
117
|
+
stdout=stdout,
|
|
118
|
+
stderr=stderr,
|
|
119
|
+
json_mode=self.options.json_output,
|
|
120
|
+
quiet=self.options.quiet,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@cached_property
|
|
124
|
+
def resolution(self) -> Resolution:
|
|
125
|
+
from elva_cli.settings.loader import resolve
|
|
126
|
+
|
|
127
|
+
return resolve(
|
|
128
|
+
overrides={
|
|
129
|
+
"profile": self.options.profile,
|
|
130
|
+
"base_url": self.options.base_url,
|
|
131
|
+
"workspace": self.options.workspace,
|
|
132
|
+
"collection": self.options.collection,
|
|
133
|
+
},
|
|
134
|
+
env=self.env,
|
|
135
|
+
cwd=self.cwd,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def settings(self) -> Settings:
|
|
140
|
+
return self.resolution.settings
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_ctx(click_ctx: typer.Context) -> Ctx:
|
|
144
|
+
ctx = click_ctx.obj
|
|
145
|
+
if not isinstance(ctx, Ctx):
|
|
146
|
+
msg = "no Ctx on the context; the root callback did not run"
|
|
147
|
+
raise ElvaError(msg)
|
|
148
|
+
return ctx
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Use cases for inspecting configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from elva_cli.settings import paths
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from elva_cli.settings.loader import Resolution
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ConfigFileInfo:
|
|
16
|
+
kind: str
|
|
17
|
+
path: str
|
|
18
|
+
exists: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ConfigPaths:
|
|
23
|
+
config_dir: str
|
|
24
|
+
cache_dir: str
|
|
25
|
+
files: list[ConfigFileInfo] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SettingValue:
|
|
30
|
+
key: str
|
|
31
|
+
value: Any
|
|
32
|
+
origin: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ConfigValues:
|
|
37
|
+
profile: str
|
|
38
|
+
settings: list[SettingValue] = field(default_factory=list)
|
|
39
|
+
profiles: list[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def describe_paths(resolution: Resolution) -> ConfigPaths:
|
|
43
|
+
"""Report every location the CLI consulted, whether or not it existed."""
|
|
44
|
+
return ConfigPaths(
|
|
45
|
+
config_dir=str(paths.config_dir()),
|
|
46
|
+
cache_dir=str(paths.cache_dir()),
|
|
47
|
+
files=[
|
|
48
|
+
ConfigFileInfo(kind=f.kind, path=str(f.path), exists=f.exists) for f in resolution.files
|
|
49
|
+
],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def describe_values(resolution: Resolution) -> ConfigValues:
|
|
54
|
+
"""Report each resolved setting alongside the layer that set it."""
|
|
55
|
+
settings = resolution.settings
|
|
56
|
+
return ConfigValues(
|
|
57
|
+
profile=settings.profile,
|
|
58
|
+
settings=[
|
|
59
|
+
SettingValue(
|
|
60
|
+
key=key,
|
|
61
|
+
value=getattr(settings, key),
|
|
62
|
+
origin=resolution.origins[key],
|
|
63
|
+
)
|
|
64
|
+
for key in sorted(type(settings).model_fields)
|
|
65
|
+
],
|
|
66
|
+
profiles=list(resolution.profiles),
|
|
67
|
+
)
|
|
@@ -49,6 +49,18 @@ def root(
|
|
|
49
49
|
collection: str | None = typer.Option(
|
|
50
50
|
None, "--collection", "-c", envvar="ELVA_COLLECTION", help="Collection to act on."
|
|
51
51
|
),
|
|
52
|
+
json_output: bool = typer.Option(
|
|
53
|
+
False, "--json", help="Emit machine readable JSON instead of formatted output."
|
|
54
|
+
),
|
|
55
|
+
quiet: bool = typer.Option(
|
|
56
|
+
False, "--quiet", "-q", help="Suppress hints and warnings. Data and errors still print."
|
|
57
|
+
),
|
|
58
|
+
assume_yes: bool = typer.Option(
|
|
59
|
+
False, "--yes", "-y", help="Answer every confirmation with yes. Required in CI."
|
|
60
|
+
),
|
|
61
|
+
color: bool | None = typer.Option(
|
|
62
|
+
None, "--color/--no-color", help="Force or disable colour. Honours NO_COLOR."
|
|
63
|
+
),
|
|
52
64
|
version: bool = typer.Option(
|
|
53
65
|
False,
|
|
54
66
|
"--version",
|
|
@@ -64,6 +76,10 @@ def root(
|
|
|
64
76
|
base_url=base_url,
|
|
65
77
|
workspace=workspace,
|
|
66
78
|
collection=collection,
|
|
79
|
+
json_output=json_output,
|
|
80
|
+
quiet=quiet,
|
|
81
|
+
color=color,
|
|
82
|
+
assume_yes=assume_yes,
|
|
67
83
|
),
|
|
68
84
|
cwd=Path.cwd(),
|
|
69
85
|
env=os.environ,
|
|
@@ -71,10 +87,14 @@ def root(
|
|
|
71
87
|
|
|
72
88
|
|
|
73
89
|
def report(error: ElvaError) -> None:
|
|
74
|
-
"""Render a user-facing error to stderr
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
"""Render a user-facing error to stderr.
|
|
91
|
+
|
|
92
|
+
Goes through ui/ so errors look the same wherever they come from, but does not
|
|
93
|
+
need a Ctx: the boundary has to work when building the Ctx is what failed.
|
|
94
|
+
"""
|
|
95
|
+
from elva_cli.ui.output import report_error
|
|
96
|
+
|
|
97
|
+
report_error(error, color=False if os.environ.get("NO_COLOR") else None)
|
|
78
98
|
|
|
79
99
|
|
|
80
100
|
def write_crash(exc: BaseException) -> Path | None:
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Two consoles: data on stdout, everything else on stderr.
|
|
2
|
+
|
|
3
|
+
That split is what keeps `elva pull spec | jq` clean. Spinners, hints, warnings
|
|
4
|
+
and errors must never land in the stream a caller is parsing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
from elva_cli.ui.theme import ELVA_THEME
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_consoles(*, color: bool | None, quiet: bool) -> tuple[Console, Console]:
|
|
17
|
+
out = Console(
|
|
18
|
+
file=sys.stdout,
|
|
19
|
+
theme=ELVA_THEME,
|
|
20
|
+
highlight=False,
|
|
21
|
+
no_color=color is False,
|
|
22
|
+
force_terminal=True if color else None,
|
|
23
|
+
)
|
|
24
|
+
err = Console(
|
|
25
|
+
file=sys.stderr,
|
|
26
|
+
stderr=True,
|
|
27
|
+
theme=ELVA_THEME,
|
|
28
|
+
highlight=False,
|
|
29
|
+
quiet=quiet,
|
|
30
|
+
no_color=color is False,
|
|
31
|
+
force_terminal=True if color else None,
|
|
32
|
+
)
|
|
33
|
+
return out, err
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The single place a Result turns into bytes on a stream.
|
|
2
|
+
|
|
3
|
+
Commands hand their Result here and stop. The human/JSON branch happens once, at
|
|
4
|
+
the last moment, so --json costs nothing per command and can never drift from
|
|
5
|
+
what a person sees.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import json
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from elva_cli.ui.renderables import render
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
|
|
19
|
+
from elva_cli.errors import ElvaError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def as_data(result: object) -> Any:
|
|
23
|
+
if dataclasses.is_dataclass(result) and not isinstance(result, type):
|
|
24
|
+
return dataclasses.asdict(result)
|
|
25
|
+
msg = f"Results must be dataclasses, got {type(result).__name__}"
|
|
26
|
+
raise TypeError(msg)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Output:
|
|
30
|
+
def __init__(self, *, stdout: Console, stderr: Console, json_mode: bool, quiet: bool) -> None:
|
|
31
|
+
self._out = stdout
|
|
32
|
+
self._err = stderr
|
|
33
|
+
self.json_mode = json_mode
|
|
34
|
+
self.quiet = quiet
|
|
35
|
+
|
|
36
|
+
def result(self, result: object) -> None:
|
|
37
|
+
"""Emit a command's payload. Exactly one call per command."""
|
|
38
|
+
if self.json_mode:
|
|
39
|
+
self._out.file.write(json.dumps(as_data(result), indent=2, default=str) + "\n")
|
|
40
|
+
self._out.file.flush()
|
|
41
|
+
return
|
|
42
|
+
self._out.print(render(result), soft_wrap=True)
|
|
43
|
+
|
|
44
|
+
def hint(self, message: str) -> None:
|
|
45
|
+
self._err.print(message, style="elva.dim")
|
|
46
|
+
|
|
47
|
+
def warn(self, message: str) -> None:
|
|
48
|
+
self._err.print(f"warning: {message}", style="elva.warn")
|
|
49
|
+
|
|
50
|
+
def error(self, error: ElvaError) -> None:
|
|
51
|
+
write_error(self._err, error)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def write_error(console: Console, error: ElvaError) -> None:
|
|
55
|
+
console.print(f"{error.code}: {error.message}", style="elva.error", soft_wrap=True)
|
|
56
|
+
if error.hint:
|
|
57
|
+
console.print(f" -> {error.hint}", style="elva.dim", soft_wrap=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def report_error(error: ElvaError, *, color: bool | None = None) -> None:
|
|
61
|
+
"""Render an error when no Ctx exists, which is the case in the boundary."""
|
|
62
|
+
from elva_cli.ui.console import build_consoles
|
|
63
|
+
|
|
64
|
+
_, err = build_consoles(color=color, quiet=False)
|
|
65
|
+
write_error(err, error)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Prompts that always have a non-interactive equivalent.
|
|
2
|
+
|
|
3
|
+
Every helper takes the flag value first. If it was supplied, nothing is asked. If
|
|
4
|
+
it was not and there is nobody to ask, the result is a usage error rather than a
|
|
5
|
+
hang, because a CLI that blocks waiting for input in CI is the worst failure mode
|
|
6
|
+
there is.
|
|
7
|
+
|
|
8
|
+
These live in ui/ so nothing under core/ can reach them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from elva_cli.errors import UsageError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
|
|
20
|
+
from elva_cli.context import Ctx
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _refuse(flag: str) -> UsageError:
|
|
24
|
+
return UsageError(
|
|
25
|
+
f"{flag} is required when there is no terminal to prompt on",
|
|
26
|
+
hint=f"Pass {flag} on the command line, or run this in an interactive shell.",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def text(
|
|
31
|
+
value: str | None,
|
|
32
|
+
*,
|
|
33
|
+
prompt: str,
|
|
34
|
+
flag: str,
|
|
35
|
+
ctx: Ctx,
|
|
36
|
+
default: str | None = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
if value is not None:
|
|
39
|
+
return value
|
|
40
|
+
if not ctx.interactive:
|
|
41
|
+
raise _refuse(flag)
|
|
42
|
+
|
|
43
|
+
import questionary
|
|
44
|
+
|
|
45
|
+
return str(questionary.text(prompt, default=default or "").unsafe_ask())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def select(
|
|
49
|
+
value: str | None,
|
|
50
|
+
*,
|
|
51
|
+
prompt: str,
|
|
52
|
+
choices: Sequence[str],
|
|
53
|
+
flag: str,
|
|
54
|
+
ctx: Ctx,
|
|
55
|
+
) -> str:
|
|
56
|
+
if value is not None:
|
|
57
|
+
if value not in choices:
|
|
58
|
+
raise UsageError(
|
|
59
|
+
f"{value!r} is not a valid value for {flag}",
|
|
60
|
+
hint=f"Choose one of: {', '.join(choices)}",
|
|
61
|
+
)
|
|
62
|
+
return value
|
|
63
|
+
if not ctx.interactive:
|
|
64
|
+
raise _refuse(flag)
|
|
65
|
+
|
|
66
|
+
import questionary
|
|
67
|
+
|
|
68
|
+
return str(questionary.select(prompt, choices=list(choices)).unsafe_ask())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def confirm(
|
|
72
|
+
value: bool | None,
|
|
73
|
+
*,
|
|
74
|
+
prompt: str,
|
|
75
|
+
ctx: Ctx,
|
|
76
|
+
default: bool = False,
|
|
77
|
+
) -> bool:
|
|
78
|
+
"""Ask for a yes or no. --yes answers every one of these without asking."""
|
|
79
|
+
if value is not None:
|
|
80
|
+
return value
|
|
81
|
+
if ctx.assume_yes:
|
|
82
|
+
return True
|
|
83
|
+
if not ctx.interactive:
|
|
84
|
+
raise UsageError(
|
|
85
|
+
f"cannot ask for confirmation: {prompt}",
|
|
86
|
+
hint="Pass --yes to confirm without prompting.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
import questionary
|
|
90
|
+
|
|
91
|
+
return bool(questionary.confirm(prompt, default=default).unsafe_ask())
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Rich renderers for Result objects, dispatched on type.
|
|
2
|
+
|
|
3
|
+
Keeping the data to presentation mapping here is what stops --json from drifting
|
|
4
|
+
from human output: both start from the same dataclass.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from elva_cli.ui.renderables import config as _config # noqa: F401 registers renderers
|
|
10
|
+
from elva_cli.ui.renderables.base import render
|
|
11
|
+
|
|
12
|
+
__all__ = ["render"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import singledispatch
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
from rich.console import RenderableType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@singledispatch
|
|
15
|
+
def render(result: object) -> RenderableType:
|
|
16
|
+
msg = f"no renderer registered for {type(result).__name__}"
|
|
17
|
+
raise NotImplementedError(msg)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def aligned_rows(entries: Sequence[tuple[str, Text, Text]]) -> Text:
|
|
21
|
+
"""Lay out rows as aligned plain lines."""
|
|
22
|
+
if not entries:
|
|
23
|
+
return Text("")
|
|
24
|
+
|
|
25
|
+
label_width = max(len(label) for label, _, _ in entries) + 2
|
|
26
|
+
value_width = max(value.cell_len for _, value, _ in entries) + 2
|
|
27
|
+
|
|
28
|
+
lines: list[Text] = []
|
|
29
|
+
for label, value, note in entries:
|
|
30
|
+
line = Text()
|
|
31
|
+
line.append(label.ljust(label_width), style="elva.key")
|
|
32
|
+
line.append_text(value)
|
|
33
|
+
if note.plain:
|
|
34
|
+
line.append(" " * max(1, value_width - value.cell_len))
|
|
35
|
+
line.append_text(note)
|
|
36
|
+
lines.append(line)
|
|
37
|
+
return Text("\n").join(lines)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.console import Group
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
|
|
6
|
+
# Imported at runtime, not under TYPE_CHECKING: singledispatch resolves the
|
|
7
|
+
# annotation when register() runs, so the class has to actually exist.
|
|
8
|
+
from elva_cli.core.services.config import ConfigPaths, ConfigValues # noqa: TC001
|
|
9
|
+
from elva_cli.ui.renderables.base import aligned_rows, render
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@render.register
|
|
13
|
+
def _(result: ConfigPaths) -> Text:
|
|
14
|
+
entries = [
|
|
15
|
+
("config dir", Text(result.config_dir), Text("")),
|
|
16
|
+
("cache dir", Text(result.cache_dir), Text("")),
|
|
17
|
+
]
|
|
18
|
+
for file in result.files:
|
|
19
|
+
state = Text("found", style="elva.ok") if file.exists else Text("absent", style="elva.dim")
|
|
20
|
+
entries.append((f"{file.kind} config", Text(file.path), state))
|
|
21
|
+
return aligned_rows(entries)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@render.register
|
|
25
|
+
def _(result: ConfigValues) -> Group:
|
|
26
|
+
entries = []
|
|
27
|
+
for setting in result.settings:
|
|
28
|
+
if setting.value is None:
|
|
29
|
+
value = Text("-", style="elva.dim")
|
|
30
|
+
elif setting.value is True:
|
|
31
|
+
value = Text("true")
|
|
32
|
+
elif setting.value is False:
|
|
33
|
+
value = Text("false")
|
|
34
|
+
else:
|
|
35
|
+
value = Text(str(setting.value))
|
|
36
|
+
entries.append((setting.key, value, Text(setting.origin, style="elva.origin")))
|
|
37
|
+
|
|
38
|
+
table = aligned_rows(entries)
|
|
39
|
+
if not result.profiles:
|
|
40
|
+
return Group(table)
|
|
41
|
+
return Group(table, Text(""), Text(f"profiles: {', '.join(result.profiles)}", style="elva.dim"))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.theme import Theme
|
|
4
|
+
|
|
5
|
+
ELVA_THEME = Theme(
|
|
6
|
+
{
|
|
7
|
+
"elva.key": "bold",
|
|
8
|
+
"elva.dim": "dim",
|
|
9
|
+
"elva.accent": "cyan",
|
|
10
|
+
"elva.ok": "green",
|
|
11
|
+
"elva.warn": "yellow",
|
|
12
|
+
"elva.error": "bold red",
|
|
13
|
+
"elva.origin": "dim",
|
|
14
|
+
}
|
|
15
|
+
)
|