start implementation on new architecture
This commit is contained in:
@@ -1,12 +1,4 @@
|
||||
FROM huggingface/transformers-pytorch-gpu AS requirements
|
||||
RUN apt update
|
||||
RUN apt install -y \
|
||||
apt-transport-https ca-certificates \
|
||||
curl software-properties-common
|
||||
RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -
|
||||
RUN add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu bionic stable"
|
||||
RUN apt update
|
||||
RUN apt install -y docker-ce
|
||||
COPY requirements.txt /requirements.txt
|
||||
RUN pip3 install -r /requirements.txt
|
||||
RUN rm -rf /requirements.txt
|
||||
|
||||
53
readme.md
53
readme.md
@@ -318,7 +318,7 @@ stateDiagram-v2
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
direction LR
|
||||
direction LR
|
||||
class BaseAgent {
|
||||
<<abstract>>
|
||||
#working_memory WorkingMemory
|
||||
@@ -331,8 +331,8 @@ classDiagram
|
||||
}
|
||||
|
||||
class LLMEngine {
|
||||
+LLMEngine(path str)
|
||||
+set_model_path(path str)
|
||||
+LLMEngine(model_path str)
|
||||
+set_model_path(model_path str)
|
||||
+inference(context str) Iterator~str~
|
||||
}
|
||||
|
||||
@@ -348,14 +348,15 @@ classDiagram
|
||||
}
|
||||
|
||||
class WebAgentState {
|
||||
<<enumeration>>
|
||||
UPDATE
|
||||
CONTEXT_APPROVAL
|
||||
INFERENCE
|
||||
RESPONSE_APPROVAL
|
||||
<<enumeration>>
|
||||
UPDATE
|
||||
CONTEXT_APPROVAL
|
||||
INFERENCE
|
||||
RESPONSE_APPROVAL
|
||||
}
|
||||
|
||||
class WorkingMemory {
|
||||
+WorkingMemory()
|
||||
-entries List~Entry~
|
||||
+add_entry(entry Entry)
|
||||
+remove_entry(id str)
|
||||
@@ -369,12 +370,13 @@ classDiagram
|
||||
|
||||
class XMLValidator {
|
||||
-schema ElementTree
|
||||
+XMLValidator(schema_path str)
|
||||
+XMLValidator(schema str)
|
||||
+validate(xml str) Optional~str~
|
||||
}
|
||||
|
||||
class ResponseParser {
|
||||
-io_buffer IOBuffer
|
||||
+ResponseParser(io_buffer IOBuffer)
|
||||
+parse(xml str) Command | Entry
|
||||
}
|
||||
|
||||
@@ -382,6 +384,7 @@ classDiagram
|
||||
<<abstract>>
|
||||
+id str
|
||||
+timestamp datetime
|
||||
+Entry(id str, timestamp datetime)
|
||||
+update()*
|
||||
+generate_context() ElementTree*
|
||||
}
|
||||
@@ -391,6 +394,7 @@ classDiagram
|
||||
+stdout str
|
||||
+stderr str
|
||||
+exit_code int
|
||||
+SingleShotEntry(script str, id str, timestamp datetime)
|
||||
+update()
|
||||
+generate_context() ElementTree
|
||||
}
|
||||
@@ -400,6 +404,7 @@ classDiagram
|
||||
+stdout str
|
||||
+stderr str
|
||||
+exit_code int
|
||||
+RepeatEntry(script str, id str, timestamp datetime)
|
||||
+update()
|
||||
+generate_context() ElementTree
|
||||
}
|
||||
@@ -409,12 +414,14 @@ classDiagram
|
||||
+stdout str
|
||||
+stderr str
|
||||
+process Process
|
||||
+BackgroundEntry(script str, id str, timestamp datetime)
|
||||
+update()
|
||||
+generate_context() ElementTree
|
||||
}
|
||||
|
||||
class ReasoningEntry {
|
||||
+content str
|
||||
+ReasoningEntry(content str, id str, timestamp datetime)
|
||||
+update()
|
||||
+generate_context() ElementTree
|
||||
}
|
||||
@@ -422,18 +429,21 @@ classDiagram
|
||||
class ParseErrorEntry {
|
||||
+content str
|
||||
+error str
|
||||
+ParseErrorEntry(content str, error str, id str, timestamp datetime)
|
||||
+update()
|
||||
+generate_context() ElementTree
|
||||
}
|
||||
|
||||
class ReadEntry {
|
||||
+content str
|
||||
+ReadEntry(io_buffer IOBuffer, id str, timestamp datetime)
|
||||
+update()
|
||||
+generate_context() ElementTree
|
||||
}
|
||||
|
||||
class WriteEntry {
|
||||
+content str
|
||||
+WriteEntry(content str, io_buffer IOBuffer, id str, timestamp datetime)
|
||||
+update()
|
||||
+generate_context() ElementTree
|
||||
}
|
||||
@@ -446,6 +456,7 @@ classDiagram
|
||||
}
|
||||
|
||||
class StandardIOBuffer {
|
||||
+StandardIOBuffer()
|
||||
+read() str
|
||||
+write(content str)
|
||||
+buffer_length() int
|
||||
@@ -454,6 +465,7 @@ classDiagram
|
||||
class WebIOBuffer {
|
||||
-stdin_buffer str
|
||||
-stdout_buffer str
|
||||
+WebIOBuffer()
|
||||
+read() str
|
||||
+write(content str)
|
||||
+buffer_length() int
|
||||
@@ -462,23 +474,35 @@ classDiagram
|
||||
+clear_stdout()
|
||||
}
|
||||
|
||||
class CommandResult {
|
||||
+should_stop bool
|
||||
+success bool
|
||||
+message str
|
||||
+CommandResult(should_stop bool, success bool, message str)
|
||||
+static success() CommandResult
|
||||
+static failure(message str) CommandResult
|
||||
+static stop() CommandResult
|
||||
}
|
||||
|
||||
class Command {
|
||||
<<abstract>>
|
||||
+execute(memory &WorkingMemory)*
|
||||
+execute(memory &WorkingMemory) CommandResult*
|
||||
}
|
||||
|
||||
class DeleteCommand {
|
||||
+id: str
|
||||
+execute(memory &WorkingMemory)
|
||||
+id str
|
||||
+DeleteCommand(id str)
|
||||
+execute(memory &WorkingMemory) CommandResult
|
||||
}
|
||||
|
||||
class StopCommand {
|
||||
+execute(memory &WorkingMemory)
|
||||
+execute(memory &WorkingMemory) CommandResult
|
||||
}
|
||||
|
||||
class WebServer {
|
||||
-agent WebAgent
|
||||
-clients List~WebSocket~
|
||||
+WebServer(agent WebAgent)
|
||||
-broadcast_state_change()
|
||||
}
|
||||
|
||||
@@ -491,7 +515,6 @@ classDiagram
|
||||
BaseAgent "1" *-- "1" LLMEngine
|
||||
BaseAgent "1" *-- "1" ResponseParser
|
||||
|
||||
|
||||
WebServer "1" *-- "1" WebIOBuffer
|
||||
WebServer "1" *-- "1" WebAgent
|
||||
|
||||
@@ -509,7 +532,7 @@ classDiagram
|
||||
|
||||
Command <|-- DeleteCommand
|
||||
Command <|-- StopCommand
|
||||
|
||||
Command -- CommandResult
|
||||
|
||||
IOBuffer <|.. WebIOBuffer
|
||||
IOBuffer <|.. StandardIOBuffer
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
bs4
|
||||
docker
|
||||
torch
|
||||
transformers
|
||||
@@ -1,6 +1,4 @@
|
||||
from .agent_core import AgentCore
|
||||
from .llm_engine import LlmEngine
|
||||
from .docker_module import DockerModule
|
||||
|
||||
def main():
|
||||
"""Main entry point for the SIA application."""
|
||||
@@ -13,13 +11,7 @@ def main():
|
||||
with open(action_schema_path, 'r') as f:
|
||||
action_schema = f.read()
|
||||
|
||||
agent_core = AgentCore(
|
||||
system_prompt=f"{system_prompt}{action_schema}",
|
||||
action_schema=action_schema,
|
||||
docker_module=DockerModule(),
|
||||
llm_engine=LlmEngine(model_path=model_path)
|
||||
)
|
||||
agent_core.run_iteration()
|
||||
print("todo")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,51 +0,0 @@
|
||||
from itertools import tee
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from .docker_module import DockerModule
|
||||
from .llm_engine import LlmEngine
|
||||
from .context_template import generate_context
|
||||
from .util import get_valid_root_elements, split_response
|
||||
|
||||
class AgentCore:
|
||||
"""
|
||||
Core orchestration class for SIA that manages the interaction between
|
||||
different modules and runs the main agent loop.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
system_prompt: str,
|
||||
action_schema: str,
|
||||
docker_module: DockerModule,
|
||||
llm_engine: LlmEngine
|
||||
):
|
||||
"""
|
||||
Initialize the AgentCore with required components.
|
||||
|
||||
Args:
|
||||
system_prompt: System prompt to use for the LLM
|
||||
action_schema_path: Path to the XML schema defining valid actions
|
||||
docker_module: DockerModule instance
|
||||
llm_engine: LLmEngine instance
|
||||
"""
|
||||
self.system_prompt = system_prompt
|
||||
self.action_schema = action_schema
|
||||
self.docker_module = docker_module
|
||||
self.llm_engine = llm_engine
|
||||
|
||||
self.valid_elements = get_valid_root_elements(self.action_schema)
|
||||
|
||||
def run_iteration(self) -> None:
|
||||
"""Run a single iteration of the main agent loop."""
|
||||
containers = self.docker_module.get_all_container_statuses()
|
||||
context = generate_context(containers)
|
||||
tokens = self.llm_engine.infer(
|
||||
self.system_prompt,
|
||||
context
|
||||
)
|
||||
print_tokens, response_tokens = tee(tokens)
|
||||
for token in print_tokens:
|
||||
print(token, end="", flush=True)
|
||||
response_tokens = ''.join(response_tokens)
|
||||
result = split_response(response_tokens, self.valid_elements)
|
||||
print(f"result: {result}")
|
||||
175
sia/background_entry.py
Normal file
175
sia/background_entry.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class BackgroundEntry(Entry):
|
||||
"""
|
||||
Entry type for long-running background processes.
|
||||
|
||||
Attributes:
|
||||
script: The script/command to execute
|
||||
stdout: Captured standard output since last update
|
||||
stderr: Captured standard error since last update
|
||||
process: The running subprocess.Popen instance
|
||||
_accumulated_stdout: Total stdout collected so far
|
||||
_accumulated_stderr: Total stderr collected so far
|
||||
_exit_code: Exit code when process completes
|
||||
"""
|
||||
|
||||
def __init__(self, script: str, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new background entry.
|
||||
|
||||
Args:
|
||||
script: The script/command to execute
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.script = script
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
self.process: Optional[subprocess.Popen] = None
|
||||
self._accumulated_stdout = ""
|
||||
self._accumulated_stderr = ""
|
||||
self._exit_code: Optional[int] = None
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Clean up the background process if it's still running.
|
||||
Ensures process is terminated and file handles are closed.
|
||||
"""
|
||||
self._cleanup_process()
|
||||
|
||||
def _cleanup_process(self):
|
||||
"""Clean up process and file handles"""
|
||||
if self.process is not None:
|
||||
try:
|
||||
# Close file handles if they're open
|
||||
if self.process.stdout:
|
||||
self.process.stdout.close()
|
||||
if self.process.stderr:
|
||||
self.process.stderr.close()
|
||||
|
||||
# Terminate process if it's still running
|
||||
if self.process.poll() is None:
|
||||
self.process.terminate()
|
||||
try:
|
||||
self.process.wait(timeout=1.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
except:
|
||||
pass # Ignore cleanup errors
|
||||
finally:
|
||||
self.process = None
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Start the process if not running and collect any new output.
|
||||
Updates stdout and stderr with any new output since last update.
|
||||
"""
|
||||
try:
|
||||
# Reset current output buffers
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
|
||||
# Start process if not running
|
||||
if self.process is None and self._exit_code is None:
|
||||
self.process = subprocess.Popen(
|
||||
self.script,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1 # Line buffered
|
||||
)
|
||||
return # Return after starting to allow process to generate output
|
||||
|
||||
if self.process is None:
|
||||
return # Process already completed
|
||||
|
||||
# Check if process has finished
|
||||
exit_code = self.process.poll()
|
||||
if exit_code is not None:
|
||||
# Process has terminated, collect remaining output
|
||||
try:
|
||||
remaining_out, remaining_err = self.process.communicate(timeout=0.1)
|
||||
self.stdout = remaining_out
|
||||
self.stderr = remaining_err
|
||||
self._accumulated_stdout += remaining_out
|
||||
self._accumulated_stderr += remaining_err
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # Process didn't finish communicating, try again next update
|
||||
|
||||
self._exit_code = exit_code
|
||||
self._cleanup_process()
|
||||
return
|
||||
|
||||
# Read stdout if available
|
||||
if self.process.stdout:
|
||||
while True:
|
||||
try:
|
||||
line = self.process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
self.stdout += line
|
||||
self._accumulated_stdout += line
|
||||
except:
|
||||
break
|
||||
|
||||
# Read stderr if available
|
||||
if self.process.stderr:
|
||||
while True:
|
||||
try:
|
||||
line = self.process.stderr.readline()
|
||||
if not line:
|
||||
break
|
||||
self.stderr += line
|
||||
self._accumulated_stderr += line
|
||||
except:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# Handle any errors
|
||||
error_msg = f"Error handling background process: {str(e)}"
|
||||
self.stderr = error_msg
|
||||
self._accumulated_stderr += f"\n{error_msg}"
|
||||
self._exit_code = -1
|
||||
self._cleanup_process()
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this background entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element with appropriate status
|
||||
element = ET.Element("background", {"id": self.id})
|
||||
|
||||
if self.process is not None:
|
||||
element.set("pid", str(self.process.pid))
|
||||
elif self._exit_code is not None:
|
||||
element.set("exit_code", str(self._exit_code))
|
||||
|
||||
# Add script as CDATA or escaped text
|
||||
element.text = escape_text_for_xml(self.script)
|
||||
|
||||
# Add stdout element with accumulated output
|
||||
stdout_elem = ET.SubElement(element, "stdout")
|
||||
stdout_elem.text = escape_text_for_xml(self._accumulated_stdout)
|
||||
|
||||
# Add stderr element with accumulated output
|
||||
stderr_elem = ET.SubElement(element, "stderr")
|
||||
stderr_elem.text = escape_text_for_xml(self._accumulated_stderr)
|
||||
|
||||
return element
|
||||
|
||||
def __del__(self):
|
||||
"""Ensure process is terminated and file handles are closed when entry is destroyed"""
|
||||
self._cleanup_process()
|
||||
25
sia/command.py
Normal file
25
sia/command.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .working_memory import WorkingMemory
|
||||
from .command_result import CommandResult
|
||||
|
||||
class Command(ABC):
|
||||
"""
|
||||
Abstract base class for all commands that can be executed on working memory.
|
||||
Commands represent immediate actions that modify the system state.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, memory: 'WorkingMemory') -> 'CommandResult':
|
||||
"""
|
||||
Execute the command on the given working memory.
|
||||
|
||||
Args:
|
||||
memory: WorkingMemory instance to execute command on
|
||||
|
||||
Returns:
|
||||
CommandResult: Result of command execution
|
||||
"""
|
||||
pass
|
||||
35
sia/command_result.py
Normal file
35
sia/command_result.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""
|
||||
Result of a command execution.
|
||||
|
||||
Attributes:
|
||||
message: Optional message describing the result
|
||||
success: Whether the command executed successfully
|
||||
should_stop: Whether the agent should stop after this command
|
||||
"""
|
||||
message: str
|
||||
success: bool = True
|
||||
should_stop: bool = False
|
||||
|
||||
@staticmethod
|
||||
def success() -> 'CommandResult':
|
||||
"""Create a successful command result."""
|
||||
return CommandResult(message="", success=True, should_stop=False)
|
||||
|
||||
@staticmethod
|
||||
def failure(message: str) -> 'CommandResult':
|
||||
"""
|
||||
Create a failed command result.
|
||||
|
||||
Args:
|
||||
message: Description of the failure
|
||||
"""
|
||||
return CommandResult(message=message, success=False, should_stop=False)
|
||||
|
||||
@staticmethod
|
||||
def stop() -> 'CommandResult':
|
||||
"""Create a command result indicating the agent should stop."""
|
||||
return CommandResult(message="", success=True, should_stop=True)
|
||||
@@ -1,40 +0,0 @@
|
||||
from bs4 import BeautifulSoup
|
||||
from typing import List, Dict
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
def generate_context(containers: List[Dict[str, any]]) -> str:
|
||||
"""Generate full context XML string.
|
||||
|
||||
Args:
|
||||
containers: List of container status dictionaries
|
||||
|
||||
Returns:
|
||||
Formatted context XML string
|
||||
"""
|
||||
context_elem = ET.Element("context")
|
||||
context_elem.append(format_containers_xml(containers))
|
||||
return BeautifulSoup(ET.tostring(context_elem, encoding='unicode'), "xml").prettify()
|
||||
|
||||
def format_containers_xml(containers: List[Dict[str, any]]) -> ET.Element:
|
||||
"""Format container statuses as XML string.
|
||||
|
||||
Args:
|
||||
containers: List of dictionaries containing container information
|
||||
|
||||
Returns:
|
||||
ElementTree.Element representing the containers
|
||||
"""
|
||||
containers_elem = ET.Element("containers")
|
||||
|
||||
for container in containers:
|
||||
container_elem = ET.SubElement(containers_elem, "container")
|
||||
container_elem.set("name", container['name'])
|
||||
container_elem.set("status", container['status'])
|
||||
container_elem.set("started_at", container['started_at'])
|
||||
container_elem.set("stdout", str(container['stdout_size']))
|
||||
container_elem.set("stderr", str(container['stderr_size']))
|
||||
if 'exit_code' in container.keys():
|
||||
container_elem.set("exit_code", str(container['exit_code']))
|
||||
if 'error' in container.keys():
|
||||
container_elem.set("error", container['error'])
|
||||
return containers_elem
|
||||
42
sia/delete_command.py
Normal file
42
sia/delete_command.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from .command import Command
|
||||
from .command_result import CommandResult
|
||||
from .working_memory import WorkingMemory
|
||||
|
||||
class DeleteCommand(Command):
|
||||
"""
|
||||
Command to delete an entry from working memory.
|
||||
Ensures proper cleanup of entry resources before removal.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier of entry to delete
|
||||
"""
|
||||
|
||||
def __init__(self, id: str):
|
||||
"""
|
||||
Initialize delete command.
|
||||
|
||||
Args:
|
||||
id: Unique identifier of entry to delete
|
||||
"""
|
||||
self.id = id
|
||||
|
||||
def execute(self, memory: WorkingMemory) -> CommandResult:
|
||||
"""
|
||||
Delete the specified entry from working memory.
|
||||
Performs cleanup on the entry before removal.
|
||||
|
||||
Args:
|
||||
memory: WorkingMemory instance to delete entry from
|
||||
|
||||
Returns:
|
||||
CommandResult: Success if entry was found and deleted,
|
||||
failure with message if entry not found
|
||||
"""
|
||||
entry = memory.get_entry(self.id)
|
||||
if entry is None:
|
||||
return CommandResult.failure(f"Entry with id '{self.id}' not found")
|
||||
|
||||
# Perform cleanup before removing
|
||||
entry.cleanup()
|
||||
memory.remove_entry(self.id)
|
||||
return CommandResult.success()
|
||||
@@ -1,271 +0,0 @@
|
||||
from docker.models.containers import Container
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import docker
|
||||
import logging
|
||||
import requests
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
class DockerModule:
|
||||
"""
|
||||
Module for handling Docker container operations in SIA.
|
||||
Manages container lifecycle, I/O operations, and status monitoring.
|
||||
"""
|
||||
|
||||
def _start_docker_engine():
|
||||
"""
|
||||
Start the Docker engine
|
||||
"""
|
||||
subprocess.run(['/etc/init.d/docker', 'start'])
|
||||
time.sleep(1)
|
||||
|
||||
def __init__(self):
|
||||
DockerModule._start_docker_engine()
|
||||
self.client = docker.DockerClient(base_url='unix://var/run/docker.sock')
|
||||
self.containers: Dict[str, (Container, docker.APIClient.AttachSocket)] = {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exec_type, exec_value, traceback):
|
||||
del self
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup all resources before shutdown."""
|
||||
try:
|
||||
# Stop and remove all containers
|
||||
for (container, socket) in self.containers.values():
|
||||
self._wait_and_stop(container, socket, 0)
|
||||
# Close the Docker client
|
||||
if hasattr(self, 'client'):
|
||||
self.client.close()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error during cleanup: {str(e)}")
|
||||
|
||||
def _wait_and_stop(self, container: Container, socket, timeout: int) -> Tuple[int, str]:
|
||||
"""Helper method for waiting on long- and short-lived containers."""
|
||||
status_code = None
|
||||
if timeout > 0:
|
||||
try:
|
||||
result = container.wait(timeout=timeout/1000)
|
||||
status_code = result.get('StatusCode')
|
||||
except requests.exceptions.ReadTimeout as e:
|
||||
container.stop()
|
||||
status_code = -1
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
container.stop()
|
||||
status_code = -1
|
||||
else:
|
||||
container.stop()
|
||||
status_code = -1
|
||||
try:
|
||||
socket.close()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error closing socket: {str(e)}")
|
||||
output = container.logs(stdout=True, stderr=True).decode('utf-8')
|
||||
try:
|
||||
container.remove(force=True)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error removing container: {str(e)}")
|
||||
return status_code, output
|
||||
|
||||
def start_container(
|
||||
self,
|
||||
image: str,
|
||||
name: Optional[str] = None,
|
||||
timeout: int = -1,
|
||||
command: Optional[str] = None,
|
||||
arguments: Optional[List[str]] = None,
|
||||
volumes: Optional[Dict[str, str]] = None,
|
||||
ports: Optional[Dict[str, str]] = None,
|
||||
environment: Optional[Dict[str, str]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Start a new Docker container with the specified configuration.
|
||||
|
||||
Args:
|
||||
image: Docker image to use
|
||||
name: Unique container name for long running containers
|
||||
timeout: Timeout in milliseconds for short running containers
|
||||
command: Main command to run in container
|
||||
arguments: List of command line arguments
|
||||
volumes: Dictionary mapping host paths to container paths
|
||||
ports: Dictionary mapping host ports to container ports
|
||||
environment: Dictionary of environment variables and values
|
||||
|
||||
Returns:
|
||||
For short-lived containers (with timeout): Container output
|
||||
For long-running containers: None
|
||||
"""
|
||||
# Validate inputs
|
||||
if name is None and timeout < 0:
|
||||
raise ValueError("Either name or timeout must be provided")
|
||||
cmd = []
|
||||
if command:
|
||||
cmd.append(command)
|
||||
if arguments:
|
||||
cmd.extend(arguments)
|
||||
volume_binds = {}
|
||||
if volumes:
|
||||
for host_path, container_path in volumes.items():
|
||||
volume_binds[host_path] = {'bind': container_path, 'mode': 'rw'}
|
||||
port_bindings = {}
|
||||
if ports:
|
||||
for host_port, container_port in ports.items():
|
||||
port_bindings[container_port] = host_port
|
||||
self.client.images.pull(image)
|
||||
container = self.client.containers.create(
|
||||
image,
|
||||
command=cmd if cmd else None,
|
||||
name=name,
|
||||
detach=True,
|
||||
volumes=volume_binds,
|
||||
ports=port_bindings,
|
||||
environment=environment,
|
||||
tty=False,
|
||||
stdin_open=True
|
||||
)
|
||||
socket = container.attach_socket(params={
|
||||
'stdin': 1,
|
||||
'stdout': 1,
|
||||
'stderr': 1,
|
||||
'stream': 1,
|
||||
'demux': True
|
||||
})
|
||||
container.start()
|
||||
if timeout >= 0:
|
||||
# For short-lived containers
|
||||
return self._wait_and_stop(container, socket, timeout)[1]
|
||||
else:
|
||||
# For long-running containers
|
||||
self.containers[name] = (container, socket)
|
||||
|
||||
def write_container_stdin(self, name: str, data: str) -> None:
|
||||
"""Write data to a container's standard input.
|
||||
|
||||
Args:
|
||||
name: Name of the target container
|
||||
data: Data to write to stdin
|
||||
|
||||
Raises:
|
||||
KeyError: If container name not found
|
||||
docker.errors.APIError: If write operation fails
|
||||
"""
|
||||
if name not in self.containers:
|
||||
raise KeyError(f"Container {name} not found")
|
||||
|
||||
try:
|
||||
_, socket = self.containers[name]
|
||||
socket._sock.send(data.encode('utf-8'))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error writing to container {name} stdin: {str(e)}")
|
||||
raise
|
||||
|
||||
def read_container_stdout(self, name: str, n: int = -1) -> str:
|
||||
"""Read from a container's standard output buffer.
|
||||
|
||||
Args:
|
||||
name: Name of the container
|
||||
n: Number of bytes to read; -1 means read all available
|
||||
|
||||
Returns:
|
||||
Data read from stdout
|
||||
|
||||
Raises:
|
||||
KeyError: If container name not found
|
||||
docker.errors.APIError: If read operation fails
|
||||
"""
|
||||
if name not in self.containers:
|
||||
raise KeyError(f"Container {name} not found")
|
||||
|
||||
try:
|
||||
container, socket = self.containers[name]
|
||||
logs = container.logs(stdout=True, stderr=False, tail=n if n > 0 else 'all')
|
||||
return logs.decode('utf-8')
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reading container {name} stdout: {str(e)}")
|
||||
raise
|
||||
|
||||
def read_container_stderr(self, name: str, n: int = -1) -> str:
|
||||
"""Read from a container's standard error buffer.
|
||||
|
||||
Args:
|
||||
name: Name of the container
|
||||
n: Number of bytes to read; -1 means read all available
|
||||
|
||||
Returns:
|
||||
Data read from stderr
|
||||
|
||||
Raises:
|
||||
KeyError: If container name not found
|
||||
docker.errors.APIError: If read operation fails
|
||||
"""
|
||||
if name not in self.containers:
|
||||
raise KeyError(f"Container {name} not found")
|
||||
|
||||
try:
|
||||
container, socket = self.containers[name]
|
||||
logs = container.logs(stdout=False, stderr=True, tail=n if n > 0 else 'all')
|
||||
return logs.decode('utf-8')
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error reading container {name} stderr: {str(e)}")
|
||||
raise
|
||||
|
||||
def wait_container(self, name: str, timeout: int) -> Tuple[int, str]:
|
||||
"""Wait for a container to finish execution.
|
||||
|
||||
Args:
|
||||
name: Name of the container to wait for
|
||||
timeout: Time to wait in milliseconds
|
||||
|
||||
Returns:
|
||||
Tuple of (exit_code, output)
|
||||
"""
|
||||
if name not in self.containers:
|
||||
raise KeyError(f"Container {name} not found")
|
||||
|
||||
container, socket = self.containers.pop(name)
|
||||
return self._wait_and_stop(container, socket, timeout)
|
||||
|
||||
def get_container_status(self, name: str) -> Dict[str, any]:
|
||||
"""Get current status information for a container.
|
||||
|
||||
Args:
|
||||
name: Name of the container
|
||||
|
||||
Returns:
|
||||
Dictionary with container status information
|
||||
|
||||
Raises:
|
||||
KeyError: If container name not found
|
||||
docker.errors.APIError: If status check fails
|
||||
"""
|
||||
if name not in self.containers:
|
||||
raise KeyError(f"Container {name} not found")
|
||||
|
||||
try:
|
||||
container, socket = self.containers[name]
|
||||
container.reload() # Refresh container info
|
||||
|
||||
return {
|
||||
'name': name,
|
||||
'status': container.status,
|
||||
'started_at': container.attrs['State']['StartedAt'],
|
||||
'exit_code': container.attrs['State']['ExitCode'],
|
||||
'error': container.attrs['State']['Error'],
|
||||
'stdout_size': len(container.logs(stdout=True, stderr=False)),
|
||||
'stderr_size': len(container.logs(stdout=False, stderr=True))
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting status for container {name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_all_container_statuses(self) -> List[Dict[str, any]]:
|
||||
"""Get current status information for all containers.
|
||||
Returns:
|
||||
List of dictionaries with container status information
|
||||
"""
|
||||
statuses = []
|
||||
for name, (container, socket) in self.containers.items():
|
||||
statuses.append(self.get_container_status(name))
|
||||
return statuses
|
||||
50
sia/entry.py
Normal file
50
sia/entry.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
class Entry(ABC):
|
||||
"""
|
||||
Abstract base class for all entry types in the working memory.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for the entry
|
||||
timestamp: When the entry was created
|
||||
"""
|
||||
|
||||
def __init__(self, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new entry with provided id and timestamp.
|
||||
|
||||
Args:
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
self.id = id
|
||||
self.timestamp = timestamp
|
||||
|
||||
@abstractmethod
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Update the entry's state.
|
||||
Must be implemented by concrete classes.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this entry's context.
|
||||
Must be implemented by concrete classes.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Clean up any resources used by this entry.
|
||||
Should be overridden by classes that need cleanup.
|
||||
Default implementation does nothing.
|
||||
"""
|
||||
pass
|
||||
@@ -1,5 +0,0 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
class InferenceResult(NamedTuple):
|
||||
reasoning: str
|
||||
actions: str
|
||||
52
sia/parse_error_entry.py
Normal file
52
sia/parse_error_entry.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class ParseErrorEntry(Entry):
|
||||
"""
|
||||
Entry type for parse and validation errors.
|
||||
|
||||
Attributes:
|
||||
content: The original content that failed to parse
|
||||
error: The error message describing what went wrong
|
||||
"""
|
||||
|
||||
def __init__(self, content: str, error: str, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new parse error entry.
|
||||
|
||||
Args:
|
||||
content: Original content that failed to parse
|
||||
error: Error message describing the failure
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.content = content
|
||||
self.error = error
|
||||
|
||||
def update(self) -> None:
|
||||
"""No update needed for parse error entries."""
|
||||
pass
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this parse error entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("parse_error", {"id": self.id})
|
||||
|
||||
# Add error subelement
|
||||
error_elem = ET.SubElement(element, "error")
|
||||
error_elem.text = escape_text_for_xml(self.error)
|
||||
|
||||
# Add content subelement
|
||||
content_elem = ET.SubElement(element, "content")
|
||||
content_elem.text = escape_text_for_xml(self.content)
|
||||
|
||||
return element
|
||||
54
sia/read_entry.py
Normal file
54
sia/read_entry.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class ReadEntry(Entry):
|
||||
"""
|
||||
Entry type for reading content from standard input.
|
||||
|
||||
Attributes:
|
||||
content: Content read from stdin, empty until first update
|
||||
io_buffer: Buffer to use for IO operations
|
||||
_read: Whether content has been read
|
||||
"""
|
||||
|
||||
def __init__(self, io_buffer, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new read entry.
|
||||
|
||||
Args:
|
||||
io_buffer: Buffer to use for IO operations
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.content = ""
|
||||
self.io_buffer = io_buffer
|
||||
self._read = False
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Read from stdin if not already read.
|
||||
Uses the provided IO buffer for the actual read operation.
|
||||
"""
|
||||
if not self._read:
|
||||
self.content = self.io_buffer.read()
|
||||
self._read = True
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this read entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("read_stdin", {"id": self.id})
|
||||
|
||||
# Add content as CDATA or escaped text if content has been read
|
||||
if self._read:
|
||||
element.text = escape_text_for_xml(self.content)
|
||||
|
||||
return element
|
||||
44
sia/reasoning_entry.py
Normal file
44
sia/reasoning_entry.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class ReasoningEntry(Entry):
|
||||
"""
|
||||
Entry type for agent reasoning steps.
|
||||
|
||||
Attributes:
|
||||
content: The reasoning text
|
||||
"""
|
||||
|
||||
def __init__(self, content: str, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new reasoning entry.
|
||||
|
||||
Args:
|
||||
content: The reasoning text
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.content = content
|
||||
|
||||
def update(self) -> None:
|
||||
"""No update needed for reasoning entries."""
|
||||
pass
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this reasoning entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("reasoning", {"id": self.id})
|
||||
|
||||
# Add content as CDATA or escaped text
|
||||
element.text = escape_text_for_xml(self.content)
|
||||
|
||||
return element
|
||||
90
sia/repeat_entry.py
Normal file
90
sia/repeat_entry.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class RepeatEntry(Entry):
|
||||
"""
|
||||
Entry type for scripts that are executed on every update.
|
||||
|
||||
Attributes:
|
||||
script: The script/command to execute
|
||||
stdout: Captured standard output from most recent execution
|
||||
stderr: Captured standard error from most recent execution
|
||||
exit_code: Process exit code from most recent execution
|
||||
"""
|
||||
|
||||
def __init__(self, script: str, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new repeat entry.
|
||||
|
||||
Args:
|
||||
script: The script/command to execute
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.script = script
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
self.exit_code: Optional[int] = None
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Execute the script and update the output.
|
||||
Captures stdout, stderr and exit code from each execution.
|
||||
"""
|
||||
try:
|
||||
# Run script and capture output
|
||||
process = subprocess.run(
|
||||
self.script,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
# Store results
|
||||
self.stdout = process.stdout
|
||||
self.stderr = process.stderr
|
||||
self.exit_code = process.returncode
|
||||
|
||||
except subprocess.SubprocessError as e:
|
||||
# Handle subprocess errors by capturing the error message
|
||||
self.stdout = ""
|
||||
self.stderr = str(e)
|
||||
self.exit_code = -1
|
||||
|
||||
except Exception as e:
|
||||
# Handle any other errors
|
||||
self.stdout = ""
|
||||
self.stderr = f"Error executing script: {str(e)}"
|
||||
self.exit_code = -1
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this repeat entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("repeat", {
|
||||
"id": self.id,
|
||||
"exit_code": str(self.exit_code) if self.exit_code is not None else "-1"
|
||||
})
|
||||
|
||||
# Add script as CDATA or escaped text
|
||||
element.text = escape_text_for_xml(self.script)
|
||||
|
||||
# Add stdout element
|
||||
stdout_elem = ET.SubElement(element, "stdout")
|
||||
stdout_elem.text = escape_text_for_xml(self.stdout)
|
||||
|
||||
# Add stderr element
|
||||
stderr_elem = ET.SubElement(element, "stderr")
|
||||
stderr_elem.text = escape_text_for_xml(self.stderr)
|
||||
|
||||
return element
|
||||
101
sia/single_shot_entry.py
Normal file
101
sia/single_shot_entry.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from datetime import datetime
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class SingleShotEntry(Entry):
|
||||
"""
|
||||
Entry type for one-time script executions.
|
||||
|
||||
Attributes:
|
||||
script: The script/command to execute
|
||||
stdout: Captured standard output from script execution
|
||||
stderr: Captured standard error from script execution
|
||||
exit_code: Process exit code after completion
|
||||
_executed: Whether the script has been executed
|
||||
"""
|
||||
|
||||
def __init__(self, script: str, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new single shot entry.
|
||||
|
||||
Args:
|
||||
script: The script/command to execute
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.script = script
|
||||
self.stdout = ""
|
||||
self.stderr = ""
|
||||
self.exit_code: Optional[int] = None
|
||||
self._executed = False
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Execute the script if not already executed.
|
||||
Captures stdout, stderr and exit code.
|
||||
"""
|
||||
if self._executed:
|
||||
return
|
||||
|
||||
try:
|
||||
# Run script and capture output
|
||||
process = subprocess.run(
|
||||
self.script,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
# Store results
|
||||
self.stdout = process.stdout
|
||||
self.stderr = process.stderr
|
||||
self.exit_code = process.returncode
|
||||
|
||||
except subprocess.SubprocessError as e:
|
||||
# Handle subprocess errors by capturing the error message
|
||||
self.stdout = ""
|
||||
self.stderr = str(e)
|
||||
self.exit_code = -1
|
||||
|
||||
except Exception as e:
|
||||
# Handle any other errors
|
||||
self.stdout = ""
|
||||
self.stderr = f"Error executing script: {str(e)}"
|
||||
self.exit_code = -1
|
||||
|
||||
self._executed = True
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this single shot entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("single_shot", {
|
||||
"id": self.id,
|
||||
})
|
||||
|
||||
# Add script as CDATA or escaped text
|
||||
element.text = escape_text_for_xml(self.script)
|
||||
|
||||
# Only add output elements if script has executed
|
||||
if self._executed:
|
||||
# Add stdout element if there is output
|
||||
stdout_elem = ET.SubElement(element, "stdout")
|
||||
stdout_elem.text = escape_text_for_xml(self.stdout)
|
||||
|
||||
# Add stderr element if there are errors
|
||||
stderr_elem = ET.SubElement(element, "stderr")
|
||||
stderr_elem.text = escape_text_for_xml(self.stderr)
|
||||
|
||||
# Add exit code attribute after execution
|
||||
element.set("exit_code", str(self.exit_code))
|
||||
|
||||
return element
|
||||
66
sia/standard_io_buffer.py
Normal file
66
sia/standard_io_buffer.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import sys
|
||||
|
||||
class StandardIOBuffer:
|
||||
"""
|
||||
IOBuffer implementation that uses system standard input/output.
|
||||
|
||||
This class provides direct access to stdin/stdout for IO operations.
|
||||
It implements a basic line-based input buffer to handle cases where
|
||||
multiple reads are needed to process all available input.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the standard IO buffer."""
|
||||
self._input_buffer: str = ""
|
||||
|
||||
def read(self) -> str:
|
||||
"""
|
||||
Read available input from stdin.
|
||||
|
||||
If there is buffered input from a previous read, return that first.
|
||||
Otherwise, try to read new input from stdin if available.
|
||||
|
||||
Returns:
|
||||
str: Content read from stdin, or empty string if no input available
|
||||
"""
|
||||
# Return and clear any existing buffered input
|
||||
if self._input_buffer:
|
||||
content = self._input_buffer
|
||||
self._input_buffer = ""
|
||||
return content
|
||||
|
||||
# Check if there's input available
|
||||
if not sys.stdin.isatty() and sys.stdin.readable():
|
||||
try:
|
||||
content = sys.stdin.read()
|
||||
if content:
|
||||
return content
|
||||
except Exception:
|
||||
pass # Ignore any read errors
|
||||
|
||||
return ""
|
||||
|
||||
def write(self, content: str) -> None:
|
||||
"""
|
||||
Write content to stdout.
|
||||
|
||||
Args:
|
||||
content: String content to write
|
||||
"""
|
||||
if not content:
|
||||
return
|
||||
|
||||
try:
|
||||
sys.stdout.write(content)
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass # Ignore write errors
|
||||
|
||||
def buffer_length(self) -> int:
|
||||
"""
|
||||
Get the current length of buffered input.
|
||||
|
||||
Returns:
|
||||
int: Number of characters in the input buffer
|
||||
"""
|
||||
return len(self._input_buffer)
|
||||
30
sia/stop_command.py
Normal file
30
sia/stop_command.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from .command import Command
|
||||
from .command_result import CommandResult
|
||||
from .working_memory import WorkingMemory
|
||||
|
||||
class StopCommand(Command):
|
||||
"""
|
||||
Command to stop the agent.
|
||||
Performs cleanup on all entries and clears working memory.
|
||||
"""
|
||||
|
||||
def execute(self, memory: WorkingMemory) -> CommandResult:
|
||||
"""
|
||||
Signal that the agent should stop.
|
||||
Cleans up all entries and clears the working memory.
|
||||
|
||||
Args:
|
||||
memory: WorkingMemory instance to clear
|
||||
|
||||
Returns:
|
||||
CommandResult: Stop result
|
||||
"""
|
||||
# Get a copy of entries to iterate over
|
||||
entries = memory.get_entries()
|
||||
|
||||
# Clean up each entry individually
|
||||
for entry in entries:
|
||||
entry.cleanup()
|
||||
memory.remove_entry(entry.id)
|
||||
|
||||
return CommandResult.stop()
|
||||
73
sia/util.py
73
sia/util.py
@@ -1,52 +1,6 @@
|
||||
from typing import Iterator
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .inference_result import InferenceResult
|
||||
|
||||
def get_valid_root_elements(schema: str) -> set:
|
||||
"""
|
||||
Extract valid root element names from the XML schema.
|
||||
|
||||
Args:
|
||||
schema: XML schema string
|
||||
|
||||
Returns:
|
||||
set: Set of valid root element names
|
||||
"""
|
||||
try:
|
||||
schema = schema.strip()
|
||||
ET.register_namespace('xs', 'http://www.w3.org/2001/XMLSchema')
|
||||
root = ET.fromstring(schema)
|
||||
ns = {'xs': 'http://www.w3.org/2001/XMLSchema'}
|
||||
elements = root.findall(".//xs:element", ns)
|
||||
return {elem.get('name') for elem in elements if elem.get('name')}
|
||||
except ET.ParseError as e:
|
||||
print(f"Error parsing schema: {e}")
|
||||
return set()
|
||||
|
||||
def split_response(response: str, valid_elements: set) -> InferenceResult:
|
||||
"""
|
||||
Split the response into reasoning and actions based on valid XML elements.
|
||||
|
||||
Args:
|
||||
response: Raw response string from the model
|
||||
valid_elements: Set of valid root element names from the schema
|
||||
|
||||
Returns:
|
||||
InferenceResult: Tuple containing reasoning and actions
|
||||
"""
|
||||
elements_pattern = '|'.join(map(re.escape, valid_elements))
|
||||
pattern = f"<({elements_pattern})[^>]*>"
|
||||
matches = list(re.finditer(pattern, response))
|
||||
if not matches:
|
||||
return InferenceResult(response.strip(), "")
|
||||
last_match = matches[-1]
|
||||
split_point = last_match.start()
|
||||
reasoning = response[:split_point].strip()
|
||||
actions = response[split_point:].strip()
|
||||
return InferenceResult(reasoning, actions)
|
||||
|
||||
def stop_before_value(iterator: Iterator[str], stop_value: str) -> Iterator[str]:
|
||||
"""
|
||||
Creates an iterator that yields values from the input iterator
|
||||
@@ -67,3 +21,30 @@ def stop_before_value(iterator: Iterator[str], stop_value: str) -> Iterator[str]
|
||||
yield item[:split_point]
|
||||
break
|
||||
yield item
|
||||
|
||||
def escape_text_for_xml(text: str) -> str:
|
||||
"""
|
||||
Convert any text value to a properly escaped XML string.
|
||||
|
||||
Args:
|
||||
text: Text to escape, can be string, number, or None
|
||||
|
||||
Returns:
|
||||
str: XML-escaped string representation
|
||||
"""
|
||||
if text is None:
|
||||
return ''
|
||||
|
||||
# Convert numbers to strings
|
||||
text_str = str(text)
|
||||
|
||||
# Standard XML escaping if string contains CDATA end sequence
|
||||
if ']]>' in text_str:
|
||||
return text_str.replace('&', '&') \
|
||||
.replace('<', '<') \
|
||||
.replace('>', '>') \
|
||||
.replace('"', '"') \
|
||||
.replace("'", ''')
|
||||
|
||||
# Otherwise wrap in CDATA
|
||||
return f'<![CDATA[{text_str}]]>'
|
||||
66
sia/web_io_buffer.py
Normal file
66
sia/web_io_buffer.py
Normal file
@@ -0,0 +1,66 @@
|
||||
class WebIOBuffer:
|
||||
"""
|
||||
IOBuffer implementation for web interface communication.
|
||||
|
||||
This class manages input/output through memory buffers instead of system IO,
|
||||
allowing for asynchronous communication through a web interface. It maintains
|
||||
separate buffers for stdin and stdout to simulate bidirectional communication.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize empty input and output buffers."""
|
||||
self._stdin_buffer = ""
|
||||
self._stdout_buffer = ""
|
||||
|
||||
def read(self) -> str:
|
||||
"""
|
||||
Read and clear all available input from the stdin buffer.
|
||||
|
||||
Returns:
|
||||
str: Content from the stdin buffer, or empty string if buffer is empty
|
||||
"""
|
||||
content = self._stdin_buffer
|
||||
self._stdin_buffer = ""
|
||||
return content
|
||||
|
||||
def write(self, content: str) -> None:
|
||||
"""
|
||||
Append content to the stdout buffer.
|
||||
|
||||
Args:
|
||||
content: String content to append to the buffer
|
||||
"""
|
||||
if content:
|
||||
self._stdout_buffer += content
|
||||
|
||||
def buffer_length(self) -> int:
|
||||
"""
|
||||
Get the current length of the stdin buffer.
|
||||
|
||||
Returns:
|
||||
int: Number of characters in the stdin buffer
|
||||
"""
|
||||
return len(self._stdin_buffer)
|
||||
|
||||
def append_stdin(self, content: str) -> None:
|
||||
"""
|
||||
Append content to the stdin buffer.
|
||||
|
||||
Args:
|
||||
content: String content to append to the stdin buffer
|
||||
"""
|
||||
if content:
|
||||
self._stdin_buffer += content
|
||||
|
||||
def get_stdout(self) -> str:
|
||||
"""
|
||||
Get the current content of the stdout buffer.
|
||||
|
||||
Returns:
|
||||
str: Current content of the stdout buffer
|
||||
"""
|
||||
return self._stdout_buffer
|
||||
|
||||
def clear_stdout(self) -> None:
|
||||
"""Clear the stdout buffer."""
|
||||
self._stdout_buffer = ""
|
||||
116
sia/working_memory.py
Normal file
116
sia/working_memory.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from typing import List, Optional
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .entry import Entry
|
||||
|
||||
class WorkingMemory:
|
||||
"""
|
||||
Manages a collection of entries that represent the current state of the system.
|
||||
|
||||
The working memory stores different types of entries (scripts, reasoning, errors, etc.)
|
||||
and provides methods to add, remove, and update them. It also generates XML context
|
||||
representing the current state.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize an empty working memory."""
|
||||
self._entries: List[Entry] = []
|
||||
|
||||
def add_entry(self, entry: Entry) -> None:
|
||||
"""
|
||||
Add a new entry to working memory.
|
||||
|
||||
Args:
|
||||
entry: Entry object to add
|
||||
"""
|
||||
if not isinstance(entry, Entry):
|
||||
raise TypeError("Entry must be an instance of Entry class")
|
||||
self._entries.append(entry)
|
||||
|
||||
def remove_entry(self, id: str) -> None:
|
||||
"""
|
||||
Remove an entry from working memory by its ID.
|
||||
Ensures cleanup is performed before removal.
|
||||
|
||||
Args:
|
||||
id: Unique identifier of entry to remove
|
||||
"""
|
||||
entry = self.get_entry(id)
|
||||
if entry is not None:
|
||||
entry.cleanup()
|
||||
self._entries = [e for e in self._entries if e.id != id]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""
|
||||
Remove all entries from working memory.
|
||||
Ensures cleanup is performed on all entries.
|
||||
"""
|
||||
for entry in self._entries:
|
||||
entry.cleanup()
|
||||
self._entries.clear()
|
||||
|
||||
def __del__(self):
|
||||
"""
|
||||
Clean up all entries when memory is deleted.
|
||||
"""
|
||||
if hasattr(self, '_entries'): # Check in case of partial initialization
|
||||
self.clear()
|
||||
|
||||
def get_entry(self, id: str) -> Optional[Entry]:
|
||||
"""
|
||||
Get an entry by its ID.
|
||||
|
||||
Args:
|
||||
id: Unique identifier of entry to retrieve
|
||||
|
||||
Returns:
|
||||
Entry if found, None otherwise
|
||||
"""
|
||||
for entry in self._entries:
|
||||
if entry.id == id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def get_entries(self) -> List[Entry]:
|
||||
"""
|
||||
Get all entries in working memory.
|
||||
|
||||
Returns:
|
||||
List[Entry]: List of all entries
|
||||
"""
|
||||
return self._entries.copy() # Return a copy to prevent external modification
|
||||
|
||||
def get_entries_count(self) -> int:
|
||||
"""
|
||||
Get the total number of entries.
|
||||
|
||||
Returns:
|
||||
int: Number of entries in working memory
|
||||
"""
|
||||
return len(self._entries)
|
||||
|
||||
def get_entries_by_type(self, entry_type: type) -> List[Entry]:
|
||||
"""
|
||||
Get all entries of a specific type.
|
||||
|
||||
Args:
|
||||
entry_type: Type of entries to retrieve
|
||||
|
||||
Returns:
|
||||
List[Entry]: List of entries matching the specified type
|
||||
"""
|
||||
return [e for e in self._entries if isinstance(e, entry_type)]
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update all entries in working memory."""
|
||||
for entry in self._entries:
|
||||
entry.update()
|
||||
|
||||
def generate_context(self) -> List[ET.Element]:
|
||||
"""
|
||||
Generate XML Elements representing all entries.
|
||||
|
||||
Returns:
|
||||
List[ET.Element]: List of XML elements for each entry
|
||||
"""
|
||||
return [entry.generate_context() for entry in self._entries]
|
||||
54
sia/write_entry.py
Normal file
54
sia/write_entry.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .entry import Entry
|
||||
from .util import escape_text_for_xml
|
||||
|
||||
class WriteEntry(Entry):
|
||||
"""
|
||||
Entry type for writing content to standard output.
|
||||
|
||||
Attributes:
|
||||
content: Text content to write to stdout
|
||||
io_buffer: Buffer to use for IO operations
|
||||
_written: Whether the content has been written
|
||||
"""
|
||||
|
||||
def __init__(self, content: str, io_buffer, id: str, timestamp: datetime):
|
||||
"""
|
||||
Initialize a new write entry.
|
||||
|
||||
Args:
|
||||
content: Text to write to stdout
|
||||
io_buffer: Buffer to use for IO operations
|
||||
id: Unique identifier for this entry
|
||||
timestamp: Creation timestamp for this entry
|
||||
"""
|
||||
super().__init__(id, timestamp)
|
||||
self.content = content
|
||||
self.io_buffer = io_buffer
|
||||
self._written = False
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Write the content to stdout if not already written.
|
||||
Uses the provided IO buffer for the actual write operation.
|
||||
"""
|
||||
if not self._written:
|
||||
self.io_buffer.write(self.content)
|
||||
self._written = True
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
"""
|
||||
Generate an XML Element representing this write entry.
|
||||
|
||||
Returns:
|
||||
ET.Element: XML element containing the entry's data
|
||||
"""
|
||||
# Create root element
|
||||
element = ET.Element("write_stdout", {"id": self.id})
|
||||
|
||||
# Add content as CDATA or escaped text
|
||||
element.text = escape_text_for_xml(self.content)
|
||||
|
||||
return element
|
||||
97
sia/xml_validator.py
Normal file
97
sia/xml_validator.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional, Set
|
||||
|
||||
class XMLValidator:
|
||||
"""
|
||||
Validates XML content against a schema.
|
||||
|
||||
Attributes:
|
||||
_schema: The parsed XML schema to validate against
|
||||
_valid_root_elements: Set of valid root element names from schema
|
||||
"""
|
||||
|
||||
def __init__(self, schema: str):
|
||||
"""
|
||||
Initialize validator with XML schema.
|
||||
|
||||
Args:
|
||||
schema: XML schema string
|
||||
"""
|
||||
# Register namespace used in schema
|
||||
ET.register_namespace('xs', 'http://www.w3.org/2001/XMLSchema')
|
||||
|
||||
try:
|
||||
# Parse schema
|
||||
self._schema = ET.fromstring(schema.strip())
|
||||
|
||||
# Extract valid root elements
|
||||
ns = {'xs': 'http://www.w3.org/2001/XMLSchema'}
|
||||
elements = self._schema.findall(".//xs:element", ns)
|
||||
self._valid_root_elements = {elem.get('name') for elem in elements if elem.get('name')}
|
||||
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"Invalid schema: {e}")
|
||||
|
||||
def validate(self, xml: str) -> Optional[str]:
|
||||
"""
|
||||
Validate XML content against the schema.
|
||||
|
||||
Args:
|
||||
xml: XML string to validate
|
||||
|
||||
Returns:
|
||||
str: Error message if validation fails, None if validation succeeds
|
||||
"""
|
||||
try:
|
||||
# Parse XML
|
||||
root = ET.fromstring(xml.strip())
|
||||
|
||||
# Check root element is valid
|
||||
if root.tag not in self._valid_root_elements:
|
||||
return f"Invalid root element: {root.tag}. Expected one of: {sorted(self._valid_root_elements)}"
|
||||
|
||||
# Get schema definition for this element
|
||||
ns = {'xs': 'http://www.w3.org/2001/XMLSchema'}
|
||||
element_schema = self._schema.find(f".//xs:element[@name='{root.tag}']", ns)
|
||||
if element_schema is None:
|
||||
return f"Schema definition not found for element: {root.tag}"
|
||||
|
||||
# Validate attributes if complex type defined
|
||||
complex_type = element_schema.find('xs:complexType', ns)
|
||||
if complex_type is not None:
|
||||
# Check required attributes
|
||||
for attr in complex_type.findall('.//xs:attribute[@use="required"]', ns):
|
||||
attr_name = attr.get('name')
|
||||
if attr_name not in root.attrib:
|
||||
return f"Missing required attribute '{attr_name}' on element '{root.tag}'"
|
||||
|
||||
# Check attribute types
|
||||
for attr_name, attr_value in root.attrib.items():
|
||||
attr_schema = complex_type.find(f'.//xs:attribute[@name="{attr_name}"]', ns)
|
||||
if attr_schema is None:
|
||||
return f"Unexpected attribute '{attr_name}' on element '{root.tag}'"
|
||||
|
||||
attr_type = attr_schema.get('type')
|
||||
if attr_type == 'xs:string':
|
||||
continue # All string values are valid
|
||||
elif attr_type == 'xs:integer':
|
||||
try:
|
||||
int(attr_value)
|
||||
except ValueError:
|
||||
return f"Invalid integer value '{attr_value}' for attribute '{attr_name}'"
|
||||
|
||||
return None # Validation successful
|
||||
|
||||
except ET.ParseError as e:
|
||||
return f"Invalid XML: {e}"
|
||||
except Exception as e:
|
||||
return f"Validation error: {e}"
|
||||
|
||||
def get_valid_root_elements(self) -> Set[str]:
|
||||
"""
|
||||
Get set of valid root element names from schema.
|
||||
|
||||
Returns:
|
||||
Set[str]: Set of valid root element names
|
||||
"""
|
||||
return self._valid_root_elements.copy()
|
||||
227
test/background_entry_test.py
Normal file
227
test/background_entry_test.py
Normal file
@@ -0,0 +1,227 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
import os
|
||||
import time
|
||||
import signal
|
||||
import platform
|
||||
|
||||
from sia.background_entry import BackgroundEntry
|
||||
|
||||
class BackgroundEntryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test cases with fixed id and timestamp"""
|
||||
self.test_id = "test-id-1234"
|
||||
self.test_timestamp = datetime(2024, 10, 31, 12, 0, 0)
|
||||
|
||||
# Create temporary files for testing
|
||||
self.test_filename = "test_output.txt"
|
||||
self.counter_file = "counter.txt"
|
||||
self.cleanup_files()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test files and ensure no processes are left running"""
|
||||
self.cleanup_files()
|
||||
|
||||
def cleanup_files(self):
|
||||
"""Helper to remove test files"""
|
||||
for filename in [self.test_filename, self.counter_file]:
|
||||
if os.path.exists(filename):
|
||||
os.remove(filename)
|
||||
|
||||
def wait_for_process_start(self, entry, timeout=1.0):
|
||||
"""Wait for process to start, with timeout"""
|
||||
start_time = time.time()
|
||||
while entry.process is None and time.time() - start_time < timeout:
|
||||
entry.update()
|
||||
time.sleep(0.1)
|
||||
self.assertIsNotNone(entry.process, "Process failed to start")
|
||||
|
||||
def wait_for_process_exit(self, entry, timeout=1.0):
|
||||
"""Wait for process to exit, with timeout"""
|
||||
start_time = time.time()
|
||||
while entry.process is not None and time.time() - start_time < timeout:
|
||||
entry.update()
|
||||
time.sleep(0.1)
|
||||
self.assertIsNone(entry.process, "Process failed to exit")
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test entry initialization"""
|
||||
script = "echo test"
|
||||
entry = BackgroundEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
self.assertEqual(entry.script, script)
|
||||
self.assertEqual(entry.stdout, "")
|
||||
self.assertEqual(entry.stderr, "")
|
||||
self.assertIsNone(entry.process)
|
||||
self.assertEqual(entry.id, self.test_id)
|
||||
self.assertEqual(entry.timestamp, self.test_timestamp)
|
||||
|
||||
def test_short_running_process(self):
|
||||
"""Test execution of a quick process"""
|
||||
script = "echo 'test'"
|
||||
entry = BackgroundEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
# Start and wait for completion
|
||||
entry.update()
|
||||
self.wait_for_process_start(entry)
|
||||
self.wait_for_process_exit(entry)
|
||||
|
||||
# Verify output
|
||||
self.assertEqual(entry._accumulated_stdout.strip(), "test")
|
||||
self.assertEqual(entry._accumulated_stderr, "")
|
||||
self.assertEqual(entry._exit_code, 0)
|
||||
|
||||
def test_continuous_output(self):
|
||||
"""Test process that generates continuous output"""
|
||||
# Create a Python script that counts to 3 with minimal delay
|
||||
script = (
|
||||
'python3 -c "'
|
||||
'import sys\n'
|
||||
'for i in range(3):\n'
|
||||
' sys.stdout.write(f\\\"count {i+1}\\n\\\")\n'
|
||||
' sys.stdout.flush()\n'
|
||||
'"'
|
||||
)
|
||||
|
||||
entry = BackgroundEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
# Start process and wait for completion
|
||||
entry.update()
|
||||
self.wait_for_process_start(entry)
|
||||
self.wait_for_process_exit(entry, timeout=2.0)
|
||||
|
||||
# One more update to ensure we get all output
|
||||
entry.update()
|
||||
|
||||
# Verify all output was captured
|
||||
output = entry._accumulated_stdout
|
||||
self.assertIn('count 1', output, "Missing count 1")
|
||||
self.assertIn('count 2', output, "Missing count 2")
|
||||
self.assertIn('count 3', output, "Missing count 3")
|
||||
|
||||
def test_process_failure(self):
|
||||
"""Test handling of process failures"""
|
||||
entry = BackgroundEntry("nonexistentcommand", self.test_id, self.test_timestamp)
|
||||
|
||||
# Start and wait for failure
|
||||
entry.update()
|
||||
self.wait_for_process_exit(entry)
|
||||
|
||||
# Verify error was captured
|
||||
self.assertTrue(len(entry._accumulated_stderr) > 0)
|
||||
self.assertEqual(entry._accumulated_stdout, "")
|
||||
self.assertNotEqual(entry._exit_code, 0)
|
||||
|
||||
def test_generate_context_running(self):
|
||||
"""Test XML context generation for running process"""
|
||||
script = "sleep 1"
|
||||
|
||||
entry = BackgroundEntry(script, self.test_id, self.test_timestamp)
|
||||
entry.update() # Start process
|
||||
self.wait_for_process_start(entry)
|
||||
|
||||
element = entry.generate_context()
|
||||
|
||||
# Verify XML structure
|
||||
self.assertEqual(element.tag, "background")
|
||||
self.assertEqual(element.get("id"), self.test_id)
|
||||
self.assertIsNotNone(element.get("pid"))
|
||||
self.assertIsNone(element.get("exit_code"))
|
||||
|
||||
# Clean up
|
||||
entry._cleanup_process()
|
||||
|
||||
def test_generate_context_completed(self):
|
||||
"""Test XML context generation for completed process"""
|
||||
script = "echo 'test'"
|
||||
entry = BackgroundEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
# Start and wait for completion
|
||||
entry.update()
|
||||
self.wait_for_process_start(entry)
|
||||
self.wait_for_process_exit(entry)
|
||||
|
||||
element = entry.generate_context()
|
||||
|
||||
# Verify XML structure
|
||||
self.assertEqual(element.tag, "background")
|
||||
self.assertEqual(element.get("id"), self.test_id)
|
||||
self.assertIsNone(element.get("pid"))
|
||||
self.assertEqual(element.get("exit_code"), "0")
|
||||
|
||||
stdout_text = element.find("stdout").text
|
||||
self.assertTrue(stdout_text.startswith("<![CDATA["))
|
||||
self.assertIn("test", stdout_text)
|
||||
self.assertTrue(stdout_text.endswith("]]>"))
|
||||
|
||||
def test_cleanup_on_deletion(self):
|
||||
"""Test process cleanup when entry is deleted"""
|
||||
script = "sleep 10"
|
||||
|
||||
entry = BackgroundEntry(script, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
self.wait_for_process_start(entry)
|
||||
|
||||
# Verify process is running
|
||||
self.assertIsNotNone(entry.process)
|
||||
pid = entry.process.pid
|
||||
|
||||
# Delete the entry
|
||||
entry._cleanup_process()
|
||||
del entry
|
||||
|
||||
# Give process time to be cleaned up
|
||||
time.sleep(0.1)
|
||||
|
||||
# Verify process was terminated
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
os.kill(pid, 0) # Check if process exists
|
||||
|
||||
def test_cleanup_running_process(self):
|
||||
"""Test cleanup of running process"""
|
||||
script = "sleep 10"
|
||||
entry = BackgroundEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
# Start the process
|
||||
entry.update()
|
||||
self.assertIsNotNone(entry.process)
|
||||
process_pid = entry.process.pid
|
||||
|
||||
# Cleanup should terminate process
|
||||
entry.cleanup()
|
||||
|
||||
# Verify process was terminated
|
||||
self.assertIsNone(entry.process)
|
||||
time.sleep(0.1) # Give process time to terminate
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
import os
|
||||
os.kill(process_pid, 0)
|
||||
|
||||
def test_cleanup_completed_process(self):
|
||||
"""Test cleanup of already completed process"""
|
||||
script = "echo 'test'"
|
||||
entry = BackgroundEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
# Run and wait for completion
|
||||
entry.update()
|
||||
self.wait_for_process_exit(entry)
|
||||
|
||||
# Cleanup should be safe for completed process
|
||||
entry.cleanup()
|
||||
self.assertIsNone(entry.process)
|
||||
|
||||
def test_multiple_cleanup_calls(self):
|
||||
"""Test that multiple cleanup calls are safe"""
|
||||
script = "sleep 10"
|
||||
entry = BackgroundEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
# Start process
|
||||
entry.update()
|
||||
self.assertIsNotNone(entry.process)
|
||||
|
||||
# Multiple cleanups should be safe
|
||||
entry.cleanup()
|
||||
entry.cleanup()
|
||||
entry.cleanup()
|
||||
|
||||
self.assertIsNone(entry.process)
|
||||
@@ -1,33 +0,0 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from sia.context_template import generate_context
|
||||
|
||||
class TestContextTemplate(unittest.TestCase):
|
||||
def test_empty_containers(self):
|
||||
"""Test context generation with no containers."""
|
||||
context = generate_context([])
|
||||
self.assertIn("<context>", context)
|
||||
self.assertIn("<containers/>", context)
|
||||
self.assertIn("</context>", context)
|
||||
|
||||
def test_single_container(self):
|
||||
"""Test context generation with a single container."""
|
||||
container_status = {
|
||||
'name': 'test-container',
|
||||
'status': 'running',
|
||||
'started_at': '2024-10-25T10:00:00Z',
|
||||
'stdout_size': 100,
|
||||
'stderr_size': 50
|
||||
}
|
||||
context = generate_context([container_status])
|
||||
self.assertIn("<context>", context)
|
||||
self.assertIn("<containers", context)
|
||||
self.assertIn('name="test-container"', context)
|
||||
self.assertIn('status="running"', context)
|
||||
self.assertIn('started_at="2024-10-25T10:00:00Z"', context)
|
||||
self.assertIn('stdout="100"', context)
|
||||
self.assertIn('stderr="50"', context)
|
||||
self.assertIn("</context>", context)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
66
test/delete_command_test.py
Normal file
66
test/delete_command_test.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
from sia.working_memory import WorkingMemory
|
||||
from sia.delete_command import DeleteCommand
|
||||
from sia.reasoning_entry import ReasoningEntry
|
||||
from sia.background_entry import BackgroundEntry
|
||||
|
||||
class DeleteCommandTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test cases with a fresh working memory"""
|
||||
self.memory = WorkingMemory()
|
||||
self.test_id = "test-id-1234"
|
||||
self.test_timestamp = datetime(2024, 10, 31, 12, 0, 0)
|
||||
|
||||
def test_delete_existing_entry(self):
|
||||
"""Test deleting an existing entry"""
|
||||
# Add test entry
|
||||
entry = ReasoningEntry("test content", self.test_id, self.test_timestamp)
|
||||
self.memory.add_entry(entry)
|
||||
|
||||
# Execute delete command
|
||||
command = DeleteCommand(self.test_id)
|
||||
result = command.execute(self.memory)
|
||||
|
||||
# Verify result and memory state
|
||||
self.assertTrue(result.success)
|
||||
self.assertFalse(result.should_stop)
|
||||
self.assertEqual(result.message, "")
|
||||
self.assertEqual(self.memory.get_entries_count(), 0)
|
||||
|
||||
def test_delete_running_background_entry(self):
|
||||
"""Test deleting a background entry that is still running"""
|
||||
# Create a background entry with a long-running command
|
||||
entry = BackgroundEntry("sleep 10", self.test_id, self.test_timestamp)
|
||||
self.memory.add_entry(entry)
|
||||
|
||||
# Start the process
|
||||
entry.update()
|
||||
self.assertIsNotNone(entry.process)
|
||||
process_pid = entry.process.pid
|
||||
|
||||
# Execute delete command
|
||||
command = DeleteCommand(self.test_id)
|
||||
result = command.execute(self.memory)
|
||||
|
||||
# Verify process was terminated
|
||||
time.sleep(0.1) # Give process time to terminate
|
||||
self.assertTrue(result.success)
|
||||
self.assertIsNone(entry.process)
|
||||
|
||||
# Verify process no longer exists
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
import os
|
||||
os.kill(process_pid, 0)
|
||||
|
||||
def test_delete_nonexistent_entry(self):
|
||||
"""Test attempting to delete a nonexistent entry"""
|
||||
command = DeleteCommand("nonexistent-id")
|
||||
result = command.execute(self.memory)
|
||||
|
||||
# Verify error result
|
||||
self.assertFalse(result.success)
|
||||
self.assertFalse(result.should_stop)
|
||||
self.assertIn("not found", result.message)
|
||||
@@ -1,121 +0,0 @@
|
||||
import unittest
|
||||
import time
|
||||
from sia.docker_module import DockerModule
|
||||
|
||||
class DockerModuleTest(unittest.TestCase):
|
||||
def test_initialization(self):
|
||||
"""Test DockerModule initialization."""
|
||||
with DockerModule() as docker_module:
|
||||
self.assertIsInstance(docker_module, DockerModule)
|
||||
|
||||
def test_start_container_short_lived(self):
|
||||
"""Test starting a short-lived container."""
|
||||
with DockerModule() as docker_module:
|
||||
image = "busybox:latest"
|
||||
timeout = 1000
|
||||
command = "echo"
|
||||
arguments = ["Hello World"]
|
||||
output = docker_module.start_container(
|
||||
image=image,
|
||||
timeout=timeout,
|
||||
command=command,
|
||||
arguments=arguments
|
||||
)
|
||||
self.assertEqual(output, "Hello World\n")
|
||||
|
||||
def test_start_container_long_running(self):
|
||||
"""Test starting a long-running container."""
|
||||
with DockerModule() as docker_module:
|
||||
name = "test_start_container_long_running"
|
||||
image = "busybox:latest"
|
||||
container_name = docker_module.start_container(
|
||||
image=image,
|
||||
name=name
|
||||
)
|
||||
self.assertEqual(container_name, name)
|
||||
self.assertIn(name, docker_module.containers)
|
||||
docker_module.wait_container(container_name, 0)
|
||||
self.assertNotIn(name, docker_module.containers)
|
||||
|
||||
def test_start_container_with_volumes(self):
|
||||
"""Test starting a container with volume mappings."""
|
||||
with DockerModule() as docker_module:
|
||||
arguments = ["sh", "-c", "echo 'Hello World' > /write_here/test_start_container_with_volumes"]
|
||||
volumes = {"/tmp": "/write_here"}
|
||||
docker_module.start_container(
|
||||
image="busybox:latest",
|
||||
timeout=1000,
|
||||
arguments=arguments,
|
||||
volumes=volumes
|
||||
)
|
||||
with open("/tmp/test_start_container_with_volumes", "r") as f:
|
||||
self.assertEqual(f.read(), "Hello World\n")
|
||||
|
||||
def test_invalid_container_start(self):
|
||||
"""Test error handling for invalid container start."""
|
||||
with DockerModule() as docker_module:
|
||||
with self.assertRaises(ValueError):
|
||||
docker_module.start_container(image="busybox:latest")
|
||||
|
||||
def test_container_io_operations(self):
|
||||
"""Test container I/O operations."""
|
||||
with DockerModule() as docker_module:
|
||||
arguments = [
|
||||
"sh",
|
||||
"-c",
|
||||
"read input; echo \"$input\" | tr '[:lower:]' '[:upper:]'; echo \"$input\" | tr '[:upper:]' '[:lower:]' >&2"
|
||||
]
|
||||
name = "test_container_io_operations"
|
||||
container_name = docker_module.start_container(
|
||||
image="busybox:latest",
|
||||
name=name,
|
||||
arguments=arguments
|
||||
)
|
||||
test_input = "Test Input\n"
|
||||
docker_module.write_container_stdin(name, test_input)
|
||||
time.sleep(1) # for processing
|
||||
stdout = docker_module.read_container_stdout(name)
|
||||
self.assertEqual(stdout, "TEST INPUT\n")
|
||||
stderr = docker_module.read_container_stderr(name)
|
||||
self.assertEqual(stderr, "test input\n")
|
||||
docker_module.wait_container(container_name, 0)
|
||||
|
||||
def test_wait_container(self):
|
||||
"""Test waiting for container completion."""
|
||||
with DockerModule() as docker_module:
|
||||
name = "test_wait_container"
|
||||
docker_module.start_container(
|
||||
image="busybox:latest",
|
||||
name=name
|
||||
)
|
||||
exit_code, output = docker_module.wait_container(name, timeout=1000)
|
||||
self.assertEqual(exit_code, -1)
|
||||
self.assertEqual(output, "")
|
||||
|
||||
def test_get_container_status(self):
|
||||
"""Test getting container status information."""
|
||||
with DockerModule() as docker_module:
|
||||
arguments = [
|
||||
"sh",
|
||||
"-c",
|
||||
"echo 'standard output'; echo 'standard error' >&2; sleep 3"
|
||||
]
|
||||
name = "test_get_container_status"
|
||||
docker_module.start_container(
|
||||
image="busybox:latest",
|
||||
name=name,
|
||||
arguments=arguments
|
||||
)
|
||||
time.sleep(1)
|
||||
status = docker_module.get_container_status(name)
|
||||
self.assertEqual(status['name'], name)
|
||||
self.assertEqual(status['status'], "running")
|
||||
self.assertGreater(len(status['started_at']), 0)
|
||||
self.assertEqual(status['exit_code'], 0)
|
||||
self.assertEqual(status['error'], "")
|
||||
self.assertEqual(status['stdout_size'], 16)
|
||||
self.assertEqual(status['stderr_size'], 15)
|
||||
docker_module.wait_container(name, 0)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -3,7 +3,6 @@ import unittest
|
||||
from itertools import tee
|
||||
|
||||
from . import test_data
|
||||
from . import test_util
|
||||
|
||||
from sia.llm_engine import LlmEngine
|
||||
|
||||
|
||||
88
test/parse_error_entry_test.py
Normal file
88
test/parse_error_entry_test.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sia.parse_error_entry import ParseErrorEntry
|
||||
|
||||
class ParseErrorEntryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test cases with fixed id and timestamp"""
|
||||
self.test_id = "test-id-1234"
|
||||
self.test_timestamp = datetime(2024, 10, 31, 12, 0, 0)
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test entry initialization"""
|
||||
content = "invalid content"
|
||||
error = "parsing failed"
|
||||
entry = ParseErrorEntry(content, error, self.test_id, self.test_timestamp)
|
||||
|
||||
self.assertEqual(entry.content, content)
|
||||
self.assertEqual(entry.error, error)
|
||||
self.assertEqual(entry.id, self.test_id)
|
||||
self.assertEqual(entry.timestamp, self.test_timestamp)
|
||||
|
||||
def test_update_does_nothing(self):
|
||||
"""Test that update operation has no effect"""
|
||||
content = "invalid content"
|
||||
error = "parsing failed"
|
||||
entry = ParseErrorEntry(content, error, self.test_id, self.test_timestamp)
|
||||
|
||||
# Store initial state
|
||||
initial_content = entry.content
|
||||
initial_error = entry.error
|
||||
|
||||
# Perform update
|
||||
entry.update()
|
||||
|
||||
# Verify state hasn't changed
|
||||
self.assertEqual(entry.content, initial_content)
|
||||
self.assertEqual(entry.error, initial_error)
|
||||
|
||||
def test_generate_context(self):
|
||||
"""Test XML context generation"""
|
||||
content = "invalid content"
|
||||
error = "parsing failed"
|
||||
entry = ParseErrorEntry(content, error, self.test_id, self.test_timestamp)
|
||||
|
||||
element = entry.generate_context()
|
||||
|
||||
# Verify XML structure
|
||||
self.assertEqual(element.tag, "parse_error")
|
||||
self.assertEqual(element.get("id"), self.test_id)
|
||||
|
||||
# Verify error element
|
||||
error_elem = element.find("error")
|
||||
self.assertIsNotNone(error_elem)
|
||||
self.assertEqual(error_elem.text, f"<![CDATA[{error}]]>")
|
||||
|
||||
# Verify content element
|
||||
content_elem = element.find("content")
|
||||
self.assertIsNotNone(content_elem)
|
||||
self.assertEqual(content_elem.text, f"<![CDATA[{content}]]>")
|
||||
|
||||
def test_special_characters(self):
|
||||
"""Test handling content and errors with special characters"""
|
||||
content = "Special content: \n\t\r\'\"\\"
|
||||
error = "Special error: \n\t\r\'\"\\"
|
||||
entry = ParseErrorEntry(content, error, self.test_id, self.test_timestamp)
|
||||
|
||||
element = entry.generate_context()
|
||||
self.assertEqual(element.find("content").text, f"<![CDATA[{content}]]>")
|
||||
self.assertEqual(element.find("error").text, f"<![CDATA[{error}]]>")
|
||||
|
||||
def test_empty_content_and_error(self):
|
||||
"""Test handling empty content and error messages"""
|
||||
entry = ParseErrorEntry("", "", self.test_id, self.test_timestamp)
|
||||
|
||||
element = entry.generate_context()
|
||||
self.assertEqual(element.find("content").text, "<![CDATA[]]>")
|
||||
self.assertEqual(element.find("error").text, "<![CDATA[]]>")
|
||||
|
||||
def test_large_content(self):
|
||||
"""Test handling large content and error messages"""
|
||||
content = "x" * 10000
|
||||
error = "y" * 10000
|
||||
entry = ParseErrorEntry(content, error, self.test_id, self.test_timestamp)
|
||||
|
||||
element = entry.generate_context()
|
||||
self.assertEqual(element.find("content").text, f"<![CDATA[{content}]]>")
|
||||
self.assertEqual(element.find("error").text, f"<![CDATA[{error}]]>")
|
||||
130
test/read_entry_test.py
Normal file
130
test/read_entry_test.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sia.web_io_buffer import WebIOBuffer
|
||||
from sia.read_entry import ReadEntry
|
||||
|
||||
class ReadEntryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test cases with fixed id, timestamp, and IO buffer"""
|
||||
self.test_id = "test-id-1234"
|
||||
self.test_timestamp = datetime(2024, 10, 31, 12, 0, 0)
|
||||
self.io_buffer = WebIOBuffer()
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test entry initialization"""
|
||||
entry = ReadEntry(self.io_buffer, self.test_id, self.test_timestamp)
|
||||
|
||||
self.assertEqual(entry.content, "")
|
||||
self.assertEqual(entry.id, self.test_id)
|
||||
self.assertEqual(entry.timestamp, self.test_timestamp)
|
||||
self.assertFalse(entry._read)
|
||||
|
||||
def test_single_read(self):
|
||||
"""Test reading content once"""
|
||||
test_input = "test message"
|
||||
self.io_buffer.append_stdin(test_input)
|
||||
|
||||
entry = ReadEntry(self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
# Verify content was read
|
||||
self.assertEqual(entry.content, test_input)
|
||||
self.assertTrue(entry._read)
|
||||
self.assertEqual(self.io_buffer.buffer_length(), 0) # Buffer should be cleared
|
||||
|
||||
def test_multiple_updates(self):
|
||||
"""Test that content is only read once even with multiple updates"""
|
||||
test_input = "initial input"
|
||||
self.io_buffer.append_stdin(test_input)
|
||||
|
||||
entry = ReadEntry(self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
initial_content = entry.content
|
||||
|
||||
# Add more input and update again
|
||||
self.io_buffer.append_stdin("additional input")
|
||||
entry.update()
|
||||
entry.update()
|
||||
|
||||
# Verify content hasn't changed after first read
|
||||
self.assertEqual(entry.content, initial_content)
|
||||
self.assertTrue(entry._read)
|
||||
|
||||
def test_empty_input(self):
|
||||
"""Test reading when no input is available"""
|
||||
entry = ReadEntry(self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
self.assertEqual(entry.content, "")
|
||||
self.assertTrue(entry._read)
|
||||
|
||||
def test_special_characters(self):
|
||||
"""Test reading content with special characters"""
|
||||
test_input = "Special chars: \n\t\r\'\"\\"
|
||||
self.io_buffer.append_stdin(test_input)
|
||||
|
||||
entry = ReadEntry(self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
self.assertEqual(entry.content, test_input)
|
||||
|
||||
def test_large_content(self):
|
||||
"""Test reading large content"""
|
||||
test_input = "x" * 10000
|
||||
self.io_buffer.append_stdin(test_input)
|
||||
|
||||
entry = ReadEntry(self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
self.assertEqual(entry.content, test_input)
|
||||
|
||||
def test_generate_context_before_read(self):
|
||||
"""Test XML context generation before reading"""
|
||||
entry = ReadEntry(self.io_buffer, self.test_id, self.test_timestamp)
|
||||
element = entry.generate_context()
|
||||
|
||||
# Verify XML structure
|
||||
self.assertEqual(element.tag, "read_stdin")
|
||||
self.assertEqual(element.get("id"), self.test_id)
|
||||
self.assertIsNone(element.text) # No content before read
|
||||
|
||||
def test_generate_context_after_read(self):
|
||||
"""Test XML context generation after reading"""
|
||||
test_input = "test message"
|
||||
self.io_buffer.append_stdin(test_input)
|
||||
|
||||
entry = ReadEntry(self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
element = entry.generate_context()
|
||||
|
||||
# Verify XML structure
|
||||
self.assertEqual(element.tag, "read_stdin")
|
||||
self.assertEqual(element.get("id"), self.test_id)
|
||||
self.assertEqual(element.text, f"<![CDATA[{test_input}]]>")
|
||||
|
||||
def test_unicode_content(self):
|
||||
"""Test reading Unicode content"""
|
||||
test_input = "Hello 世界 😊"
|
||||
self.io_buffer.append_stdin(test_input)
|
||||
|
||||
entry = ReadEntry(self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
self.assertEqual(entry.content, test_input)
|
||||
|
||||
def test_multiline_content(self):
|
||||
"""Test reading multiline content"""
|
||||
test_input = """Line 1
|
||||
Line 2
|
||||
Line 3"""
|
||||
self.io_buffer.append_stdin(test_input)
|
||||
|
||||
entry = ReadEntry(self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
self.assertEqual(entry.content, test_input)
|
||||
|
||||
# Verify XML escaping for multiline content
|
||||
element = entry.generate_context()
|
||||
self.assertEqual(element.text, f"<![CDATA[{test_input}]]>")
|
||||
78
test/reasoning_entry_test.py
Normal file
78
test/reasoning_entry_test.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sia.reasoning_entry import ReasoningEntry
|
||||
|
||||
class ReasoningEntryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test cases with fixed id and timestamp"""
|
||||
self.test_id = "test-id-1234"
|
||||
self.test_timestamp = datetime(2024, 10, 31, 12, 0, 0)
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test entry initialization"""
|
||||
content = "test reasoning"
|
||||
entry = ReasoningEntry(content, self.test_id, self.test_timestamp)
|
||||
|
||||
self.assertEqual(entry.content, content)
|
||||
self.assertEqual(entry.id, self.test_id)
|
||||
self.assertEqual(entry.timestamp, self.test_timestamp)
|
||||
|
||||
def test_update_does_nothing(self):
|
||||
"""Test that update operation has no effect"""
|
||||
content = "test reasoning"
|
||||
entry = ReasoningEntry(content, self.test_id, self.test_timestamp)
|
||||
|
||||
# Store initial state
|
||||
initial_content = entry.content
|
||||
|
||||
# Perform update
|
||||
entry.update()
|
||||
|
||||
# Verify state hasn't changed
|
||||
self.assertEqual(entry.content, initial_content)
|
||||
|
||||
def test_generate_context(self):
|
||||
"""Test XML context generation"""
|
||||
content = "test reasoning"
|
||||
entry = ReasoningEntry(content, self.test_id, self.test_timestamp)
|
||||
|
||||
element = entry.generate_context()
|
||||
|
||||
# Verify XML structure
|
||||
self.assertEqual(element.tag, "reasoning")
|
||||
self.assertEqual(element.get("id"), self.test_id)
|
||||
self.assertEqual(element.text, f"<![CDATA[{content}]]>")
|
||||
|
||||
def test_special_characters(self):
|
||||
"""Test handling reasoning text with special characters"""
|
||||
content = "Special reasoning: \n\t\r\'\"\\"
|
||||
entry = ReasoningEntry(content, self.test_id, self.test_timestamp)
|
||||
|
||||
element = entry.generate_context()
|
||||
self.assertEqual(element.text, f"<![CDATA[{content}]]>")
|
||||
|
||||
def test_empty_content(self):
|
||||
"""Test handling empty reasoning text"""
|
||||
entry = ReasoningEntry("", self.test_id, self.test_timestamp)
|
||||
|
||||
element = entry.generate_context()
|
||||
self.assertEqual(element.text, "<![CDATA[]]>")
|
||||
|
||||
def test_large_content(self):
|
||||
"""Test handling large reasoning text"""
|
||||
content = "x" * 10000
|
||||
entry = ReasoningEntry(content, self.test_id, self.test_timestamp)
|
||||
|
||||
element = entry.generate_context()
|
||||
self.assertEqual(element.text, f"<![CDATA[{content}]]>")
|
||||
|
||||
def test_multiline_content(self):
|
||||
"""Test handling multiline reasoning text"""
|
||||
content = """Line 1
|
||||
Line 2
|
||||
Line 3"""
|
||||
entry = ReasoningEntry(content, self.test_id, self.test_timestamp)
|
||||
|
||||
element = entry.generate_context()
|
||||
self.assertEqual(element.text, f"<![CDATA[{content}]]>")
|
||||
164
test/repeat_entry_test.py
Normal file
164
test/repeat_entry_test.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
from sia.repeat_entry import RepeatEntry
|
||||
|
||||
class RepeatEntryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test cases with fixed id and timestamp"""
|
||||
self.test_id = "test-id-1234"
|
||||
self.test_timestamp = datetime(2024, 10, 31, 12, 0, 0)
|
||||
|
||||
# Create a temporary file for testing
|
||||
self.test_filename = "test_output.txt"
|
||||
self.counter_file = "counter.txt"
|
||||
self.cleanup_files()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up any test files"""
|
||||
self.cleanup_files()
|
||||
|
||||
def cleanup_files(self):
|
||||
"""Helper to remove test files"""
|
||||
for filename in [self.test_filename, self.counter_file]:
|
||||
if os.path.exists(filename):
|
||||
os.remove(filename)
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test entry initialization"""
|
||||
script = "echo test"
|
||||
entry = RepeatEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
self.assertEqual(entry.script, script)
|
||||
self.assertEqual(entry.stdout, "")
|
||||
self.assertEqual(entry.stderr, "")
|
||||
self.assertIsNone(entry.exit_code)
|
||||
self.assertEqual(entry.id, self.test_id)
|
||||
self.assertEqual(entry.timestamp, self.test_timestamp)
|
||||
|
||||
def test_successful_execution(self):
|
||||
"""Test successful script execution"""
|
||||
script = "echo 'test'"
|
||||
entry = RepeatEntry(script, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
# Verify entry state
|
||||
self.assertEqual(entry.stdout.strip(), "test")
|
||||
self.assertEqual(entry.stderr, "")
|
||||
self.assertEqual(entry.exit_code, 0)
|
||||
|
||||
def test_failed_execution(self):
|
||||
"""Test failed script execution with invalid command"""
|
||||
entry = RepeatEntry("nonexistentcommand", self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
# Verify entry state
|
||||
self.assertEqual(entry.stdout, "")
|
||||
self.assertTrue(len(entry.stderr) > 0) # Error message will vary by OS
|
||||
self.assertNotEqual(entry.exit_code, 0) # Exit code will be non-zero
|
||||
|
||||
def test_repeated_execution(self):
|
||||
"""Test that script executes on every update"""
|
||||
# Create a counter file
|
||||
with open(self.counter_file, 'w') as f:
|
||||
f.write('0')
|
||||
|
||||
# Script that increments and reads the counter
|
||||
script = (
|
||||
f'python3 -c "'
|
||||
f'import os; '
|
||||
f'f=open(\'{self.counter_file}\', \'r+\'); '
|
||||
f'n=int(f.read()); '
|
||||
f'f.seek(0); '
|
||||
f'f.write(str(n+1)); '
|
||||
f'f.truncate(); '
|
||||
f'f.close(); '
|
||||
f'print(n+1)'
|
||||
f'"'
|
||||
)
|
||||
|
||||
entry = RepeatEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
# Run update multiple times
|
||||
outputs = []
|
||||
for _ in range(3):
|
||||
entry.update()
|
||||
outputs.append(int(entry.stdout.strip()))
|
||||
|
||||
# Verify outputs are sequential numbers
|
||||
self.assertEqual(outputs, [1, 2, 3])
|
||||
|
||||
def test_generate_context(self):
|
||||
"""Test XML context generation"""
|
||||
script = "echo 'test'"
|
||||
entry = RepeatEntry(script, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
element = entry.generate_context()
|
||||
|
||||
# Verify XML structure
|
||||
self.assertEqual(element.tag, "repeat")
|
||||
self.assertEqual(element.get("id"), self.test_id)
|
||||
self.assertEqual(element.text, f"<![CDATA[{script}]]>")
|
||||
self.assertEqual(element.get("exit_code"), "0")
|
||||
|
||||
# Get stdout and remove trailing newline for comparison
|
||||
stdout_text = element.find("stdout").text
|
||||
self.assertTrue(stdout_text.startswith("<![CDATA[test"))
|
||||
self.assertTrue(stdout_text.endswith("]]>"))
|
||||
|
||||
self.assertEqual(element.find("stderr").text, "<![CDATA[]]>")
|
||||
|
||||
def test_changing_output(self):
|
||||
"""Test handling of changing command output"""
|
||||
# Create a counter file
|
||||
with open(self.counter_file, 'w') as f:
|
||||
f.write('0')
|
||||
|
||||
# Script that outputs an incrementing number
|
||||
script = (
|
||||
f'python3 -c "'
|
||||
f'import os; '
|
||||
f'f=open(\'{self.counter_file}\', \'r+\'); '
|
||||
f'n=int(f.read()); '
|
||||
f'f.seek(0); '
|
||||
f'f.write(str(n+1)); '
|
||||
f'f.truncate(); '
|
||||
f'f.close(); '
|
||||
f'print(\'Output\', n+1)'
|
||||
f'"'
|
||||
)
|
||||
|
||||
entry = RepeatEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
# Execute multiple times and verify output changes
|
||||
entry.update()
|
||||
first_output = entry.stdout.strip()
|
||||
|
||||
entry.update()
|
||||
second_output = entry.stdout.strip()
|
||||
|
||||
entry.update()
|
||||
third_output = entry.stdout.strip()
|
||||
|
||||
# Outputs should be different for each run
|
||||
outputs = [first_output, second_output, third_output]
|
||||
unique_outputs = set(outputs)
|
||||
self.assertEqual(len(unique_outputs), 3)
|
||||
self.assertEqual(outputs, ['Output 1', 'Output 2', 'Output 3'])
|
||||
|
||||
def test_error_recovery(self):
|
||||
"""Test that entry recovers from errors in subsequent updates"""
|
||||
# Start with a failing command
|
||||
entry = RepeatEntry("nonexistentcommand", self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
self.assertNotEqual(entry.exit_code, 0)
|
||||
|
||||
# Change to a working command
|
||||
entry.script = "echo 'test'"
|
||||
entry.update()
|
||||
|
||||
# Verify recovery
|
||||
self.assertEqual(entry.exit_code, 0)
|
||||
self.assertEqual(entry.stdout.strip(), "test")
|
||||
self.assertEqual(entry.stderr, "")
|
||||
141
test/single_shot_entry_test.py
Normal file
141
test/single_shot_entry_test.py
Normal file
@@ -0,0 +1,141 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
from sia.single_shot_entry import SingleShotEntry
|
||||
|
||||
class SingleShotEntryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test cases with fixed id and timestamp"""
|
||||
self.test_id = "test-id-1234"
|
||||
self.test_timestamp = datetime(2024, 10, 31, 12, 0, 0)
|
||||
|
||||
# Create a temporary file for testing
|
||||
self.test_filename = "test_output.txt"
|
||||
self.cleanup_files()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up any test files"""
|
||||
self.cleanup_files()
|
||||
|
||||
def cleanup_files(self):
|
||||
"""Helper to remove test files"""
|
||||
if os.path.exists(self.test_filename):
|
||||
os.remove(self.test_filename)
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test entry initialization"""
|
||||
script = "echo test"
|
||||
entry = SingleShotEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
self.assertEqual(entry.script, script)
|
||||
self.assertEqual(entry.stdout, "")
|
||||
self.assertEqual(entry.stderr, "")
|
||||
self.assertIsNone(entry.exit_code)
|
||||
self.assertEqual(entry.id, self.test_id)
|
||||
self.assertEqual(entry.timestamp, self.test_timestamp)
|
||||
self.assertFalse(entry._executed)
|
||||
|
||||
def test_successful_execution(self):
|
||||
"""Test successful script execution"""
|
||||
script = "echo 'test'"
|
||||
|
||||
entry = SingleShotEntry(script, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
# Verify entry state
|
||||
self.assertEqual(entry.stdout.strip(), "test")
|
||||
self.assertEqual(entry.stderr, "")
|
||||
self.assertEqual(entry.exit_code, 0)
|
||||
self.assertTrue(entry._executed)
|
||||
|
||||
def test_failed_execution(self):
|
||||
"""Test failed script execution with invalid command"""
|
||||
entry = SingleShotEntry("nonexistentcommand", self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
# Verify entry state
|
||||
self.assertEqual(entry.stdout, "")
|
||||
self.assertTrue(len(entry.stderr) > 0) # Error message will vary by OS
|
||||
self.assertNotEqual(entry.exit_code, 0) # Exit code will be non-zero
|
||||
self.assertTrue(entry._executed)
|
||||
|
||||
def test_file_creation(self):
|
||||
"""Test script that creates a file"""
|
||||
script = f"echo 'test' > {self.test_filename}"
|
||||
|
||||
entry = SingleShotEntry(script, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
# Verify file was created and contains expected content
|
||||
self.assertTrue(os.path.exists(self.test_filename))
|
||||
with open(self.test_filename, 'r') as f:
|
||||
content = f.read().strip()
|
||||
self.assertEqual(content, "test")
|
||||
|
||||
def test_single_execution(self):
|
||||
"""Test that script only executes once"""
|
||||
script = f"echo 'test' >> {self.test_filename}"
|
||||
|
||||
entry = SingleShotEntry(script, self.test_id, self.test_timestamp)
|
||||
|
||||
# Run update multiple times
|
||||
entry.update()
|
||||
entry.update()
|
||||
entry.update()
|
||||
|
||||
# Verify file only contains one line
|
||||
with open(self.test_filename, 'r') as f:
|
||||
lines = f.readlines()
|
||||
self.assertEqual(len(lines), 1)
|
||||
self.assertEqual(lines[0].strip(), "test")
|
||||
|
||||
def test_generate_context_before_execution(self):
|
||||
"""Test XML context generation before execution"""
|
||||
entry = SingleShotEntry("echo test", self.test_id, self.test_timestamp)
|
||||
element = entry.generate_context()
|
||||
|
||||
# Verify XML structure
|
||||
self.assertEqual(element.tag, "single_shot")
|
||||
self.assertEqual(element.get("id"), self.test_id)
|
||||
self.assertEqual(element.text, "<![CDATA[echo test]]>")
|
||||
|
||||
# Verify no output elements exist
|
||||
self.assertIsNone(element.find("stdout"))
|
||||
self.assertIsNone(element.find("stderr"))
|
||||
self.assertIsNone(element.get("exit_code"))
|
||||
|
||||
def test_generate_context_after_execution(self):
|
||||
"""Test XML context generation after execution"""
|
||||
script = "echo 'test'"
|
||||
|
||||
entry = SingleShotEntry(script, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
element = entry.generate_context()
|
||||
|
||||
# Verify XML structure
|
||||
self.assertEqual(element.tag, "single_shot")
|
||||
self.assertEqual(element.get("id"), self.test_id)
|
||||
self.assertEqual(element.text, f"<![CDATA[{script}]]>")
|
||||
|
||||
# Get stdout and remove trailing newline for comparison
|
||||
stdout_text = element.find("stdout").text
|
||||
self.assertTrue(stdout_text.startswith("<![CDATA[test"))
|
||||
self.assertTrue(stdout_text.endswith("]]>"))
|
||||
|
||||
self.assertEqual(element.find("stderr").text, "<![CDATA[]]>")
|
||||
self.assertEqual(element.get("exit_code"), "0")
|
||||
|
||||
def test_multiple_commands(self):
|
||||
"""Test executing multiple commands in one script"""
|
||||
script = f"echo 'first' > {self.test_filename} && echo 'second' >> {self.test_filename}"
|
||||
|
||||
entry = SingleShotEntry(script, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
# Verify file contains both lines
|
||||
with open(self.test_filename, 'r') as f:
|
||||
lines = f.readlines()
|
||||
self.assertEqual(len(lines), 2)
|
||||
self.assertEqual(lines[0].strip(), "first")
|
||||
self.assertEqual(lines[1].strip(), "second")
|
||||
180
test/standard_io_buffer_test.py
Normal file
180
test/standard_io_buffer_test.py
Normal file
@@ -0,0 +1,180 @@
|
||||
import unittest
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
class StandardIOBufferTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Create temporary directory for test files"""
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.sia_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
self.write_test_script()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up temporary test files"""
|
||||
for file in Path(self.test_dir).glob('*'):
|
||||
file.unlink()
|
||||
os.rmdir(self.test_dir)
|
||||
|
||||
def write_test_script(self):
|
||||
"""Create a Python script that uses StandardIOBuffer"""
|
||||
script_path = os.path.join(self.test_dir, 'test_script.py')
|
||||
with open(script_path, 'w', newline='') as f:
|
||||
f.write(f'''
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
|
||||
# Add the sia package directory to Python path
|
||||
sys.path.insert(0, {repr(self.sia_path)})
|
||||
|
||||
from sia.standard_io_buffer import StandardIOBuffer
|
||||
|
||||
def run_buffer_test(test_type):
|
||||
buffer = StandardIOBuffer()
|
||||
|
||||
if test_type == 'read':
|
||||
content = buffer.read()
|
||||
sys.stdout.buffer.write(f"Read: {{content}}".encode('utf-8'))
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
elif test_type == 'write':
|
||||
buffer.write("Test output")
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
elif test_type == 'multiple_write':
|
||||
buffer.write("Part 1")
|
||||
buffer.write("Part 2")
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
elif test_type == 'buffer_length':
|
||||
buffer._input_buffer = "test"
|
||||
sys.stdout.buffer.write(str(buffer.buffer_length()).encode('utf-8'))
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
elif test_type == 'read_lines':
|
||||
content1 = buffer.read()
|
||||
sys.stdout.buffer.write(f"First read: {{content1}}".encode('utf-8'))
|
||||
sys.stdout.buffer.flush()
|
||||
time.sleep(0.1)
|
||||
content2 = buffer.read()
|
||||
sys.stdout.buffer.write(f"Second read: {{content2}}".encode('utf-8'))
|
||||
sys.stdout.buffer.flush()
|
||||
|
||||
elif test_type == 'debug':
|
||||
print("Python path:", sys.path)
|
||||
print("Current directory:", os.getcwd())
|
||||
print("Buffer import successful")
|
||||
print("Python version:", sys.version)
|
||||
print("Platform:", sys.platform)
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("Test type required")
|
||||
sys.exit(1)
|
||||
test_type = sys.argv[1]
|
||||
run_buffer_test(test_type)
|
||||
''')
|
||||
self.script_path = script_path
|
||||
|
||||
def run_script(self, test_type, input_data=None):
|
||||
"""Run test script with given input and return output"""
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, self.script_path, test_type],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env={**os.environ, 'PYTHONPATH': self.sia_path},
|
||||
# Use binary mode for consistent line endings
|
||||
universal_newlines=False
|
||||
)
|
||||
|
||||
try:
|
||||
# Convert input data to bytes if provided
|
||||
input_bytes = input_data.encode('utf-8') if input_data is not None else None
|
||||
stdout_bytes, stderr_bytes = process.communicate(input=input_bytes, timeout=5)
|
||||
|
||||
# Decode output using utf-8, preserving special characters
|
||||
stdout = stdout_bytes.decode('utf-8')
|
||||
stderr = stderr_bytes.decode('utf-8')
|
||||
|
||||
return stdout, stderr, process.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
stdout_bytes, stderr_bytes = process.communicate()
|
||||
stdout = stdout_bytes.decode('utf-8')
|
||||
stderr = stderr_bytes.decode('utf-8')
|
||||
return stdout, stderr, -1
|
||||
|
||||
def test_debug_environment(self):
|
||||
"""Test that the environment is properly set up"""
|
||||
stdout, stderr, code = self.run_script('debug')
|
||||
self.assertEqual(code, 0, f"Debug script failed with stderr: {stderr}")
|
||||
self.assertIn("Buffer import successful", stdout)
|
||||
|
||||
def test_read_available_input(self):
|
||||
"""Test reading available input without mocks"""
|
||||
stdout, stderr, code = self.run_script('read', 'test input\n')
|
||||
self.assertEqual(code, 0, f"Script failed with stderr: {stderr}")
|
||||
self.assertEqual(stdout, 'Read: test input\n')
|
||||
|
||||
def test_read_no_input(self):
|
||||
"""Test reading when no input is provided"""
|
||||
stdout, stderr, code = self.run_script('read')
|
||||
self.assertEqual(code, 0, f"Script failed with stderr: {stderr}")
|
||||
self.assertEqual(stdout, 'Read: ')
|
||||
|
||||
def test_write_output(self):
|
||||
"""Test writing output without mocks"""
|
||||
stdout, stderr, code = self.run_script('write')
|
||||
self.assertEqual(code, 0, f"Script failed with stderr: {stderr}")
|
||||
self.assertEqual(stdout, 'Test output')
|
||||
|
||||
def test_multiple_writes(self):
|
||||
"""Test multiple write operations"""
|
||||
stdout, stderr, code = self.run_script('multiple_write')
|
||||
self.assertEqual(code, 0, f"Script failed with stderr: {stderr}")
|
||||
self.assertEqual(stdout, 'Part 1Part 2')
|
||||
|
||||
def test_buffer_length(self):
|
||||
"""Test buffer length reporting"""
|
||||
stdout, stderr, code = self.run_script('buffer_length')
|
||||
self.assertEqual(code, 0, f"Script failed with stderr: {stderr}")
|
||||
self.assertEqual(stdout.strip(), '4')
|
||||
|
||||
def test_multiple_reads(self):
|
||||
"""Test multiple read operations with actual input"""
|
||||
input_data = 'line1\nline2\n'
|
||||
stdout, stderr, code = self.run_script('read_lines', input_data)
|
||||
self.assertEqual(code, 0, f"Script failed with stderr: {stderr}")
|
||||
self.assertIn('First read: line1\nline2\n', stdout)
|
||||
self.assertIn('Second read: ', stdout)
|
||||
|
||||
def test_special_characters(self):
|
||||
"""Test handling special characters in input/output"""
|
||||
# Use raw string to ensure exact character representation
|
||||
special_chars = 'Special chars: \n\t\r\'"'
|
||||
stdout, stderr, code = self.run_script('read', special_chars)
|
||||
self.assertEqual(code, 0, f"Script failed with stderr: {stderr}")
|
||||
expected = f'Read: {special_chars}'
|
||||
# Print actual bytes for debugging
|
||||
if stdout != expected:
|
||||
print("\nDebug output:")
|
||||
print("Expected bytes:", expected.encode('utf-8'))
|
||||
print("Actual bytes:", stdout.encode('utf-8'))
|
||||
self.assertEqual(stdout, expected)
|
||||
|
||||
def test_empty_input(self):
|
||||
"""Test handling empty input"""
|
||||
stdout, stderr, code = self.run_script('read', '')
|
||||
self.assertEqual(code, 0, f"Script failed with stderr: {stderr}")
|
||||
self.assertEqual(stdout, 'Read: ')
|
||||
|
||||
def test_large_input(self):
|
||||
"""Test handling large input"""
|
||||
large_input = 'x' * 10000
|
||||
stdout, stderr, code = self.run_script('read', large_input)
|
||||
self.assertEqual(code, 0, f"Script failed with stderr: {stderr}")
|
||||
self.assertEqual(stdout, f'Read: {large_input}')
|
||||
45
test/stop_command_test.py
Normal file
45
test/stop_command_test.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
from sia.working_memory import WorkingMemory
|
||||
from sia.stop_command import StopCommand
|
||||
from sia.reasoning_entry import ReasoningEntry
|
||||
from sia.background_entry import BackgroundEntry
|
||||
|
||||
class StopCommandTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test cases with a fresh working memory"""
|
||||
self.memory = WorkingMemory()
|
||||
self.test_timestamp = datetime(2024, 10, 31, 12, 0, 0)
|
||||
|
||||
def test_stop_command_cleanup(self):
|
||||
"""Test stop command cleans up and removes all entries"""
|
||||
# Add a mix of entry types including a background process
|
||||
entries = [
|
||||
ReasoningEntry("test content", "id-1", self.test_timestamp),
|
||||
BackgroundEntry("sleep 10", "id-2", self.test_timestamp)
|
||||
]
|
||||
|
||||
for entry in entries:
|
||||
self.memory.add_entry(entry)
|
||||
|
||||
# Start the background process
|
||||
background_entry = self.memory.get_entry("id-2")
|
||||
background_entry.update()
|
||||
self.assertIsNotNone(background_entry.process)
|
||||
process_pid = background_entry.process.pid
|
||||
|
||||
# Execute stop command
|
||||
command = StopCommand()
|
||||
result = command.execute(self.memory)
|
||||
|
||||
# Verify all entries removed
|
||||
self.assertTrue(result.should_stop)
|
||||
self.assertEqual(self.memory.get_entries_count(), 0)
|
||||
|
||||
# Verify background process was terminated
|
||||
time.sleep(0.1) # Give process time to terminate
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
import os
|
||||
os.kill(process_pid, 0)
|
||||
@@ -1,8 +0,0 @@
|
||||
import unittest
|
||||
|
||||
class SequentialTestLoader(unittest.TestLoader):
|
||||
def getTestCaseNames(self, testCaseClass):
|
||||
test_names = super().getTestCaseNames(testCaseClass)
|
||||
testcase_methods = list(testCaseClass.__dict__.keys())
|
||||
test_names.sort(key=testcase_methods.index)
|
||||
return test_names
|
||||
@@ -1,17 +1,118 @@
|
||||
import unittest
|
||||
from typing import Iterator
|
||||
|
||||
from . import test_data
|
||||
|
||||
from sia import util
|
||||
|
||||
class UtilTest(unittest.TestCase):
|
||||
def test_get_valid_root_elements_single(self):
|
||||
valid_elements = util.get_valid_root_elements(test_data.echo_action_schema)
|
||||
self.assertEqual(valid_elements, {'test_tag'})
|
||||
def test_stop_before_value(self):
|
||||
# Helper function to create iterator from list
|
||||
def create_iterator(items: list) -> Iterator[str]:
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
def test_split_response_single_element(self):
|
||||
response = "Some reasoning here\n<test_tag>content</test_tag>"
|
||||
valid_elements = {'test_tag'}
|
||||
result = util.split_response(response, valid_elements)
|
||||
self.assertEqual(result.reasoning, "Some reasoning here")
|
||||
self.assertEqual(result.actions, "<test_tag>content</test_tag>")
|
||||
# Test case 1: Stop value in middle of sequence
|
||||
input_sequence = ['hello', 'world', 'STOP', 'ignored']
|
||||
result = list(util.stop_before_value(create_iterator(input_sequence), 'STOP'))
|
||||
self.assertEqual(result, ['hello', 'world'])
|
||||
|
||||
# Test case 2: Stop value not in sequence
|
||||
input_sequence = ['hello', 'world']
|
||||
result = list(util.stop_before_value(create_iterator(input_sequence), 'STOP'))
|
||||
self.assertEqual(result, ['hello', 'world'])
|
||||
|
||||
# Test case 3: Stop value at start of sequence
|
||||
input_sequence = ['STOP', 'ignored']
|
||||
result = list(util.stop_before_value(create_iterator(input_sequence), 'STOP'))
|
||||
self.assertEqual(result, [])
|
||||
|
||||
# Test case 4: Stop value as part of an item
|
||||
input_sequence = ['hello', 'woSTOPrld', 'ignored']
|
||||
result = list(util.stop_before_value(create_iterator(input_sequence), 'STOP'))
|
||||
self.assertEqual(result, ['hello', 'wo'])
|
||||
|
||||
# Test case 5: Empty sequence
|
||||
input_sequence = []
|
||||
result = list(util.stop_before_value(create_iterator(input_sequence), 'STOP'))
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_none_value(self):
|
||||
"""Test that None values are converted to empty strings"""
|
||||
self.assertEqual(util.escape_text_for_xml(None), '')
|
||||
|
||||
def test_number_values(self):
|
||||
"""Test that numbers are properly converted to strings and wrapped in CDATA"""
|
||||
self.assertEqual(util.escape_text_for_xml(42), '<![CDATA[42]]>')
|
||||
self.assertEqual(util.escape_text_for_xml(3.14), '<![CDATA[3.14]]>')
|
||||
self.assertEqual(util.escape_text_for_xml(-100), '<![CDATA[-100]]>')
|
||||
self.assertEqual(util.escape_text_for_xml(0), '<![CDATA[0]]>')
|
||||
|
||||
def test_simple_strings(self):
|
||||
"""Test that simple strings are wrapped in CDATA"""
|
||||
self.assertEqual(util.escape_text_for_xml('hello'), '<![CDATA[hello]]>')
|
||||
self.assertEqual(util.escape_text_for_xml(''), '<![CDATA[]]>')
|
||||
self.assertEqual(util.escape_text_for_xml(' '), '<![CDATA[ ]]>')
|
||||
|
||||
def test_strings_with_special_characters(self):
|
||||
"""Test strings containing XML special characters but no CDATA end sequence"""
|
||||
self.assertEqual(util.escape_text_for_xml('<hello>'), '<![CDATA[<hello>]]>')
|
||||
self.assertEqual(util.escape_text_for_xml('a & b'), '<![CDATA[a & b]]>')
|
||||
self.assertEqual(util.escape_text_for_xml('"quoted"'), '<![CDATA["quoted"]]>')
|
||||
self.assertEqual(util.escape_text_for_xml("'single'"), "<![CDATA['single']]>")
|
||||
self.assertEqual(util.escape_text_for_xml('<![CDATA['), '<![CDATA[<![CDATA[]]>')
|
||||
|
||||
def test_strings_with_cdata_end_sequence(self):
|
||||
"""Test strings containing CDATA end sequence are properly escaped"""
|
||||
# Simple case with just the CDATA end sequence
|
||||
self.assertEqual(util.escape_text_for_xml(']]>'), ']]>')
|
||||
|
||||
# CDATA end sequence with other special characters
|
||||
self.assertEqual(
|
||||
util.escape_text_for_xml('Text with ]]> and <tags> & ampersands'),
|
||||
'Text with ]]> and <tags> & ampersands'
|
||||
)
|
||||
|
||||
# Multiple CDATA end sequences
|
||||
self.assertEqual(
|
||||
util.escape_text_for_xml('Multiple ]]> end ]]> sequences'),
|
||||
'Multiple ]]> end ]]> sequences'
|
||||
)
|
||||
|
||||
def test_multiline_strings(self):
|
||||
"""Test multiline strings are properly handled"""
|
||||
multiline = """Line 1
|
||||
Line 2
|
||||
Line 3"""
|
||||
self.assertEqual(util.escape_text_for_xml(multiline), '<![CDATA[Line 1\n Line 2\n Line 3]]>')
|
||||
|
||||
multiline_with_cdata = """Line 1
|
||||
Line ]]> 2
|
||||
Line 3"""
|
||||
self.assertEqual(
|
||||
util.escape_text_for_xml(multiline_with_cdata),
|
||||
'Line 1\n Line ]]> 2\n Line 3'
|
||||
)
|
||||
|
||||
def test_edge_cases(self):
|
||||
"""Test edge cases and unusual inputs"""
|
||||
# Empty string
|
||||
self.assertEqual(util.escape_text_for_xml(''), '<![CDATA[]]>')
|
||||
|
||||
# String with only whitespace
|
||||
self.assertEqual(util.escape_text_for_xml('\n\t '), '<![CDATA[\n\t ]]>')
|
||||
|
||||
# String with Unicode characters
|
||||
self.assertEqual(util.escape_text_for_xml('Hello 世界'), '<![CDATA[Hello 世界]]>')
|
||||
|
||||
# String with control characters
|
||||
self.assertEqual(util.escape_text_for_xml('Hello\0World'), '<![CDATA[Hello\0World]]>')
|
||||
|
||||
# Very long string
|
||||
long_string = 'x' * 1000
|
||||
self.assertEqual(util.escape_text_for_xml(long_string), f'<![CDATA[{long_string}]]>')
|
||||
|
||||
def test_boolean_values(self):
|
||||
"""Test boolean values are properly converted"""
|
||||
self.assertEqual(util.escape_text_for_xml(True), '<![CDATA[True]]>')
|
||||
self.assertEqual(util.escape_text_for_xml(False), '<![CDATA[False]]>')
|
||||
106
test/web_io_buffer_test.py
Normal file
106
test/web_io_buffer_test.py
Normal file
@@ -0,0 +1,106 @@
|
||||
import unittest
|
||||
|
||||
from sia.web_io_buffer import WebIOBuffer
|
||||
|
||||
class WebIOBufferTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up a fresh WebIOBuffer for each test."""
|
||||
self.buffer = WebIOBuffer()
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test initial state of buffer"""
|
||||
self.assertEqual(self.buffer.buffer_length(), 0)
|
||||
self.assertEqual(self.buffer.get_stdout(), "")
|
||||
self.assertEqual(self.buffer.read(), "")
|
||||
|
||||
def test_append_stdin(self):
|
||||
"""Test appending to stdin buffer"""
|
||||
test_input = "test input"
|
||||
self.buffer.append_stdin(test_input)
|
||||
self.assertEqual(self.buffer.buffer_length(), len(test_input))
|
||||
|
||||
def test_read_clears_buffer(self):
|
||||
"""Test that reading clears the stdin buffer"""
|
||||
test_input = "test input"
|
||||
self.buffer.append_stdin(test_input)
|
||||
content = self.buffer.read()
|
||||
self.assertEqual(content, test_input)
|
||||
self.assertEqual(self.buffer.buffer_length(), 0)
|
||||
self.assertEqual(self.buffer.read(), "")
|
||||
|
||||
def test_write_output(self):
|
||||
"""Test writing to stdout buffer"""
|
||||
test_output = "test output"
|
||||
self.buffer.write(test_output)
|
||||
self.assertEqual(self.buffer.get_stdout(), test_output)
|
||||
|
||||
def test_multiple_writes(self):
|
||||
"""Test multiple write operations"""
|
||||
self.buffer.write("Part 1")
|
||||
self.buffer.write("Part 2")
|
||||
self.assertEqual(self.buffer.get_stdout(), "Part 1Part 2")
|
||||
|
||||
def test_clear_stdout(self):
|
||||
"""Test clearing stdout buffer"""
|
||||
self.buffer.write("test output")
|
||||
self.buffer.clear_stdout()
|
||||
self.assertEqual(self.buffer.get_stdout(), "")
|
||||
|
||||
def test_multiple_stdin_appends(self):
|
||||
"""Test multiple stdin append operations"""
|
||||
self.buffer.append_stdin("Line 1\n")
|
||||
self.buffer.append_stdin("Line 2\n")
|
||||
content = self.buffer.read()
|
||||
self.assertEqual(content, "Line 1\nLine 2\n")
|
||||
|
||||
def test_empty_write(self):
|
||||
"""Test writing empty content"""
|
||||
self.buffer.write("")
|
||||
self.assertEqual(self.buffer.get_stdout(), "")
|
||||
|
||||
def test_empty_append_stdin(self):
|
||||
"""Test appending empty content to stdin"""
|
||||
self.buffer.append_stdin("")
|
||||
self.assertEqual(self.buffer.buffer_length(), 0)
|
||||
|
||||
def test_special_characters(self):
|
||||
"""Test handling special characters"""
|
||||
special_chars = "Special chars: \n\t\r\'\"\\"
|
||||
self.buffer.append_stdin(special_chars)
|
||||
content = self.buffer.read()
|
||||
self.assertEqual(content, special_chars)
|
||||
|
||||
self.buffer.write(special_chars)
|
||||
self.assertEqual(self.buffer.get_stdout(), special_chars)
|
||||
|
||||
def test_large_content(self):
|
||||
"""Test handling large content"""
|
||||
large_input = "x" * 10000
|
||||
self.buffer.append_stdin(large_input)
|
||||
content = self.buffer.read()
|
||||
self.assertEqual(content, large_input)
|
||||
|
||||
self.buffer.write(large_input)
|
||||
self.assertEqual(self.buffer.get_stdout(), large_input)
|
||||
|
||||
def test_multiple_operations(self):
|
||||
"""Test mixed read/write operations"""
|
||||
# Write some output
|
||||
self.buffer.write("Output 1\n")
|
||||
self.buffer.write("Output 2\n")
|
||||
|
||||
# Add some input
|
||||
self.buffer.append_stdin("Input 1\n")
|
||||
self.buffer.append_stdin("Input 2\n")
|
||||
|
||||
# Verify stdout accumulated correctly
|
||||
self.assertEqual(self.buffer.get_stdout(), "Output 1\nOutput 2\n")
|
||||
|
||||
# Read input and verify it's cleared
|
||||
content = self.buffer.read()
|
||||
self.assertEqual(content, "Input 1\nInput 2\n")
|
||||
self.assertEqual(self.buffer.buffer_length(), 0)
|
||||
|
||||
# Clear stdout and verify
|
||||
self.buffer.clear_stdout()
|
||||
self.assertEqual(self.buffer.get_stdout(), "")
|
||||
246
test/working_memory_test.py
Normal file
246
test/working_memory_test.py
Normal file
@@ -0,0 +1,246 @@
|
||||
from datetime import datetime
|
||||
import time
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from sia.background_entry import BackgroundEntry
|
||||
from sia.entry import Entry
|
||||
from sia.parse_error_entry import ParseErrorEntry
|
||||
from sia.read_entry import ReadEntry
|
||||
from sia.reasoning_entry import ReasoningEntry
|
||||
from sia.web_io_buffer import WebIOBuffer
|
||||
from sia.working_memory import WorkingMemory
|
||||
from sia.write_entry import WriteEntry
|
||||
|
||||
class MockEntry(Entry):
|
||||
"""Mock entry class for testing"""
|
||||
def __init__(self, id: str, timestamp: datetime):
|
||||
super().__init__(id, timestamp)
|
||||
self.updated = False
|
||||
|
||||
def update(self) -> None:
|
||||
self.updated = True
|
||||
|
||||
def generate_context(self) -> ET.Element:
|
||||
elem = ET.Element("mock", {"id": self.id})
|
||||
elem.text = "<![CDATA[mock content]]>"
|
||||
return elem
|
||||
|
||||
class WorkingMemoryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test cases with a fresh working memory"""
|
||||
self.memory = WorkingMemory()
|
||||
self.test_timestamp = datetime(2024, 10, 31, 12, 0, 0)
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test initial state of working memory"""
|
||||
self.assertEqual(self.memory.get_entries_count(), 0)
|
||||
self.assertEqual(self.memory.generate_context(), [])
|
||||
|
||||
def test_add_entry(self):
|
||||
"""Test adding entries"""
|
||||
entry = MockEntry("test-id-1", self.test_timestamp)
|
||||
self.memory.add_entry(entry)
|
||||
|
||||
self.assertEqual(self.memory.get_entries_count(), 1)
|
||||
self.assertEqual(self.memory.get_entry("test-id-1"), entry)
|
||||
|
||||
def test_add_invalid_entry(self):
|
||||
"""Test adding invalid entry type"""
|
||||
with self.assertRaises(TypeError):
|
||||
self.memory.add_entry("not an entry")
|
||||
|
||||
def test_remove_entry(self):
|
||||
"""Test removing entries"""
|
||||
entry1 = MockEntry("test-id-1", self.test_timestamp)
|
||||
entry2 = MockEntry("test-id-2", self.test_timestamp)
|
||||
|
||||
self.memory.add_entry(entry1)
|
||||
self.memory.add_entry(entry2)
|
||||
self.memory.remove_entry("test-id-1")
|
||||
|
||||
self.assertEqual(self.memory.get_entries_count(), 1)
|
||||
self.assertIsNone(self.memory.get_entry("test-id-1"))
|
||||
self.assertEqual(self.memory.get_entry("test-id-2"), entry2)
|
||||
|
||||
def test_remove_nonexistent_entry(self):
|
||||
"""Test removing entry that doesn't exist"""
|
||||
entry = MockEntry("test-id-1", self.test_timestamp)
|
||||
self.memory.add_entry(entry)
|
||||
self.memory.remove_entry("nonexistent-id")
|
||||
|
||||
self.assertEqual(self.memory.get_entries_count(), 1)
|
||||
self.assertEqual(self.memory.get_entry("test-id-1"), entry)
|
||||
|
||||
def test_update_entries(self):
|
||||
"""Test updating all entries"""
|
||||
entries = [
|
||||
MockEntry(f"test-id-{i}", self.test_timestamp)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
for entry in entries:
|
||||
self.memory.add_entry(entry)
|
||||
|
||||
self.memory.update()
|
||||
|
||||
for entry in entries:
|
||||
self.assertTrue(entry.updated)
|
||||
|
||||
def test_generate_context(self):
|
||||
"""Test generating XML context"""
|
||||
entry1 = MockEntry("test-id-1", self.test_timestamp)
|
||||
entry2 = MockEntry("test-id-2", self.test_timestamp)
|
||||
|
||||
self.memory.add_entry(entry1)
|
||||
self.memory.add_entry(entry2)
|
||||
|
||||
context = self.memory.generate_context()
|
||||
|
||||
self.assertEqual(len(context), 2)
|
||||
self.assertEqual(context[0].tag, "mock")
|
||||
self.assertEqual(context[0].get("id"), "test-id-1")
|
||||
self.assertEqual(context[1].tag, "mock")
|
||||
self.assertEqual(context[1].get("id"), "test-id-2")
|
||||
|
||||
def test_get_entries_by_type(self):
|
||||
"""Test retrieving entries by type"""
|
||||
# Create IO buffer for IO entries
|
||||
io_buffer = WebIOBuffer()
|
||||
|
||||
# Add different types of entries
|
||||
entries = [
|
||||
ReasoningEntry("test reasoning", "reasoning-1", self.test_timestamp),
|
||||
ParseErrorEntry("bad content", "error msg", "error-1", self.test_timestamp),
|
||||
ReadEntry(io_buffer, "read-1", self.test_timestamp),
|
||||
WriteEntry("test output", io_buffer, "write-1", self.test_timestamp)
|
||||
]
|
||||
|
||||
for entry in entries:
|
||||
self.memory.add_entry(entry)
|
||||
|
||||
# Test filtering by each type
|
||||
reasoning_entries = self.memory.get_entries_by_type(ReasoningEntry)
|
||||
self.assertEqual(len(reasoning_entries), 1)
|
||||
self.assertIsInstance(reasoning_entries[0], ReasoningEntry)
|
||||
|
||||
error_entries = self.memory.get_entries_by_type(ParseErrorEntry)
|
||||
self.assertEqual(len(error_entries), 1)
|
||||
self.assertIsInstance(error_entries[0], ParseErrorEntry)
|
||||
|
||||
read_entries = self.memory.get_entries_by_type(ReadEntry)
|
||||
self.assertEqual(len(read_entries), 1)
|
||||
self.assertIsInstance(read_entries[0], ReadEntry)
|
||||
|
||||
write_entries = self.memory.get_entries_by_type(WriteEntry)
|
||||
self.assertEqual(len(write_entries), 1)
|
||||
self.assertIsInstance(write_entries[0], WriteEntry)
|
||||
|
||||
def test_empty_context_generation(self):
|
||||
"""Test context generation with no entries"""
|
||||
context = self.memory.generate_context()
|
||||
self.assertEqual(context, [])
|
||||
|
||||
def test_get_nonexistent_entry(self):
|
||||
"""Test retrieving entry that doesn't exist"""
|
||||
self.assertIsNone(self.memory.get_entry("nonexistent-id"))
|
||||
|
||||
def test_remove_entry_cleanup(self):
|
||||
"""Test that removing an entry triggers cleanup"""
|
||||
# Create a background process entry
|
||||
entry = BackgroundEntry("sleep 10", "test-id", datetime(2024, 10, 31, 12, 0, 0))
|
||||
self.memory.add_entry(entry)
|
||||
|
||||
# Start the process
|
||||
entry.update()
|
||||
self.assertIsNotNone(entry.process)
|
||||
process_pid = entry.process.pid
|
||||
|
||||
# Remove entry should trigger cleanup
|
||||
self.memory.remove_entry("test-id")
|
||||
|
||||
# Verify process was terminated
|
||||
time.sleep(0.1) # Give process time to terminate
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
import os
|
||||
os.kill(process_pid, 0)
|
||||
|
||||
def test_cleanup_on_memory_clear(self):
|
||||
"""Test that clearing memory properly cleans up all entries"""
|
||||
# Add multiple background processes
|
||||
processes = []
|
||||
for i in range(3):
|
||||
entry = BackgroundEntry(
|
||||
"sleep 10",
|
||||
f"id-{i}",
|
||||
datetime(2024, 10, 31, 12, 0, 0)
|
||||
)
|
||||
self.memory.add_entry(entry)
|
||||
entry.update()
|
||||
processes.append(entry.process.pid)
|
||||
|
||||
# Clear memory
|
||||
self.memory.clear()
|
||||
|
||||
# Verify all processes were terminated
|
||||
time.sleep(0.1) # Give processes time to terminate
|
||||
for pid in processes:
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
import os
|
||||
os.kill(pid, 0)
|
||||
|
||||
self.assertEqual(self.memory.get_entries_count(), 0)
|
||||
|
||||
def test_cleanup_on_memory_deletion(self):
|
||||
"""Test that deleting memory properly cleans up all entries"""
|
||||
# Add a background process
|
||||
entry = BackgroundEntry(
|
||||
"sleep 10",
|
||||
"test-id",
|
||||
datetime(2024, 10, 31, 12, 0, 0)
|
||||
)
|
||||
self.memory.add_entry(entry)
|
||||
entry.update()
|
||||
process_pid = entry.process.pid
|
||||
|
||||
# Delete memory
|
||||
del self.memory
|
||||
|
||||
# Verify process was terminated
|
||||
time.sleep(0.1) # Give process time to terminate
|
||||
with self.assertRaises(ProcessLookupError):
|
||||
import os
|
||||
os.kill(process_pid, 0)
|
||||
|
||||
def test_get_entries(self):
|
||||
"""Test getting all entries"""
|
||||
# Add multiple entries
|
||||
entries = [
|
||||
ReasoningEntry(f"content {i}", f"id-{i}", self.test_timestamp)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
for entry in entries:
|
||||
self.memory.add_entry(entry)
|
||||
|
||||
# Get all entries
|
||||
retrieved_entries = self.memory.get_entries()
|
||||
|
||||
# Verify count and contents
|
||||
self.assertEqual(len(retrieved_entries), len(entries))
|
||||
for entry in entries:
|
||||
self.assertIn(entry, retrieved_entries)
|
||||
|
||||
def test_get_entries_returns_copy(self):
|
||||
"""Test that get_entries returns a copy of the list"""
|
||||
# Add an entry
|
||||
entry = ReasoningEntry("test content", "test-id", self.test_timestamp)
|
||||
self.memory.add_entry(entry)
|
||||
|
||||
# Get entries and modify the returned list
|
||||
entries = self.memory.get_entries()
|
||||
entries.clear()
|
||||
|
||||
# Verify original memory is unchanged
|
||||
self.assertEqual(self.memory.get_entries_count(), 1)
|
||||
self.assertIsNotNone(self.memory.get_entry("test-id"))
|
||||
117
test/write_entry_test.py
Normal file
117
test/write_entry_test.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sia.web_io_buffer import WebIOBuffer
|
||||
from sia.write_entry import WriteEntry
|
||||
|
||||
class WriteEntryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test cases with fixed id, timestamp, and IO buffer"""
|
||||
self.test_id = "test-id-1234"
|
||||
self.test_timestamp = datetime(2024, 10, 31, 12, 0, 0)
|
||||
self.io_buffer = WebIOBuffer()
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test entry initialization"""
|
||||
content = "test message"
|
||||
entry = WriteEntry(content, self.io_buffer, self.test_id, self.test_timestamp)
|
||||
|
||||
self.assertEqual(entry.content, content)
|
||||
self.assertEqual(entry.id, self.test_id)
|
||||
self.assertEqual(entry.timestamp, self.test_timestamp)
|
||||
self.assertFalse(entry._written)
|
||||
self.assertEqual(self.io_buffer.get_stdout(), "")
|
||||
|
||||
def test_single_write(self):
|
||||
"""Test writing content once"""
|
||||
content = "test message"
|
||||
entry = WriteEntry(content, self.io_buffer, self.test_id, self.test_timestamp)
|
||||
|
||||
# Perform update and verify content was written
|
||||
entry.update()
|
||||
self.assertEqual(self.io_buffer.get_stdout(), content)
|
||||
self.assertTrue(entry._written)
|
||||
|
||||
def test_multiple_updates(self):
|
||||
"""Test that content is only written once even with multiple updates"""
|
||||
content = "test message"
|
||||
entry = WriteEntry(content, self.io_buffer, self.test_id, self.test_timestamp)
|
||||
|
||||
# Perform multiple updates
|
||||
entry.update()
|
||||
initial_output = self.io_buffer.get_stdout()
|
||||
self.io_buffer.clear_stdout()
|
||||
|
||||
entry.update()
|
||||
entry.update()
|
||||
|
||||
# Verify no additional content was written
|
||||
self.assertEqual(self.io_buffer.get_stdout(), "")
|
||||
self.assertTrue(entry._written)
|
||||
self.assertEqual(initial_output, content)
|
||||
|
||||
def test_empty_content(self):
|
||||
"""Test writing empty content"""
|
||||
entry = WriteEntry("", self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
self.assertEqual(self.io_buffer.get_stdout(), "")
|
||||
self.assertTrue(entry._written)
|
||||
|
||||
def test_special_characters(self):
|
||||
"""Test writing content with special characters"""
|
||||
content = "Special chars: \n\t\r\'\"\\"
|
||||
entry = WriteEntry(content, self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
self.assertEqual(self.io_buffer.get_stdout(), content)
|
||||
|
||||
def test_large_content(self):
|
||||
"""Test writing large content"""
|
||||
content = "x" * 10000
|
||||
entry = WriteEntry(content, self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
self.assertEqual(self.io_buffer.get_stdout(), content)
|
||||
|
||||
def test_generate_context(self):
|
||||
"""Test XML context generation"""
|
||||
content = "test message"
|
||||
entry = WriteEntry(content, self.io_buffer, self.test_id, self.test_timestamp)
|
||||
|
||||
# Generate context before writing
|
||||
element = entry.generate_context()
|
||||
|
||||
# Verify XML structure
|
||||
self.assertEqual(element.tag, "write_stdout")
|
||||
self.assertEqual(element.get("id"), self.test_id)
|
||||
self.assertEqual(element.text, f"<![CDATA[{content}]]>")
|
||||
|
||||
# Write content and verify context remains the same
|
||||
entry.update()
|
||||
element_after = entry.generate_context()
|
||||
self.assertEqual(element_after.tag, "write_stdout")
|
||||
self.assertEqual(element_after.get("id"), self.test_id)
|
||||
self.assertEqual(element_after.text, f"<![CDATA[{content}]]>")
|
||||
|
||||
def test_unicode_content(self):
|
||||
"""Test writing Unicode content"""
|
||||
content = "Hello 世界 😊"
|
||||
entry = WriteEntry(content, self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
self.assertEqual(self.io_buffer.get_stdout(), content)
|
||||
|
||||
def test_multiline_content(self):
|
||||
"""Test writing multiline content"""
|
||||
content = """Line 1
|
||||
Line 2
|
||||
Line 3"""
|
||||
entry = WriteEntry(content, self.io_buffer, self.test_id, self.test_timestamp)
|
||||
entry.update()
|
||||
|
||||
self.assertEqual(self.io_buffer.get_stdout(), content)
|
||||
|
||||
# Verify XML escaping for multiline content
|
||||
element = entry.generate_context()
|
||||
self.assertEqual(element.text, f"<![CDATA[{content}]]>")
|
||||
116
test/xml_validator_test.py
Normal file
116
test/xml_validator_test.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import unittest
|
||||
|
||||
from sia.xml_validator import XMLValidator
|
||||
|
||||
class XMLValidatorTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test schema"""
|
||||
self.test_schema = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="test_element">
|
||||
<xs:complexType>
|
||||
<xs:simpleContent>
|
||||
<xs:extension base="xs:string">
|
||||
<xs:attribute name="id" type="xs:string" use="required"/>
|
||||
<xs:attribute name="count" type="xs:integer"/>
|
||||
</xs:extension>
|
||||
</xs:simpleContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
<xs:element name="simple_element">
|
||||
<xs:complexType>
|
||||
<xs:simpleContent>
|
||||
<xs:extension base="xs:string"/>
|
||||
</xs:simpleContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>"""
|
||||
|
||||
self.validator = XMLValidator(self.test_schema)
|
||||
|
||||
def test_initialization(self):
|
||||
"""Test validator initialization and root element extraction"""
|
||||
valid_elements = self.validator.get_valid_root_elements()
|
||||
self.assertEqual(valid_elements, {'test_element', 'simple_element'})
|
||||
|
||||
def test_invalid_schema(self):
|
||||
"""Test handling invalid schema"""
|
||||
with self.assertRaises(ValueError):
|
||||
XMLValidator("invalid schema")
|
||||
|
||||
def test_valid_xml(self):
|
||||
"""Test validation of valid XML"""
|
||||
xml = '<test_element id="123" count="42">test content</test_element>'
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNone(result)
|
||||
|
||||
# Test without optional attribute
|
||||
xml = '<test_element id="123">test content</test_element>'
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNone(result)
|
||||
|
||||
# Test simple element
|
||||
xml = '<simple_element>test content</simple_element>'
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_invalid_root_element(self):
|
||||
"""Test validation with invalid root element"""
|
||||
xml = '<invalid_element>test content</invalid_element>'
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("Invalid root element", result)
|
||||
|
||||
def test_missing_required_attribute(self):
|
||||
"""Test validation with missing required attribute"""
|
||||
xml = '<test_element>test content</test_element>'
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("Missing required attribute", result)
|
||||
|
||||
def test_invalid_integer_attribute(self):
|
||||
"""Test validation with invalid integer attribute"""
|
||||
xml = '<test_element id="123" count="not_a_number">test content</test_element>'
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("Invalid integer value", result)
|
||||
|
||||
def test_unexpected_attribute(self):
|
||||
"""Test validation with unexpected attribute"""
|
||||
xml = '<test_element id="123" invalid_attr="value">test content</test_element>'
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("Unexpected attribute", result)
|
||||
|
||||
def test_invalid_xml_syntax(self):
|
||||
"""Test validation with invalid XML syntax"""
|
||||
xml = '<test_element>unclosed tag'
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertIn("Invalid XML", result)
|
||||
|
||||
def test_whitespace_handling(self):
|
||||
"""Test validation with whitespace in XML"""
|
||||
xml = """
|
||||
<test_element id="123">
|
||||
test content
|
||||
</test_element>
|
||||
"""
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_empty_content(self):
|
||||
"""Test validation with empty element content"""
|
||||
xml = '<test_element id="123"></test_element>'
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNone(result)
|
||||
|
||||
xml = '<test_element id="123"/>'
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_special_characters(self):
|
||||
"""Test validation with special characters"""
|
||||
xml = '<test_element id="123"><![CDATA[Special & < > " \' chars]]></test_element>'
|
||||
result = self.validator.validate(xml)
|
||||
self.assertIsNone(result)
|
||||
Reference in New Issue
Block a user