Skip to content

Commit e3d2bff

Browse files
committed
Merge branch 'stable' into fac-merge-stable-into-develop
2 parents 89426c6 + 7c63009 commit e3d2bff

File tree

6 files changed

+260
-4
lines changed

6 files changed

+260
-4
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# `infrahubctl task`
2+
3+
Manage Infrahub tasks.
4+
5+
**Usage**:
6+
7+
```console
8+
$ infrahubctl task [OPTIONS] COMMAND [ARGS]...
9+
```
10+
11+
**Options**:
12+
13+
* `--install-completion`: Install completion for the current shell.
14+
* `--show-completion`: Show completion for the current shell, to copy it or customize the installation.
15+
* `--help`: Show this message and exit.
16+
17+
**Commands**:
18+
19+
* `list`: List Infrahub tasks.
20+
21+
## `infrahubctl task list`
22+
23+
List Infrahub tasks.
24+
25+
**Usage**:
26+
27+
```console
28+
$ infrahubctl task list [OPTIONS]
29+
```
30+
31+
**Options**:
32+
33+
* `-s, --state TEXT`: Filter by task state. Can be provided multiple times.
34+
* `--limit INTEGER`: Maximum number of tasks to retrieve.
35+
* `--offset INTEGER`: Offset for pagination.
36+
* `--include-related-nodes / --no-include-related-nodes`: Include related nodes in the output. [default: no-include-related-nodes]
37+
* `--include-logs / --no-include-logs`: Include task logs in the output. [default: no-include-logs]
38+
* `--json`: Output the result as JSON.
39+
* `--debug / --no-debug`: [default: no-debug]
40+
* `--config-file TEXT`: [env var: INFRAHUBCTL_CONFIG; default: infrahubctl.toml]
41+
* `--help`: Show this message and exit.

