This commit is contained in:
2025-08-22 13:11:04 +02:00
parent d80b956ff7
commit bc0d90d41a
14 changed files with 17332 additions and 13 deletions

1762
aiprompts/libtmux_python.md Normal file

File diff suppressed because it is too large Load Diff

14342
aiprompts/psutil_python.md Normal file

File diff suppressed because it is too large Load Diff

17
examples/tmuxrunner_start.py Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env python3
import sys
from herolib.infra.tmuxrunner.task_runner_enhanced import TaskOrchestrator
def main():
tasks_dir = sys.argv[1]
api_port = int(sys.argv[2]) if len(sys.argv) > 2 else 8000
orchestrator = TaskOrchestrator(tasks_dir, api_port)
orchestrator.run()
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python enhanced_runner.py <tasks_directory_path> [api_port]")
sys.exit(1)
main()

View File

@@ -6,6 +6,11 @@ Author-email: Kilo Code <kilo.code@example.com>
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: peewee
Requires-Dist: psutil>=5.9.0
Requires-Dist: fastapi>=0.100.0
Requires-Dist: uvicorn>=0.23.0
Requires-Dist: toml>=0.10.2
Requires-Dist: libtmux>=0.25.0
# herolib_python

View File

@@ -64,6 +64,7 @@ herolib/downloader/scrape_scapegraph/scrape_md.py
herolib/downloader/scrape_scapegraph/scrape_search.py
herolib/downloader/scrape_scapegraph/scrape_with_local_llm.py
herolib/downloader/scrape_scapegraph/scrape_with_local_llm_search.py
herolib/infra/tmuxrunner/enhanced_runner.py
herolib/tools/__init__.py
herolib/tools/extensions.py
herolib/tools/gitscanner.py

View File

@@ -1 +1,6 @@
peewee
psutil>=5.9.0
fastapi>=0.100.0
uvicorn>=0.23.0
toml>=0.10.2
libtmux>=0.25.0

View File

View File

@@ -0,0 +1,86 @@
import os
import time
import re
import sys
import toml
import libtmux
from libtmux.pane import Pane
from libtmux.window import Window
from libtmux.session import Session
import psutil
from typing import Dict, List, Optional, Any, Set, Tuple
from dataclasses import dataclass, field, asdict
from datetime import datetime
import uuid
from pathlib import Path
import asyncio
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import threading
# Configuration
WAITING_MESSAGE = "WAITING FOR JOBS"
HPY_SH_PATH = "/root/heromonkey/functions/hpy.sh" # Path to hpy.sh
@dataclass
class ProcessMetrics:
"""Metrics for a running process and its children."""
cpu_percent: float = 0.0
memory_rss: int = 0 # Resident Set Size in bytes
memory_vms: int = 0 # Virtual Memory Size in bytes
memory_percent: float = 0.0
num_threads: int = 0
num_children: int = 0
children_cpu_percent: float = 0.0
children_memory_rss: int = 0
last_updated: str = ""
@dataclass
class TaskStatus:
"""Status of an individual task (script)."""
script_path: str
script_name: str
state: str = "PENDING" # PENDING, WAITING, RUNNING, DONE, ERROR, CRASHED, TIMED_OUT
start_time: Optional[str] = None
end_time: Optional[str] = None
duration_seconds: float = 0.0
exit_code: Optional[int] = None
error_message: Optional[str] = None
pane_id: Optional[str] = None
process_metrics: ProcessMetrics = field(default_factory=ProcessMetrics)
@dataclass
class DirectoryStatus:
"""Status of a directory containing tasks."""
directory_num: int
directory_path: str
state: str = "PENDING" # PENDING, RUNNING, DONE, ERROR, TIMED_OUT
timeout: int = 600
start_time: Optional[str] = None
end_time: Optional[str] = None
duration_seconds: float = 0.0
tasks: List[TaskStatus] = field(default_factory=list)
window_name: Optional[str] = None
@dataclass
class DAGStructure:
"""Complete DAG structure for the task run."""
run_name: str
run_id: str
state: str = "INITIALIZING" # INITIALIZING, RUNNING, COMPLETED, FAILED
start_time: str = ""
end_time: Optional[str] = None
duration_seconds: float = 0.0
total_directories: int = 0
completed_directories: int = 0
failed_directories: int = 0
directories: List[DirectoryStatus] = field(default_factory=list)
last_updated: str = ""
class MetaData:
"""Class to hold metadata for a task directory."""
def __init__(self, timeout: int = 600): # Default timeout to 10 minutes (600 seconds)
self.timeout = timeout
# Add more attributes here in the future

