Skip to content
Open
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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ repos:
hooks:
- id: mypy-cli
name: mypy (cli)
entry: bash -c 'cd cli && mypy localci/'
entry: python -c "import pathlib, subprocess, sys; sys.exit(subprocess.call([sys.executable, '-m', 'mypy', 'localci/'], cwd=pathlib.Path('.').resolve() / 'cli'))"
language: python
pass_filenames: false
always_run: true
Expand Down
39 changes: 33 additions & 6 deletions cli/localci/core/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from enum import Enum
from pathlib import Path
from threading import Event as ThreadEvent
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from localci.core.cmake_cache import compute_cmake_input_digest
from localci.core.command_builder import ActCommandBuilder
Expand Down Expand Up @@ -174,7 +174,7 @@ def __init__(
self._state = OrchestratorState.IDLE
self._run: ExecutionRun | None = None
self._pool: ThreadPoolExecutor | None = None
self._futures: dict[str, Future] = {}
self._futures: dict[str, Future[JobResult]] = {}
self._shutdown_event = ThreadEvent()
self._listeners: list[Callable[[JobEvent], None]] = []

Expand Down Expand Up @@ -291,9 +291,28 @@ def _dispatch_loop(self) -> None:
time.sleep(self.config.dispatch_interval)
continue
logger.info("Dispatching: %s", job.matrix_entry.name)
future = self._pool.submit(self._execute_job, job)
pool = self._pool
if pool is None:
logger.error(
"Thread pool unavailable; marking job as failed: %s",
job.matrix_entry.name,
)
failed = Future[JobResult]()
failed.set_result(
JobResult(
job_id=job.job_id,
matrix_index=job.matrix_entry.index,
matrix_name=job.matrix_entry.name,
status=JobStatus.ERROR,
error_message="Orchestrator thread pool unavailable",
)
)
self._futures[job.queue_key] = failed
self._on_job_done(job, failed)
continue
future = pool.submit(self._execute_job, job)
self._futures[job.queue_key] = future
future.add_done_callback(lambda f, j=job: self._on_job_done(j, f))
future.add_done_callback(self._make_done_callback(job))

def _wait_for_completion(self) -> None:
for key, future in list(self._futures.items()):
Expand Down Expand Up @@ -549,7 +568,15 @@ def _prepare_image(self, job: QueuedJob) -> str | None:
)
return None

def _on_job_done(self, job: QueuedJob, future: Future) -> None:
def _make_done_callback(
self, job: QueuedJob
) -> Callable[[Future[JobResult]], None]:
def callback(future: Future[JobResult]) -> None:
self._on_job_done(job, future)

return callback

def _on_job_done(self, job: QueuedJob, future: Future[JobResult]) -> None:
try:
result = future.result()
except Exception as e:
Expand Down Expand Up @@ -635,7 +662,7 @@ def execution_id(self) -> str | None:
def active_workers(self) -> int:
return len([f for f in self._futures.values() if not f.done()])

def get_status(self) -> dict:
def get_status(self) -> dict[str, Any]:
if not self._run:
return {"state": "idle"}
snap = self._resource_monitor.snapshot()
Expand Down
26 changes: 15 additions & 11 deletions cli/localci/core/progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from localci.core.executor import JobResult
from localci.core.models import (
Expand All @@ -25,6 +25,9 @@
)

if TYPE_CHECKING:
from rich.console import RenderableType
from rich.live import Live

from localci.core.orchestrator import ExecutionRun
from localci.core.queue import PriorityJobQueue

Expand Down Expand Up @@ -209,7 +212,7 @@ def __init__(
self._jobs: dict[str, JobProgress] = {}
self._started_at: datetime | None = None
self._execution_id: str | None = None
self._live = None # Rich Live instance
self._live: Live | None = None
self._completed_durations: list[float] = []
self._last_status_write: float = 0.0
self._status_write_interval: float = 1.0
Expand Down Expand Up @@ -392,31 +395,32 @@ def start_live(self) -> None:
"""Start Rich Live display."""
from rich.live import Live

self._live = Live(
live = Live(
self._render_live(),
refresh_per_second=4,
transient=True,
)
self._live.start()
self._live = live
live.start()

def stop_live(self) -> None:
"""Stop Rich Live display."""
if self._live:
self._live.stop()
self._live = None

def _render_live(self):
def _render_live(self) -> RenderableType:
"""Render the complete live display."""
from rich.console import Group

parts = []
parts: list[RenderableType] = []
parts.append(self._render_header())
parts.append(self._render_progress_bar())
parts.append(self._render_priority_levels())
parts.append(self._render_job_table())
return Group(*parts)

def _render_header(self):
def _render_header(self) -> RenderableType:
"""Render execution header panel."""
from rich.panel import Panel

Expand All @@ -433,7 +437,7 @@ def _render_header(self):
)
return Panel(content, title="Local CI Execution", expand=True)

def _render_progress_bar(self):
def _render_progress_bar(self) -> RenderableType:
"""Render progress bar with ETA."""
from rich.text import Text

Expand All @@ -451,7 +455,7 @@ def _render_progress_bar(self):
f"Elapsed: {self._elapsed_display()} {eta_str}\n"
)