docs/sidebars-infrahubctl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const sidebars: SidebarsConfig = {
2424
'infrahubctl-repository',
2525
'infrahubctl-run',
2626
'infrahubctl-schema',
27+
'infrahubctl-task',
2728
'infrahubctl-transform',
2829
'infrahubctl-validate',
2930
'infrahubctl-version'

infrahub_sdk/ctl/cli_commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from ..ctl.repository import app as repository_app
3434
from ..ctl.repository import get_repository_config
3535
from ..ctl.schema import app as schema_app
36+
from ..ctl.task import app as task_app
3637
from ..ctl.transform import list_transforms
3738
from ..ctl.utils import (
3839
catch_exception,
@@ -65,6 +66,7 @@
6566
app.add_typer(menu_app, name="menu")
6667
app.add_typer(object_app, name="object")
6768
app.add_typer(graphql_app, name="graphql")
69+
app.add_typer(task_app, name="task")
6870

6971
app.command(name="dump")(dump)
7072
app.command(name="load")(load)

infrahub_sdk/ctl/task.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional
4+
5+
import typer
6+
from rich.console import Console
7+
from rich.table import Table
8+
9+
from ..async_typer import AsyncTyper
10+
from ..task.manager import TaskFilter
11+
from ..task.models import Task, TaskState
12+
from .client import initialize_client
13+
from .parameters import CONFIG_PARAM
14+
from .utils import catch_exception, init_logging
15+
16+
app = AsyncTyper()
17+
console = Console()
18+
19+
20+
@app.callback()
21+
def callback() -> None:
22+
"""Manage Infrahub tasks."""
23+
24+
25+
def _parse_states(states: list[str] | None) -> list[TaskState] | None:
26+
if not states:
27+
return None
28+
29+
parsed_states: list[TaskState] = []
30+
for state in states:
31+
normalized_state = state.strip().upper()
32+
try:
33+
parsed_states.append(TaskState(normalized_state))
34+
except ValueError as exc: # pragma: no cover - typer will surface this as CLI error
35+
raise typer.BadParameter(
36+
f"Unsupported state '{state}'. Available states: {', '.join(item.value.lower() for item in TaskState)}"
37+
) from exc
38+
39+
return parsed_states
40+
41+
42+
def _render_table(tasks: list[Task]) -> None:
43+
table = Table(title="Infrahub Tasks", box=None)
44+
table.add_column("ID", style="cyan", overflow="fold")
45+
table.add_column("Title", style="magenta", overflow="fold")
46+
table.add_column("State", style="green")
47+
table.add_column("Progress", justify="right")
48+
table.add_column("Workflow", overflow="fold")
49+
table.add_column("Branch", overflow="fold")
50+
table.add_column("Updated")
51+
52+
if not tasks:
53+
table.add_row("-", "No tasks found", "-", "-", "-", "-", "-")
54+
console.print(table)
55+
return
56+
57+
for task in tasks:
58+
progress = f"{task.progress:.0%}" if task.progress is not None else "-"
59+
table.add_row(
60+
task.id,
61+
task.title,
62+
task.state.value,
63+
progress,
64+
task.workflow or "-",
65+
task.branch or "-",
66+
task.updated_at.isoformat(),
67+
)
68+
69+
console.print(table)
70+
71+
72+
@app.command(name="list")
73+
@catch_exception(console=console)
74+
async def list_tasks(
75+
state: list[str] = typer.Option(
76+
None, "--state", "-s", help="Filter by task state. Can be provided multiple times."
77+
),
78+
limit: Optional[int] = typer.Option(None, help="Maximum number of tasks to retrieve."),
79+
offset: Optional[int] = typer.Option(None, help="Offset for pagination."),
80+
include_related_nodes: bool = typer.Option(False, help="Include related nodes in the output."),
81+
include_logs: bool = typer.Option(False, help="Include task logs in the output."),
82+
json_output: bool = typer.Option(False, "--json", help="Output the result as JSON."),
83+
debug: bool = False,
84+
_: str = CONFIG_PARAM,
85+
) -> None:
86+
"""List Infrahub tasks."""
87+
88+
init_logging(debug=debug)
89+
90+
client = initialize_client()
91+
filters = TaskFilter()
92+
parsed_states = _parse_states(state)
93+
if parsed_states:
94+
filters.state = parsed_states
95+
96+
tasks = await client.task.filter(
97+
filter=filters,
98+
limit=limit,
99+
offset=offset,
100+
include_related_nodes=include_related_nodes,
101+
include_logs=include_logs,
102+
)
103+
104+
if json_output:
105+
console.print_json(
106+
data=[task.model_dump(mode="json") for task in tasks], indent=2, sort_keys=True, highlight=False
107+
)
108+
return
109+
110+
_render_table(tasks)

infrahub_sdk/task/models.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,14 @@ def from_graphql(cls, data: dict) -> Task:
4949
related_nodes: list[TaskRelatedNode] = []
5050
logs: list[TaskLog] = []
5151

52-
if data.get("related_nodes"):
53-
related_nodes = [TaskRelatedNode(**item) for item in data["related_nodes"]]
52+
if "related_nodes" in data:
53+
if data.get("related_nodes"):
54+
related_nodes = [TaskRelatedNode(**item) for item in data["related_nodes"]]
5455
del data["related_nodes"]
5556

56-
if data.get("logs"):
57-
logs = [TaskLog(**item["node"]) for item in data["logs"]["edges"]]
57+
if "logs" in data:
58+
if data.get("logs"):
59+
logs = [TaskLog(**item["node"]) for item in data["logs"]["edges"]]
5860
del data["logs"]
5961

6062
return cls(**data, related_nodes=related_nodes, logs=logs)

tests/unit/ctl/test_task_app.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from datetime import datetime, timezone
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
from typer.testing import CliRunner
9+
10+
from infrahub_sdk.ctl.task import app
11+
12+
runner = CliRunner()
13+
14+
pytestmark = pytest.mark.httpx_mock(can_send_already_matched_responses=True)
15+
16+
if TYPE_CHECKING:
17+
from pytest_httpx import HTTPXMock
18+
19+
20+
def _task_response() -> dict:
21+
now = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc).isoformat()
22+
return {
23+
"data": {
24+
"InfrahubTask": {
25+
"edges": [
26+
{
27+
"node": {
28+
"id": "task-1",
29+
"title": "Sync repositories",
30+
"state": "RUNNING",
31+
"progress": 0.5,
32+
"workflow": "RepositorySync",
33+
"branch": "main",
34+
"created_at": now,
35+
"updated_at": now,
36+
"logs": {"edges": []},
37+
"related_nodes": [],
38+
}
39+
}
40+
],
41+
"count": 1,
42+
}
43+
}
44+
}
45+
46+
47+
def _empty_task_response() -> dict:
48+
return {"data": {"InfrahubTask": {"edges": [], "count": 0}}}
49+
50+
51+
def test_task_list_command(httpx_mock: HTTPXMock) -> None:
52+
httpx_mock.add_response(
53+
method="POST",
54+
url="http://mock/graphql/main",
55+
json=_task_response(),
56+
match_headers={"X-Infrahub-Tracker": "query-tasks-page1"},
57+
)
58+
59+
result = runner.invoke(app=app, args=["list"])
60+
61+
assert result.exit_code == 0
62+
assert "Infrahub Tasks" in result.stdout
63+
assert "Sync repositories" in result.stdout
64+
65+
66+
def test_task_list_json_output(httpx_mock: HTTPXMock) -> None:
67+
httpx_mock.add_response(
68+
method="POST",
69+
url="http://mock/graphql/main",
70+
json=_task_response(),
71+
match_headers={"X-Infrahub-Tracker": "query-tasks-page1"},
72+
)
73+
74+
result = runner.invoke(app=app, args=["list", "--json"])
75+
76+
assert result.exit_code == 0
77+
payload = json.loads(result.stdout)
78+
assert payload[0]["state"] == "RUNNING"
79+
assert payload[0]["title"] == "Sync repositories"
80+
81+
82+
def test_task_list_with_state_filter(httpx_mock: HTTPXMock) -> None:
83+
httpx_mock.add_response(
84+
method="POST",
85+
url="http://mock/graphql/main",
86+
json=_empty_task_response(),
87+
match_headers={"X-Infrahub-Tracker": "query-tasks-page1"},
88+
)
89+
90+
result = runner.invoke(app=app, args=["list", "--state", "running"])
91+
92+
assert result.exit_code == 0
93+
assert "No tasks found" in result.stdout
94+
95+
96+
def test_task_list_invalid_state() -> None:
97+
result = runner.invoke(app=app, args=["list", "--state", "invalid"])
98+
99+
assert result.exit_code != 0
100+
assert "Unsupported state" in result.stdout

0 commit comments

Comments
 (0)