View File

@@ -0,0 +1,106 @@
import os
import time
import re
import sys
import toml
import libtmux
from libtmux.pane import Pane
from libtmux.window import Window
from libtmux.session import Session
import psutil
from typing import Dict, List, Optional, Any, Set, Tuple
from dataclasses import dataclass, field, asdict
from datetime import datetime
import uuid
from pathlib import Path
import asyncio
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import threading
class ProcessMonitor:
"""Monitor processes running in tmux panes using psutil."""
@staticmethod
def get_pane_process_tree(pane: Pane) -> Tuple[Optional[psutil.Process], List[psutil.Process]]:
"""Get the main process and all child processes for a tmux pane."""
try:
pane_pid = pane.pane_pid
if pane_pid is None:
return None, []
# Get the main process
try:
main_process = psutil.Process(int(pane_pid))
except (psutil.NoSuchProcess, ValueError):
return None, []
# Get all children recursively
children = []
try:
children = main_process.children(recursive=True)
except psutil.NoSuchProcess:
pass
return main_process, children
except Exception as e:
print(f"Error getting process tree: {e}")
return None, []
@staticmethod
def get_process_metrics(pane: Pane) -> ProcessMetrics:
"""Get CPU and memory metrics for all processes in a pane."""
metrics = ProcessMetrics()
metrics.last_updated = datetime.now().isoformat()
main_proc, children = ProcessMonitor.get_pane_process_tree(pane)
if main_proc is None:
return metrics
try:
# Get main process metrics
if main_proc.is_running():
metrics.cpu_percent = main_proc.cpu_percent(interval=0.1)
mem_info = main_proc.memory_info()
metrics.memory_rss = mem_info.rss
metrics.memory_vms = mem_info.vms
metrics.memory_percent = main_proc.memory_percent()
metrics.num_threads = main_proc.num_threads()
# Get children metrics
metrics.num_children = len(children)
for child in children:
try:
if child.is_running():
metrics.children_cpu_percent += child.cpu_percent(interval=0.1)
child_mem = child.memory_info()
metrics.children_memory_rss += child_mem.rss
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
print(f"Error getting process metrics: {e}")
return metrics
@staticmethod
def is_process_running_command(pane: Pane, command_pattern: str) -> bool:
"""Check if a specific command is running in the pane."""
main_proc, children = ProcessMonitor.get_pane_process_tree(pane)
all_processes = [main_proc] + children if main_proc else children
for proc in all_processes:
try:
if proc and proc.is_running():
cmdline = " ".join(proc.cmdline())
if command_pattern in cmdline:
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
return False

View File

