elva-cli 0.0.3__tar.gz → 0.1.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.
Potentially problematic release.
This version of elva-cli might be problematic. Click here for more details.
- {elva_cli-0.0.3 → elva_cli-0.1.0}/PKG-INFO +71 -9
- elva_cli-0.1.0/README.md +130 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/pyproject.toml +3 -1
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/_version.py +2 -2
- elva_cli-0.1.0/src/elva_cli/commands/config.py +40 -0
- elva_cli-0.1.0/src/elva_cli/context.py +67 -0
- elva_cli-0.1.0/src/elva_cli/errors.py +90 -0
- elva_cli-0.1.0/src/elva_cli/main.py +162 -0
- elva_cli-0.1.0/src/elva_cli/registry.py +46 -0
- elva_cli-0.1.0/src/elva_cli/settings/__init__.py +6 -0
- elva_cli-0.1.0/src/elva_cli/settings/loader.py +154 -0
- elva_cli-0.1.0/src/elva_cli/settings/models.py +38 -0
- elva_cli-0.1.0/src/elva_cli/settings/paths.py +40 -0
- elva_cli-0.1.0/tests/cli/test_cli_exit_codes.py +48 -0
- elva_cli-0.1.0/tests/cli/test_config_command.py +84 -0
- elva_cli-0.1.0/tests/cli/test_lazy_imports.py +45 -0
- elva_cli-0.1.0/tests/unit/test_context.py +57 -0
- elva_cli-0.1.0/tests/unit/test_error_boundary.py +126 -0
- elva_cli-0.1.0/tests/unit/test_errors.py +68 -0
- elva_cli-0.1.0/tests/unit/test_exit_codes.py +34 -0
- elva_cli-0.1.0/tests/unit/test_settings_loader.py +148 -0
- elva_cli-0.0.3/README.md +0 -70
- elva_cli-0.0.3/src/elva_cli/context.py +0 -5
- elva_cli-0.0.3/src/elva_cli/errors.py +0 -9
- elva_cli-0.0.3/src/elva_cli/main.py +0 -55
- elva_cli-0.0.3/src/elva_cli/registry.py +0 -5
- elva_cli-0.0.3/src/elva_cli/settings/__init__.py +0 -5
- {elva_cli-0.0.3 → elva_cli-0.1.0}/.gitignore +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/__init__.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/__main__.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/auth/__init__.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/commands/__init__.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/core/__init__.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/core/api/__init__.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/core/services/__init__.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/core/spec/__init__.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/logging.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/telemetry.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/ui/__init__.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/ui/prompts.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/ui/renderables/__init__.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/ui/views/__init__.py +0 -0
- {elva_cli-0.0.3 → elva_cli-0.1.0}/src/elva_cli/update.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: elva-cli
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.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
|
|
@@ -16,6 +16,8 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
16
16
|
Classifier: Topic :: Software Development :: Documentation
|
|
17
17
|
Classifier: Typing :: Typed
|
|
18
18
|
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: platformdirs>=4.2
|
|
20
|
+
Requires-Dist: pydantic>=2.7
|
|
19
21
|
Requires-Dist: typer<1.0,>=0.15
|
|
20
22
|
Provides-Extra: dev
|
|
21
23
|
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
@@ -71,17 +73,76 @@ powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
|
|
|
71
73
|
uv tool upgrade elva-cli # or: pipx upgrade elva-cli
|
|
72
74
|
```
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
## Configuration
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
`externally-managed-environment`. Those systems reserve their Python for the OS package
|
|
78
|
-
manager ([PEP 668](https://peps.python.org/pep-0668/)), and `--user` is blocked too.
|
|
78
|
+
Settings can come from several places. Highest priority wins:
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
1. Command flags: `--workspace`, `--collection`, `--profile`
|
|
81
|
+
2. Environment: `ELVA_WORKSPACE`, `ELVA_COLLECTION`, `ELVA_PROFILE`, `ELVA_TIMEOUT`
|
|
82
|
+
3. `elva.json` in your project
|
|
83
|
+
4. The selected profile in your user config
|
|
84
|
+
5. Your user config
|
|
85
|
+
6. Built in defaults
|
|
83
86
|
|
|
84
|
-
|
|
87
|
+
### Project file
|
|
88
|
+
|
|
89
|
+
Commit an `elva.json` next to your spec and stop repeating flags:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"workspace": "payments-team",
|
|
94
|
+
"collection": "payments-api"
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
It is found by walking up from the current directory to the repo root, so it works
|
|
99
|
+
from any subfolder. Keep secrets out of it, it is meant to be committed.
|
|
100
|
+
|
|
101
|
+
### User config and profiles
|
|
102
|
+
|
|
103
|
+
| Platform | Location |
|
|
104
|
+
|---|---|
|
|
105
|
+
| Linux | `~/.config/elva/config.json` |
|
|
106
|
+
| macOS | `~/Library/Application Support/elva/config.json` |
|
|
107
|
+
| Windows | `%LOCALAPPDATA%\elva\config.json` |
|
|
108
|
+
|
|
109
|
+
A profile is a named set of defaults. Useful when you work across more than one
|
|
110
|
+
workspace and do not want a project file for each:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"profiles": {
|
|
115
|
+
"work": { "workspace": "work-team", "collection": "work-api" },
|
|
116
|
+
"side": { "workspace": "side-team" }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
elva --profile work collection list
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A project file beats a profile, so a repo with its own `elva.json` always wins over
|
|
126
|
+
whichever profile you have selected.
|
|
127
|
+
|
|
128
|
+
### Seeing what was resolved
|
|
129
|
+
|
|
130
|
+
When something targets the wrong place, these two answer it:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
elva config path # which files were read, and whether they exist
|
|
134
|
+
elva config list # each value, and which layer set it
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
$ elva --profile work config list
|
|
139
|
+
collection work-api profile:work
|
|
140
|
+
profile work flag
|
|
141
|
+
timeout 30.0 default
|
|
142
|
+
workspace work-team profile:work
|
|
143
|
+
|
|
144
|
+
profiles side, work
|
|
145
|
+
```
|
|
85
146
|
|
|
86
147
|
## Requirements
|
|
87
148
|
|
|
@@ -92,4 +153,5 @@ Inside an already-activated virtualenv, `pip install elva-cli` works fine.
|
|
|
92
153
|
|
|
93
154
|
- [Elva](https://getelva.ai)
|
|
94
155
|
- [Issues](https://github.com/Theneo-Inc/theneo-elva-cli/issues)
|
|
156
|
+
- [Exit codes](docs/exit-codes.md), for scripting and CI
|
|
95
157
|
- [Contributing](CONTRIBUTING.md)
|
elva_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Elva CLI
|
|
2
|
+
|
|
3
|
+
Manage your [Elva](https://getelva.ai) API projects from the terminal: import specs,
|
|
4
|
+
inspect collections, and generate MCP servers without opening a browser.
|
|
5
|
+
|
|
6
|
+
> **Early alpha.** The command surface is still taking shape. This release ships
|
|
7
|
+
> `--version` and `--help` only; the first working commands land in `0.1.0`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Requires Python 3.11 or newer.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv tool install elva-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or with [pipx](https://pipx.pypa.io/), if you already use it:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pipx install elva-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Either way, `elva` is then available from any directory:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
elva --version
|
|
27
|
+
elva --help
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
To try it without installing anything:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uvx --from elva-cli elva --version
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Don't have `uv`? It is a single command and no prerequisites:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
curl -fsSL https://astral.sh/uv/install.sh | sh # macOS, Linux
|
|
40
|
+
powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Upgrade
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv tool upgrade elva-cli # or: pipx upgrade elva-cli
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Settings can come from several places. Highest priority wins:
|
|
52
|
+
|
|
53
|
+
1. Command flags: `--workspace`, `--collection`, `--profile`
|
|
54
|
+
2. Environment: `ELVA_WORKSPACE`, `ELVA_COLLECTION`, `ELVA_PROFILE`, `ELVA_TIMEOUT`
|
|
55
|
+
3. `elva.json` in your project
|
|
56
|
+
4. The selected profile in your user config
|
|
57
|
+
5. Your user config
|
|
58
|
+
6. Built in defaults
|
|
59
|
+
|
|
60
|
+
### Project file
|
|
61
|
+
|
|
62
|
+
Commit an `elva.json` next to your spec and stop repeating flags:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"workspace": "payments-team",
|
|
67
|
+
"collection": "payments-api"
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
It is found by walking up from the current directory to the repo root, so it works
|
|
72
|
+
from any subfolder. Keep secrets out of it, it is meant to be committed.
|
|
73
|
+
|
|
74
|
+
### User config and profiles
|
|
75
|
+
|
|
76
|
+
| Platform | Location |
|
|
77
|
+
|---|---|
|
|
78
|
+
| Linux | `~/.config/elva/config.json` |
|
|
79
|
+
| macOS | `~/Library/Application Support/elva/config.json` |
|
|
80
|
+
| Windows | `%LOCALAPPDATA%\elva\config.json` |
|
|
81
|
+
|
|
82
|
+
A profile is a named set of defaults. Useful when you work across more than one
|
|
83
|
+
workspace and do not want a project file for each:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"profiles": {
|
|
88
|
+
"work": { "workspace": "work-team", "collection": "work-api" },
|
|
89
|
+
"side": { "workspace": "side-team" }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
elva --profile work collection list
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A project file beats a profile, so a repo with its own `elva.json` always wins over
|
|
99
|
+
whichever profile you have selected.
|
|
100
|
+
|
|
101
|
+
### Seeing what was resolved
|
|
102
|
+
|
|
103
|
+
When something targets the wrong place, these two answer it:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
elva config path # which files were read, and whether they exist
|
|
107
|
+
elva config list # each value, and which layer set it
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
$ elva --profile work config list
|
|
112
|
+
collection work-api profile:work
|
|
113
|
+
profile work flag
|
|
114
|
+
timeout 30.0 default
|
|
115
|
+
workspace work-team profile:work
|
|
116
|
+
|
|
117
|
+
profiles side, work
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Requirements
|
|
121
|
+
|
|
122
|
+
- Python 3.11 or newer (bundled automatically if you install via `uv tool`)
|
|
123
|
+
- An [Elva](https://getelva.ai) account
|
|
124
|
+
|
|
125
|
+
## Links
|
|
126
|
+
|
|
127
|
+
- [Elva](https://getelva.ai)
|
|
128
|
+
- [Issues](https://github.com/Theneo-Inc/theneo-elva-cli/issues)
|
|
129
|
+
- [Exit codes](docs/exit-codes.md), for scripting and CI
|
|
130
|
+
- [Contributing](CONTRIBUTING.md)
|
|
@@ -23,6 +23,8 @@ classifiers = [
|
|
|
23
23
|
|
|
24
24
|
dependencies = [
|
|
25
25
|
"typer>=0.15,<1.0",
|
|
26
|
+
"platformdirs>=4.2",
|
|
27
|
+
"pydantic>=2.7",
|
|
26
28
|
]
|
|
27
29
|
|
|
28
30
|
[project.optional-dependencies]
|
|
@@ -91,7 +93,7 @@ ban-relative-imports = "all"
|
|
|
91
93
|
[tool.mypy]
|
|
92
94
|
python_version = "3.11"
|
|
93
95
|
strict = true
|
|
94
|
-
files = ["src"]
|
|
96
|
+
files = ["src", "tests"]
|
|
95
97
|
warn_unreachable = true
|
|
96
98
|
enable_error_code = ["ignore-without-code", "redundant-expr"]
|
|
97
99
|
|
|
@@ -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.0
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
def _row(label: str, value: str, note: str = "") -> str:
|
|
11
|
+
return f"{label:<16}{value}{' ' + note if note else ''}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("path")
|
|
15
|
+
def path(click_ctx: typer.Context) -> None:
|
|
16
|
+
"""Show every file the CLI reads configuration from."""
|
|
17
|
+
from elva_cli.settings import paths
|
|
18
|
+
|
|
19
|
+
ctx = get_ctx(click_ctx)
|
|
20
|
+
typer.echo(_row("config dir", str(paths.config_dir())))
|
|
21
|
+
typer.echo(_row("cache dir", str(paths.cache_dir())))
|
|
22
|
+
for file in ctx.resolution.files:
|
|
23
|
+
typer.echo(
|
|
24
|
+
_row(f"{file.kind} config", str(file.path), "(found)" if file.exists else "(absent)")
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command("list")
|
|
29
|
+
def list_(click_ctx: typer.Context) -> None:
|
|
30
|
+
"""Show each setting, its value, and which layer set it."""
|
|
31
|
+
ctx = get_ctx(click_ctx)
|
|
32
|
+
resolution = ctx.resolution
|
|
33
|
+
settings = resolution.settings
|
|
34
|
+
for field in sorted(type(settings).model_fields):
|
|
35
|
+
value = getattr(settings, field)
|
|
36
|
+
shown = "-" if value is None else str(value)
|
|
37
|
+
typer.echo(f"{field:<14}{shown:<32}{resolution.origins[field]}")
|
|
38
|
+
if resolution.profiles:
|
|
39
|
+
typer.echo("")
|
|
40
|
+
typer.echo(f"profiles {', '.join(resolution.profiles)}")
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
from dataclasses import dataclass
|
|
13
|
+
from functools import cached_property
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from elva_cli.errors import ElvaError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
|
|
24
|
+
from elva_cli.settings.loader import Resolution
|
|
25
|
+
from elva_cli.settings.models import Settings
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class GlobalOptions:
|
|
30
|
+
profile: str | None = None
|
|
31
|
+
base_url: str | None = None
|
|
32
|
+
workspace: str | None = None
|
|
33
|
+
collection: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Ctx:
|
|
37
|
+
def __init__(self, options: GlobalOptions, *, cwd: Path, env: Mapping[str, str]) -> None:
|
|
38
|
+
self.options = options
|
|
39
|
+
self.cwd = cwd
|
|
40
|
+
self.env = env
|
|
41
|
+
|
|
42
|
+
@cached_property
|
|
43
|
+
def resolution(self) -> Resolution:
|
|
44
|
+
from elva_cli.settings.loader import resolve
|
|
45
|
+
|
|
46
|
+
return resolve(
|
|
47
|
+
overrides={
|
|
48
|
+
"profile": self.options.profile,
|
|
49
|
+
"base_url": self.options.base_url,
|
|
50
|
+
"workspace": self.options.workspace,
|
|
51
|
+
"collection": self.options.collection,
|
|
52
|
+
},
|
|
53
|
+
env=self.env,
|
|
54
|
+
cwd=self.cwd,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def settings(self) -> Settings:
|
|
59
|
+
return self.resolution.settings
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_ctx(click_ctx: typer.Context) -> Ctx:
|
|
63
|
+
ctx = click_ctx.obj
|
|
64
|
+
if not isinstance(ctx, Ctx):
|
|
65
|
+
msg = "no Ctx on the context; the root callback did not run"
|
|
66
|
+
raise ElvaError(msg)
|
|
67
|
+
return ctx
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Error taxonomy and the exit-code contract.
|
|
2
|
+
|
|
3
|
+
Exit codes are a public API that pipelines branch on. Never renumber a shipped
|
|
4
|
+
value, and never collapse VALIDATION into UNEXPECTED, callers rely on the
|
|
5
|
+
difference between "your spec is wrong" and "the tool broke".
|
|
6
|
+
|
|
7
|
+
Every ElvaError carries a stable machine code, a human message, and where one
|
|
8
|
+
exists, the next action to take. Anything that escapes as a bare Exception is a
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import enum
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExitCode(enum.IntEnum):
|
|
17
|
+
"""Process exit statuses. Documented in docs/exit-codes.md."""
|
|
18
|
+
|
|
19
|
+
OK = 0
|
|
20
|
+
UNEXPECTED = 1
|
|
21
|
+
USAGE = 2
|
|
22
|
+
AUTH = 3
|
|
23
|
+
VALIDATION = 4
|
|
24
|
+
API = 5
|
|
25
|
+
INTERRUPTED = 130
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ElvaError(Exception):
|
|
29
|
+
"""Base class for every failure the user is meant to see."""
|
|
30
|
+
|
|
31
|
+
code: str = "ELVA_ERROR"
|
|
32
|
+
exit_code: ExitCode = ExitCode.UNEXPECTED
|
|
33
|
+
default_hint: str | None = None
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
message: str,
|
|
38
|
+
*,
|
|
39
|
+
hint: str | None = None,
|
|
40
|
+
code: str | None = None,
|
|
41
|
+
exit_code: ExitCode | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.message = message
|
|
45
|
+
self.hint = self.default_hint if hint is None else hint
|
|
46
|
+
if code is not None:
|
|
47
|
+
self.code = code
|
|
48
|
+
if exit_code is not None:
|
|
49
|
+
self.exit_code = exit_code
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
return self.message
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UsageError(ElvaError):
|
|
56
|
+
"""The command was invoked wrongly, or needs an answer it cannot ask for."""
|
|
57
|
+
|
|
58
|
+
code = "ELVA_USAGE"
|
|
59
|
+
exit_code = ExitCode.USAGE
|
|
60
|
+
default_hint = "Run 'elva --help' to see the available commands and options."
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ConfigError(ElvaError):
|
|
64
|
+
"""Configuration is missing or malformed."""
|
|
65
|
+
|
|
66
|
+
code = "ELVA_CONFIG"
|
|
67
|
+
exit_code = ExitCode.USAGE
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AuthError(ElvaError):
|
|
71
|
+
"""Not authenticated, or the stored credentials no longer work."""
|
|
72
|
+
|
|
73
|
+
code = "ELVA_AUTH"
|
|
74
|
+
exit_code = ExitCode.AUTH
|
|
75
|
+
default_hint = "Run 'elva auth login' to sign in."
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ValidationError(ElvaError):
|
|
79
|
+
"""The input spec is invalid. The CLI itself worked correctly."""
|
|
80
|
+
|
|
81
|
+
code = "ELVA_VALIDATION"
|
|
82
|
+
exit_code = ExitCode.VALIDATION
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ApiError(ElvaError):
|
|
86
|
+
"""The Elva API could not be reached, or returned a server error."""
|
|
87
|
+
|
|
88
|
+
code = "ELVA_API"
|
|
89
|
+
exit_code = ExitCode.API
|
|
90
|
+
default_hint = "Check your connection and try again."
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Typer root and the single error boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol, TypeGuard
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from elva_cli.context import Ctx, GlobalOptions
|
|
14
|
+
from elva_cli.errors import ElvaError, ExitCode
|
|
15
|
+
from elva_cli.registry import LazyGroup
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
cls=LazyGroup,
|
|
19
|
+
name="elva",
|
|
20
|
+
help="Elva - CLI for Theneo Elva.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
pretty_exceptions_enable=False,
|
|
23
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _version_callback(value: bool) -> None:
|
|
28
|
+
if not value:
|
|
29
|
+
return
|
|
30
|
+
from elva_cli import __version__
|
|
31
|
+
|
|
32
|
+
machine = f"{platform.system().lower()}-{platform.machine()}"
|
|
33
|
+
typer.echo(f"elva {__version__} (python {platform.python_version()}, {machine})")
|
|
34
|
+
raise typer.Exit(ExitCode.OK)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app.callback()
|
|
38
|
+
def root(
|
|
39
|
+
click_ctx: typer.Context,
|
|
40
|
+
profile: str | None = typer.Option(
|
|
41
|
+
None, "--profile", envvar="ELVA_PROFILE", help="Named set of defaults from your config."
|
|
42
|
+
),
|
|
43
|
+
# Internal escape hatch for Theneo development and CI against staging. Users
|
|
44
|
+
# only ever have prod, so it stays out of --help and out of the README.
|
|
45
|
+
base_url: str | None = typer.Option(None, "--base-url", envvar="ELVA_BASE_URL", hidden=True),
|
|
46
|
+
workspace: str | None = typer.Option(
|
|
47
|
+
None, "--workspace", "-w", envvar="ELVA_WORKSPACE", help="Workspace to act on."
|
|
48
|
+
),
|
|
49
|
+
collection: str | None = typer.Option(
|
|
50
|
+
None, "--collection", "-c", envvar="ELVA_COLLECTION", help="Collection to act on."
|
|
51
|
+
),
|
|
52
|
+
version: bool = typer.Option(
|
|
53
|
+
False,
|
|
54
|
+
"--version",
|
|
55
|
+
"-V",
|
|
56
|
+
callback=_version_callback,
|
|
57
|
+
is_eager=True,
|
|
58
|
+
help="Show the current build version.",
|
|
59
|
+
),
|
|
60
|
+
) -> None:
|
|
61
|
+
click_ctx.obj = Ctx(
|
|
62
|
+
GlobalOptions(
|
|
63
|
+
profile=profile,
|
|
64
|
+
base_url=base_url,
|
|
65
|
+
workspace=workspace,
|
|
66
|
+
collection=collection,
|
|
67
|
+
),
|
|
68
|
+
cwd=Path.cwd(),
|
|
69
|
+
env=os.environ,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def report(error: ElvaError) -> None:
|
|
74
|
+
"""Render a user-facing error to stderr as code, message, then next action."""
|
|
75
|
+
typer.secho(f"{error.code}: {error.message}", err=True, fg=typer.colors.RED)
|
|
76
|
+
if error.hint:
|
|
77
|
+
typer.secho(f" -> {error.hint}", err=True, dim=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def write_crash(exc: BaseException) -> Path | None:
|
|
81
|
+
"""Persist a traceback for an unexpected failure and return its path.
|
|
82
|
+
|
|
83
|
+
Deliberately records no argv: a crash report is written to disk and kept, and
|
|
84
|
+
a mistyped secret on a command line must not outlive the process.
|
|
85
|
+
"""
|
|
86
|
+
import time
|
|
87
|
+
import traceback
|
|
88
|
+
|
|
89
|
+
from elva_cli import __version__
|
|
90
|
+
from elva_cli.settings.paths import crash_dir
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
directory = crash_dir()
|
|
94
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
target = directory / f"crash-{int(time.time())}-{os.getpid()}.log"
|
|
96
|
+
target.write_text(
|
|
97
|
+
f"elva {__version__}\n"
|
|
98
|
+
f"python {platform.python_version()} on {platform.platform()}\n\n"
|
|
99
|
+
+ "".join(traceback.format_exception(exc)),
|
|
100
|
+
encoding="utf-8",
|
|
101
|
+
)
|
|
102
|
+
except OSError:
|
|
103
|
+
return None
|
|
104
|
+
return target
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _FrameworkError(Protocol):
|
|
108
|
+
"""The shape every vendored Click exception exposes."""
|
|
109
|
+
|
|
110
|
+
exit_code: int
|
|
111
|
+
|
|
112
|
+
def show(self) -> None: ...
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_framework_error(exc: BaseException) -> TypeGuard[_FrameworkError]:
|
|
116
|
+
"""Recognise a Typer/Click argument-parsing failure."""
|
|
117
|
+
return (
|
|
118
|
+
type(exc).__module__.startswith("typer")
|
|
119
|
+
and callable(getattr(exc, "show", None))
|
|
120
|
+
and isinstance(getattr(exc, "exit_code", None), int)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _run() -> int:
|
|
125
|
+
try:
|
|
126
|
+
app(standalone_mode=False)
|
|
127
|
+
except typer.Exit as exc:
|
|
128
|
+
return int(exc.exit_code)
|
|
129
|
+
except typer.Abort:
|
|
130
|
+
return int(ExitCode.INTERRUPTED)
|
|
131
|
+
except ElvaError as exc:
|
|
132
|
+
report(exc)
|
|
133
|
+
return int(exc.exit_code)
|
|
134
|
+
except KeyboardInterrupt:
|
|
135
|
+
typer.secho("interrupted", err=True, dim=True)
|
|
136
|
+
return int(ExitCode.INTERRUPTED)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
if _is_framework_error(exc):
|
|
139
|
+
exc.show()
|
|
140
|
+
return int(exc.exit_code)
|
|
141
|
+
path = write_crash(exc)
|
|
142
|
+
report(
|
|
143
|
+
ElvaError(
|
|
144
|
+
f"unexpected error: {type(exc).__name__}: {exc}",
|
|
145
|
+
code="ELVA_CRASH",
|
|
146
|
+
hint=(
|
|
147
|
+
f"Details written to {path}. Please include that file when reporting this."
|
|
148
|
+
if path
|
|
149
|
+
else "Please report this, including the command you ran."
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return int(ExitCode.UNEXPECTED)
|
|
154
|
+
return int(ExitCode.OK)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main() -> None:
|
|
158
|
+
sys.exit(_run())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
main()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Lazy command dispatch.
|
|
2
|
+
|
|
3
|
+
Only the command a user actually typed gets imported. That keeps `elva --version`
|
|
4
|
+
away from pydantic, httpx and anything else a command pulls in.
|
|
5
|
+
|
|
6
|
+
Adding a command means adding a line here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
14
|
+
|
|
15
|
+
from typer.core import TyperGroup
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from typer._click.core import Command, Context
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Lazy:
|
|
23
|
+
module: str
|
|
24
|
+
help: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LazyGroup(TyperGroup):
|
|
28
|
+
commands_: ClassVar[dict[str, Lazy]] = {
|
|
29
|
+
"config": Lazy("elva_cli.commands.config", "Inspect resolved configuration."),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def list_commands(self, ctx: Context) -> list[str]:
|
|
33
|
+
return sorted({*super().list_commands(ctx), *self.commands_})
|
|
34
|
+
|
|
35
|
+
def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
|
|
36
|
+
lazy = self.commands_.get(cmd_name)
|
|
37
|
+
if lazy is None:
|
|
38
|
+
return super().get_command(ctx, cmd_name)
|
|
39
|
+
|
|
40
|
+
import typer.main
|
|
41
|
+
|
|
42
|
+
module = importlib.import_module(lazy.module)
|
|
43
|
+
command = typer.main.get_command(module.app)
|
|
44
|
+
command.name = cmd_name
|
|
45
|
+
command.short_help = lazy.help
|
|
46
|
+
return command
|