Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,25 @@ packages = ["src/keboola_agent_cli"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
markers = [
"integration: marks tests as integration tests requiring real API credentials (deselect with '-m \"not integration\"')",
]

[tool.ruff]
target-version = "py312"
line-length = 100
src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "I", "W", "UP", "B", "SIM", "RUF"]
ignore = ["E501", "B008"]

[tool.ruff.lint.isort]
known-first-party = ["keboola_agent_cli"]

[dependency-groups]
dev = [
"pytest>=8",
"pytest-httpx>=0.30",
"ruff>=0.8",
]
9 changes: 3 additions & 6 deletions src/keboola_agent_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
Raises:
KeboolaApiError: On HTTP errors (with masked token) or after retries exhausted.
"""
last_exception: Exception | None = None
last_response: httpx.Response | None = None

for attempt in range(MAX_RETRIES):
Expand All @@ -69,7 +68,7 @@ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
return response

if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES - 1:
delay = BACKOFF_BASE * (2 ** attempt)
delay = BACKOFF_BASE * (2**attempt)
time.sleep(delay)
last_response = response
continue
Expand All @@ -78,9 +77,8 @@ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:

except httpx.TimeoutException as exc:
if attempt < MAX_RETRIES - 1:
delay = BACKOFF_BASE * (2 ** attempt)
delay = BACKOFF_BASE * (2**attempt)
time.sleep(delay)
last_exception = exc
continue
raise KeboolaApiError(
message=f"Request timed out connecting to {self._stack_url} (token: {self._masked_token})",
Expand All @@ -91,9 +89,8 @@ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:

except httpx.ConnectError as exc:
if attempt < MAX_RETRIES - 1:
delay = BACKOFF_BASE * (2 ** attempt)
delay = BACKOFF_BASE * (2**attempt)
time.sleep(delay)
last_exception = exc
continue
raise KeboolaApiError(
message=f"Cannot connect to {self._stack_url} (token: {self._masked_token})",
Expand Down
18 changes: 7 additions & 11 deletions src/keboola_agent_cli/commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@
No business logic belongs here.
"""

from typing import Optional

import typer

