looky-cli 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.
@@ -0,0 +1,33 @@
1
+ # Runtime-only source config and secrets
2
+ workspaces/**/runtime/sources.runtime.yml
3
+ workspaces/**/secrets/
4
+ ops/secrets/
5
+ # CLI local workspaces — runtime and secrets are push-only, never commit
6
+ cli/workspaces/*/runtime/sources.runtime.yml
7
+ cli/workspaces/*/secrets/
8
+
9
+ # Local environment files
10
+ .env
11
+ .env.*
12
+ !.env.example
13
+
14
+ # Generated artifacts
15
+ .DS_Store
16
+ __pycache__/
17
+ cli/.venv/
18
+ workspaces/**/.bk/
19
+ *.pyc
20
+ *.swp
21
+ app/__pycache__/
22
+ query-engine/node_modules/
23
+ app/node_modules/
24
+ docs-site/site/
25
+
26
+ # Optional local cache files
27
+ cache/
28
+ *.duckdb
29
+ *.duckdb.wal
30
+ workspaces/**/runtime/cache/
31
+ workspaces/**/runtime/exports/
32
+ workspaces/**/tmp/
33
+ workspaces/**/content/models/.bak/
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: looky-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for publishing content to a looky instance
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: pyyaml>=6
8
+ Requires-Dist: rich>=13
9
+ Requires-Dist: typer>=0.12
File without changes
@@ -0,0 +1,85 @@
1
+ """
2
+ HTTP client for the looky API.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+
11
+ class LookyClientError(Exception):
12
+ def __init__(self, message: str, *, status_code: int = 0):
13
+ super().__init__(message)
14
+ self.status_code = status_code
15
+
16
+
17
+ class LookyClient:
18
+ def __init__(self, *, url: str, token: str | None = None, timeout: float = 30.0):
19
+ self.base_url = url.rstrip("/")
20
+ self.token = token
21
+ self.timeout = timeout
22
+
23
+ def _headers(self) -> dict[str, str]:
24
+ headers: dict[str, str] = {"Content-Type": "application/json"}
25
+ if self.token:
26
+ headers["Authorization"] = f"Bearer {self.token}"
27
+ return headers
28
+
29
+ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
30
+ url = f"{self.base_url}{path}"
31
+ try:
32
+ resp = httpx.request(
33
+ method,
34
+ url,
35
+ headers=self._headers(),
36
+ timeout=self.timeout,
37
+ **kwargs,
38
+ )
39
+ except httpx.ConnectError as exc:
40
+ raise LookyClientError(f"Cannot connect to {self.base_url}: {exc}") from exc
41
+ except httpx.TimeoutException as exc:
42
+ raise LookyClientError(f"Request timed out: {exc}") from exc
43
+ except httpx.RequestError as exc:
44
+ raise LookyClientError(f"Request error: {exc}") from exc
45
+
46
+ try:
47
+ data = resp.json()
48
+ except Exception:
49
+ data = {}
50
+
51
+ if not resp.is_success:
52
+ msg = (data.get("error") if isinstance(data, dict) else None) or resp.reason_phrase or "request failed"
53
+ raise LookyClientError(str(msg), status_code=resp.status_code)
54
+
55
+ return data if isinstance(data, dict) else {}
56
+
57
+ def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
58
+ return self._request("GET", path, **kwargs)
59
+
60
+ def post(self, path: str, json: dict[str, Any] | None = None, **kwargs: Any) -> dict[str, Any]:
61
+ return self._request("POST", path, json=json, **kwargs)
62
+
63
+ def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
64
+ return self._request("DELETE", path, **kwargs)
65
+
66
+ def otp_start(self, email: str) -> dict[str, Any]:
67
+ return self.post("/api/auth/otp/email/start", json={"email": email, "expiration_minutes": 10})
68
+
69
+ def otp_authenticate(self, *, method_id: str, email: str, code: str) -> dict[str, Any]:
70
+ return self.post(
71
+ "/api/auth/otp/email/authenticate",
72
+ json={
73
+ "method_id": method_id,
74
+ "email": email,
75
+ "code": code,
76
+ "session_duration_minutes": 60,
77
+ },
78
+ params={"mode": "cli"},
79
+ )
80
+
81
+ def whoami(self) -> dict[str, Any]:
82
+ return self.get("/api/cli/whoami")
83
+
84
+ def revoke_token(self) -> dict[str, Any]:
85
+ return self.delete("/api/cli/token")
File without changes
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from looky_cli import config as cfg
10
+ from looky_cli.client import LookyClient, LookyClientError
11
+ from looky_cli.commands.billing import relogin_command, require_root_context
12
+
13
+ console = Console()
14
+ app = typer.Typer(help="Authenticate with a looky instance.")
15
+
16
+
17
+ @app.command("login")
18
+ def login(
19
+ url: str = typer.Argument(..., help="Instance URL (e.g. https://looky.example.com)"),
20
+ root_path: Path = typer.Argument(..., help="Local root path linked to this instance"),
21
+ email: str = typer.Option(None, "--email", "-e", help="Email address registered in this looky instance"),
22
+ ) -> None:
23
+ """Log in to a looky instance via email OTP."""
24
+ url = url.rstrip("/")
25
+ linked_root = root_path.expanduser().resolve()
26
+ if not linked_root.is_dir():
27
+ console.print(f"[red]Root path not found: [bold]{linked_root}[/bold][/red]")
28
+ raise typer.Exit(1)
29
+
30
+ if not email:
31
+ email = typer.prompt("Email")
32
+ email = email.strip().lower()
33
+
34
+ client = LookyClient(url=url)
35
+
36
+ console.print(f"Sending verification code to [bold]{email}[/bold]…")
37
+ try:
38
+ start_result = client.otp_start(email)
39
+ except LookyClientError as exc:
40
+ console.print(f"[red]Error: {exc}[/red]")
41
+ raise typer.Exit(1) from exc
42
+
43
+ method_id: str = str(start_result.get("method_id") or "")
44
+ project_type: str = str(start_result.get("project_type") or "consumer")
45
+
46
+ console.print("Check your email for the verification code.")
47
+ code = typer.prompt("Verification code")
48
+ code = code.strip()
49
+
50
+ try:
51
+ auth_result = client.otp_authenticate(
52
+ method_id=method_id,
53
+ email=email if project_type == "b2b" else "",
54
+ code=code,
55
+ )
56
+ except LookyClientError as exc:
57
+ console.print(f"[red]Authentication failed: {exc}[/red]")
58
+ raise typer.Exit(1) from exc
59
+
60
+ cli_token = str(auth_result.get("cli_token") or "")
61
+ cli_token_expires_at = str(auth_result.get("cli_token_expires_at") or "")
62
+ app_user = auth_result.get("app_user") or {}
63
+
64
+ if not cli_token:
65
+ console.print("[red]Server did not return a CLI token. Make sure the server supports CLI auth.[/red]")
66
+ raise typer.Exit(1)
67
+
68
+ cfg.save_session(
69
+ url,
70
+ token=cli_token,
71
+ expires_at=cli_token_expires_at,
72
+ user=app_user,
73
+ )
74
+ try:
75
+ cfg.bind_workspace_root(linked_root, url=url)
76
+ except ValueError as exc:
77
+ console.print(f"[red]{exc}[/red]")
78
+ raise typer.Exit(1) from exc
79
+
80
+ console.print(f"[green]Logged in as [bold]{app_user.get('email', email)}[/bold][/green]")
81
+ console.print(f"[dim]Linked root: [bold]{linked_root}[/bold] -> {url}[/dim]")
82
+ _print_user_table(app_user)
83
+
84
+
85
+ @app.command("logout")
86
+ def logout() -> None:
87
+ """Revoke the CLI token and clear local credentials."""
88
+ root_context = require_root_context(Path.cwd(), exact_root=False)
89
+ url = root_context.url
90
+ token = root_context.token
91
+
92
+ if token and url:
93
+ client = LookyClient(url=url, token=token)
94
+ try:
95
+ client.revoke_token()
96
+ except LookyClientError:
97
+ pass
98
+
99
+ cfg.clear_session(url)
100
+ cfg.clear_root_billing_account_id(root_context.root_path)
101
+ console.print(
102
+ "[green]Logged out from "
103
+ f"[bold]{url}[/bold] for linked root [bold]{root_context.root_path}[/bold].[/green]"
104
+ )
105
+
106
+
107
+ @app.command("whoami")
108
+ def whoami() -> None:
109
+ """Show the currently authenticated user."""
110
+ root_context = require_root_context(Path.cwd(), exact_root=False)
111
+ url = root_context.url
112
+ token = root_context.token
113
+
114
+ if not token:
115
+ console.print(
116
+ "[red]Not logged in for the linked root "
117
+ f"[bold]{root_context.root_path}[/bold]. Run "
118
+ f"[bold]looky login {url} {root_context.root_path}[/bold] first.[/red]"
119
+ )
120
+ raise typer.Exit(1)
121
+
122
+ client = LookyClient(url=url, token=token)
123
+ try:
124
+ result = client.whoami()
125
+ except LookyClientError as exc:
126
+ if exc.status_code == 401:
127
+ console.print(
128
+ "[red]Token expired or revoked. Run "
129
+ f"[bold]{relogin_command(root_context)}[/bold] again.[/red]"
130
+ )
131
+ else:
132
+ console.print(f"[red]Error: {exc}[/red]")
133
+ raise typer.Exit(1) from exc
134
+
135
+ user = result.get("user") or {}
136
+ console.print(
137
+ f"[green]Authenticated on [bold]{url}[/bold] "
138
+ f"for [bold]{root_context.root_path}[/bold][/green]"
139
+ )
140
+ _print_user_table(user)
141
+
142
+
143
+ def _print_user_table(user: dict) -> None:
144
+ t = Table(show_header=False, box=None, padding=(0, 1))
145
+ t.add_column(style="dim")
146
+ t.add_column()
147
+ if user.get("first_name") or user.get("last_name"):
148
+ name = f"{user.get('first_name', '')} {user.get('last_name', '')}".strip()
149
+ t.add_row("name", name)
150
+ if user.get("email"):
151
+ t.add_row("email", str(user["email"]))
152
+ if user.get("role"):
153
+ t.add_row("role", str(user["role"]))
154
+ console.print(t)
@@ -0,0 +1,165 @@
1
+ """Billing account commands for the looky CLI."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from looky_cli import config as cfg
11
+ from looky_cli.client import LookyClient, LookyClientError
12
+ from looky_cli.workspace import RootContext, WorkspaceResolutionError, resolve_root_context
13
+
14
+ console = Console()
15
+ app = typer.Typer(help="Manage billing account context.")
16
+
17
+
18
+ def relogin_command(root_context: RootContext) -> str:
19
+ return f"looky login {root_context.url} {root_context.root_path}"
20
+
21
+
22
+ def require_root_context(path: Path, *, exact_root: bool) -> RootContext:
23
+ try:
24
+ root_context = resolve_root_context(path.resolve())
25
+ except WorkspaceResolutionError as exc:
26
+ console.print(f"[red]{exc}[/red]")
27
+ raise typer.Exit(1) from exc
28
+
29
+ if exact_root and root_context.current_path != root_context.root_path:
30
+ console.print(
31
+ f"[red]This command must be run from the linked root [bold]{root_context.root_path}[/bold].[/red]"
32
+ )
33
+ raise typer.Exit(1)
34
+
35
+ return root_context
36
+
37
+
38
+ def build_authenticated_client(root_context: RootContext) -> LookyClient:
39
+ if not root_context.token:
40
+ console.print(
41
+ "[red]Not logged in for the linked root "
42
+ f"[bold]{root_context.root_path}[/bold]. Run "
43
+ f"[bold]looky login {root_context.url} {root_context.root_path}[/bold] first.[/red]"
44
+ )
45
+ raise typer.Exit(1)
46
+ return LookyClient(url=root_context.url, token=root_context.token)
47
+
48
+
49
+ def require_authenticated_client(path: Path, *, exact_root: bool) -> tuple[RootContext, LookyClient]:
50
+ root_context = require_root_context(path, exact_root=exact_root)
51
+ return root_context, build_authenticated_client(root_context)
52
+
53
+
54
+ def require_billing_context(
55
+ root_context: RootContext,
56
+ client: LookyClient,
57
+ *,
58
+ expected_billing_account_id: str | None = None,
59
+ ) -> str:
60
+ """
61
+ If billing is enabled, require an active billing account.
62
+ Prints error and raises typer.Exit(1) if not set.
63
+ """
64
+ try:
65
+ client.get("/api/cli/whoami")
66
+ except LookyClientError:
67
+ pass
68
+ bid = root_context.billing_account_id
69
+ if not bid:
70
+ console.print(
71
+ "[red]No active billing account for linked root "
72
+ f"[bold]{root_context.root_path}[/bold]. Run "
73
+ "[bold]looky billing use <id>[/bold] first.[/red]"
74
+ )
75
+ raise typer.Exit(1)
76
+ if expected_billing_account_id and bid != expected_billing_account_id:
77
+ console.print(
78
+ "[red]Active billing account "
79
+ f"[bold]{bid}[/bold] does not match the current path. Expected "
80
+ f"[bold]{expected_billing_account_id}[/bold]. Run "
81
+ f"[bold]looky billing use {expected_billing_account_id}[/bold] "
82
+ f"from [bold]{root_context.root_path}[/bold] first.[/red]"
83
+ )
84
+ raise typer.Exit(1)
85
+ return bid
86
+
87
+
88
+ @app.command("list")
89
+ def billing_list() -> None:
90
+ """List billing accounts you have access to (owner, developer, or read)."""
91
+ root_context, client = require_authenticated_client(Path.cwd(), exact_root=True)
92
+ try:
93
+ result = client.get("/api/cli/billing/list")
94
+ except LookyClientError as exc:
95
+ if exc.status_code == 401:
96
+ console.print(
97
+ "[red]Token expired or revoked. Run "
98
+ f"[bold]{relogin_command(root_context)}[/bold] again.[/red]"
99
+ )
100
+ else:
101
+ console.print(f"[red]Error: {exc}[/red]")
102
+ raise typer.Exit(1) from exc
103
+
104
+ accounts = result.get("billing_accounts") or []
105
+ current = root_context.billing_account_id
106
+
107
+ if not accounts:
108
+ console.print("[yellow]No billing accounts available. You need access as owner, developer, or read.[/yellow]")
109
+ return
110
+
111
+ t = Table(show_header=True, header_style="bold")
112
+ t.add_column("ID")
113
+ t.add_column("Name")
114
+ t.add_column("Role")
115
+ t.add_column("", width=4)
116
+
117
+ for a in accounts:
118
+ bid = str(a.get("id") or "")
119
+ name = str(a.get("display_name") or bid)
120
+ role = str(a.get("role") or "")
121
+ marker = " *" if bid == current else ""
122
+ t.add_row(bid, name, role, marker)
123
+
124
+ console.print(t)
125
+ if current:
126
+ console.print(f"\n[dim]* = active billing account ([bold]{current}[/bold])[/dim]")
127
+ else:
128
+ console.print(
129
+ "\n[yellow]No active billing account. Run [bold]looky billing use &lt;id&gt;[/bold] to set one.[/yellow]"
130
+ )
131
+
132
+
133
+ @app.command("use")
134
+ def billing_use(
135
+ billing_account_id: str = typer.Argument(..., help="Billing account ID to activate"),
136
+ ) -> None:
137
+ """Set the active billing account. Validates you have access (SA, owner, developer, or read)."""
138
+ root_context, client = require_authenticated_client(Path.cwd(), exact_root=True)
139
+
140
+ bid = billing_account_id.strip()
141
+ if not bid:
142
+ console.print("[red]Billing account ID is required.[/red]")
143
+ raise typer.Exit(1)
144
+
145
+ try:
146
+ client.get("/api/cli/billing/validate", params={"billing_account_id": bid})
147
+ except LookyClientError as exc:
148
+ if exc.status_code == 401:
149
+ console.print(
150
+ "[red]Token expired or revoked. Run "
151
+ f"[bold]{relogin_command(root_context)}[/bold] again.[/red]"
152
+ )
153
+ elif exc.status_code == 403:
154
+ console.print(
155
+ f"[red]No access to billing account [bold]{bid}[/bold]. "
156
+ "Run [bold]looky billing list[/bold] to see available accounts.[/red]"
157
+ )
158
+ elif exc.status_code == 400:
159
+ console.print(f"[red]{exc}[/red]")
160
+ else:
161
+ console.print(f"[red]Error: {exc}[/red]")
162
+ raise typer.Exit(1) from exc
163
+
164
+ cfg.set_root_billing_account_id(root_context.root_path, bid)
165
+ console.print(f"[green]Active billing account set to [bold]{bid}[/bold].[/green]")
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from looky_cli.client import LookyClient, LookyClientError
10
+ from looky_cli.commands.billing import build_authenticated_client, relogin_command, require_billing_context
11
+ from looky_cli.workspace import WorkspaceResolutionError, resolve_billing_directory
12
+
13
+ console = Console()
14
+
15
+
16
+ def create(
17
+ workspace_slug: str = typer.Argument(..., help="Workspace slug to create under the current billing directory"),
18
+ name: str = typer.Option(None, "--name", "-n", help="Human-readable workspace name"),
19
+ description: str = typer.Option("", "--description", "-d", help="Short description"),
20
+ local_only: bool = typer.Option(
21
+ False,
22
+ "--local-only",
23
+ help="Only create the local skeleton, skip server registration",
24
+ ),
25
+ ) -> None:
26
+ """
27
+ Register a new workspace on the server and scaffold it locally.
28
+
29
+ The workspace is registered on the server first (fails if it already exists),
30
+ then the local directory skeleton is created. The calling user is automatically
31
+ granted write access.
32
+ """
33
+ slug = _normalize_workspace_slug(workspace_slug)
34
+ try:
35
+ billing_dir = resolve_billing_directory(Path.cwd())
36
+ except WorkspaceResolutionError as exc:
37
+ console.print(f"[red]{exc}[/red]")
38
+ raise typer.Exit(1) from exc
39
+
40
+ workspace_id = f"{billing_dir.billing_account_id}/{slug}"
41
+ resolved_name = name or slug
42
+ dest = billing_dir.billing_path / slug
43
+
44
+ client: LookyClient | None = None
45
+ if not local_only:
46
+ client = build_authenticated_client(billing_dir.root_context)
47
+ require_billing_context(
48
+ billing_dir.root_context,
49
+ client,
50
+ expected_billing_account_id=billing_dir.billing_account_id,
51
+ )
52
+
53
+ if not local_only:
54
+ with console.status(f"Registering [bold]{workspace_id}[/bold] on {billing_dir.root_context.url}…"):
55
+ try:
56
+ result = client.post( # type: ignore[union-attr]
57
+ "/api/cli/workspaces",
58
+ json={
59
+ "workspace_id": workspace_id,
60
+ "name": resolved_name,
61
+ "description": description,
62
+ },
63
+ )
64
+ except LookyClientError as exc:
65
+ if exc.status_code == 409:
66
+ console.print(
67
+ f"[red]Workspace [bold]{workspace_id}[/bold] already exists on the server. "
68
+ f"Use [bold]looky pull {slug}[/bold] from [bold]{billing_dir.billing_path}[/bold] "
69
+ "to get its content.[/red]"
70
+ )
71
+ elif exc.status_code == 401:
72
+ console.print(
73
+ "[red]Token expired or revoked. Run "
74
+ f"[bold]{relogin_command(billing_dir.root_context)}[/bold] again.[/red]"
75
+ )
76
+ elif exc.status_code == 400:
77
+ console.print(f"[red]Invalid input: {exc}[/red]")
78
+ else:
79
+ console.print(f"[red]Failed to create workspace: {exc}[/red]")
80
+ raise typer.Exit(1) from exc
81
+
82
+ console.print(
83
+ f"[green]✓ Workspace [bold]{workspace_id}[/bold] registered on server "
84
+ f"(access: {result.get('access_mode', 'write')})[/green]"
85
+ )
86
+
87
+ # -----------------------------------------------------------------------
88
+ # Local skeleton
89
+ # -----------------------------------------------------------------------
90
+ if dest.exists() and any(dest.iterdir()):
91
+ console.print(
92
+ f"[yellow]Local directory [bold]{dest}[/bold] already exists and is not empty — skipping skeleton.[/yellow]"
93
+ )
94
+ else:
95
+ _scaffold_local(dest, workspace_slug=slug, name=resolved_name, description=description)
96
+ console.print(f"[green]✓ Local skeleton created at [bold]{dest}[/bold][/green]")
97
+
98
+ _print_next_steps(workspace_id, dest)
99
+
100
+
101
+ def _normalize_workspace_slug(workspace_slug: str) -> str:
102
+ slug = workspace_slug.strip().strip("/")
103
+ if not slug or "/" in slug or slug in {".", ".."}:
104
+ console.print("[red]Workspace slug must be a single path segment.[/red]")
105
+ raise typer.Exit(1)
106
+ return slug
107
+
108
+
109
+ def _scaffold_local(dest: Path, *, workspace_slug: str, name: str, description: str) -> None:
110
+ dest.mkdir(parents=True, exist_ok=True)
111
+
112
+ ws_yml = dest / "workspace.yml"
113
+ ws_yml.write_text(
114
+ f"id: {workspace_slug}\n"
115
+ f"name: {name}\n"
116
+ + (f"description: {description}\n" if description else "")
117
+ + "ui:\n"
118
+ " default_locale: en\n"
119
+ " supported_locales: [en, es]\n",
120
+ encoding="utf-8",
121
+ )
122
+
123
+ for subdir in (
124
+ "content/models",
125
+ "content/visualizations",
126
+ "content/dashboards",
127
+ "content/exports",
128
+ "runtime",
129
+ ):
130
+ (dest / subdir).mkdir(parents=True, exist_ok=True)
131
+
132
+ runtime_example = dest / "runtime" / "sources.runtime.yml"
133
+ runtime_example.write_text(
134
+ "# Runtime source configuration — DO NOT commit secrets.\n"
135
+ "# See docs for supported types: bigquery, postgres.\n\n"
136
+ "sources:\n"
137
+ " my_source:\n"
138
+ " type: bigquery\n"
139
+ " project_id: \"my-gcp-project\"\n"
140
+ " credentials_file: \"secrets/my-sa.json\"\n",
141
+ encoding="utf-8",
142
+ )
143
+
144
+ secrets_gitkeep = dest / "secrets" / ".gitkeep"
145
+ secrets_gitkeep.parent.mkdir(parents=True, exist_ok=True)
146
+ secrets_gitkeep.touch()
147
+
148
+
149
+ def _print_next_steps(workspace_id: str, dest: Path) -> None:
150
+ console.print(f"\n[bold]Next steps:[/bold]")
151
+ t = Table(show_header=False, box=None, padding=(0, 1))
152
+ t.add_column(style="dim", no_wrap=True)
153
+ t.add_column()
154
+ t.add_row("1.", f"Add your source config in [bold]{dest}/runtime/sources.runtime.yml[/bold]")
155
+ t.add_row("2.", f"Add credentials to [bold]{dest}/secrets/[/bold] (never commit these)")
156
+ t.add_row("3.", f"Write your Malloy models in [bold]{dest}/content/models/[/bold]")
157
+ t.add_row("4.", f"Change into [bold]{dest}[/bold] and run [bold]looky validate[/bold]")
158
+ t.add_row("5.", f"From [bold]{dest}[/bold], run [bold]looky push[/bold] when ready")
159
+ console.print(t)