avr-cli 0.1.0__py3-none-any.whl

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.
avrea_cli/click_ext.py ADDED
@@ -0,0 +1,261 @@
1
+ """Click extensions for sectioned help formatting and command aliases."""
2
+
3
+ from contextlib import contextmanager
4
+ import click
5
+ import importlib
6
+
7
+ SECTION_ORDER = ["Core Commands", "Setup & Config", "Additional Commands"]
8
+
9
+ LEARN_MORE = 'Use "avr <command> <subcommand> --help" for more information about a command.\nRead the docs at https://docs.avrea.com/cli'
10
+
11
+
12
+ def _heading(text: str) -> str:
13
+ """Bold-uppercase section heading. ANSI codes are stripped automatically
14
+ by click.echo when stdout is not a TTY (e.g., piped to less)."""
15
+ return click.style(text.upper(), bold=True)
16
+
17
+
18
+ def _write_section(formatter: click.HelpFormatter, heading: str, body_fn) -> None:
19
+ """Write a section with a bold uppercase heading (no trailing colon)."""
20
+ formatter.write(f"\n{_heading(heading)}\n")
21
+ formatter.indent()
22
+ body_fn()
23
+ formatter.dedent()
24
+
25
+
26
+ def _write_text_section(formatter: click.HelpFormatter, heading: str, text: str) -> None:
27
+ """Write a section with plain text, properly indented."""
28
+ indent = " " * (formatter.current_indent + 2)
29
+ formatter.write(f"\n{_heading(heading)}\n")
30
+ for line in text.splitlines():
31
+ formatter.write(f"{indent}{line}\n")
32
+
33
+
34
+ class LazyGroup(click.Group):
35
+ """``click.Group`` with lazy command registration.
36
+
37
+ ``add_lazy_command`` records ``name -> (import_path, attr, short_help)`` so
38
+ ``--help`` can render the row from cached metadata without importing the
39
+ implementation module. The module is imported on first ``get_command``
40
+ (i.e. when a user actually invokes the command), at which point the lazy
41
+ spec is consumed and the resolved Click command is registered eagerly.
42
+
43
+ Aliases (``AliasGroup``) sit above this layer and rewrite argv to the
44
+ canonical name before lookup, so they work transparently with lazy entries.
45
+ """
46
+
47
+ def __init__(self, *args, **kwargs):
48
+ super().__init__(*args, **kwargs)
49
+ # name -> (import_path, attr, short_help_or_None).
50
+ self._lazy_specs: dict[str, tuple[str, str, str | None]] = {}
51
+ # name -> section_or_None. Hidden commands carry None.
52
+ self._lazy_section: dict[str, str | None] = {}
53
+
54
+ def add_lazy_command(
55
+ self,
56
+ name: str,
57
+ import_path: str,
58
+ attr: str,
59
+ short_help: str | None,
60
+ section: str | None = None,
61
+ ) -> None:
62
+ """Register a command without importing its module.
63
+
64
+ ``short_help`` is the row text shown in ``--help``; pass ``None`` for
65
+ hidden commands (they're listed for resolution but skipped from help).
66
+ ``section`` defaults to the group's active ``with section(...)`` block,
67
+ matching ``add_command``'s auto-tagging.
68
+ """
69
+ # An eager registration with the same name takes precedence — drop it
70
+ # so this lazy registration wins. Mirrors ``add_command``'s
71
+ # replace-by-name semantics.
72
+ self.commands.pop(name, None)
73
+ self._lazy_specs[name] = (import_path, attr, short_help)
74
+ active = section if section is not None else getattr(self, "_current_section", None)
75
+ self._lazy_section[name] = active
76
+
77
+ def add_command(self, cmd, name=None):
78
+ # Eager registration wins over any pending lazy registration with the
79
+ # same name; drop the lazy entry so help/dispatch see only the new one.
80
+ resolved = name or cmd.name
81
+ self._lazy_specs.pop(resolved, None)
82
+ self._lazy_section.pop(resolved, None)
83
+ super().add_command(cmd, name)
84
+
85
+ def list_commands(self, ctx):
86
+ return sorted({*super().list_commands(ctx), *self._lazy_specs})
87
+
88
+ def get_command(self, ctx, cmd_name):
89
+ spec = self._lazy_specs.pop(cmd_name, None)
90
+ if spec is None:
91
+ return super().get_command(ctx, cmd_name)
92
+ import_path, attr, _ = spec
93
+ section = self._lazy_section.pop(cmd_name, None)
94
+ module = importlib.import_module(import_path)
95
+ cmd = getattr(module, attr)
96
+ # Skip our own ``add_command`` (it would just no-op the lazy popping
97
+ # we already did) and bypass any section auto-tagging by going
98
+ # straight to ``click.Group`` — we apply the cached section
99
+ # explicitly so it survives the load.
100
+ click.Group.add_command(self, cmd, cmd_name)
101
+ if section is not None:
102
+ cmd.help_section = section
103
+ return cmd
104
+
105
+
106
+ class GhHelpMixin:
107
+ """Mixin that reformats Click help output with bold uppercase sections,
108
+ a USAGE/CORE COMMANDS/SETUP & CONFIG/ADDITIONAL COMMANDS layout, and a
109
+ LEARN MORE footer.
110
+
111
+ Must be mixed with click.BaseCommand or a subclass (Group, Command).
112
+ """
113
+
114
+ # The mixin accesses attributes from click.BaseCommand / click.Group.
115
+ # Type checkers can't see these through the mixin, so we annotate access
116
+ # points with type: ignore[attr-defined].
117
+
118
+ def format_help(self, ctx, formatter):
119
+ help_text = self.help # type: ignore[attr-defined]
120
+ if help_text:
121
+ formatter.write(f"{help_text}\n")
122
+
123
+ pieces = self.collect_usage_pieces(ctx) # type: ignore[attr-defined]
124
+ usage_line = f"{ctx.command_path} {' '.join(pieces)}"
125
+ _write_text_section(formatter, "USAGE", usage_line)
126
+
127
+ self.format_commands(ctx, formatter) # type: ignore[attr-defined]
128
+ self.format_options(ctx, formatter)
129
+
130
+ _write_text_section(formatter, "LEARN MORE", LEARN_MORE)
131
+
132
+ def format_options(self, ctx, formatter):
133
+ """Render options as FLAGS / INHERITED FLAGS."""
134
+ opts = []
135
+ for param in self.get_params(ctx): # type: ignore[attr-defined]
136
+ rv = param.get_help_record(ctx)
137
+ if rv is not None:
138
+ opts.append(rv)
139
+
140
+ if opts:
141
+ inherited = [(n, h) for n, h in opts if n.strip().startswith("--help")]
142
+ own = [(n, h) for n, h in opts if not n.strip().startswith("--help")]
143
+
144
+ if own:
145
+ _write_section(formatter, "FLAGS", lambda: formatter.write_dl(own))
146
+
147
+ if inherited:
148
+ _write_section(formatter, "INHERITED FLAGS", lambda: formatter.write_dl(inherited))
149
+
150
+
151
+ class AliasGroup(GhHelpMixin, LazyGroup):
152
+ """Root CLI group with sectioned help, plural-to-singular aliases, and grouped commands.
153
+
154
+ ``section_order`` is the list of section headings rendered, top to bottom.
155
+ Commands tag themselves with ``cmd.help_section = "<heading>"``; commands
156
+ without a tag are silently omitted from --help (so registering a new
157
+ command without picking a section is loud — you notice immediately).
158
+ Defaults to the public-CLI section list; downstream CLIs (e.g. avr-admin)
159
+ can pass their own.
160
+ """
161
+
162
+ def __init__(self, *args, aliases=None, section_order=None, **kwargs):
163
+ super().__init__(*args, **kwargs)
164
+ self._aliases = aliases or {}
165
+ self._section_order = list(section_order) if section_order is not None else SECTION_ORDER
166
+ self._current_section: str | None = None
167
+
168
+ @contextmanager
169
+ def section(self, name: str):
170
+ """Tag every command registered inside the ``with`` block with ``name``.
171
+
172
+ Lets the caller register commands in batches without re-listing the
173
+ names afterwards just to assign ``help_section``::
174
+
175
+ with cli.section("Public Commands"):
176
+ cli.add_command(auth_group, name="auth")
177
+ cli.add_command(jobs)
178
+
179
+ with cli.section("Admin Commands"):
180
+ cli.add_command(ai)
181
+
182
+ Nests cleanly — outer section is restored on exit.
183
+ """
184
+ prev = self._current_section
185
+ self._current_section = name
186
+ try:
187
+ yield
188
+ finally:
189
+ self._current_section = prev
190
+
191
+ def add_command(self, cmd, name=None):
192
+ super().add_command(cmd, name)
193
+ if self._current_section is not None:
194
+ # Auto-tag with the active section. We always overwrite — when
195
+ # the same command object is registered to two CLIs (avr-admin
196
+ # inheriting from avr) each registration's section context wins
197
+ # for its own group. Different processes anyway, so cross-CLI
198
+ # state collisions aren't a real concern.
199
+ cmd.help_section = self._current_section
200
+
201
+ def get_command(self, ctx, cmd_name):
202
+ rv = super().get_command(ctx, cmd_name)
203
+ if rv is not None:
204
+ return rv
205
+ canonical = self._aliases.get(cmd_name)
206
+ if canonical:
207
+ return super().get_command(ctx, canonical)
208
+ return None
209
+
210
+ def resolve_command(self, ctx, args):
211
+ cmd_name = args[0] if args else None
212
+ if cmd_name in self._aliases:
213
+ args = [self._aliases[cmd_name], *args[1:]]
214
+ return super().resolve_command(ctx, args)
215
+
216
+ def format_commands(self, ctx, formatter):
217
+ commands_by_section: dict[str, list[tuple[str, str]]] = {}
218
+ for name in self.list_commands(ctx):
219
+ # Lazy entries render straight from cached metadata — never import.
220
+ if name in self._lazy_specs:
221
+ short = self._lazy_specs[name][2]
222
+ section = self._lazy_section.get(name)
223
+ if short is None or section is None:
224
+ continue
225
+ else:
226
+ cmd = self.get_command(ctx, name)
227
+ if cmd is None or cmd.hidden:
228
+ continue
229
+ section = getattr(cmd, "help_section", None)
230
+ if section is None:
231
+ continue
232
+ short = cmd.get_short_help_str(limit=formatter.width)
233
+ commands_by_section.setdefault(section, []).append((name, short))
234
+
235
+ for section in self._section_order:
236
+ cmds = commands_by_section.get(section)
237
+ if not cmds:
238
+ continue
239
+ rows = [(f"{name}:", short) for name, short in cmds]
240
+ _write_section(formatter, section, lambda r=rows: formatter.write_dl(r))
241
+
242
+
243
+ class GhGroup(GhHelpMixin, LazyGroup):
244
+ """Command group with sectioned help (no aliases, no command-grouping)."""
245
+
246
+ def format_commands(self, ctx, formatter):
247
+ rows: list[tuple[str, str]] = []
248
+ for name in self.list_commands(ctx):
249
+ if name in self._lazy_specs:
250
+ short = self._lazy_specs[name][2]
251
+ if short is None:
252
+ continue
253
+ rows.append((f"{name}:", short))
254
+ continue
255
+ cmd = self.get_command(ctx, name)
256
+ if cmd is None or cmd.hidden:
257
+ continue
258
+ rows.append((f"{name}:", cmd.get_short_help_str(limit=formatter.width)))
259
+
260
+ if rows:
261
+ _write_section(formatter, "AVAILABLE COMMANDS", lambda: formatter.write_dl(rows))
@@ -0,0 +1 @@
1
+ """CLI command modules."""
@@ -0,0 +1,296 @@
1
+ """Authentication CLI commands (avr auth login/logout/status)."""
2
+
3
+ from avrea_cli import auth
4
+ from avrea_cli.api_client import ApiClient
5
+ from avrea_cli.click_ext import GhGroup
6
+ from avrea_cli.config import CliConfig
7
+ from avrea_cli.helpers import ensure_authenticated
8
+ from avrea_cli.helpers import ensure_ctx
9
+ from avrea_cli.helpers import handle_http_error
10
+ from avrea_cli.json_output import emit_json_record
11
+ from avrea_cli.json_output import handle_json_meta
12
+ from avrea_cli.json_output import json_options
13
+ from avrea_cli.json_output import make_schema
14
+ from avrea_cli.json_output import split_fields
15
+ from avrea_cli.output import format_key_value
16
+ import click
17
+ import httpx
18
+ import sys
19
+
20
+ # `host`/`default_org`/`token` come from local config, not the API response.
21
+ # The handler injects them under `_local:*` keys before projection so the
22
+ # schema stays a flat wire-name → path map (matching every other command).
23
+ _AUTH_STATUS_FIELDS = make_schema(
24
+ "email",
25
+ "name",
26
+ "created_at",
27
+ user_id="id",
28
+ host="_local:host",
29
+ default_org="_local:default_org",
30
+ token="_local:token",
31
+ )
32
+
33
+
34
+ def _fetch_email(api_url: str, api_key: str) -> str | None:
35
+ """Best-effort: read the just-issued key's owner so we can show
36
+ 'Logged in as <email>'. Failures are silent — the key is already stored."""
37
+ try:
38
+ with httpx.Client(timeout=5.0) as c:
39
+ r = c.get(f"{api_url}/users/me", headers={"Authorization": f"Bearer {api_key}"})
40
+ r.raise_for_status()
41
+ return r.json().get("email")
42
+ except httpx.HTTPError, ValueError, KeyError:
43
+ return None
44
+
45
+
46
+ def _maybe_auto_pin_default_org(api_url: str, api_key: str) -> tuple[str, str] | None:
47
+ """If the user belongs to exactly one organization, store it as the
48
+ default for this host so subsequent commands don't have to re-resolve.
49
+
50
+ Returns (slug, org_id) when an org was pinned, None otherwise (zero
51
+ orgs, multiple orgs, transport failure). Multi-org users still pick
52
+ explicitly via ``avr config set org`` — auto-picking would silently
53
+ bias every command toward an arbitrary org."""
54
+ try:
55
+ with httpx.Client(timeout=5.0) as c:
56
+ r = c.get(f"{api_url}/users/me/organizations", headers={"Authorization": f"Bearer {api_key}"})
57
+ r.raise_for_status()
58
+ data = r.json().get("data") or []
59
+ except httpx.HTTPError, ValueError, KeyError:
60
+ return None
61
+ if len(data) != 1:
62
+ return None
63
+ org_id = data[0].get("organization_id")
64
+ slug = data[0].get("slug") or org_id
65
+ if not org_id:
66
+ return None
67
+ return slug, org_id
68
+
69
+
70
+ def _do_login(ctx, provider):
71
+ config: CliConfig = ctx.obj["config"]
72
+ try:
73
+ api_key = auth.login(config.public_api_url, provider=provider)
74
+ except click.ClickException as exc:
75
+ # Click's exception carries a useful `.message`; surface it under a
76
+ # consistent "Login failed:" prefix and exit 1.
77
+ click.echo(f"Login failed: {exc.format_message()}", err=True)
78
+ sys.exit(1)
79
+ # click.Abort is intentionally not caught here — auth.login already
80
+ # printed a context-specific error to stderr before raising it. Letting
81
+ # it propagate hands control back to Click's runner, which exits cleanly
82
+ # without our generic "Login failed:" prefix duplicating the message.
83
+
84
+ auth.store_token(api_key, host=config.public_api_url)
85
+
86
+ check = click.style("✓", fg="green")
87
+ email = _fetch_email(config.public_api_url, api_key)
88
+ if email:
89
+ click.echo(f"{check} Logged in as {click.style(email, bold=True)}")
90
+ else:
91
+ click.echo(f"{check} Authentication complete.")
92
+ click.echo(f" Credentials saved to {auth.HOSTS_FILE}")
93
+
94
+ # Auto-pin only fires for single-org users — see _maybe_auto_pin_default_org.
95
+ if not auth.load_default_org(host=config.public_api_url):
96
+ pinned = _maybe_auto_pin_default_org(config.public_api_url, api_key)
97
+ if pinned:
98
+ slug, org_id = pinned
99
+ auth.store_default_org(org_id, host=config.public_api_url)
100
+ click.echo(f" Default organization: {click.style(slug, bold=True)} ({org_id})")
101
+
102
+ click.echo()
103
+ click.echo("Try: avr status")
104
+
105
+
106
+ def _do_logout(ctx):
107
+ client: ApiClient = ctx.obj["client"]
108
+ config: CliConfig = ctx.obj["config"]
109
+
110
+ if config.auth_token:
111
+ try:
112
+ client.public_delete("/users/me/api-keys/current")
113
+ except httpx.HTTPStatusError:
114
+ click.echo("Warning: Could not revoke API key on server.", err=True)
115
+ except httpx.ConnectError, httpx.TimeoutException:
116
+ click.echo("Warning: Could not reach server to revoke API key.", err=True)
117
+
118
+ cleared = auth.clear(host=config.public_api_url)
119
+ check = click.style("✓", fg="green")
120
+ if cleared:
121
+ click.echo(f"{check} Logged out.")
122
+ else:
123
+ click.echo("No stored credentials found.")
124
+
125
+
126
+ @click.group(cls=GhGroup)
127
+ @click.pass_context
128
+ def auth_group(ctx):
129
+ """Authenticate and manage credentials."""
130
+ ensure_ctx(ctx)
131
+
132
+
133
+ @auth_group.command("login")
134
+ @click.option(
135
+ "--provider",
136
+ type=click.Choice(["google", "github"], case_sensitive=False),
137
+ default="github",
138
+ show_default=True,
139
+ help="OAuth provider to use for CLI login.",
140
+ )
141
+ @click.pass_context
142
+ def auth_login(ctx, provider: str):
143
+ """Authenticate via browser and store credentials."""
144
+ _do_login(ctx, provider)
145
+
146
+
147
+ @auth_group.command("logout")
148
+ @click.pass_context
149
+ def auth_logout(ctx):
150
+ """Revoke the current API key and remove stored credentials."""
151
+ _do_logout(ctx)
152
+
153
+
154
+ @auth_group.command("switch")
155
+ @click.argument("host", required=False)
156
+ @click.pass_context
157
+ def auth_switch(ctx, host: str | None):
158
+ """Switch the default host used when AVR_HOST isn't set.
159
+
160
+ \b
161
+ Examples:
162
+ avr auth switch # show current default + all hosts
163
+ avr auth switch https://api.avrea.com
164
+ """
165
+ ensure_ctx(ctx)
166
+ hosts = auth.list_hosts()
167
+ if not hosts:
168
+ click.echo("No stored credentials. Run `avr auth login` first.", err=True)
169
+ sys.exit(1)
170
+
171
+ current = auth.load_default_host()
172
+ if host is None:
173
+ # Bare `auth switch` lists what's available so the user can pick.
174
+ # Stdout gets bare hostnames so `avr auth switch | fzf` works; the
175
+ # header, default marker, and pick hint go to stderr.
176
+ if sys.stdout.isatty():
177
+ click.echo("Stored hosts:", err=True)
178
+ for h in hosts:
179
+ click.echo(h)
180
+ if h == current and sys.stdout.isatty():
181
+ click.echo(click.style(f" (default: {h})", fg="green"), err=True)
182
+ click.echo(f"\nPass one of these to switch, e.g. `avr auth switch {hosts[0]}`.", err=True)
183
+ return
184
+
185
+ target = host.rstrip("/")
186
+ if target not in hosts:
187
+ click.echo(f"Error: no stored credentials for host {target!r}.", err=True)
188
+ click.echo("Available hosts:", err=True)
189
+ for h in hosts:
190
+ click.echo(f" {h}", err=True)
191
+ sys.exit(1)
192
+
193
+ auth.set_default_host(target)
194
+ check = click.style("✓", fg="green")
195
+ click.echo(f"{check} Default host set to {click.style(target, bold=True)}")
196
+
197
+
198
+ @auth_group.command("status")
199
+ @click.option("--show-token", is_flag=True, help="Display the auth token in plain text.")
200
+ @json_options
201
+ @click.pass_context
202
+ def auth_status(ctx, show_token: bool, json_fields, jq_expr):
203
+ """Display the authenticated user and connection state."""
204
+ if handle_json_meta(json_fields, jq_expr, _AUTH_STATUS_FIELDS):
205
+ return
206
+
207
+ client: ApiClient = ctx.obj["client"]
208
+ config: CliConfig = ctx.obj["config"]
209
+
210
+ ensure_authenticated(config)
211
+
212
+ try:
213
+ result = client.public_get("/users/me")
214
+ except httpx.HTTPStatusError as exc:
215
+ # handle_http_error short-circuits 401 to the same auth hint, and
216
+ # surfaces other statuses with detail-aware framing.
217
+ handle_http_error(exc, "fetch user info")
218
+
219
+ # `.get()` for every field — a partial /users/me response (schema drift,
220
+ # degraded backend) should yield a missing-field message, not a KeyError
221
+ # traceback dumped at the user.
222
+
223
+ if json_fields is not None:
224
+ # Build a synthetic record so the schema can mix server fields with
225
+ # local config (host, default_org, token). The token field is removed
226
+ # from the schema entirely unless --show-token is set — `--json '*'`
227
+ # then returns only the fields that exist, and `--json token` errors
228
+ # with the available-fields hint, prompting the user to add the flag.
229
+ record: dict[str, object] = dict(result)
230
+ record["_local:host"] = config.public_api_url
231
+ record["_local:default_org"] = config.default_org
232
+ if show_token:
233
+ record["_local:token"] = config.auth_token
234
+ schema = _AUTH_STATUS_FIELDS
235
+ else:
236
+ schema = {k: v for k, v in _AUTH_STATUS_FIELDS.items() if k != "token"}
237
+ emit_json_record(record, split_fields(json_fields, schema), schema, jq_expr)
238
+ return
239
+
240
+ click.echo(
241
+ format_key_value(
242
+ {
243
+ "User ID": result.get("id") or "-",
244
+ "Email": result.get("email") or "-",
245
+ "Name": result.get("name", "N/A"),
246
+ "Host": config.public_api_url,
247
+ "Default org": config.default_org or "(not set)",
248
+ "Created": result.get("created_at") or "-",
249
+ "Token": _format_token(config.auth_token, show_token),
250
+ }
251
+ )
252
+ )
253
+ click.echo()
254
+ click.echo(click.style(" Switch host: avr auth switch <host>", dim=True))
255
+ click.echo(click.style(" Switch default org: avr config set org <slug>", dim=True))
256
+
257
+
258
+ def _format_token(token: str | None, show: bool) -> str:
259
+ """Render the API token for `auth status`. Masks by default; reveals the
260
+ full token only when --show-token is passed."""
261
+ if not token:
262
+ return "(none)"
263
+ if show:
264
+ return token
265
+ # Show enough of the prefix to identify the key family without leaking it.
266
+ # Tokens ≤ 8 chars are out-of-spec (real Avrea API keys are much longer);
267
+ # collapsing all such inputs to a featureless 32-asterisk string is
268
+ # intentional, not a bug — the test suite pins this for safety.
269
+ visible = token[:4] if len(token) > 8 else ""
270
+ return f"{visible}{'*' * 32}" if visible else "*" * 32
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # Top-level aliases (hidden, for backward compat)
275
+ # ---------------------------------------------------------------------------
276
+
277
+
278
+ @click.command("login", hidden=True)
279
+ @click.option(
280
+ "--provider",
281
+ type=click.Choice(["google", "github"], case_sensitive=False),
282
+ default="github",
283
+ show_default=True,
284
+ help="OAuth provider to use for CLI login.",
285
+ )
286
+ @click.pass_context
287
+ def login_alias(ctx, provider: str):
288
+ """Authenticate via browser and store credentials."""
289
+ _do_login(ctx, provider)
290
+
291
+
292
+ @click.command("logout", hidden=True)
293
+ @click.pass_context
294
+ def logout_alias(ctx):
295
+ """Revoke the current API key and remove stored credentials."""
296
+ _do_logout(ctx)