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
19 changes: 18 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1566,7 +1566,24 @@
],
"responses": {
"200": {
"description": "Successful Response"
"description": "Successful Response",
"content": {
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
}
},
"headers": {
"Content-Disposition": {
"description": "Suggests a filename for the downloaded ZIP file",
"schema": {
"type": "string"
},
"example": "attachment; filename=\"archive.zip\""
}
}
},
"422": {
"description": "Validation Error",
Expand Down
5 changes: 5 additions & 0 deletions scripts/datamodel_generate_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ def get_response_type(self, responses: dict[str, Any]) -> str:
if "application/json" in content:
schema = content["application/json"].get("schema", {})
return self._get_python_type(schema, resolve_ref=True)
if "application/zip" in content:
schema = content["application/zip"].get("schema", {})
return self._get_python_type(schema, resolve_ref=True)

# Check for any 2xx response
for status, response in responses.items():
Expand Down Expand Up @@ -808,6 +811,8 @@ async def send(self, request: Request, type_: Type[T]) -> T | str:
try:
# Use Pydantic v2 TypeAdapter for validation
adapter = TypeAdapter(type_)
if type_ == bytes:
return adapter.validate_python(response.content)
return adapter.validate_python(response.json()) if type_ else response.text
except Exception as e:
raise ResponseHandlingException(e)
Expand Down
8 changes: 4 additions & 4 deletions th_cli/api_lib_autogen/api/test_run_executions_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,14 @@ def _build_for_download_log_api_v1_test_run_executions__id__log_get(

def _build_for_download_grouped_log_api_v1_test_run_executions__id__grouped_log_get(
self, id: int
) -> Coroutine[Any, Any, None]:
) -> Coroutine[Any, Any, bytes]:
"""
Download Grouped Log
"""
path_params = {"id": str(id)}

return self.api_client.request(
type_=None, method="GET", url="/api/v1/test_run_executions/{id}/grouped-log", path_params=path_params
type_=bytes, method="GET", url="/api/v1/test_run_executions/{id}/grouped-log", path_params=path_params
)

def _build_for_upload_file_api_v1_test_run_executions_file_upload__post(
Expand Down Expand Up @@ -514,7 +514,7 @@ async def download_log_api_v1_test_run_executions__id__log_get(
id=id, json_entries=json_entries, download=download
)

async def download_grouped_log_api_v1_test_run_executions__id__grouped_log_get(self, id: int) -> None:
async def download_grouped_log_api_v1_test_run_executions__id__grouped_log_get(self, id: int) -> bytes:
"""
Download Grouped Log
"""
Expand Down Expand Up @@ -706,7 +706,7 @@ def download_log_api_v1_test_run_executions__id__log_get(
)
return get_event_loop().run_until_complete(coroutine)

def download_grouped_log_api_v1_test_run_executions__id__grouped_log_get(self, id: int) -> None:
def download_grouped_log_api_v1_test_run_executions__id__grouped_log_get(self, id: int) -> bytes:
"""
Download Grouped Log
"""
Expand Down
2 changes: 2 additions & 0 deletions th_cli/api_lib_autogen/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ async def send(self, request: Request, type_: Type[T]) -> T | str:
try:
# Use Pydantic v2 TypeAdapter for validation
adapter = TypeAdapter(type_)
if type_ == bytes:
return adapter.validate_python(response.content)
return adapter.validate_python(response.json()) if type_ else response.text
except Exception as e:
raise ResponseHandlingException(e)
Expand Down
233 changes: 172 additions & 61 deletions th_cli/commands/test_run_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,71 +27,113 @@
table_format_header = "{:<6} {:<55} {}"
table_format = "{:<6} {} {}"

_list_options = [
click.option(
"--id",
"-i",
default=None,
required=False,
type=int,
help=colorize_help("Fetch specific Test Run via ID"),
),
click.option(
"--skip",
"-s",
default=None,
required=False,
type=int,
help=colorize_help("The first N Test Runs to skip, ordered by ID"),
),
click.option(
"--limit",
"-l",
default=None,
required=False,
type=int,
help=colorize_help("Maximum number of test runs to fetch (default: 100)"),
),
click.option(
"--sort",
default="desc",
required=False,
type=click.Choice(["asc", "desc"], case_sensitive=False),
help=colorize_help(
"Sort order for test runs by ID. 'desc' shows highest ID first, 'asc' shows lowest ID first"
),
),
click.option(
"--project-id",
"-p",
default=None,
required=False,
type=int,
help=colorize_help("Filter test runs by project ID"),
),
click.option(
"--log",
is_flag=True,
default=False,
help=colorize_help("Fetch log content for the specified test run execution ID (requires --id)"),
deprecated="Use the log command",
),
click.option(
"--json",
is_flag=True,
default=False,
help=colorize_help("Print JSON response for more details (not applicable with --log)"),
),
click.option(
"--all",
is_flag=True,
default=False,
help=colorize_help("Fetch all test run executions with screen pagination (cannot be used with --limit)"),
),
]


def add_options(options):
def _add_options(func):
for option in reversed(options): # reversed preserves order
func = option(func)
return func

return _add_options


@click.command(
@click.group(
short_help=colorize_help("Manage test run executions"),
help=colorize_cmd_help(
"test_run_execution", "List test run execution history or fetch logs for a specific execution"
),
invoke_without_command=True,
)
@click.option(
"--id",
"-i",
default=None,
required=False,
type=int,
help=colorize_help("Fetch specific Test Run via ID"),
)
@click.option(
"--skip",
"-s",
default=None,
required=False,
type=int,
help=colorize_help("The first N Test Runs to skip, ordered by ID"),
)
@click.option(
"--limit",
"-l",
default=None,
required=False,
type=int,
help=colorize_help("Maximum number of test runs to fetch (default: 100)"),
)
@click.option(
"--sort",
default="desc",
required=False,
type=click.Choice(["asc", "desc"], case_sensitive=False),
help=colorize_help("Sort order for test runs by ID. 'desc' shows highest ID first, 'asc' shows lowest ID first"),
)
@click.option(
"--project-id",
"-p",
default=None,
required=False,
type=int,
help=colorize_help("Filter test runs by project ID"),
)
@click.option(
"--log",
is_flag=True,
default=False,
help=colorize_help("Fetch log content for the specified test run execution ID (requires --id)"),
)
@click.option(
"--json",
is_flag=True,
default=False,
help=colorize_help("Print JSON response for more details (not applicable with --log)"),
)
@click.option(
"--all",
is_flag=True,
default=False,
help=colorize_help("Fetch all test run executions with screen pagination (cannot be used with --limit)"),
)
@click.pass_context
# For the sake of backwards-compatibility, these arguments are applied to both the base command
# as well as the list command
@add_options(_list_options)
def test_run_execution(
ctx,
id: int | None,
skip: int | None,
limit: int | None,
sort: str,
project_id: int | None,
log: bool,
json: bool,
all: bool,
) -> None:
"""Manage test run executions - list history or fetch logs"""
if ctx.invoked_subcommand is None:
ctx.forward(list_executions)


@test_run_execution.command(
name="list",
short_help=colorize_help("List test run executions"),
help=colorize_cmd_help("list", "List test run execution history"),
)
@add_options(_list_options)
def list_executions(
id: int | None,
skip: int | None,
limit: int | None,
Expand Down Expand Up @@ -129,7 +171,7 @@ def test_run_execution(
sync_apis = SyncApis(client)

if log:
__fetch_test_run_execution_log(sync_apis, id)
__fetch_test_run_execution_log(sync_apis, id, None)
elif id is not None:
__test_run_execution_by_id(sync_apis, id, json)
else:
Expand All @@ -139,6 +181,44 @@ def test_run_execution(
raise # Re-raise CLI Errors as-is


@test_run_execution.command(
short_help=colorize_help("Fetch test run execution logs"),
help=colorize_cmd_help("log", "Fetch logs for a specific execution"),
)
@click.option(
"--id",
"-i",
required=True,
type=int,
help=colorize_help("Fetch specific Test Run logs via ID"),
)
@click.option(
"--output-file",
"-o",
required=False,
type=str,
help=colorize_help("Output file. Test run execution title will be used by default"),
)
@click.option(
"--grouped",
is_flag=True,
default=False,
help=colorize_help("Download a zip archive of the grouped logs"),
)
def log(id: int, output_file: str, grouped: bool) -> None:
try:
with closing(get_client()) as client:
sync_apis = SyncApis(client)

if grouped:
__fetch_grouped_test_run_execution_log(sync_apis, id, output_file)
else:
__fetch_test_run_execution_log(sync_apis, id, output_file)

except CLIError:
raise # Re-raise CLI Errors as-is


def __test_run_execution_by_id(sync_apis: SyncApis, id: int, json: bool) -> None:
try:
test_run_execution_api = sync_apis.test_run_executions_api
Expand Down Expand Up @@ -257,22 +337,53 @@ def __test_run_execution_batch(
handle_api_error(e, "get test run executions")


def __fetch_test_run_execution_log(sync_apis: SyncApis, id: int) -> None:
def __fetch_test_run_execution_log(sync_apis: SyncApis, id: int, output_file: str | None) -> None:
try:
test_run_execution_api = sync_apis.test_run_executions_api
log_content = test_run_execution_api.download_log_api_v1_test_run_executions__id__log_get(
id=id, json_entries=False, download=False
)

if log_content:
click.echo(log_content)
if output_file:
with open(output_file, "w", encoding="utf-8") as outfile:
outfile.write(log_content)
else:
click.echo(log_content)
else:
click.echo("No log content available for this test run execution.")

except UnexpectedResponse as e:
handle_api_error(e, "fetch test run execution log")


def __fetch_grouped_test_run_execution_log(sync_apis: SyncApis, id: int, output_file: str | None) -> None:
try:
test_run_execution_api = sync_apis.test_run_executions_api
log_content = test_run_execution_api.download_grouped_log_api_v1_test_run_executions__id__grouped_log_get(id=id)

if log_content:
if not output_file:
execution_data = test_run_execution_api.read_test_run_execution_api_v1_test_run_executions__id__get(
id=id
)
if execution_data:
import re

output_file = re.sub(r"[^\w]", "", execution_data.title) + ".zip"
else:
output_file = f"test_run_execution_{id}_grouped.zip"

with open(output_file, "wb") as outfile:
outfile.write(log_content)

else:
click.echo("No log content available for this test run execution.")

except UnexpectedResponse as e:
handle_api_error(e, "fetch grouped test run execution log")


def __print_table_test_executions(test_execution: list) -> None:
__print_table_header()
if isinstance(test_execution, list):
Expand Down