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
41 changes: 41 additions & 0 deletions docs/docs/infrahubctl/infrahubctl-task.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# `infrahubctl task`

Manage Infrahub tasks.

**Usage**:

```console
$ infrahubctl task [OPTIONS] COMMAND [ARGS]...
```

**Options**:

* `--install-completion`: Install completion for the current shell.
* `--show-completion`: Show completion for the current shell, to copy it or customize the installation.
* `--help`: Show this message and exit.

**Commands**:

* `list`: List Infrahub tasks.

## `infrahubctl task list`

List Infrahub tasks.

**Usage**:

```console
$ infrahubctl task list [OPTIONS]
```

**Options**:

* `-s, --state TEXT`: Filter by task state. Can be provided multiple times.
* `--limit INTEGER`: Maximum number of tasks to retrieve.
* `--offset INTEGER`: Offset for pagination.
* `--include-related-nodes / --no-include-related-nodes`: Include related nodes in the output. [default: no-include-related-nodes]
* `--include-logs / --no-include-logs`: Include task logs in the output. [default: no-include-logs]
* `--json`: Output the result as JSON.
* `--debug / --no-debug`: [default: no-debug]
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
* `--help`: Show this message and exit.
1 change: 1 addition & 0 deletions docs/sidebars-infrahubctl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const sidebars: SidebarsConfig = {
'infrahubctl-repository',
'infrahubctl-run',
'infrahubctl-schema',
'infrahubctl-task',
'infrahubctl-transform',
'infrahubctl-validate',
'infrahubctl-version'
Expand Down
2 changes: 2 additions & 0 deletions infrahub_sdk/ctl/cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from ..ctl.repository import app as repository_app
from ..ctl.repository import get_repository_config
from ..ctl.schema import app as schema_app
from ..ctl.task import app as task_app
from ..ctl.transform import list_transforms
from ..ctl.utils import (
catch_exception,
Expand Down Expand Up @@ -65,6 +66,7 @@
app.add_typer(menu_app, name="menu")
app.add_typer(object_app, name="object")
app.add_typer(graphql_app, name="graphql")
app.add_typer(task_app, name="task")

app.command(name="dump")(dump)
app.command(name="load")(load)
Expand Down
110 changes: 110 additions & 0 deletions infrahub_sdk/ctl/task.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from __future__ import annotations

from typing import Optional

import typer
from rich.console import Console
from rich.table import Table

from ..async_typer import AsyncTyper
from ..task.manager import TaskFilter
from ..task.models import Task, TaskState
Comment on lines +10 to +11
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify TaskFilter import path.

TaskFilter is imported from ..task.manager but the relevant code snippets show it's defined in infrahub_sdk/task/models.py. Please confirm whether TaskFilter is re-exported from manager or if the import should be from ..task.models instead.


🏁 Script executed:

#!/bin/bash
# Verify where TaskFilter is defined and exported
echo "=== Checking TaskFilter definition ==="
rg -n "^class TaskFilter" --type=py

echo -e "\n=== Checking TaskFilter exports from manager ==="
rg -n "TaskFilter" infrahub_sdk/task/manager.py

echo -e "\n=== Checking TaskFilter exports from models ==="
rg -n "^from.*TaskFilter|^import.*TaskFilter|__all__.*TaskFilter" infrahub_sdk/task/models.py

Length of output: 1015



🏁 Script executed:

#!/bin/bash
# Check if manager.py explicitly defines __all__
echo "=== Checking for __all__ in manager.py ==="
rg -n "__all__" infrahub_sdk/task/manager.py

# Check import patterns across the codebase for comparison
echo -e "\n=== Checking import patterns from task module ==="
rg -n "from.*task\.(manager|models)" --type=py | head -20

# Verify the exact import context in manager.py
echo -e "\n=== First 25 lines of manager.py ==="
head -25 infrahub_sdk/task/manager.py

Length of output: 1346


Update import to use consistent path from models.

TaskFilter is defined in infrahub_sdk/task/models.py and should be imported directly from there. While manager.py imports and uses TaskFilter, it does not explicitly re-export it (no __all__ definition), and the codebase consistently imports it from models elsewhere. Change line 10 from:

from ..task.manager import TaskFilter

to:

from ..task.models import TaskFilter

This aligns with the pattern used in tests and makes the import source consistent with the other items on line 11.

🤖 Prompt for AI Agents
In infrahub_sdk/ctl/task.py around lines 10 to 11, the TaskFilter import should
come from the models module to match the codebase pattern; replace the current
import from ..task.manager with an import from ..task.models so both TaskFilter,
Task, and TaskState are imported from infrahub_sdk/task/models.py, ensuring
consistent import sources and avoiding reliance on manager re-exports.

from .client import initialize_client
from .parameters import CONFIG_PARAM
from .utils import catch_exception, init_logging

app = AsyncTyper()
console = Console()


@app.callback()
def callback() -> None:
"""Manage Infrahub tasks."""


def _parse_states(states: list[str] | None) -> list[TaskState] | None:
if not states:
return None

parsed_states: list[TaskState] = []
for state in states:
normalized_state = state.strip().upper()
try:
parsed_states.append(TaskState(normalized_state))
except ValueError as exc: # pragma: no cover - typer will surface this as CLI error
raise typer.BadParameter(
f"Unsupported state '{state}'. Available states: {', '.join(item.value.lower() for item in TaskState)}"
) from exc

return parsed_states


def _render_table(tasks: list[Task]) -> None:
table = Table(title="Infrahub Tasks", box=None)
table.add_column("ID", style="cyan", overflow="fold")
table.add_column("Title", style="magenta", overflow="fold")
table.add_column("State", style="green")
table.add_column("Progress", justify="right")
table.add_column("Workflow", overflow="fold")
table.add_column("Branch", overflow="fold")
table.add_column("Updated")

if not tasks:
table.add_row("-", "No tasks found", "-", "-", "-", "-", "-")
console.print(table)
return

for task in tasks:
progress = f"{task.progress:.0%}" if task.progress is not None else "-"
table.add_row(
task.id,
task.title,
task.state.value,
progress,
task.workflow or "-",
task.branch or "-",
task.updated_at.isoformat(),
)

console.print(table)


@app.command(name="list")
@catch_exception(console=console)
async def list_tasks(
state: list[str] = typer.Option(
None, "--state", "-s", help="Filter by task state. Can be provided multiple times."
),
limit: Optional[int] = typer.Option(None, help="Maximum number of tasks to retrieve."),
offset: Optional[int] = typer.Option(None, help="Offset for pagination."),
include_related_nodes: bool = typer.Option(False, help="Include related nodes in the output."),
include_logs: bool = typer.Option(False, help="Include task logs in the output."),
json_output: bool = typer.Option(False, "--json", help="Output the result as JSON."),
debug: bool = False,
_: str = CONFIG_PARAM,
) -> None:
"""List Infrahub tasks."""

init_logging(debug=debug)

client = initialize_client()
filters = TaskFilter()
parsed_states = _parse_states(state)
if parsed_states:
filters.state = parsed_states

tasks = await client.task.filter(
filter=filters,
limit=limit,
offset=offset,
include_related_nodes=include_related_nodes,
include_logs=include_logs,
)

if json_output:
console.print_json(
data=[task.model_dump(mode="json") for task in tasks], indent=2, sort_keys=True, highlight=False
)
return

_render_table(tasks)
10 changes: 6 additions & 4 deletions infrahub_sdk/task/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,14 @@ def from_graphql(cls, data: dict) -> Task:
related_nodes: list[TaskRelatedNode] = []
logs: list[TaskLog] = []

if data.get("related_nodes"):
related_nodes = [TaskRelatedNode(**item) for item in data["related_nodes"]]
if "related_nodes" in data:
if data.get("related_nodes"):
related_nodes = [TaskRelatedNode(**item) for item in data["related_nodes"]]
del data["related_nodes"]

if data.get("logs"):
logs = [TaskLog(**item["node"]) for item in data["logs"]["edges"]]
if "logs" in data:
if data.get("logs"):
logs = [TaskLog(**item["node"]) for item in data["logs"]["edges"]]
del data["logs"]

return cls(**data, related_nodes=related_nodes, logs=logs)
Expand Down
100 changes: 100 additions & 0 deletions tests/unit/ctl/test_task_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import annotations

import json
from datetime import datetime, timezone
from typing import TYPE_CHECKING

import pytest
from typer.testing import CliRunner

from infrahub_sdk.ctl.task import app

runner = CliRunner()

pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True)

if TYPE_CHECKING:
from pytest_httpx import HTTPXMock


def _task_response() -> dict:
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc).isoformat()
return {
"data": {
"InfrahubTask": {
"edges": [
{
"node": {
"id": "task-1",
"title": "Sync repositories",
"state": "RUNNING",
"progress": 0.5,
"workflow": "RepositorySync",
"branch": "main",
"created_at": now,
"updated_at": now,
"logs": {"edges": []},
"related_nodes": [],
}
}
],
"count": 1,
}
}
}