from ..errors import ConfigError, KeboolaApiError
Expand All @@ -30,17 +28,17 @@ def _get_service(ctx: typer.Context) -> ConfigService:
@config_app.command("list")
def config_list(
ctx: typer.Context,
project: Optional[list[str]] = typer.Option(
project: list[str] | None = typer.Option(
None,
"--project",
help="Project alias to query (can be repeated for multiple projects)",
),
component_type: Optional[str] = typer.Option(
component_type: str | None = typer.Option(
None,
"--component-type",
help="Filter by component type: extractor, writer, transformation, application",
),
component_id: Optional[str] = typer.Option(
component_id: str | None = typer.Option(
None,
"--component-id",
help="Filter by specific component ID (e.g. keboola.ex-db-snowflake)",
Expand All @@ -67,7 +65,7 @@ def config_list(
)
except ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
raise typer.Exit(code=5)
raise typer.Exit(code=5) from None

# In JSON mode, include both configs and errors in the response
if formatter.json_mode:
Expand All @@ -78,9 +76,7 @@ def config_list(

# Show error warnings on stderr too
for err in result.get("errors", []):
formatter.warning(
f"Project '{err['project_alias']}': {err['message']}"
)
formatter.warning(f"Project '{err['project_alias']}': {err['message']}")


@config_app.command("detail")
Expand All @@ -103,7 +99,7 @@ def config_detail(
formatter.output(result, format_config_detail)
except ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
raise typer.Exit(code=5)
raise typer.Exit(code=5) from None
except KeboolaApiError as exc:
if exc.error_code == "INVALID_TOKEN":
exit_code = 3
Expand All @@ -117,4 +113,4 @@ def config_detail(
project=project,
retryable=exc.retryable,
)
raise typer.Exit(code=exit_code)
raise typer.Exit(code=exit_code) from None
64 changes: 34 additions & 30 deletions src/keboola_agent_cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,10 @@
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table

from .. import __version__
from ..client import KeboolaClient
from ..config_store import ConfigStore
from ..errors import KeboolaApiError, mask_token
from ..errors import KeboolaApiError
from ..models import AppConfig
from ..output import OutputFormatter
from ..services.project_service import ClientFactory, default_client_factory
Expand Down Expand Up @@ -141,12 +139,14 @@ def _check_connectivity(
List of check result dicts, one per project.
"""
if config is None or not config.projects:
return [{
"check": "connectivity",
"name": "Project connectivity",
"status": "skip",
"message": "No projects configured.",
}]
return [
{
"check": "connectivity",
"name": "Project connectivity",
"status": "skip",
"message": "No projects configured.",
}
]

results = []
for alias, project in config.projects.items():
Expand All @@ -155,29 +155,33 @@ def _check_connectivity(
try:
token_info = client.verify_token()
elapsed = time.monotonic() - start_time
results.append({
"check": "connectivity",
"name": f"Project '{alias}'",
"status": "pass",
"message": (
f"Connected to {project.stack_url} "
f"(project: {token_info.project_name}, id: {token_info.project_id}) "
f"in {round(elapsed * 1000)}ms"
),
"alias": alias,
"response_time_ms": round(elapsed * 1000),
})
results.append(
{
"check": "connectivity",
"name": f"Project '{alias}'",
"status": "pass",
"message": (
f"Connected to {project.stack_url} "
f"(project: {token_info.project_name}, id: {token_info.project_id}) "
f"in {round(elapsed * 1000)}ms"
),
"alias": alias,
"response_time_ms": round(elapsed * 1000),
}
)
except KeboolaApiError as exc:
elapsed = time.monotonic() - start_time
results.append({
"check": "connectivity",
"name": f"Project '{alias}'",
"status": "fail",
"message": f"Failed: {exc.message}",
"alias": alias,
"error_code": exc.error_code,
"response_time_ms": round(elapsed * 1000),
})
results.append(
{
"check": "connectivity",
"name": f"Project '{alias}'",
"status": "fail",
"message": f"Failed: {exc.message}",
"alias": alias,
"error_code": exc.error_code,
"response_time_ms": round(elapsed * 1000),
}
)
finally:
client.close()

Expand Down
52 changes: 30 additions & 22 deletions src/keboola_agent_cli/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
No business logic belongs here.
"""

from typing import Any, Optional
from typing import Any

import typer
from rich.console import Console
Expand Down Expand Up @@ -106,21 +106,24 @@ def project_add(

try:
result = service.add_project(alias=alias, stack_url=url, token=token)
formatter.output(result, lambda c, d: c.print(
f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] added "
f"(project: {d['project_name']}, id: {d['project_id']})"
))
formatter.output(
result,
lambda c, d: c.print(
f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] added "
f"(project: {d['project_name']}, id: {d['project_id']})"
),
)
except KeboolaApiError as exc:
exit_code = 3 if exc.error_code == "INVALID_TOKEN" else 4
formatter.error(
message=exc.message,
error_code=exc.error_code,
retryable=exc.retryable,
)
raise typer.Exit(code=exit_code)
raise typer.Exit(code=exit_code) from None
except ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
raise typer.Exit(code=5)
raise typer.Exit(code=5) from None


@project_app.command("list")
Expand All @@ -134,7 +137,7 @@ def project_list(ctx: typer.Context) -> None:
formatter.output(projects, _format_project_table)
except ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
raise typer.Exit(code=5)
raise typer.Exit(code=5) from None


@project_app.command("remove")
Expand All @@ -148,47 +151,52 @@ def project_remove(

try:
result = service.remove_project(alias=alias)
formatter.output(result, lambda c, d: c.print(
f"[bold green]Success:[/bold green] {d['message']}"
))
formatter.output(
result, lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}")
)
except ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
raise typer.Exit(code=5)
raise typer.Exit(code=5) from None


@project_app.command("edit")
def project_edit(
ctx: typer.Context,
alias: str = typer.Option(..., help="Alias of the project to edit"),
url: Optional[str] = typer.Option(None, help="New Keboola stack URL"),
token: Optional[str] = typer.Option(None, help="New Storage API token"),
url: str | None = typer.Option(None, help="New Keboola stack URL"),
token: str | None = typer.Option(None, help="New Storage API token"),
) -> None:
"""Edit an existing Keboola project connection."""
formatter = _get_formatter(ctx)
service = _get_service(ctx)

try:
result = service.edit_project(alias=alias, stack_url=url, token=token)
formatter.output(result, lambda c, d: c.print(
f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] updated."
))
formatter.output(
result,
lambda c, d: c.print(
f"[bold green]Success:[/bold green] Project [bold]{d['alias']}[/bold] updated."
),
)
except KeboolaApiError as exc:
exit_code = 3 if exc.error_code == "INVALID_TOKEN" else 4
formatter.error(
message=exc.message,
error_code=exc.error_code,
retryable=exc.retryable,
)
raise typer.Exit(code=exit_code)
raise typer.Exit(code=exit_code) from None
except ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
raise typer.Exit(code=5)
raise typer.Exit(code=5) from None


@project_app.command("status")
def project_status(
ctx: typer.Context,
project: Optional[str] = typer.Option(None, "--project", help="Check only this project (default: all)"),
project: str | None = typer.Option(
None, "--project", help="Check only this project (default: all)"
),
) -> None:
"""Test connectivity to connected Keboola projects."""
formatter = _get_formatter(ctx)
Expand All @@ -201,12 +209,12 @@ def project_status(
formatter.output(statuses, _format_status_table)
except ConfigError as exc:
formatter.error(message=exc.message, error_code="CONFIG_ERROR")
raise typer.Exit(code=5)
raise typer.Exit(code=5) from None
except KeboolaApiError as exc:
exit_code = 3 if exc.error_code == "INVALID_TOKEN" else 4
formatter.error(
message=exc.message,
error_code=exc.error_code,
retryable=exc.retryable,
)
raise typer.Exit(code=exit_code)
raise typer.Exit(code=exit_code) from None
7 changes: 7 additions & 0 deletions src/keboola_agent_cli/config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,19 @@ def load(self) -> AppConfig:
raw = self._config_path.read_text(encoding="utf-8")
except OSError as exc:
raise ConfigError(f"Cannot read config file {self._config_path}: {exc}") from exc
except UnicodeDecodeError as exc:
raise ConfigError(f"Config file is not valid UTF-8 text: {exc}") from exc

try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise ConfigError(f"Config file is not valid JSON: {exc}") from exc

if not isinstance(data, dict):
raise ConfigError(
f"Config file has invalid structure: expected JSON object, got {type(data).__name__}"
)

version = data.get("version", 1)
if version > CURRENT_CONFIG_VERSION:
raise ConfigError(
Expand Down
2 changes: 1 addition & 1 deletion src/keboola_agent_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def mask_token(token: str) -> str:
if dash_index == -1 or dash_index >= len(token) - 4:
return "***"

prefix = token[: dash_index]
prefix = token[:dash_index]
last4 = token[-4:]
return f"{prefix}-...{last4}"

Expand Down
4 changes: 3 additions & 1 deletion src/keboola_agent_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ class ProjectConfig(BaseModel):

stack_url: str = Field(description="Keboola stack URL, e.g. https://connection.keboola.com")
token: str = Field(description="Storage API token")
project_name: str = Field(default="", description="Human-readable project name (populated on add)")
project_name: str = Field(
default="", description="Human-readable project name (populated on add)"
)
project_id: int = Field(default=0, description="Keboola project ID (populated on add)")


Expand Down
Loading