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.
- cloudclerk-0.1.0/.github/workflows/ci.yml +31 -0
- cloudclerk-0.1.0/.github/workflows/publish.yml +35 -0
- cloudclerk-0.1.0/.gitignore +29 -0
- cloudclerk-0.1.0/PKG-INFO +115 -0
- cloudclerk-0.1.0/README.md +81 -0
- cloudclerk-0.1.0/pyproject.toml +58 -0
- cloudclerk-0.1.0/src/cloudclerk/__init__.py +3 -0
- cloudclerk-0.1.0/src/cloudclerk/__main__.py +5 -0
- cloudclerk-0.1.0/src/cloudclerk/cli.py +29 -0
- cloudclerk-0.1.0/src/cloudclerk/client.py +93 -0
- cloudclerk-0.1.0/src/cloudclerk/commands/__init__.py +0 -0
- cloudclerk-0.1.0/src/cloudclerk/commands/configure.py +51 -0
- cloudclerk-0.1.0/src/cloudclerk/commands/queries.py +62 -0
- cloudclerk-0.1.0/src/cloudclerk/config.py +85 -0
- cloudclerk-0.1.0/src/cloudclerk/display.py +259 -0
- cloudclerk-0.1.0/tests/__init__.py +0 -0
- cloudclerk-0.1.0/tests/test_client.py +71 -0
- cloudclerk-0.1.0/tests/test_commands.py +67 -0
- cloudclerk-0.1.0/tests/test_config.py +39 -0
- cloudclerk-0.1.0/tests/test_display.py +34 -0
|
@@ -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,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("") == "-"
|