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.
Files changed (82) hide show
  1. {elva_cli-0.3.0 → elva_cli-0.4.0}/PKG-INFO +119 -3
  2. {elva_cli-0.3.0 → elva_cli-0.4.0}/README.md +115 -2
  3. {elva_cli-0.3.0 → elva_cli-0.4.0}/pyproject.toml +6 -0
  4. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/_version.py +2 -2
  5. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/auth/__init__.py +20 -3
  6. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/auth/session.py +15 -0
  7. elva_cli-0.4.0/src/elva_cli/commands/import_.py +94 -0
  8. elva_cli-0.4.0/src/elva_cli/commands/whoami.py +18 -0
  9. elva_cli-0.4.0/src/elva_cli/core/api/http.py +213 -0
  10. elva_cli-0.4.0/src/elva_cli/core/api/targets.py +132 -0
  11. elva_cli-0.4.0/src/elva_cli/core/services/import_result.py +56 -0
  12. elva_cli-0.4.0/src/elva_cli/core/services/import_spec.py +493 -0
  13. elva_cli-0.4.0/src/elva_cli/core/services/whoami.py +80 -0
  14. elva_cli-0.4.0/src/elva_cli/core/services/whoami_result.py +12 -0
  15. elva_cli-0.4.0/src/elva_cli/core/spec/detect.py +118 -0
  16. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/registry.py +2 -0
  17. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/__init__.py +2 -0
  18. elva_cli-0.4.0/src/elva_cli/ui/renderables/import_spec.py +92 -0
  19. elva_cli-0.4.0/src/elva_cli/ui/renderables/whoami.py +16 -0
  20. elva_cli-0.4.0/tests/cli/conftest.py +66 -0
  21. elva_cli-0.4.0/tests/cli/test_auth_logout_command.py +19 -0
  22. elva_cli-0.4.0/tests/cli/test_import_command.py +146 -0
  23. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/cli/test_never_blocks.py +1 -1
  24. elva_cli-0.4.0/tests/cli/test_whoami_command.py +17 -0
  25. elva_cli-0.4.0/tests/unit/test_http.py +188 -0
  26. elva_cli-0.4.0/tests/unit/test_import_flow.py +753 -0
  27. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_login_flow.py +1 -12
  28. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_output.py +73 -0
  29. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_session.py +24 -0
  30. elva_cli-0.4.0/tests/unit/test_spec_detect.py +175 -0
  31. elva_cli-0.4.0/tests/unit/test_targets.py +147 -0
  32. elva_cli-0.4.0/tests/unit/test_whoami_flow.py +166 -0
  33. elva_cli-0.3.0/tests/cli/test_auth_logout_command.py +0 -52
  34. {elva_cli-0.3.0 → elva_cli-0.4.0}/.gitignore +0 -0
  35. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/__init__.py +0 -0
  36. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/__main__.py +0 -0
  37. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/auth/models.py +0 -0
  38. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/auth/store.py +0 -0
  39. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/commands/__init__.py +0 -0
  40. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/commands/auth.py +0 -0
  41. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/commands/config.py +0 -0
  42. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/context.py +0 -0
  43. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/__init__.py +0 -0
  44. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/api/__init__.py +0 -0
  45. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/services/__init__.py +0 -0
  46. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/services/auth.py +0 -0
  47. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/services/auth_result.py +0 -0
  48. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/services/config.py +0 -0
  49. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/core/spec/__init__.py +0 -0
  50. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/errors.py +0 -0
  51. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/logging.py +0 -0
  52. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/main.py +0 -0
  53. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/settings/__init__.py +0 -0
  54. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/settings/loader.py +0 -0
  55. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/settings/models.py +0 -0
  56. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/settings/paths.py +0 -0
  57. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/telemetry.py +0 -0
  58. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/__init__.py +0 -0
  59. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/console.py +0 -0
  60. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/output.py +0 -0
  61. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/prompts.py +0 -0
  62. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/auth.py +0 -0
  63. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/base.py +0 -0
  64. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/renderables/config.py +0 -0
  65. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/theme.py +0 -0
  66. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/ui/views/__init__.py +0 -0
  67. {elva_cli-0.3.0 → elva_cli-0.4.0}/src/elva_cli/update.py +0 -0
  68. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/cli/test_cli_exit_codes.py +0 -0
  69. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/cli/test_config_command.py +0 -0
  70. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/cli/test_lazy_imports.py +0 -0
  71. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/cli/test_output_streams.py +0 -0
  72. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_boundary.py +0 -0
  73. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_context.py +0 -0
  74. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_credentials.py +0 -0
  75. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_error_boundary.py +0 -0
  76. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_errors.py +0 -0
  77. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_exit_codes.py +0 -0
  78. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_interactivity.py +0 -0
  79. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_logout_flow.py +0 -0
  80. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_prompts.py +0 -0
  81. {elva_cli-0.3.0 → elva_cli-0.4.0}/tests/unit/test_settings_loader.py +0 -0
  82. {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.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. This release ships
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. This release ships
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.3.0'
22
- __version_tuple__ = version_tuple = (0, 3, 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 ENV_TOKEN, get_access_token, logout, save_login, save_pat
21
-
22
- __all__ = ["ENV_TOKEN", "Credentials", "get_access_token", "logout", "save_login", "save_pat"]
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