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. Private Attributes: _script: The script/command to execute _stdout: Captured standard output _stderr: Captured standard error _process: The running subprocess.Popen instance _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._exit_code: Optional[int] = None @property def script(self) -> str: """Get the script/command being executed.""" return self._script @property def stdout(self) -> str: """Get the captured standard output.""" return self._stdout @property def stderr(self) -> str: """Get the captured standard error.""" return self._stderr @property def exit_code(self) -> Optional[int]: """Get the exit code of the process (None if still running).""" return self._exit_code @property def pid(self) -> Optional[int]: """Get the process ID (None if not running).""" return self._process.pid if self._process is not None else None def cleanup(self) -> None: """ Clean up the background process if it's still running. Ensures process is terminated and file handles are closed. """ 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. """ try: # 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 except subprocess.TimeoutExpired: pass # Process didn't finish communicating, try again next update self._exit_code = exit_code self.cleanup() return # Read stdout if available if self._process.stdout: while True: try: line = self._process.stdout.readline() if not line: break self._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 except: break except Exception as e: # Handle any errors error_msg = f"Error handling background process: {str(e)}" self._stderr += f"\n{error_msg}" self._exit_code = -1 self.cleanup() 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._stdout) # Add stderr element with accumulated output stderr_elem = ET.SubElement(element, "stderr") stderr_elem.text = escape_text_for_xml(self._stderr) return element