@@ -0,0 +1,594 @@
import os
import time
import re
import sys
import toml
import libtmux
from libtmux.pane import Pane
from libtmux.window import Window
from libtmux.session import Session
import psutil
from typing import Dict, List, Optional, Any, Set, Tuple
from dataclasses import dataclass, field, asdict
from datetime import datetime
import uuid
from pathlib import Path
import asyncio
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import threading
# Configuration
WAITING_MESSAGE = "WAITING FOR JOBS"
HPY_SH_PATH = "/root/heromonkey/functions/hpy.sh" # Path to hpy.sh
class TaskRunner:
def __init__(self, tasks_root_dir: str):
self.tasks_root_dir = tasks_root_dir
self.run_name = os.path.basename(os.path.abspath(tasks_root_dir)) # Derive run_name
self.session = self._get_current_tmux_session()
self.all_tasks_with_meta = self._get_sorted_tasks_with_meta(tasks_root_dir)
self.window_panes = {} # {window_idx: [pane1, pane2, ...]}
self.run_id = str(uuid.uuid4())
self.dag = self._initialize_dag()
self.dag_file_path = Path(tasks_root_dir) / ".dag.toml"
self.process_monitor = ProcessMonitor()
self._save_dag()
def _initialize_dag(self) -> DAGStructure:
"""Initialize the DAG structure."""
dag = DAGStructure(
run_name=self.run_name,
run_id=self.run_id,
state="INITIALIZING",
start_time=datetime.now().isoformat(),
total_directories=len(self.all_tasks_with_meta)
)
# Create directory entries
for dir_num, (scripts, metadata) in self.all_tasks_with_meta.items():
dir_status = DirectoryStatus(
directory_num=dir_num,
directory_path=os.path.dirname(scripts[0]) if scripts else "",
timeout=metadata.timeout,
window_name=f"{self.run_name}_{dir_num}"
)
# Create task entries
for script_path in scripts:
task = TaskStatus(
script_path=script_path,
script_name=os.path.basename(script_path)
)
dir_status.tasks.append(task)
dag.directories.append(dir_status)
return dag
def _save_dag(self):
"""Save the DAG structure to a TOML file."""
try:
dag_dict = asdict(self.dag)
with open(self.dag_file_path, 'w') as f:
toml.dump(dag_dict, f)
except Exception as e:
print(f"Error saving DAG: {e}")
def _update_task_state(self, dir_idx: int, task_idx: int,
state: str, error_message: Optional[str] = None):
"""Update task state and save DAG."""
task = self.dag.directories[dir_idx].tasks[task_idx]
old_state = task.state
task.state = state
if state == "RUNNING" and old_state != "RUNNING":
task.start_time = datetime.now().isoformat()
elif state in ["DONE", "ERROR", "CRASHED", "TIMED_OUT"]:
task.end_time = datetime.now().isoformat()
if task.start_time:
start = datetime.fromisoformat(task.start_time)
end = datetime.fromisoformat(task.end_time)
task.duration_seconds = (end - start).total_seconds()
if error_message:
task.error_message = error_message
self.dag.last_updated = datetime.now().isoformat()
self._save_dag()
def _update_directory_state(self, dir_idx: int, state: str):
"""Update directory state and save DAG."""
directory = self.dag.directories[dir_idx]
old_state = directory.state
directory.state = state
if state == "RUNNING" and old_state != "RUNNING":
directory.start_time = datetime.now().isoformat()
elif state in ["DONE", "ERROR", "TIMED_OUT"]:
directory.end_time = datetime.now().isoformat()
if directory.start_time:
start = datetime.fromisoformat(directory.start_time)
end = datetime.fromisoformat(directory.end_time)
directory.duration_seconds = (end - start).total_seconds()
if state == "DONE":
self.dag.completed_directories += 1
else:
self.dag.failed_directories += 1
self._save_dag()
def _check_task_status(self, script_path: str, pane: Pane) -> Tuple[str, Optional[str]]:
"""
Comprehensive task status checking.
Returns: (state, error_message)
"""
script_basename = os.path.basename(script_path)
done_file = f"{script_path}.done"
error_file = f"{script_path}.error"
ok_file = f"{script_path}.ok"
# Check file markers
if os.path.exists(done_file) or os.path.exists(ok_file):
# Create .ok file if it doesn't exist
if not os.path.exists(ok_file):
Path(ok_file).touch()
return "DONE", None
if os.path.exists(error_file):
error_msg = None
try:
with open(error_file, 'r') as f:
error_msg = f.read().strip()
except:
error_msg = "Unknown error"
return "ERROR", error_msg
# Check if hpy command is running
if self.process_monitor.is_process_running_command(pane, f"hpy {script_basename}"):
return "RUNNING", None
# Check if pane has any running process
if self._is_pane_running(pane):
# Might be setting up or running something else
return "RUNNING", None
# If we get here, the process finished without markers
# This is likely a crash
error_msg = f"Process terminated without completion marker"
# Create error file
with open(error_file, 'w') as f:
f.write(error_msg)
return "CRASHED", error_msg
def _monitor_directory_tasks(self, dir_idx: int, timeout: int) -> bool:
"""
Monitor tasks in a directory with comprehensive status checking.
Returns: True if all tasks completed successfully, False otherwise.
"""
directory = self.dag.directories[dir_idx]
scripts, metadata = self.all_tasks_with_meta[directory.directory_num]
panes = self.window_panes[dir_idx]
self._update_directory_state(dir_idx, "RUNNING")
start_time = time.time()
all_success = True
while True:
all_finished = True
has_errors = False
for task_idx, (script_path, pane) in enumerate(zip(scripts, panes)):
task = directory.tasks[task_idx]
# Get process metrics if running
if task.state == "RUNNING":
metrics = self.process_monitor.get_process_metrics(pane)
task.process_metrics = metrics
# Check task status
new_state, error_msg = self._check_task_status(script_path, pane)
if new_state != task.state:
self._update_task_state(dir_idx, task_idx, new_state, error_msg)
print(f" Task {task.script_name}: {task.state}")
if new_state == "RUNNING":
all_finished = False
elif new_state in ["ERROR", "CRASHED", "TIMED_OUT"]:
has_errors = True
all_success = False
# Save DAG periodically
self._save_dag()
if all_finished:
if has_errors:
self._update_directory_state(dir_idx, "ERROR")
else:
self._update_directory_state(dir_idx, "DONE")
break
# Check timeout
elapsed = time.time() - start_time
if elapsed > timeout:
print(f" Directory {directory.directory_num} timed out!")
for task_idx, task in enumerate(directory.tasks):
if task.state == "RUNNING":
self._update_task_state(dir_idx, task_idx, "TIMED_OUT")
panes[task_idx].send_keys("C-c", literal=True)
self._update_directory_state(dir_idx, "TIMED_OUT")
all_success = False
break
time.sleep(2) # Check every 2 seconds
return all_success
def run(self):
"""Enhanced run method with DAG tracking."""
print(f"Starting enhanced task orchestration for '{self.run_name}'")
print(f"Run ID: {self.run_id}")
print(f"DAG file: {self.dag_file_path}")
self.dag.state = "RUNNING"
self._save_dag()
# Initialize windows and panes (similar to original)
self._setup_windows_and_panes()
# Process directories sequentially
overall_success = True
for dir_idx in range(len(self.dag.directories)):
directory = self.dag.directories[dir_idx]
print(f"\n--- Processing Directory {directory.directory_num} ---")
# Start tasks if not the first directory
if dir_idx > 0:
self._start_directory_tasks(dir_idx)
# Monitor tasks
success = self._monitor_directory_tasks(
dir_idx,
directory.timeout
)
if not success:
overall_success = False
# Update final DAG state
self.dag.state = "COMPLETED" if overall_success else "FAILED"
self.dag.end_time = datetime.now().isoformat()
if self.dag.start_time:
start = datetime.fromisoformat(self.dag.start_time)
end = datetime.fromisoformat(self.dag.end_time)
self.dag.duration_seconds = (end - start).total_seconds()
self._save_dag()
print(f"\nTask orchestration completed: {self.dag.state}")
print(f"Total duration: {self.dag.duration_seconds:.2f} seconds")
def reset(self):
"""Kills all processes and panes inside task windows, removes windows, and deletes .done/.error/.ok files."""
print(f"\n--- Resetting run '{self.run_name}' ---")
self.cleanup() # First, kill all associated tmux windows
# Then, remove all .done, .error, and .ok files
print(" Removing .done, .error, and .ok files...")
for dir_num, (scripts, metadata) in self.all_tasks_with_meta.items():
for script_path in scripts:
done_file = f"{script_path}.done"
error_file = f"{script_path}.error"
ok_file = f"{script_path}.ok"
if os.path.exists(done_file):
os.remove(done_file)
print(f" Removed: {done_file}")
if os.path.exists(error_file):
os.remove(error_file)
print(f" Removed: {error_file}")
if os.path.exists(ok_file):
os.remove(ok_file)
print(f" Removed: {ok_file}")
# Also remove the .dag.toml file if it exists
if hasattr(self, 'dag_file_path') and self.dag_file_path.exists():
os.remove(self.dag_file_path)
print(f" Removed: {self.dag_file_path}")
print("Reset complete.")
def _get_sorted_tasks_with_meta(self, tasks_root):
"""
Reads all scripts and .meta.toml from the tasks_root, sorts them by directory,
and then by script name within each directory.
Returns a dictionary where keys are directory numbers (e.g., 1, 2)
and values are tuples of (list_of_full_script_paths, MetaData_object).
"""
tasks_with_meta = {}
for dirpath, dirnames, filenames in os.walk(tasks_root):
if dirpath == tasks_root:
dirnames[:] = sorted([d for d in dirnames if d.isdigit()], key=int)
relative_path = os.path.relpath(dirpath, tasks_root)
if relative_path != "." and relative_path.isdigit():
dir_num = int(relative_path)
scripts = sorted([os.path.join(dirpath, f) for f in filenames if f.endswith(".sh")])
metadata_file = os.path.join(dirpath, ".meta.toml")
metadata = MetaData() # Default metadata
if os.path.exists(metadata_file):
try:
with open(metadata_file, 'r') as f:
meta_data_dict = toml.load(f)
if 'timeout' in meta_data_dict:
metadata.timeout = int(meta_data_dict['timeout'])
except Exception as e:
print(f"Warning: Could not read or parse .meta.toml for directory {dir_num}: {e}")
if scripts:
tasks_with_meta[dir_num] = (scripts, metadata)
sorted_tasks_with_meta = dict(sorted(tasks_with_meta.items()))
return sorted_tasks_with_meta
def _get_current_tmux_session(self) -> Session:
"""Gets the current tmux session based on TMUX environment variable."""
server = libtmux.Server()
tmux_env = os.environ.get('TMUX')
if not tmux_env:
raise Exception("Not running inside a tmux session. The 'TMUX' environment variable is not set.")
try:
# TMUX variable format: /tmp/tmux-1000/default,12345,0
# The last part '0' is the session index.
match = re.search(r',(\d+)$', tmux_env)
if not match:
raise Exception(f"Could not parse session index from TMUX environment variable: {tmux_env}")
session_index_from_env = match.group(1)
found_session = None
for s in server.sessions:
if s.session_id == f"${session_index_from_env}":
found_session = s
break
if not found_session:
raise Exception(f"Could not find tmux session with ID: ${session_index_from_env}")
print(f"Attached to current tmux session: '{found_session.name}' via TMUX env var.")
return found_session
except Exception as e:
raise Exception(f"Error getting current tmux session: {e}")
def _create_tmux_window(self, window_name: str) -> Window:
"""Creates a new tmux window."""
window = self.session.new_window(attach=False, window_name=window_name)
print(f" Tmux window '{window_name}' created.")
return window
def _create_tmux_pane(self, window: Window, pane_index: int, command: str) -> Pane:
"""Creates a tmux pane and sends a command."""
if pane_index == 0:
pane = window.active_pane
pane.send_keys("clear", enter=True)
else:
pane = window.split(attach=False)
pane.send_keys(command, enter=True)
print(f" Pane {pane_index}: Command sent: '{command}'")
return pane
def _is_pane_running(self, pane: Pane) -> bool:
"""Checks if a tmux pane is still running a process."""
try:
pane_pid = pane.pane_pid
if pane_pid is not None:
try:
pid_int = int(pane_pid)
if pid_int > 0:
os.kill(pid_int, 0)
return True
except (ValueError, OSError):
return False
return False
except Exception as e:
print(f"Error checking pane status for {pane.window_name}:{pane.pane_index}: {e}")
return False
def _setup_windows_and_panes(self):
"""Initial setup of tmux windows and panes for all tasks."""
all_dir_nums = sorted(self.all_tasks_with_meta.keys())
print("\n--- Initial Tmux Setup: Creating windows and panes ---")
for window_idx, dir_num in enumerate(all_dir_nums):
scripts, metadata = self.all_tasks_with_meta[dir_num]
window_name = f"{self.run_name}_{dir_num}"
window = self._create_tmux_window(window_name)
self.window_panes[window_idx] = []
for pane_idx, script_path in enumerate(scripts):
script_dir = os.path.dirname(script_path)
script_basename = os.path.basename(script_path)
if window_idx == 0:
# Send cd command first, then the hpy command
pane = self._create_tmux_pane(window, pane_idx, f"cd {script_dir}")
pane.send_keys(f"source {HPY_SH_PATH} && hpy {script_basename}; echo \"Script {script_basename} finished.\"", enter=True)
print(f" Pane {pane_idx}: Command sent: 'cd {script_dir}' and 'source {HPY_SH_PATH} && hpy {script_basename}'")
else:
command = f"echo '{WAITING_MESSAGE} for {script_basename}'"
pane = self._create_tmux_pane(window, pane_idx, command)
self.window_panes[window_idx].append(pane)
if window_idx == 0:
print(f" Window '{window_name}' (Directory {dir_num}) tasks started.")
else:
print(f" Window '{window_name}' (Directory {dir_num}) panes set to '{WAITING_MESSAGE}'.")
def _start_directory_tasks(self, dir_idx: int):
"""Starts tasks in a specific directory (window)."""
directory = self.dag.directories[dir_idx]
scripts, metadata = self.all_tasks_with_meta[directory.directory_num]
panes_in_current_window = self.window_panes[dir_idx]
print(f"\n--- Activating tasks in window '{directory.window_name}' (Directory {directory.directory_num}) ---")
for pane_idx, script_path in enumerate(scripts):
script_dir = os.path.dirname(script_path)
script_basename = os.path.basename(script_path)
pane = panes_in_current_window[pane_idx]
pane.send_keys("C-c", literal=True) # Clear any previous command/output
# Send cd command first, then the hpy command
pane.send_keys(f"cd {script_dir}", enter=True)
pane.send_keys(f"source {HPY_SH_PATH} && hpy {script_basename}; echo \"Script {script_basename} finished.\"", enter=True)
print(f" Pane {pane_idx}: Command sent: 'cd {script_dir}' and 'source {HPY_SH_PATH} && hpy {script_basename}'")
def run(self):
print(f"Starting task orchestration for run '{self.run_name}' from {self.tasks_root_dir}")
if not self.all_tasks_with_meta:
print("No tasks found to execute.")
return
print("Detected tasks:")
for dir_num, (scripts, metadata) in self.all_tasks_with_meta.items():
print(f" Directory {dir_num} (Timeout: {metadata.timeout}s):")
for script in scripts:
print(f" - {script}")
all_dir_nums = sorted(self.all_tasks_with_meta.keys())
# Phase 1: Initial setup - create all windows and panes,
# execute first window's tasks, set others to WAITING.
self._setup_windows_and_panes()
# Phase 2: Sequential execution and monitoring
print("\n--- Starting Sequential Task Execution ---")
for window_idx, dir_num in enumerate(all_dir_nums):
scripts, metadata = self.all_tasks_with_meta[dir_num]
window_name = f"{self.run_name}_{dir_num}"
panes_in_current_window = self.window_panes[window_idx]
if window_idx > 0:
self._start_directory_tasks(window_idx)
print(f" Monitoring tasks in window '{window_name}' (Timeout: {metadata.timeout}s)...")
start_time = time.time()
script_states = {os.path.basename(s): "INITIAL" for s in scripts} # Track state for each script
while True:
all_tasks_finished_or_failed = True
current_status_report = []
for pane_idx, script_path in enumerate(scripts):
script_basename = os.path.basename(script_path)
pane = panes_in_current_window[pane_idx]
done_file = f"{script_path}.done"
error_file = f"{script_path}.error"
ok_file = f"{script_path}.ok" # Added .ok file check
current_script_state = script_states[script_basename]
new_script_state = current_script_state
if os.path.exists(done_file) or os.path.exists(ok_file): # Check for .ok file
new_script_state = "DONE"
elif os.path.exists(error_file):
new_script_state = "ERROR"
elif self._is_pane_running(pane):
new_script_state = "RUNNING"
all_tasks_finished_or_failed = False
elif current_script_state == "RUNNING":
# If it was running but now isn't, and no done/error file, it crashed
new_script_state = "CRASHED"
if new_script_state != current_script_state:
script_states[script_basename] = new_script_state
current_status_report.append(f" - {script_basename}: {new_script_state}")
if current_status_report:
print(f" Status update for window '{window_name}' (Elapsed: {int(time.time() - start_time)}s):")
for report_line in current_status_report:
print(report_line)
if all_tasks_finished_or_failed:
print(f" All tasks in window '{window_name}' completed or failed.")
break
elapsed_time = time.time() - start_time
if elapsed_time > metadata.timeout:
print(f" ERROR: Tasks in window '{window_name}' timed out after {metadata.timeout}s.")
for pane_idx, script_path in enumerate(scripts):
script_basename = os.path.basename(script_path)
pane = panes_in_current_window[pane_idx]
if script_states[script_basename] == "RUNNING":
script_states[script_basename] = "TIMED_OUT"
print(f" - {script_basename}: TIMED_OUT (Killing pane)")
pane.send_keys("C-c", literal=True)
time.sleep(1)
break
time.sleep(5)
print("\nAll task directories processed. You can attach to the tmux session to review:")
print(f"tmux attach -t {self.session.name}")
def cleanup(self):
"""Removes all tmux windows created by this run."""
print(f"\n--- Cleaning up tmux windows for run '{self.run_name}' ---")
print(f" Current session name: '{self.session.name}'")
all_session_windows = [w.name for w in self.session.windows if w.name]
print(f" All windows in current session: {all_session_windows}")
windows_to_kill = []
expected_prefix = f"{self.run_name}_"
print(f" Looking for windows starting with prefix: '{expected_prefix}'")
for window in self.session.windows:
if window.name and window.name.startswith(expected_prefix):
windows_to_kill.append(window)
if not windows_to_kill:
print(f" No windows found to kill with prefix '{expected_prefix}'.")
print("Cleanup complete.")
return
print(f" Identified {len(windows_to_kill)} windows to kill: {[w.name for w in windows_to_kill]}")
for window in windows_to_kill:
try:
window.kill()
print(f" Killed window: '{window.name}'")
except Exception as e:
print(f" Error killing window '{window.name}': {e}")
print("Cleanup complete.")
def reset(self):
"""Kills all processes and panes inside task windows, removes windows, and deletes .done/.error files."""
print(f"\n--- Resetting run '{self.run_name}' ---")
self.cleanup() # First, kill all associated tmux windows
# Then, remove all .done and .error files
print(" Removing .done and .error files...")
for dir_num, (scripts, metadata) in self.all_tasks_with_meta.items():
for script_path in scripts:
done_file = f"{script_path}.done"
error_file = f"{script_path}.error"
ok_file = f"{script_path}.ok" # Added .ok file to be removed
if os.path.exists(done_file):
os.remove(done_file)
print(f" Removed: {done_file}")
if os.path.exists(error_file):
os.remove(error_file)
print(f" Removed: {error_file}")
if os.path.exists(ok_file): # Remove .ok file
os.remove(ok_file)
print(f" Removed: {ok_file}")
print("Reset complete.")

