cloudclerk 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,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: pip install -e ".[dev]"
21
+ - run: pytest -v
22
+
23
+ lint:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.12"
30
+ - run: pip install ruff
31
+ - run: ruff check src/ tests/
@@ -0,0 +1,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ id-token: write
9
+
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ matrix:
15
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - run: pip install -e ".[dev]"
22
+ - run: pytest
23
+
24
+ publish:
25
+ needs: test
26
+ runs-on: ubuntu-latest
27
+ environment: pypi
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - uses: actions/setup-python@v5
31
+ with:
32
+ python-version: "3.12"
33
+ - run: pip install build
34
+ - run: python -m build
35
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,29 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+
9
+ # Virtual environments
10
+ .venv/
11
+ venv/
12
+
13
+ # Testing
14
+ .pytest_cache/
15
+ .coverage
16
+ htmlcov/
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+
23
+ # OS
24
+ .DS_Store
25
+ Thumbs.db
26
+
27
+ # Build artifacts
28
+ *.whl
29
+ *.tar.gz
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: cloudclerk
3
+ Version: 0.1.0
4
+ Summary: CLI for CloudClerk - BigQuery cost optimization from the terminal
5
+ Project-URL: Homepage, https://cloudclerk.io
6
+ Project-URL: Documentation, https://github.com/cloud-clerk/cloud-clerk-cli#readme
7
+ Project-URL: Repository, https://github.com/cloud-clerk/cloud-clerk-cli
8
+ Project-URL: Issues, https://github.com/cloud-clerk/cloud-clerk-cli/issues
9
+ Project-URL: Changelog, https://github.com/cloud-clerk/cloud-clerk-cli/releases
10
+ Author-email: CloudClerk <team@cloudclerk.ai>
11
+ License-Expression: MIT
12
+ Keywords: bigquery,cli,cloud,cost-optimization,gcp
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Database
23
+ Classifier: Topic :: System :: Monitoring
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: httpx>=0.27.0
26
+ Requires-Dist: rich>=13.0.0
27
+ Requires-Dist: tomli-w>=1.0.0
28
+ Requires-Dist: tomli>=2.0.0; python_version < '3.11'
29
+ Requires-Dist: typer>=0.9.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # CloudClerk CLI
36
+
37
+ BigQuery cost optimization from the terminal. Analyze your most expensive queries and get actionable recommendations to reduce costs.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pipx install cloudclerk
43
+ ```
44
+
45
+ Or with `uv`:
46
+
47
+ ```bash
48
+ uv tool install cloudclerk
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ ```bash
54
+ # 1. Configure with your API key (from the CloudClerk dashboard)
55
+ cloudclerk configure
56
+
57
+ # 2. See your most expensive queries
58
+ cloudclerk queries top
59
+
60
+ # 3. Get details and recommendations for a specific query
61
+ cloudclerk queries show <query_sha>
62
+ ```
63
+
64
+ ## Commands
65
+
66
+ | Command | Description |
67
+ |---|---|
68
+ | `cloudclerk configure` | Set API key and server URL |
69
+ | `cloudclerk queries top` | List most expensive queries by cost |
70
+ | `cloudclerk queries show <sha>` | Full analysis: context, recommendations, issues |
71
+
72
+ ## Options
73
+
74
+ ```bash
75
+ cloudclerk queries top --limit 20 # Show top 20
76
+ cloudclerk queries top --priority high # Filter by priority (high/medium/low)
77
+ cloudclerk queries top --json # Raw JSON for piping
78
+ cloudclerk queries show <sha> --json # Raw JSON for piping
79
+ cloudclerk --version # Show version
80
+ ```
81
+
82
+ ## What You Get
83
+
84
+ **`queries top`** - A ranked table of your most expensive queries with cost, savings potential, and priority.
85
+
86
+ **`queries show`** - Full detail for a single query:
87
+ - Query context (executions, bytes billed, referenced tables, date range)
88
+ - The actual SQL pattern
89
+ - Actionable recommendations with estimated savings and SQL examples
90
+ - Issues found with severity ratings
91
+
92
+ ## Authentication
93
+
94
+ API keys are generated from the CloudClerk dashboard. Keys are prefixed with `cc_live_` and scoped to your organization.
95
+
96
+ Configuration is stored in `~/.cloudclerk/config.toml`.
97
+
98
+ ## Requirements
99
+
100
+ - Python 3.10+
101
+ - A CloudClerk account with API key access
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ git clone https://github.com/numia-xyz/cloudclerk-cli.git
107
+ cd cloudclerk-cli
108
+ uv venv && source .venv/bin/activate
109
+ uv pip install -e ".[dev]"
110
+ pytest -v
111
+ ```
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,81 @@
1
+ # CloudClerk CLI
2
+
3
+ BigQuery cost optimization from the terminal. Analyze your most expensive queries and get actionable recommendations to reduce costs.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pipx install cloudclerk
9
+ ```
10
+
11
+ Or with `uv`:
12
+
13
+ ```bash
14
+ uv tool install cloudclerk
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ # 1. Configure with your API key (from the CloudClerk dashboard)
21
+ cloudclerk configure
22
+
23
+ # 2. See your most expensive queries
24
+ cloudclerk queries top
25
+
26
+ # 3. Get details and recommendations for a specific query
27
+ cloudclerk queries show <query_sha>
28
+ ```
29
+
30
+ ## Commands
31
+
32
+ | Command | Description |
33
+ |---|---|
34
+ | `cloudclerk configure` | Set API key and server URL |
35
+ | `cloudclerk queries top` | List most expensive queries by cost |
36
+ | `cloudclerk queries show <sha>` | Full analysis: context, recommendations, issues |
37
+
38
+ ## Options
39
+
40
+ ```bash
41
+ cloudclerk queries top --limit 20 # Show top 20
42
+ cloudclerk queries top --priority high # Filter by priority (high/medium/low)
43
+ cloudclerk queries top --json # Raw JSON for piping
44
+ cloudclerk queries show <sha> --json # Raw JSON for piping
45
+ cloudclerk --version # Show version
46
+ ```
47
+
48
+ ## What You Get
49
+
50
+ **`queries top`** - A ranked table of your most expensive queries with cost, savings potential, and priority.
51
+
52
+ **`queries show`** - Full detail for a single query:
53
+ - Query context (executions, bytes billed, referenced tables, date range)
54
+ - The actual SQL pattern
55
+ - Actionable recommendations with estimated savings and SQL examples
56
+ - Issues found with severity ratings
57
+
58
+ ## Authentication
59
+
60
+ API keys are generated from the CloudClerk dashboard. Keys are prefixed with `cc_live_` and scoped to your organization.
61
+
62
+ Configuration is stored in `~/.cloudclerk/config.toml`.
63
+
64
+ ## Requirements
65
+
66
+ - Python 3.10+
67
+ - A CloudClerk account with API key access
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ git clone https://github.com/numia-xyz/cloudclerk-cli.git
73
+ cd cloudclerk-cli
74
+ uv venv && source .venv/bin/activate
75
+ uv pip install -e ".[dev]"
76
+ pytest -v
77
+ ```
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cloudclerk"
7
+ version = "0.1.0"
8
+ description = "CLI for CloudClerk - BigQuery cost optimization from the terminal"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "CloudClerk", email = "team@cloudclerk.ai" }]
13
+ keywords = ["bigquery", "cost-optimization", "cli", "cloud", "gcp"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Database",
25
+ "Topic :: System :: Monitoring",
26
+ ]
27
+ dependencies = [
28
+ "typer>=0.9.0",
29
+ "httpx>=0.27.0",
30
+ "rich>=13.0.0",
31
+ "tomli>=2.0.0;python_version<'3.11'",
32
+ "tomli-w>=1.0.0",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=7.0.0",
38
+ "ruff>=0.4.0",
39
+ ]
40
+
41
+ [project.scripts]
42
+ cloudclerk = "cloudclerk.cli:app"
43
+
44
+ [project.urls]
45
+ Homepage = "https://cloudclerk.io"
46
+ Documentation = "https://github.com/cloud-clerk/cloud-clerk-cli#readme"
47
+ Repository = "https://github.com/cloud-clerk/cloud-clerk-cli"
48
+ Issues = "https://github.com/cloud-clerk/cloud-clerk-cli/issues"
49
+ Changelog = "https://github.com/cloud-clerk/cloud-clerk-cli/releases"
50
+
51
+ [tool.hatch.build.targets.wheel]
52
+ packages = ["src/cloudclerk"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+
57
+ [tool.ruff]
58
+ line-length = 120
@@ -0,0 +1,3 @@
1
+ """CloudClerk CLI — BigQuery cost optimization from the terminal."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m cloudclerk"""
2
+
3
+ from cloudclerk.cli import app
4
+
5
+ app()
@@ -0,0 +1,29 @@
1
+ """CloudClerk CLI — BigQuery cost optimization from the terminal."""
2
+
3
+ import typer
4
+
5
+ from cloudclerk import __version__
6
+ from cloudclerk.commands.configure import configure
7
+ from cloudclerk.commands.queries import queries_app
8
+
9
+ app = typer.Typer(
10
+ name="cloudclerk",
11
+ help="CloudClerk CLI — BigQuery cost optimization from the terminal.",
12
+ no_args_is_help=True,
13
+ )
14
+
15
+ app.command()(configure)
16
+ app.add_typer(queries_app, name="queries", help="Query cost analysis")
17
+
18
+
19
+ def version_callback(value: bool) -> None:
20
+ if value:
21
+ typer.echo(f"cloudclerk {__version__}")
22
+ raise typer.Exit()
23
+
24
+
25
+ @app.callback()
26
+ def main(
27
+ version: bool = typer.Option(False, "--version", "-v", callback=version_callback, is_eager=True, help="Show version"),
28
+ ) -> None:
29
+ """CloudClerk CLI — BigQuery cost optimization from the terminal."""
@@ -0,0 +1,93 @@
1
+ """HTTP client for the CloudClerk API."""
2
+
3
+ import sys
4
+
5
+ import httpx
6
+
7
+ from cloudclerk.config import require_config
8
+
9
+
10
+ class APIError(Exception):
11
+ """Raised when the CloudClerk API returns an error."""
12
+
13
+ def __init__(self, status_code: int, detail: str):
14
+ self.status_code = status_code
15
+ self.detail = detail
16
+ super().__init__(f"HTTP {status_code}: {detail}")
17
+
18
+
19
+ class CloudClerkClient:
20
+ """Thin wrapper around httpx for CloudClerk API calls."""
21
+
22
+ def __init__(self, api_key: str | None = None, server_url: str | None = None):
23
+ if api_key and server_url:
24
+ self._api_key = api_key
25
+ self._base_url = server_url.rstrip("/")
26
+ else:
27
+ config = require_config()
28
+ self._api_key = config["auth"]["api_key"]
29
+ self._base_url = config["server"]["url"].rstrip("/")
30
+
31
+ self._client = httpx.Client(
32
+ base_url=self._base_url,
33
+ headers={
34
+ "Authorization": f"Bearer {self._api_key}",
35
+ "Content-Type": "application/json",
36
+ },
37
+ timeout=30.0,
38
+ )
39
+
40
+ def _request(self, method: str, path: str, **kwargs) -> dict:
41
+ """Make a request and return the JSON body. Raises APIError on failure."""
42
+ try:
43
+ response = self._client.request(method, path, **kwargs)
44
+ except httpx.ConnectError:
45
+ from rich.console import Console
46
+
47
+ Console(stderr=True).print(
48
+ f"[red]Connection failed.[/red] Could not reach [bold]{self._base_url}[/bold].\n"
49
+ "Check your server URL with [bold]cloudclerk configure[/bold]."
50
+ )
51
+ sys.exit(1)
52
+ except httpx.TimeoutException:
53
+ from rich.console import Console
54
+
55
+ Console(stderr=True).print("[red]Request timed out.[/red] The server took too long to respond.")
56
+ sys.exit(1)
57
+
58
+ if response.status_code == 401:
59
+ raise APIError(401, "Invalid or expired API key. Run 'cloudclerk configure' to update.")
60
+ if response.status_code == 403:
61
+ raise APIError(403, "Access denied. Your API key may not have permission for this resource.")
62
+ if response.status_code == 404:
63
+ raise APIError(404, "Resource not found.")
64
+ if response.status_code >= 400:
65
+ detail = response.json().get("detail", response.text) if response.text else "Unknown error"
66
+ raise APIError(response.status_code, detail)
67
+
68
+ return response.json()
69
+
70
+ def get(self, path: str, params: dict | None = None) -> dict:
71
+ return self._request("GET", path, params=params)
72
+
73
+ # -- Cost Optimization endpoints --
74
+
75
+ def list_queries(
76
+ self,
77
+ limit: int = 10,
78
+ priority: str | None = None,
79
+ ordering: str = "-estimated_cost_usd",
80
+ ) -> dict:
81
+ """List query analyses, ordered by cost (most expensive first by default)."""
82
+ params = {"page_size": limit, "ordering": ordering}
83
+ if priority:
84
+ params["priority"] = priority
85
+ return self.get("/api/v1/cost-optimization/analyses/", params=params)
86
+
87
+ def get_query(self, query_sha: str) -> dict:
88
+ """Get full details for a single query analysis."""
89
+ return self.get(f"/api/v1/cost-optimization/analyses/{query_sha}/")
90
+
91
+ def get_latest_run(self) -> dict:
92
+ """Get the most recent optimization run (used to verify connectivity)."""
93
+ return self.get("/api/v1/cost-optimization/runs/latest/")
File without changes
@@ -0,0 +1,51 @@
1
+ """'cloudclerk configure' command — set up API key and server URL."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from cloudclerk.config import DEFAULT_SERVER_URL, load_config, save_config
7
+
8
+ console = Console()
9
+
10
+
11
+ def configure() -> None:
12
+ """Configure CloudClerk CLI with your API key and server URL."""
13
+ console.print()
14
+ console.print("[bold]CloudClerk CLI Configuration[/bold]")
15
+ console.print()
16
+
17
+ existing = load_config()
18
+ existing_url = (existing or {}).get("server", {}).get("url", DEFAULT_SERVER_URL)
19
+
20
+ api_key = typer.prompt("API Key", hide_input=True)
21
+
22
+ if not api_key.startswith(("cc_live_", "cc_test_")):
23
+ console.print("[red]Invalid API key format.[/red] Keys start with 'cc_live_' or 'cc_test_'.")
24
+ raise typer.Exit(1)
25
+
26
+ server_url = typer.prompt("Server URL", default=existing_url)
27
+
28
+ path = save_config(api_key=api_key, server_url=server_url)
29
+ console.print()
30
+ console.print(f"[green]Configuration saved[/green] to [dim]{path}[/dim]")
31
+
32
+ # Test connectivity
33
+ console.print()
34
+ console.print("[dim]Testing connection...[/dim]", end=" ")
35
+
36
+ try:
37
+ from cloudclerk.client import CloudClerkClient
38
+
39
+ client = CloudClerkClient(api_key=api_key, server_url=server_url)
40
+ client.get_latest_run()
41
+ console.print("[green]OK[/green]")
42
+ except SystemExit:
43
+ # Connection error already printed by client
44
+ console.print()
45
+ console.print("[yellow]Configuration saved, but connection failed.[/yellow]")
46
+ console.print("You can still use the CLI — check your server URL and API key.")
47
+ except Exception as e:
48
+ console.print(f"[yellow]Warning:[/yellow] {e}")
49
+ console.print("Configuration saved, but could not verify the connection.")
50
+
51
+ console.print()
@@ -0,0 +1,62 @@
1
+ """'cloudclerk queries' commands — list and inspect expensive BigQuery queries."""
2
+
3
+ import json
4
+ import sys
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from cloudclerk.client import APIError, CloudClerkClient
11
+ from cloudclerk.display import render_queries_table, render_query_detail
12
+
13
+ console = Console(stderr=True)
14
+
15
+ queries_app = typer.Typer(help="Query cost analysis", no_args_is_help=True)
16
+
17
+
18
+ @queries_app.command("top")
19
+ def queries_top(
20
+ limit: int = typer.Option(10, "--limit", "-n", help="Number of queries to show"),
21
+ priority: Optional[str] = typer.Option(None, "--priority", "-p", help="Filter by priority: high, medium, low"),
22
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output raw JSON"),
23
+ ) -> None:
24
+ """Show the most expensive queries, ranked by cost."""
25
+ if priority and priority.lower() not in ("high", "medium", "low"):
26
+ console.print("[red]Invalid priority.[/red] Use: high, medium, low")
27
+ raise typer.Exit(1)
28
+
29
+ try:
30
+ client = CloudClerkClient()
31
+ data = client.list_queries(limit=limit, priority=priority)
32
+ except APIError as e:
33
+ console.print(f"[red]Error:[/red] {e.detail}")
34
+ raise typer.Exit(1)
35
+
36
+ if output_json:
37
+ typer.echo(json.dumps(data, indent=2))
38
+ else:
39
+ render_queries_table(data)
40
+
41
+
42
+ @queries_app.command("show")
43
+ def queries_show(
44
+ query_sha: str = typer.Argument(help="The query SHA to inspect (from 'queries top' output)"),
45
+ output_json: bool = typer.Option(False, "--json", "-j", help="Output raw JSON"),
46
+ ) -> None:
47
+ """Show full details and recommendations for a specific query."""
48
+ try:
49
+ client = CloudClerkClient()
50
+ data = client.get_query(query_sha)
51
+ except APIError as e:
52
+ if e.status_code == 404:
53
+ console.print(f"[red]Query not found:[/red] {query_sha}")
54
+ console.print("[dim]Use 'cloudclerk queries top' to see available query SHAs.[/dim]")
55
+ else:
56
+ console.print(f"[red]Error:[/red] {e.detail}")
57
+ raise typer.Exit(1)
58
+
59
+ if output_json:
60
+ typer.echo(json.dumps(data, indent=2))
61
+ else:
62
+ render_query_detail(data)
@@ -0,0 +1,85 @@
1
+ """Configuration management for CloudClerk CLI.
2
+
3
+ Stores config in ~/.cloudclerk/config.toml:
4
+
5
+ [auth]
6
+ api_key = "cc_live_..."
7
+
8
+ [server]
9
+ url = "https://app.cloudclerk.io"
10
+ """
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import tomli_w
16
+
17
+ try:
18
+ import tomllib
19
+ except ModuleNotFoundError:
20
+ import tomli as tomllib
21
+
22
+
23
+ DEFAULT_SERVER_URL = "https://app.cloudclerk.io"
24
+ CONFIG_DIR_NAME = ".cloudclerk"
25
+ CONFIG_FILE_NAME = "config.toml"
26
+
27
+
28
+ def get_config_dir() -> Path:
29
+ return Path.home() / CONFIG_DIR_NAME
30
+
31
+
32
+ def get_config_path() -> Path:
33
+ return get_config_dir() / CONFIG_FILE_NAME
34
+
35
+
36
+ def load_config() -> dict | None:
37
+ """Load config from disk. Returns None if file doesn't exist."""
38
+ path = get_config_path()
39
+ if not path.exists():
40
+ return None
41
+ with open(path, "rb") as f:
42
+ return tomllib.load(f)
43
+
44
+
45
+ def save_config(api_key: str, server_url: str) -> Path:
46
+ """Save config to disk. Creates directory if needed. Returns the config path."""
47
+ config_dir = get_config_dir()
48
+ config_dir.mkdir(parents=True, exist_ok=True)
49
+
50
+ config = {
51
+ "auth": {"api_key": api_key},
52
+ "server": {"url": server_url},
53
+ }
54
+
55
+ path = get_config_path()
56
+ with open(path, "wb") as f:
57
+ tomli_w.dump(config, f)
58
+
59
+ return path
60
+
61
+
62
+ def require_config() -> dict:
63
+ """Load config or exit with a helpful message."""
64
+ config = load_config()
65
+ if config is None:
66
+ from rich.console import Console
67
+
68
+ console = Console(stderr=True)
69
+ console.print("[red]Not configured.[/red] Run [bold]cloudclerk configure[/bold] first.")
70
+ sys.exit(1)
71
+
72
+ missing = []
73
+ if not config.get("auth", {}).get("api_key"):
74
+ missing.append("api_key")
75
+ if not config.get("server", {}).get("url"):
76
+ missing.append("server url")
77
+
78
+ if missing:
79
+ from rich.console import Console
80
+
81
+ console = Console(stderr=True)
82
+ console.print(f"[red]Config incomplete[/red] (missing: {', '.join(missing)}). Run [bold]cloudclerk configure[/bold].")
83
+ sys.exit(1)
84
+
85
+ return config
@@ -0,0 +1,259 @@
1
+ """Rich display helpers for CloudClerk CLI output."""
2
+
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.table import Table
6
+ from rich.text import Text
7
+
8
+
9
+ console = Console()
10
+
11
+ PRIORITY_COLORS = {
12
+ "high": "red",
13
+ "medium": "yellow",
14
+ "low": "green",
15
+ }
16
+
17
+
18
+ def format_usd(value) -> str:
19
+ """Format a numeric value as USD."""
20
+ if value is None:
21
+ return "-"
22
+ return f"${float(value):,.2f}"
23
+
24
+
25
+ def format_savings(min_val, max_val) -> str:
26
+ """Format a savings range as '$X - $Y'."""
27
+ if min_val is None and max_val is None:
28
+ return "-"
29
+ return f"{format_usd(min_val)} - {format_usd(max_val)}"
30
+
31
+
32
+ def format_priority(priority: str | None) -> Text:
33
+ """Return a colored priority label."""
34
+ if not priority:
35
+ return Text("-")
36
+ color = PRIORITY_COLORS.get(priority.lower(), "white")
37
+ return Text(priority.upper(), style=f"bold {color}")
38
+
39
+
40
+ def format_date(date_str: str | None) -> str:
41
+ """Format an ISO datetime string to a short date."""
42
+ if not date_str:
43
+ return "-"
44
+ return date_str[:10]
45
+
46
+
47
+ def render_queries_table(data: dict) -> None:
48
+ """Render a table of query analyses."""
49
+ results = data.get("results", [])
50
+
51
+ if not results:
52
+ console.print("[dim]No query analyses found.[/dim]")
53
+ return
54
+
55
+ table = Table(title="Most Expensive Queries", show_lines=True, expand=False)
56
+ table.add_column("#", justify="right", width=3)
57
+ table.add_column("Priority", justify="center", width=8)
58
+ table.add_column("Cost", justify="right", width=12)
59
+ table.add_column("Savings", justify="right", width=18)
60
+ table.add_column("Run Date", width=10)
61
+
62
+ shas = []
63
+ for i, row in enumerate(results, 1):
64
+ sha = row.get("query_sha") or ""
65
+ shas.append(sha)
66
+ table.add_row(
67
+ str(i),
68
+ format_priority(row.get("priority")),
69
+ format_usd(row.get("estimated_cost_usd")),
70
+ format_savings(row.get("potential_savings_min_usd"), row.get("potential_savings_max_usd")),
71
+ format_date(row.get("run_created_at") or row.get("created_at")),
72
+ )
73
+
74
+ console.print(table)
75
+
76
+ # Print SHAs below the table so they're fully copy-pasteable
77
+ console.print()
78
+ console.print("[bold]Query SHAs[/bold] (use with [bold]cloudclerk queries show <sha>[/bold]):")
79
+ for i, sha in enumerate(shas, 1):
80
+ console.print(f" {i}. {sha}")
81
+ console.print()
82
+
83
+
84
+ def render_query_detail(data: dict) -> None:
85
+ """Render full details for a single query analysis."""
86
+ analysis = data.get("analysis", data)
87
+
88
+ query_sha = analysis.get("query_sha", "unknown")
89
+ priority = analysis.get("priority")
90
+ cost = analysis.get("estimated_cost_usd")
91
+ savings_min = analysis.get("potential_savings_min_usd")
92
+ savings_max = analysis.get("potential_savings_max_usd")
93
+ created = format_date(analysis.get("run_created_at") or analysis.get("created_at"))
94
+
95
+ # Header
96
+ console.print()
97
+ console.print(f"[bold]Query Analysis:[/bold] {query_sha}")
98
+ console.print("=" * 50)
99
+ console.print()
100
+
101
+ # Summary info
102
+ info_lines = [
103
+ f"[bold]Priority:[/bold] {format_priority(priority)}",
104
+ f"[bold]Estimated Cost:[/bold] {format_usd(cost)}",
105
+ f"[bold]Potential Savings:[/bold] {format_savings(savings_min, savings_max)}",
106
+ f"[bold]Analyzed:[/bold] {created}",
107
+ ]
108
+ for line in info_lines:
109
+ console.print(line)
110
+
111
+ # Query execution context from query_data
112
+ query_data = analysis.get("query_data")
113
+ if query_data:
114
+ _render_query_context(query_data)
115
+
116
+ # Query pattern (the actual SQL)
117
+ if query_data:
118
+ _render_query_pattern(query_data)
119
+
120
+ # Recommendations from analysis_result
121
+ analysis_result = analysis.get("analysis_result")
122
+ if analysis_result:
123
+ _render_recommendations(analysis_result)
124
+ _render_issues(analysis_result)
125
+
126
+ console.print()
127
+
128
+
129
+ def _format_bytes(num_bytes) -> str:
130
+ """Format bytes into a human-readable string."""
131
+ if num_bytes is None:
132
+ return "-"
133
+ num = float(num_bytes)
134
+ for unit in ("B", "KB", "MB", "GB", "TB", "PB"):
135
+ if abs(num) < 1024:
136
+ return f"{num:,.1f} {unit}"
137
+ num /= 1024
138
+ return f"{num:,.1f} EB"
139
+
140
+
141
+ def _render_query_context(query_data: dict) -> None:
142
+ """Render execution context from query_data."""
143
+ lines = []
144
+
145
+ execution_count = query_data.get("execution_count")
146
+ if execution_count is not None:
147
+ lines.append(f" [bold]Executions:[/bold] {execution_count:,}")
148
+
149
+ statement_type = query_data.get("statement_type")
150
+ if statement_type:
151
+ lines.append(f" [bold]Statement Type:[/bold] {statement_type}")
152
+
153
+ distinct_users = query_data.get("distinct_users")
154
+ if distinct_users is not None:
155
+ lines.append(f" [bold]Distinct Users:[/bold] {distinct_users}")
156
+
157
+ bytes_billed = query_data.get("sum_bytes_billed")
158
+ if bytes_billed is not None:
159
+ lines.append(f" [bold]Total Bytes Billed:[/bold] {_format_bytes(bytes_billed)}")
160
+
161
+ first_seen = query_data.get("first_seen")
162
+ last_seen = query_data.get("last_seen")
163
+ if first_seen and last_seen:
164
+ lines.append(f" [bold]Observed:[/bold] {first_seen} to {last_seen}")
165
+
166
+ tables = query_data.get("referenced_tables", [])
167
+ if tables:
168
+ lines.append(f" [bold]Referenced Tables:[/bold]")
169
+ for t in tables:
170
+ lines.append(f" - {t}")
171
+
172
+ dest = query_data.get("destination_table")
173
+ if dest:
174
+ lines.append(f" [bold]Destination:[/bold] {dest}")
175
+
176
+ if lines:
177
+ console.print()
178
+ console.print(Panel("\n".join(lines), title="Query Context", border_style="cyan"))
179
+
180
+
181
+ def _render_recommendations(analysis_result: dict) -> None:
182
+ """Render the recommendations section."""
183
+ recommendations = analysis_result.get("recommendations", [])
184
+ if not recommendations:
185
+ return
186
+
187
+ console.print()
188
+ lines = []
189
+ for i, rec in enumerate(recommendations, 1):
190
+ if isinstance(rec, dict):
191
+ action = rec.get("action") or rec.get("title") or rec.get("recommendation", "")
192
+ description = rec.get("description", "")
193
+ savings = rec.get("estimated_savings_usd", "")
194
+ sql = rec.get("sql_example", "")
195
+
196
+ lines.append(f" [bold green]{i}. {action}[/bold green]")
197
+ if description:
198
+ lines.append(f" {description}")
199
+ if savings:
200
+ lines.append(f" [dim]Estimated savings: {savings}[/dim]")
201
+ if sql:
202
+ lines.append(f" [blue]SQL: {sql}[/blue]")
203
+ lines.append("")
204
+ else:
205
+ lines.append(f" [bold]{i}.[/bold] {rec}")
206
+ lines.append("")
207
+
208
+ console.print(Panel("\n".join(lines).rstrip(), title="Recommendations", border_style="green"))
209
+
210
+
211
+ def _render_issues(analysis_result: dict) -> None:
212
+ """Render the issues section."""
213
+ issues = analysis_result.get("issues", [])
214
+ if not issues:
215
+ return
216
+
217
+ lines = []
218
+ for i, issue in enumerate(issues, 1):
219
+ if isinstance(issue, dict):
220
+ issue_type = issue.get("type", "")
221
+ severity = issue.get("severity", "")
222
+ description = issue.get("description", "")
223
+ impact = issue.get("impact_estimate", "")
224
+
225
+ color = PRIORITY_COLORS.get(severity.lower(), "white")
226
+ label = f"[{color}][{severity.upper()}][/{color}]" if severity else ""
227
+ type_label = issue_type.replace("_", " ").title() if issue_type else "Issue"
228
+
229
+ lines.append(f" [bold]{i}. {type_label}[/bold] {label}")
230
+ if description:
231
+ lines.append(f" {description}")
232
+ if impact:
233
+ lines.append(f" [dim]Impact: {impact}[/dim]")
234
+ lines.append("")
235
+ else:
236
+ lines.append(f" [bold]{i}.[/bold] {issue}")
237
+ lines.append("")
238
+
239
+ console.print(Panel("\n".join(lines).rstrip(), title="Issues Found", border_style="yellow"))
240
+
241
+
242
+ def _render_query_pattern(query_data: dict) -> None:
243
+ """Render the query pattern section."""
244
+ # query_data is a JSON blob from ClickHouse — try common field names
245
+ query_text = (
246
+ query_data.get("query")
247
+ or query_data.get("query_text")
248
+ or query_data.get("parameterized_query")
249
+ or query_data.get("query_pattern")
250
+ )
251
+ if not query_text:
252
+ return
253
+
254
+ # Truncate very long queries
255
+ if len(query_text) > 1000:
256
+ query_text = query_text[:1000] + "\n ... (truncated)"
257
+
258
+ console.print()
259
+ console.print(Panel(query_text, title="Query Pattern", border_style="blue"))
File without changes
@@ -0,0 +1,71 @@
1
+ """Tests for the HTTP client."""
2
+
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from cloudclerk.client import APIError, CloudClerkClient
9
+
10
+
11
+ @pytest.fixture
12
+ def mock_client(monkeypatch):
13
+ """Create a CloudClerkClient without loading config."""
14
+ monkeypatch.setattr(
15
+ "cloudclerk.client.require_config",
16
+ lambda: {"auth": {"api_key": "cc_live_test"}, "server": {"url": "https://test.cloudclerk.io"}},
17
+ )
18
+ return CloudClerkClient()
19
+
20
+
21
+ def test_client_sets_auth_header(mock_client):
22
+ assert mock_client._client.headers["authorization"] == "Bearer cc_live_test"
23
+
24
+
25
+ def test_client_sets_base_url(mock_client):
26
+ assert str(mock_client._client.base_url) == "https://test.cloudclerk.io"
27
+
28
+
29
+ def test_client_strips_trailing_slash(monkeypatch):
30
+ monkeypatch.setattr(
31
+ "cloudclerk.client.require_config",
32
+ lambda: {"auth": {"api_key": "cc_live_test"}, "server": {"url": "https://test.cloudclerk.io/"}},
33
+ )
34
+ client = CloudClerkClient()
35
+ assert str(client._client.base_url) == "https://test.cloudclerk.io"
36
+
37
+
38
+ def test_client_explicit_params():
39
+ client = CloudClerkClient(api_key="cc_live_abc", server_url="https://my.server.io")
40
+ assert client._api_key == "cc_live_abc"
41
+ assert client._base_url == "https://my.server.io"
42
+
43
+
44
+ def test_list_queries_params(mock_client, monkeypatch):
45
+ captured = {}
46
+
47
+ def mock_get(self, path, params=None):
48
+ captured["path"] = path
49
+ captured["params"] = params
50
+ return {"results": []}
51
+
52
+ monkeypatch.setattr(CloudClerkClient, "get", mock_get)
53
+
54
+ mock_client.list_queries(limit=5, priority="high")
55
+ assert captured["path"] == "/api/v1/cost-optimization/analyses/"
56
+ assert captured["params"]["page_size"] == 5
57
+ assert captured["params"]["priority"] == "high"
58
+ assert captured["params"]["ordering"] == "-estimated_cost_usd"
59
+
60
+
61
+ def test_get_query_path(mock_client, monkeypatch):
62
+ captured = {}
63
+
64
+ def mock_get(self, path, params=None):
65
+ captured["path"] = path
66
+ return {"analysis": {}}
67
+
68
+ monkeypatch.setattr(CloudClerkClient, "get", mock_get)
69
+
70
+ mock_client.get_query("abc123def456")
71
+ assert captured["path"] == "/api/v1/cost-optimization/analyses/abc123def456/"
@@ -0,0 +1,67 @@
1
+ """Tests for CLI commands."""
2
+
3
+ import json
4
+
5
+ from typer.testing import CliRunner
6
+
7
+ from cloudclerk.cli import app
8
+
9
+ runner = CliRunner()
10
+
11
+
12
+ def test_version_flag():
13
+ result = runner.invoke(app, ["--version"])
14
+ assert result.exit_code == 0
15
+ assert "cloudclerk" in result.output
16
+ assert "0.1.0" in result.output
17
+
18
+
19
+ def test_help():
20
+ result = runner.invoke(app, ["--help"])
21
+ assert result.exit_code == 0
22
+ assert "configure" in result.output
23
+ assert "queries" in result.output
24
+
25
+
26
+ def test_queries_help():
27
+ result = runner.invoke(app, ["queries", "--help"])
28
+ assert result.exit_code == 0
29
+ assert "top" in result.output
30
+ assert "show" in result.output
31
+
32
+
33
+ def test_queries_top_invalid_priority(monkeypatch):
34
+ monkeypatch.setattr(
35
+ "cloudclerk.config.require_config",
36
+ lambda: {"auth": {"api_key": "cc_live_test"}, "server": {"url": "https://test.io"}},
37
+ )
38
+ result = runner.invoke(app, ["queries", "top", "--priority", "invalid"])
39
+ assert result.exit_code == 1
40
+
41
+
42
+ def test_queries_top_json_output(monkeypatch):
43
+ mock_data = {"results": [{"query_sha": "abc123", "estimated_cost_usd": "100.00", "priority": "high"}]}
44
+
45
+ monkeypatch.setattr(
46
+ "cloudclerk.commands.queries.CloudClerkClient",
47
+ lambda: type("MockClient", (), {"list_queries": lambda self, **kw: mock_data})(),
48
+ )
49
+
50
+ result = runner.invoke(app, ["queries", "top", "--json"])
51
+ assert result.exit_code == 0
52
+ parsed = json.loads(result.output)
53
+ assert parsed["results"][0]["query_sha"] == "abc123"
54
+
55
+
56
+ def test_queries_show_json_output(monkeypatch):
57
+ mock_data = {"analysis": {"query_sha": "abc123", "priority": "high", "analysis_result": {"recommendations": []}}}
58
+
59
+ monkeypatch.setattr(
60
+ "cloudclerk.commands.queries.CloudClerkClient",
61
+ lambda: type("MockClient", (), {"get_query": lambda self, sha: mock_data})(),
62
+ )
63
+
64
+ result = runner.invoke(app, ["queries", "show", "abc123", "--json"])
65
+ assert result.exit_code == 0
66
+ parsed = json.loads(result.output)
67
+ assert parsed["analysis"]["query_sha"] == "abc123"
@@ -0,0 +1,39 @@
1
+ """Tests for config management."""
2
+
3
+ from pathlib import Path
4
+
5
+ from cloudclerk.config import get_config_path, load_config, save_config
6
+
7
+
8
+ def test_save_and_load_config(tmp_path, monkeypatch):
9
+ monkeypatch.setattr("cloudclerk.config.get_config_dir", lambda: tmp_path)
10
+ monkeypatch.setattr("cloudclerk.config.get_config_path", lambda: tmp_path / "config.toml")
11
+
12
+ path = save_config(api_key="cc_live_testkey123", server_url="https://example.com")
13
+ assert path.exists()
14
+
15
+ config = load_config()
16
+ assert config["auth"]["api_key"] == "cc_live_testkey123"
17
+ assert config["server"]["url"] == "https://example.com"
18
+
19
+
20
+ def test_load_config_returns_none_when_missing(tmp_path, monkeypatch):
21
+ monkeypatch.setattr("cloudclerk.config.get_config_path", lambda: tmp_path / "nonexistent.toml")
22
+ assert load_config() is None
23
+
24
+
25
+ def test_require_config_exits_when_missing(tmp_path, monkeypatch):
26
+ monkeypatch.setattr("cloudclerk.config.get_config_path", lambda: tmp_path / "nonexistent.toml")
27
+
28
+ import pytest
29
+
30
+ with pytest.raises(SystemExit):
31
+ from cloudclerk.config import require_config
32
+
33
+ require_config()
34
+
35
+
36
+ def test_get_config_path_is_in_home():
37
+ path = get_config_path()
38
+ assert ".cloudclerk" in str(path)
39
+ assert path.name == "config.toml"
@@ -0,0 +1,34 @@
1
+ """Tests for display formatting helpers."""
2
+
3
+ from cloudclerk.display import format_date, format_priority, format_savings, format_usd
4
+
5
+
6
+ def test_format_usd():
7
+ assert format_usd(1234.56) == "$1,234.56"
8
+ assert format_usd(0) == "$0.00"
9
+ assert format_usd(None) == "-"
10
+
11
+
12
+ def test_format_savings():
13
+ assert format_savings(100, 500) == "$100.00 - $500.00"
14
+ assert format_savings(None, None) == "-"
15
+
16
+
17
+ def test_format_priority_colors():
18
+ high = format_priority("high")
19
+ assert high.plain == "HIGH"
20
+
21
+ medium = format_priority("medium")
22
+ assert medium.plain == "MEDIUM"
23
+
24
+ low = format_priority("low")
25
+ assert low.plain == "LOW"
26
+
27
+ none = format_priority(None)
28
+ assert none.plain == "-"
29
+
30
+
31
+ def test_format_date():
32
+ assert format_date("2026-03-25T14:30:00Z") == "2026-03-25"
33
+ assert format_date(None) == "-"
34
+ assert format_date("") == "-"