def _empty_task_response() -> dict:
return {"data": {"InfrahubTask": {"edges": [], "count": 0}}}


def test_task_list_command(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
method="POST",
url="http://mock/graphql/main",
json=_task_response(),
match_headers={"X-Infrahub-Tracker": "query-tasks-page1"},
)

result = runner.invoke(app=app, args=["list"])

assert result.exit_code == 0
assert "Infrahub Tasks" in result.stdout
assert "Sync repositories" in result.stdout


def test_task_list_json_output(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
method="POST",
url="http://mock/graphql/main",
json=_task_response(),
match_headers={"X-Infrahub-Tracker": "query-tasks-page1"},
)

result = runner.invoke(app=app, args=["list", "--json"])

assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload[0]["state"] == "RUNNING"
assert payload[0]["title"] == "Sync repositories"


def test_task_list_with_state_filter(httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
method="POST",
url="http://mock/graphql/main",
json=_empty_task_response(),
match_headers={"X-Infrahub-Tracker": "query-tasks-page1"},
)

result = runner.invoke(app=app, args=["list", "--state", "running"])

assert result.exit_code == 0
assert "No tasks found" in result.stdout


def test_task_list_invalid_state() -> None:
result = runner.invoke(app=app, args=["list", "--state", "invalid"])

assert result.exit_code != 0
assert "Unsupported state" in result.stdout
Loading