elva-cli 0.0.3__tar.gz → 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of elva-cli might be problematic. Click here for more details.

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