Files
pristontale-eu-linux/src/ptlinux/application/common.py

246 lines
6.4 KiB
Python

"""Shared CLI telemetry, result reporting, and output helpers."""
from __future__ import annotations
import argparse
import json
import signal
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from ..command import CommandError
from ..paths import AppPaths
from ..telemetry import TelemetryService
CommandTelemetryEvents = tuple[str, str, str, str]
@dataclass(frozen=True, slots=True)
class CommandTelemetryLifecycle:
telemetry: TelemetryService | None
events: CommandTelemetryEvents | None
started_at: float
owner: bool
def start_command_telemetry(
api: Any,
paths: AppPaths,
command: str,
arguments: argparse.Namespace,
manage_telemetry: bool,
) -> CommandTelemetryLifecycle:
telemetry: TelemetryService | None = None
telemetry_owner = False
if manage_telemetry:
interface = "gui" if command == "gui" else "cli"
telemetry, telemetry_owner = api.initialize_shared_telemetry(
paths,
session_metadata={"interface": interface, "command": command},
report_launcher_lifecycle=command == "gui",
)
events = api._command_telemetry_events(command, arguments, paths)
started_at = time.monotonic()
if telemetry is not None and events is not None:
operation, started_event, _success_event, _failed_event = events
if operation == "repair":
telemetry.send_event(
"repair_requested",
metadata={"command": command, "reason": "user_requested"},
context={"operation": operation, "stage": "requested"},
)
telemetry.send_event(
started_event,
metadata={"command": command},
context={"operation": operation, "stage": "started"},
)
return CommandTelemetryLifecycle(
telemetry=telemetry,
events=events,
started_at=started_at,
owner=telemetry_owner,
)
def shutdown_command_telemetry(
api: Any,
lifecycle: CommandTelemetryLifecycle,
) -> None:
if lifecycle.telemetry is not None and lifecycle.owner:
api.shutdown_shared_telemetry(lifecycle.telemetry)
def command_telemetry_events(
command: str,
arguments: argparse.Namespace,
paths: AppPaths,
) -> CommandTelemetryEvents | None:
if bool(getattr(arguments, "dry_run", False)):
return None
if command in {"update", "sync", "wine-update"}:
if bool(getattr(arguments, "repair", False)):
return ("repair", "repair_started", "repair_success", "repair_failed")
if not paths.source_game.exists():
return ("install", "install_started", "install_success", "install_failed")
return ("update", "update_started", "update_success", "update_failed")
if command == "launcher-update" and not (
bool(getattr(arguments, "check", False))
or bool(getattr(arguments, "verify", False))
or getattr(arguments, "rollback", None) is not None
):
return (
"launcher_update",
"update_started",
"update_success",
"update_failed",
)
if command in {"setup", "runtime-bootstrap"}:
return (
"runtime_install",
"runtime_install_started",
"runtime_install_success",
"runtime_install_failed",
)
if command == "doctor":
return (
"compatibility_check",
"compatibility_check_started",
"compatibility_check_completed",
"compatibility_check_failed",
)
return None
def report_command_result(
telemetry: TelemetryService | None,
events: CommandTelemetryEvents | None,
command: str,
started_at: float,
returncode: int,
) -> None:
if telemetry is None or events is None:
return
operation, _started_event, success_event, failed_event = events
duration_ms = round((time.monotonic() - started_at) * 1000)
telemetry.send_event(
success_event if returncode == 0 else failed_event,
severity="info" if returncode == 0 else "error",
message=None if returncode == 0 else f"Command exited with status {returncode}",
metadata={
"command": command,
"duration_ms": duration_ms,
"exit_code": returncode,
},
context={
"operation": operation,
"stage": "completed" if returncode == 0 else "command_exit",
},
)
def report_command_failure(
telemetry: TelemetryService | None,
events: CommandTelemetryEvents | None,
command: str,
started_at: float,
message: str,
*,
stage: str = "failed",
) -> None:
if telemetry is None:
return
if command == "play":
telemetry.send_event(
"game_launch_failed",
severity="error",
message=message,
metadata={
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": "launch_game", "stage": stage},
)
return
if events is None:
return
operation, _started_event, _success_event, failed_event = events
telemetry.send_event(
failed_event,
severity="error",
message=message,
metadata={
"command": command,
"duration_ms": round((time.monotonic() - started_at) * 1000),
},
context={"operation": operation, "stage": stage},
)
def report_game_launch_cancelled(
telemetry: TelemetryService | None,
started_at: float,
*,
stage: str,
metadata: dict[str, object] | None = None,
) -> None:
if telemetry is None:
return
event_metadata: dict[str, object] = {
"duration_ms": round((time.monotonic() - started_at) * 1000),
"interrupted": True,
"launcher_exit_code": 130,
"signal": int(signal.SIGINT),
"signal_name": signal.SIGINT.name,
}
if metadata is not None:
event_metadata.update(metadata)
telemetry.send_event(
"game_launch_cancelled",
severity="info",
message="Game launch was cancelled",
metadata=event_metadata,
context={"operation": "launch_game", "stage": stage},
)
def failure_stage(command: str, exception: BaseException) -> str:
if isinstance(exception, ValueError):
return "configuration"
if isinstance(exception, CommandError):
return "subprocess"
message = str(exception).casefold()
if any(value in message for value in ("manifest", "cdn", "request failed")):
return "manifest"
if any(value in message for value in ("download", "incomplete response")):
return "download"
if any(value in message for value in ("sha-1", "checksum", "hash mismatch")):
return "validation"
if any(
value in message
for value in ("permission", "read-only", "cannot write", "writable")
):
return "filesystem"
if any(value in message for value in ("prefix", "wine", "proton", "umu")):
return "runtime_check"
return f"{command}_operation"
def emit(
value: object,
as_json: bool,
*,
json_printer: Callable[[object], None],
) -> None:
if as_json:
json_printer(value)
else:
if isinstance(value, dict) and value.get("ok") is True:
print("Done.")
print(json.dumps(value, indent=2, sort_keys=True))
def print_json(value: object) -> None:
print(json.dumps(value, indent=2, sort_keys=True))