View File

@@ -0,0 +1,176 @@
import os
import time
import re
import sys
import toml
import libtmux
from libtmux.pane import Pane
from libtmux.window import Window
from libtmux.session import Session
import psutil
from typing import Dict, List, Optional, Any, Set, Tuple
from dataclasses import dataclass, field, asdict
from datetime import datetime
import uuid
from pathlib import Path
import asyncio
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import threading
class TaskRunnerAPI:
"""FastAPI interface for the task runner."""
def __init__(self, runner: EnhancedTaskRunner):
self.runner = runner
self.app = FastAPI(title="Task Runner API", version="1.0.0")
# Add CORS middleware
self.app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
self._setup_routes()
def _setup_routes(self):
"""Setup API routes."""
@self.app.get("/")
async def root():
"""Get API information."""
return {
"name": "Task Runner API",
"version": "1.0.0",
"run_id": self.runner.run_id,
"run_name": self.runner.run_name
}
@self.app.get("/status")
async def get_status():
"""Get current run status."""
return {
"run_id": self.runner.run_id,
"run_name": self.runner.run_name,
"state": self.runner.dag.state,
"start_time": self.runner.dag.start_time,
"end_time": self.runner.dag.end_time,
"duration_seconds": self.runner.dag.duration_seconds,
"total_directories": self.runner.dag.total_directories,
"completed_directories": self.runner.dag.completed_directories,
"failed_directories": self.runner.dag.failed_directories
}
@self.app.get("/directories")
async def get_directories():
"""Get all directory statuses."""
return [
{
"directory_num": d.directory_num,
"directory_path": d.directory_path,
"state": d.state,
"timeout": d.timeout,
"start_time": d.start_time,
"end_time": d.end_time,
"duration_seconds": d.duration_seconds,
"task_count": len(d.tasks),
"tasks_done": sum(1 for t in d.tasks if t.state == "DONE"),
"tasks_error": sum(1 for t in d.tasks if t.state in ["ERROR", "CRASHED", "TIMED_OUT"])
}
for d in self.runner.dag.directories
]
@self.app.get("/directories/{dir_num}/tasks")
async def get_directory_tasks(dir_num: int):
"""Get tasks for a specific directory."""
for d in self.runner.dag.directories:
if d.directory_num == dir_num:
return d.tasks
raise HTTPException(status_code=404, detail="Directory not found")
@self.app.get("/tasks/{dir_num}/{task_name}")
async def get_task_details(dir_num: int, task_name: str):
"""Get detailed information about a specific task."""
for d in self.runner.dag.directories:
if d.directory_num == dir_num:
for t in d.tasks:
if t.script_name == task_name:
return t
raise HTTPException(status_code=404, detail="Task not found")
@self.app.get("/metrics")
async def get_metrics():
"""Get current process metrics for all running tasks."""
metrics = []
for d in self.runner.dag.directories:
for t in d.tasks:
if t.state == "RUNNING":
metrics.append({
"directory": d.directory_num,
"task": t.script_name,
"cpu_percent": t.process_metrics.cpu_percent,
"memory_rss_mb": t.process_metrics.memory_rss / (1024 * 1024),
"memory_percent": t.process_metrics.memory_percent,
"num_threads": t.process_metrics.num_threads,
"num_children": t.process_metrics.num_children
})
return metrics
@self.app.get("/dag")
async def get_full_dag():
"""Get the complete DAG structure."""
return asdict(self.runner.dag)
def start(self, host: str = "0.0.0.0", port: int = 8000):
"""Start the FastAPI server."""
uvicorn.run(self.app, host=host, port=port)
class TaskOrchestrator:
"""Main orchestrator that runs tasks and API server."""
def __init__(self, tasks_dir: str, api_port: int = 8000):
self.runner = EnhancedTaskRunner(tasks_dir)
self.api = TaskRunnerAPI(self.runner)
self.api_thread = None
def start_api_server(self, port: int = 8000):
"""Start API server in a separate thread."""
self.api_thread = threading.Thread(
target=self.api.start,
args=("0.0.0.0", port),
daemon=True
)
self.api_thread.start()
print(f"API server started on http://0.0.0.0:{port}")
def run(self):
"""Run the task orchestration."""
# Start API server
self.start_api_server()
# Reset and run tasks
self.runner.reset()
try:
self.runner.run()
except Exception as e:
print(f"Error during execution: {e}")
self.runner.dag.state = "FAILED"
self.runner.dag.end_time = datetime.now().isoformat()
self.runner._save_dag()
print("\nExecution completed. API server still running.")
print("Press Ctrl+C to stop the API server.")
try:
# Keep the main thread alive for API access
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nShutting down...")

