elva-cli 0.0.4__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.4 → elva_cli-0.1.0}/PKG-INFO +74 -1
- elva_cli-0.1.0/README.md +130 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/pyproject.toml +1 -0
- {elva_cli-0.0.4 → 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.0.4 → elva_cli-0.1.0}/src/elva_cli/main.py +28 -5
- 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_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_settings_loader.py +148 -0
- elva_cli-0.0.4/README.md +0 -58
- elva_cli-0.0.4/src/elva_cli/context.py +0 -5
- elva_cli-0.0.4/src/elva_cli/registry.py +0 -5
- elva_cli-0.0.4/src/elva_cli/settings/__init__.py +0 -5
- elva_cli-0.0.4/src/elva_cli/settings/paths.py +0 -16
- {elva_cli-0.0.4 → elva_cli-0.1.0}/.gitignore +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/__init__.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/__main__.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/auth/__init__.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/commands/__init__.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/core/__init__.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/core/api/__init__.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/core/services/__init__.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/core/spec/__init__.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/errors.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/logging.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/telemetry.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/ui/__init__.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/ui/prompts.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/ui/renderables/__init__.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/ui/views/__init__.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/src/elva_cli/update.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/tests/cli/test_cli_exit_codes.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/tests/unit/test_error_boundary.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/tests/unit/test_errors.py +0 -0
- {elva_cli-0.0.4 → elva_cli-0.1.0}/tests/unit/test_exit_codes.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
|
|
@@ -17,6 +17,7 @@ Classifier: Topic :: Software Development :: Documentation
|
|
|
17
17
|
Classifier: Typing :: Typed
|
|
18
18
|
Requires-Python: >=3.11
|
|
19
19
|
Requires-Dist: platformdirs>=4.2
|
|
20
|
+
Requires-Dist: pydantic>=2.7
|
|
20
21
|
Requires-Dist: typer<1.0,>=0.15
|
|
21
22
|
Provides-Extra: dev
|
|
22
23
|
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
@@ -72,6 +73,77 @@ powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
|
|
|
72
73
|
uv tool upgrade elva-cli # or: pipx upgrade elva-cli
|
|
73
74
|
```
|
|
74
75
|
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
Settings can come from several places. Highest priority wins:
|
|
79
|
+
|
|
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
|
|
86
|
+
|
|
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
|
+
```
|
|
146
|
+
|
|
75
147
|
## Requirements
|
|
76
148
|
|
|
77
149
|
- Python 3.11 or newer (bundled automatically if you install via `uv tool`)
|
|
@@ -81,4 +153,5 @@ uv tool upgrade elva-cli # or: pipx upgrade elva-cli
|
|
|
81
153
|
|
|
82
154
|
- [Elva](https://getelva.ai)
|
|
83
155
|
- [Issues](https://github.com/Theneo-Inc/theneo-elva-cli/issues)
|
|
156
|
+
- [Exit codes](docs/exit-codes.md), for scripting and CI
|
|
84
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)
|
|
@@ -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
|
|
@@ -5,16 +5,17 @@ from __future__ import annotations
|
|
|
5
5
|
import os
|
|
6
6
|
import platform
|
|
7
7
|
import sys
|
|
8
|
-
from
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol, TypeGuard
|
|
9
10
|
|
|
10
11
|
import typer
|
|
11
12
|
|
|
13
|
+
from elva_cli.context import Ctx, GlobalOptions
|
|
12
14
|
from elva_cli.errors import ElvaError, ExitCode
|
|
13
|
-
|
|
14
|
-
if TYPE_CHECKING:
|
|
15
|
-
from pathlib import Path
|
|
15
|
+
from elva_cli.registry import LazyGroup
|
|
16
16
|
|
|
17
17
|
app = typer.Typer(
|
|
18
|
+
cls=LazyGroup,
|
|
18
19
|
name="elva",
|
|
19
20
|
help="Elva - CLI for Theneo Elva.",
|
|
20
21
|
no_args_is_help=True,
|
|
@@ -35,6 +36,19 @@ def _version_callback(value: bool) -> None:
|
|
|
35
36
|
|
|
36
37
|
@app.callback()
|
|
37
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
|
+
),
|
|
38
52
|
version: bool = typer.Option(
|
|
39
53
|
False,
|
|
40
54
|
"--version",
|
|
@@ -44,7 +58,16 @@ def root(
|
|
|
44
58
|
help="Show the current build version.",
|
|
45
59
|
),
|
|
46
60
|
) -> None:
|
|
47
|
-
|
|
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
|
+
)
|
|
48
71
|
|
|
49
72
|
|
|
50
73
|
def report(error: ElvaError) -> None:
|
|
@@ -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
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
8
|
+
|
|
9
|
+
from elva_cli.errors import ConfigError
|
|
10
|
+
from elva_cli.settings import paths
|
|
11
|
+
from elva_cli.settings.models import Settings
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
ENV_PREFIX = "ELVA_"
|
|
18
|
+
|
|
19
|
+
_ENV_KEYS = {
|
|
20
|
+
"ELVA_BASE_URL": "base_url",
|
|
21
|
+
"ELVA_PROFILE": "profile",
|
|
22
|
+
"ELVA_WORKSPACE": "workspace",
|
|
23
|
+
"ELVA_COLLECTION": "collection",
|
|
24
|
+
"ELVA_TIMEOUT": "timeout",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
DEFAULT_PROFILE = "default"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ConfigFile:
|
|
32
|
+
kind: str
|
|
33
|
+
path: Path
|
|
34
|
+
exists: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Resolution:
|
|
39
|
+
settings: Settings
|
|
40
|
+
origins: dict[str, str]
|
|
41
|
+
files: tuple[ConfigFile, ...]
|
|
42
|
+
profiles: tuple[str, ...]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
46
|
+
try:
|
|
47
|
+
raw = path.read_text(encoding="utf-8")
|
|
48
|
+
except OSError as exc:
|
|
49
|
+
raise ConfigError(f"cannot read {path}: {exc}") from exc
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(raw)
|
|
52
|
+
except json.JSONDecodeError as exc:
|
|
53
|
+
raise ConfigError(f"{path} is not valid JSON: {exc}") from exc
|
|
54
|
+
if not isinstance(data, dict):
|
|
55
|
+
raise ConfigError(f"{path} must contain a JSON object")
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _coerce(field: str, raw: str, source: str) -> Any:
|
|
60
|
+
if field == "timeout":
|
|
61
|
+
try:
|
|
62
|
+
return float(raw)
|
|
63
|
+
except ValueError:
|
|
64
|
+
raise ConfigError(f"{source} must be a number, got {raw!r}") from None
|
|
65
|
+
return raw
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _split_profiles(data: dict[str, Any], path: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
69
|
+
profiles = data.pop("profiles", {})
|
|
70
|
+
if not isinstance(profiles, dict):
|
|
71
|
+
raise ConfigError(f'"profiles" in {path} must be an object')
|
|
72
|
+
return data, profiles
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve(
|
|
76
|
+
*,
|
|
77
|
+
overrides: Mapping[str, Any] | None = None,
|
|
78
|
+
env: Mapping[str, str],
|
|
79
|
+
cwd: Path,
|
|
80
|
+
) -> Resolution:
|
|
81
|
+
"""Merge every configuration source into one frozen Settings.
|
|
82
|
+
|
|
83
|
+
Highest precedence first, and the first layer carrying a field wins.
|
|
84
|
+
"""
|
|
85
|
+
flags = {k: v for k, v in (overrides or {}).items() if v is not None}
|
|
86
|
+
|
|
87
|
+
env_layer: dict[str, Any] = {}
|
|
88
|
+
for key, field in _ENV_KEYS.items():
|
|
89
|
+
raw = env.get(key)
|
|
90
|
+
if raw:
|
|
91
|
+
env_layer[field] = _coerce(field, raw, key)
|
|
92
|
+
|
|
93
|
+
user_path = paths.user_config_file()
|
|
94
|
+
user_layer, profiles = (
|
|
95
|
+
_split_profiles(_read_json(user_path), user_path) if user_path.is_file() else ({}, {})
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
project_path = paths.find_project_file(cwd)
|
|
99
|
+
project_layer = _read_json(project_path) if project_path else {}
|
|
100
|
+
project_layer.pop("profiles", None)
|
|
101
|
+
|
|
102
|
+
selected = (
|
|
103
|
+
flags.get("profile")
|
|
104
|
+
or env_layer.get("profile")
|
|
105
|
+
or project_layer.get("profile")
|
|
106
|
+
or user_layer.get("profile")
|
|
107
|
+
or DEFAULT_PROFILE
|
|
108
|
+
)
|
|
109
|
+
overlay = profiles.get(selected, {})
|
|
110
|
+
if not isinstance(overlay, dict):
|
|
111
|
+
raise ConfigError(f'profile "{selected}" must be an object')
|
|
112
|
+
if selected != DEFAULT_PROFILE and selected not in profiles:
|
|
113
|
+
raise ConfigError(
|
|
114
|
+
f'unknown profile "{selected}"',
|
|
115
|
+
hint=f"Profiles defined in {user_path}: {', '.join(sorted(profiles)) or 'none'}",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
layers: list[tuple[str, Mapping[str, Any]]] = [
|
|
119
|
+
("flag", flags),
|
|
120
|
+
("env", env_layer),
|
|
121
|
+
("project", project_layer),
|
|
122
|
+
(f"profile:{selected}", overlay),
|
|
123
|
+
("user", user_layer),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
merged: dict[str, Any] = {}
|
|
127
|
+
origins: dict[str, str] = {}
|
|
128
|
+
for name, layer in layers:
|
|
129
|
+
for field, value in layer.items():
|
|
130
|
+
if field not in merged:
|
|
131
|
+
merged[field] = value
|
|
132
|
+
origins[field] = name
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
settings = Settings(**merged)
|
|
136
|
+
except PydanticValidationError as exc:
|
|
137
|
+
first = exc.errors()[0]
|
|
138
|
+
field = str(first["loc"][0]) if first["loc"] else "config"
|
|
139
|
+
where = origins.get(field, "default")
|
|
140
|
+
raise ConfigError(f"invalid setting {field!r} from {where}: {first['msg']}") from exc
|
|
141
|
+
|
|
142
|
+
for field in Settings.model_fields:
|
|
143
|
+
origins.setdefault(field, "default")
|
|
144
|
+
|
|
145
|
+
files = (
|
|
146
|
+
ConfigFile("project", project_path or cwd / paths.PROJECT_FILE, project_path is not None),
|
|
147
|
+
ConfigFile("user", user_path, user_path.is_file()),
|
|
148
|
+
)
|
|
149
|
+
return Resolution(
|
|
150
|
+
settings=settings,
|
|
151
|
+
origins=origins,
|
|
152
|
+
files=files,
|
|
153
|
+
profiles=tuple(sorted(profiles)),
|
|
154
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
4
|
+
|
|
5
|
+
DEFAULT_BASE_URL = "https://api.getelva.ai"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Settings(BaseModel):
|
|
9
|
+
"""Every setting the CLI has.
|
|
10
|
+
|
|
11
|
+
Adding a field here is the only way to add a setting: the loader derives its
|
|
12
|
+
precedence handling and `elva config list` derives its output. Unknown keys in
|
|
13
|
+
a config file are an error rather than a silent typo.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
17
|
+
|
|
18
|
+
base_url: str = DEFAULT_BASE_URL
|
|
19
|
+
profile: str = "default"
|
|
20
|
+
workspace: str | None = None
|
|
21
|
+
collection: str | None = None
|
|
22
|
+
timeout: float = 30.0
|
|
23
|
+
|
|
24
|
+
@field_validator("base_url")
|
|
25
|
+
@classmethod
|
|
26
|
+
def _must_be_http(cls, value: str) -> str:
|
|
27
|
+
if not value.startswith(("http://", "https://")):
|
|
28
|
+
msg = "must start with http:// or https://"
|
|
29
|
+
raise ValueError(msg)
|
|
30
|
+
return value.rstrip("/")
|
|
31
|
+
|
|
32
|
+
@field_validator("timeout")
|
|
33
|
+
@classmethod
|
|
34
|
+
def _must_be_positive(cls, value: float) -> float:
|
|
35
|
+
if value <= 0:
|
|
36
|
+
msg = "must be greater than 0"
|
|
37
|
+
raise ValueError(msg)
|
|
38
|
+
return value
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import platformdirs
|
|
6
|
+
|
|
7
|
+
APP_NAME = "elva"
|
|
8
|
+
PROJECT_FILE = "elva.json"
|
|
9
|
+
USER_CONFIG_FILE = "config.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def config_dir() -> Path:
|
|
13
|
+
return Path(platformdirs.user_config_dir(APP_NAME, appauthor=False))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cache_dir() -> Path:
|
|
17
|
+
return Path(platformdirs.user_cache_dir(APP_NAME, appauthor=False))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def crash_dir() -> Path:
|
|
21
|
+
return cache_dir() / "crashes"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def user_config_file() -> Path:
|
|
25
|
+
return config_dir() / USER_CONFIG_FILE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_project_file(start: Path) -> Path | None:
|
|
29
|
+
"""Walk up from start looking for elva.json, stopping at the repo root.
|
|
30
|
+
|
|
31
|
+
The directory holding .git is checked and then the search ends, so a stray
|
|
32
|
+
elva.json above someone's checkout is never picked up.
|
|
33
|
+
"""
|
|
34
|
+
for directory in [start, *start.parents]:
|
|
35
|
+
candidate = directory / PROJECT_FILE
|
|
36
|
+
if candidate.is_file():
|
|
37
|
+
return candidate
|
|
38
|
+
if (directory / ".git").exists():
|
|
39
|
+
break
|
|
40
|
+
return None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(
|
|
13
|
+
*args: str, cwd: Path, env: dict[str, str] | None = None
|
|
14
|
+
) -> subprocess.CompletedProcess[str]:
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
full = {**os.environ, "XDG_CONFIG_HOME": str(cwd / "xdgconfig")}
|
|
18
|
+
full.update(env or {})
|
|
19
|
+
return subprocess.run(
|
|
20
|
+
[sys.executable, "-m", "elva_cli", *args],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
check=False,
|
|
24
|
+
cwd=cwd,
|
|
25
|
+
env=full,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def project(tmp_path: Path, data: dict[str, object] | None = None) -> Path:
|
|
30
|
+
root = tmp_path / "repo"
|
|
31
|
+
root.mkdir()
|
|
32
|
+
(root / ".git").mkdir()
|
|
33
|
+
if data is not None:
|
|
34
|
+
(root / "elva.json").write_text(json.dumps(data), encoding="utf-8")
|
|
35
|
+
return root
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_config_path_lists_every_file_even_when_absent(tmp_path: Path) -> None:
|
|
39
|
+
root = project(tmp_path)
|
|
40
|
+
result = run("config", "path", cwd=root)
|
|
41
|
+
assert result.returncode == 0
|
|
42
|
+
assert "project config" in result.stdout
|
|
43
|
+
assert "user config" in result.stdout
|
|
44
|
+
assert "(absent)" in result.stdout
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_config_path_marks_an_existing_project_file(tmp_path: Path) -> None:
|
|
48
|
+
root = project(tmp_path, {"workspace": "payments"})
|
|
49
|
+
result = run("config", "path", cwd=root)
|
|
50
|
+
assert "(found)" in result.stdout
|
|
51
|
+
assert "elva.json" in result.stdout
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_config_list_shows_value_and_origin(tmp_path: Path) -> None:
|
|
55
|
+
root = project(tmp_path, {"workspace": "payments"})
|
|
56
|
+
result = run("config", "list", cwd=root)
|
|
57
|
+
assert result.returncode == 0
|
|
58
|
+
lines = {line.split()[0]: line for line in result.stdout.splitlines() if line.strip()}
|
|
59
|
+
assert "project" in lines["workspace"]
|
|
60
|
+
assert "payments" in lines["workspace"]
|
|
61
|
+
assert "default" in lines["base_url"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_config_list_reflects_a_flag(tmp_path: Path) -> None:
|
|
65
|
+
root = project(tmp_path, {"workspace": "payments"})
|
|
66
|
+
result = run("--workspace", "other", "config", "list", cwd=root)
|
|
67
|
+
assert "other" in result.stdout
|
|
68
|
+
assert "flag" in result.stdout
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_config_list_reflects_env(tmp_path: Path) -> None:
|
|
72
|
+
root = project(tmp_path)
|
|
73
|
+
result = run("config", "list", cwd=root, env={"ELVA_BASE_URL": "https://env.example.com"})
|
|
74
|
+
assert "https://env.example.com" in result.stdout
|
|
75
|
+
assert "env" in result.stdout
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_bad_config_reports_a_coded_error(tmp_path: Path) -> None:
|
|
79
|
+
root = project(tmp_path)
|
|
80
|
+
(root / "elva.json").write_text("{oops", encoding="utf-8")
|
|
81
|
+
result = run("config", "list", cwd=root)
|
|
82
|
+
assert result.returncode == 2
|
|
83
|
+
assert "ELVA_CONFIG" in result.stderr
|
|
84
|
+
assert "Traceback" not in result.stderr
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Guards the startup cost of `elva --version`.
|
|
2
|
+
|
|
3
|
+
pydantic and httpx are only needed once a command touches settings or the API.
|
|
4
|
+
If they ever get imported at module scope, every invocation pays for them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
PROBE = """
|
|
13
|
+
import sys
|
|
14
|
+
sys.argv = ["elva", "{args}"]
|
|
15
|
+
from elva_cli import main
|
|
16
|
+
main._run()
|
|
17
|
+
print("pydantic:", "pydantic" in sys.modules)
|
|
18
|
+
print("httpx:", "httpx" in sys.modules)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def probe(args: str) -> str:
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
[sys.executable, "-c", PROBE.format(args=args)],
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
check=True,
|
|
28
|
+
)
|
|
29
|
+
return result.stdout
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_version_does_not_import_pydantic_or_httpx() -> None:
|
|
33
|
+
out = probe("--version")
|
|
34
|
+
assert "pydantic: False" in out
|
|
35
|
+
assert "httpx: False" in out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_importing_main_alone_does_not_import_pydantic() -> None:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
[sys.executable, "-c", "import elva_cli.main, sys; print('pydantic' in sys.modules)"],
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True,
|
|
43
|
+
check=True,
|
|
44
|
+
)
|
|
45
|
+
assert result.stdout.strip() == "False"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from elva_cli.context import Ctx, GlobalOptions, get_ctx
|
|
9
|
+
from elva_cli.errors import ElvaError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_flags_reach_the_resolver(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
13
|
+
monkeypatch.setattr("elva_cli.settings.paths.config_dir", lambda: tmp_path)
|
|
14
|
+
(tmp_path / ".git").mkdir()
|
|
15
|
+
ctx = Ctx(GlobalOptions(workspace="payments"), cwd=tmp_path, env={})
|
|
16
|
+
assert ctx.settings.workspace == "payments"
|
|
17
|
+
assert ctx.resolution.origins["workspace"] == "flag"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_resolution_is_computed_once(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
21
|
+
monkeypatch.setattr("elva_cli.settings.paths.config_dir", lambda: tmp_path)
|
|
22
|
+
(tmp_path / ".git").mkdir()
|
|
23
|
+
calls = 0
|
|
24
|
+
real = __import__("elva_cli.settings.loader", fromlist=["resolve"]).resolve
|
|
25
|
+
|
|
26
|
+
def counting(**kwargs: object) -> object:
|
|
27
|
+
nonlocal calls
|
|
28
|
+
calls += 1
|
|
29
|
+
return real(**kwargs)
|
|
30
|
+
|
|
31
|
+
monkeypatch.setattr("elva_cli.settings.loader.resolve", counting)
|
|
32
|
+
ctx = Ctx(GlobalOptions(), cwd=tmp_path, env={})
|
|
33
|
+
assert ctx.resolution is ctx.resolution
|
|
34
|
+
assert ctx.settings is not None
|
|
35
|
+
assert calls == 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _bare_context() -> typer.Context:
|
|
39
|
+
"""A click Context with nothing on obj, as if the root callback never ran."""
|
|
40
|
+
dummy = typer.Typer()
|
|
41
|
+
|
|
42
|
+
@dummy.command()
|
|
43
|
+
def noop() -> None: ...
|
|
44
|
+
|
|
45
|
+
return typer.Context(typer.main.get_command(dummy))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_get_ctx_returns_what_the_callback_stored() -> None:
|
|
49
|
+
ctx = Ctx(GlobalOptions(), cwd=Path("/"), env={})
|
|
50
|
+
click_ctx = _bare_context()
|
|
51
|
+
click_ctx.obj = ctx
|
|
52
|
+
assert get_ctx(click_ctx) is ctx
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_get_ctx_fails_loudly_when_the_callback_did_not_run() -> None:
|
|
56
|
+
with pytest.raises(ElvaError, match="root callback"):
|
|
57
|
+
get_ctx(_bare_context())
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from elva_cli.errors import ConfigError
|
|
9
|
+
from elva_cli.settings.loader import resolve
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
17
|
+
"""Point the user config at a temp dir and give us an isolated project root."""
|
|
18
|
+
config = tmp_path / "config"
|
|
19
|
+
config.mkdir()
|
|
20
|
+
monkeypatch.setattr("elva_cli.settings.paths.config_dir", lambda: config)
|
|
21
|
+
project = tmp_path / "project"
|
|
22
|
+
project.mkdir()
|
|
23
|
+
(project / ".git").mkdir()
|
|
24
|
+
return project
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write(path: Path, data: dict[str, Any]) -> None:
|
|
28
|
+
path.write_text(json.dumps(data), encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_defaults_when_nothing_is_configured(home: Path) -> None:
|
|
32
|
+
result = resolve(env={}, cwd=home)
|
|
33
|
+
assert result.settings.base_url == "https://api.getelva.ai"
|
|
34
|
+
assert result.settings.workspace is None
|
|
35
|
+
assert result.origins["base_url"] == "default"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_flag_beats_env(home: Path) -> None:
|
|
39
|
+
result = resolve(
|
|
40
|
+
overrides={"workspace": "from-flag"}, env={"ELVA_WORKSPACE": "from-env"}, cwd=home
|
|
41
|
+
)
|
|
42
|
+
assert result.settings.workspace == "from-flag"
|
|
43
|
+
assert result.origins["workspace"] == "flag"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_env_beats_project_file(home: Path) -> None:
|
|
47
|
+
write(home / "elva.json", {"workspace": "from-project"})
|
|
48
|
+
result = resolve(env={"ELVA_WORKSPACE": "from-env"}, cwd=home)
|
|
49
|
+
assert result.settings.workspace == "from-env"
|
|
50
|
+
assert result.origins["workspace"] == "env"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_project_file_beats_user_config(home: Path, tmp_path: Path) -> None:
|
|
54
|
+
write(tmp_path / "config" / "config.json", {"workspace": "from-user"})
|
|
55
|
+
write(home / "elva.json", {"workspace": "from-project"})
|
|
56
|
+
result = resolve(env={}, cwd=home)
|
|
57
|
+
assert result.settings.workspace == "from-project"
|
|
58
|
+
assert result.origins["workspace"] == "project"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_user_config_beats_defaults(home: Path, tmp_path: Path) -> None:
|
|
62
|
+
write(tmp_path / "config" / "config.json", {"base_url": "https://staging.example.com"})
|
|
63
|
+
result = resolve(env={}, cwd=home)
|
|
64
|
+
assert result.settings.base_url == "https://staging.example.com"
|
|
65
|
+
assert result.origins["base_url"] == "user"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_project_file_is_found_by_walking_up(home: Path) -> None:
|
|
69
|
+
write(home / "elva.json", {"workspace": "root-level"})
|
|
70
|
+
nested = home / "a" / "b"
|
|
71
|
+
nested.mkdir(parents=True)
|
|
72
|
+
result = resolve(env={}, cwd=nested)
|
|
73
|
+
assert result.settings.workspace == "root-level"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_search_stops_at_the_repo_root(home: Path, tmp_path: Path) -> None:
|
|
77
|
+
write(tmp_path / "elva.json", {"workspace": "outside-the-repo"})
|
|
78
|
+
result = resolve(env={}, cwd=home)
|
|
79
|
+
assert result.settings.workspace is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_selected_profile_overrides_user_defaults(home: Path, tmp_path: Path) -> None:
|
|
83
|
+
write(
|
|
84
|
+
tmp_path / "config" / "config.json",
|
|
85
|
+
{
|
|
86
|
+
"base_url": "https://api.getelva.ai",
|
|
87
|
+
"profiles": {"staging": {"base_url": "https://api-staging.getelva.ai"}},
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
result = resolve(overrides={"profile": "staging"}, env={}, cwd=home)
|
|
91
|
+
assert result.settings.base_url == "https://api-staging.getelva.ai"
|
|
92
|
+
assert result.origins["base_url"] == "profile:staging"
|
|
93
|
+
assert result.profiles == ("staging",)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_profile_can_be_selected_by_env(home: Path, tmp_path: Path) -> None:
|
|
97
|
+
write(tmp_path / "config" / "config.json", {"profiles": {"prod": {"timeout": 5.0}}})
|
|
98
|
+
result = resolve(env={"ELVA_PROFILE": "prod"}, cwd=home)
|
|
99
|
+
assert result.settings.timeout == 5.0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_unknown_profile_is_an_error(home: Path, tmp_path: Path) -> None:
|
|
103
|
+
write(tmp_path / "config" / "config.json", {"profiles": {"prod": {}}})
|
|
104
|
+
with pytest.raises(ConfigError, match="unknown profile"):
|
|
105
|
+
resolve(overrides={"profile": "nope"}, env={}, cwd=home)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_files_are_reported_whether_or_not_they_exist(home: Path) -> None:
|
|
109
|
+
result = resolve(env={}, cwd=home)
|
|
110
|
+
kinds = {f.kind: f.exists for f in result.files}
|
|
111
|
+
assert kinds == {"project": False, "user": False}
|
|
112
|
+
|
|
113
|
+
write(home / "elva.json", {})
|
|
114
|
+
result = resolve(env={}, cwd=home)
|
|
115
|
+
assert {f.kind: f.exists for f in result.files}["project"] is True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_every_field_has_an_origin(home: Path) -> None:
|
|
119
|
+
result = resolve(env={}, cwd=home)
|
|
120
|
+
assert set(result.origins) == set(type(result.settings).model_fields)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_malformed_json_is_an_error(home: Path) -> None:
|
|
124
|
+
(home / "elva.json").write_text("{not json", encoding="utf-8")
|
|
125
|
+
with pytest.raises(ConfigError, match="not valid JSON"):
|
|
126
|
+
resolve(env={}, cwd=home)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_unknown_key_is_an_error(home: Path) -> None:
|
|
130
|
+
write(home / "elva.json", {"nope": 1})
|
|
131
|
+
with pytest.raises(ConfigError, match="invalid setting"):
|
|
132
|
+
resolve(env={}, cwd=home)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_bad_base_url_names_the_layer_that_set_it(home: Path) -> None:
|
|
136
|
+
with pytest.raises(ConfigError, match="from env"):
|
|
137
|
+
resolve(env={"ELVA_BASE_URL": "ftp://nope"}, cwd=home)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_bad_timeout_from_env_is_an_error(home: Path) -> None:
|
|
141
|
+
with pytest.raises(ConfigError, match="must be a number"):
|
|
142
|
+
resolve(env={"ELVA_TIMEOUT": "soon"}, cwd=home)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_settings_are_frozen(home: Path) -> None:
|
|
146
|
+
settings = resolve(env={}, cwd=home).settings
|
|
147
|
+
with pytest.raises(Exception, match=r"frozen|immutable"):
|
|
148
|
+
settings.base_url = "https://elsewhere.example.com"
|
elva_cli-0.0.4/README.md
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
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
|
-
## Requirements
|
|
50
|
-
|
|
51
|
-
- Python 3.11 or newer (bundled automatically if you install via `uv tool`)
|
|
52
|
-
- An [Elva](https://getelva.ai) account
|
|
53
|
-
|
|
54
|
-
## Links
|
|
55
|
-
|
|
56
|
-
- [Elva](https://getelva.ai)
|
|
57
|
-
- [Issues](https://github.com/Theneo-Inc/theneo-elva-cli/issues)
|
|
58
|
-
- [Contributing](CONTRIBUTING.md)
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
"""The Ctx object, built once in the root callback and injected into every command.
|
|
2
|
-
|
|
3
|
-
Carries resolved settings, the API client, output and logging. Commands never
|
|
4
|
-
import global state, which is what makes them testable. Everything expensive is a
|
|
5
|
-
cached_property so `elva --version` pays for none of it."""
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
"""Configuration schema and precedence resolution.
|
|
2
|
-
|
|
3
|
-
Order, highest first: command flags, ELVA_* environment, project elva.toml, user
|
|
4
|
-
config.toml, defaults. Resolved once in the root callback and frozen onto the Ctx;
|
|
5
|
-
nothing downstream re-reads the environment or the filesystem."""
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
import platformdirs
|
|
6
|
-
|
|
7
|
-
APP_NAME = "elva"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def cache_dir() -> Path:
|
|
11
|
-
"""Per-user cache location: XDG on Linux, ~/Library on macOS, %LOCALAPPDATA% on Windows."""
|
|
12
|
-
return Path(platformdirs.user_cache_dir(APP_NAME, appauthor=False))
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def crash_dir() -> Path:
|
|
16
|
-
return cache_dir() / "crashes"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|