elva-cli 0.3.0__tar.gz → 0.4.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.3.0 → elva_cli-0.4.0}/PKG-INFO +119 -3
- {elva_cli-0.3.0 → elva_cli-0.4.0}/README.md +115 -2
- {elva_cli-0.3.0 → elva_cli-0.4.0}/pyproject.toml +6 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/_version.py +2 -2
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/auth/__init__.py +20 -3
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/auth/session.py +15 -0
- elva_cli-0.4.0/src/elva_cli/commands/import_.py +94 -0
- elva_cli-0.4.0/src/elva_cli/commands/whoami.py +18 -0
- elva_cli-0.4.0/src/elva_cli/core/api/http.py +213 -0
- elva_cli-0.4.0/src/elva_cli/core/api/targets.py +132 -0
- elva_cli-0.4.0/src/elva_cli/core/services/import_result.py +56 -0
- elva_cli-0.4.0/src/elva_cli/core/services/import_spec.py +493 -0
- elva_cli-0.4.0/src/elva_cli/core/services/whoami.py +80 -0
- elva_cli-0.4.0/src/elva_cli/core/services/whoami_result.py +12 -0
- elva_cli-0.4.0/src/elva_cli/core/spec/detect.py +118 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/registry.py +2 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/__init__.py +2 -0
- elva_cli-0.4.0/src/elva_cli/ui/renderables/import_spec.py +92 -0
- elva_cli-0.4.0/src/elva_cli/ui/renderables/whoami.py +16 -0
- elva_cli-0.4.0/tests/cli/conftest.py +66 -0
- elva_cli-0.4.0/tests/cli/test_auth_logout_command.py +19 -0
- elva_cli-0.4.0/tests/cli/test_import_command.py +146 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/cli/test_never_blocks.py +1 -1
- elva_cli-0.4.0/tests/cli/test_whoami_command.py +17 -0
- elva_cli-0.4.0/tests/unit/test_http.py +188 -0
- elva_cli-0.4.0/tests/unit/test_import_flow.py +753 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_login_flow.py +1 -12
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_output.py +73 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_session.py +24 -0
- elva_cli-0.4.0/tests/unit/test_spec_detect.py +175 -0
- elva_cli-0.4.0/tests/unit/test_targets.py +147 -0
- elva_cli-0.4.0/tests/unit/test_whoami_flow.py +166 -0
- elva_cli-0.3.0/tests/cli/test_auth_logout_command.py +0 -52
- {elva_cli-0.3.0 → elva_cli-0.4.0}/.gitignore +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/__main__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/auth/models.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/auth/store.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/commands/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/commands/auth.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/commands/config.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/context.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/api/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/services/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/services/auth.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/services/auth_result.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/services/config.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/spec/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/errors.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/logging.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/main.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/settings/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/settings/loader.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/settings/models.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/settings/paths.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/telemetry.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/console.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/output.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/prompts.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/auth.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/base.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/config.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/theme.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/views/__init__.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/update.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/cli/test_cli_exit_codes.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/cli/test_config_command.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/cli/test_lazy_imports.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/cli/test_output_streams.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_boundary.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_context.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_credentials.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_error_boundary.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_errors.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_exit_codes.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_interactivity.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_logout_flow.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_prompts.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_settings_loader.py +0 -0
- {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_token_store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: elva-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -19,11 +19,14 @@ Requires-Python: >=3.11
|
|
|
19
19
|
Requires-Dist: keyring>=25
|
|
20
20
|
Requires-Dist: platformdirs>=4.2
|
|
21
21
|
Requires-Dist: pydantic>=2.7
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Requires-Dist: questionary>=2.0
|
|
22
24
|
Requires-Dist: typer<1.0,>=0.15
|
|
23
25
|
Provides-Extra: dev
|
|
24
26
|
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
25
27
|
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
26
28
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
|
|
27
30
|
Description-Content-Type: text/markdown
|
|
28
31
|
|
|
29
32
|
# Elva CLI
|
|
@@ -31,8 +34,7 @@ Description-Content-Type: text/markdown
|
|
|
31
34
|
Manage your [Elva](https://getelva.ai) API projects from the terminal: import specs,
|
|
32
35
|
inspect collections, and generate MCP servers without opening a browser.
|
|
33
36
|
|
|
34
|
-
> **Early alpha.** The command surface is still taking shape.
|
|
35
|
-
> `--version` and `--help` only; the first working commands land in `0.1.0`.
|
|
37
|
+
> **Early alpha.** The command surface is still taking shape.
|
|
36
38
|
|
|
37
39
|
## Install
|
|
38
40
|
|
|
@@ -74,6 +76,120 @@ powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
|
|
|
74
76
|
uv tool upgrade elva-cli # or: pipx upgrade elva-cli
|
|
75
77
|
```
|
|
76
78
|
|
|
79
|
+
## Importing a spec
|
|
80
|
+
|
|
81
|
+
Create a collection from an OpenAPI document:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
elva import spec openapi.yaml
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
Created Payments Platform API from openapi.yaml.
|
|
89
|
+
|
|
90
|
+
workspace Theneo
|
|
91
|
+
format openapi
|
|
92
|
+
title Payments Platform API
|
|
93
|
+
version 2.4.1
|
|
94
|
+
endpoints 17
|
|
95
|
+
collection id 6aa1322d7ef06cc8f9017460
|
|
96
|
+
url https://app.getelva.ai/collections?selected=6aa1322d7ef06cc8f9017460
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The collection is named after the spec's `info.title`. Override it with `--name`, which
|
|
100
|
+
is also what you will be asked for if the spec has no title:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
elva import spec openapi.yaml --name "Payments v2"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Where the spec comes from
|
|
107
|
+
|
|
108
|
+
A path, a URL Elva fetches itself, or stdin:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
elva import spec openapi.yaml
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
elva import spec --url https://example.com/openapi.yaml --name Payments
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
curl -s https://example.com/openapi.yaml | elva import spec - --name Payments
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A URL is fetched server-side, so nothing is read locally -- which is why `--name` cannot
|
|
123
|
+
be defaulted from it. Files must be `.json`, `.yaml` or `.yml`, and 10 MB or smaller.
|
|
124
|
+
|
|
125
|
+
### Updating an existing collection
|
|
126
|
+
|
|
127
|
+
Importing never overwrites. Replacing the spec of a collection that already exists is a
|
|
128
|
+
separate, explicit action:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
elva -c payments-api import spec openapi.yaml --update
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The previous spec is kept as a restorable version. `--collection` takes a name or an id,
|
|
135
|
+
and `--workspace` picks which workspace to look that name up in; an account with a single
|
|
136
|
+
workspace needs neither. Both are global flags, so they go before the subcommand, and both
|
|
137
|
+
can live in `elva.json` instead.
|
|
138
|
+
|
|
139
|
+
### Checking first
|
|
140
|
+
|
|
141
|
+
`--dry-run` reports what would happen and sends nothing at all -- it works signed out:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
elva import spec openapi.yaml --dry-run
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
Would create Payments Platform API from openapi.yaml.
|
|
149
|
+
|
|
150
|
+
format openapi
|
|
151
|
+
title Payments Platform API
|
|
152
|
+
version 2.4.1
|
|
153
|
+
endpoints 17
|
|
154
|
+
size 11.3 KB
|
|
155
|
+
|
|
156
|
+
Nothing was sent. Drop --dry-run to do it.
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Postman
|
|
160
|
+
|
|
161
|
+
Postman collections are not supported here. Elva reads operations out of an uploaded file
|
|
162
|
+
as OpenAPI, so a Postman collection would import successfully and produce an empty
|
|
163
|
+
collection -- `elva` refuses it rather than let that happen. Export to OpenAPI first, or
|
|
164
|
+
use the Postman integration in the web app.
|
|
165
|
+
|
|
166
|
+
### In CI
|
|
167
|
+
|
|
168
|
+
The exit code is the whole interface:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
elva import spec openapi.yaml
|
|
172
|
+
case $? in
|
|
173
|
+
0) echo "imported" ;;
|
|
174
|
+
2) echo "bad invocation - wrong name, missing file, name already taken"; exit 1 ;;
|
|
175
|
+
3) echo "not signed in"; exit 1 ;;
|
|
176
|
+
4) echo "the spec was rejected"; exit 1 ;;
|
|
177
|
+
5) echo "Elva unreachable"; exit 0 ;;
|
|
178
|
+
esac
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Note what `0` does and does not mean. Elva stores the file and extracts what it can, so a
|
|
182
|
+
spec it cannot parse imports *successfully* with no endpoints in it rather than failing.
|
|
183
|
+
The CLI says so plainly in that case, but the exit code still comes from the server. A
|
|
184
|
+
pipeline that cares should check the count:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
count=$(elva --json import spec openapi.yaml | jq '.endpoints // 0')
|
|
188
|
+
[ "$count" -gt 0 ] || { echo "spec produced no endpoints"; exit 1; }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
See [exit codes](docs/exit-codes.md) for the full table.
|
|
192
|
+
|
|
77
193
|
## Configuration
|
|
78
194
|
|
|
79
195
|
Settings can come from several places. Highest priority wins:
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
Manage your [Elva](https://getelva.ai) API projects from the terminal: import specs,
|
|
4
4
|
inspect collections, and generate MCP servers without opening a browser.
|
|
5
5
|
|
|
6
|
-
> **Early alpha.** The command surface is still taking shape.
|
|
7
|
-
> `--version` and `--help` only; the first working commands land in `0.1.0`.
|
|
6
|
+
> **Early alpha.** The command surface is still taking shape.
|
|
8
7
|
|
|
9
8
|
## Install
|
|
10
9
|
|
|
@@ -46,6 +45,120 @@ powershell -c "irm https://astral.sh/uv/install.ps1|iex" # Windows
|
|
|
46
45
|
uv tool upgrade elva-cli # or: pipx upgrade elva-cli
|
|
47
46
|
```
|
|
48
47
|
|
|
48
|
+
## Importing a spec
|
|
49
|
+
|
|
50
|
+
Create a collection from an OpenAPI document:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
elva import spec openapi.yaml
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Created Payments Platform API from openapi.yaml.
|
|
58
|
+
|
|
59
|
+
workspace Theneo
|
|
60
|
+
format openapi
|
|
61
|
+
title Payments Platform API
|
|
62
|
+
version 2.4.1
|
|
63
|
+
endpoints 17
|
|
64
|
+
collection id 6aa1322d7ef06cc8f9017460
|
|
65
|
+
url https://app.getelva.ai/collections?selected=6aa1322d7ef06cc8f9017460
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The collection is named after the spec's `info.title`. Override it with `--name`, which
|
|
69
|
+
is also what you will be asked for if the spec has no title:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
elva import spec openapi.yaml --name "Payments v2"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Where the spec comes from
|
|
76
|
+
|
|
77
|
+
A path, a URL Elva fetches itself, or stdin:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
elva import spec openapi.yaml
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
elva import spec --url https://example.com/openapi.yaml --name Payments
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
curl -s https://example.com/openapi.yaml | elva import spec - --name Payments
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A URL is fetched server-side, so nothing is read locally -- which is why `--name` cannot
|
|
92
|
+
be defaulted from it. Files must be `.json`, `.yaml` or `.yml`, and 10 MB or smaller.
|
|
93
|
+
|
|
94
|
+
### Updating an existing collection
|
|
95
|
+
|
|
96
|
+
Importing never overwrites. Replacing the spec of a collection that already exists is a
|
|
97
|
+
separate, explicit action:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
elva -c payments-api import spec openapi.yaml --update
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The previous spec is kept as a restorable version. `--collection` takes a name or an id,
|
|
104
|
+
and `--workspace` picks which workspace to look that name up in; an account with a single
|
|
105
|
+
workspace needs neither. Both are global flags, so they go before the subcommand, and both
|
|
106
|
+
can live in `elva.json` instead.
|
|
107
|
+
|
|
108
|
+
### Checking first
|
|
109
|
+
|
|
110
|
+
`--dry-run` reports what would happen and sends nothing at all -- it works signed out:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
elva import spec openapi.yaml --dry-run
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
Would create Payments Platform API from openapi.yaml.
|
|
118
|
+
|
|
119
|
+
format openapi
|
|
120
|
+
title Payments Platform API
|
|
121
|
+
version 2.4.1
|
|
122
|
+
endpoints 17
|
|
123
|
+
size 11.3 KB
|
|
124
|
+
|
|
125
|
+
Nothing was sent. Drop --dry-run to do it.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Postman
|
|
129
|
+
|
|
130
|
+
Postman collections are not supported here. Elva reads operations out of an uploaded file
|
|
131
|
+
as OpenAPI, so a Postman collection would import successfully and produce an empty
|
|
132
|
+
collection -- `elva` refuses it rather than let that happen. Export to OpenAPI first, or
|
|
133
|
+
use the Postman integration in the web app.
|
|
134
|
+
|
|
135
|
+
### In CI
|
|
136
|
+
|
|
137
|
+
The exit code is the whole interface:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
elva import spec openapi.yaml
|
|
141
|
+
case $? in
|
|
142
|
+
0) echo "imported" ;;
|
|
143
|
+
2) echo "bad invocation - wrong name, missing file, name already taken"; exit 1 ;;
|
|
144
|
+
3) echo "not signed in"; exit 1 ;;
|
|
145
|
+
4) echo "the spec was rejected"; exit 1 ;;
|
|
146
|
+
5) echo "Elva unreachable"; exit 0 ;;
|
|
147
|
+
esac
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Note what `0` does and does not mean. Elva stores the file and extracts what it can, so a
|
|
151
|
+
spec it cannot parse imports *successfully* with no endpoints in it rather than failing.
|
|
152
|
+
The CLI says so plainly in that case, but the exit code still comes from the server. A
|
|
153
|
+
pipeline that cares should check the count:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
count=$(elva --json import spec openapi.yaml | jq '.endpoints // 0')
|
|
157
|
+
[ "$count" -gt 0 ] || { echo "spec produced no endpoints"; exit 1; }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
See [exit codes](docs/exit-codes.md) for the full table.
|
|
161
|
+
|
|
49
162
|
## Configuration
|
|
50
163
|
|
|
51
164
|
Settings can come from several places. Highest priority wins:
|
|
@@ -26,6 +26,11 @@ dependencies = [
|
|
|
26
26
|
"platformdirs>=4.2",
|
|
27
27
|
"pydantic>=2.7",
|
|
28
28
|
"keyring>=25",
|
|
29
|
+
# Reads info.title out of a spec so --name can default to it, and backs
|
|
30
|
+
# --dry-run's report. Parses JSON too, so one parser covers both formats.
|
|
31
|
+
"pyyaml>=6.0",
|
|
32
|
+
# ui/prompts.py has always imported this; it was simply never declared.
|
|
33
|
+
"questionary>=2.0",
|
|
29
34
|
]
|
|
30
35
|
|
|
31
36
|
[project.optional-dependencies]
|
|
@@ -33,6 +38,7 @@ dev = [
|
|
|
33
38
|
"ruff>=0.6",
|
|
34
39
|
"mypy>=1.11",
|
|
35
40
|
"pytest>=8.2",
|
|
41
|
+
"types-PyYAML>=6.0",
|
|
36
42
|
]
|
|
37
43
|
|
|
38
44
|
[project.urls]
|
|
@@ -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.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -17,9 +17,26 @@ from typing import TYPE_CHECKING
|
|
|
17
17
|
from elva_cli.auth.models import Credentials
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
|
-
from elva_cli.auth.session import
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
from elva_cli.auth.session import (
|
|
21
|
+
ENV_TOKEN,
|
|
22
|
+
current_identity,
|
|
23
|
+
forget_stored_credentials,
|
|
24
|
+
get_access_token,
|
|
25
|
+
logout,
|
|
26
|
+
save_login,
|
|
27
|
+
save_pat,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ENV_TOKEN",
|
|
32
|
+
"Credentials",
|
|
33
|
+
"current_identity",
|
|
34
|
+
"forget_stored_credentials",
|
|
35
|
+
"get_access_token",
|
|
36
|
+
"logout",
|
|
37
|
+
"save_login",
|
|
38
|
+
"save_pat",
|
|
39
|
+
]
|
|
23
40
|
|
|
24
41
|
_LAZY = frozenset(__all__) - {"Credentials"}
|
|
25
42
|
|
|
@@ -259,6 +259,21 @@ def _refresh_session(*, base_url: str) -> str:
|
|
|
259
259
|
return refreshed.access_token
|
|
260
260
|
|
|
261
261
|
|
|
262
|
+
def current_identity() -> str:
|
|
263
|
+
"""Where get_access_token(base_url=...) would source its token right now:
|
|
264
|
+
'env' (ELVA_TOKEN), 'pat' (a stored personal access token), 'session' (a
|
|
265
|
+
stored browser session), or 'none'."""
|
|
266
|
+
if os.environ.get(ENV_TOKEN):
|
|
267
|
+
return "env"
|
|
268
|
+
creds, _ = _load_from_first_available_store()
|
|
269
|
+
return "none" if creds is None else creds.kind
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def forget_stored_credentials() -> None:
|
|
273
|
+
"""Drop whatever the TokenStores hold."""
|
|
274
|
+
_clear_all_stores()
|
|
275
|
+
|
|
276
|
+
|
|
262
277
|
def save_login(payload: dict[str, Any]) -> None:
|
|
263
278
|
"""Persist a fresh OAuth session from the CLI login exchange.
|
|
264
279
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path # noqa: TC003
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from elva_cli.context import get_ctx
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="import",
|
|
11
|
+
help="Import an API spec into a collection.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
STDIN = "-"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.callback()
|
|
19
|
+
def main() -> None:
|
|
20
|
+
"""Keeps `import` a command group for future `import github` subcommands."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.command("spec")
|
|
24
|
+
def spec(
|
|
25
|
+
click_ctx: typer.Context,
|
|
26
|
+
file: Path | None = typer.Argument(
|
|
27
|
+
None,
|
|
28
|
+
metavar="[FILE]",
|
|
29
|
+
help="OpenAPI document to upload, or - to read one from stdin.",
|
|
30
|
+
),
|
|
31
|
+
url: str | None = typer.Option(
|
|
32
|
+
None, "--url", metavar="URL", help="Have Elva fetch the spec from here instead."
|
|
33
|
+
),
|
|
34
|
+
name: str | None = typer.Option(
|
|
35
|
+
None,
|
|
36
|
+
"--name",
|
|
37
|
+
metavar="NAME",
|
|
38
|
+
help="Name for the new collection. Defaults to the spec's info.title.",
|
|
39
|
+
),
|
|
40
|
+
update: bool = typer.Option(
|
|
41
|
+
False,
|
|
42
|
+
"--update",
|
|
43
|
+
help="Replace the spec of the existing --collection instead of creating one.",
|
|
44
|
+
),
|
|
45
|
+
format_: str | None = typer.Option(
|
|
46
|
+
None,
|
|
47
|
+
"--format",
|
|
48
|
+
metavar="FORMAT",
|
|
49
|
+
help="Skip detection and say what the file is: openapi or postman.",
|
|
50
|
+
),
|
|
51
|
+
dry_run: bool = typer.Option(
|
|
52
|
+
False, "--dry-run", help="Report what would be imported without sending anything."
|
|
53
|
+
),
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Create a collection from an OpenAPI document.
|
|
56
|
+
|
|
57
|
+
Reads a local file, stdin, or a URL Elva fetches itself. Creating is the
|
|
58
|
+
default; --update replaces the spec of the collection named by --collection.
|
|
59
|
+
"""
|
|
60
|
+
from elva_cli.core.services.import_spec import import_spec
|
|
61
|
+
from elva_cli.ui import prompts
|
|
62
|
+
|
|
63
|
+
ctx = get_ctx(click_ctx)
|
|
64
|
+
|
|
65
|
+
stdin: bytes | None = None
|
|
66
|
+
path: Path | None = file
|
|
67
|
+
if file is not None and str(file) == STDIN:
|
|
68
|
+
path = None
|
|
69
|
+
stdin = read_stdin()
|
|
70
|
+
|
|
71
|
+
result = import_spec(
|
|
72
|
+
base_url=ctx.settings.base_url,
|
|
73
|
+
workspace=ctx.settings.workspace,
|
|
74
|
+
path=path,
|
|
75
|
+
stdin=stdin,
|
|
76
|
+
spec_url=url,
|
|
77
|
+
name=name,
|
|
78
|
+
prompt_for_name=lambda: prompts.text(
|
|
79
|
+
None, prompt="Name for the new collection", flag="--name", ctx=ctx
|
|
80
|
+
),
|
|
81
|
+
collection=ctx.settings.collection,
|
|
82
|
+
update=update,
|
|
83
|
+
spec_format=format_,
|
|
84
|
+
dry_run=dry_run,
|
|
85
|
+
)
|
|
86
|
+
ctx.out.result(result)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_stdin() -> bytes:
|
|
90
|
+
"""The spec piped in. Commands own the stdin boundary; nothing below them
|
|
91
|
+
reads a stream."""
|
|
92
|
+
import sys
|
|
93
|
+
|
|
94
|
+
return sys.stdin.buffer.read()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from elva_cli.context import get_ctx
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(name="whoami", help="Show who you're signed in as.", add_completion=False)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command()
|
|
11
|
+
def whoami(click_ctx: typer.Context) -> None:
|
|
12
|
+
"""Show the email (and, for a personal access token, the scoped company)
|
|
13
|
+
the current credentials belong to."""
|
|
14
|
+
from elva_cli.core.services.whoami import whoami as whoami_service
|
|
15
|
+
|
|
16
|
+
ctx = get_ctx(click_ctx)
|
|
17
|
+
result = whoami_service(base_url=ctx.settings.base_url)
|
|
18
|
+
ctx.out.result(result)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""The HTTP calls the CLI makes, and the one place a status becomes an error.
|
|
2
|
+
|
|
3
|
+
Not the generated client this package's docstring describes -- that arrives
|
|
4
|
+
with its own ticket. This is the minimum that stops every new command
|
|
5
|
+
hand-rolling urllib and its own status mapping.
|
|
6
|
+
|
|
7
|
+
Callers get HttpError for a rejected request and map the codes they have
|
|
8
|
+
something specific to say about; `default_error` handles the rest.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from elva_cli.errors import ApiError, AuthError, ElvaError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import urllib.error
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
|
|
22
|
+
DEFAULT_TIMEOUT = 30.0
|
|
23
|
+
UNEXPECTED_RESPONSE = "The server returned an unexpected response."
|
|
24
|
+
REDIRECTED = "The server redirected the request. Check the configured base URL."
|
|
25
|
+
_REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HttpError(Exception):
|
|
29
|
+
"""A non-2xx response, with whatever explanation the server sent."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, status: int, detail: str | None) -> None:
|
|
32
|
+
super().__init__(f"HTTP {status}")
|
|
33
|
+
self.status = status
|
|
34
|
+
self.detail = detail
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_error(error: HttpError, *, action: str) -> ElvaError:
|
|
38
|
+
"""The mapping every caller shares. `action` completes "... failed"."""
|
|
39
|
+
if error.status in (401, 403):
|
|
40
|
+
return AuthError("Your credentials are no longer valid.")
|
|
41
|
+
return ApiError(f"{action} failed (HTTP {error.status}).")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_json(url: str, *, token: str, timeout: float = DEFAULT_TIMEOUT) -> Any:
|
|
45
|
+
return _send(url, token=token, method="GET", timeout=timeout)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def send_json(
|
|
49
|
+
url: str,
|
|
50
|
+
*,
|
|
51
|
+
token: str,
|
|
52
|
+
method: str,
|
|
53
|
+
payload: Mapping[str, Any],
|
|
54
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
55
|
+
) -> Any:
|
|
56
|
+
return _send(
|
|
57
|
+
url,
|
|
58
|
+
token=token,
|
|
59
|
+
method=method,
|
|
60
|
+
body=json.dumps(payload).encode("utf-8"),
|
|
61
|
+
content_type="application/json",
|
|
62
|
+
timeout=timeout,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def send_form(
|
|
67
|
+
url: str,
|
|
68
|
+
*,
|
|
69
|
+
token: str,
|
|
70
|
+
method: str,
|
|
71
|
+
fields: Mapping[str, str],
|
|
72
|
+
field: str | None = None,
|
|
73
|
+
filename: str | None = None,
|
|
74
|
+
data: bytes | None = None,
|
|
75
|
+
content_type: str | None = None,
|
|
76
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
77
|
+
) -> Any:
|
|
78
|
+
"""A multipart/form-data request: text fields, plus at most one file."""
|
|
79
|
+
body, boundary_type = _multipart(
|
|
80
|
+
fields=fields,
|
|
81
|
+
field=field,
|
|
82
|
+
filename=filename,
|
|
83
|
+
data=data,
|
|
84
|
+
content_type=content_type,
|
|
85
|
+
)
|
|
86
|
+
return _send(
|
|
87
|
+
url, token=token, method=method, body=body, content_type=boundary_type, timeout=timeout
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _multipart(
|
|
92
|
+
*,
|
|
93
|
+
fields: Mapping[str, str],
|
|
94
|
+
field: str | None,
|
|
95
|
+
filename: str | None,
|
|
96
|
+
data: bytes | None,
|
|
97
|
+
content_type: str | None,
|
|
98
|
+
) -> tuple[bytes, str]:
|
|
99
|
+
"""Body and Content-Type header for a multipart form.
|
|
100
|
+
|
|
101
|
+
Hand-rolled to keep the dependency list flat. If this ever needs more than
|
|
102
|
+
one file part, or streaming, reach for httpx instead of growing it.
|
|
103
|
+
"""
|
|
104
|
+
import secrets
|
|
105
|
+
|
|
106
|
+
boundary = f"----elva{secrets.token_hex(16)}"
|
|
107
|
+
parts: list[bytes] = []
|
|
108
|
+
|
|
109
|
+
for name, value in fields.items():
|
|
110
|
+
parts += [
|
|
111
|
+
f"--{boundary}".encode(),
|
|
112
|
+
f'Content-Disposition: form-data; name="{_header_safe(name)}"'.encode(),
|
|
113
|
+
b"",
|
|
114
|
+
value.encode("utf-8"),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
if field is not None and data is not None:
|
|
118
|
+
parts += [
|
|
119
|
+
f"--{boundary}".encode(),
|
|
120
|
+
(
|
|
121
|
+
f'Content-Disposition: form-data; name="{_header_safe(field)}"; '
|
|
122
|
+
f'filename="{_header_safe(filename or "spec")}"'
|
|
123
|
+
).encode(),
|
|
124
|
+
f"Content-Type: {content_type or 'application/octet-stream'}".encode(),
|
|
125
|
+
b"",
|
|
126
|
+
data,
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
parts += [f"--{boundary}--".encode(), b""]
|
|
130
|
+
return b"\r\n".join(parts), f"multipart/form-data; boundary={boundary}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _header_safe(value: str) -> str:
|
|
134
|
+
"""A quote or newline would otherwise break out of the part header."""
|
|
135
|
+
return value.translate({ord(c): "_" for c in '"\\\r\n'})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_OPENER: Any = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _opener() -> Any:
|
|
142
|
+
"""An opener that refuses redirects instead of following them.
|
|
143
|
+
|
|
144
|
+
urllib's default handler would replay the request at the new location with
|
|
145
|
+
our `Authorization: Bearer` still attached -- to another host, if that is
|
|
146
|
+
where it points -- and on 301/302/303 it rewrites POST/PATCH to GET, which
|
|
147
|
+
drops the upload body and surfaces only as an unexpected response. Neither
|
|
148
|
+
is ours to do on the caller's behalf, so a redirect becomes an error.
|
|
149
|
+
"""
|
|
150
|
+
global _OPENER
|
|
151
|
+
if _OPENER is None:
|
|
152
|
+
import urllib.request
|
|
153
|
+
|
|
154
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
155
|
+
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
159
|
+
return _OPENER
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _send(
|
|
163
|
+
url: str,
|
|
164
|
+
*,
|
|
165
|
+
token: str,
|
|
166
|
+
method: str,
|
|
167
|
+
body: bytes | None = None,
|
|
168
|
+
content_type: str | None = None,
|
|
169
|
+
timeout: float,
|
|
170
|
+
) -> Any:
|
|
171
|
+
import urllib.error
|
|
172
|
+
import urllib.request
|
|
173
|
+
|
|
174
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
175
|
+
if content_type is not None:
|
|
176
|
+
headers["Content-Type"] = content_type
|
|
177
|
+
|
|
178
|
+
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
179
|
+
try:
|
|
180
|
+
with _opener().open(request, timeout=timeout) as response:
|
|
181
|
+
raw = response.read()
|
|
182
|
+
except urllib.error.HTTPError as exc:
|
|
183
|
+
if exc.code in _REDIRECT_CODES:
|
|
184
|
+
raise ApiError(REDIRECTED) from exc
|
|
185
|
+
raise HttpError(exc.code, _detail(exc)) from exc
|
|
186
|
+
except OSError as exc:
|
|
187
|
+
# URLError and TimeoutError are both OSError, but a connection reset
|
|
188
|
+
# part-way through reading the response is neither -- it arrives raw
|
|
189
|
+
# from the socket, and catching only those two lets it out as an
|
|
190
|
+
# unhandled traceback under exit 1 instead of a reachability failure.
|
|
191
|
+
raise ApiError("Could not reach the server.") from exc
|
|
192
|
+
|
|
193
|
+
if not raw:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
return json.loads(raw)
|
|
197
|
+
except json.JSONDecodeError as exc:
|
|
198
|
+
raise ApiError(UNEXPECTED_RESPONSE) from exc
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _detail(exc: urllib.error.HTTPError) -> str | None:
|
|
202
|
+
"""The server's own explanation, when it sends one worth showing."""
|
|
203
|
+
try:
|
|
204
|
+
payload = json.loads(exc.read())
|
|
205
|
+
except (OSError, ValueError):
|
|
206
|
+
return None
|
|
207
|
+
if not isinstance(payload, dict):
|
|
208
|
+
return None
|
|
209
|
+
for key in ("message", "error", "detail"):
|
|
210
|
+
value = payload.get(key)
|
|
211
|
+
if isinstance(value, str) and value.strip():
|
|
212
|
+
return value.strip()
|
|
213
|
+
return None
|