def _render_priority_levels(self):
def _render_priority_levels(self) -> RenderableType:
"""Render priority level summary."""
from rich.text import Text

Expand All @@ -468,7 +472,7 @@ def _render_priority_levels(self):
lines.append(f"Priority {level.priority} {icon} {label}{suffix}")
return Text("\n".join(lines) + "\n")

def _render_job_table(self):
def _render_job_table(self) -> RenderableType:
"""Render per-job status table with current step when running."""
from rich.table import Table

Expand Down Expand Up @@ -623,7 +627,7 @@ def print_summary(self, run: ExecutionRun) -> None:
# JSON status (for localci status --format json)
# -----------------------------------------------------------------------

def get_status_dict(self) -> dict:
def get_status_dict(self) -> dict[str, Any]:
"""Get structured status for JSON output and status files."""
with self._lock:
jobs = list(self._jobs.values())
Expand Down
8 changes: 4 additions & 4 deletions cli/localci/core/queue_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import fnmatch
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from localci.core.image_tag import derive_image_tag
from localci.core.models import QueuedJob
Expand Down Expand Up @@ -40,7 +40,7 @@ def _resolve_image_tag_and_build(
return derived_tag, None, True


def _matches_filter(entry: MatrixEntry, filters: list[dict]) -> bool:
def _matches_filter(entry: MatrixEntry, filters: list[dict[str, Any]]) -> bool:
"""True if entry matches any of the filter dicts."""
for f in filters:
match = True
Expand Down Expand Up @@ -96,8 +96,8 @@ def build(
platform_filter: Platform | None = None,
job_filter: list[str] | None = None,
compiler_filter: str | None = None,
matrix_include: list[dict] | None = None,
matrix_exclude: list[dict] | None = None,
matrix_include: list[dict[str, Any]] | None = None,
matrix_exclude: list[dict[str, Any]] | None = None,
entries_include: set[tuple[str, int]] | None = None,
registry_path: Path | None = None,
) -> PriorityJobQueue:
Expand Down
8 changes: 4 additions & 4 deletions cli/localci/core/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class RegistryEntry:
usage_count: int = 0
# Optional fields from existing registry (preserved on load/save)
variants: list[str] = field(default_factory=list)
raw: dict = field(default_factory=dict)
raw: dict[str, Any] = field(default_factory=dict)

@classmethod
def from_dict(cls, d: dict[str, Any]) -> RegistryEntry:
Expand Down Expand Up @@ -257,9 +257,9 @@ def select_image(
)

best_ess = max(s[0] for s in scored)
candidates = [(ext, r) for ess, ext, r in scored if ess == best_ess]
best_ext = max(e for e, _ in candidates)
base_candidates = [r for e, r in candidates if e == best_ext]
scored_candidates = [(ext, r) for ess, ext, r in scored if ess == best_ess]
best_ext = max(e for e, _ in scored_candidates)
base_candidates = [r for e, r in scored_candidates if e == best_ext]
best = base_candidates[0]
for other in base_candidates[1:]:
best = _tie_break(best, other)
Expand Down
3 changes: 2 additions & 1 deletion cli/localci/core/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any

from localci.core.executor import JobResult, JobStatus

Expand Down Expand Up @@ -151,7 +152,7 @@ def summary_report(self) -> str:
# Persistence
# -----------------------------------------------------------------

def to_dict(self) -> dict:
def to_dict(self) -> dict[str, Any]:
"""Serialise to a plain dict (JSON-safe)."""
return {
"execution_id": self.execution_id,
Expand Down
8 changes: 4 additions & 4 deletions cli/localci/core/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from dataclasses import asdict
from enum import Enum
from pathlib import Path
from typing import Any
from typing import Any, cast

from localci.core.workflow import (
Workflow,
Expand Down Expand Up @@ -52,12 +52,12 @@ def workflow_to_json(workflow: Workflow, indent: int = 2) -> str:
return json.dumps(workflow, cls=WorkflowEncoder, indent=indent)


def workflow_to_dict(workflow: Workflow) -> dict:
def workflow_to_dict(workflow: Workflow) -> dict[str, Any]:
"""Convert *workflow* to a plain ``dict`` (JSON round-trip)."""
return json.loads(workflow_to_json(workflow))
return cast(dict[str, Any], json.loads(workflow_to_json(workflow)))


def workflow_summary(workflow: Workflow) -> dict:
def workflow_summary(workflow: Workflow) -> dict[str, Any]:
"""Generate a concise summary dict for *workflow*."""
return {
"name": workflow.name,
Expand Down
20 changes: 11 additions & 9 deletions cli/localci/core/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ class MatrixEntry:
is_latest: bool = False
is_earliest: bool = False
timeout_minutes: int = 120
raw: dict = field(default_factory=dict)
raw: dict[str, Any] = field(default_factory=dict)

@property
def image_requirements_key(self) -> str:
Expand Down Expand Up @@ -247,12 +247,12 @@ class Job:
container: ContainerInfo | None = None
needs: list[str] = field(default_factory=list)
condition: str | None = None
strategy: dict | None = None
strategy: dict[str, Any] | None = None
matrix: list[MatrixEntry] = field(default_factory=list)
steps: list[StepInfo] = field(default_factory=list)
timeout_minutes: int = 60
env: dict[str, str] = field(default_factory=dict)
defaults: dict | None = None
defaults: dict[str, Any] | None = None

@property
def has_matrix(self) -> bool:
Expand All @@ -277,7 +277,7 @@ class Workflow:
file_path: Path
events: list[str]
env: dict[str, str] = field(default_factory=dict)
concurrency: dict | None = None
concurrency: dict[str, Any] | None = None
jobs: dict[str, Job] = field(default_factory=dict)

@property
Expand Down Expand Up @@ -556,7 +556,7 @@ def _parse_job(self, file: Path, job_id: str) -> Job:
# -----------------------------------------------------------------

def _parse_matrix_entry(
self, index: int, entry: dict, job_data: dict
self, index: int, entry: dict[str, Any], job_data: dict[str, Any]
) -> MatrixEntry:
"""Parse a single matrix include entry."""
compiler = self._parse_compiler(entry)
Expand Down Expand Up @@ -603,7 +603,7 @@ def _parse_matrix_entry(
# Field parsers
# -----------------------------------------------------------------

def _parse_compiler(self, entry: dict) -> CompilerInfo:
def _parse_compiler(self, entry: dict[str, Any]) -> CompilerInfo:
family_str = entry.get("compiler", "unknown")
family = self._classify_compiler(family_str)
return CompilerInfo(
Expand All @@ -628,7 +628,7 @@ def _parse_container(self, container_data: Any) -> ContainerInfo:
)
return ContainerInfo()

def _parse_packages(self, entry: dict) -> PackageRequirements:
def _parse_packages(self, entry: dict[str, Any]) -> PackageRequirements:
install_str = entry.get("install", "")
apt_packages = (
[p.strip() for p in install_str.split() if p.strip()] if install_str else []
Expand All @@ -650,7 +650,7 @@ def _parse_packages(self, entry: dict) -> PackageRequirements:
ccflags=entry.get("ccflags"),
)

def _parse_step(self, step: dict) -> StepInfo:
def _parse_step(self, step: dict[str, Any]) -> StepInfo:
return StepInfo(
name=step.get("name", ""),
uses=step.get("uses"),
Expand Down Expand Up @@ -697,7 +697,9 @@ def _classify_platform(self, runs_on: str, container: ContainerInfo) -> Platform
def _classify_compiler(self, compiler_str: str) -> CompilerFamily:
return self._COMPILER_MAP.get(compiler_str.lower(), CompilerFamily.UNKNOWN)

def _detect_build_system(self, entry: dict, job_data: dict) -> BuildSystem:
def _detect_build_system(
self, entry: dict[str, Any], job_data: dict[str, Any]
) -> BuildSystem:
has_b2 = False
has_cmake = False

Expand Down
3 changes: 2 additions & 1 deletion cli/localci/utils/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import subprocess
import time
from pathlib import Path
from typing import Any

from localci.errors import DockerNotAvailableError

Expand Down Expand Up @@ -204,7 +205,7 @@ def cleanup_act_containers(self) -> int:
# Resource monitoring
# -----------------------------------------------------------------

def disk_usage(self) -> dict:
def disk_usage(self) -> dict[str, Any]:
"""Get Docker disk usage summary."""
result = subprocess.run(
self._docker_cmd("system", "df", "--format", "{{json .}}"),
Expand Down
Loading
Loading