View File

@@ -48,12 +48,3 @@ else
uv pip install herolib@git+https://git.ourworld.tf/herocode/herolib_python.git --force-reinstall --no-cache-dir
fi
echo -e "${GREEN}✅ Dependencies installed${NC}"
# Create necessary directories
mkdir -p static/css static/js static/images
mkdir -p templates
mkdir -p md
echo -e "${GREEN}✅ Directory structure verified${NC}"
echo -e "${GREEN}🎉 Installation complete! You can now run start_server.sh${NC}"

View File

@@ -7,7 +7,7 @@ authors = [
]
readme = "README.md"
requires-python = ">=3.12"
dependencies = ["peewee"]
dependencies = ["peewee", "psutil>=5.9.0", "fastapi>=0.100.0", "uvicorn>=0.23.0", "toml>=0.10.2", "libtmux>=0.25.0"]
[build-system]
@@ -18,4 +18,4 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["herolib*"]
include = ["herolib*"]

238
uv.lock generated
View File

@@ -1,20 +1,254 @@
version = 1
revision = 3
requires-python = "==3.12"
requires-python = ">=3.12"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
]
[[package]]
name = "click"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.116.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "herolib"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "libtmux" },
{ name = "peewee" },
{ name = "psutil" },
{ name = "toml" },
{ name = "uvicorn" },
]
[package.metadata]
requires-dist = [{ name = "peewee" }]
requires-dist = [
{ name = "fastapi", specifier = ">=0.100.0" },
{ name = "libtmux", specifier = ">=0.25.0" },
{ name = "peewee" },
{ name = "psutil", specifier = ">=5.9.0" },
{ name = "toml", specifier = ">=0.10.2" },
{ name = "uvicorn", specifier = ">=0.23.0" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "libtmux"
version = "0.46.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/aa/7e1dcaa097156d6f3a7d8669be4389dced997feeb81744e3ff4681d65ee8/libtmux-0.46.2.tar.gz", hash = "sha256:9a398fec5d714129c8344555d466e1a903dfc0f741ba07aabe75a8ceb25c5dda", size = 346887, upload-time = "2025-05-26T19:40:04.096Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/2f/9d207039fcfa00d3b30e4d765f062fbcc42c873c7518a8cfebb3eafd00e0/libtmux-0.46.2-py3-none-any.whl", hash = "sha256:6c32dbf22bde8e5e33b2714a4295f6e838dc640f337cd4c085a044f6828c7793", size = 60873, upload-time = "2025-05-26T19:40:02.284Z" },
]
[[package]]
name = "peewee"
version = "3.18.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/89/76f6f1b744c8608e0d416b588b9d63c2a500ff800065ae610f7c80f532d6/peewee-3.18.2.tar.gz", hash = "sha256:77a54263eb61aff2ea72f63d2eeb91b140c25c1884148e28e4c0f7c4f64996a0", size = 949220, upload-time = "2025-07-08T12:52:03.941Z" }
[[package]]
name = "psutil"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" },
{ url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" },
{ url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" },
{ url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" },
{ url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" },
{ url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "starlette"
version = "0.47.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" },
]
[[package]]
name = "toml"
version = "0.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" },
]
[[package]]
name = "typing-extensions"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
]
[[package]]
name = "uvicorn"
version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
]