diff --git a/.gitignore b/.gitignore index 0b4b1f6..15a3990 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ *.egg-info /dist __pycache__/ -*.pyc \ No newline at end of file +*.pyc +.env \ No newline at end of file diff --git a/README.md b/README.md index 1b529e7..12c26c5 100644 --- a/README.md +++ b/README.md @@ -814,7 +814,7 @@ async def test_component_initialization(container, sample_component): See the complete example in `samples/http_server/`: ```python -# samples/http_server/http_server.py +# samples/http_server/__init__.py import asyncio from ioc import get_config, get_logger, inject import pydantic diff --git a/samples/http_server/.awioc/manifest.yaml b/samples/http_server/.awioc/manifest.yaml new file mode 100644 index 0000000..bb566c3 --- /dev/null +++ b/samples/http_server/.awioc/manifest.yaml @@ -0,0 +1,13 @@ +manifest_version: '1.0' +name: http_server +version: 1.0.0 +description: Auto-generated manifest for http_server +components: + - name: Http File Server + version: 2.0.0 + description: HTTP File Server with upload, download, and delete capabilities + file: http_server.py + class: HttpServerApp + wire: true + config: + - model: http_server:ServerConfig diff --git a/samples/http_server/__init__.py b/samples/http_server/__init__.py new file mode 100644 index 0000000..998f664 --- /dev/null +++ b/samples/http_server/__init__.py @@ -0,0 +1,7 @@ +""" +A sample HTTP server application using AWIOC framework. +""" + +from .http_server import HttpServerApp + +__all__ = ["HttpServerApp"] diff --git a/samples/http_server/http_server.py b/samples/http_server/http_server.py index 4a14e7c..02cc732 100644 --- a/samples/http_server/http_server.py +++ b/samples/http_server/http_server.py @@ -1,89 +1,508 @@ """ -Simple HTTP Server App Component +HTTP File Server App Component -A minimal HTTP server demonstrating the IOC framework. +A full-featured HTTP file server similar to `python -m http.server` with: +- Directory browsing +- File upload +- Folder download as ZIP +- File/folder deletion """ import asyncio +import html +import io +import mimetypes +import os +import shutil +import urllib.parse +import zipfile from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from pathlib import Path from threading import Thread from typing import Optional import pydantic -from awioc import get_config, get_logger, inject +from awioc import ( + get_config, + get_logger, + inject, + as_component +) class ServerConfig(pydantic.BaseModel): """HTTP Server configuration.""" __prefix__ = "server" - host: str = "127.0.0.1" - port: int = 8080 + host: str = pydantic.Field( + default="127.0.0.1", + description="The hostname or IP address to bind the server to" + ) + port: int = pydantic.Field( + default=8080, + description="The port number to listen on" + ) + root_dir: Path = pydantic.Field( + default=Path("./public"), + description="The root directory to serve files from" + ) + allow_upload: bool = pydantic.Field( + default=False, + description="Allow users to upload files via the web interface" + ) + allow_delete: bool = pydantic.Field( + default=False, + description="Allow users to delete files and folders via the web interface" + ) + allow_zip_download: bool = pydantic.Field( + default=False, + description="Allow users to download folders as ZIP archives" + ) -__metadata__ = { - "name": "http_server_app", - "version": "1.0.0", - "description": "Simple HTTP Server Application", - "wire": True, - "config": ServerConfig -} +# Global config reference (set during initialization) +_server_config: Optional[ServerConfig] = None -class RequestHandler(BaseHTTPRequestHandler): - """Simple HTTP request handler.""" + +class FileServerHandler(BaseHTTPRequestHandler): + """HTTP request handler for file server operations.""" + + @property + def root_dir(self) -> Path: + return _server_config.root_dir.resolve() if _server_config else Path(".").resolve() + + def _get_fs_path(self, url_path: str) -> Optional[Path]: + """Convert URL path to filesystem path, ensuring it's within root.""" + # Decode URL and normalize + decoded = urllib.parse.unquote(url_path) + # Remove leading slash and normalize + clean_path = decoded.lstrip("/") + # Resolve to absolute path + fs_path = (self.root_dir / clean_path).resolve() + # Security check: ensure path is within root + try: + fs_path.relative_to(self.root_dir) + except ValueError: + return None + return fs_path + + def _send_error_page(self, code: int, message: str): + """Send an error page.""" + self.send_response(code) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(f""" + +Error {code} + +

Error {code}

+

{html.escape(message)}

+Back to root + +""".encode("utf-8")) + + def _send_json(self, data: dict, code: int = 200): + """Send a JSON response.""" + import json + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(data).encode("utf-8")) + + def _format_size(self, size: int) -> str: + """Format file size in human-readable format.""" + for unit in ["B", "KB", "MB", "GB", "TB"]: + if size < 1024: + return f"{size:.1f} {unit}" if unit != "B" else f"{size} {unit}" + size /= 1024 + return f"{size:.1f} PB" @inject - def do_GET( - self, - logger=get_logger() - ): + def do_GET(self, logger=get_logger()): """Handle GET requests.""" + parsed = urllib.parse.urlparse(self.path) + url_path = parsed.path + query = urllib.parse.parse_qs(parsed.query) + logger.info(f"GET {self.path} FROM {self.client_address[0]}:{self.client_address[1]}") - if self.path == "/": - self.send_response(200) - self.send_header("Content-Type", "text/html") + + # Handle ZIP download request + if "zip" in query and _server_config and _server_config.allow_zip_download: + self._handle_zip_download(url_path) + return + + fs_path = self._get_fs_path(url_path) + + if fs_path is None: + self._send_error_page(403, "Access denied: Path outside root directory") + return + + if not fs_path.exists(): + self._send_error_page(404, f"Path not found: {url_path}") + return + + if fs_path.is_dir(): + self._serve_directory(url_path, fs_path) + else: + self._serve_file(fs_path) + + def _serve_directory(self, url_path: str, fs_path: Path): + """Serve a directory listing.""" + # Ensure URL path ends with / + if not url_path.endswith("/"): + self.send_response(301) + self.send_header("Location", url_path + "/") self.end_headers() - self.wfile.write(b""" - + return + + entries = [] + try: + for entry in sorted(fs_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): + stat = entry.stat() + entries.append({ + "name": entry.name, + "is_dir": entry.is_dir(), + "size": stat.st_size if entry.is_file() else 0, + "mtime": stat.st_mtime, + }) + except PermissionError: + self._send_error_page(403, "Permission denied") + return + + # Generate HTML + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + + parent_link = "" + if url_path != "/": + parent = str(Path(url_path).parent) + if not parent.endswith("/"): + parent += "/" + parent_link = f'📁 ..' + + rows = [] + for e in entries: + name = html.escape(e["name"]) + href = html.escape(urllib.parse.quote(e["name"])) + if e["is_dir"]: + icon = "📁" + href += "/" + size_str = "-" + zip_link = f'📦' if _server_config and _server_config.allow_zip_download else "" + else: + icon = "📄" + size_str = self._format_size(e["size"]) + zip_link = "" + + delete_btn = "" + if _server_config and _server_config.allow_delete: + delete_btn = f'' + + rows.append(f""" + + {icon} {name} + {size_str} + {zip_link} + {delete_btn} + + """) + + upload_form = "" + if _server_config and _server_config.allow_upload: + upload_form = """ +
+

Upload Files

+
+ + +
+
+
+ """ + + html_content = f""" -IOC Test Server + + + Index of {html.escape(url_path)} + + -

IOC Framework Test Server

-

The server is running successfully!

- +

Index of {html.escape(url_path)}

+ + + + + + + + + + + {parent_link} + {"".join(rows)} + +
NameSizeZIPDelete
+ {upload_form} + - -""") - elif self.path == "/health": +""" + self.wfile.write(html_content.encode("utf-8")) + + def _serve_file(self, fs_path: Path): + """Serve a file.""" + try: + content_type, _ = mimetypes.guess_type(str(fs_path)) + if content_type is None: + content_type = "application/octet-stream" + + stat = fs_path.stat() self.send_response(200) - self.send_header("Content-Type", "application/json") + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(stat.st_size)) + self.send_header("Content-Disposition", f'inline; filename="{fs_path.name}"') self.end_headers() - self.wfile.write(b'{"status": "healthy"}') - elif self.path == "/info": + + with open(fs_path, "rb") as f: + shutil.copyfileobj(f, self.wfile) + except PermissionError: + self._send_error_page(403, "Permission denied") + except Exception as e: + self._send_error_page(500, str(e)) + + def _handle_zip_download(self, url_path: str): + """Handle downloading a directory as ZIP.""" + fs_path = self._get_fs_path(url_path) + + if fs_path is None: + self._send_error_page(403, "Access denied") + return + + if not fs_path.exists() or not fs_path.is_dir(): + self._send_error_page(404, "Directory not found") + return + + try: + # Create ZIP in memory + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for root, dirs, files in os.walk(fs_path): + for file in files: + file_path = Path(root) / file + arcname = file_path.relative_to(fs_path) + try: + zf.write(file_path, arcname) + except PermissionError: + pass # Skip files we can't read + + zip_data = zip_buffer.getvalue() + zip_name = fs_path.name or "root" + self.send_response(200) - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(b'{"name": "IOC Test Server", "version": "1.0.0"}') - else: - self.send_response(404) - self.send_header("Content-Type", "text/plain") + self.send_header("Content-Type", "application/zip") + self.send_header("Content-Length", str(len(zip_data))) + self.send_header("Content-Disposition", f'attachment; filename="{zip_name}.zip"') self.end_headers() - self.wfile.write(b"Not Found") + self.wfile.write(zip_data) + except Exception as e: + self._send_error_page(500, str(e)) + + @inject + def do_POST(self, logger=get_logger()): + """Handle POST requests (file upload).""" + logger.info(f"POST {self.path} FROM {self.client_address[0]}:{self.client_address[1]}") + + if not _server_config or not _server_config.allow_upload: + self._send_json({"error": "Upload not allowed"}, 403) + return + + fs_path = self._get_fs_path(self.path) + if fs_path is None or not fs_path.is_dir(): + self._send_json({"error": "Invalid upload destination"}, 400) + return + + content_type = self.headers.get("Content-Type", "") + if "multipart/form-data" not in content_type: + self._send_json({"error": "Invalid content type"}, 400) + return + + try: + # Parse multipart form data + boundary = content_type.split("boundary=")[1].strip() + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + + files_saved = [] + parts = body.split(f"--{boundary}".encode()) + + for part in parts: + if b"filename=" not in part: + continue + + # Extract filename + header_end = part.find(b"\r\n\r\n") + if header_end == -1: + continue + + header = part[:header_end].decode("utf-8", errors="ignore") + file_content = part[header_end + 4:] + if file_content.endswith(b"\r\n"): + file_content = file_content[:-2] + + # Parse filename from Content-Disposition + for line in header.split("\r\n"): + if "filename=" in line: + start = line.find('filename="') + 10 + end = line.find('"', start) + if end > start: + filename = line[start:end] + # Security: only use basename + filename = Path(filename).name + if filename: + dest = fs_path / filename + with open(dest, "wb") as f: + f.write(file_content) + files_saved.append(filename) + logger.info(f"Uploaded: {dest}") + + if files_saved: + self._send_json({"message": f"Uploaded {len(files_saved)} file(s): {', '.join(files_saved)}"}) + else: + self._send_json({"error": "No files uploaded"}, 400) + + except Exception as e: + logger.error(f"Upload error: {e}") + self._send_json({"error": str(e)}, 500) + + @inject + def do_DELETE(self, logger=get_logger()): + """Handle DELETE requests.""" + logger.info(f"DELETE {self.path} FROM {self.client_address[0]}:{self.client_address[1]}") + + if not _server_config or not _server_config.allow_delete: + self._send_json({"error": "Delete not allowed"}, 403) + return + + fs_path = self._get_fs_path(self.path) + if fs_path is None: + self._send_json({"error": "Access denied"}, 403) + return + + if not fs_path.exists(): + self._send_json({"error": "Path not found"}, 404) + return + + # Prevent deleting the root directory + if fs_path == self.root_dir: + self._send_json({"error": "Cannot delete root directory"}, 403) + return + + try: + if fs_path.is_dir(): + shutil.rmtree(fs_path) + logger.info(f"Deleted directory: {fs_path}") + else: + fs_path.unlink() + logger.info(f"Deleted file: {fs_path}") + + self._send_json({"message": "Deleted successfully"}) + except PermissionError: + self._send_json({"error": "Permission denied"}, 403) + except Exception as e: + logger.error(f"Delete error: {e}") + self._send_json({"error": str(e)}, 500) def log_message(self, format, *args): """Suppress default logging.""" pass +@as_component( + name="Http File Server", + version="2.0.0", + description="HTTP File Server with upload, download, and delete capabilities", + wire=True, + config=ServerConfig +) class HttpServerApp: """ - HTTP Server App Component. + HTTP File Server App Component. - This is an AppComponent that runs a simple HTTP server. - AppComponents require both initialize() and shutdown() methods. + Provides a web-based file browser with upload, download, and delete capabilities. """ def __init__(self): @@ -95,32 +514,45 @@ def __init__(self): @inject async def initialize( self, - logger = get_logger(), - config = get_config(ServerConfig) + logger=get_logger(), + config=get_config(ServerConfig) ) -> None: """Start the HTTP server.""" + global _server_config + _server_config = config + self._shutdown_event = asyncio.Event() - logger.info(f"Starting HTTP server on {config.host}:{config.port}") + # Resolve and validate root directory + root = config.root_dir.resolve() + if not root.exists(): + logger.warning(f"Root directory does not exist, creating: {root}") + root.mkdir(parents=True, exist_ok=True) - self._server = ThreadingHTTPServer((config.host, config.port), RequestHandler) + logger.info(f"Starting HTTP File Server on {config.host}:{config.port}") + logger.info(f"Serving files from: {root}") + logger.info(f"Upload: {'enabled' if config.allow_upload else 'disabled'}") + logger.info(f"Delete: {'enabled' if config.allow_delete else 'disabled'}") + logger.info(f"ZIP download: {'enabled' if config.allow_zip_download else 'disabled'}") + + self._server = ThreadingHTTPServer((config.host, config.port), FileServerHandler) self._running = True - # Use serve_forever in a thread - it handles shutdown properly self._thread = Thread(target=self._server.serve_forever, daemon=True) self._thread.start() - logger.info(f"HTTP server running at http://{config.host}:{config.port}") + logger.info(f"HTTP File Server running at http://{config.host}:{config.port}") async def wait(self) -> None: """Wait until shutdown is requested.""" if self._shutdown_event: await self._shutdown_event.wait() - async def shutdown( - self - ) -> None: + async def shutdown(self) -> None: """Stop the HTTP server.""" + global _server_config + _server_config = None + self._running = False if self._shutdown_event: @@ -134,8 +566,3 @@ async def shutdown( if self._thread: self._thread.join(timeout=2) self._thread = None - -http_server_app = HttpServerApp() -initialize = http_server_app.initialize -shutdown = http_server_app.shutdown -wait = http_server_app.wait diff --git a/samples/http_server/ioc.yaml b/samples/http_server/ioc.yaml index 98e65f7..2faf452 100644 --- a/samples/http_server/ioc.yaml +++ b/samples/http_server/ioc.yaml @@ -1,15 +1,9 @@ -# IOC Framework Configuration -# This file defines the application components - -app: http_server - -# Optional: Define libraries -# libraries: - -# Optional: Define plugins -# plugins: - -# Application-specific configuration +components: + app: http_server:HttpServerApp() server: - host: "127.0.0.1" + host: 127.0.0.1 port: 8080 + root_dir: ./public + allow_upload: false + allow_delete: false + allow_zip_download: false diff --git a/samples/management_dashboard/.awioc/manifest.yaml b/samples/management_dashboard/.awioc/manifest.yaml new file mode 100644 index 0000000..3d7d5c3 --- /dev/null +++ b/samples/management_dashboard/.awioc/manifest.yaml @@ -0,0 +1,15 @@ +manifest_version: '1.0' +name: management_dashboard +version: 1.0.0 +description: Auto-generated manifest for management_dashboard +components: + - name: Management Dashboard + version: 1.3.0 + description: Management Dashboard App Component + file: dashboard.py + class: ManagementDashboardApp + wire: true + wirings: + - http_handler + config: + - model: dashboard:DashboardConfig diff --git a/samples/management_dashboard/__init__.py b/samples/management_dashboard/__init__.py new file mode 100644 index 0000000..a7e50df --- /dev/null +++ b/samples/management_dashboard/__init__.py @@ -0,0 +1,13 @@ +""" +Management Dashboard App Component + +A web server that exposes endpoints for: +- Listing all activated components +- Enabling/disabling plugins +- Showing overall application state +- Real-time updates via WebSocket with component state monitoring +- Real-time log streaming with filtering +""" +from .dashboard import ManagementDashboardApp + +__all__ = ["ManagementDashboardApp"] diff --git a/samples/management_dashboard/config.py b/samples/management_dashboard/config.py new file mode 100644 index 0000000..8ed7773 --- /dev/null +++ b/samples/management_dashboard/config.py @@ -0,0 +1,39 @@ +"""Dashboard configuration models.""" + +from pathlib import Path + +import pydantic + + +class DashboardConfig(pydantic.BaseModel): + """Dashboard Server configuration.""" + __prefix__ = "dashboard" + + host: str = pydantic.Field( + default="127.0.0.1", + description="The hostname or IP address to bind the HTTP server to" + ) + port: int = pydantic.Field( + default=8090, + description="The port number for the HTTP dashboard interface" + ) + ws_port: int = pydantic.Field( + default=8091, + description="The port number for the WebSocket server (real-time updates)" + ) + monitor_interval: float = pydantic.Field( + default=0.25, + description="How often to check for component state changes (in seconds)" + ) + log_buffer_size: int = pydantic.Field( + default=500, + description="Maximum number of log entries to keep in memory for the UI" + ) + plugin_upload_path: str = pydantic.Field( + default="plugins", + description="Directory path where uploaded plugins will be saved" + ) + + +# Path to the web assets directory +WEB_DIR = Path(__file__).parent / "static" diff --git a/samples/management_dashboard/dashboard.py b/samples/management_dashboard/dashboard.py new file mode 100644 index 0000000..0119200 --- /dev/null +++ b/samples/management_dashboard/dashboard.py @@ -0,0 +1,1191 @@ +""" +Management Dashboard App Component + +A web server that exposes endpoints for: +- Listing all activated components +- Enabling/disabling plugins +- Showing overall application state +- Real-time updates via WebSocket with component state monitoring +- Real-time log streaming with filtering +""" + +import asyncio +import base64 +import json +import logging +import warnings +from collections import deque +from http.server import ThreadingHTTPServer +from pathlib import Path +from threading import Thread +from typing import Any, Dict, List, Optional, Set + +import pydantic +import websockets +import yaml +from pydantic_core import PydanticUndefined +from websockets.server import WebSocketServerProtocol + +from awioc import ( + as_component, + compile_component, + component_internals, + component_registration, + ContainerInterface, + get_config, + get_container_api, + get_logger, + initialize_components, + inject, + is_awioc_project, + open_project, + reconfigure_ioc_app, + register_plugin, + shutdown_components, +) +from .config import DashboardConfig +from .http_handler import DashboardRequestHandler +from .models import ComponentState, DashboardLogHandler, log_buffer, LogBuffer, LogEntry + + +def _get_component_file(component) -> Optional[str]: + """Get the file path for a component using AWIOC's component_registration API.""" + reg = component_registration(component) + if reg and reg.file: + return reg.file + return getattr(component, '__file__', None) + + +class WebSocketManager: + """Manages WebSocket connections and broadcasts.""" + + def __init__(self): + self._clients: Set[WebSocketServerProtocol] = set() + self._container: Optional[ContainerInterface] = None + self._lock = asyncio.Lock() + self._previous_states: Dict[str, ComponentState] = {} + self._monitoring = False + self._main_loop: Optional[asyncio.AbstractEventLoop] = None + self._ws_loop: Optional[asyncio.AbstractEventLoop] = None + self._log_buffer: Optional[LogBuffer] = None + self._plugin_upload_path: Optional[Path] = None + self._discovered_plugins: Dict[str, dict] = {} + + def set_container(self, container: ContainerInterface): + self._container = container + + def set_plugin_upload_path(self, path: Path): + self._plugin_upload_path = path + + def set_main_loop(self, loop: asyncio.AbstractEventLoop): + self._main_loop = loop + + def set_ws_loop(self, loop: asyncio.AbstractEventLoop): + self._ws_loop = loop + + def set_log_buffer(self, buffer: LogBuffer): + self._log_buffer = buffer + + def on_new_log(self, entry: LogEntry): + """Callback when a new log entry is added.""" + if self._ws_loop and self._clients: + asyncio.run_coroutine_threadsafe(self.broadcast_log(entry), self._ws_loop) + + async def broadcast_log(self, entry: LogEntry): + await self.broadcast({"type": "log", "entry": entry.to_dict()}) + + async def register(self, websocket: WebSocketServerProtocol): + async with self._lock: + self._clients.add(websocket) + + async def unregister(self, websocket: WebSocketServerProtocol): + async with self._lock: + self._clients.discard(websocket) + + @property + def has_clients(self) -> bool: + return len(self._clients) > 0 + + def _get_component_state(self, component) -> ComponentState: + internals = component_internals(component) + return ComponentState( + is_initialized=internals.is_initialized, + is_initializing=internals.is_initializing, + is_shutting_down=internals.is_shutting_down, + ) + + def _get_component_info(self, component) -> dict: + """Get detailed information about a component.""" + internals = component_internals(component) + metadata = component.__metadata__ + state = self._get_component_state(component) + config_info = self._get_component_config_info(component) + + requires = metadata.get("requires") or set() + requires_names = list(requires) # requires now contains strings (component names) + + registration = component_registration(component) + registration_info = None + if registration: + registration_info = { + "registered_by": registration.registered_by, + "registered_at": registration.registered_at.isoformat(), + "file": registration.file, + "line": registration.line, + } + + return { + "name": metadata.get("name", "unknown"), + "version": metadata.get("version", "unknown"), + "description": metadata.get("description", ""), + "type": internals.type.value, + "state": { + "is_initialized": internals.is_initialized, + "is_initializing": internals.is_initializing, + "is_shutting_down": internals.is_shutting_down, + }, + "status": state.get_status_label(), + "required_by": [req.__metadata__.get("name", "unknown") for req in internals.required_by], + "config": config_info, + "registration": registration_info, + "internals": { + "module": getattr(component, "__name__", "unknown"), + "wire": metadata.get("wire", False), + "requires": requires_names, + "initialized_by": [req.__metadata__.get("name", "unknown") for req in internals.initialized_by], + } + } + + def _normalize_pydantic_schema(self, model: type[pydantic.BaseModel]) -> dict: + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=pydantic.json_schema.PydanticJsonSchemaWarning) + raw_schema = model.model_json_schema(ref_template="#/$defs/{model}") + + defs = raw_schema.get("$defs", {}) + + if "$ref" in raw_schema: + ref_name = raw_schema["$ref"].split("/")[-1] + schema = defs.get(ref_name, {}).copy() + else: + schema = {k: v for k, v in raw_schema.items() if k != "$defs"} + + schema = self._resolve_refs(schema, defs) + schema.setdefault("type", "object") + schema.setdefault("properties", {}) + schema.setdefault("required", []) + + for field_name, field in model.model_fields.items(): + prop = schema["properties"].get(field_name, {}) + default = field.default + if default is not PydanticUndefined: + try: + if isinstance(default, pydantic.BaseModel): + default = default.model_dump() + json.dumps(default) + prop.setdefault("default", default) + except TypeError: + prop.setdefault("default", str(default)) + schema["properties"][field_name] = prop + + return schema + + def _resolve_refs(self, obj: Any, defs: dict) -> Any: + """Recursively resolve all $ref references in a schema.""" + if isinstance(obj, dict): + if "allOf" in obj and isinstance(obj["allOf"], list): + merged = {} + for item in obj["allOf"]: + resolved_item = self._resolve_refs(item, defs) + if isinstance(resolved_item, dict): + for k, v in resolved_item.items(): + if k == "properties" and "properties" in merged: + merged["properties"].update(v) + elif k == "required" and "required" in merged: + merged["required"] = list(set(merged["required"]) | set(v)) + else: + merged[k] = v + for k, v in obj.items(): + if k != "allOf" and k not in merged: + merged[k] = self._resolve_refs(v, defs) + return merged + + if "$ref" in obj: + ref_path = obj["$ref"] + if ref_path.startswith("#/$defs/"): + ref_name = ref_path.split("/")[-1] + resolved = defs.get(ref_name, {}).copy() + resolved = self._resolve_refs(resolved, defs) + for k, v in obj.items(): + if k != "$ref" and k not in resolved: + resolved[k] = self._resolve_refs(v, defs) + return resolved + return obj + + return {k: self._resolve_refs(v, defs) for k, v in obj.items() if k != "$defs"} + + elif isinstance(obj, list): + return [self._resolve_refs(item, defs) for item in obj] + + return obj + + def _get_component_config_info(self, component) -> Optional[list[dict]]: + metadata = component.__metadata__ + config_model = metadata.get("config") + + if not config_model: + return None + + if isinstance(config_model, (list, tuple, set, frozenset)): + config_models = list(config_model) + else: + config_models = [config_model] + + configs = [] + for idx, model in enumerate(config_models): + prefix = getattr(model, "__prefix__", None) or getattr(model, "__name__", f"config_{idx}") + + values = {} + try: + provided_config = self._container.provided_config(model) + if provided_config: + values = self._make_json_serializable(provided_config.model_dump()) + except Exception: + try: + for field_name, field_info in model.model_fields.items(): + if field_info.default is not None and field_info.default is not PydanticUndefined: + default_val = field_info.default + if isinstance(default_val, pydantic.BaseModel): + default_val = default_val.model_dump() + values[field_name] = self._make_json_serializable(default_val) + except Exception: + pass + + try: + schema = self._normalize_pydantic_schema(model) + except Exception: + schema = {"type": "object", "properties": {}, "required": []} + + configs.append({"prefix": prefix, "values": values, "schema": schema}) + + return configs if configs else None + + def _make_json_serializable(self, obj): + if isinstance(obj, dict): + return {k: self._make_json_serializable(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [self._make_json_serializable(v) for v in obj] + elif isinstance(obj, Path): + return str(obj) + elif hasattr(obj, '__str__') and not isinstance(obj, (str, int, float, bool, type(None))): + try: + json.dumps(obj) + return obj + except (TypeError, ValueError): + return str(obj) + return obj + + def _get_full_state(self) -> dict: + if not self._container: + return {} + + components = self._container.components + app = self._container.provided_app() + plugins = self._container.provided_plugins() + libs = self._container.provided_libs() + + initialized_count = sum(1 for c in components if component_internals(c).is_initialized) + + config_file_path = None + config_targets = [] + try: + ioc_config = self._container.ioc_config_model + config_path = ioc_config.config_path + config_file_path = str(config_path) if config_path else None + + if config_path: + config_dir = config_path.parent + config_targets.append({ + "id": "yaml", "label": f"YAML ({config_path.name})", + "path": str(config_path), "exists": config_path.exists() + }) + env_path = config_dir / ".env" + config_targets.append({ + "id": "env", "label": ".env", + "path": str(env_path), "exists": env_path.exists() + }) + + context = ioc_config.context + if not context and config_path: + stem = config_path.stem + if stem.endswith('.conf') or stem.endswith('.config'): + context = stem.rsplit('.', 1)[0] + elif '.' in stem: + parts = stem.split('.') + if len(parts) == 2 and parts[0] in ('ioc', 'config', 'conf'): + context = parts[1] + + if context: + context_env_path = config_dir / f".{context}.env" + config_targets.append({ + "id": "context_env", "label": f".{context}.env", + "path": str(context_env_path), "exists": context_env_path.exists() + }) + except Exception: + pass + + return { + "type": "full_state", + "state": { + "app_name": app.__metadata__.get("name", "unknown"), + "app_version": app.__metadata__.get("version", "unknown"), + "total_components": len(components), + "initialized_components": initialized_count, + "plugins_count": len(plugins), + "libraries_count": len(libs), + "discovered_plugins_count": len(self._discovered_plugins), + "config_file": config_file_path, + "config_targets": config_targets, + }, + "components": [self._get_component_info(c) for c in components], + "plugins": [self._get_component_info(p) for p in plugins], + "discovered_plugins": list(self._discovered_plugins.values()), + } + + async def broadcast(self, message: dict): + if not self._clients: + return + data = json.dumps(message) + async with self._lock: + dead_clients = set() + for client in self._clients: + try: + await client.send(data) + except websockets.exceptions.ConnectionClosed: + dead_clients.add(client) + self._clients -= dead_clients + + async def broadcast_state(self): + await self.broadcast(self._get_full_state()) + + async def broadcast_component_update(self, component_name: str, component_info: dict, old_status: str, + new_status: str): + await self.broadcast({ + "type": "component_update", + "component": component_info, + "transition": {"from": old_status, "to": new_status} + }) + + async def check_state_changes(self): + if not self._container or not self._clients: + return + + state_changed = False + for component in self._container.components: + name = component.__metadata__.get("name", "unknown") + current_state = self._get_component_state(component) + previous_state = self._previous_states.get(name) + + if previous_state is None: + self._previous_states[name] = current_state + elif current_state != previous_state: + state_changed = True + await self.broadcast_component_update( + name, self._get_component_info(component), + previous_state.get_status_label(), current_state.get_status_label() + ) + self._previous_states[name] = current_state + + if state_changed: + await self.broadcast_state_summary() + + async def broadcast_state_summary(self): + if not self._container: + return + + components = self._container.components + app = self._container.provided_app() + plugins = self._container.provided_plugins() + libs = self._container.provided_libs() + + await self.broadcast({ + "type": "state_summary", + "state": { + "app_name": app.__metadata__.get("name", "unknown"), + "app_version": app.__metadata__.get("version", "unknown"), + "total_components": len(components), + "initialized_components": sum(1 for c in components if component_internals(c).is_initialized), + "plugins_count": len(plugins), + "libraries_count": len(libs), + } + }) + + async def start_monitoring(self, interval: float = 0.25): + self._monitoring = True + while self._monitoring: + try: + await self.check_state_changes() + except Exception: + pass + await asyncio.sleep(interval) + + def stop_monitoring(self): + self._monitoring = False + + async def handle_client(self, websocket: WebSocketServerProtocol): + await self.register(websocket) + try: + await websocket.send(json.dumps(self._get_full_state())) + if self._log_buffer: + await websocket.send(json.dumps({"type": "logs_history", "logs": self._log_buffer.get_all()})) + + async for message in websocket: + try: + await self._handle_message(websocket, json.loads(message)) + except json.JSONDecodeError: + await websocket.send(json.dumps({"type": "error", "error": "Invalid JSON"})) + except websockets.exceptions.ConnectionClosed: + pass + finally: + await self.unregister(websocket) + + async def _handle_message(self, websocket: WebSocketServerProtocol, data: dict): + action = data.get("action") + result = None + + if action == "refresh": + await websocket.send(json.dumps(self._get_full_state())) + elif action == "get_logs" and self._log_buffer: + last_id = data.get("since_id", 0) + logs = self._log_buffer.get_since(last_id) if last_id else self._log_buffer.get_all() + await websocket.send(json.dumps({"type": "logs_history", "logs": logs})) + elif action == "clear_logs" and self._log_buffer: + self._log_buffer.clear() + await websocket.send(json.dumps({"type": "success", "message": "Logs cleared"})) + await self.broadcast({"type": "logs_cleared"}) + elif action == "enable_plugin": + result = await self._enable_plugin(data.get("name")) + elif action == "disable_plugin": + result = await self._disable_plugin(data.get("name")) + elif action == "register_plugin": + result = await self._register_plugin_from_path(data.get("path"), + class_reference=data.get("class_reference")) + elif action == "upload_plugin": + upload_type = data.get("type") + if upload_type == "file": + result = await self._upload_plugin_file(data.get("filename"), data.get("content")) + elif upload_type == "directory": + result = await self._upload_plugin_directory(data.get("dirname"), data.get("files")) + else: + result = {"type": "error", "error": "Invalid upload type"} + elif action == "save_config": + result = await self._save_component_config( + data.get("name"), data.get("config"), + data.get("target_file", "yaml"), data.get("prefix") + ) + elif action == "unregister_plugin": + result = await self._unregister_plugin(data.get("name")) + elif action == "save_plugins": + result = await self._save_plugins_to_config() + elif action == "sync_plugins": + result = await self._sync_plugins_from_path() + elif action == "remove_plugin": + result = await self._remove_plugin_file(data.get("path")) + elif action == "list_pots": + result = await self._list_pots() + elif action == "list_pot_components": + result = await self._list_pot_components(data.get("pot_name")) + elif action == "register_pot_component": + result = await self._register_plugin_from_path(f"@{data.get('pot_name')}/{data.get('component_name')}") + + if result: + await websocket.send(json.dumps(result)) + if result.get("type") == "success": + await self.broadcast_state() + + def _run_in_main_loop(self, coro) -> Any: + if self._main_loop is None: + raise RuntimeError("Main event loop not set") + return asyncio.run_coroutine_threadsafe(coro, self._main_loop).result(timeout=30) + + @inject + async def _enable_plugin(self, plugin_name: str, logger=get_logger()) -> dict: + if not plugin_name: + return {"type": "error", "error": "Plugin name required"} + + plugin = self._container.provided_plugin(plugin_name) + if plugin is None: + return {"type": "error", "error": f"Plugin '{plugin_name}' not found"} + + internals = component_internals(plugin) + if internals.is_initialized: + return {"type": "info", "message": f"Plugin '{plugin_name}' is already enabled"} + + try: + await asyncio.get_event_loop().run_in_executor( + None, lambda: self._run_in_main_loop(initialize_components(plugin)) + ) + return {"type": "success", "message": f"Plugin '{plugin_name}' enabled successfully"} + except Exception as e: + logger.error(f"Error enabling plugin '{plugin_name}'", exc_info=e) + return {"type": "error", "error": str(e)} + + @inject + async def _disable_plugin(self, plugin_name: str, logger=get_logger()) -> dict: + if not plugin_name: + return {"type": "error", "error": "Plugin name required"} + + plugin = self._container.provided_plugin(plugin_name) + if plugin is None: + return {"type": "error", "error": f"Plugin '{plugin_name}' not found"} + + internals = component_internals(plugin) + if not internals.is_initialized: + return {"type": "info", "message": f"Plugin '{plugin_name}' is already disabled"} + + active_dependents = [r for r in internals.required_by if component_internals(r).is_initialized] + if active_dependents: + return {"type": "error", + "error": f"Cannot disable: required by {[r.__metadata__.get('name') for r in active_dependents]}"} + + try: + await asyncio.get_event_loop().run_in_executor( + None, lambda: self._run_in_main_loop(shutdown_components(plugin)) + ) + return {"type": "success", "message": f"Plugin '{plugin_name}' disabled successfully"} + except Exception as e: + logger.error(f"Error disabling plugin '{plugin_name}'", exc_info=e) + return {"type": "error", "error": str(e)} + + @inject + async def _register_plugin_from_path( + self, plugin_path: str, class_reference: Optional[str] = None, + auto_initialize: bool = False, logger=get_logger() + ) -> dict: + if not plugin_path: + return {"type": "error", "error": "Plugin path required"} + + try: + path = Path(plugin_path) + if not path.exists() and not plugin_path.startswith("@"): + return {"type": "error", "error": f"File not found: {plugin_path}"} + + if class_reference: + ref = class_reference.strip() + if not ref.startswith(":"): + ref = f":{ref}" + if not ref.endswith("()"): + ref = f"{ref}()" + component_ref = f"{path}:{ref[1:]}" + elif plugin_path.startswith("@"): + component_ref = plugin_path + elif is_awioc_project(path): + project = open_project(path) + if project.components and project.components[0].class_name: + logger.info(f"Auto-detected: {project.components[0].class_name}") + component_ref = f"{path}:{project.components[0].class_name}()" + else: + component_ref = path + else: + component_ref = path + + plugin = compile_component(component_ref) + plugin_name = plugin.__metadata__.get("name", "unknown") + + if self._container.provided_plugin(plugin_name) is not None: + return {"type": "info", "message": f"Plugin '{plugin_name}' is already registered"} + + async def register_only(): + await register_plugin(self._container, plugin) + reconfigure_ioc_app(self._container, (plugin,)) + if auto_initialize: + await initialize_components(plugin) + + await asyncio.get_event_loop().run_in_executor( + None, lambda: self._run_in_main_loop(register_only()) + ) + + if self._log_buffer: + module_name = getattr(plugin, '__name__', type(plugin).__name__) + internals = component_internals(plugin) + with self._log_buffer._lock: + self._log_buffer._component_info[module_name] = (plugin_name, internals.type.value) + + if not plugin_path.startswith("@"): + self._discovered_plugins.pop(str(path.resolve()), None) + + return {"type": "success", "message": f"Plugin '{plugin_name}' registered successfully"} + except Exception as e: + logger.error(f"Error registering plugin from '{plugin_path}'", exc_info=e) + return {"type": "error", "error": str(e)} + + @inject + async def _upload_plugin_file( + self, filename: str, content: str, + config=get_config(DashboardConfig), ioc_api=get_container_api(), logger=get_logger() + ) -> dict: + if not filename or not content: + return {"type": "error", "error": "Filename and content required"} + + try: + raw_bytes = base64.b64decode(content) + upload_path = Path(config.plugin_upload_path) + if not upload_path.is_absolute(): + ioc_config = ioc_api.ioc_config_model + upload_path = (ioc_config.config_path.parent if ioc_config.config_path else Path.cwd()) / upload_path + + upload_path.mkdir(parents=True, exist_ok=True) + plugin_path = upload_path / filename + + if plugin_path.exists(): + return {"type": "error", "error": f"Plugin file '{filename}' already exists"} + + plugin_path.write_bytes(raw_bytes) + logger.info(f"Saved plugin file to {plugin_path}") + return await self._register_plugin_from_path(str(plugin_path), auto_initialize=False) + except Exception as e: + logger.error(f"Error uploading plugin file '{filename}'", exc_info=e) + return {"type": "error", "error": str(e)} + + @inject + async def _upload_plugin_directory( + self, dirname: str, files: List[Dict[str, str]], + config=get_config(DashboardConfig), ioc_api=get_container_api(), logger=get_logger() + ) -> dict: + if not dirname or not files: + return {"type": "error", "error": "Directory name and files required"} + + try: + upload_path = Path(config.plugin_upload_path) + if not upload_path.is_absolute(): + ioc_config = ioc_api.ioc_config_model + upload_path = (ioc_config.config_path.parent if ioc_config.config_path else Path.cwd()) / upload_path + + upload_path.mkdir(parents=True, exist_ok=True) + plugin_dir = upload_path / dirname + + if plugin_dir.exists(): + return {"type": "error", "error": f"Plugin directory '{dirname}' already exists"} + + for file_info in files: + relative_path, content_b64 = file_info.get("path", ""), file_info.get("content", "") + if relative_path and content_b64: + file_path = upload_path / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_bytes(base64.b64decode(content_b64)) + + logger.info(f"Saved plugin directory to {plugin_dir}") + return await self._register_plugin_from_path(str(plugin_dir), auto_initialize=False) + except Exception as e: + logger.error(f"Error uploading plugin directory '{dirname}'", exc_info=e) + return {"type": "error", "error": str(e)} + + @inject + async def _save_component_config( + self, component_name: str, config_values: Dict[str, Any], + target_file: str = "yaml", config_prefix: Optional[str] = None, + ioc_api=get_container_api(), logger=get_logger() + ) -> dict: + if not component_name: + return {"type": "error", "error": "Component name required"} + if not config_values: + return {"type": "error", "error": "Configuration values required"} + if target_file not in ("yaml", "env", "context_env"): + return {"type": "error", "error": f"Invalid target file: {target_file}"} + + try: + component = next((c for c in self._container.components if c.__metadata__.get("name") == component_name), + None) + if component is None: + return {"type": "error", "error": f"Component '{component_name}' not found"} + + config_models = component.__metadata__.get("config") + if not config_models: + return {"type": "error", "error": f"Component '{component_name}' has no configuration"} + + config_models = list(config_models) if isinstance(config_models, (list, tuple, set, frozenset)) else [ + config_models] + + config_model = None + if config_prefix: + config_model = next((m for m in config_models if getattr(m, "__prefix__", None) == config_prefix), None) + if not config_model: + return {"type": "error", "error": f"No config with prefix '{config_prefix}' found"} + else: + config_model = config_models[0] + + prefix = getattr(config_model, "__prefix__", None) + if not prefix: + return {"type": "error", "error": f"Configuration model has no prefix"} + + ioc_config = ioc_api.ioc_config_model + SECRET_MASK = "**********" + + def filter_masked(obj): + if isinstance(obj, dict): + return {k: filter_masked(v) for k, v in obj.items() if v != SECRET_MASK} + elif isinstance(obj, list): + return [filter_masked(v) for v in obj if v != SECRET_MASK] + return obj + + def serialize(obj): + if isinstance(obj, dict): + return {k: serialize(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [serialize(v) for v in obj] + elif isinstance(obj, Path): + return str(obj) + return obj + + filtered_values = serialize(filter_masked(config_values)) + if not filtered_values: + return {"type": "info", "message": "No changes to save"} + + if target_file == "yaml": + config_path = ioc_config.config_path + if not config_path or not config_path.exists(): + return {"type": "error", "error": "IOC configuration file not found"} + + with open(config_path, 'r', encoding='utf-8') as f: + existing = yaml.safe_load(f) or {} + + def deep_merge(base, updates): + result = base.copy() + for k, v in updates.items(): + if k in result and isinstance(result[k], dict) and isinstance(v, dict): + result[k] = deep_merge(result[k], v) + else: + result[k] = v + return result + + existing[prefix] = deep_merge(existing.get(prefix, {}), filtered_values) + + with open(config_path, 'w', encoding='utf-8') as f: + yaml.dump(existing, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + + target_display = str(config_path) + else: + config_path = ioc_config.config_path + if not config_path: + return {"type": "error", "error": "IOC configuration path not set"} + + config_dir = config_path.parent + if target_file == "env": + env_path = config_dir / ".env" + else: + context = ioc_config.context + if not context: + return {"type": "error", "error": "No context configured"} + env_path = config_dir / f".{context}.env" + + existing_env = {} + if env_path.exists(): + with open(env_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, _, value = line.partition('=') + existing_env[key.strip()] = value.strip() + + prefix_upper = prefix.upper() + for key, value in filtered_values.items(): + env_key = f"{prefix_upper}_{key.upper()}" + if isinstance(value, bool): + existing_env[env_key] = str(value).lower() + elif isinstance(value, (dict, list)): + existing_env[env_key] = json.dumps(value) + else: + existing_env[env_key] = str(value) + + with open(env_path, 'w', encoding='utf-8') as f: + for key, value in existing_env.items(): + f.write(f"{key}={value}\n") + + target_display = str(env_path) + + try: + reconfigure_ioc_app(self._container, components=(component,)) + except Exception as e: + logger.warning(f"Could not reconfigure '{component_name}': {e}") + + return {"type": "success", "message": f"Saved {len(filtered_values)} field(s) to {target_display}"} + + except Exception as e: + logger.error(f"Error saving config for '{component_name}'", exc_info=e) + return {"type": "error", "error": str(e)} + + @inject + async def _unregister_plugin(self, plugin_name: str, logger=get_logger()) -> dict: + if not plugin_name: + return {"type": "error", "error": "Plugin name required"} + + plugin = self._container.provided_plugin(plugin_name) + if plugin is None: + return {"type": "error", "error": f"Plugin '{plugin_name}' not found"} + + internals = component_internals(plugin) + if internals.is_initialized: + return {"type": "error", "error": f"Cannot unregister active plugin '{plugin_name}'. Disable it first."} + + if internals.required_by: + return {"type": "error", + "error": f"Cannot unregister: required by {[r.__metadata__.get('name') for r in internals.required_by]}"} + + try: + self._container.unregister_plugins(plugin) + + if self._log_buffer: + module_name = getattr(plugin, '__name__', type(plugin).__name__) + with self._log_buffer._lock: + self._log_buffer._component_info.pop(module_name, None) + + self._previous_states.pop(plugin_name, None) + return {"type": "success", "message": f"Plugin '{plugin_name}' unregistered successfully"} + except Exception as e: + logger.error(f"Error unregistering plugin '{plugin_name}'", exc_info=e) + return {"type": "error", "error": str(e)} + + @inject + async def _sync_plugins_from_path(self, logger=get_logger()) -> dict: + if not self._plugin_upload_path: + return {"type": "error", "error": "Plugin upload path not configured"} + + if not self._plugin_upload_path.exists(): + self._plugin_upload_path.mkdir(parents=True, exist_ok=True) + return {"type": "info", "message": f"Created plugin directory: {self._plugin_upload_path}"} + + try: + plugin_paths_on_disk: Set[Path] = set() + for item in self._plugin_upload_path.iterdir(): + if item.name.startswith('_') or item.name.startswith('.'): + continue + if item.is_file() and item.suffix == '.py': + plugin_paths_on_disk.add(item.resolve()) + elif item.is_dir() and (item / '__init__.py').exists(): + plugin_paths_on_disk.add(item.resolve()) + + registered_paths: Set[Path] = set() + registered_names: Set[str] = set() + + for plugin in self._container.provided_plugins(): + plugin_meta_name = plugin.__metadata__.get("name", "") + if plugin_meta_name: + registered_names.add(plugin_meta_name) + + plugin_file = _get_component_file(plugin) + if plugin_file: + plugin_path = Path(plugin_file).resolve() + registered_paths.add(plugin_path) + if plugin_path.suffix == '.py': + parent = plugin_path.parent + registered_paths.add(parent) + if (parent / '__init__.py').exists(): + registered_paths.add(parent) + + self._discovered_plugins.clear() + for path in plugin_paths_on_disk: + is_registered = path in registered_paths + if not is_registered and path.is_dir(): + is_registered = (path / "__init__.py") in registered_paths + + if not is_registered: + if not is_awioc_project(path): + continue + + try: + project = open_project(path) + except Exception: + continue + + unregistered = [c for c in project.components if c.name not in registered_names] + if not unregistered: + continue + + self._discovered_plugins[str(path)] = { + "name": project.name, + "path": str(path), + "is_directory": path.is_dir(), + "manifest_version": project.manifest_version, + "manifest_description": project.description or "", + "component_classes": [ + {"class_name": c.class_name or "", "metadata_name": c.name, + "reference": f":{c.class_name}()" if c.class_name else ""} + for c in unregistered + ], + } + + if self._discovered_plugins: + return {"type": "success", "message": f"Found {len(self._discovered_plugins)} unregistered plugin(s)"} + return {"type": "info", "message": "No unregistered plugins found"} + + except Exception as e: + logger.error("Error discovering plugins", exc_info=e) + return {"type": "error", "error": str(e)} + + @inject + async def _remove_plugin_file(self, plugin_path: str, logger=get_logger()) -> dict: + import shutil + + if not plugin_path: + return {"type": "error", "error": "Plugin path required"} + + path = Path(plugin_path).resolve() + + if not self._plugin_upload_path: + return {"type": "error", "error": "Plugin upload path not configured"} + + try: + path.relative_to(self._plugin_upload_path.resolve()) + except ValueError: + return {"type": "error", "error": "Cannot remove plugins outside upload directory"} + + if not path.exists(): + self._discovered_plugins.pop(str(path), None) + return {"type": "error", "error": "Plugin file not found"} + + try: + plugin_name = path.stem if path.is_file() else path.name + if path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + + self._discovered_plugins.pop(str(path), None) + return {"type": "success", "message": f"Plugin '{plugin_name}' removed"} + except Exception as e: + logger.error(f"Error removing plugin '{plugin_path}'", exc_info=e) + return {"type": "error", "error": str(e)} + + @inject + async def _save_plugins_to_config(self, ioc_api=get_container_api(), logger=get_logger()) -> dict: + try: + ioc_config = ioc_api.ioc_config_model + config_path = ioc_config.config_path + + if not config_path or not config_path.exists(): + return {"type": "error", "error": "IOC configuration file not found"} + + with open(config_path, 'r', encoding='utf-8') as f: + existing = yaml.safe_load(f) or {} + + config_dir = config_path.parent + plugin_paths = [] + + for plugin in self._container.provided_plugins(): + source_ref = plugin.__metadata__.get("_source_ref") + if source_ref and source_ref.startswith("@"): + plugin_paths.append(source_ref) + continue + + plugin_file = _get_component_file(plugin) + if plugin_file: + plugin_path = Path(plugin_file) + is_class_based = not hasattr(plugin, '__file__') + + try: + path_str = str(plugin_path.relative_to(config_dir)) + except ValueError: + path_str = str(plugin_path) + + if plugin_path.name == "__init__.py": + path_str = str(Path(path_str).parent) + + if is_class_based: + path_str = f"{path_str}:{type(plugin).__name__}()" + + plugin_paths.append(path_str) + + existing.setdefault("components", {})["plugins"] = plugin_paths + + with open(config_path, 'w', encoding='utf-8') as f: + yaml.dump(existing, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + + return {"type": "success", "message": f"Saved {len(plugin_paths)} plugin(s) to {config_path.name}"} + except Exception as e: + logger.error("Error saving plugins", exc_info=e) + return {"type": "error", "error": str(e)} + + @inject + async def _list_pots(self, logger=get_logger()) -> dict: + try: + from awioc.commands.pot import get_pot_dir, load_pot_manifest + + pot_dir = get_pot_dir() + if not pot_dir.exists(): + return {"type": "success", "pots": []} + + pots = [] + for pot_path in sorted(pot_dir.iterdir()): + if not pot_path.is_dir(): + continue + manifest = load_pot_manifest(pot_path) + pots.append({ + "name": pot_path.name, + "version": manifest.get("version", "?"), + "description": manifest.get("description", ""), + "component_count": len(manifest.get("components", {})), + }) + + return {"type": "success", "pots": pots} + except Exception as e: + logger.error("Error listing pots", exc_info=e) + return {"type": "error", "error": str(e)} + + @inject + async def _list_pot_components(self, pot_name: str, logger=get_logger()) -> dict: + try: + if not pot_name: + return {"type": "error", "error": "Pot name required"} + + from awioc.commands.pot import get_pot_path, load_pot_manifest + + pot_path = get_pot_path(pot_name) + if not pot_path.exists(): + return {"type": "error", "error": f"Pot not found: {pot_name}"} + + manifest = load_pot_manifest(pot_path) + components_data = manifest.get("components", {}) + + registered_refs = {p.__metadata__.get("_source_ref", "") for p in self._container.provided_plugins() if + p.__metadata__.get("_source_ref", "").startswith("@")} + + components = [] + for comp_id, comp_info in sorted(components_data.items()): + pot_ref = f"@{pot_name}/{comp_id}" + components.append({ + "id": comp_id, + "name": comp_info.get("name", comp_id), + "version": comp_info.get("version", "?"), + "description": comp_info.get("description", ""), + "class_name": comp_info.get("class_name"), + "pot_ref": pot_ref, + "is_registered": pot_ref in registered_refs, + }) + + return { + "type": "success", "pot_name": pot_name, + "pot_version": manifest.get("version", "?"), + "pot_description": manifest.get("description", ""), + "components": components, + } + except Exception as e: + logger.error(f"Error listing components in pot '{pot_name}'", exc_info=e) + return {"type": "error", "error": str(e)} + + +# Global WebSocket manager instance +ws_manager = WebSocketManager() + + +@as_component( + name="Management Dashboard", + version="1.3.0", + description="Management Dashboard App Component", + wirings=("http_handler",), + config=DashboardConfig, +) +class ManagementDashboardApp: + """Management Dashboard App Component.""" + + def __init__(self): + self._server: Optional[ThreadingHTTPServer] = None + self._thread: Optional[Thread] = None + self._ws_thread: Optional[Thread] = None + self._running = False + self._shutdown_event: Optional[asyncio.Event] = None + self._ws_loop: Optional[asyncio.AbstractEventLoop] = None + self._monitor_interval: float = 0.25 + self._log_handler: Optional[DashboardLogHandler] = None + + def _run_ws_server(self, host: str, port: int): + self._ws_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._ws_loop) + ws_manager.set_ws_loop(self._ws_loop) + + async def serve(): + monitor_task = asyncio.create_task(ws_manager.start_monitoring(self._monitor_interval)) + async with websockets.serve(ws_manager.handle_client, host, port): + while self._running: + await asyncio.sleep(0.1) + ws_manager.stop_monitoring() + monitor_task.cancel() + try: + await monitor_task + except asyncio.CancelledError: + pass + + self._ws_loop.run_until_complete(serve()) + self._ws_loop.close() + + @inject + async def initialize( + self, logger=get_logger(), config=get_config(DashboardConfig), container=get_container_api() + ) -> None: + self._shutdown_event = asyncio.Event() + self._running = True + self._monitor_interval = config.monitor_interval + + DashboardRequestHandler.container = container + ws_manager.set_container(container) + ws_manager.set_main_loop(asyncio.get_running_loop()) + + log_buffer._buffer = deque(maxlen=config.log_buffer_size) + component_info = {} + for comp in container.components: + display_name = comp.__metadata__.get("name", "unknown") + internals = component_internals(comp) + component_info[display_name] = (display_name, internals.type.value) + log_buffer.set_component_info(component_info) + + ws_manager.set_log_buffer(log_buffer) + self._log_handler = DashboardLogHandler(log_buffer, broadcast_callback=ws_manager.on_new_log) + self._log_handler.setLevel(logging.DEBUG) + logger.parent.addHandler(self._log_handler) + + logger.info(f"Starting Dashboard on {config.host}:{config.port}") + self._server = ThreadingHTTPServer((config.host, config.port), DashboardRequestHandler) + self._thread = Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + + upload_path = Path(config.plugin_upload_path) + if not upload_path.is_absolute(): + ioc_config = container.ioc_config_model + upload_path = (ioc_config.config_path.parent if ioc_config.config_path else Path.cwd()) / upload_path + ws_manager.set_plugin_upload_path(upload_path) + + sync_result = await ws_manager._sync_plugins_from_path() + if sync_result.get("type") in ("success", "info"): + logger.info(sync_result.get("message")) + + logger.info(f"Starting WebSocket on {config.host}:{config.ws_port}") + self._ws_thread = Thread(target=self._run_ws_server, args=(config.host, config.ws_port), daemon=True) + self._ws_thread.start() + + logger.info(f"Dashboard running at http://{config.host}:{config.port}") + + async def wait(self) -> None: + if self._shutdown_event: + await self._shutdown_event.wait() + + async def shutdown(self) -> None: + self._running = False + ws_manager.stop_monitoring() + + if self._shutdown_event: + self._shutdown_event.set() + + if self._log_handler: + logging.getLogger().removeHandler(self._log_handler) + self._log_handler = None + + if self._server: + self._server.shutdown() + self._server.server_close() + self._server = None + + if self._thread: + self._thread.join(timeout=2) + self._thread = None + + if self._ws_thread: + self._ws_thread.join(timeout=2) + self._ws_thread = None diff --git a/samples/management_dashboard/http_handler.py b/samples/management_dashboard/http_handler.py new file mode 100644 index 0000000..e93143d --- /dev/null +++ b/samples/management_dashboard/http_handler.py @@ -0,0 +1,288 @@ +"""HTTP request handler for the management dashboard.""" + +import asyncio +import json +import os +from http.server import BaseHTTPRequestHandler, SimpleHTTPRequestHandler +from typing import Optional +from urllib.parse import urlparse + +from awioc import ( + ContainerInterface, + component_internals, + get_container_api, + get_logger, + initialize_components, + inject, + shutdown_components, +) +from .config import WEB_DIR + + +class DashboardRequestHandler(BaseHTTPRequestHandler): + """HTTP request handler for the management dashboard.""" + + container: Optional[ContainerInterface] = None + + @inject + def _get_dependencies( + self, + logger=get_logger(), + container=get_container_api() + ): + return logger, container + + def _send_json_response(self, data: dict, status: int = 200): + """Send a JSON response.""" + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps(data, indent=2).encode()) + + def _send_html_response(self, html: str, status: int = 200): + """Send an HTML response.""" + self.send_response(status) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(html.encode()) + + def _serve_static_file(self): + """ + Delegate static file serving to SimpleHTTPRequestHandler + sandboxed to WEB_DIR. + """ + + # Strip "/static" prefix + self.path = self.path[len("/static"):] + + # Ensure leading slash + if not self.path.startswith("/"): + self.path = "/" + self.path + + # Temporarily change working directory (required by SimpleHTTPRequestHandler) + original_cwd = os.getcwd() + try: + os.chdir(WEB_DIR) + + handler = SimpleHTTPRequestHandler( + request=self.request, + client_address=self.client_address, + server=self.server, + ) + + # Reuse our logging behavior + handler.log_message = self.log_message + + finally: + os.chdir(original_cwd) + + def _get_component_info(self, component) -> dict: + """Get information about a component.""" + internals = component_internals(component) + metadata = component.__metadata__ + return { + "name": metadata.get("name", "unknown"), + "version": metadata.get("version", "unknown"), + "description": metadata.get("description", ""), + "type": internals.type.value, + "state": { + "is_initialized": internals.is_initialized, + "is_initializing": internals.is_initializing, + "is_shutting_down": internals.is_shutting_down, + }, + "required_by": [ + req.__metadata__.get("name", "unknown") + for req in internals.required_by + ] + } + + def do_GET(self): + """Handle GET requests.""" + logger, container = self._get_dependencies() + parsed_path = urlparse(self.path) + path = parsed_path.path + + logger.debug(f"GET {self.path}") + + if path == "/": + self._serve_dashboard_html() + elif path == "/api/components": + self._handle_list_components(container) + elif path == "/api/state": + self._handle_app_state(container) + elif path == "/api/plugins": + self._handle_list_plugins(container) + elif path.startswith("/static/"): + requested = (WEB_DIR / path[len("/static/"):]).resolve() + + try: + requested.relative_to(WEB_DIR.resolve()) + except ValueError: + self._send_json_response({"error": "Invalid path"}, 403) + return + + try: + content = requested.read_bytes() + self.send_response(200) + if requested.suffix == ".css": + self.send_header("Content-Type", "text/css") + elif requested.suffix == ".js": + self.send_header("Content-Type", "application/javascript") + else: + self.send_header("Content-Type", "application/octet-stream") + self.end_headers() + self.wfile.write(content) + except FileNotFoundError: + self._send_json_response({"error": "Asset not found"}, 404) + else: + self._send_json_response({"error": "Not Found"}, 404) + + def do_POST(self): + """Handle POST requests.""" + logger, container = self._get_dependencies() + parsed_path = urlparse(self.path) + path = parsed_path.path + + logger.debug(f"POST {self.path}") + + if path == "/api/plugins/enable": + self._handle_enable_plugin(container) + elif path == "/api/plugins/disable": + self._handle_disable_plugin(container) + else: + self._send_json_response({"error": "Not Found"}, 404) + + def do_OPTIONS(self): + """Handle CORS preflight requests.""" + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def _serve_dashboard_html(self): + """Serve the dashboard HTML page from web/index.html.""" + index_path = WEB_DIR / "index.html" + try: + html = index_path.read_text(encoding="utf-8") + self._send_html_response(html) + except FileNotFoundError: + self._send_json_response({"error": "Dashboard not found"}, 404) + + def _handle_list_components(self, container: ContainerInterface): + """List all registered components.""" + components = container.components + components_info = [self._get_component_info(c) for c in components] + self._send_json_response({"components": components_info}) + + def _handle_app_state(self, container: ContainerInterface): + """Get overall application state.""" + components = container.components + app = container.provided_app() + plugins = container.provided_plugins() + libs = container.provided_libs() + + initialized_count = sum( + 1 for c in components + if component_internals(c).is_initialized + ) + + state = { + "app_name": app.__metadata__.get("name", "unknown"), + "app_version": app.__metadata__.get("version", "unknown"), + "total_components": len(components), + "initialized_components": initialized_count, + "plugins_count": len(plugins), + "libraries_count": len(libs), + "plugins": [p.__metadata__.get("name") for p in plugins], + "libraries": [lib.__metadata__.get("name") for lib in libs], + } + self._send_json_response(state) + + def _handle_list_plugins(self, container: ContainerInterface): + """List all registered plugins.""" + plugins = container.provided_plugins() + plugins_info = [self._get_component_info(p) for p in plugins] + self._send_json_response({"plugins": plugins_info}) + + def _handle_enable_plugin(self, container: ContainerInterface): + """Enable (initialize) a plugin.""" + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length).decode() + + try: + data = json.loads(body) + plugin_name = data.get("name") + except json.JSONDecodeError: + self._send_json_response({"error": "Invalid JSON"}, 400) + return + + if not plugin_name: + self._send_json_response({"error": "Plugin name required"}, 400) + return + + plugin = container.provided_plugin(plugin_name) + if plugin is None: + self._send_json_response({"error": f"Plugin '{plugin_name}' not found"}, 404) + return + + internals = component_internals(plugin) + if internals.is_initialized: + self._send_json_response({"message": f"Plugin '{plugin_name}' is already enabled"}) + return + + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(initialize_components(plugin)) + self._send_json_response({"message": f"Plugin '{plugin_name}' enabled successfully"}) + except Exception as e: + self._send_json_response({"error": str(e)}, 500) + finally: + loop.close() + + def _handle_disable_plugin(self, container: ContainerInterface): + """Disable (shutdown) a plugin.""" + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length).decode() + + try: + data = json.loads(body) + plugin_name = data.get("name") + except json.JSONDecodeError: + self._send_json_response({"error": "Invalid JSON"}, 400) + return + + if not plugin_name: + self._send_json_response({"error": "Plugin name required"}, 400) + return + + plugin = container.provided_plugin(plugin_name) + if plugin is None: + self._send_json_response({"error": f"Plugin '{plugin_name}' not found"}, 404) + return + + internals = component_internals(plugin) + if not internals.is_initialized: + self._send_json_response({"message": f"Plugin '{plugin_name}' is already disabled"}) + return + + if internals.required_by: + required_names = [r.__metadata__.get("name") for r in internals.required_by] + self._send_json_response({ + "error": f"Cannot disable plugin '{plugin_name}': required by {required_names}" + }, 400) + return + + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(shutdown_components(plugin)) + self._send_json_response({"message": f"Plugin '{plugin_name}' disabled successfully"}) + except Exception as e: + self._send_json_response({"error": str(e)}, 500) + finally: + loop.close() + + def log_message(self, format, *args): + """Suppress default logging.""" + pass diff --git a/samples/management_dashboard/ioc.yaml b/samples/management_dashboard/ioc.yaml new file mode 100644 index 0000000..c7ae883 --- /dev/null +++ b/samples/management_dashboard/ioc.yaml @@ -0,0 +1,8 @@ +components: + app: dashboard:ManagementDashboardApp() +dashboard: + host: 127.0.0.1 + port: 8090 + ws_port: 8091 + monitor_interval: 0.25 + log_buffer_size: 500 diff --git a/samples/management_dashboard/models.py b/samples/management_dashboard/models.py new file mode 100644 index 0000000..74ca076 --- /dev/null +++ b/samples/management_dashboard/models.py @@ -0,0 +1,158 @@ +"""Data models for the management dashboard.""" + +import logging +import time +from collections import deque +from dataclasses import dataclass +from threading import Lock +from typing import Callable, Deque, Dict, List, Optional + + +@dataclass +class ComponentState: + """Snapshot of a component's state.""" + is_initialized: bool + is_initializing: bool + is_shutting_down: bool + + def __eq__(self, other): + if not isinstance(other, ComponentState): + return False + return ( + self.is_initialized == other.is_initialized and + self.is_initializing == other.is_initializing and + self.is_shutting_down == other.is_shutting_down + ) + + def get_status_label(self) -> str: + """Get a human-readable status label.""" + if self.is_shutting_down: + return "shutting_down" + elif self.is_initializing: + return "initializing" + elif self.is_initialized: + return "active" + else: + return "inactive" + + +@dataclass +class LogEntry: + """A single log entry.""" + id: int + timestamp: float + level: str + logger_name: str + message: str + source: str = "unknown" # app, plugin, library, or framework + component: str = "unknown" # component name + + def to_dict(self) -> dict: + return { + "id": self.id, + "timestamp": self.timestamp, + "level": self.level, + "logger_name": self.logger_name, + "message": self.message, + "source": self.source, + "component": self.component, + } + + +class LogBuffer: + """Thread-safe circular buffer for log entries.""" + + def __init__(self, max_size: int = 500): + self._buffer: Deque[LogEntry] = deque(maxlen=max_size) + self._lock = Lock() + self._id_counter = 0 + self._component_info: Dict[str, tuple] = {} + + def set_component_info(self, component_info: Dict[str, tuple]): + """Set the mapping of module names to (display_name, type).""" + with self._lock: + self._component_info = component_info.copy() + + def add(self, level: str, logger_name: str, message: str) -> LogEntry: + """Add a log entry and return it.""" + with self._lock: + self._id_counter += 1 + source, component = self._parse_logger_name(logger_name) + entry = LogEntry( + id=self._id_counter, + timestamp=time.time(), + level=level, + logger_name=logger_name, + message=message, + source=source, + component=component, + ) + self._buffer.append(entry) + return entry + + def _parse_logger_name(self, logger_name: str) -> tuple: + """Parse logger name to determine source and component.""" + logger_lower = logger_name.lower() + + # Check for framework logs first + if "awioc" in logger_lower: + return "framework", "awioc" + + # Try to match logger name against registered module names + for module_name, (display_name, comp_type) in self._component_info.items(): + module_lower = module_name.lower() + if module_lower in logger_lower or logger_lower.endswith(module_lower): + return comp_type, display_name + module_last = module_lower.rsplit('.', 1)[-1] + if module_last in logger_lower: + return comp_type, display_name + + # Default - extract component name from logger path + parts = logger_name.split(".") + return "unknown", parts[-1] if parts else logger_name + + def get_all(self) -> List[dict]: + """Get all log entries as dicts.""" + with self._lock: + return [entry.to_dict() for entry in self._buffer] + + def get_since(self, last_id: int) -> List[dict]: + """Get log entries since a given ID.""" + with self._lock: + return [entry.to_dict() for entry in self._buffer if entry.id > last_id] + + def clear(self): + """Clear all log entries.""" + with self._lock: + self._buffer.clear() + + +class DashboardLogHandler(logging.Handler): + """Custom logging handler that captures logs for the dashboard.""" + + def __init__( + self, + log_buffer: LogBuffer, + broadcast_callback: Optional[Callable[[LogEntry], None]] = None + ): + super().__init__() + self._log_buffer = log_buffer + self._broadcast_callback = broadcast_callback + self.setFormatter(logging.Formatter("%(message)s")) + + def emit(self, record: logging.LogRecord): + try: + message = self.format(record) + entry = self._log_buffer.add( + level=record.levelname, + logger_name=record.name, + message=message, + ) + if self._broadcast_callback: + self._broadcast_callback(entry) + except Exception: + self.handleError(record) + + +# Global log buffer instance +log_buffer = LogBuffer() diff --git a/samples/management_dashboard/static/app.js b/samples/management_dashboard/static/app.js new file mode 100644 index 0000000..d20063f --- /dev/null +++ b/samples/management_dashboard/static/app.js @@ -0,0 +1,1907 @@ +// IOC Management Dashboard Application + +// ==================== State ==================== +let currentComponents = []; +let currentPlugins = []; +let discoveredPlugins = []; +let currentLogs = []; +let configTargets = []; +let originalConfigValues = {}; // Track original values to detect changes +let secretFields = new Set(); // Track which fields are secrets +let ws = null; +let reconnectAttempts = 0; +let selectedComponent = null; +let currentPanelItem = null; +let currentConfigPrefix = null; // Track current config prefix for multiple configs +let currentConfigs = []; // Array of config objects for current item +let treeNodeIdCounter = 0; + +// ==================== Tab Switching ==================== +function switchTab(tabName) { + document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); + + document.querySelector(`[onclick="switchTab('${tabName}')"]`).classList.add('active'); + document.getElementById(`tab-${tabName}`).classList.add('active'); +} + +// ==================== WebSocket Connection ==================== +function connectWebSocket() { + const wsUrl = `ws://${location.hostname}:8091`; + updateConnectionStatus('connecting'); + + try { + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + updateConnectionStatus('connected'); + reconnectAttempts = 0; + requestRefresh(); + requestLogs(); + }; + + ws.onclose = () => { + updateConnectionStatus('disconnected'); + setTimeout(() => { + reconnectAttempts++; + connectWebSocket(); + }, Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)); + }; + + ws.onerror = () => updateConnectionStatus('disconnected'); + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleMessage(data); + } catch (e) { + console.error('Failed to parse message:', e); + } + }; + } catch (e) { + updateConnectionStatus('disconnected'); + } +} + +function updateConnectionStatus(status) { + const el = document.getElementById('connectionStatus'); + el.className = 'connection-status ' + status; + const dot = el.querySelector('.status-dot'); + dot.className = 'status-dot ' + status; + + const labels = { connected: 'Connected', disconnected: 'Disconnected', connecting: 'Connecting...' }; + el.querySelector('span:last-child').textContent = labels[status] || status; +} + +function handleMessage(data) { + switch (data.type) { + case 'full_state': + currentComponents = data.components || []; + currentPlugins = data.plugins || []; + discoveredPlugins = data.discovered_plugins || []; + configTargets = (data.state && data.state.config_targets) || []; + updateTopPanel(); + updateConfigTargetDropdown(); + renderAllSections(); + renderDiscoveredPlugins(); + // Refresh pot components list if pot browser is open + refreshSelectedPotComponents(); + // Auto-select app if nothing selected + if (!selectedComponent) { + const apps = currentComponents.filter(c => c.type === 'app'); + if (apps.length > 0) { + selectComponent(apps[0].name); + } + } else { + // Refresh current selection + selectComponent(selectedComponent); + } + break; + case 'component_update': + if (data.component) { + const idx = currentComponents.findIndex(c => c.name === data.component.name); + if (idx >= 0) currentComponents[idx] = data.component; + else currentComponents.push(data.component); + renderAllSections(data.component.name); + if (selectedComponent === data.component.name) { + selectComponent(selectedComponent); + } + } + break; + case 'logs_history': + currentLogs = data.logs || []; + renderLogs(); + break; + case 'log': + addLogEntry(data.entry); + break; + case 'logs_cleared': + currentLogs = []; + renderLogs(); + break; + case 'success': + // Handle pot browser responses + if (data.pots !== undefined) { + handlePotsResponse(data); + break; + } + if (data.pot_name !== undefined && data.components !== undefined) { + handlePotComponentsResponse(data); + break; + } + showToast(data.message, 'success'); + // After successful save, update original values with the saved values + // so subsequent saves only track new changes + if (data.message && data.message.includes('Saved') && data.message.includes('field')) { + updateOriginalConfigAfterSave(); + } + break; + case 'error': + case 'info': + showToast(data.message || data.error, data.type); + break; + } +} + +function updateTopPanel() { + const app = currentComponents.find(c => c.type === 'app'); + if (app) { + document.getElementById('appName').textContent = app.name; + document.getElementById('appVersion').textContent = 'v' + (app.version || '?'); + } + + document.getElementById('statComponents').textContent = currentComponents.length; + document.getElementById('statPlugins').textContent = currentPlugins.length; +} + +function requestRefresh() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'refresh' })); + } +} + +function requestLogs() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'get_logs' })); + } +} + +// ==================== Helper Functions ==================== +function escapeHtml(text) { + if (text === undefined || text === null) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; +} + +function escapeJs(text) { + if (text === undefined || text === null) return ''; + return String(text).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); +} + +function getStatusClass(item) { + if (!item.state) return 'inactive'; + if (item.state.is_shutting_down) return 'shutting_down'; + if (item.state.is_initializing) return 'initializing'; + return item.state.is_initialized ? 'active' : 'inactive'; +} + +function getStatusLabel(item) { + if (!item.state) return 'Inactive'; + if (item.state.is_shutting_down) return 'Stopping'; + if (item.state.is_initializing) return 'Starting'; + return item.state.is_initialized ? 'Active' : 'Inactive'; +} + +// ==================== Component List Rendering ==================== +function getAllItemsCombined() { + const combinedMap = new Map(); + (currentComponents || []).forEach(c => combinedMap.set(c.name, Object.assign({ _kind: 'component' }, c))); + (currentPlugins || []).forEach(p => combinedMap.set(p.name, Object.assign({ _kind: 'plugin' }, p))); + return Array.from(combinedMap.values()).sort((a, b) => a.name.localeCompare(b.name)); +} + +function applyFilters(items) { + const nameSearch = (document.getElementById('itemSearch')?.value || '').toLowerCase().trim(); + const statusFilter = (document.getElementById('itemStatus')?.value || '').toLowerCase(); + + return items.filter(item => { + if (nameSearch && !item.name.toLowerCase().includes(nameSearch)) return false; + if (statusFilter) { + const isActive = !!(item.state && item.state.is_initialized); + if (statusFilter === 'enabled' && !isActive) return false; + if (statusFilter === 'disabled' && isActive) return false; + } + return true; + }); +} + +function getItemsByCategory() { + const all = getAllItemsCombined(); + const filtered = applyFilters(all); + + const apps = filtered.filter(item => String(item.type || '').toLowerCase() === 'app'); + const libraries = filtered.filter(item => String(item.type || '').toLowerCase() === 'library'); + const plugins = filtered.filter(item => item._kind === 'plugin'); + + return { apps, libraries, plugins }; +} + +function renderComponentCard(item, highlightName) { + const statusClass = getStatusClass(item); + const statusLabel = getStatusLabel(item); + const isSelected = selectedComponent === item.name; + const isActive = item.state && item.state.is_initialized; + const highlight = item.name === highlightName ? 'state-change' : ''; + + return ` +
+
+
+
${escapeHtml(item.name)}
+
v${escapeHtml(item.version || '')}
+
+ ${statusLabel} +
+
+ `; +} + +function renderSection(containerId, items, highlightName) { + const container = document.getElementById(containerId); + if (!container) return; + + if (items.length === 0) { + container.innerHTML = '
No items
'; + return; + } + + container.innerHTML = items.map(item => renderComponentCard(item, highlightName)).join(''); + + if (highlightName) { + setTimeout(() => { + const el = container.querySelector(`[data-name="${highlightName}"]`); + if (el) el.classList.remove('state-change'); + }, 600); + } +} + +function renderAllSections(highlightName = null) { + const { apps, libraries, plugins } = getItemsByCategory(); + + renderSection('appSection', apps, highlightName); + renderSection('librariesSection', libraries, highlightName); + renderSection('pluginsSection', plugins, highlightName); + + // Update counts + document.getElementById('appCount').textContent = apps.length; + document.getElementById('libCount').textContent = libraries.length; + document.getElementById('pluginCount').textContent = plugins.length; +} + +function renderDiscoveredPlugins() { + const container = document.getElementById('discoveredSection'); + const header = document.getElementById('discoveredHeader'); + const countEl = document.getElementById('discoveredCount'); + + if (!container || !header) return; + + if (discoveredPlugins.length === 0) { + header.style.display = 'none'; + container.innerHTML = ''; + return; + } + + header.style.display = 'flex'; + countEl.textContent = discoveredPlugins.length; + + container.innerHTML = discoveredPlugins.map(plugin => { + const classes = plugin.component_classes || []; + const hasModuleMeta = plugin.has_module_metadata || false; + const hasClasses = classes.length > 0; + + // Build registration options + let registrationOptionsHtml = ''; + + if (hasClasses || hasModuleMeta) { + // Show dropdown for multiple registration options + registrationOptionsHtml = ` +
+
Register as:
+
+ ${hasModuleMeta ? ` +
+ +
+ ` : ''} + ${classes.map(cls => ` +
+ +
+ `).join('')} +
+
+ `; + } else { + // No classes or module metadata found - show warning + registrationOptionsHtml = ` +
+
+ No component classes detected. The module may have import errors or use a non-standard pattern. +
+
+ +
+
+ `; + } + + // Build component classes info display + let classesInfoHtml = ''; + if (hasClasses) { + classesInfoHtml = ` +
+ ${classes.length} component class${classes.length > 1 ? 'es' : ''} found +
+ `; + } + + return ` +
+
+
+
${escapeHtml(plugin.name)}
+
+ ${plugin.is_directory ? 'Directory' : 'File'} +
+ ${classesInfoHtml} +
+ Not Registered +
+ ${registrationOptionsHtml} +
+ +
+
+ `; + }).join(''); +} + +function registerDiscoveredPlugin(path, classReference) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + showToast('Not connected', 'error'); + return; + } + const message = { action: 'register_plugin', path: path }; + if (classReference) { + message.class_reference = classReference; + } + ws.send(JSON.stringify(message)); + showToast(classReference ? `Registering ${classReference}...` : 'Registering plugin...', 'info'); +} + +function removeDiscoveredPlugin(path, name) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + showToast('Not connected', 'error'); + return; + } + if (confirm(`Are you sure you want to remove "${name}" from disk? This cannot be undone.`)) { + ws.send(JSON.stringify({ action: 'remove_plugin', path: path })); + showToast('Removing plugin...', 'info'); + } +} + +function clearItemFilters() { + document.getElementById('itemSearch').value = ''; + document.getElementById('itemStatus').value = ''; + renderAllSections(); +} + +// ==================== Component Selection and Details ==================== +function findItemByName(name) { + if (!name) return null; + const all = getAllItemsCombined(); + return all.find(i => i.name === name) || null; +} + +function selectComponent(name) { + selectedComponent = name; + const item = findItemByName(name); + if (item) { + populateDetailsPanel(item); + } + renderAllSections(); +} + +function populateDetailsPanel(item) { + currentPanelItem = item; + + document.getElementById('panelTitle').textContent = item.name; + document.getElementById('panelVersion').textContent = 'v' + (item.version || ''); + document.getElementById('panelDesc').textContent = item.description || 'No description available.'; + document.getElementById('panelKind').textContent = item._kind ? item._kind.charAt(0).toUpperCase() + item._kind.slice(1) : '-'; + document.getElementById('panelType').textContent = item.type ? item.type.charAt(0).toUpperCase() + item.type.slice(1) : '-'; + + const statusEl = document.getElementById('panelStatus'); + statusEl.innerHTML = `${getStatusLabel(item)}`; + + // Internals + const internals = item.internals || {}; + document.getElementById('panelWire').textContent = internals.wire ? 'Yes' : 'No'; + + // Dependencies + const depsContainer = document.getElementById('panelDependencies'); + let depsHtml = ''; + + const clickableBadge = (name, badgeClass) => + `${escapeHtml(name)}`; + + const requires = internals.requires || []; + if (requires.length > 0) { + depsHtml += `
Depends on
${requires.map(n => clickableBadge(n, 'type-library')).join('')}
`; + } + + const requiredBy = item.required_by || []; + if (requiredBy.length > 0) { + depsHtml += `
Required by
${requiredBy.map(n => clickableBadge(n, 'type-plugin')).join('')}
`; + } + + const initializedBy = internals.initialized_by || []; + if (initializedBy.length > 0) { + depsHtml += `
Initialized by
${initializedBy.map(n => clickableBadge(n, 'type-app')).join('')}
`; + } + + if (!depsHtml) { + depsHtml = 'No dependencies'; + } + depsContainer.innerHTML = depsHtml; + + // Registration info + const registration = item.registration; + const registrationSection = document.getElementById('panelRegistrationSection'); + if (registration) { + registrationSection.style.display = 'block'; + document.getElementById('panelRegisteredBy').textContent = registration.registered_by || '-'; + + // Format the timestamp nicely + if (registration.registered_at) { + const date = new Date(registration.registered_at); + document.getElementById('panelRegisteredAt').textContent = date.toLocaleString(); + } else { + document.getElementById('panelRegisteredAt').textContent = '-'; + } + + // Show source file if available + const fileItem = document.getElementById('panelRegistrationFileItem'); + if (registration.file) { + fileItem.style.display = 'block'; + const fileName = registration.file.split(/[/\\]/).pop(); + const lineInfo = registration.line ? `:${registration.line}` : ''; + document.getElementById('panelRegistrationFile').textContent = fileName + lineInfo; + document.getElementById('panelRegistrationFile').title = registration.file + lineInfo; + } else { + fileItem.style.display = 'none'; + } + } else { + registrationSection.style.display = 'none'; + } + + // Config + renderConfigForm(item); + + // Actions + const actions = document.getElementById('panelActions'); + actions.innerHTML = ''; + + const lowerType = String(item.type || '').toLowerCase(); + const isApp = lowerType === 'app'; + const isLibrary = lowerType === 'library'; + const isActive = item.state && item.state.is_initialized; + const isTransitioning = item.state && (item.state.is_initializing || item.state.is_shutting_down); + + if (!isApp && !isLibrary) { + if (isTransitioning) { + const btn = document.createElement('button'); + btn.className = 'btn'; + btn.disabled = true; + btn.textContent = 'Please wait...'; + actions.appendChild(btn); + } else if (isActive) { + const btn = document.createElement('button'); + btn.className = 'btn btn-disable'; + btn.textContent = 'Disable'; + btn.onclick = () => disableItem(item.name, item._kind); + actions.appendChild(btn); + } else { + const btn = document.createElement('button'); + btn.className = 'btn btn-enable'; + btn.textContent = 'Enable'; + btn.onclick = () => enableItem(item.name, item._kind); + actions.appendChild(btn); + } + + // Add unregister button for plugins (only when inactive) + if (item._kind === 'plugin' && !isActive && !isTransitioning) { + const unregBtn = document.createElement('button'); + unregBtn.className = 'btn btn-danger'; + unregBtn.textContent = 'Unregister'; + unregBtn.onclick = () => unregisterPlugin(item.name); + actions.appendChild(unregBtn); + } + } +} + +// ==================== Config Form Rendering ==================== +function renderConfigForm(item) { + const configSection = document.getElementById('panelConfigSection'); + const configForm = document.getElementById('panelConfigForm'); + const configTabs = document.getElementById('configTabs'); + + // Handle both array of configs and legacy single config object + let configs = item.config; + if (!configs) { + configSection.style.display = 'none'; + currentConfigs = []; + currentConfigPrefix = null; + return; + } + + // Normalize to array + if (!Array.isArray(configs)) { + configs = [configs]; + } + + // Filter out configs without schema + configs = configs.filter(c => c && c.schema); + if (configs.length === 0) { + configSection.style.display = 'none'; + currentConfigs = []; + currentConfigPrefix = null; + return; + } + + currentConfigs = configs; + configSection.style.display = 'block'; + + // Render tabs if multiple configs + if (configs.length > 1) { + configTabs.style.display = 'flex'; + configTabs.innerHTML = ''; + configs.forEach((cfg, index) => { + const tab = document.createElement('button'); + tab.type = 'button'; // Prevent form submission + tab.className = 'config-tab' + (index === 0 ? ' active' : ''); + tab.textContent = cfg.prefix || `Config ${index + 1}`; + tab.dataset.configIndex = index; + tab.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + switchConfigTab(parseInt(e.currentTarget.dataset.configIndex, 10)); + }); + configTabs.appendChild(tab); + }); + } else { + configTabs.style.display = 'none'; + } + + // Render first config + renderSingleConfig(configs[0]); +} + +function switchConfigTab(index) { + if (index < 0 || index >= currentConfigs.length) return; + + // Update tab active state + const tabs = document.querySelectorAll('#configTabs .config-tab'); + tabs.forEach((tab, i) => { + tab.classList.toggle('active', i === index); + }); + + // Render selected config + renderSingleConfig(currentConfigs[index]); +} + +function renderSingleConfig(config) { + const configForm = document.getElementById('panelConfigForm'); + configForm.innerHTML = ''; + treeNodeIdCounter = 0; + + // Reset tracking for original values and secrets + originalConfigValues = {}; + secretFields = new Set(); + currentConfigPrefix = config.prefix || null; + + const schema = config.schema; + const values = config.values || {}; + const properties = schema.properties || {}; + + // Store original values for change detection + originalConfigValues = JSON.parse(JSON.stringify(values)); + + for (const [fieldName, fieldSchema] of Object.entries(properties)) { + const fieldType = fieldSchema.type || 'string'; + const fieldTitle = fieldSchema.title || fieldName; + const fieldDescription = fieldSchema.description || ''; + const currentValue = values[fieldName]; + const defaultValue = fieldSchema.default; + const effectiveValue = currentValue !== undefined ? currentValue : defaultValue; + + // Detect secret fields (pydantic SecretStr, SecretBytes) + const isSecret = fieldSchema.format === 'password' || fieldSchema.writeOnly === true; + if (isSecret) { + secretFields.add(fieldName); + } + + let effectiveType = fieldType; + if (typeof effectiveValue === 'object' && effectiveValue !== null) { + effectiveType = Array.isArray(effectiveValue) ? 'array' : 'object'; + } + + if (effectiveType === 'array' || effectiveType === 'object') { + const treeNode = renderTreeNode(fieldName, fieldTitle, effectiveType, fieldSchema, effectiveValue, fieldDescription, false); + configForm.appendChild(treeNode); + } else { + const fieldDiv = document.createElement('div'); + fieldDiv.className = 'config-field'; + if (isSecret) { + fieldDiv.classList.add('secret-field'); + } + + if (fieldType === 'boolean') { + const checkboxLabel = document.createElement('label'); + checkboxLabel.className = 'checkbox-label'; + + const input = document.createElement('input'); + input.type = 'checkbox'; + input.id = `config_${fieldName}`; + input.name = fieldName; + input.dataset.configPath = fieldName; + input.dataset.configType = 'boolean'; + input.checked = effectiveValue || false; + + checkboxLabel.appendChild(input); + checkboxLabel.appendChild(document.createTextNode(fieldTitle)); + fieldDiv.appendChild(checkboxLabel); + } else { + const label = document.createElement('label'); + label.htmlFor = `config_${fieldName}`; + label.textContent = fieldTitle + (isSecret ? ' [secret]' : ''); + fieldDiv.appendChild(label); + + if (fieldSchema.enum && Array.isArray(fieldSchema.enum)) { + const select = document.createElement('select'); + select.id = `config_${fieldName}`; + select.name = fieldName; + select.dataset.configPath = fieldName; + select.dataset.configType = fieldType; + + fieldSchema.enum.forEach(enumValue => { + const option = document.createElement('option'); + option.value = enumValue; + option.textContent = enumValue; + if (effectiveValue === enumValue) option.selected = true; + select.appendChild(option); + }); + + fieldDiv.appendChild(select); + } else { + const input = document.createElement('input'); + input.id = `config_${fieldName}`; + input.name = fieldName; + input.dataset.configPath = fieldName; + input.dataset.configType = fieldType; + if (isSecret) { + input.dataset.isSecret = 'true'; + } + + if (fieldType === 'integer' || fieldType === 'number') { + input.type = 'number'; + if (fieldType === 'number') input.step = 'any'; + } else if (isSecret) { + input.type = 'password'; + input.placeholder = 'Enter new value to change'; + } else { + input.type = 'text'; + } + + input.value = effectiveValue !== undefined ? effectiveValue : ''; + fieldDiv.appendChild(input); + } + } + + if (fieldDescription) { + const desc = document.createElement('div'); + desc.className = 'field-description'; + desc.textContent = fieldDescription; + fieldDiv.appendChild(desc); + } + + configForm.appendChild(fieldDiv); + } + } +} + +function renderTreeNode(path, label, type, schema, value, description, isNested) { + const nodeId = `tree_${treeNodeIdCounter++}`; + const node = document.createElement('div'); + node.className = 'config-tree-node' + (isNested ? ' nested' : ''); + node.dataset.treePath = path; + node.dataset.treeType = type; + + const header = document.createElement('div'); + header.className = 'config-tree-header'; + + const toggle = document.createElement('button'); + toggle.className = 'config-tree-toggle'; + toggle.textContent = '\u25BC'; + toggle.type = 'button'; + toggle.onclick = (e) => { + e.stopPropagation(); + const children = node.querySelector(':scope > .config-tree-children'); + if (children) { + const isCollapsed = children.classList.toggle('collapsed'); + toggle.textContent = isCollapsed ? '\u25B6' : '\u25BC'; + const addRow = node.querySelector(':scope > .config-tree-add-row'); + if (addRow) addRow.style.display = isCollapsed ? 'none' : 'flex'; + } + }; + header.appendChild(toggle); + + const labelSpan = document.createElement('span'); + labelSpan.className = 'config-tree-label'; + labelSpan.textContent = label; + header.appendChild(labelSpan); + + const typeBadge = document.createElement('span'); + typeBadge.className = 'config-tree-type-badge'; + typeBadge.textContent = type; + header.appendChild(typeBadge); + + node.appendChild(header); + + const children = document.createElement('div'); + children.className = 'config-tree-children'; + + if (type === 'array' && Array.isArray(value)) { + const itemSchema = schema.items || {}; + const itemType = itemSchema.type || 'string'; + value.forEach((v, idx) => { + addArrayItemElement(children, path, idx, itemSchema, itemType, v); + }); + } else if (type === 'object' && value && typeof value === 'object') { + const props = schema.properties || {}; + for (const [key, val] of Object.entries(value)) { + const propSchema = props[key] || {}; + const propType = propSchema.type || (typeof val === 'object' ? (Array.isArray(val) ? 'array' : 'object') : typeof val); + addObjectPropertyElement(children, path, key, propSchema, propType, val, !!props[key]); + } + } + + node.appendChild(children); + + // Add "Add" row for objects and arrays + if (type === 'object') { + const addRow = createAddPropertyRow(node, children, path); + node.appendChild(addRow); + } else if (type === 'array') { + const addRow = createAddArrayItemRow(node, children, path, schema.items || {}); + node.appendChild(addRow); + } + + return node; +} + +function createAddArrayItemRow(node, childrenContainer, basePath, itemSchema) { + const addRow = document.createElement('div'); + addRow.className = 'config-tree-add-row'; + + const itemType = itemSchema.type || 'string'; + + const typeSelect = document.createElement('select'); + typeSelect.className = 'config-tree-type-select'; + typeSelect.innerHTML = ` + + + + + `; + + const valueInput = document.createElement('input'); + valueInput.type = 'text'; + valueInput.placeholder = 'New item value'; + valueInput.style.cssText = 'flex:1;min-width:150px;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.1);border-radius:4px;padding:4px 8px;color:#eee;font-size:0.85em;'; + + const addBtn = document.createElement('button'); + addBtn.className = 'config-tree-btn add'; + addBtn.textContent = '+'; + addBtn.title = 'Add item'; + addBtn.onclick = () => { + const selectedType = typeSelect.value; + let value = valueInput.value; + + // Convert value based on type + if (selectedType === 'integer') { + value = parseInt(value, 10) || 0; + } else if (selectedType === 'number') { + value = parseFloat(value) || 0; + } else if (selectedType === 'boolean') { + value = value.toLowerCase() === 'true' || value === '1'; + } + + // Find the next index + const existingItems = childrenContainer.querySelectorAll('[data-array-index]'); + const nextIndex = existingItems.length; + + // Add the array item element + addArrayItemElement(childrenContainer, basePath, nextIndex, itemSchema, selectedType, value); + + // Clear input + valueInput.value = ''; + }; + + addRow.appendChild(typeSelect); + addRow.appendChild(valueInput); + addRow.appendChild(addBtn); + + return addRow; +} + +function createAddPropertyRow(node, childrenContainer, basePath) { + const addRow = document.createElement('div'); + addRow.className = 'config-tree-add-row'; + + const keyInput = document.createElement('input'); + keyInput.type = 'text'; + keyInput.placeholder = 'Key'; + keyInput.style.cssText = 'flex:1;min-width:80px;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.1);border-radius:4px;padding:4px 8px;color:#eee;font-size:0.85em;'; + + const typeSelect = document.createElement('select'); + typeSelect.className = 'config-tree-type-select'; + typeSelect.innerHTML = ` + + + + + `; + + const valueInput = document.createElement('input'); + valueInput.type = 'text'; + valueInput.placeholder = 'Value'; + valueInput.style.cssText = 'flex:2;min-width:100px;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.1);border-radius:4px;padding:4px 8px;color:#eee;font-size:0.85em;'; + + const addBtn = document.createElement('button'); + addBtn.className = 'config-tree-btn add'; + addBtn.textContent = '+'; + addBtn.title = 'Add property'; + addBtn.onclick = () => { + const key = keyInput.value.trim(); + if (!key) { + showToast('Property key is required', 'error'); + return; + } + + // Check if key already exists + const existingItem = childrenContainer.querySelector(`[data-property-key="${key}"]`); + if (existingItem) { + showToast('Property already exists', 'error'); + return; + } + + const propType = typeSelect.value; + let value = valueInput.value; + + // Convert value based on type + if (propType === 'integer') { + value = parseInt(value, 10) || 0; + } else if (propType === 'number') { + value = parseFloat(value) || 0; + } else if (propType === 'boolean') { + value = value.toLowerCase() === 'true' || value === '1'; + } + + // Add the property element + addObjectPropertyElement(childrenContainer, basePath, key, {}, propType, value, false); + + // Clear inputs + keyInput.value = ''; + valueInput.value = ''; + }; + + addRow.appendChild(keyInput); + addRow.appendChild(typeSelect); + addRow.appendChild(valueInput); + addRow.appendChild(addBtn); + + return addRow; +} + +function addArrayItemElement(container, basePath, index, itemSchema, itemType, value) { + const itemPath = `${basePath}[${index}]`; + + let effectiveType = itemType; + if (typeof value === 'object' && value !== null) { + effectiveType = Array.isArray(value) ? 'array' : 'object'; + } + + if (effectiveType === 'object' || effectiveType === 'array') { + const nestedNode = renderTreeNode(itemPath, `[${index}]`, effectiveType, itemSchema, value, '', true); + nestedNode.dataset.arrayIndex = index; + + // Add delete button to nested node header + const header = nestedNode.querySelector('.config-tree-header'); + if (header) { + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'config-tree-btn remove'; + deleteBtn.textContent = '\u00D7'; + deleteBtn.title = 'Remove item'; + deleteBtn.style.marginLeft = 'auto'; + deleteBtn.onclick = (e) => { + e.stopPropagation(); + nestedNode.remove(); + reindexArrayItems(container, basePath); + }; + header.appendChild(deleteBtn); + } + + container.appendChild(nestedNode); + } else { + const itemDiv = document.createElement('div'); + itemDiv.className = 'config-tree-item'; + itemDiv.dataset.arrayIndex = index; + + const keySpan = document.createElement('div'); + keySpan.className = 'config-tree-item-key'; + keySpan.textContent = `[${index}]`; + itemDiv.appendChild(keySpan); + + const valueDiv = document.createElement('div'); + valueDiv.className = 'config-tree-item-value'; + + const input = createValueInput(itemPath, itemType, value, itemSchema); + valueDiv.appendChild(input); + itemDiv.appendChild(valueDiv); + + // Add delete button + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'config-tree-actions'; + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'config-tree-btn remove'; + deleteBtn.textContent = '\u00D7'; + deleteBtn.title = 'Remove item'; + deleteBtn.onclick = () => { + itemDiv.remove(); + reindexArrayItems(container, basePath); + }; + actionsDiv.appendChild(deleteBtn); + itemDiv.appendChild(actionsDiv); + + container.appendChild(itemDiv); + } +} + +function reindexArrayItems(container, basePath) { + // Re-index all array items after deletion + const items = container.querySelectorAll(':scope > [data-array-index]'); + items.forEach((item, newIndex) => { + item.dataset.arrayIndex = newIndex; + + // Update the key label + const keySpan = item.querySelector('.config-tree-item-key, .config-tree-label'); + if (keySpan) { + keySpan.textContent = `[${newIndex}]`; + } + + // Update the input path + const input = item.querySelector('[data-config-path]'); + if (input) { + input.dataset.configPath = `${basePath}[${newIndex}]`; + } + + // Update nested node path + if (item.dataset.treePath) { + item.dataset.treePath = `${basePath}[${newIndex}]`; + } + }); +} + +function addObjectPropertyElement(container, basePath, key, keySchema, valueType, value, isFixed) { + const itemPath = key ? `${basePath}.${key}` : basePath; + + let effectiveType = valueType; + if (typeof value === 'object' && value !== null) { + effectiveType = Array.isArray(value) ? 'array' : 'object'; + } + + if (effectiveType === 'object' || effectiveType === 'array') { + const nestedNode = renderTreeNode(itemPath, key || '(new)', effectiveType, keySchema, value, '', true); + nestedNode.dataset.propertyKey = key; + container.appendChild(nestedNode); + } else { + const itemDiv = document.createElement('div'); + itemDiv.className = 'config-tree-item'; + itemDiv.dataset.propertyKey = key; + + const keyDiv = document.createElement('div'); + keyDiv.className = 'config-tree-item-key'; + keyDiv.textContent = key; + itemDiv.appendChild(keyDiv); + + const valueDiv = document.createElement('div'); + valueDiv.className = 'config-tree-item-value'; + + const input = createValueInput(itemPath, valueType, value, keySchema); + valueDiv.appendChild(input); + itemDiv.appendChild(valueDiv); + + // Add delete button for dynamically added properties (not fixed schema properties) + if (!isFixed) { + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'config-tree-actions'; + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'config-tree-btn remove'; + deleteBtn.textContent = '\u00D7'; + deleteBtn.title = 'Remove property'; + deleteBtn.onclick = () => { + itemDiv.remove(); + }; + actionsDiv.appendChild(deleteBtn); + itemDiv.appendChild(actionsDiv); + } + + container.appendChild(itemDiv); + } +} + +function createValueInput(path, type, value, schema = null) { + if (schema && schema.enum && Array.isArray(schema.enum)) { + const select = document.createElement('select'); + select.dataset.configPath = path; + select.dataset.configType = type; + + schema.enum.forEach(enumValue => { + const option = document.createElement('option'); + option.value = enumValue; + option.textContent = enumValue; + if (value === enumValue) option.selected = true; + select.appendChild(option); + }); + + return select; + } + + const input = document.createElement('input'); + input.dataset.configPath = path; + input.dataset.configType = type; + + if (type === 'boolean') { + input.type = 'checkbox'; + input.checked = !!value; + } else if (type === 'integer') { + input.type = 'number'; + input.step = '1'; + input.value = value !== undefined ? value : 0; + } else if (type === 'number') { + input.type = 'number'; + input.step = 'any'; + input.value = value !== undefined ? value : 0; + } else { + input.type = 'text'; + input.value = value !== undefined ? String(value) : ''; + } + + return input; +} + +function collectConfigValues(onlyModified = true) { + const form = document.getElementById('panelConfigForm'); + const values = {}; + const SECRET_MASK = '**********'; + + // Collect values from inputs + form.querySelectorAll('[data-config-path]').forEach(input => { + const path = input.dataset.configPath; + const type = input.dataset.configType; + const isSecret = input.dataset.isSecret === 'true'; + let value; + + if (input.type === 'checkbox') { + value = input.checked; + } else if (type === 'integer') { + value = parseInt(input.value, 10) || 0; + } else if (type === 'number') { + value = parseFloat(input.value) || 0; + } else if (type === 'boolean') { + value = input.value === 'true' || input.value === '1'; + } else { + value = input.value; + } + + // Skip secret fields that still have the masked value + if (isSecret && value === SECRET_MASK) { + return; // Don't include unchanged secrets + } + + // Only include modified fields if onlyModified is true + if (onlyModified) { + const originalValue = getNestedValue(originalConfigValues, path); + // For secrets, if original was masked and new value is different, include it + if (isSecret) { + if (value !== SECRET_MASK && value !== '') { + setNestedValue(values, path, value); + } + } else if (!deepEqual(value, originalValue)) { + setNestedValue(values, path, value); + } + } else { + // Skip masked secrets even when not in onlyModified mode + if (!(isSecret && value === SECRET_MASK)) { + setNestedValue(values, path, value); + } + } + }); + + // Handle arrays and objects that might be empty or have modified structure + form.querySelectorAll('.config-tree-node[data-tree-type]').forEach(node => { + const path = node.dataset.treePath; + const type = node.dataset.treeType; + + // Only process top-level nodes (not nested) + if (node.classList.contains('nested')) return; + + if (type === 'array') { + // Collect all array items + const children = node.querySelector('.config-tree-children'); + const items = children ? children.querySelectorAll(':scope > [data-array-index]') : []; + const arrayValues = []; + + items.forEach((item, idx) => { + // Check if this item has a direct input + const input = item.querySelector('[data-config-path]'); + if (input) { + const itemType = input.dataset.configType; + let itemValue; + if (input.type === 'checkbox') { + itemValue = input.checked; + } else if (itemType === 'integer') { + itemValue = parseInt(input.value, 10) || 0; + } else if (itemType === 'number') { + itemValue = parseFloat(input.value) || 0; + } else { + itemValue = input.value; + } + arrayValues.push(itemValue); + } else if (item.dataset.treeType) { + // Nested object/array - collect recursively + const nestedValue = collectNestedTreeValue(item); + arrayValues.push(nestedValue); + } + }); + + // Check if array was modified + const originalArray = getNestedValue(originalConfigValues, path); + if (!onlyModified || !deepEqual(arrayValues, originalArray)) { + values[path] = arrayValues; + } + } else if (type === 'object') { + // Collect all object properties + const children = node.querySelector('.config-tree-children'); + const items = children ? children.querySelectorAll(':scope > [data-property-key]') : []; + const objectValues = {}; + + items.forEach(item => { + const key = item.dataset.propertyKey; + const input = item.querySelector('[data-config-path]'); + if (input) { + const itemType = input.dataset.configType; + let itemValue; + if (input.type === 'checkbox') { + itemValue = input.checked; + } else if (itemType === 'integer') { + itemValue = parseInt(input.value, 10) || 0; + } else if (itemType === 'number') { + itemValue = parseFloat(input.value) || 0; + } else { + itemValue = input.value; + } + objectValues[key] = itemValue; + } else if (item.dataset.treeType) { + // Nested object/array + objectValues[key] = collectNestedTreeValue(item); + } + }); + + // Check if object was modified + const originalObject = getNestedValue(originalConfigValues, path); + if (!onlyModified || !deepEqual(objectValues, originalObject)) { + values[path] = objectValues; + } + } + }); + + return values; +} + +function collectNestedTreeValue(node) { + const type = node.dataset.treeType; + const children = node.querySelector('.config-tree-children'); + + if (type === 'array') { + const items = children ? children.querySelectorAll(':scope > [data-array-index]') : []; + const arrayValues = []; + + items.forEach(item => { + const input = item.querySelector('[data-config-path]'); + if (input) { + const itemType = input.dataset.configType; + let itemValue; + if (input.type === 'checkbox') { + itemValue = input.checked; + } else if (itemType === 'integer') { + itemValue = parseInt(input.value, 10) || 0; + } else if (itemType === 'number') { + itemValue = parseFloat(input.value) || 0; + } else { + itemValue = input.value; + } + arrayValues.push(itemValue); + } else if (item.dataset.treeType) { + arrayValues.push(collectNestedTreeValue(item)); + } + }); + + return arrayValues; + } else if (type === 'object') { + const items = children ? children.querySelectorAll(':scope > [data-property-key]') : []; + const objectValues = {}; + + items.forEach(item => { + const key = item.dataset.propertyKey; + const input = item.querySelector('[data-config-path]'); + if (input) { + const itemType = input.dataset.configType; + let itemValue; + if (input.type === 'checkbox') { + itemValue = input.checked; + } else if (itemType === 'integer') { + itemValue = parseInt(input.value, 10) || 0; + } else if (itemType === 'number') { + itemValue = parseFloat(input.value) || 0; + } else { + itemValue = input.value; + } + objectValues[key] = itemValue; + } else if (item.dataset.treeType) { + objectValues[key] = collectNestedTreeValue(item); + } + }); + + return objectValues; + } + + return null; +} + +function getNestedValue(obj, path) { + if (!obj) return undefined; + const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.'); + let current = obj; + for (const part of parts) { + if (current === undefined || current === null) return undefined; + current = current[part]; + } + return current; +} + +function deepEqual(a, b) { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== typeof b) return false; + if (typeof a !== 'object') return a === b; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every(key => deepEqual(a[key], b[key])); +} + +function setNestedValue(obj, path, value) { + const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.'); + let current = obj; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + const nextPart = parts[i + 1]; + const isNextArray = /^\d+$/.test(nextPart); + + if (!(part in current)) { + current[part] = isNextArray ? [] : {}; + } + current = current[part]; + } + + const lastPart = parts[parts.length - 1]; + current[lastPart] = value; +} + +function updateOriginalConfigAfterSave() { + // Update original values with current form values after a successful save + // This ensures subsequent saves only track new changes + const currentValues = collectConfigValues(false); // Get all values, not just modified + for (const [key, value] of Object.entries(currentValues)) { + setNestedValue(originalConfigValues, key, value); + } +} + +function updateConfigTargetDropdown() { + const select = document.getElementById('configTargetFile'); + if (!select) return; + + // Remember current selection + const currentValue = select.value; + + // Clear and rebuild options + select.innerHTML = ''; + + if (configTargets.length === 0) { + // Fallback if no targets available + select.innerHTML = ''; + } else { + configTargets.forEach(target => { + const option = document.createElement('option'); + option.value = target.id; + option.textContent = target.label + (target.exists ? '' : ' (new)'); + option.title = target.path; + select.appendChild(option); + }); + } + + // Restore selection if still valid + if (currentValue && Array.from(select.options).some(o => o.value === currentValue)) { + select.value = currentValue; + } +} + +function saveConfig() { + if (!currentPanelItem) return; + + const values = collectConfigValues(); + const targetFile = document.getElementById('configTargetFile')?.value || 'yaml'; + + // Check if any fields were modified + if (Object.keys(values).length === 0) { + showToast('No changes to save', 'info'); + return; + } + + if (ws && ws.readyState === WebSocket.OPEN) { + const message = { + action: 'save_config', + name: currentPanelItem.name, + config: values, + target_file: targetFile + }; + // Include prefix for components with multiple configs + if (currentConfigPrefix) { + message.prefix = currentConfigPrefix; + } + ws.send(JSON.stringify(message)); + } +} + +// ==================== Enable/Disable with Optimistic UI ==================== +function enableItem(name, kind) { + // Optimistic UI: immediately show "initializing" state + setOptimisticState(name, { is_initialized: false, is_initializing: true, is_shutting_down: false }); + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'enable_plugin', name })); + } +} + +function disableItem(name, kind) { + // Optimistic UI: immediately show "shutting_down" state + setOptimisticState(name, { is_initialized: true, is_initializing: false, is_shutting_down: true }); + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'disable_plugin', name })); + } +} + +function setOptimisticState(name, state) { + // Update component state optimistically for immediate UI feedback + const idx = currentComponents.findIndex(c => c.name === name); + if (idx >= 0) { + currentComponents[idx] = { ...currentComponents[idx], state }; + } + const pidx = currentPlugins.findIndex(p => p.name === name); + if (pidx >= 0) { + currentPlugins[pidx] = { ...currentPlugins[pidx], state }; + } + renderAllSections(name); + if (selectedComponent === name) { + selectComponent(selectedComponent); + } +} + +// ==================== Plugin Upload ==================== +function uploadPluginFile() { + const input = document.getElementById('pluginFile'); + const status = document.getElementById('uploadStatus'); + if (!input.files.length) return; + + const file = input.files[0]; + const reader = new FileReader(); + status.textContent = 'Uploading...'; + status.className = 'upload-status uploading'; + + reader.onload = () => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + action: 'upload_plugin', + type: 'file', + filename: file.name, + content: reader.result.split(',')[1] + })); + } + input.value = ''; + }; + reader.readAsDataURL(file); +} + +function uploadPluginDirectory() { + const input = document.getElementById('pluginDir'); + const status = document.getElementById('uploadStatus'); + if (!input.files.length) return; + + const files = []; + let loaded = 0; + const total = input.files.length; + status.textContent = `Reading ${total} files...`; + status.className = 'upload-status uploading'; + + const dirname = input.files[0].webkitRelativePath.split('/')[0]; + + Array.from(input.files).forEach(file => { + const reader = new FileReader(); + reader.onload = () => { + files.push({ + path: file.webkitRelativePath, + content: reader.result.split(',')[1] + }); + loaded++; + if (loaded === total) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + action: 'upload_plugin', + type: 'directory', + dirname, + files + })); + } + input.value = ''; + } + }; + reader.readAsDataURL(file); + }); +} + +// ==================== Logs ==================== +function formatTime(timestamp) { + if (timestamp === undefined || timestamp === null) { + return '--:--:--'; + } + const date = new Date(timestamp * 1000); + if (isNaN(date.getTime())) { + return '--:--:--'; + } + return date.toLocaleTimeString('en-US', { hour12: false }); +} + +function renderLogs() { + const container = document.getElementById('logsContainer'); + const searchText = (document.getElementById('logSearch')?.value || '').toLowerCase(); + const levelFilter = document.getElementById('logLevel')?.value || ''; + + const filtered = currentLogs.filter(log => { + if (levelFilter && log.level !== levelFilter) return false; + if (searchText) { + const searchable = `${log.component} ${log.message}`.toLowerCase(); + if (!searchable.includes(searchText)) return false; + } + return true; + }); + + document.getElementById('logCount').textContent = `${filtered.length} logs`; + + if (filtered.length === 0) { + container.innerHTML = '
No logs match filters
'; + return; + } + + container.innerHTML = filtered.map(log => ` +
+ ${formatTime(log.timestamp)} + ${log.level} + ${escapeHtml(log.component)} + ${escapeHtml(log.message)} +
+ `).join(''); + + if (document.getElementById('autoScroll')?.checked) { + container.scrollTop = container.scrollHeight; + } +} + +function addLogEntry(log) { + if (!log) return; + currentLogs.push(log); + if (currentLogs.length > 1000) currentLogs.shift(); + renderLogs(); +} + +function filterLogs() { + renderLogs(); +} + +function clearLogs() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'clear_logs' })); + } +} + +// ==================== Toast Notifications ==================== +function showToast(message, type = 'info') { + const container = document.getElementById('toastContainer'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.innerHTML = `${escapeHtml(message)}`; + container.appendChild(toast); + + requestAnimationFrame(() => toast.classList.add('show')); + + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 4000); +} + +// ==================== Drag and Drop Handlers ==================== +function handleDragOver(event) { + event.preventDefault(); + event.stopPropagation(); + const dropzone = document.getElementById('uploadDropzone'); + dropzone.classList.add('dragover'); +} + +function handleDragLeave(event) { + event.preventDefault(); + event.stopPropagation(); + const dropzone = document.getElementById('uploadDropzone'); + dropzone.classList.remove('dragover'); +} + +function handleDrop(event) { + event.preventDefault(); + event.stopPropagation(); + const dropzone = document.getElementById('uploadDropzone'); + dropzone.classList.remove('dragover'); + + const items = event.dataTransfer.items; + const files = event.dataTransfer.files; + + if (items && items.length > 0) { + // Check if it's a directory (using webkitGetAsEntry) + const firstItem = items[0]; + if (firstItem.webkitGetAsEntry) { + const entry = firstItem.webkitGetAsEntry(); + if (entry && entry.isDirectory) { + // Handle directory drop + handleDirectoryDrop(entry); + return; + } + } + } + + // Handle file(s) drop + if (files && files.length > 0) { + handleFilesDrop(files); + } +} + +function handleFilesDrop(files) { + const status = document.getElementById('uploadStatus'); + const pyFiles = Array.from(files).filter(f => f.name.endsWith('.py')); + + if (pyFiles.length === 0) { + status.textContent = 'No .py files found'; + status.className = 'upload-status error'; + return; + } + + status.textContent = `Uploading ${pyFiles.length} file(s)...`; + status.className = 'upload-status'; + + let uploaded = 0; + pyFiles.forEach(file => { + const reader = new FileReader(); + reader.onload = () => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + action: 'upload_plugin', + type: 'file', + filename: file.name, + content: reader.result.split(',')[1] + })); + } + uploaded++; + if (uploaded === pyFiles.length) { + status.textContent = `Uploaded ${uploaded} file(s)`; + status.className = 'upload-status success'; + } + }; + reader.readAsDataURL(file); + }); +} + +function handleDirectoryDrop(directoryEntry) { + const status = document.getElementById('uploadStatus'); + status.textContent = 'Reading directory...'; + status.className = 'upload-status'; + + const files = []; + const dirName = directoryEntry.name; + + function readEntries(reader, path) { + return new Promise((resolve) => { + reader.readEntries(async (entries) => { + if (entries.length === 0) { + resolve(); + return; + } + for (const entry of entries) { + if (entry.isFile) { + const file = await getFile(entry); + files.push({ + path: path + '/' + entry.name, + file: file + }); + } else if (entry.isDirectory) { + const newReader = entry.createReader(); + await readEntries(newReader, path + '/' + entry.name); + } + } + // Keep reading until no more entries + await readEntries(reader, path); + resolve(); + }); + }); + } + + function getFile(entry) { + return new Promise((resolve) => { + entry.file(resolve); + }); + } + + const reader = directoryEntry.createReader(); + readEntries(reader, dirName).then(() => { + if (files.length === 0) { + status.textContent = 'No files found in directory'; + status.className = 'upload-status error'; + return; + } + + status.textContent = `Reading ${files.length} files...`; + let loaded = 0; + const fileData = []; + + files.forEach(({ path, file }) => { + const reader = new FileReader(); + reader.onload = () => { + fileData.push({ + path: path, + content: reader.result.split(',')[1] + }); + loaded++; + if (loaded === files.length) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + action: 'upload_plugin', + type: 'directory', + dirname: dirName, + files: fileData + })); + } + status.textContent = `Uploaded directory: ${dirName}`; + status.className = 'upload-status success'; + } + }; + reader.readAsDataURL(file); + }); + }); +} + +// ==================== Unregister Plugin ==================== +function unregisterPlugin(name) { + if (!confirm(`Are you sure you want to unregister plugin "${name}"?\n\nThis will remove it from the current session.`)) { + return; + } + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + action: 'unregister_plugin', + name: name + })); + } + + // Clear selection if we just unregistered the selected item + if (selectedComponent === name) { + selectedComponent = null; + document.getElementById('panelTitle').textContent = 'Select a component'; + document.getElementById('panelVersion').textContent = ''; + document.getElementById('panelDesc').textContent = 'Select a component from the left panel to view its details.'; + document.getElementById('panelActions').innerHTML = ''; + } +} + +// ==================== Save/Sync Plugins ==================== +function savePlugins() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + action: 'save_plugins' + })); + } +} + +function syncPlugins() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + action: 'sync_plugins' + })); + } +} + +// ==================== Pot Browser ==================== +let potBrowserVisible = false; +let currentPots = []; +let selectedPotName = null; + +function togglePotBrowser() { + potBrowserVisible = !potBrowserVisible; + const section = document.getElementById('potBrowserSection'); + section.style.display = potBrowserVisible ? 'block' : 'none'; + if (potBrowserVisible) { + refreshPots(); + } +} + +function refreshPots() { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'list_pots' })); + showToast('Refreshing pots...', 'info'); + } +} + +function handlePotsResponse(data) { + if (data.type === 'error') { + showToast(data.error, 'error'); + return; + } + currentPots = data.pots || []; + document.getElementById('potCount').textContent = currentPots.length; + renderPotsList(); +} + +function renderPotsList() { + const container = document.getElementById('potsList'); + if (currentPots.length === 0) { + container.innerHTML = '
No pots found.
Use awioc pot init to create one.
'; + return; + } + + container.innerHTML = currentPots.map(pot => ` +
+
+
+ ${escapeHtml(pot.name)} +
+
v${escapeHtml(pot.version)}
+
+
+ ${pot.component_count} component(s) +
+ ${pot.description ? `
${escapeHtml(pot.description)}
` : ''} +
+ `).join(''); +} + +function selectPot(potName) { + selectedPotName = potName; + renderPotsList(); + // Fetch components for this pot + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'list_pot_components', pot_name: potName })); + } +} + +function handlePotComponentsResponse(data) { + if (data.type === 'error') { + showToast(data.error, 'error'); + return; + } + + const container = document.getElementById('potComponentsList'); + container.style.display = 'block'; + + const components = data.components || []; + if (components.length === 0) { + container.innerHTML = ` +
+ ${escapeHtml(data.pot_name)} has no components.
+ Use awioc pot push to add components. +
`; + return; + } + + container.innerHTML = ` +
+ ${escapeHtml(data.pot_name)} + v${escapeHtml(data.pot_version)} +
${components.length} component(s)
+
+ ${components.map(comp => ` +
+
+
${escapeHtml(comp.name)}
+
v${escapeHtml(comp.version)}
+
+ ${comp.description ? `
${escapeHtml(comp.description)}
` : ''} +
+ + ${escapeHtml(comp.pot_ref)} + + ${comp.is_registered + ? 'Already registered' + : `` + } +
+
+ `).join('')} + `; +} + +function registerPotComponent(potName, componentId) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + action: 'register_pot_component', + pot_name: potName, + component_name: componentId + })); + showToast(`Registering @${potName}/${componentId}...`, 'info'); + } +} + +// Refresh pot components after state changes (to update "Already registered" status) +function refreshSelectedPotComponents() { + if (selectedPotName && potBrowserVisible && ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'list_pot_components', pot_name: selectedPotName })); + } +} + +// ==================== Initialize ==================== +connectWebSocket(); diff --git a/samples/management_dashboard/static/index.html b/samples/management_dashboard/static/index.html new file mode 100644 index 0000000..7e0bbf1 --- /dev/null +++ b/samples/management_dashboard/static/index.html @@ -0,0 +1,267 @@ + + + + + IOC Management Dashboard + + + + + +
+
+

IOC Management Dashboard

+
+
+
+ App: + - +
+
+ Version: + - +
+
+ Config: + - +
+
+
+
+ 0 + Components +
+
+ 0 + Plugins +
+
+
+ + Connecting... +
+
+ + +
+ +
+
+

Components

+
+ + +
+
+
+ +
+

Application

+ 0 +
+
+ + +
+

Libraries

+ 0 +
+
+ + +
+

Plugins

+ 0 +
+
+ + + +
+ +
+ + + +
+
+
+
+ Drop plugin files here
+ or click to browse +
+
+ +
+ + +
+ + + +
+ + +
+
+ + +
+

Pot Browser

+ 0 +
+ +
+
+ + +
+
+ + +
+ + +
+
+
+
+
Select a component
+
+
+
+
+ +
+
Description
+
Select a component from the left panel to view its details. +
+
+ +
+
Details
+
+
+ Kind + - +
+
+ Type + - +
+
+ Status + - +
+
+ Auto-wire + - +
+
+
+ +
+
Dependencies
+
+ No dependencies +
+
+ +
+
Registration
+
+
+ Registered by + - +
+
+ Registered at + - +
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ 0 logs + + + +
+
+
+
No logs yet...
+
+
+
+
+
+ +
+ + + + diff --git a/samples/management_dashboard/static/styles.css b/samples/management_dashboard/static/styles.css new file mode 100644 index 0000000..3290734 --- /dev/null +++ b/samples/management_dashboard/static/styles.css @@ -0,0 +1,746 @@ +/* IOC Management Dashboard Styles */ + +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #1a1a2e; + color: #eee; + height: 100vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* ========== TOP PANEL ========== */ +.top-panel { + background: linear-gradient(180deg, #0d1a2d 0%, #132041 100%); + border-bottom: 1px solid rgba(0, 217, 255, 0.15); + padding: 16px 24px; + display: flex; + align-items: center; + gap: 24px; + flex-shrink: 0; +} +.app-branding { + display: flex; + align-items: center; + gap: 16px; +} +.app-branding h1 { + color: #00d9ff; + font-size: 1.4em; + font-weight: 700; +} +.app-info { + flex: 1; + display: flex; + align-items: center; + gap: 20px; +} +.app-info-item { + display: flex; + align-items: center; + gap: 8px; +} +.app-info-item .label { + color: #666; + font-size: 0.8em; + text-transform: uppercase; +} +.app-info-item .value { + color: #ccc; + font-size: 0.95em; + font-weight: 500; +} +.app-stats { + display: flex; + gap: 16px; +} +.stat-badge { + background: rgba(0, 217, 255, 0.1); + padding: 8px 14px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 8px; +} +.stat-badge .stat-value { + color: #00d9ff; + font-weight: 700; + font-size: 1.1em; +} +.stat-badge .stat-label { + color: #888; + font-size: 0.8em; +} +.connection-status { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: 6px; + font-size: 0.9em; +} +.connection-status.connected { background: rgba(0, 200, 83, 0.12); color: #00c853; } +.connection-status.disconnected { background: rgba(255, 82, 82, 0.08); color: #ff5252; } +.connection-status.connecting { background: rgba(255, 193, 7, 0.08); color: #ffc107; } +.status-dot { width: 10px; height: 10px; border-radius: 50%; } +.status-dot.connected { background: #00c853; } +.status-dot.disconnected { background: #ff5252; } +.status-dot.connecting { background: #ffc107; animation: pulse 1s infinite; } +@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } + +/* ========== MAIN LAYOUT ========== */ +.main-layout { + flex: 1; + display: flex; + overflow: hidden; +} + +/* ========== LEFT PANEL (Components) ========== */ +.left-panel { + width: 380px; + min-width: 300px; + max-width: 500px; + background: #16213e; + border-right: 1px solid rgba(255,255,255,0.06); + display: flex; + flex-direction: column; + overflow: hidden; +} +.left-panel-header { + padding: 16px; + border-bottom: 1px solid rgba(255,255,255,0.06); + flex-shrink: 0; +} +.left-panel-header h2 { + color: #00d9ff; + font-size: 1em; + margin-bottom: 12px; +} +.filters-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.filters-row input[type="text"], +.filters-row select { + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + padding: 8px 12px; + color: #eee; + font-size: 0.9em; + flex: 1; + min-width: 100px; +} +.filters-row input::placeholder { color: #666; } +.filters-row select { cursor: pointer; } +.left-panel-content { + flex: 1; + overflow-y: auto; + padding: 16px; +} +.left-panel-content::-webkit-scrollbar { width: 8px; } +.left-panel-content::-webkit-scrollbar-track { background: transparent; } +.left-panel-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; } + +/* Section headers in left panel */ +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + margin-top: 16px; +} +.section-header:first-child { margin-top: 0; } +.section-header h3 { + color: #888; + font-size: 0.75em; + text-transform: uppercase; + letter-spacing: 1px; +} +.section-count { + background: rgba(0, 217, 255, 0.15); + color: #00d9ff; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.75em; + font-weight: 600; +} + +/* Component cards in left panel */ +.component-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.component-card { + background: rgba(0,0,0,0.2); + border-radius: 8px; + padding: 12px; + cursor: pointer; + border: 2px solid transparent; + transition: all 0.15s ease; +} +.component-card:hover { + background: rgba(0, 217, 255, 0.05); + border-color: rgba(0, 217, 255, 0.2); +} +.component-card.selected { + background: rgba(0, 217, 255, 0.1); + border-color: #00d9ff; +} +.component-card.disabled { + opacity: 0.6; +} +.component-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.component-card-name { + color: #eee; + font-weight: 600; + font-size: 0.95em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.component-card-version { + color: #666; + font-size: 0.8em; +} +.component-card-status { + display: inline-block; + padding: 3px 8px; + border-radius: 10px; + font-size: 0.7em; + font-weight: 700; +} +.component-card-status.active { background: #00c853; color: #000; } +.component-card-status.inactive { background: #ff5252; color: #fff; } +.component-card-status.initializing { background: #ffc107; color: #000; } +.component-card-status.shutting_down { background: #ff9800; color: #000; } + +/* Upload section */ +.upload-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(255,255,255,0.06); + display: flex; + flex-direction: column; + gap: 10px; +} +.upload-dropzone { + border: 2px dashed rgba(0, 217, 255, 0.3); + border-radius: 8px; + padding: 20px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + background: rgba(0, 0, 0, 0.15); +} +.upload-dropzone:hover, .upload-dropzone.dragover { + border-color: #00d9ff; + background: rgba(0, 217, 255, 0.08); +} +.upload-dropzone.dragover { + transform: scale(1.02); +} +.upload-dropzone-icon { + font-size: 2em; + margin-bottom: 8px; + opacity: 0.6; +} +.upload-dropzone-text { + color: #888; + font-size: 0.85em; + line-height: 1.4; +} +.upload-dropzone-text strong { + color: #00d9ff; +} +.upload-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.upload-section .btn { font-size: 0.8em; padding: 6px 10px; flex: 1; min-width: 80px; } +.upload-status { color: #888; font-size: 0.8em; } +.upload-status.success { color: #00c853; } +.upload-status.error { color: #ff5252; } +.plugin-actions { + display: flex; + gap: 8px; + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid rgba(255,255,255,0.04); +} + +/* ========== RIGHT PANEL (Tabs) ========== */ +.right-panel { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: #1a1a2e; +} +.tab-bar { + display: flex; + background: #16213e; + border-bottom: 1px solid rgba(255,255,255,0.06); + flex-shrink: 0; +} +.tab-btn { + padding: 14px 24px; + background: transparent; + border: none; + color: #888; + font-size: 0.95em; + font-weight: 600; + cursor: pointer; + border-bottom: 3px solid transparent; + transition: all 0.15s ease; +} +.tab-btn:hover { + color: #ccc; + background: rgba(0, 217, 255, 0.03); +} +.tab-btn.active { + color: #00d9ff; + border-bottom-color: #00d9ff; +} +.tab-content { + flex: 1; + overflow: hidden; + display: none; +} +.tab-content.active { + display: flex; + flex-direction: column; +} + +/* ========== DETAILS TAB ========== */ +.details-content { + flex: 1; + overflow-y: auto; + padding: 24px; +} +.details-content::-webkit-scrollbar { width: 8px; } +.details-content::-webkit-scrollbar-track { background: transparent; } +.details-content::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; } + +.details-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 24px; +} +.details-title { + color: #00d9ff; + font-size: 1.5em; + font-weight: 700; +} +.details-version { + color: #666; + font-size: 0.9em; + margin-top: 4px; +} +.details-actions { + display: flex; + gap: 10px; +} + +.panel-section { + margin-bottom: 24px; +} +.panel-section-title { + color: #888; + font-size: 0.75em; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 10px; + font-weight: 600; +} +.panel-desc { + color: #cfcfcf; + line-height: 1.6; + font-size: 0.95em; +} + +.panel-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} +.panel-meta-item { + background: rgba(0,0,0,0.2); + padding: 14px 16px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.04); +} +.panel-meta-item .label { + color: #666; + font-size: 0.75em; + text-transform: uppercase; + letter-spacing: 0.5px; + display: block; + margin-bottom: 4px; +} +.panel-meta-item .value { + color: #fff; + font-weight: 600; + font-size: 0.95em; +} + +.panel-state-box { + background: rgba(0,0,0,0.3); + padding: 14px; + border-radius: 8px; + font-family: 'Consolas', 'Monaco', monospace; + color: #aaa; + font-size: 0.85em; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +/* Status badges */ +.status { display: inline-block; padding: 6px 12px; border-radius: 12px; font-size: 0.85em; font-weight: 700; } +.status.active { background: #00c853; color: #000; } +.status.inactive { background: #ff5252; color: #fff; } +.status.initializing { background: #ffc107; color: #000; animation: pulse 1s infinite; } +.status.shutting_down { background: #ff9800; color: #000; animation: pulse 1s infinite; } + +.type-badge { display: inline-block; padding: 5px 10px; border-radius: 12px; font-size: 0.8em; font-weight: 700; } +.type-app { background: #7c4dff; color: #fff; } +.type-plugin { background: #00bcd4; color: #000; } +.type-library { background: #ff9800; color: #000; } + +/* Buttons */ +.btn { padding: 10px 16px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.9em; transition: all 0.15s ease; } +.btn:hover:not(:disabled) { transform: scale(1.02); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-enable { background: #00c853; color: #000; } +.btn-disable { background: #ff5252; color: #fff; } +.btn-secondary { background: #455a64; color: #fff; } +.btn-danger { background: #ff5252; color: #fff; } + +/* Configuration tabs for multiple configs */ +.config-tabs { + display: flex; + gap: 8px; + margin-bottom: 16px; + border-bottom: 1px solid rgba(255,255,255,0.1); + padding-bottom: 10px; +} +.config-tab { + padding: 8px 16px; + background: rgba(0,0,0,0.2); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px 6px 0 0; + color: #888; + cursor: pointer; + font-size: 0.9em; + transition: all 0.2s; +} +.config-tab:hover { + background: rgba(0,0,0,0.3); + color: #ccc; +} +.config-tab.active { + background: rgba(0, 217, 255, 0.1); + border-color: rgba(0, 217, 255, 0.3); + color: #00d9ff; +} +.config-prefix-badge { + font-size: 0.8em; + color: #888; + margin-left: 6px; +} + +/* Configuration form */ +.config-form { + display: flex; + flex-direction: column; + gap: 12px; +} +.config-field { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px; + background: rgba(0,0,0,0.15); + border-radius: 8px; + border-left: 3px solid rgba(0, 217, 255, 0.3); +} +.config-field label { + color: #ccc; + font-size: 0.9em; + font-weight: 500; +} +.config-field input, +.config-field select { + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + padding: 10px 12px; + color: #eee; + font-size: 0.95em; +} +.config-field input:focus, +.config-field select:focus { + outline: none; + border-color: rgba(0, 217, 255, 0.5); +} +.config-field input[type="checkbox"] { + width: auto; + margin-right: 8px; +} +.config-field .checkbox-label { + display: flex; + align-items: center; + color: #ccc; + font-size: 0.95em; +} +.config-field .field-description { + color: #888; + font-size: 0.8em; + line-height: 1.4; + margin-top: 4px; + padding: 6px 8px; + background: rgba(0, 217, 255, 0.05); + border-radius: 4px; + border-left: 2px solid rgba(0, 217, 255, 0.2); +} +.config-actions { + margin-top: 16px; + display: flex; + gap: 10px; + justify-content: flex-end; +} + +/* Tree structure for nested config */ +.config-tree-node { + position: relative; + margin-bottom: 8px; + background: rgba(0,0,0,0.1); + border-radius: 8px; + overflow: hidden; +} +.config-tree-node.nested { + margin-left: 20px; + border-left: 2px solid rgba(0, 217, 255, 0.2); +} +.config-tree-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: rgba(0,0,0,0.15); + cursor: pointer; +} +.config-tree-toggle { + background: none; + border: none; + color: #00d9ff; + font-size: 0.8em; + cursor: pointer; + padding: 4px; +} +.config-tree-label { + color: #ccc; + font-weight: 500; + font-size: 0.9em; +} +.config-tree-type-badge { + background: rgba(0, 217, 255, 0.15); + color: #00d9ff; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.7em; + font-weight: 600; +} +.config-tree-children { + padding: 8px 12px; +} +.config-tree-children.collapsed { + display: none; +} +.config-tree-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; +} +.config-tree-item-key { + color: #888; + font-size: 0.85em; + min-width: 80px; +} +.config-tree-item-value { + flex: 1; +} +.config-tree-item-value input, +.config-tree-item-value select { + width: 100%; + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 6px 10px; + color: #eee; + font-size: 0.85em; +} +.config-tree-actions { + display: flex; + gap: 4px; +} +.config-tree-btn { + background: rgba(255,255,255,0.1); + border: none; + color: #aaa; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + font-size: 0.8em; +} +.config-tree-btn:hover { background: rgba(255,255,255,0.15); } +.config-tree-btn.add { color: #00c853; } +.config-tree-btn.remove { color: #ff5252; } +.config-tree-add-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; + margin-top: 4px; + border-top: 1px solid rgba(255,255,255,0.05); +} +.config-tree-type-select { + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 4px; + padding: 4px 8px; + color: #00d9ff; + font-size: 0.8em; + cursor: pointer; +} + +/* ========== LOGS TAB ========== */ +.logs-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} +.logs-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #16213e; + border-bottom: 1px solid rgba(255,255,255,0.06); + flex-shrink: 0; + flex-wrap: wrap; + gap: 10px; +} +.logs-filters { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} +.logs-filters input[type="text"] { + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + padding: 8px 12px; + color: #eee; + min-width: 200px; + font-size: 0.9em; +} +.logs-filters input::placeholder { color: #666; } +.logs-filters select { + background: rgba(0,0,0,0.3); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + padding: 8px 12px; + color: #eee; + cursor: pointer; + font-size: 0.9em; +} +.logs-actions { + display: flex; + gap: 10px; + align-items: center; +} +.log-count { + background: rgba(0, 217, 255, 0.1); + color: #00d9ff; + padding: 6px 12px; + border-radius: 6px; + font-size: 0.85em; + font-weight: 600; +} +.auto-scroll-indicator { + font-size: 0.85em; + color: #888; + display: flex; + align-items: center; + gap: 6px; +} +.logs-container { + flex: 1; + overflow-y: auto; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.85em; + padding: 8px; + background: #0a0a1a; +} +.logs-container::-webkit-scrollbar { width: 10px; } +.logs-container::-webkit-scrollbar-track { background: #1a1a2e; } +.logs-container::-webkit-scrollbar-thumb { background: #333; border-radius: 6px; } +.log-entry { + padding: 8px 12px; + border-bottom: 1px solid #141424; + display: flex; + gap: 12px; + align-items: center; +} +.log-entry:hover { background: rgba(0, 217, 255, 0.03); } +.log-entry.new { animation: log-flash 0.5s ease; } +@keyframes log-flash { 0% { background: rgba(0, 217, 255, 0.12); } 100% { background: transparent; } } +.log-time { color: #666; white-space: nowrap; min-width: 80px; font-size: 0.9em; } +.log-level { padding: 3px 8px; border-radius: 4px; font-size: 0.8em; min-width: 60px; text-align: center; font-weight: 700; } +.log-level.DEBUG { background: #455a64; color: #fff; } +.log-level.INFO { background: #2196f3; color: #fff; } +.log-level.WARNING { background: #ffc107; color: #000; } +.log-level.ERROR { background: #ff5252; color: #fff; } +.log-level.CRITICAL { background: #d32f2f; color: #fff; } +.log-component { color: #00d9ff; min-width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } +.log-message { color: #bbb; flex: 1; word-break: break-word; line-height: 1.3; } +.no-logs { color: #666; text-align: center; padding: 40px; } + +/* Toast notifications */ +.toast-container { position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 1000; } +.toast { padding: 12px 18px; border-radius: 8px; color: #fff; font-size: 0.9em; opacity: 0; transform: translateX(100px); transition: all 0.3s ease; display: flex; align-items: center; gap: 10px; } +.toast.show { opacity: 1; transform: translateX(0); } +.toast.success { background: #00c853; } +.toast.error { background: #ff5252; } +.toast.info { background: #00bcd4; } +.toast.state-change { background: #7c4dff; } + +/* Responsive */ +@media (max-width: 1024px) { + .left-panel { width: 320px; min-width: 280px; } + .app-info { display: none; } +} +@media (max-width: 768px) { + .main-layout { flex-direction: column; } + .left-panel { width: 100%; max-width: none; height: 40vh; border-right: none; border-bottom: 1px solid rgba(255,255,255,0.06); } + .right-panel { height: 60vh; } + .top-panel { padding: 12px 16px; } + .app-stats { display: none; } +} diff --git a/setup.cfg b/setup.cfg index c38b7fa..0f8f348 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = awioc -version = 0.1.0 +version = 2.0.0 description = A Python IoC/DI library long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/awioc/__init__.py b/src/awioc/__init__.py index 1495958..19afa05 100644 --- a/src/awioc/__init__.py +++ b/src/awioc/__init__.py @@ -8,16 +8,18 @@ from .api import ( # Bootstrap initialize_ioc_app, - create_container, compile_ioc_app, reconfigure_ioc_app, - reload_configuration, # Lifecycle initialize_components, shutdown_components, wait_for_components, register_plugin, unregister_plugin, + # Events + ComponentEvent, + on_event, + clear_event_handlers, # DI Providers get_library, get_config, @@ -25,6 +27,8 @@ get_raw_container, get_app, get_logger, + get_plugin, + get_component, wire, inject, # Config @@ -45,29 +49,43 @@ ComponentMetadata, AppMetadata, ComponentTypes, + RegistrationInfo, + metadata, as_component, component_requires, + component_required_by, component_internals, component_str, + component_registration, # Loader compile_component, # Logging setup_logging, ) +from .project import ( + # Project API + AWIOCProject, + is_awioc_project, + open_project, + create_project, +) + __all__ = [ # Bootstrap "initialize_ioc_app", - "create_container", "compile_ioc_app", "reconfigure_ioc_app", - "reload_configuration", # Lifecycle "initialize_components", "shutdown_components", "wait_for_components", "register_plugin", "unregister_plugin", + # Events + "ComponentEvent", + "on_event", + "clear_event_handlers", # DI Providers "get_library", "get_config", @@ -75,6 +93,8 @@ "get_raw_container", "get_app", "get_logger", + "get_plugin", + "get_component", "wire", "inject", # Config @@ -95,12 +115,21 @@ "ComponentMetadata", "AppMetadata", "ComponentTypes", + "RegistrationInfo", + "metadata", "as_component", + "component_required_by", "component_requires", "component_internals", "component_str", + "component_registration", # Loader "compile_component", # Logging "setup_logging", + # Project API + "AWIOCProject", + "is_awioc_project", + "open_project", + "create_project", ] diff --git a/src/awioc/__main__.py b/src/awioc/__main__.py index 8c38a05..60458c5 100644 --- a/src/awioc/__main__.py +++ b/src/awioc/__main__.py @@ -1,41 +1,66 @@ -"""Entry point for running the IOC framework as a module (python -m ioc).""" +"""Entry point for running the AWIOC framework as a module (python -m awioc).""" import argparse import asyncio import logging - -logger = logging.getLogger(__name__) - import logging.config -import os -from dataclasses import dataclass +import sys from pathlib import Path from typing import Optional -from . import ( - compile_ioc_app, - initialize_ioc_app, - initialize_components, - shutdown_components, - wait_for_components, -) +from .commands import CommandContext, get_registered_commands from .utils import expanded_path +logger = logging.getLogger(__name__) -@dataclass -class CLIConfig: - """CLI-specific configuration for logging and IOC settings.""" - logging_config: Optional[Path] = None - verbose: int = 0 - config_path: Optional[Path] = None - context: Optional[str] = None - +# Available commands for help text +AVAILABLE_COMMANDS = { + "run": "Start the AWIOC application (default)", + "init": "Initialize a new AWIOC project", + "add": "Add plugins or libraries to the project", + "remove": "Remove plugins or libraries from the project", + "info": "Show project information", + "config": "Manage project configuration", + "pot": "Manage shared component directories", + "generate": "Generate manifest.yaml from components", +} + + +def create_parser() -> argparse.ArgumentParser: + """Create the argument parser with subcommands.""" + # Build epilog with available commands + epilog_lines = ["\nAvailable commands:"] + for cmd, desc in AVAILABLE_COMMANDS.items(): + epilog_lines.append(f" {cmd:12} {desc}") + epilog_lines.append("\nUse 'awioc --help' for more information about a command.") -def parse_args() -> CLIConfig: - """Parse command-line arguments using argparse.""" parser = argparse.ArgumentParser( - description="Run the IOC framework application", - formatter_class=argparse.RawDescriptionHelpFormatter + prog="awioc", + description="AWIOC - Async Wired IOC Framework", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="\n".join(epilog_lines), + add_help=False, # We handle --help manually to support command-specific help + ) + + parser.add_argument( + "-h", "--help", + action="store_true", + help="Show this help message and exit" + ) + + parser.add_argument( + "command", + nargs="?", + default="run", + metavar="COMMAND", + help="Command to execute (default: run)" + ) + + parser.add_argument( + "args", + nargs="*", + metavar="ARGS", + help="Command arguments" ) parser.add_argument( @@ -66,44 +91,40 @@ def parse_args() -> CLIConfig: help="Verbosity level: -v (INFO), -vv (DEBUG), -vvv (DEBUG + libs)" ) - args, _ = parser.parse_known_args() - - logging_config = None - if args.logging_config: - logging_config = expanded_path(args.logging_config) - - config_path = None - if args.config_path: - config_path = expanded_path(args.config_path) - - return CLIConfig( - logging_config=logging_config, - verbose=args.verbose, - config_path=config_path, - context=args.context + parser.add_argument( + "--version", + action="store_true", + help="Show version information" ) + return parser + -def configure_logging(config: CLIConfig) -> None: +def configure_logging( + verbose: int = 0, + logging_config: Optional[Path] = None +) -> None: """Configure logging based on CLI arguments. Priority: 1. logging_config (.ini file) if provided 2. verbose level (-v, -vv, -vvv) - 3. Default (INFO level, simple format) + 3. Default (WARNING level for minimal output) """ - if config.logging_config and config.logging_config.exists(): - logging.config.fileConfig(config.logging_config) + if logging_config and logging_config.exists(): + logging.config.fileConfig(logging_config) return + # For commands other than 'run', default to WARNING for cleaner output level_map = { - 0: logging.INFO, - 1: logging.DEBUG + 0: logging.WARNING, + 1: logging.INFO, + 2: logging.DEBUG } - level = level_map.get(min(config.verbose, len(level_map)), logging.DEBUG) + level = level_map.get(min(verbose, 2), logging.DEBUG) format_str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - if config.verbose >= 2: + if verbose >= 2: format_str = "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s" logging.basicConfig( @@ -112,60 +133,139 @@ def configure_logging(config: CLIConfig) -> None: datefmt="%Y-%m-%d %H:%M:%S" ) - if config.verbose < 3: + if verbose < 3: for lib in ("asyncio", "urllib3", "httpcore", "httpx"): logging.getLogger(lib).setLevel(logging.WARNING) -async def run(cli_config: CLIConfig): - if cli_config.config_path: - os.environ["CONFIG_PATH"] = str(cli_config.config_path) +def show_help(parser: argparse.ArgumentParser) -> int: + """Show help message.""" + parser.print_help() + return 0 + - if cli_config.context: - os.environ["CONTEXT"] = cli_config.context +def show_version() -> int: + """Show version information.""" + try: + from importlib.metadata import version + ver = version("awioc") + except Exception: + ver = "unknown" + print(f"awioc version {ver}") + return 0 + + +async def dispatch_command( + command_name: str, + ctx: CommandContext +) -> int: + """Dispatch to the appropriate command handler. + + Args: + command_name: Name of the command to execute. + ctx: Command context with arguments and options. + + Returns: + Exit code from the command. + """ + registered_commands = get_registered_commands() - api = initialize_ioc_app() - app = api.provided_app() + if command_name not in registered_commands: + logger.error(f"Unknown command: {command_name}") + print(f"\nUnknown command: {command_name}") + print(f"Available commands: {', '.join(AVAILABLE_COMMANDS.keys())}") + print("Use 'awioc --help' for more information.") + return 1 - compile_ioc_app(api) + # Instantiate and execute the command + command_class = registered_commands[command_name] + command = command_class() - try: - await initialize_components(app) + return await command.execute(ctx) + + +def show_command_help(command_name: str) -> int: + """Show help for a specific command.""" + registered_commands = get_registered_commands() + + if command_name not in registered_commands: + print(f"Unknown command: {command_name}") + print(f"Available commands: {', '.join(AVAILABLE_COMMANDS.keys())}") + return 1 + + command_class = registered_commands[command_name] + command = command_class() + print(command.help_text) + return 0 + + +def main() -> int: + """Main entry point for the CLI.""" + parser = create_parser() - exceptions = await initialize_components( - *api.provided_libs(), - return_exceptions=True - ) + # Use parse_known_args to allow commands to have their own arguments + args, remaining = parser.parse_known_args() - if exceptions: - logger.error("Error during library initialization", - exc_info=ExceptionGroup("Initialization Errors", exceptions)) - return # Abort initialization on library errors + # Handle --version flag + if args.version: + return show_version() - exceptions = await initialize_components( - *api.provided_plugins(), - return_exceptions=True - ) + command_name = args.command - if exceptions: - logger.error("Error during plugin initialization", - exc_info=ExceptionGroup("Initialization Errors", exceptions)) + # Combine args.args and remaining arguments + command_args = (args.args or []) + remaining - await wait_for_components(app) - finally: - await shutdown_components(app) + # Check if --help or -h is requested for a specific command + help_requested = args.help or "-h" in command_args or "--help" in command_args + if help_requested: + # If no command specified or command is 'run' and help was explicit on main parser + if args.help and command_name == "run" and not args.args and not remaining: + # Show main help + return show_help(parser) + else: + # Show command-specific help + return show_command_help(command_name) -def main(): - cli_config = parse_args() + # Configure logging + logging_config = None + if args.logging_config: + logging_config = expanded_path(args.logging_config) + + # For 'run' command, default to INFO level for better visibility + verbose = args.verbose + if command_name == "run" and verbose == 0: + verbose = 1 + + configure_logging(verbose=verbose, logging_config=logging_config) + + # Build command context + config_path = None + if args.config_path: + config_path = str(expanded_path(args.config_path)) + + # Combine args.args and remaining arguments + command_args = (args.args or []) + remaining - configure_logging(cli_config) + ctx = CommandContext( + command=command_name, + args=command_args, + verbose=args.verbose, + config_path=config_path, + context=args.context, + ) + # Execute the command try: - asyncio.run(run(cli_config)) + exit_code = asyncio.run(dispatch_command(command_name, ctx)) + return exit_code except KeyboardInterrupt: - ... + print("\nInterrupted") + return 130 + except Exception: + logger.exception("Error executing command") + return 1 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/src/awioc/api.py b/src/awioc/api.py index 3c52345..d5a10e9 100644 --- a/src/awioc/api.py +++ b/src/awioc/api.py @@ -9,10 +9,13 @@ from .bootstrap import ( initialize_ioc_app, - create_container, compile_ioc_app, - reconfigure_ioc_app, - reload_configuration, + reconfigure_ioc_app +) +from .components.events import ( + ComponentEvent, + on_event, + clear_handlers as clear_event_handlers, ) from .components.lifecycle import ( initialize_components, @@ -25,6 +28,8 @@ ComponentMetadata, AppMetadata, ComponentTypes, + RegistrationInfo, + metadata ) from .components.protocols import ( Component, @@ -34,9 +39,11 @@ ) from .components.registry import ( as_component, + component_required_by, component_requires, component_internals, component_str, + component_registration, ) from .config.base import Settings from .config.loaders import load_file @@ -51,6 +58,8 @@ get_raw_container, get_app, get_logger, + get_plugin, + get_component, ) from .di.wiring import wire from .loader.module_loader import compile_component @@ -67,16 +76,24 @@ "ComponentMetadata", "AppMetadata", "ComponentTypes", + "RegistrationInfo", + "metadata", "as_component", + "component_required_by", "component_requires", "component_internals", "component_str", + "component_registration", # Lifecycle "initialize_components", "shutdown_components", "wait_for_components", "register_plugin", "unregister_plugin", + # Events + "ComponentEvent", + "on_event", + "clear_event_handlers", # DI "get_library", "get_config", @@ -84,6 +101,8 @@ "get_raw_container", "get_app", "get_logger", + "get_plugin", + "get_component", "wire", "inject", # Config @@ -95,10 +114,8 @@ "IOCBaseConfig", # Bootstrap "initialize_ioc_app", - "create_container", "compile_ioc_app", "reconfigure_ioc_app", - "reload_configuration", # Loader "compile_component", # Logging diff --git a/src/awioc/bootstrap.py b/src/awioc/bootstrap.py index 2bea9f0..1eec636 100644 --- a/src/awioc/bootstrap.py +++ b/src/awioc/bootstrap.py @@ -1,39 +1,22 @@ import logging +from pathlib import Path from typing import Iterable from dependency_injector import providers +from pydantic_settings import YamlConfigSettingsSource, DotEnvSettingsSource logger = logging.getLogger(__name__) +from .components.registry import component_internals from .components.protocols import Component -from .config.base import Settings -from .config.loaders import load_file -from .config.models import IOCComponentsDefinition, IOCBaseConfig -from .config.setup import setup_logging +from .config.models import IOCBaseConfig from .container import AppContainer, ContainerInterface from .di.wiring import wire, inject_dependencies -from .loader.module_loader import compile_component -from .utils import deep_update +from .loader.module_loader import compile_component, compile_components_from_manifest +from .loader.manifest import has_awioc_dir -def create_container( - container_cls=AppContainer -) -> ContainerInterface: - """ - Create and return an instance of the application container. - - :param container_cls: The class of the container to instantiate. - :return: An instance of the application container. - """ - logger.debug("Creating container with class: %s", container_cls.__name__) - container = container_cls() - container.config.override(providers.Singleton(Settings)) - container.logger.override(providers.Singleton(logging.getLogger)) - logger.debug("Container created successfully") - return ContainerInterface(container=container) - - -def initialize_ioc_app() -> ContainerInterface: # pragma: no cover +def initialize_ioc_app() -> ContainerInterface: # TODO: add test coverage """ Initialize the IOC application. @@ -43,34 +26,103 @@ def initialize_ioc_app() -> ContainerInterface: # pragma: no cover :return: The initialized container interface. """ logger.info("Initializing IOC application") - ioc_config_env = IOCBaseConfig.load_config() - logger.debug("Loaded IOC base configuration") + ioc_config = IOCBaseConfig() # Initial load to get context and config path from .env and CLI - if ioc_config_env.context: - logger.debug("Loading context-specific configuration: %s", ioc_config_env.context) - ioc_config_env.model_config["env_file"] = f".{ioc_config_env.context}.env" - ioc_config_context = ioc_config_env.load_config() + if ioc_config.context: + logger.debug("Loading context-specific configuration for context: %s", ioc_config.context) + + IOCBaseConfig.add_sources(lambda x: DotEnvSettingsSource( + x, + env_file=f".{ioc_config.context}.env" + )) + + ioc_config = IOCBaseConfig( + context=ioc_config.context + ) # Reload to apply context-specific settings else: - ioc_config_context = ioc_config_env + logger.debug("No context provided; using default environment configuration") + + if ioc_config.config_path: + logger.debug("Loading configuration from: %s", ioc_config.config_path) - logger.debug("Loading config file: %s", ioc_config_context.config_path) - file_data = load_file(ioc_config_context.config_path) + IOCBaseConfig.add_sources(lambda x: YamlConfigSettingsSource( + x, + yaml_file=ioc_config.config_path + )) - ioc_components_definition = IOCComponentsDefinition.model_validate(file_data) - logger.debug("Validated components definition") + ioc_config = IOCBaseConfig( + context=ioc_config.context, + config_path=ioc_config.config_path + ) # Final load to apply YAML configuration + else: + logger.debug("No config path provided; skipping YAML configuration loading") - logger.debug("Compiling app component: %s", ioc_components_definition.app) - app = compile_component(ioc_components_definition.app) + return compile_ioc_app(ioc_config) + + +def _is_manifest_directory(plugin_ref: str) -> bool: + """Check if a plugin reference points to a directory with .awioc/manifest.yaml.""" + # Skip pot references + if plugin_ref.startswith("@"): + return False + + # Check if it's a directory with .awioc/manifest.yaml + path = Path(plugin_ref) + if path.is_dir(): + return has_awioc_dir(path) + + return False + + +def compile_ioc_app( # TODO: add test coverage + ioc_config: IOCBaseConfig +) -> ContainerInterface: + """ + Compile the IOC application using the provided container interface. + + Supports both single-file plugins and directory-based plugins with .awioc/manifest.yaml. + When a plugin reference is a directory containing .awioc/manifest.yaml, all components + defined in that manifest will be loaded. + + :param ioc_config: The IOC configuration. + """ + assert ioc_config is not None + assert ioc_config.ioc_components_definitions is not None + + logger.info("Compiling IOC application") + + ioc_components_definitions = ioc_config.ioc_components_definitions + + logger.debug("Got components definition: %s", ioc_config.ioc_components_definitions) + + app = compile_component(ioc_components_definitions.app) + + # Compile plugins with error handling - missing plugins are skipped with a warning + plugins = set() + for plugin_ref in ioc_components_definitions.plugins: + try: + # Check if this is a directory with manifest.yaml + if _is_manifest_directory(plugin_ref): + logger.debug("Loading plugins from manifest directory: %s", plugin_ref) + directory_plugins = compile_components_from_manifest(Path(plugin_ref)) + plugins.update(directory_plugins) + logger.debug( + "Loaded %d plugin(s) from %s", + len(directory_plugins), + plugin_ref + ) + else: + # Single plugin file or pot reference + plugin = compile_component(plugin_ref) + plugins.add(plugin) + except FileNotFoundError: + logger.warning("Plugin not found, skipping: %s", plugin_ref) + except Exception as e: + logger.error("Failed to compile plugin '%s': %s", plugin_ref, e, exc_info=True) - logger.debug("Compiling %d plugins", len(ioc_components_definition.plugins)) - plugins = { - compile_component(plugin_name) - for plugin_name in ioc_components_definition.plugins - } - logger.debug("Compiling %d libraries", len(ioc_components_definition.libraries)) libraries = { id_: compile_component(library_name) - for id_, library_name in ioc_components_definition.libraries.items() + for id_, library_name in ioc_components_definitions.libraries.items() } container = AppContainer() @@ -85,92 +137,36 @@ def initialize_ioc_app() -> ContainerInterface: # pragma: no cover if plugins: api_container.register_plugins(*plugins) - app_internals = app.__metadata__["_internals"] + app_internals = component_internals(app) assert app_internals is not None - app_internals.ioc_components_definition = ioc_components_definition - app_internals.ioc_config = ioc_config_context - - logger.info("IOC application initialized successfully") - return api_container + app_internals.ioc_config = ioc_config - -def compile_ioc_app(ioc_api: ContainerInterface): # pragma: no cover - """ - Compile the IOC application by reconfiguring all components. - - :param ioc_api: The container interface. - """ - logger.info("Compiling IOC application") - reconfigure_ioc_app(ioc_api, components=ioc_api.components) - logger.info("IOC application compiled successfully") + return reconfigure_ioc_app(api_container, components=api_container.components) def reconfigure_ioc_app( - ioc_api: ContainerInterface, + api_container: ContainerInterface, components: Iterable[Component] -): - """ - Reconfigure the IOC application with the given components. - - :param ioc_api: The container interface. - :param components: Components to reconfigure. - """ - logger.debug("Reconfiguring IOC application") - inject_dependencies(ioc_api, components=components) - - base_config = ioc_api.app_config_model - ioc_config = ioc_api.ioc_config_model - - base_config.model_config = ioc_config.model_config - env_config = base_config.load_config() - logger.debug("Loaded environment configuration: %s", type(env_config).__name__) - - config_file_content = load_file(ioc_config.config_path) - - file_config = env_config.model_validate(config_file_content) - logger.debug("Validated file configuration") - - validated_config = env_config.model_validate( - deep_update( - file_config.model_dump(exclude_unset=True, by_alias=True), - env_config.model_dump(exclude_unset=True, by_alias=True) - ) - ) - - ioc_api.set_config(validated_config) - logger.debug("Wiring components") - wire(ioc_api, components=components) - logger.debug("Reconfiguration complete") - - -def reload_configuration(api_container: ContainerInterface): # pragma: no cover +) -> ContainerInterface: """ - Reload configuration for the container. + Reconfigure given components in the IOC application. :param api_container: The container interface. + :param components: Components to reconfigure. """ - logger.info("Reloading configuration") - raw_container = api_container.raw_container() + logger.info("Configuring IOC application") - raw_container.config.reset_override() - raw_container.logger.reset_override() - logger.debug("Reset container overrides") + inject_dependencies(api_container, components=components) - inject_dependencies(api_container) - - # Setup configuration base_config = api_container.app_config_model - ioc_config = api_container.ioc_config_model - base_config.model_config = ioc_config.model_config + base_config.model_config = api_container.ioc_config_model.model_config + config = base_config.load_config() - logger.debug("Loaded configuration: %s", type(config).__name__) + logger.debug("Loaded application configuration: %s", config) - raw_container.config.override(config) - raw_container.wire((__name__,)) + api_container.set_config(config) - new_logger = setup_logging() - raw_container.logger.override(new_logger) + wire(api_container, components=components) - wire(api_container) - logger.info("Configuration reloaded successfully") + return api_container diff --git a/src/awioc/commands/__init__.py b/src/awioc/commands/__init__.py new file mode 100644 index 0000000..b99769d --- /dev/null +++ b/src/awioc/commands/__init__.py @@ -0,0 +1,31 @@ +"""AWIOC CLI Commands package. + +This package contains all CLI commands implemented as AWIOC components. +""" + +# Import command modules (registers them via @register_command decorator) +from . import run, init, add, remove, info, config, pot, generate +from .base import ( + CommandContext, + Command, + BaseCommand, + register_command, + get_registered_commands, +) + +__all__ = [ + "CommandContext", + "Command", + "BaseCommand", + "register_command", + "get_registered_commands", + # Command modules + "run", + "init", + "add", + "remove", + "info", + "config", + "pot", + "generate", +] diff --git a/src/awioc/commands/add.py b/src/awioc/commands/add.py new file mode 100644 index 0000000..5f8bf74 --- /dev/null +++ b/src/awioc/commands/add.py @@ -0,0 +1,164 @@ +"""Add command - adds plugins or libraries to an AWIOC project.""" + +import logging +from pathlib import Path + +import yaml + +from .base import BaseCommand, CommandContext, register_command +from ..components.registry import as_component + +logger = logging.getLogger(__name__) + + +@register_command("add") +@as_component( + name="Add Command", + version="1.0.0", + description="Add plugins or libraries to an AWIOC project", +) +class AddCommand(BaseCommand): + """Add command that adds plugins or libraries to the configuration. + + Modifies the ioc.yaml file to include new plugins or libraries. + """ + + @property + def name(self) -> str: + return "add" + + @property + def description(self) -> str: + return "Add plugins or libraries to the project" + + @property + def help_text(self) -> str: + return """Add plugins or libraries to an AWIOC project. + +Usage: + awioc add plugin [options] + awioc add library [options] + +Arguments: + plugin Add a plugin component + library Add a library component + Path to the component file or directory + Library identifier (for library type) + +Options: + -c, --config-path Path to ioc.yaml (default: ./ioc.yaml) + +Examples: + awioc add plugin plugins/my_plugin.py + awioc add plugin plugins/my_module:MyClass() + awioc add library db plugins/database.py +""" + + async def execute(self, ctx: CommandContext) -> int: + """Execute the add command.""" + args = ctx.args.copy() + + if not args: + logger.error("Usage: awioc add [options]") + return 1 + + component_type = args.pop(0).lower() + + if component_type == "plugin": + return await self._add_plugin(args, ctx) + elif component_type == "library": + return await self._add_library(args, ctx) + else: + logger.error(f"Unknown component type: {component_type}") + logger.error("Use 'plugin' or 'library'") + return 1 + + async def _add_plugin(self, args: list[str], ctx: CommandContext) -> int: + """Add a plugin to the configuration.""" + if not args: + logger.error("Usage: awioc add plugin ") + return 1 + + plugin_path = args.pop(0) + config_path = self._get_config_path(ctx) + + if not config_path.exists(): + logger.error(f"Configuration file not found: {config_path}") + logger.error("Run 'awioc init' first or specify --config-path") + return 1 + + # Load existing configuration + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + + # Ensure components.plugins exists + if "components" not in config: + config["components"] = {} + if "plugins" not in config["components"]: + config["components"]["plugins"] = [] + + plugins = config["components"]["plugins"] + + # Check if plugin already exists + if plugin_path in plugins: + logger.warning(f"Plugin already configured: {plugin_path}") + return 0 + + # Add the plugin + plugins.append(plugin_path) + + # Write back + config_path.write_text( + yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False), + encoding="utf-8" + ) + + logger.info(f"Added plugin: {plugin_path}") + return 0 + + async def _add_library(self, args: list[str], ctx: CommandContext) -> int: + """Add a library to the configuration.""" + if len(args) < 2: + logger.error("Usage: awioc add library ") + return 1 + + lib_name = args.pop(0) + lib_path = args.pop(0) + config_path = self._get_config_path(ctx) + + if not config_path.exists(): + logger.error(f"Configuration file not found: {config_path}") + logger.error("Run 'awioc init' first or specify --config-path") + return 1 + + # Load existing configuration + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + + # Ensure components.libraries exists + if "components" not in config: + config["components"] = {} + if "libraries" not in config["components"]: + config["components"]["libraries"] = {} + + libraries = config["components"]["libraries"] + + # Check if library already exists + if lib_name in libraries: + logger.warning(f"Library '{lib_name}' already configured, updating path") + + # Add/update the library + libraries[lib_name] = lib_path + + # Write back + config_path.write_text( + yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False), + encoding="utf-8" + ) + + logger.info(f"Added library '{lib_name}': {lib_path}") + return 0 + + def _get_config_path(self, ctx: CommandContext) -> Path: + """Get the configuration file path.""" + if ctx.config_path: + return Path(ctx.config_path) + return Path.cwd() / "ioc.yaml" diff --git a/src/awioc/commands/base.py b/src/awioc/commands/base.py new file mode 100644 index 0000000..1eb9b19 --- /dev/null +++ b/src/awioc/commands/base.py @@ -0,0 +1,110 @@ +"""Base command protocol and utilities for AWIOC CLI commands.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Optional, Any, Protocol, runtime_checkable + + +@dataclass +class CommandContext: + """Context passed to command execution. + + Contains parsed arguments and any additional context needed by commands. + """ + command: str + args: list[str] = field(default_factory=list) + verbose: int = 0 + config_path: Optional[str] = None + context: Optional[str] = None + extra: dict[str, Any] = field(default_factory=dict) + + +@runtime_checkable +class Command(Protocol): + """Protocol for CLI commands. + + Commands must implement the execute method which receives a CommandContext + and returns an exit code (0 for success, non-zero for failure). + """ + + @property + def name(self) -> str: + """The command name (e.g., 'run', 'init', 'add').""" + ... + + @property + def description(self) -> str: + """Short description of what the command does.""" + ... + + @property + def help_text(self) -> str: + """Detailed help text for the command.""" + ... + + async def execute(self, ctx: CommandContext) -> int: + """Execute the command. + + Args: + ctx: The command context with parsed arguments. + + Returns: + Exit code (0 for success, non-zero for failure). + """ + ... + + +class BaseCommand(ABC): + """Abstract base class for CLI commands. + + Provides a common structure for implementing CLI commands as AWIOC components. + Each command should be decorated with @as_component to register it. + """ + + @property + @abstractmethod + def name(self) -> str: + """The command name.""" + pass + + @property + @abstractmethod + def description(self) -> str: + """Short description of the command.""" + pass + + @property + def help_text(self) -> str: + """Detailed help text. Override for custom help.""" + return self.description + + @abstractmethod + async def execute(self, ctx: CommandContext) -> int: + """Execute the command. Must be implemented by subclasses.""" + pass + + +# Command registry to track available commands +_command_registry: dict[str, type] = {} + + +def register_command(name: str): + """Decorator to register a command class in the registry. + + Usage: + @register_command("init") + @as_component(name="Init Command", ...) + class InitCommand(BaseCommand): + ... + """ + + def decorator(cls): + _command_registry[name] = cls + return cls + + return decorator + + +def get_registered_commands() -> dict[str, type]: + """Get all registered command classes.""" + return _command_registry.copy() diff --git a/src/awioc/commands/config.py b/src/awioc/commands/config.py new file mode 100644 index 0000000..ba8d743 --- /dev/null +++ b/src/awioc/commands/config.py @@ -0,0 +1,271 @@ +"""Config command - manages AWIOC project configuration.""" + +import logging +from pathlib import Path +from typing import Any + +import yaml + +from .base import BaseCommand, CommandContext, register_command +from ..components.registry import as_component + +logger = logging.getLogger(__name__) + + +@register_command("config") +@as_component( + name="Config Command", + version="1.0.0", + description="Manage AWIOC project configuration", +) +class ConfigCommand(BaseCommand): + """Config command that manages project configuration. + + Allows viewing, getting, and setting configuration values in ioc.yaml. + """ + + @property + def name(self) -> str: + return "config" + + @property + def description(self) -> str: + return "Manage project configuration" + + @property + def help_text(self) -> str: + return """Manage AWIOC project configuration. + +Usage: + awioc config Show all configuration + awioc config get Get a configuration value + awioc config set Set a configuration value + awioc config unset Remove a configuration value + +Arguments: + Dot-separated path to configuration key (e.g., server.port) + Value to set (strings, numbers, booleans, JSON supported) + +Options: + -c, --config-path Path to ioc.yaml (default: ./ioc.yaml) + +Examples: + awioc config get server.port + awioc config set server.port 8080 + awioc config set server.host "0.0.0.0" + awioc config set features.enabled true + awioc config unset server.debug +""" + + async def execute(self, ctx: CommandContext) -> int: + """Execute the config command.""" + args = ctx.args.copy() + + if not args: + return await self._show_config(ctx) + + subcommand = args.pop(0).lower() + + if subcommand == "get": + return await self._get_config(args, ctx) + elif subcommand == "set": + return await self._set_config(args, ctx) + elif subcommand == "unset": + return await self._unset_config(args, ctx) + else: + logger.error(f"Unknown subcommand: {subcommand}") + logger.error("Use 'get', 'set', or 'unset'") + return 1 + + async def _show_config(self, ctx: CommandContext) -> int: + """Show all configuration.""" + config_path = self._get_config_path(ctx) + + if not config_path.exists(): + logger.error(f"Configuration file not found: {config_path}") + return 1 + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + logger.error(f"Error parsing configuration: {e}") + return 1 + + # Pretty print the configuration + print(yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False)) + return 0 + + async def _get_config(self, args: list[str], ctx: CommandContext) -> int: + """Get a configuration value.""" + if not args: + logger.error("Usage: awioc config get ") + return 1 + + key = args.pop(0) + config_path = self._get_config_path(ctx) + + if not config_path.exists(): + logger.error(f"Configuration file not found: {config_path}") + return 1 + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + logger.error(f"Error parsing configuration: {e}") + return 1 + + # Navigate to the key + value = self._get_nested(config, key) + + if value is None: + logger.error(f"Key not found: {key}") + return 1 + + if isinstance(value, dict): + print(yaml.dump(value, default_flow_style=False, allow_unicode=True)) + else: + print(value) + + return 0 + + async def _set_config(self, args: list[str], ctx: CommandContext) -> int: + """Set a configuration value.""" + if len(args) < 2: + logger.error("Usage: awioc config set ") + return 1 + + key = args.pop(0) + value_str = " ".join(args) # Join remaining args for values with spaces + config_path = self._get_config_path(ctx) + + if not config_path.exists(): + logger.error(f"Configuration file not found: {config_path}") + return 1 + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + logger.error(f"Error parsing configuration: {e}") + return 1 + + # Parse the value + value = self._parse_value(value_str) + + # Set the value + self._set_nested(config, key, value) + + # Write back + config_path.write_text( + yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False), + encoding="utf-8" + ) + + logger.info(f"Set {key} = {value}") + return 0 + + async def _unset_config(self, args: list[str], ctx: CommandContext) -> int: + """Remove a configuration value.""" + if not args: + logger.error("Usage: awioc config unset ") + return 1 + + key = args.pop(0) + config_path = self._get_config_path(ctx) + + if not config_path.exists(): + logger.error(f"Configuration file not found: {config_path}") + return 1 + + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + logger.error(f"Error parsing configuration: {e}") + return 1 + + # Remove the value + if not self._unset_nested(config, key): + logger.error(f"Key not found: {key}") + return 1 + + # Write back + config_path.write_text( + yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False), + encoding="utf-8" + ) + + logger.info(f"Removed: {key}") + return 0 + + def _get_nested(self, obj: dict, key: str) -> Any: + """Get a nested value by dot-separated key.""" + parts = key.split(".") + current = obj + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + def _set_nested(self, obj: dict, key: str, value: Any) -> None: + """Set a nested value by dot-separated key.""" + parts = key.split(".") + current = obj + for part in parts[:-1]: + if part not in current or not isinstance(current[part], dict): + current[part] = {} + current = current[part] + current[parts[-1]] = value + + def _unset_nested(self, obj: dict, key: str) -> bool: + """Remove a nested value by dot-separated key. Returns True if removed.""" + parts = key.split(".") + current = obj + for part in parts[:-1]: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return False + if isinstance(current, dict) and parts[-1] in current: + del current[parts[-1]] + return True + return False + + def _parse_value(self, value_str: str) -> Any: + """Parse a string value into the appropriate type.""" + import json + + # Strip quotes if present + value_str = value_str.strip() + if (value_str.startswith('"') and value_str.endswith('"')) or \ + (value_str.startswith("'") and value_str.endswith("'")): + return value_str[1:-1] + + # Try to parse as JSON (handles booleans, numbers, arrays, objects) + try: + return json.loads(value_str) + except json.JSONDecodeError: + pass + + # Boolean detection + if value_str.lower() in ("true", "yes", "on"): + return True + if value_str.lower() in ("false", "no", "off"): + return False + + # Try as number + try: + if "." in value_str: + return float(value_str) + return int(value_str) + except ValueError: + pass + + # Return as string + return value_str + + def _get_config_path(self, ctx: CommandContext) -> Path: + """Get the configuration file path.""" + if ctx.config_path: + return Path(ctx.config_path) + return Path.cwd() / "ioc.yaml" diff --git a/src/awioc/commands/generate.py b/src/awioc/commands/generate.py new file mode 100644 index 0000000..487acae --- /dev/null +++ b/src/awioc/commands/generate.py @@ -0,0 +1,452 @@ +"""Generate command - generates .awioc/manifest.yaml from existing components.""" + +import ast +import logging +import shutil +from pathlib import Path +from typing import Optional + +import yaml + +from .base import BaseCommand, CommandContext, register_command +from ..components.registry import as_component +from ..loader.manifest import AWIOC_DIR, MANIFEST_FILENAME + +logger = logging.getLogger(__name__) + + +def _extract_decorator_metadata(node: ast.ClassDef) -> Optional[dict]: + """Extract metadata from @as_component decorator on a class.""" + for decorator in node.decorator_list: + # Check for @as_component or @as_component(...) + if isinstance(decorator, ast.Name) and decorator.id == "as_component": + # Simple @as_component without args + return {"name": node.name, "class": node.name} + + if isinstance(decorator, ast.Call): + func = decorator.func + if isinstance(func, ast.Name) and func.id == "as_component": + # @as_component(...) with arguments + metadata = {"class": node.name} + + # Parse keyword arguments + for keyword in decorator.keywords: + key = keyword.arg + value = _ast_literal_eval(keyword.value) + if value is not None: + metadata[key] = value + + # Set default name if not provided + if "name" not in metadata: + metadata["name"] = node.name + + return metadata + + return None + + +def _extract_module_metadata(tree: ast.Module) -> Optional[dict]: + """Extract __metadata__ dict from module-level assignment.""" + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__metadata__": + if isinstance(node.value, ast.Dict): + metadata = {} + for key, value in zip(node.value.keys, node.value.values): + if isinstance(key, ast.Constant): + val = _ast_literal_eval(value) + if val is not None: + metadata[str(key.value)] = val + return metadata + return None + + +def _ast_literal_eval(node: ast.expr) -> Optional[any]: + """Safely evaluate an AST node to a Python literal.""" + try: + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Str): # Python 3.7 compatibility + return node.s + if isinstance(node, ast.Num): # Python 3.7 compatibility + return node.n + if isinstance(node, ast.NameConstant): # Python 3.7 compatibility + return node.value + if isinstance(node, ast.List): + return [_ast_literal_eval(elt) for elt in node.elts] + if isinstance(node, ast.Tuple): + return [_ast_literal_eval(elt) for elt in node.elts] + if isinstance(node, ast.Set): + return {_ast_literal_eval(elt) for elt in node.elts} + if isinstance(node, ast.Dict): + return { + _ast_literal_eval(k): _ast_literal_eval(v) + for k, v in zip(node.keys, node.values) + if k is not None + } + if isinstance(node, ast.Name): + # Reference to a class or variable - return as string reference + return f":{node.id}" + if isinstance(node, ast.Attribute): + # Attribute access like module.Class + parts = [] + current = node + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + if isinstance(current, ast.Name): + parts.append(current.id) + parts.reverse() + return ":".join(parts) + except Exception: + pass + return None + + +def _scan_python_file(file_path: Path) -> list[dict]: + """Scan a Python file for components and extract their metadata.""" + components = [] + + try: + content = file_path.read_text(encoding="utf-8") + tree = ast.parse(content) + except Exception as e: + logger.warning("Failed to parse %s: %s", file_path, e) + return components + + # Check for module-level __metadata__ + module_meta = _extract_module_metadata(tree) + if module_meta: + entry = { + "name": module_meta.get("name", file_path.stem), + "version": module_meta.get("version", "0.0.0"), + "description": module_meta.get("description", ""), + "file": file_path.name, + "wire": module_meta.get("wire", True), + } + + # Handle wirings + wirings_value = module_meta.get("wirings") + if wirings_value: + entry["wirings"] = _format_wirings(wirings_value) + + # Handle requires + requires_value = module_meta.get("requires") + if requires_value: + entry["requires"] = _format_requires(requires_value) + + # Handle config + config_value = module_meta.get("config") + if config_value: + entry["config"] = _format_config_ref(config_value, file_path) + + components.append(entry) + return components # Module-level metadata takes precedence + + # Scan for class-based components + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + meta = _extract_decorator_metadata(node) + if meta: + entry = { + "name": meta.get("name", node.name), + "version": meta.get("version", "0.0.0"), + "description": meta.get("description", ""), + "file": file_path.name, + "class": meta.get("class", node.name), + "wire": meta.get("wire", True), + } + + # Handle wirings + wirings_value = meta.get("wirings") + if wirings_value: + entry["wirings"] = _format_wirings(wirings_value) + + # Handle requires + requires_value = meta.get("requires") + if requires_value: + entry["requires"] = _format_requires(requires_value) + + # Handle config + config_value = meta.get("config") + if config_value: + entry["config"] = _format_config_ref(config_value, file_path) + + components.append(entry) + + return components + + +def _format_wirings(wirings_value: any) -> list[str]: + """Format wirings for manifest. + + Converts various wiring formats to a list of strings. + """ + if isinstance(wirings_value, str): + return [wirings_value] + elif isinstance(wirings_value, (list, tuple, set)): + return [str(w) for w in wirings_value if w] + return [] + + +def _format_requires(requires_value: any) -> list[str]: + """Format requires for manifest. + + Converts various requires formats to a list of component name strings. + References like ':ComponentName' are converted to 'ComponentName'. + """ + result = [] + if isinstance(requires_value, str): + # Single string reference + if requires_value.startswith(":"): + result.append(requires_value[1:]) + else: + result.append(requires_value) + elif isinstance(requires_value, (list, tuple, set)): + for item in requires_value: + if isinstance(item, str): + if item.startswith(":"): + result.append(item[1:]) + else: + result.append(item) + return result + + +def _format_config_ref(config_value: any, file_path: Path) -> list[dict]: + """Format config references for manifest.""" + configs = [] + + if isinstance(config_value, str) and config_value.startswith(":"): + # Single class reference + class_name = config_value[1:] + configs.append({"model": f"{file_path.stem}:{class_name}"}) + elif isinstance(config_value, (list, set)): + for item in config_value: + if isinstance(item, str) and item.startswith(":"): + class_name = item[1:] + configs.append({"model": f"{file_path.stem}:{class_name}"}) + + return configs if configs else None + + +def _generate_manifest(directory: Path) -> dict: + """Generate manifest content for a directory.""" + manifest = { + "manifest_version": "1.0", + "name": directory.name, + "version": "1.0.0", + "description": f"Auto-generated manifest for {directory.name}", + "components": [], + } + + # Scan all Python files + py_files = sorted(directory.glob("*.py")) + + for py_file in py_files: + if py_file.name.startswith("_"): + continue # Skip __init__.py and private files + + components = _scan_python_file(py_file) + manifest["components"].extend(components) + + return manifest + + +@register_command("generate") +@as_component( + name="Generate Command", + version="1.0.0", + description="Generate .awioc/manifest.yaml from existing components", +) +class GenerateCommand(BaseCommand): + """Generate command that creates .awioc/manifest.yaml from decorated components. + + Scans Python files for @as_component decorators and __metadata__ dicts, + then generates a manifest.yaml file in the .awioc directory. + """ + + @property + def name(self) -> str: + return "generate" + + @property + def description(self) -> str: + return "Generate .awioc/manifest.yaml from existing components" + + @property + def help_text(self) -> str: + return """Generate .awioc/manifest.yaml from existing components. + +Scans Python files for @as_component decorators and __metadata__ dicts, +then generates a manifest.yaml file in the .awioc directory. + +Usage: + awioc generate manifest [path] [options] + awioc generate migrate [path] [options] + +Subcommands: + manifest Generate a .awioc/manifest.yaml file + migrate Migrate existing manifest.yaml to .awioc/manifest.yaml + +Arguments: + [path] Directory to scan/migrate (default: current directory) + +Options: + -o, --output PATH Output file path (default: /.awioc/manifest.yaml) + --dry-run Preview without writing + --force Overwrite existing manifest + +Examples: + awioc generate manifest plugins/ + awioc generate manifest plugins/ --dry-run + awioc generate manifest plugins/ --force + awioc generate migrate plugins/ +""" + + async def execute(self, ctx: CommandContext) -> int: + """Execute the generate command.""" + args = ctx.args.copy() + + if not args: + print(self.help_text) + return 1 + + subcommand = args.pop(0).lower() + + if subcommand == "manifest": + return await self._generate_manifest(args, ctx) + elif subcommand == "migrate": + return await self._migrate_manifest(args, ctx) + elif subcommand == "help": + print(self.help_text) + return 0 + else: + logger.error(f"Unknown subcommand: {subcommand}") + print(self.help_text) + return 1 + + async def _generate_manifest(self, args: list[str], ctx: CommandContext) -> int: + """Generate .awioc/manifest.yaml for a directory.""" + # Parse arguments + target_dir = Path.cwd() + output_path = None + dry_run = False + force = False + + while args: + arg = args.pop(0) + if arg in ("-o", "--output") and args: + output_path = Path(args.pop(0)) + elif arg == "--dry-run": + dry_run = True + elif arg == "--force": + force = True + elif not arg.startswith("-"): + target_dir = Path(arg) + + # Resolve paths + target_dir = target_dir.resolve() + + if not target_dir.exists(): + logger.error(f"Directory not found: {target_dir}") + return 1 + + if not target_dir.is_dir(): + logger.error(f"Not a directory: {target_dir}") + return 1 + + # Default output path is .awioc/manifest.yaml + if output_path is None: + awioc_dir = target_dir / AWIOC_DIR + output_path = awioc_dir / MANIFEST_FILENAME + + # Check if manifest already exists + if output_path.exists() and not force and not dry_run: + logger.error(f"Manifest already exists: {output_path}") + logger.error("Use --force to overwrite or --dry-run to preview") + return 1 + + # Generate manifest + print(f"Scanning {target_dir}...") + manifest = _generate_manifest(target_dir) + + if not manifest["components"]: + logger.warning("No components found in directory") + return 0 + + # Format output + yaml_content = yaml.dump( + manifest, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ) + + print(f"\nFound {len(manifest['components'])} component(s):") + for comp in manifest["components"]: + class_info = f" ({comp['class']})" if comp.get("class") else "" + print(f" - {comp['name']} v{comp['version']}{class_info}") + + if dry_run: + print(f"\n--- Preview of {output_path} ---\n") + print(yaml_content) + print("--- End preview (--dry-run, no file written) ---") + else: + # Create .awioc directory if needed + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(yaml_content, encoding="utf-8") + print(f"\nGenerated: {output_path}") + + return 0 + + async def _migrate_manifest(self, args: list[str], ctx: CommandContext) -> int: + """Migrate existing manifest.yaml to .awioc/manifest.yaml.""" + # Parse arguments + target_dir = Path.cwd() + force = False + + while args: + arg = args.pop(0) + if arg == "--force": + force = True + elif not arg.startswith("-"): + target_dir = Path(arg) + + # Resolve paths + target_dir = target_dir.resolve() + + if not target_dir.exists(): + logger.error(f"Directory not found: {target_dir}") + return 1 + + if not target_dir.is_dir(): + logger.error(f"Not a directory: {target_dir}") + return 1 + + # Check for old manifest + old_manifest = target_dir / MANIFEST_FILENAME + if not old_manifest.exists(): + logger.error(f"No manifest.yaml found in {target_dir}") + return 1 + + # Set up new paths + awioc_dir = target_dir / AWIOC_DIR + new_manifest = awioc_dir / MANIFEST_FILENAME + + # Check if new manifest already exists + if new_manifest.exists() and not force: + logger.error(f"New manifest already exists: {new_manifest}") + logger.error("Use --force to overwrite") + return 1 + + # Create .awioc directory + awioc_dir.mkdir(exist_ok=True) + + # Move manifest + if new_manifest.exists(): + new_manifest.unlink() + shutil.move(str(old_manifest), str(new_manifest)) + + print(f"Migrated: {old_manifest} -> {new_manifest}") + return 0 diff --git a/src/awioc/commands/info.py b/src/awioc/commands/info.py new file mode 100644 index 0000000..6302a9c --- /dev/null +++ b/src/awioc/commands/info.py @@ -0,0 +1,194 @@ +"""Info command - shows information about an AWIOC project.""" + +import logging +from pathlib import Path +from typing import Optional + +import yaml + +from .base import BaseCommand, CommandContext, register_command +from ..components.registry import as_component +from ..loader.manifest import find_manifest, load_manifest + +logger = logging.getLogger(__name__) + + +@register_command("info") +@as_component( + name="Info Command", + version="1.0.0", + description="Show information about an AWIOC project", +) +class InfoCommand(BaseCommand): + """Info command that displays project information. + + Shows details about the configured components, plugins, and libraries. + """ + + @property + def name(self) -> str: + return "info" + + @property + def description(self) -> str: + return "Show project information" + + @property + def help_text(self) -> str: + return """Show information about an AWIOC project. + +Displays the project configuration including: + - Application component + - Configured libraries + - Configured plugins + - Environment files + - Manifest information (when .awioc/manifest.yaml present) + +Usage: + awioc info [options] + +Options: + -c, --config-path Path to ioc.yaml (default: ./ioc.yaml) + --verbose Show detailed component information + --show-manifest Display .awioc/manifest.yaml contents for directories +""" + + async def execute(self, ctx: CommandContext) -> int: + """Execute the info command.""" + config_path = self._get_config_path(ctx) + + # Check for --show-manifest flag in args + show_manifest = "--show-manifest" in ctx.args + + if not config_path.exists(): + logger.error(f"Configuration file not found: {config_path}") + logger.info("Run 'awioc init' to create a new project") + return 1 + + # Load configuration + try: + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + logger.error(f"Error parsing configuration: {e}") + return 1 + + # Display project info + print(f"\n{'=' * 60}") + print(f" AWIOC Project Information") + print(f"{'=' * 60}") + print(f"\nConfiguration: {config_path}") + + # Check for environment files + config_dir = config_path.parent + env_files = [] + if (config_dir / ".env").exists(): + env_files.append(".env") + for f in config_dir.glob(".*.env"): + env_files.append(f.name) + if env_files: + print(f"Environment files: {', '.join(env_files)}") + + # Application component + components = config.get("components", {}) + app = components.get("app", "Not configured") + print(f"\n--- Application ---") + print(f" App: {app}") + + # Libraries + libraries = components.get("libraries", {}) + print(f"\n--- Libraries ({len(libraries)}) ---") + if libraries: + for name, path in libraries.items(): + exists = self._check_path(path, config_dir) + status = "[OK]" if exists else "[NOT FOUND]" + print(f" {name}: {path} {status}") + else: + print(" (none)") + + # Plugins + plugins = components.get("plugins", []) + print(f"\n--- Plugins ({len(plugins)}) ---") + if plugins: + for i, plugin in enumerate(plugins): + exists, manifest_info = self._check_path_with_manifest(plugin, config_dir) + status = "[OK]" if exists else "[NOT FOUND]" + manifest_tag = " [MANIFEST]" if manifest_info else "" + print(f" [{i}] {plugin} {status}{manifest_tag}") + + # Show manifest details if requested + if show_manifest and manifest_info: + self._print_manifest_info(manifest_info, indent=6) + else: + print(" (none)") + + # Other configuration sections + other_sections = [k for k in config.keys() if k != "components"] + if other_sections and ctx.verbose > 0: + print(f"\n--- Configuration Sections ---") + for section in other_sections: + print(f" {section}:") + section_config = config[section] + if isinstance(section_config, dict): + for key, value in section_config.items(): + print(f" {key}: {value}") + else: + print(f" {section_config}") + + print(f"\n{'=' * 60}\n") + return 0 + + def _print_manifest_info(self, manifest_path: Path, indent: int = 4) -> None: + """Print manifest information.""" + prefix = " " * indent + try: + # manifest_path is .awioc/manifest.yaml, so parent.parent is the component directory + component_dir = manifest_path.parent.parent + manifest = load_manifest(component_dir) + print(f"{prefix}Manifest: {manifest_path}") + print(f"{prefix}Components ({len(manifest.components)}):") + for comp in manifest.components: + class_info = f" ({comp.class_name})" if comp.class_name else "" + print(f"{prefix} - {comp.name} v{comp.version}{class_info}") + except Exception as e: + print(f"{prefix}Manifest: {manifest_path} [ERROR: {e}]") + + def _check_path(self, path_str: str, base_dir: Path) -> bool: + """Check if a component path exists.""" + exists, _ = self._check_path_with_manifest(path_str, base_dir) + return exists + + def _check_path_with_manifest( + self, path_str: str, base_dir: Path + ) -> tuple[bool, Optional[Path]]: + """Check if a component path exists and has a manifest. + + Returns: + Tuple of (exists, manifest_path or None) + """ + # Handle pot references + if path_str.startswith("@"): + return True, None # Pot references handled separately + + # Handle class reference syntax (path:ClassName()) + if ":" in path_str and not path_str.startswith(":"): + path_str = path_str.split(":")[0] + elif path_str.startswith(":"): + # Local module reference + return True, None + + path = Path(path_str) + if not path.is_absolute(): + path = base_dir / path + + if not path.exists(): + return False, None + + # Check for manifest + manifest_path = find_manifest(path) + return True, manifest_path + + def _get_config_path(self, ctx: CommandContext) -> Path: + """Get the configuration file path.""" + if ctx.config_path: + return Path(ctx.config_path) + return Path.cwd() / "ioc.yaml" diff --git a/src/awioc/commands/init.py b/src/awioc/commands/init.py new file mode 100644 index 0000000..a595211 --- /dev/null +++ b/src/awioc/commands/init.py @@ -0,0 +1,285 @@ +"""Init command - initializes a new AWIOC project.""" + +import logging +import re +from pathlib import Path + +import yaml + +from .base import BaseCommand, CommandContext, register_command +from ..components.registry import as_component +from ..loader.manifest import AWIOC_DIR, MANIFEST_FILENAME + +logger = logging.getLogger(__name__) + + +def to_snake_case(name: str) -> str: + """Convert a name to snake_case for file names.""" + # Replace spaces and hyphens with underscores + name = re.sub(r'[\s\-]+', '_', name) + # Insert underscore before uppercase letters and lowercase them + name = re.sub(r'([a-z])([A-Z])', r'\1_\2', name) + # Remove non-alphanumeric characters except underscores + name = re.sub(r'[^a-zA-Z0-9_]', '', name) + return name.lower() + + +def to_pascal_case(name: str) -> str: + """Convert a name to PascalCase for class names.""" + # Split on spaces, hyphens, and underscores + words = re.split(r'[\s\-_]+', name) + # Capitalize each word and join + return ''.join(word.capitalize() for word in words if word) + + +# Template for ioc.yaml configuration file +IOC_YAML_TEMPLATE = """# AWIOC Configuration File +# See documentation for all available options + +components: + # Main application component + app: "{module_name}:{class_name}()" + + # Libraries (named components that can be injected) + libraries: {{}} + + # Plugins (optional components loaded at runtime) + plugins: [] + +# Application-specific configuration +# Add your component configurations here using their __prefix__ +""" + +# Template for a basic app component +APP_COMPONENT_TEMPLATE = '''"""{app_name} - Main application component.""" + +import asyncio +from awioc import inject, get_logger + + +class {class_name}: + """Main application component for {app_name}. + + This component serves as the entry point for your AWIOC application. + """ + + def __init__(self): + self._shutdown_event = asyncio.Event() + + @inject + async def initialize(self, logger=get_logger()) -> None: + """Initialize the application.""" + logger.info("{app_name} starting...") + + async def wait(self) -> None: + """Wait for shutdown signal.""" + await self._shutdown_event.wait() + + async def shutdown(self) -> None: + """Shutdown the application.""" + self._shutdown_event.set() +''' + +# Template for __init__.py +INIT_TEMPLATE = '''"""{app_name} - AWIOC Application.""" + +from .{module_name} import {class_name} + +__all__ = ["{class_name}"] +''' + +# Template for .env file +ENV_TEMPLATE = """# Environment configuration for AWIOC application +# Uncomment and modify as needed + +# CONFIG_PATH=ioc.yaml +# CONTEXT=dev +""" + + +@register_command("init") +@as_component( + name="Init Command", + version="1.0.0", + description="Initialize a new AWIOC project", +) +class InitCommand(BaseCommand): + """Init command that creates a new AWIOC project structure. + + Creates the necessary files and directories for a new AWIOC application: + - ioc.yaml: Main configuration file + - .py: Application component file + - __init__.py: Module exports + - .awioc/manifest.yaml: Component manifest + - .env: Environment configuration template + """ + + @property + def name(self) -> str: + return "init" + + @property + def description(self) -> str: + return "Initialize a new AWIOC project" + + @property + def help_text(self) -> str: + return """Initialize a new AWIOC project. + +Creates the basic project structure with template files: + - ioc.yaml: Main configuration file + - .py: Application component (named after your app) + - __init__.py: Module exports + - .awioc/manifest.yaml: Component manifest + - .env: Environment configuration + +Usage: + awioc init [directory] [options] + +Arguments: + directory Target directory (default: current directory) + +Options: + --name NAME Application name (default: "My App") + --force Overwrite existing files + +Examples: + awioc init # Initialize in current directory + awioc init my_project # Initialize in my_project directory + awioc init --name "My Service" # Initialize with custom app name +""" + + async def execute(self, ctx: CommandContext) -> int: + """Execute the init command.""" + # Parse arguments + target_dir = Path.cwd() + app_name = "My App" + force = False + + args = ctx.args.copy() + while args: + arg = args.pop(0) + if arg == "--name" and args: + app_name = args.pop(0) + elif arg == "--force": + force = True + elif not arg.startswith("-"): + target_dir = Path(arg) + + # Derive names from app_name + module_name = to_snake_case(app_name) + class_name = to_pascal_case(app_name) + "Component" + + # Create target directory if needed + target_dir = target_dir.resolve() + target_dir.mkdir(parents=True, exist_ok=True) + + files_created = [] + files_skipped = [] + + # Create ioc.yaml + ioc_yaml_path = target_dir / "ioc.yaml" + if ioc_yaml_path.exists() and not force: + files_skipped.append("ioc.yaml") + logger.warning("ioc.yaml already exists, skipping (use --force to overwrite)") + else: + content = IOC_YAML_TEMPLATE.format( + module_name=module_name, + class_name=class_name, + ) + ioc_yaml_path.write_text(content, encoding="utf-8") + files_created.append("ioc.yaml") + + # Create app component file (.py) + app_path = target_dir / f"{module_name}.py" + if app_path.exists() and not force: + files_skipped.append(f"{module_name}.py") + logger.warning(f"{module_name}.py already exists, skipping (use --force to overwrite)") + else: + content = APP_COMPONENT_TEMPLATE.format( + app_name=app_name, + class_name=class_name, + ) + app_path.write_text(content, encoding="utf-8") + files_created.append(f"{module_name}.py") + + # Create __init__.py + init_path = target_dir / "__init__.py" + if init_path.exists() and not force: + files_skipped.append("__init__.py") + logger.warning("__init__.py already exists, skipping (use --force to overwrite)") + else: + content = INIT_TEMPLATE.format( + app_name=app_name, + module_name=module_name, + class_name=class_name, + ) + init_path.write_text(content, encoding="utf-8") + files_created.append("__init__.py") + + # Create .awioc/manifest.yaml + awioc_dir = target_dir / AWIOC_DIR + awioc_dir.mkdir(exist_ok=True) + manifest_path = awioc_dir / MANIFEST_FILENAME + if manifest_path.exists() and not force: + files_skipped.append(f"{AWIOC_DIR}/{MANIFEST_FILENAME}") + logger.warning(f"{AWIOC_DIR}/{MANIFEST_FILENAME} already exists, skipping (use --force to overwrite)") + else: + manifest = { + "manifest_version": "1.0", + "name": app_name, + "version": "1.0.0", + "description": f"Main application for {app_name}", + "components": [ + { + "name": app_name, + "version": "1.0.0", + "description": f"Main application component for {app_name}", + "file": f"{module_name}.py", + "class": class_name, + "wire": True, + } + ], + } + manifest_path.write_text( + yaml.dump(manifest, default_flow_style=False, allow_unicode=True, sort_keys=False), + encoding="utf-8" + ) + files_created.append(f"{AWIOC_DIR}/{MANIFEST_FILENAME}") + + # Create .env + env_path = target_dir / ".env" + if env_path.exists() and not force: + files_skipped.append(".env") + logger.warning(".env already exists, skipping (use --force to overwrite)") + else: + env_path.write_text(ENV_TEMPLATE, encoding="utf-8") + files_created.append(".env") + + # Create plugins directory + plugins_dir = target_dir / "plugins" + if not plugins_dir.exists(): + plugins_dir.mkdir() + files_created.append("plugins/") + + # Summary + print(f"\nInitializing AWIOC project: {app_name}") + print(f"Directory: {target_dir}") + print(f"Module: {module_name}.py") + print(f"Class: {class_name}") + print() + + if files_created: + print("Created files:") + for f in files_created: + print(f" - {f}") + + if files_skipped: + print(f"\nSkipped existing files: {', '.join(files_skipped)}") + + if files_created: + print(f"\nProject initialized! Run 'awioc run -c {ioc_yaml_path}' to start.") + return 0 + else: + print("No files created. Directory already contains an AWIOC project.") + return 0 diff --git a/src/awioc/commands/pot.py b/src/awioc/commands/pot.py new file mode 100644 index 0000000..f0e0ada --- /dev/null +++ b/src/awioc/commands/pot.py @@ -0,0 +1,676 @@ +"""Pot command - manages component repositories (pots). + +A "pot" is a local repository where AWIOC components can be stored and +shared across multiple projects. Components in pots are referenced using +the @pot-name/component syntax in ioc.yaml. + +Example: + plugins: + - @my-pot/http-server + - @my-pot/auth-plugin +""" + +import logging +import shutil +from pathlib import Path +from typing import Optional + +import yaml + +from .base import BaseCommand, CommandContext, register_command +from ..components.registry import as_component +from ..loader.module_loader import _load_module + +logger = logging.getLogger(__name__) + +# Default pot directory +DEFAULT_POT_DIR = Path.home() / ".awioc" / "pots" + +# Pot manifest filename (compatible with standard manifest) +POT_MANIFEST_FILENAME = "pot.yaml" + + +def get_pot_dir() -> Path: + """Get the pot directory, creating it if needed.""" + pot_dir = DEFAULT_POT_DIR + pot_dir.mkdir(parents=True, exist_ok=True) + return pot_dir + + +def get_pot_path(pot_name: str) -> Path: + """Get the path to a specific pot.""" + return get_pot_dir() / pot_name + + +def load_pot_manifest(pot_path: Path) -> dict: + """Load a pot's manifest file. + + The pot manifest format is compatible with the standard manifest.yaml format, + using a list of component entries instead of a dict for consistency. + """ + manifest_path = pot_path / POT_MANIFEST_FILENAME + if not manifest_path.exists(): + return { + "manifest_version": "1.0", + "name": pot_path.name, + "version": "1.0.0", + "components": {}, # Legacy format uses dict for quick lookup by name + } + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} + + # Ensure manifest_version exists + if "manifest_version" not in manifest: + manifest["manifest_version"] = "1.0" + + return manifest + + +def save_pot_manifest(pot_path: Path, manifest: dict) -> None: + """Save a pot's manifest file.""" + manifest_path = pot_path / POT_MANIFEST_FILENAME + manifest_path.write_text( + yaml.dump(manifest, default_flow_style=False, allow_unicode=True, sort_keys=False), + encoding="utf-8" + ) + + +def extract_component_metadata(component_path: Path) -> Optional[dict]: + """Extract metadata from a component file/directory. + + Returns dict with name, version, description if found. + """ + module = _load_module(component_path) + + # Check for module-level __metadata__ + if hasattr(module, "__metadata__"): + metadata = module.__metadata__ + if isinstance(metadata, dict): + return { + "name": metadata.get("name", component_path.stem), + "version": metadata.get("version", "1.0.0"), + "description": metadata.get("description", ""), + } + + # Check for class with __metadata__ (class-based components) + import inspect + for name, obj in inspect.getmembers(module, inspect.isclass): + if hasattr(obj, "__metadata__"): + metadata = obj.__metadata__ + if isinstance(metadata, dict): + return { + "name": metadata.get("name", component_path.stem), + "version": metadata.get("version", "1.0.0"), + "description": metadata.get("description", ""), + "class_name": name, + } + + return None + + +def resolve_pot_component(pot_ref: str) -> Optional[Path]: + """Resolve a @pot-name/component reference to a file path. + + Args: + pot_ref: Reference in format @pot-name/component-name + + Returns: + Path to the component file/directory, or None if not found. + """ + if not pot_ref.startswith("@"): + return None + + # Parse @pot-name/component-name + ref = pot_ref[1:] # Remove @ + if "/" not in ref: + logger.error(f"Invalid pot reference: {pot_ref} (expected @pot-name/component)") + return None + + pot_name, component_name = ref.split("/", 1) + pot_path = get_pot_path(pot_name) + + if not pot_path.exists(): + logger.error(f"Pot not found: {pot_name}") + return None + + # Load manifest to find component + manifest = load_pot_manifest(pot_path) + components = manifest.get("components", {}) + + if component_name not in components: + logger.error(f"Component '{component_name}' not found in pot '{pot_name}'") + return None + + component_info = components[component_name] + component_file = pot_path / component_info.get("path", component_name) + + if not component_file.exists(): + logger.error(f"Component file not found: {component_file}") + return None + + return component_file + + +@register_command("pot") +@as_component( + name="Pot Command", + version="1.0.0", + description="Manage component repositories (pots)", +) +class PotCommand(BaseCommand): + """Pot command for managing component repositories. + + A "pot" is a local repository where components can be stored and + referenced by name across multiple projects using @pot-name/component syntax. + """ + + @property + def name(self) -> str: + return "pot" + + @property + def description(self) -> str: + return "Manage component repositories" + + @property + def help_text(self) -> str: + return """Manage component repositories (pots). + +A "pot" is a local repository for storing and sharing AWIOC components. +Components in pots are referenced using @pot-name/component syntax. + +Usage: + awioc pot [options] + +Subcommands: + init Create a new pot + push [--pot ] Push a component to a pot + update / [source-path] Update a component from source + remove / Remove a component from a pot + list [pot-name] List pots or components in a pot + info / Show component details + delete Delete an entire pot + +Examples: + awioc pot init my-components + awioc pot push ./my_plugin.py --pot my-components + awioc pot push ./http_server/ --pot my-components + awioc pot update my-components/http-server ./http_server/ + awioc pot list my-components + awioc pot remove my-components/http-server + +In ioc.yaml, reference pot components like: + plugins: + - @my-components/http-server + - @my-components/auth-plugin +""" + + async def execute(self, ctx: CommandContext) -> int: + """Execute the pot command.""" + args = ctx.args.copy() + + if not args: + return self._show_help() + + subcommand = args.pop(0).lower() + + if subcommand == "init": + return await self._pot_init(args, ctx) + elif subcommand == "push": + return await self._pot_push(args, ctx) + elif subcommand == "update": + return await self._pot_update(args, ctx) + elif subcommand == "remove": + return await self._pot_remove(args, ctx) + elif subcommand == "list": + return await self._pot_list(args, ctx) + elif subcommand == "info": + return await self._pot_info(args, ctx) + elif subcommand == "delete": + return await self._pot_delete(args, ctx) + elif subcommand == "help": + return self._show_help() + else: + logger.error(f"Unknown subcommand: {subcommand}") + return self._show_help() + + def _show_help(self) -> int: + """Show pot command help.""" + print(self.help_text) + return 0 + + async def _pot_init(self, args: list[str], ctx: CommandContext) -> int: + """Initialize a new pot.""" + if not args: + logger.error("Usage: awioc pot init ") + return 1 + + pot_name = args.pop(0) + + # Validate pot name + if not pot_name.replace("-", "").replace("_", "").isalnum(): + logger.error(f"Invalid pot name: {pot_name}") + logger.error("Pot names must be alphanumeric with hyphens or underscores") + return 1 + + pot_path = get_pot_path(pot_name) + + if pot_path.exists(): + logger.error(f"Pot already exists: {pot_name}") + return 1 + + # Create pot structure + pot_path.mkdir(parents=True) + + manifest = { + "manifest_version": "1.0", + "name": pot_name, + "version": "1.0.0", + "description": f"AWIOC component pot: {pot_name}", + "components": {}, + } + save_pot_manifest(pot_path, manifest) + + logger.info(f"Created pot: {pot_name}") + logger.info(f"Location: {pot_path}") + logger.info(f"\nPush components with: awioc pot push --pot {pot_name}") + return 0 + + async def _pot_push(self, args: list[str], ctx: CommandContext) -> int: + """Push a component to a pot.""" + if not args: + logger.error("Usage: awioc pot push [--pot ]") + return 1 + + component_path = None + pot_name = None + component_name_override = None + + # Parse arguments + while args: + arg = args.pop(0) + if arg == "--pot" and args: + pot_name = args.pop(0) + elif arg == "--name" and args: + component_name_override = args.pop(0) + elif not arg.startswith("-"): + component_path = Path(arg) + + if component_path is None: + logger.error("Component path required") + return 1 + + # Resolve component path + if not component_path.is_absolute(): + component_path = Path.cwd() / component_path + component_path = component_path.resolve() + + if not component_path.exists(): + logger.error(f"Component not found: {component_path}") + return 1 + + # Get list of available pots + pot_dir = get_pot_dir() + available_pots = [d.name for d in pot_dir.iterdir() if d.is_dir()] + + if not pot_name: + if not available_pots: + logger.error("No pots available. Create one with: awioc pot init ") + return 1 + elif len(available_pots) == 1: + pot_name = available_pots[0] + logger.info(f"Using pot: {pot_name}") + else: + logger.error("Multiple pots available. Specify with --pot ") + logger.info(f"Available pots: {', '.join(available_pots)}") + return 1 + + pot_path = get_pot_path(pot_name) + if not pot_path.exists(): + logger.error(f"Pot not found: {pot_name}") + return 1 + + # Extract component metadata + metadata = extract_component_metadata(component_path) + if metadata is None: + logger.error(f"Could not find component metadata in: {component_path}") + logger.error("Ensure the component has __metadata__ (use @as_component decorator)") + return 1 + + component_name = component_name_override or metadata["name"] + # Normalize component name for filesystem + safe_name = component_name.lower().replace(" ", "-").replace("_", "-") + + # Load manifest + manifest = load_pot_manifest(pot_path) + components = manifest.setdefault("components", {}) + + # Check if component already exists + if safe_name in components: + existing = components[safe_name] + logger.warning(f"Component '{safe_name}' already exists (v{existing.get('version', '?')})") + logger.info("Updating to new version...") + + # Determine destination path + if component_path.is_file(): + dest_filename = f"{safe_name}.py" + dest_path = pot_path / dest_filename + # Copy file + shutil.copy2(component_path, dest_path) + else: + # Directory - copy entire directory + dest_path = pot_path / safe_name + if dest_path.exists(): + shutil.rmtree(dest_path) + shutil.copytree(component_path, dest_path) + dest_filename = safe_name + + # Update manifest - use format compatible with manifest.yaml + component_entry = { + "name": metadata["name"], + "version": metadata["version"], + "file": dest_filename, # Use 'file' for consistency with manifest.yaml + "path": dest_filename, # Keep 'path' for backwards compatibility + } + if metadata.get("description"): + component_entry["description"] = metadata["description"] + if metadata.get("class_name"): + component_entry["class"] = metadata["class_name"] # Use 'class' for consistency + component_entry["class_name"] = metadata["class_name"] # Backwards compat + + components[safe_name] = component_entry + save_pot_manifest(pot_path, manifest) + + logger.info(f"Pushed: {metadata['name']} v{metadata['version']}") + logger.info(f"To pot: {pot_name}") + logger.info(f"\nUse in ioc.yaml as: @{pot_name}/{safe_name}") + return 0 + + async def _pot_update(self, args: list[str], ctx: CommandContext) -> int: + """Update a component in a pot from a source path. + + Usage: + awioc pot update / [source-path] + + If source-path is provided, copies files from that path. + If not provided, re-extracts metadata from the existing component files. + """ + if not args: + print("Usage: awioc pot update / [source-path]") + return 1 + + ref = args.pop(0) + source_path = Path(args.pop(0)) if args else None + + # Handle @pot/component syntax + if ref.startswith("@"): + ref = ref[1:] + + if "/" not in ref: + print("Error: Invalid format. Use: pot-name/component-name") + return 1 + + pot_name, component_name = ref.split("/", 1) + pot_path = get_pot_path(pot_name) + + if not pot_path.exists(): + print(f"Error: Pot not found: {pot_name}") + return 1 + + # Load manifest + manifest = load_pot_manifest(pot_path) + components = manifest.get("components", {}) + + if component_name not in components: + print(f"Error: Component not found: {component_name}") + print(f"Available components: {', '.join(components.keys()) or '(none)'}") + return 1 + + component_info = components[component_name] + old_version = component_info.get("version", "?") + + if source_path: + # Update from external source + if not source_path.is_absolute(): + source_path = Path.cwd() / source_path + source_path = source_path.resolve() + + if not source_path.exists(): + print(f"Error: Source not found: {source_path}") + return 1 + + # Extract metadata from source + metadata = extract_component_metadata(source_path) + if metadata is None: + print(f"Error: Could not find component metadata in: {source_path}") + return 1 + + # Get destination path from existing component info + dest_filename = component_info.get("path", component_name) + dest_path = pot_path / dest_filename + + # Copy files + if source_path.is_file(): + shutil.copy2(source_path, dest_path) + else: + # Directory - remove old and copy new + if dest_path.exists(): + shutil.rmtree(dest_path) + shutil.copytree(source_path, dest_path) + + # Update manifest entry + component_info["name"] = metadata["name"] + component_info["version"] = metadata["version"] + if metadata.get("description"): + component_info["description"] = metadata["description"] + if metadata.get("class_name"): + component_info["class"] = metadata["class_name"] + component_info["class_name"] = metadata["class_name"] + + new_version = metadata["version"] + print(f"Updated from source: {source_path}") + + else: + # Re-extract metadata from existing files in pot + component_file = pot_path / component_info.get("path", component_name) + + if not component_file.exists(): + print(f"Error: Component file not found: {component_file}") + return 1 + + metadata = extract_component_metadata(component_file) + if metadata is None: + print(f"Error: Could not extract metadata from: {component_file}") + return 1 + + # Update manifest entry with refreshed metadata + component_info["name"] = metadata["name"] + component_info["version"] = metadata["version"] + if metadata.get("description"): + component_info["description"] = metadata["description"] + if metadata.get("class_name"): + component_info["class"] = metadata["class_name"] + component_info["class_name"] = metadata["class_name"] + + new_version = metadata["version"] + print(f"Refreshed metadata from: {component_file}") + + # Save updated manifest + components[component_name] = component_info + save_pot_manifest(pot_path, manifest) + + print(f"\nUpdated: {component_info['name']}") + print(f" Version: {old_version} -> {new_version}") + print(f" Pot: {pot_name}") + return 0 + + async def _pot_remove(self, args: list[str], ctx: CommandContext) -> int: + """Remove a component from a pot.""" + if not args: + logger.error("Usage: awioc pot remove /") + return 1 + + ref = args.pop(0) + if "/" not in ref: + logger.error("Invalid format. Use: pot-name/component-name") + return 1 + + pot_name, component_name = ref.split("/", 1) + pot_path = get_pot_path(pot_name) + + if not pot_path.exists(): + logger.error(f"Pot not found: {pot_name}") + return 1 + + # Load manifest + manifest = load_pot_manifest(pot_path) + components = manifest.get("components", {}) + + if component_name not in components: + logger.error(f"Component not found: {component_name}") + logger.info(f"Available components: {', '.join(components.keys()) or '(none)'}") + return 1 + + # Get component info and delete file + component_info = components[component_name] + component_file = pot_path / component_info.get("path", component_name) + + if component_file.exists(): + if component_file.is_dir(): + shutil.rmtree(component_file) + else: + component_file.unlink() + + # Update manifest + del components[component_name] + save_pot_manifest(pot_path, manifest) + + logger.info(f"Removed: {component_name} from {pot_name}") + return 0 + + async def _pot_list(self, args: list[str], ctx: CommandContext) -> int: + """List pots or components in a pot.""" + pot_dir = get_pot_dir() + + # If pot name specified, list its components + if args: + pot_name = args.pop(0) + pot_path = get_pot_path(pot_name) + + if not pot_path.exists(): + logger.error(f"Pot not found: {pot_name}") + return 1 + + manifest = load_pot_manifest(pot_path) + components = manifest.get("components", {}) + + print(f"\nPot: {pot_name} (v{manifest.get('version', '?')})") + if manifest.get("description"): + print(f" {manifest['description']}") + print(f"\nComponents ({len(components)}):") + + if not components: + print(" (none)") + else: + for name, info in sorted(components.items()): + version = info.get("version", "?") + desc = info.get("description", "") + desc_text = f" - {desc}" if desc else "" + print(f" {name} (v{version}){desc_text}") + print(f" Usage: @{pot_name}/{name}") + + return 0 + + # List all pots + if not pot_dir.exists(): + print("No pots directory found.") + print(f"Create a pot with: awioc pot init ") + return 0 + + pots = [d for d in pot_dir.iterdir() if d.is_dir()] + + if not pots: + print("No pots found.") + print(f"Create a pot with: awioc pot init ") + return 0 + + print(f"\nPots in {pot_dir}:\n") + for pot_path in sorted(pots): + manifest = load_pot_manifest(pot_path) + version = manifest.get("version", "?") + components = manifest.get("components", {}) + desc = manifest.get("description", "") + + print(f" {pot_path.name} (v{version}) - {len(components)} component(s)") + if desc: + print(f" {desc}") + + print(f"\nUse 'awioc pot list ' to see components in a pot.") + return 0 + + async def _pot_info(self, args: list[str], ctx: CommandContext) -> int: + """Show detailed info about a component.""" + if not args: + logger.error("Usage: awioc pot info /") + return 1 + + ref = args.pop(0) + if "/" not in ref: + logger.error("Invalid format. Use: pot-name/component-name") + return 1 + + pot_name, component_name = ref.split("/", 1) + pot_path = get_pot_path(pot_name) + + if not pot_path.exists(): + logger.error(f"Pot not found: {pot_name}") + return 1 + + manifest = load_pot_manifest(pot_path) + components = manifest.get("components", {}) + + if component_name not in components: + logger.error(f"Component not found: {component_name}") + return 1 + + info = components[component_name] + + print(f"\nComponent: {info.get('name', component_name)}") + print(f" Version: {info.get('version', '?')}") + print(f" Pot: {pot_name}") + print(f" Path: {info.get('path', component_name)}") + if info.get("description"): + print(f" Description: {info['description']}") + if info.get("class_name"): + print(f" Class: {info['class_name']}") + print(f"\n ioc.yaml reference: @{pot_name}/{component_name}") + + # Check if class reference is needed + if info.get("class_name"): + print(f" Full reference: @{pot_name}/{component_name}:{info['class_name']}()") + + return 0 + + async def _pot_delete(self, args: list[str], ctx: CommandContext) -> int: + """Delete an entire pot.""" + if not args: + logger.error("Usage: awioc pot delete ") + return 1 + + pot_name = args.pop(0) + pot_path = get_pot_path(pot_name) + + if not pot_path.exists(): + logger.error(f"Pot not found: {pot_name}") + return 1 + + # Confirm deletion + manifest = load_pot_manifest(pot_path) + component_count = len(manifest.get("components", {})) + + if component_count > 0: + logger.warning(f"Pot '{pot_name}' contains {component_count} component(s)") + + # Delete pot + shutil.rmtree(pot_path) + logger.info(f"Deleted pot: {pot_name}") + return 0 diff --git a/src/awioc/commands/remove.py b/src/awioc/commands/remove.py new file mode 100644 index 0000000..ed2210b --- /dev/null +++ b/src/awioc/commands/remove.py @@ -0,0 +1,175 @@ +"""Remove command - removes plugins or libraries from an AWIOC project.""" + +import logging +from pathlib import Path + +import yaml + +from .base import BaseCommand, CommandContext, register_command +from ..components.registry import as_component + +logger = logging.getLogger(__name__) + + +@register_command("remove") +@as_component( + name="Remove Command", + version="1.0.0", + description="Remove plugins or libraries from an AWIOC project", +) +class RemoveCommand(BaseCommand): + """Remove command that removes plugins or libraries from the configuration. + + Modifies the ioc.yaml file to remove existing plugins or libraries. + """ + + @property + def name(self) -> str: + return "remove" + + @property + def description(self) -> str: + return "Remove plugins or libraries from the project" + + @property + def help_text(self) -> str: + return """Remove plugins or libraries from an AWIOC project. + +Usage: + awioc remove plugin + awioc remove library + +Arguments: + plugin Remove a plugin component + library Remove a library component + Plugin path or index (0-based) in the plugins list + Library identifier + +Options: + -c, --config-path Path to ioc.yaml (default: ./ioc.yaml) + +Examples: + awioc remove plugin plugins/my_plugin.py + awioc remove plugin 0 # Remove first plugin + awioc remove library db +""" + + async def execute(self, ctx: CommandContext) -> int: + """Execute the remove command.""" + args = ctx.args.copy() + + if not args: + logger.error("Usage: awioc remove ") + return 1 + + component_type = args.pop(0).lower() + + if component_type == "plugin": + return await self._remove_plugin(args, ctx) + elif component_type == "library": + return await self._remove_library(args, ctx) + else: + logger.error(f"Unknown component type: {component_type}") + logger.error("Use 'plugin' or 'library'") + return 1 + + async def _remove_plugin(self, args: list[str], ctx: CommandContext) -> int: + """Remove a plugin from the configuration.""" + if not args: + logger.error("Usage: awioc remove plugin ") + return 1 + + identifier = args.pop(0) + config_path = self._get_config_path(ctx) + + if not config_path.exists(): + logger.error(f"Configuration file not found: {config_path}") + return 1 + + # Load existing configuration + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + + plugins = config.get("components", {}).get("plugins", []) + + if not plugins: + logger.error("No plugins configured") + return 1 + + # Try to interpret as index first + removed_plugin = None + try: + index = int(identifier) + if 0 <= index < len(plugins): + removed_plugin = plugins.pop(index) + else: + logger.error(f"Plugin index out of range: {index} (0-{len(plugins) - 1})") + return 1 + except ValueError: + # Not an index, treat as path + if identifier in plugins: + plugins.remove(identifier) + removed_plugin = identifier + else: + logger.error(f"Plugin not found: {identifier}") + logger.info("Configured plugins:") + for i, p in enumerate(plugins): + logger.info(f" [{i}] {p}") + return 1 + + # Write back + config["components"]["plugins"] = plugins + config_path.write_text( + yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False), + encoding="utf-8" + ) + + logger.info(f"Removed plugin: {removed_plugin}") + return 0 + + async def _remove_library(self, args: list[str], ctx: CommandContext) -> int: + """Remove a library from the configuration.""" + if not args: + logger.error("Usage: awioc remove library ") + return 1 + + lib_name = args.pop(0) + config_path = self._get_config_path(ctx) + + if not config_path.exists(): + logger.error(f"Configuration file not found: {config_path}") + return 1 + + # Load existing configuration + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + + libraries = config.get("components", {}).get("libraries", {}) + + if not libraries: + logger.error("No libraries configured") + return 1 + + if lib_name not in libraries: + logger.error(f"Library not found: {lib_name}") + logger.info("Configured libraries:") + for name, path in libraries.items(): + logger.info(f" {name}: {path}") + return 1 + + # Remove the library + removed_path = libraries.pop(lib_name) + + # Write back + config["components"]["libraries"] = libraries + config_path.write_text( + yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=False), + encoding="utf-8" + ) + + logger.info(f"Removed library '{lib_name}': {removed_path}") + return 0 + + def _get_config_path(self, ctx: CommandContext) -> Path: + """Get the configuration file path.""" + if ctx.config_path: + return Path(ctx.config_path) + return Path.cwd() / "ioc.yaml" diff --git a/src/awioc/commands/run.py b/src/awioc/commands/run.py new file mode 100644 index 0000000..6e29522 --- /dev/null +++ b/src/awioc/commands/run.py @@ -0,0 +1,97 @@ +"""Run command - starts the AWIOC application.""" + +import logging +import os + +from .base import BaseCommand, CommandContext, register_command +from ..bootstrap import initialize_ioc_app +from ..components.lifecycle import ( + initialize_components, + shutdown_components, + wait_for_components, +) +from ..components.registry import as_component + +logger = logging.getLogger(__name__) + + +@register_command("run") +@as_component( + name="Run Command", + version="1.0.0", + description="Start the AWIOC application and run components", +) +class RunCommand(BaseCommand): + """Run command that starts the AWIOC application. + + This is the default command that initializes all components, + waits for the application to complete, and handles graceful shutdown. + """ + + @property + def name(self) -> str: + return "run" + + @property + def description(self) -> str: + return "Start the AWIOC application" + + @property + def help_text(self) -> str: + return """Run the AWIOC application. + +This command initializes all configured components (app, libraries, plugins), +waits for the application to complete, and handles graceful shutdown. + +Usage: + awioc run [options] + awioc [options] (run is the default command) + +Options: + -c, --config-path PATH Path to configuration file (YAML/JSON) + --context CONTEXT Environment context (loads .{context}.env) + -v, --verbose Increase verbosity (-v, -vv, -vvv) +""" + + async def execute(self, ctx: CommandContext) -> int: + """Execute the run command.""" + + if ctx.config_path: + os.environ["CONFIG_PATH"] = str(ctx.config_path) + + if ctx.context: + os.environ["CONTEXT"] = ctx.context + + api = initialize_ioc_app() + app = api.provided_app() + + try: + await initialize_components(app) + + exceptions = await initialize_components( + *api.provided_libs(), + return_exceptions=True + ) + + if exceptions: + logger.error( + "Error during library initialization", + exc_info=ExceptionGroup("Initialization Errors", exceptions) + ) + return 1 + + exceptions = await initialize_components( + *api.provided_plugins(), + return_exceptions=True + ) + + if exceptions: + logger.error( + "Error during plugin initialization", + exc_info=ExceptionGroup("Initialization Errors", exceptions) + ) + + await wait_for_components(app) + return 0 + finally: + await shutdown_components(app) diff --git a/src/awioc/components/__init__.py b/src/awioc/components/__init__.py index aaff233..55cf187 100644 --- a/src/awioc/components/__init__.py +++ b/src/awioc/components/__init__.py @@ -1,9 +1,16 @@ +from .lifecycle import ( + initialize_components, + shutdown_components, + register_plugin, + unregister_plugin, +) from .metadata import ( ComponentTypes, Internals, ComponentMetadata, AppMetadata, ComponentMetadataType, + metadata ) from .protocols import ( Component, @@ -17,12 +24,6 @@ component_internals, component_str, ) -from .lifecycle import ( - initialize_components, - shutdown_components, - register_plugin, - unregister_plugin, -) __all__ = [ "ComponentTypes", @@ -30,6 +31,7 @@ "ComponentMetadata", "AppMetadata", "ComponentMetadataType", + "metadata", "Component", "AppComponent", "PluginComponent", diff --git a/src/awioc/components/events.py b/src/awioc/components/events.py new file mode 100644 index 0000000..5e8aa0d --- /dev/null +++ b/src/awioc/components/events.py @@ -0,0 +1,172 @@ +""" +Component event system for lifecycle hooks. + +This module provides an event-oriented approach to component lifecycle management, +allowing registration of callbacks that fire before/after initialization and shutdown. +""" +import inspect +import logging +from dataclasses import dataclass +from enum import Enum +from typing import Callable, Optional, Awaitable, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from .protocols import Component + +logger = logging.getLogger(__name__) + +# Type aliases +EventHandler = Callable[["Component"], Union[None, Awaitable[None]]] +CheckFn = Callable[["Component"], bool] + + +class ComponentEvent(Enum): + """Events emitted during component lifecycle.""" + BEFORE_INITIALIZE = "before_initialize" + AFTER_INITIALIZE = "after_initialize" + BEFORE_SHUTDOWN = "before_shutdown" + AFTER_SHUTDOWN = "after_shutdown" + + +@dataclass +class _RegisteredHandler: + """Internal representation of a registered handler.""" + handler: EventHandler + check: Optional[CheckFn] = None + + +# Registry: event -> list of registered handlers +_handlers: dict[ComponentEvent, list[_RegisteredHandler]] = {} + + +def on_event( + event: ComponentEvent, + check: Optional[CheckFn] = None, + handler: Optional[EventHandler] = None +) -> Union[EventHandler, Callable[[EventHandler], EventHandler]]: + """ + Register an event handler for component lifecycle events. + + Can be used as a direct call or as a decorator: + + # Decorator - all components + @on_event(ComponentEvent.AFTER_INITIALIZE) + async def handle_init(component): + print(f"Initialized: {component.__metadata__['name']}") + + # Decorator - with check function + @on_event(ComponentEvent.AFTER_INITIALIZE, check=lambda c: c.__metadata__["name"] == "my_plugin") + async def handle_plugin_init(component): + print("My plugin initialized!") + + # Direct call + on_event(ComponentEvent.BEFORE_SHUTDOWN, check=is_critical, handler=cleanup) + + :param event: The event type to listen for. + :param check: Optional function that receives the component and returns True if + the handler should be called. If None, handler is called for all components. + :param handler: The callback function (sync or async). If None, returns a decorator. + :return: The handler, or a decorator if handler is None. + """ + + def _register(h: EventHandler) -> EventHandler: + if event not in _handlers: + _handlers[event] = [] + _handlers[event].append(_RegisteredHandler(handler=h, check=check)) + logger.debug("Registered handler for %s (with check: %s)", event.value, check is not None) + return h + + if handler is None: + return _register + return _register(handler) + + +# Alias for convenience +on = on_event + + +def _get_component_handler(component: "Component", event: ComponentEvent) -> Optional[EventHandler]: + """ + Get the component-specific handler for an event, if it exists. + + Components can define handlers as attributes named after the event: + - on_before_initialize + - on_after_initialize + - on_before_shutdown + - on_after_shutdown + + :param component: The component to check. + :param event: The event type. + :return: The handler function if found, None otherwise. + """ + handler_name = f"on_{event.value}" + handler = getattr(component, handler_name, None) + if handler is not None and callable(handler): + return handler + return None + + +async def emit(component: "Component", event: ComponentEvent) -> None: + """ + Emit an event for a component, calling all registered handlers whose check passes. + + Also calls component-specific handlers if the component has an attribute named + after the event (e.g., on_before_initialize, on_after_initialize, etc.). + + :param component: The component emitting the event. + :param event: The event being emitted. + """ + # Check for component-specific handler first (called without arguments, uses self) + component_handler = _get_component_handler(component, event) + if component_handler is not None: + try: + logger.debug("Calling component handler %s for %s", + f"on_{event.value}", component.__metadata__["name"]) + result = component_handler() + if inspect.iscoroutine(result): + await result + except Exception as e: + logger.exception("Error in component handler for %s on %s: %s", + event.value, component.__metadata__["name"], e) + raise + + # Then call globally registered handlers + if event not in _handlers: + return + + handlers_to_call = [] + for registered in _handlers[event]: + # If no check function, always call; otherwise check must return True + if registered.check is None or registered.check(component): + handlers_to_call.append(registered.handler) + + if not handlers_to_call: + return + + logger.debug("Emitting %s for component %s (%d global handlers)", + event.value, component.__metadata__["name"], len(handlers_to_call)) + + for handler in handlers_to_call: + try: + result = handler(component) + if inspect.iscoroutine(result): + await result + except Exception as e: + logger.exception("Error in event handler for %s on %s: %s", + event.value, component.__metadata__["name"], e) + raise + + +def clear_handlers(event: Optional[ComponentEvent] = None) -> None: + """ + Clear event handlers. + + :param event: If provided, only clear handlers for this event. + If None, clear all handlers. + """ + if event is None: + _handlers.clear() + logger.debug("Cleared all event handlers") + elif event in _handlers: + del _handlers[event] + logger.debug("Cleared handlers for %s", event.value) diff --git a/src/awioc/components/lifecycle.py b/src/awioc/components/lifecycle.py index 602f220..0eae651 100644 --- a/src/awioc/components/lifecycle.py +++ b/src/awioc/components/lifecycle.py @@ -1,11 +1,13 @@ import asyncio import inspect import logging - +from datetime import datetime from typing import TYPE_CHECKING +from .events import emit, ComponentEvent +from .metadata import RegistrationInfo from .protocols import Component, PluginComponent -from .registry import component_requires, component_internals, component_str +from .registry import component_internals, component_str, component_initialized, clean_module_name if TYPE_CHECKING: from ..container import ContainerInterface @@ -34,14 +36,11 @@ async def __initialize(comp: Component): comp.__metadata__['name'], comp.__metadata__['version']) return - if any(not component_internals(required).is_initialized - for required in component_requires(comp) - if required not in components - ): - logger.debug("Component dependencies not initialized: %s v%s", - comp.__metadata__['name'], - comp.__metadata__['version']) - return + # Note: Dependency initialization check is handled by registration order. + # component_requires() returns component names (strings), not objects. + + await emit(comp, ComponentEvent.BEFORE_INITIALIZE) + if hasattr(comp, "initialize") and comp.initialize is not None: logger.debug("Initializing component: %s v%s", comp.__metadata__['name'], @@ -64,6 +63,8 @@ async def __initialize(comp: Component): comp.__metadata__['name'], comp.__metadata__['version']) + await emit(comp, ComponentEvent.AFTER_INITIALIZE) + _ret = await asyncio.gather( *map(__initialize, components), return_exceptions=return_exceptions @@ -113,6 +114,9 @@ async def __shutdown(comp: Component): comp.__metadata__['name'], comp.__metadata__['version']) return + + await emit(comp, ComponentEvent.BEFORE_SHUTDOWN) + if hasattr(comp, "shutdown") and comp.shutdown is not None: logger.debug("Shutting down component: %s v%s", comp.__metadata__['name'], @@ -131,6 +135,8 @@ async def __shutdown(comp: Component): comp.__metadata__['name'], comp.__metadata__['version']) + await emit(comp, ComponentEvent.AFTER_SHUTDOWN) + _ret = await asyncio.gather( *map(__shutdown, components), return_exceptions=return_exceptions @@ -138,17 +144,34 @@ async def __shutdown(comp: Component): _exceptions = [_exc for _exc in _ret if isinstance(_exc, Exception)] - if _exceptions: - if not return_exceptions: # pragma: no cover - raise ExceptionGroup( - "One or more errors occurred during component shutdown.", - _exceptions - ) + if return_exceptions: return _exceptions + if _exceptions: # TODO: add test coverage + raise ExceptionGroup( + "One or more errors occurred during component shutdown.", + _exceptions + ) + return components +def _find_caller_frame() -> inspect.FrameInfo: + """Find the actual caller frame, skipping internal/injector frames.""" + skip_modules = { + "dependency_injector.wiring", + "awioc.components.lifecycle", + } + + for frame in inspect.stack()[1:]: + module_name = frame.frame.f_globals.get("__name__", "") + if module_name not in skip_modules: + return frame + + # Fallback to immediate caller if nothing found + return inspect.stack()[1] # TODO: add test coverage + + async def register_plugin( api_container: "ContainerInterface", plugin: PluginComponent @@ -159,7 +182,7 @@ async def register_plugin( :param api_container: The application container. :param plugin: The plugin component to register. """ - caller_frame = inspect.stack()[2] # Get the frame of the caller of register_plugin. Avoid Inject frame. + caller_frame = _find_caller_frame() if plugin in api_container.provided_plugins(): logger.warning("Plugin already registered: %s v%s [From: %s.%s]", @@ -169,7 +192,16 @@ async def register_plugin( caller_frame.lineno) return plugin - api_container.register_plugins(plugin) + # Capture registration info from the actual caller (the component that called register_plugin) + module_name = caller_frame.frame.f_globals.get("__name__", "unknown") + registration = RegistrationInfo( + registered_by=clean_module_name(module_name), + registered_at=datetime.now(), + file=caller_frame.filename, + line=caller_frame.lineno + ) + + api_container.register_plugins(plugin, _registration=registration) logger.debug("Registering plugin: %s v%s [From: %s.%s]", plugin.__metadata__['name'], @@ -177,9 +209,6 @@ async def register_plugin( caller_frame.filename, caller_frame.lineno) - from ..di.wiring import wire - wire(api_container, components=(plugin,)) - return plugin @@ -244,7 +273,9 @@ async def unregister_plugin( caller_frame.lineno) return - if component_internals(plugin).required_by: + if any(component_initialized(requirer) + for requirer + in component_internals(plugin).required_by): raise RuntimeError( f"Cannot unregister plugin {component_str(plugin)}; " "it is still required by other components" diff --git a/src/awioc/components/metadata.py b/src/awioc/components/metadata.py index 0d04a71..5dfae56 100644 --- a/src/awioc/components/metadata.py +++ b/src/awioc/components/metadata.py @@ -1,6 +1,14 @@ from dataclasses import dataclass, field +from datetime import datetime from enum import Enum -from typing import TYPE_CHECKING, TypedDict, Optional, Union +from typing import ( + TYPE_CHECKING, + TypedDict, + Optional, + Union, + overload, + Iterable +) import pydantic @@ -16,16 +24,34 @@ class ComponentTypes(Enum): COMPONENT = "component" +@dataclass +class RegistrationInfo: + """Information about who/what registered a component.""" + registered_by: str # Module/caller that registered the component + registered_at: datetime # Timestamp when registration occurred + file: Optional[str] = None # File path where registration occurred + line: Optional[int] = None # Line number where registration occurred + + def __str__(self) -> str: + parts = [f"by '{self.registered_by}'", f"at {self.registered_at.isoformat()}"] + if self.file: + location = f"{self.file}:{self.line}" if self.line else self.file + parts.append(f"from {location}") + return f"RegistrationInfo({', '.join(parts)})" + + @dataclass class Internals: + requires: set["Component"] = field(default_factory=set) required_by: set["Component"] = field(default_factory=set) initialized_by: set["Component"] = field(default_factory=set) is_initialized: bool = False is_initializing: bool = False is_shutting_down: bool = False - ioc_components_definition: Optional[type[pydantic.BaseModel]] = None ioc_config: Optional[type["Settings"]] = None type: ComponentTypes = ComponentTypes.COMPONENT + registration: Optional[RegistrationInfo] = None + source_ref: Optional[str] = None # Original reference used to load component (e.g., @pot/name) class ComponentMetadata(TypedDict): @@ -39,7 +65,7 @@ class ComponentMetadata(TypedDict): description (str): A brief description of the component. wire (Optional[bool]): Whether the component should be auto-wired. wirings (Optional[set[str]]): A set of module names to wire. - requires (Optional[set[Component]]): A set of other components this component depends on. + requires (Optional[set[str]]): A set of component names this component depends on. Those components MUST be registered in the container for this component to work. config (Optional[type[BaseModel]]): An optional Pydantic model for configuration. """ @@ -48,7 +74,7 @@ class ComponentMetadata(TypedDict): description: str wire: Optional[bool] wirings: Optional[set[str]] - requires: Optional[set["Component"]] + requires: Optional[set[str]] config: Optional[set[type[pydantic.BaseModel]]] _internals: Optional["Internals"] @@ -60,3 +86,98 @@ class AppMetadata(ComponentMetadata): # Type alias for flexibility ComponentMetadataType = Union[ComponentMetadata, dict] + + +@overload +def metadata( + *, + name: str, + version: str, + description: str, + wire: Optional[bool] = True, + wirings: Optional[set[str]] = None, + requires: Optional[set[Union["Component", str]]] = None, + config: Optional[Union[set[type[pydantic.BaseModel]], type[pydantic.BaseModel]]] = None, + **kwargs +) -> ComponentMetadata: + ... + + +@overload +def metadata( + *, + name: str, + version: str, + description: str, + wire: Optional[bool] = True, + wirings: Optional[Iterable[str]] = None, + requires: Optional[Iterable[Union["Component", str]]] = None, + config: Optional[Union[Iterable[type[pydantic.BaseModel]], type[pydantic.BaseModel]]] = None, + base_config: Optional[type["Settings"]], + **kwargs +) -> AppMetadata: + ... + + +def _get_component_name(component: Union["Component", str]) -> str: + """Extract component name from a Component type or string.""" + if isinstance(component, str): + return component + if hasattr(component, "__metadata__") and "name" in component.__metadata__: + return component.__metadata__["name"] + return getattr(component, "__qualname__", component.__class__.__qualname__) + + +def metadata( + *, + name: str, + version: str, + description: str, + wire: Optional[bool] = True, + wirings: Optional[Iterable[str]] = None, + requires: Optional[Iterable[Union["Component", str]]] = None, + config: Optional[Union[Iterable[type[pydantic.BaseModel]], type[pydantic.BaseModel]]] = None, + base_config: Optional[type["Settings"]] = None, + **kwargs +) -> Union[ComponentMetadata, AppMetadata]: + """ + Create metadata for a component. + + Args: + name (str): The name of the component. + version (str): The version of the component. + description (str): A brief description of the component. + wire (Optional[bool]): Whether the component should be auto-wired. + wirings (Optional[Iterable[str]]): An iterable of module names to wire. + requires (Optional[Iterable[Component | str]]): An iterable of other components (types or names) this component depends on. + Those components MUST be registered in the container for this component to work. + Component types are converted to their names for storage. + config (Optional[Union[Iterable[type[BaseModel]], type[BaseModel]]]): An optional Pydantic model or iterable of models for configuration. + base_config (Optional[type[Settings]]): An optional base configuration class for app components. + **kwargs: Additional keyword arguments to include in the metadata. + """ + if wirings is not None: + wirings = set(wirings) + if requires is not None: + # Convert Component types to their names + requires = set(_get_component_name(req) for req in requires) + if config is not None: + if isinstance(config, type) and issubclass(config, pydantic.BaseModel): + config = {config} + else: + config = set(config) + + meta: ComponentMetadataType = { + "name": name, + "version": version, + "description": description, + "wire": wire, + "wirings": wirings or set(), + "requires": requires or set(), + "config": config or set(), + "_internals": None, + **kwargs + } + if base_config is not None: + meta["base_config"] = base_config + return meta diff --git a/src/awioc/components/protocols.py b/src/awioc/components/protocols.py index 145bd6c..03299c9 100644 --- a/src/awioc/components/protocols.py +++ b/src/awioc/components/protocols.py @@ -5,6 +5,23 @@ @runtime_checkable class Component(Protocol): + """ + Protocol defining the interface for components. + + Required: + __metadata__: ComponentMetadata dictionary + + Optional lifecycle methods: + initialize: Async method called during component initialization + shutdown: Async method called during component shutdown + wait: Async method for blocking until shutdown + + Optional event handlers (auto-called during lifecycle): + on_before_initialize: Called before initialize() + on_after_initialize: Called after initialize() + on_before_shutdown: Called before shutdown() + on_after_shutdown: Called after shutdown() + """ __metadata__: ComponentMetadata initialize: Optional[Callable[..., Coroutine[Any, Any, None]]] diff --git a/src/awioc/components/registry.py b/src/awioc/components/registry.py index 4722487..ba490fe 100644 --- a/src/awioc/components/registry.py +++ b/src/awioc/components/registry.py @@ -1,60 +1,141 @@ import logging -from typing import Any +from typing import Any, Optional, Callable, overload, Iterable, TypeVar, Union -from .metadata import Internals +from pydantic import BaseModel + +from .metadata import Internals, RegistrationInfo, metadata from .protocols import Component logger = logging.getLogger(__name__) +C = TypeVar("C", bound=Any) + + +@overload +def as_component(ref: object) -> Component: ... + + +@overload +def as_component( + *, + name: Optional[str] = ..., + version: Optional[str] = ..., + description: Optional[str] = ..., + wire: bool = ..., + wirings: Optional[Iterable[str]] = ..., + requires: Optional[Iterable[Union[Component, str]]] = ..., + config: Optional[Iterable[type[BaseModel]] | type[BaseModel]] = ..., + base_config: Optional[type[Any]] = ... +) -> Callable[[object], Component]: ... + + +def as_component( + ref: Optional[object] = None, + *, + name: Optional[str] = None, + version: Optional[str] = None, + description: Optional[str] = "", + wire: bool = True, + wirings: Optional[Iterable[str]] = None, + requires: Optional[Iterable[Union[Component, str]]] = None, + config: Optional[Iterable[type[BaseModel]] | type[BaseModel]] = None, + base_config: Optional[type[Any]] = None, +): + def decorator(obj: C) -> C: + """ + Decorator to convert an object to a Component by adding metadata. + + :param obj: The object to convert. + :return: The object as a Component. + """ + if hasattr(obj, "__metadata__"): + obj_metadata = obj.__metadata__ + else: + obj_metadata = {} + + updated_metadata = metadata( + name=name or getattr(obj, "__qualname__", obj.__class__.__qualname__), + version=version or "0.0.0", + description=description or (getattr(obj, "__doc__", "") or ""), + wire=wire, + wirings=wirings, + requires=requires, + config=config, + base_config=base_config + ) + + updated_metadata.update(obj_metadata) + obj.__metadata__ = updated_metadata + + if not hasattr(obj, "initialize"): + obj.initialize = None + + if not hasattr(obj, "shutdown"): + obj.shutdown = None + + if not hasattr(obj, "wait"): + obj.wait = None + + return obj + + return decorator if ref is None else decorator(ref) + + +def component_required_by(component: Component) -> set[Component]: + """ + Get the components that require a given component. -def as_component(obj: Any) -> Component: + :param component: The component to analyze. """ - Convert an object to a Component by adding metadata if missing. + _internals = component_internals(component) + assert _internals is not None + assert _internals.required_by is not None + return _internals.required_by + - :param obj: The object to convert. - :return: The object as a Component. +def component_requires(*components: Component, recursive: bool = False) -> set[Component]: """ - if not hasattr(obj, "__metadata__"): - name = getattr(obj, "__qualname__", obj.__class__.__qualname__) - logger.debug("Converting object to component: %s", name) - obj.__metadata__ = { - "name": name, - "version": "0.0.0", - "wire": False, - "description": getattr(obj, "__doc__", "") or "" - } + Get the components required by one or more components. - if not hasattr(obj, "initialize"): - obj.initialize = None + :param components: The component(s) to analyze. + :param recursive: If True, recursively resolve all dependencies. + :return: Set of required components. + """ + result: set[Component] = set() - if not hasattr(obj, "shutdown"): - obj.shutdown = None + def get_requires(comp: Component) -> set[Component]: + """Get direct requirements of a single component.""" + if not hasattr(comp, "__metadata__"): + return set() - if not hasattr(obj, "wait"): - obj.wait = None + metadata = comp.__metadata__ - return obj + # Try _internals.requires first (contains resolved component objects) + if "_internals" in metadata and metadata["_internals"] is not None: + internals = metadata["_internals"] + if internals.requires: + return internals.requires + # Fallback to metadata requires (may contain strings or objects) + return metadata.get("requires", set()) or set() -def component_requires(*components: Component, recursive: bool = False) -> set[Component]: - """ - Get the full set of components required by the given components. + def collect_recursive(comp: Component, visited: set[int]) -> None: + """Recursively collect all dependencies.""" + comp_id = id(comp) + if comp_id in visited: + return + visited.add(comp_id) - :param components: The initial components to analyze. - :param recursive: Whether to include dependencies of dependencies. - :return: A set of all required components. - """ - required = set() + for req in get_requires(comp): + result.add(req) + if recursive: + collect_recursive(req, visited) + visited: set[int] = set() for component in components: - for req in component.__metadata__.get("requires", set()): - if req in required: - continue - required.add(req) - if recursive: - required.update(component_requires(req, recursive=True)) + collect_recursive(component, visited) - return required + return result def component_internals(component: Component) -> Internals: @@ -68,12 +149,49 @@ def component_internals(component: Component) -> Internals: return component.__metadata__["_internals"] -def component_str(comp: Component) -> str: +def component_str(component: Component) -> str: """ Get a string representation of a component. - :param comp: The component. - :return: String in format "name vversion". + :param component: The component. + :return: String in format "name version". """ - meta = comp.__metadata__ + assert hasattr(component, "__metadata__") + meta = component.__metadata__ return f"{meta['name']} v{meta['version']}" + + +def component_initialized(component: Component) -> bool: + assert hasattr(component, "__metadata__") + if "_internals" not in component.__metadata__ or component.__metadata__["_internals"] is None: + return False + return component.__metadata__["_internals"].is_initialized + + +def component_registration(component: Component) -> Optional[RegistrationInfo]: + """ + Get the registration information of a component. + + Returns information about who/what registered the component, + including the source type, registrar identifier, and location. + + :param component: The component to analyze. + :return: The registration info, or None if not available. + """ + assert hasattr(component, "__metadata__") + if "_internals" not in component.__metadata__ or component.__metadata__["_internals"] is None: + return None + return component.__metadata__["_internals"].registration + + +def clean_module_name(name: str) -> str: + """ + Clean up module name for display, removing __init__ and __main__ parts. + + :param name: The raw module name (e.g., "__init__.dashboard"). + :return: Cleaned module name (e.g., "dashboard"). + """ + if not name: + return "unknown" + parts = [p for p in name.split(".") if p not in ("__init__", "__main__")] + return ".".join(parts) if parts else name diff --git a/src/awioc/config/base.py b/src/awioc/config/base.py index b7138fc..46a844e 100644 --- a/src/awioc/config/base.py +++ b/src/awioc/config/base.py @@ -7,23 +7,22 @@ from .registry import _CONFIGURATIONS _P_type = TypeVar("_P_type", bound=pydantic.BaseModel) -_S_type = TypeVar("_S_type", bound=type["Settings"]) class Settings(settings.BaseSettings): model_config = settings.SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", - extra="ignore", env_nested_delimiter="_", env_nested_max_split=1, env_prefix="", + extra="ignore", cli_avoid_json=True, validate_default=True ) @cached(cache={}, key=lambda _, type_: type_) - def get_config(self, config_type: type[_P_type]) -> _P_type: + def get_config(self, config_type: type[_P_type]) -> _P_type: # TODO: add test coverage for loop branch for name, model in _CONFIGURATIONS.items(): if model == config_type: # Attribute name is the class name, not the prefix @@ -31,7 +30,7 @@ def get_config(self, config_type: type[_P_type]) -> _P_type: raise ValueError(f"Configuration for type {config_type} not found") @classmethod - def load_config(cls: type[_S_type]) -> _S_type: + def load_config(cls) -> "Settings": mapped_fields = { value.__name__: pydantic.Field(default_factory=value, alias=key) for key, value in _CONFIGURATIONS.items() diff --git a/src/awioc/config/models.py b/src/awioc/config/models.py index a6033df..c7d3bc5 100644 --- a/src/awioc/config/models.py +++ b/src/awioc/config/models.py @@ -1,30 +1,103 @@ from pathlib import Path -from typing import Optional +from typing import Optional, Callable import pydantic +from pydantic_settings import PydanticBaseSettingsSource, BaseSettings from .base import Settings from ..utils import expanded_path +_sources: list[ + Callable[ + [type[BaseSettings]], + PydanticBaseSettingsSource] +] = [] + + +def _expand_component_path(component_ref: str) -> str: + """ + Expand the path portion of a component reference. + + Component references can be in the format: + - "path/to/module" - just a path + - "path/to/module:attribute" - path with attribute reference + + :param component_ref: The component reference string. + :return: The reference with expanded path. + """ + if ":" in component_ref: + path_part, ref_part = component_ref.rsplit(":", 1) + return f"{expanded_path(path_part)}:{ref_part}" + return str(expanded_path(component_ref)) + class IOCComponentsDefinition(pydantic.BaseModel): - app: Path + app: str + + libraries: dict[str, str] = pydantic.Field(default_factory=dict) + plugins: list[str] = pydantic.Field(default_factory=list) - libraries: dict[str, Path] = pydantic.Field(default_factory=dict) - plugins: list[Path] = pydantic.Field(default_factory=list) + @pydantic.model_validator(mode="after") + def validate_paths(self) -> "IOCComponentsDefinition": + self.app = _expand_component_path(self.app) + self.libraries = { + name: _expand_component_path(path) + for name, path in self.libraries.items() + } + self.plugins = [ + _expand_component_path(path) + for path in self.plugins + ] + return self class IOCBaseConfig(Settings): config_path: Path = pydantic.Field( default=Path("ioc.yaml"), - description="Path to the IOC components configuration file (YAML/JSON)" + description="Path to the IOC components configuration file (YAML/JSON)", + exclude=True ) context: Optional[str] = pydantic.Field( default=None, - description="Environment context (loads .{context}.env file)" + description="Environment context (loads .{context}.env file)", + exclude=True ) + ioc_components_definitions: Optional[IOCComponentsDefinition] = pydantic.Field( + default=None, + description="Loaded IOC components definition", + alias="components" + ) + + @classmethod + def add_sources( + cls, + *sources: Callable[[pydantic.ConfigDict], PydanticBaseSettingsSource], + index: int = -1, + ) -> None: + # if index is -1, append to the end + if index == -1: + _sources.extend(sources) + else: + _sources.insert(index, *sources) + + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + return ( + init_settings, + env_settings, + dotenv_settings, + file_secret_settings + ) + tuple(map(lambda s: s(settings_cls), _sources)) + @pydantic.field_validator("config_path", mode="before") @classmethod def validate_config_path(cls, v): diff --git a/src/awioc/config/registry.py b/src/awioc/config/registry.py index b1466d7..643db6b 100644 --- a/src/awioc/config/registry.py +++ b/src/awioc/config/registry.py @@ -1,6 +1,6 @@ import inspect import logging -from typing import TypeVar, Optional +from typing import TypeVar, Optional, Iterable import pydantic @@ -46,7 +46,19 @@ def __wrapper__(model: _M_type): return __wrapper__ if _ is None else __wrapper__(_) -def clear_configurations(): - """Clear all registered configurations.""" - logger.debug("Clearing all registered configurations") - _CONFIGURATIONS.clear() +def clear_configurations( + prefixes: Optional[Iterable[str]] = None +): + """ + Clear all registered configurations. + + :param prefixes: Specific prefixes to clear. If None, clears all configurations. + """ + if prefixes is None: + _CONFIGURATIONS.clear() + logger.debug("Cleared all registered configurations") + else: + for prefix in prefixes: + if prefix in _CONFIGURATIONS: + del _CONFIGURATIONS[prefix] + logger.debug("Cleared configuration with prefix '%s'", prefix) diff --git a/src/awioc/container.py b/src/awioc/container.py index 23c812a..9fbf55f 100644 --- a/src/awioc/container.py +++ b/src/awioc/container.py @@ -1,22 +1,29 @@ +import inspect import logging +from datetime import datetime from logging import Logger -from typing import TypeVar, Optional, overload +from typing import TypeVar, Optional, overload, Union import pydantic from dependency_injector import containers, providers -from .components.metadata import ComponentTypes, Internals +from .components.metadata import ComponentTypes, Internals, RegistrationInfo from .components.protocols import ( Component, AppComponent, PluginComponent, LibraryComponent, ) -from .components.registry import component_requires, component_internals +from .components.registry import ( + component_internals, + component_initialized, + clean_module_name, +) from .config.base import Settings from .config.models import IOCBaseConfig _Lib_type = TypeVar("_Lib_type") +_Plugin_type = TypeVar("_Plugin_type") _Model_type = TypeVar("_Model_type", bound=pydantic.BaseModel) logger = logging.getLogger(__name__) @@ -49,12 +56,13 @@ def __init__(self, container: AppContainer) -> None: ) @property - def app_config_model(self): + def app_config_model(self) -> type[IOCBaseConfig]: app = self.provided_app() meta = app.__metadata__ if "base_config" in meta and meta["base_config"] is not None: cfg_cls = meta["base_config"] - assert issubclass(cfg_cls, Settings) + assert issubclass(cfg_cls, + IOCBaseConfig), f"App base_config must be subclass of IOCBaseConfig, got: {cfg_cls}" return cfg_cls return IOCBaseConfig @@ -80,7 +88,17 @@ def raw_container(self) -> AppContainer: def provided_libs(self) -> set[LibraryComponent]: return set(lib() for lib in self._libs_map.values()) - def provided_lib(self, type_: type[_Lib_type]) -> _Lib_type: + @overload + def provided_lib(self, type_: type[_Lib_type]) -> _Lib_type: # pragma: no cover + ... + + @overload + def provided_lib(self, type_: str) -> _Lib_type: # pragma: no cover + ... + + def provided_lib(self, type_: Union[type[_Lib_type], str]) -> _Lib_type: # TODO: add test coverage + if isinstance(type_, str): + return self._libs_map[type_]() return self._libs_map[type_.__qualname__]() @overload @@ -105,48 +123,86 @@ def provided_app(self) -> AppComponent: def provided_plugins(self) -> set[PluginComponent]: return set(plugin() for plugin in self._plugins_map.values()) + @overload + def provided_plugin(self, type_: type[_Plugin_type]) -> Optional[_Plugin_type]: # pragma: no cover + ... + + @overload + def provided_plugin(self, type_: str) -> Optional[_Plugin_type]: # pragma: no cover + ... + + def provided_plugin(self, type_: Union[_Plugin_type, str]) -> Optional[_Plugin_type]: # TODO: add test coverage + if isinstance(type_, str): + provider = self._plugins_map.get(type_) + else: + provider = self._plugins_map.get(type_.__metadata__["name"]) # TODO: add test coverage + return provider() if provider is not None else None + + def provided_component(self, name: str) -> Optional[Component]: # TODO: add test coverage + """Get a component by its registered name/id.""" + provider = self._container.components().get(name) + return provider() if provider is not None else None + def provided_logger(self) -> Logger: return self._container.logger() - @classmethod - def __init_component(cls, component: Component) -> Internals: + @staticmethod + def __capture_registration_info(stack_level: int = 2) -> RegistrationInfo: + """Capture registration info from the call stack.""" + frame = inspect.stack()[stack_level] + module_name = frame.frame.f_globals.get("__name__", "unknown") + return RegistrationInfo( + registered_by=clean_module_name(module_name), + registered_at=datetime.now(), + file=frame.filename, + line=frame.lineno + ) + + def __init_component( + self, + component: Component, + registration: RegistrationInfo + ) -> Internals: assert hasattr(component, "__metadata__") - assert "_internals" not in component.__metadata__ + assert not component_initialized(component) _internals = Internals() + _internals.registration = registration component.__metadata__["_internals"] = _internals - for req in component_requires(component): - if not cls.__component_initialized(req): - cls.__init_component(req) - req.__metadata__["_internals"].required_by.add(component) + for req in self._compute_requirements(component): + # Check if the required component has internals (is registered) + req_internals = req.__metadata__.get("_internals") + if req_internals is not None: + req_internals.required_by.add(component) + _internals.requires.add(req) return _internals - @staticmethod - def __deinit_component(component: Component): + def __deinit_component(self, component: Component): assert hasattr(component, "__metadata__") - assert "_internals" in component.__metadata__ - for req in component_requires(component): - req.__metadata__["_internals"].required_by.discard(component) + if "_internals" not in component.__metadata__ or component.__metadata__["_internals"] is None: + return - component.__metadata__["_internals"] = None + # component_requires returns component names (strings) + for req in self._compute_requirements(component): + req_internals = req.__metadata__.get("_internals") + if req_internals is not None: + req_internals.required_by.discard(component) - @staticmethod - def __component_initialized(component: Component) -> bool: - assert hasattr(component, "__metadata__") - return "_internals" in component.__metadata__ + component.__metadata__["_internals"] = None def register_libraries( self, *libs: tuple[str | type, LibraryComponent] ) -> None: logger.debug("Registering %d libraries", len(libs)) + registration = self.__capture_registration_info(stack_level=2) for key, lib in libs: lib_id = key if isinstance(key, str) else key.__qualname__ - self.__init_component(lib) + self.__init_component(lib, registration) component_internals(lib).type = ComponentTypes.LIBRARY provider = providers.Object(lib) @@ -165,13 +221,16 @@ def unregister_libraries( # pragma: no cover def register_plugins( self, - *plugins: PluginComponent + *plugins: PluginComponent, + _registration: Optional[RegistrationInfo] = None ) -> None: logger.debug("Registering %d plugins", len(plugins)) + # Use provided registration info (from lifecycle helpers) or capture automatically + registration = _registration or self.__capture_registration_info(stack_level=2) for plugin in plugins: plugin_id = plugin.__metadata__["name"] - self.__init_component(plugin) + self.__init_component(plugin, registration) component_internals(plugin).type = ComponentTypes.PLUGIN provider = providers.Object(plugin) @@ -197,7 +256,8 @@ def set_app(self, app: AppComponent) -> None: app_name = app.__metadata__["name"] logger.debug("Setting app component: %s", app_name) - self.__init_component(app) + registration = self.__capture_registration_info(stack_level=2) + self.__init_component(app, registration) component_internals(app).type = ComponentTypes.APP self._app_component = app @@ -214,3 +274,30 @@ def set_config(self, config: Settings) -> None: self._container.config.override( providers.Object(config) ) + + def _compute_requirements( + self, + *components: Component, + recursive: bool = False + ) -> set[Component]: + required = set() + + for component in components: + if "requires" not in component.__metadata__: + requires = set() + elif not component.__metadata__["requires"]: + requires = set() + else: + requires = component.__metadata__["requires"] + + for req in requires: + if req in required: + continue + req = self.provided_component(req) + if req is None: + continue + required.add(req) + if recursive: + required.update(self._compute_requirements(req, recursive=True)) + + return required diff --git a/src/awioc/di/__init__.py b/src/awioc/di/__init__.py index 36f8ab1..415b956 100644 --- a/src/awioc/di/__init__.py +++ b/src/awioc/di/__init__.py @@ -5,6 +5,7 @@ get_raw_container, get_app, get_logger, + get_plugin ) from .wiring import wire, inject_dependencies @@ -15,6 +16,7 @@ "get_raw_container", "get_app", "get_logger", + "get_plugin", "wire", "inject_dependencies", ] diff --git a/src/awioc/di/providers.py b/src/awioc/di/providers.py index ab92bba..6eeb5bd 100644 --- a/src/awioc/di/providers.py +++ b/src/awioc/di/providers.py @@ -1,24 +1,30 @@ import inspect from logging import Logger -from typing import TypeVar, Optional, Union, overload +from types import ModuleType +from typing import TypeVar, Optional, Union, overload, Any import pydantic from dependency_injector.wiring import Provide, provided -from ..components.protocols import AppComponent +from ..components.protocols import AppComponent, Component +from ..components.registry import clean_module_name from ..container import AppContainer, ContainerInterface -_Lib_type = TypeVar("_Lib_type") -_Model_type = TypeVar("_Model_type", bound=pydantic.BaseModel) +_Component = Union[Component, ModuleType, Any] +_AppComponent = Union[AppComponent, ModuleType, Any] +_Component_type = Union[Component, Any] +_Lib_type = TypeVar("_Lib_type", bound=_Component) +_Plugin_type = TypeVar("_Plugin_type", bound=_Component) +_Model_type = TypeVar("_Model_type", bound=pydantic.BaseModel) @overload -def get_library(type_: type[_Lib_type]) -> _Lib_type: # pragma: no cover +def get_library(type_: str) -> _Component_type: # pragma: no cover ... @overload -def get_library(type_: str) -> _Lib_type: # pragma: no cover +def get_library(type_: type[_Lib_type]) -> _Lib_type: # pragma: no cover ... @@ -26,6 +32,30 @@ def get_library(type_: Union[type[_Lib_type], str]) -> _Lib_type: return Provide["api", provided().provided_lib.call(type_)] +def get_plugin(type_: str) -> Optional[_Component_type]: # TODO: add test coverage + return Provide["api", provided().provided_plugin.call(type_)] + + +@overload +def get_component() -> _Component_type: # pragma: no cover + ... + + +@overload +def get_component(name: str) -> Optional[_Component_type]: # pragma: no cover + ... + + +def get_component(name: Optional[str] = None) -> Optional[_Component_type]: # TODO: add test coverage + if name is None: + calling_frame = inspect.stack()[1] + mod = inspect.getmodule(calling_frame[0]) + if mod is None: + raise RuntimeError("Cannot determine calling component: no module found") + name = clean_module_name(mod.__name__) + return Provide["api", provided().provided_component.call(name)] + + @overload def get_config(model: type[_Model_type]) -> _Model_type: # pragma: no cover ... @@ -50,7 +80,7 @@ def get_raw_container() -> AppContainer: return Provide["__self__", provided()] -def get_app() -> AppComponent: +def get_app() -> _AppComponent: return Provide["app", provided()] @@ -68,7 +98,7 @@ def get_logger(*name: str) -> Logger: if not name: calling_frame = inspect.stack()[1] mod = inspect.getmodule(calling_frame[0]) - name = mod.__name__ if mod else "logger" + name = clean_module_name(mod.__name__) if mod else "logger" else: name = ".".join(name) diff --git a/src/awioc/di/wiring.py b/src/awioc/di/wiring.py index cca15ea..6c855c1 100644 --- a/src/awioc/di/wiring.py +++ b/src/awioc/di/wiring.py @@ -1,9 +1,11 @@ +import importlib import logging +import sys from types import ModuleType from typing import Optional, Iterable from ..components.protocols import Component -from ..config.registry import register_configuration +from ..config.registry import register_configuration, clear_configurations from ..container import ContainerInterface logger = logging.getLogger(__name__) @@ -24,6 +26,8 @@ def inject_dependencies( components = container.components def __register_components(iterable: Iterable[Component]) -> None: + new_configs = {} + for item in iterable: configs = item.__metadata__.get("config", set()) @@ -37,7 +41,12 @@ def __register_components(iterable: Iterable[Component]) -> None: prefix = item.__metadata__['name'] logger.debug("Registering configuration for component '%s' with prefix '%s'", item.__metadata__.get('name', 'unknown'), prefix) - register_configuration(config, prefix=prefix) + new_configs[prefix] = config + + clear_configurations(prefixes=new_configs.keys()) + + for prefix, config in new_configs.items(): + register_configuration(config, prefix=prefix) __register_components(components) logger.debug("Dependency injection complete") @@ -73,19 +82,52 @@ def __register_components(iterable: Iterable[Component]) -> None: if not isinstance(wirings_, Iterable) or isinstance(wirings_, str): wirings_ = (wirings_,) - if component.__package__: + # If the component is a module, we can retrieve its package via __package__ + # For class-based components, check __package__ first, then derive from __module__ + if isinstance(component, ModuleType): + package = component.__package__ + else: + # For class-based components, check __package__ attribute first + package = getattr(component, "__package__", None) + # Fall back to deriving package from __module__ + if not package: + # Check if the module itself is a package (directory with __init__.py) + # If so, use it as the package; otherwise use its parent + module_obj = sys.modules.get(module_name) + if module_obj and hasattr(module_obj, '__path__'): + # Module is a package, use it directly + package = module_name + elif "." in module_name: + # Module is a regular file, use parent as package + package = module_name.rsplit(".", 1)[0] + + if package: relative_wirings = set( - f"{component.__package__}.{wiring}" + f"{package}.{wiring}" for wiring in wirings_ ) else: - relative_wirings = wirings_ + relative_wirings = set(wirings_) wirings.update((module_name, *relative_wirings)) logger.debug("Added wiring for component: %s", module_name) __register_components(components) - logger.debug("Wiring %d modules", len(wirings)) - api_container.raw_container().wire(modules=wirings) + # Convert module names to actual module objects for dependency_injector + module_objects = set() + for module_name in wirings: + # Try to get from sys.modules first (already imported) + module_obj = sys.modules.get(module_name) + if module_obj is None: + # Try to import the module + try: + module_obj = importlib.import_module(module_name) + except ImportError as e: + logger.warning("Could not import module '%s' for wiring: %s", module_name, e) + continue + module_objects.add(module_obj) + + logger.debug("Wiring %d modules: %s", len(module_objects), [m.__name__ for m in module_objects]) + api_container.raw_container().wire(modules=module_objects) logger.debug("Container wiring complete") diff --git a/src/awioc/loader/__init__.py b/src/awioc/loader/__init__.py index 6031151..0b508dc 100644 --- a/src/awioc/loader/__init__.py +++ b/src/awioc/loader/__init__.py @@ -1,3 +1,20 @@ -from .module_loader import compile_component +from .manifest import ( + load_manifest, + find_manifest, + PluginManifest, + ComponentEntry, + ComponentConfigRef, + MANIFEST_FILENAME, +) +from .module_loader import compile_component, compile_components_from_manifest -__all__ = ["compile_component"] +__all__ = [ + "compile_component", + "compile_components_from_manifest", + "load_manifest", + "find_manifest", + "PluginManifest", + "ComponentEntry", + "ComponentConfigRef", + "MANIFEST_FILENAME", +] diff --git a/src/awioc/loader/manifest.py b/src/awioc/loader/manifest.py new file mode 100644 index 0000000..51ef932 --- /dev/null +++ b/src/awioc/loader/manifest.py @@ -0,0 +1,321 @@ +"""Manifest handling for AWIOC plugins. + +This module provides utilities for loading and validating plugin manifests, +which describe component metadata without requiring Python code execution. +""" + +import logging +from pathlib import Path +from typing import Optional, Union + +import pydantic +import yaml + +logger = logging.getLogger(__name__) + +# Manifest directory and filename constants +AWIOC_DIR = ".awioc" +MANIFEST_FILENAME = "manifest.yaml" + + +def get_manifest_path(directory: Path) -> Path: + """Get the path to manifest.yaml within a .awioc directory. + + Args: + directory: Path to the directory containing .awioc/ + + Returns: + Path to directory/.awioc/manifest.yaml + """ + return directory / AWIOC_DIR / MANIFEST_FILENAME + + +def has_awioc_dir(directory: Path) -> bool: + """Check if a directory contains a .awioc subdirectory with manifest.yaml. + + Args: + directory: Path to check + + Returns: + True if directory/.awioc/manifest.yaml exists + """ + return get_manifest_path(directory).exists() + + +class ComponentConfigRef(pydantic.BaseModel): + """Reference to a Pydantic config model. + + Config models are referenced by their module path and class name, + allowing them to be resolved at load time without executing Python. + """ + + model: str # Format: "module_name:ClassName" or "relative/path:ClassName" + prefix: Optional[str] = None # Override for __prefix__ + + model_config = pydantic.ConfigDict(extra="forbid") + + +class ComponentEntry(pydantic.BaseModel): + """Single component entry in a manifest. + + Describes a component's metadata including its location, configuration, + and dependencies. + """ + + name: str + version: str = "0.0.0" + description: str = "" + file: str # Relative path to Python file + class_name: Optional[str] = pydantic.Field(default=None, alias="class") + wire: bool = False + wirings: list[str] = pydantic.Field(default_factory=list) + requires: list[str] = pydantic.Field(default_factory=list) # Component names + config: Union[list[ComponentConfigRef], ComponentConfigRef, None] = None + + model_config = pydantic.ConfigDict( + populate_by_name=True, + extra="forbid", + ) + + @pydantic.field_validator("config", mode="before") + @classmethod + def normalize_config(cls, v): + """Normalize config to always be a list or None.""" + if v is None: + return None + if isinstance(v, dict): + return [v] + if isinstance(v, list): + return v + return [{"model": v}] if isinstance(v, str) else v + + def get_config_list(self) -> list[ComponentConfigRef]: + """Get config as a list, handling single item case.""" + if self.config is None: + return [] + if isinstance(self.config, list): + return self.config + return [self.config] + + +class PluginManifest(pydantic.BaseModel): + """Schema for manifest.yaml file. + + A manifest describes all components in a plugin directory, + including their metadata, configuration, and dependencies. + """ + + manifest_version: str = "1.0" + name: Optional[str] = None + version: Optional[str] = None + description: Optional[str] = None + components: list[ComponentEntry] = pydantic.Field(default_factory=list) + + model_config = pydantic.ConfigDict(extra="forbid") + + def get_component(self, name: str) -> Optional[ComponentEntry]: + """Get a component entry by name.""" + for component in self.components: + if component.name == name: + return component + return None + + def get_component_by_file( + self, file: str, class_name: Optional[str] = None + ) -> Optional[ComponentEntry]: + """Get a component entry by file path and optional class name.""" + for component in self.components: + if component.file == file: + if class_name is None or component.class_name == class_name: + return component + return None + + +def load_manifest(directory: Path) -> PluginManifest: + """Load and validate a manifest from a directory's .awioc folder. + + Args: + directory: Path to the directory containing .awioc/manifest.yaml + + Returns: + Validated PluginManifest object + + Raises: + FileNotFoundError: If .awioc/manifest.yaml doesn't exist + pydantic.ValidationError: If manifest is invalid + """ + manifest_path = get_manifest_path(directory) + + if not manifest_path.exists(): + raise FileNotFoundError( + f"Manifest not found: {manifest_path}. " + f"Expected manifest at: {directory / AWIOC_DIR / MANIFEST_FILENAME}" + ) + + logger.debug("Loading manifest from: %s", manifest_path) + + content = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {} + manifest = PluginManifest.model_validate(content) + + logger.debug( + "Loaded manifest with %d component(s)", len(manifest.components) + ) + + return manifest + + +def find_manifest(path: Path) -> Optional[Path]: + """Find the manifest.yaml file for a given path. + + Search order: + 1. If path is a directory (package component): path/.awioc/manifest.yaml + 2. Parent directory: path.parent/.awioc/manifest.yaml + (for single-file components registered in parent's manifest) + + Args: + path: Path to a file or directory + + Returns: + Path to .awioc/manifest.yaml if found, None otherwise + """ + path = path.resolve() + + # If path is a directory (package component), check for its own .awioc + if path.is_dir(): + manifest_path = get_manifest_path(path) + if manifest_path.exists(): + return manifest_path + + # For files or directories without own manifest, check parent's .awioc + parent_manifest = get_manifest_path(path.parent) + if parent_manifest.exists(): + return parent_manifest + + return None + + +def manifest_to_metadata( + entry: ComponentEntry, + manifest_path: Path, +) -> dict: + """Convert a ComponentEntry to a component metadata dict. + + This creates a metadata dict compatible with the Component protocol's + __metadata__ attribute. + + Args: + entry: The component entry from the manifest + manifest_path: Path to the manifest file (for resolving relative paths) + + Returns: + Metadata dict compatible with ComponentMetadata TypedDict + """ + metadata = { + "name": entry.name, + "version": entry.version, + "description": entry.description, + "wire": entry.wire, + "wirings": set(entry.wirings) if entry.wirings else set(), + "requires": set(entry.requires) if entry.requires else set(), # Component names + "config": None, # Will be resolved when configs are loaded + "_internals": None, + "_manifest_path": str(manifest_path), + "_config_refs": [ + {"model": c.model, "prefix": c.prefix} for c in entry.get_config_list() + ], + } + + return metadata + + +def resolve_config_models( + config_refs: list[dict], + base_path: Path, +) -> set: + """Resolve config model references to actual Pydantic model classes. + + Args: + config_refs: List of config reference dicts with 'model' and 'prefix' keys + base_path: Base path for resolving relative module paths + + Returns: + Set of resolved Pydantic BaseModel classes + """ + from pydantic import BaseModel + + resolved = set() + + for ref in config_refs: + model_ref = ref["model"] + prefix = ref.get("prefix") + + try: + model_class = _resolve_model_reference(model_ref, base_path) + + if not isinstance(model_class, type) or not issubclass( + model_class, BaseModel + ): + logger.warning( + "Config reference '%s' is not a Pydantic BaseModel", model_ref + ) + continue + + # Apply prefix override if specified + if prefix is not None: + model_class.__prefix__ = prefix + + resolved.add(model_class) + logger.debug("Resolved config model: %s", model_ref) + + except Exception as e: + logger.error("Failed to resolve config model '%s': %s", model_ref, e) + + return resolved + + +def _resolve_model_reference(reference: str, base_path: Path) -> type: + """Resolve a model reference string to an actual class. + + Reference formats: + - "module_name:ClassName" - relative to base_path + - "./relative/path:ClassName" - explicit relative path + - "package.module:ClassName" - absolute import + + Args: + reference: The model reference string + base_path: Base path for relative imports + + Returns: + The resolved class + + Raises: + ValueError: If reference format is invalid + ImportError: If module cannot be imported + AttributeError: If class doesn't exist in module + """ + if ":" not in reference: + raise ValueError( + f"Invalid config model reference: '{reference}'. " + "Expected format: 'module:ClassName'" + ) + + module_part, class_name = reference.rsplit(":", 1) + + # Handle relative paths + if module_part.startswith("./") or module_part.startswith(".\\"): + module_path = base_path / module_part[2:] + else: + module_path = base_path / module_part + + # Try to load as a file path first + if module_path.with_suffix(".py").exists() or module_path.exists(): + from .module_loader import _load_module + + module = _load_module(module_path) + return getattr(module, class_name) + + # Fall back to absolute import + import importlib + + module = importlib.import_module(module_part) + return getattr(module, class_name) diff --git a/src/awioc/loader/module_loader.py b/src/awioc/loader/module_loader.py index 818a3f2..82c0bb9 100644 --- a/src/awioc/loader/module_loader.py +++ b/src/awioc/loader/module_loader.py @@ -3,59 +3,392 @@ import logging import sys from pathlib import Path +from types import ModuleType +from typing import Union, cast, Callable, Optional +from .manifest import ( + AWIOC_DIR, + MANIFEST_FILENAME, + find_manifest, + load_manifest, + manifest_to_metadata, + resolve_config_models, +) from ..components.protocols import Component -from ..components.registry import as_component logger = logging.getLogger(__name__) -def compile_component(name: Path) -> Component: +def _resolve_pot_reference(pot_ref: str) -> Optional[tuple[Path, Optional[str]]]: + """Resolve a @pot-name/component reference to a file path. + + Handles references like: + - @my-pot/component-name + - @my-pot/component-name:ClassName() + + Args: + pot_ref: Reference in format @pot-name/component-name[:class] + + Returns: + Tuple of (component_path, class_reference) or None if not a pot reference. """ - Dynamically load a component from a file or directory path. + if not pot_ref.startswith("@"): + return None - :param name: Path to the component (file or directory). - :return: The loaded component. - :raises FileNotFoundError: If the component cannot be found. + # Import pot utilities here to avoid circular imports + from ..commands.pot import get_pot_path, load_pot_manifest + + # Parse @pot-name/component-name[:class] + ref = pot_ref[1:] # Remove @ + + # Normalize path separators (handle Windows backslashes) + ref = ref.replace("\\", "/") + + # Check for class reference + class_ref = None + if ":" in ref: + ref, class_ref = ref.rsplit(":", 1) + + if "/" not in ref: + logger.error(f"Invalid pot reference: {pot_ref} (expected @pot-name/component)") + return None + + pot_name, component_name = ref.split("/", 1) + pot_path = get_pot_path(pot_name) + + if not pot_path.exists(): + raise FileNotFoundError(f"Pot not found: {pot_name}") + + # Load manifest to find component + manifest = load_pot_manifest(pot_path) + components = manifest.get("components", {}) + + if component_name not in components: + available = list(components.keys()) + raise FileNotFoundError( + f"Component '{component_name}' not found in pot '{pot_name}'. " + f"Available: {available}" + ) + + component_info = components[component_name] + component_file = pot_path / component_info.get("path", component_name) + + if not component_file.exists(): + raise FileNotFoundError(f"Component file not found: {component_file}") + + # If no explicit class reference but manifest has class_name, use it + if not class_ref and component_info.get("class_name"): + class_ref = f"{component_info['class_name']}()" + + return component_file, class_ref + + +def _parse_component_reference(component_ref: str) -> tuple[Path, str | None]: + """ + Parse a component reference into path and attribute reference parts. + + Component references can be in the format: + - "path/to/module" - just a path, component is the module itself + - "path/to/module:attribute" - path with attribute reference + - "path/to/module:obj.attr" - path with nested attribute reference + + Handles Windows paths (e.g., "C:\\path\\to\\module") correctly. + + :param component_ref: The component reference string. + :return: Tuple of (path, reference) where reference is None if not specified. + """ + # Count colons - Windows paths have one for drive letter (e.g., "C:") + colon_count = component_ref.count(":") + + if colon_count == 0: + # No colon, just a path + return Path(component_ref), None + + if colon_count == 1: + # One colon - check if it's a Windows drive letter + colon_idx = component_ref.index(":") + if colon_idx == 1 and component_ref[0].isalpha(): + # It's a Windows drive letter (e.g., "C:\path"), no reference + return Path(component_ref), None + # It's a reference separator (e.g., "path/module:attr") + path_part, ref_part = component_ref.rsplit(":", 1) + return Path(path_part), ref_part + + # Multiple colons - the last one is likely the reference separator + # (e.g., "C:\path\module:attr") + path_part, ref_part = component_ref.rsplit(":", 1) + return Path(path_part), ref_part + + +def _resolve_reference(module: ModuleType, reference: str) -> object: + """ + Resolve a reference string to an object within a module. + + Supports: + - Simple attributes: "MyClass" + - Nested attributes: "obj.attr" + - Callable expressions: "factory()" or "MyClass()" + + :param module: The loaded module. + :param reference: The reference string to resolve. + :return: The resolved object. + :raises AttributeError: If the reference cannot be resolved. + """ + # Check if it's a callable expression (ends with parentheses) + if reference.endswith("()"): + attr_name = reference[:-2] + obj = _get_nested_attr(module, attr_name) + if callable(obj): + obj = cast(Callable, obj) + logger.debug("Calling %s() to get component", attr_name) + return obj() + raise TypeError(f"'{attr_name}' is not callable") + + return _get_nested_attr(module, reference) + + +def _get_nested_attr(obj: object, attr_path: str) -> object: + """ + Get a nested attribute from an object. + + :param obj: The object to get the attribute from. + :param attr_path: Dot-separated attribute path (e.g., "obj.attr.subattr"). + :return: The resolved attribute. + :raises AttributeError: If the attribute path cannot be resolved. + """ + for attr in attr_path.split("."): + obj = getattr(obj, attr) + return obj + + +def _get_manifest_metadata( + path: Path, + reference: Optional[str], +) -> Optional[tuple[dict, Path, Path, Optional[str]]]: + """ + Get component metadata from .awioc/manifest.yaml if available. + + Searches for a manifest.yaml in the component's .awioc directory and extracts + the metadata for the specified component. + + For package components (directories): looks in path/.awioc/manifest.yaml + For single-file components: looks in path.parent/.awioc/manifest.yaml + + :param path: Path to the component file or directory. + :param reference: Optional class reference (e.g., "MyClass()"). + :return: Tuple of (metadata dict, manifest path, resolved file path, class reference) + or None if no manifest found. + """ + manifest_path = find_manifest(path) + if manifest_path is None: + return None + + try: + # manifest_path is .awioc/manifest.yaml, so parent.parent is the component directory + component_dir = manifest_path.parent.parent + manifest = load_manifest(component_dir) + except Exception as e: + logger.warning("Failed to load manifest %s: %s", manifest_path, e) + return None + + # Determine the file name(s) to search for + file_names_to_try = [] + if path.is_file(): + file_names_to_try = [path.name] + elif path.is_dir(): + # For package directories, try multiple common patterns: + # 1. __init__.py (standard package) + # 2. .py (module named after directory) + # 3. (directory name without extension) + file_names_to_try = ["__init__.py", f"{path.name}.py", path.name] + else: + # Path doesn't exist yet, try both with and without .py suffix + file_names_to_try = [ + path.with_suffix(".py").name, + path.name, + ] + + # Extract class name from reference if provided + class_name = None + if reference: + class_name = reference.rstrip("()") + + # Find the component entry in the manifest, trying each possible file name + entry = None + for file_name in file_names_to_try: + entry = manifest.get_component_by_file(file_name, class_name) + if entry is not None: + break + # Try without class filter if we have a reference + if class_name: + entry = manifest.get_component_by_file(file_name) + if entry is not None: + break + + # Fallback: for directories, if no file pattern matched and manifest has + # exactly one component, use that component (handles cases like openai_gpt/ + # directory with open_ai.py file) + if entry is None and path.is_dir() and len(manifest.components) == 1: + entry = manifest.components[0] + logger.debug( + "Using single manifest entry '%s' for directory '%s'", + entry.name, + path.name, + ) + + if entry is None: + logger.debug( + "No manifest entry found for files %s (class: %s)", + file_names_to_try, + class_name, + ) + return None + + # Convert entry to metadata dict + metadata = manifest_to_metadata(entry, manifest_path) + + # Build resolved file path from manifest entry + # For directories, the file is relative to the directory + # For single files, the file is in the parent directory + if path.is_dir(): + resolved_file_path = path / entry.file + else: + resolved_file_path = path.parent / entry.file + + # Get class reference from manifest entry if available + resolved_class_ref = f"{entry.class_name}()" if entry.class_name else None + + return metadata, manifest_path, resolved_file_path, resolved_class_ref + + +def _attach_manifest_metadata( + component_obj: object, + metadata: dict, + manifest_path: Path, +) -> None: + """ + Attach metadata from manifest to a component object. + + Resolves config model references and sets up the __metadata__ attribute. + + :param component_obj: The component object to attach metadata to. + :param metadata: Metadata dict from manifest. + :param manifest_path: Path to the manifest file. """ - logger.debug("Compiling component from path: %s", name) + # Resolve config model references + # manifest_path is .awioc/manifest.yaml, so parent.parent is the component directory + config_refs = metadata.pop("_config_refs", []) + if config_refs: + try: + component_dir = manifest_path.parent.parent + resolved_configs = resolve_config_models( + config_refs, component_dir + ) + metadata["config"] = resolved_configs if resolved_configs else None + except Exception as e: + logger.warning("Failed to resolve config models: %s", e) + metadata["config"] = None + + # Set the metadata on the component + component_obj.__metadata__ = metadata + - # Determine module path and desired module name +def _load_module(name: Path) -> ModuleType: + """ + Load a module from a file or directory path. + + Handles package contexts properly so that relative imports work correctly. + If the module is inside a package (parent directory has __init__.py), + the parent package is loaded first and the module is registered as a + submodule with the correct __package__ attribute. + + :param name: Path to the module (file or directory). + :return: The loaded module. + :raises FileNotFoundError: If the module cannot be found. + """ + logger.debug("Loading module from path: %s", name) + + # Resolve relative paths like "." to absolute paths to get proper directory names + # This is needed because Path(".").name returns "" instead of the actual directory name + if not name.is_absolute(): + name = name.resolve() + logger.debug("Resolved relative path to: %s", name) + + # Determine module path and whether this is a package + is_package = False if name.is_file(): module_path = name - module_name = name.stem + module_simple_name = name.stem logger.debug("Path is a file: %s", module_path) elif name.with_suffix(".py").is_file(): module_path = name.with_suffix(".py") - module_name = name.stem + module_simple_name = name.stem logger.debug("Path resolved to .py file: %s", module_path) elif name.is_dir(): module_path = name / "__init__.py" - module_name = name.name + module_simple_name = name.name + is_package = True logger.debug("Path is a directory, using __init__.py: %s", module_path) else: - logger.error("Component not found: %s", name) - raise FileNotFoundError(f"Component not found: {name}") + logger.error("Module not found: %s", name) + raise FileNotFoundError(f"Module not found: {name}") - parent_dir = module_path.parent.as_posix() - if parent_dir not in sys.path: - logger.debug("Adding to sys.path: %s", parent_dir) - sys.path.insert(0, parent_dir) + # Determine if this module is inside a package (parent has __init__.py) + # and build the full dotted module name + parent_package = None + parent_package_name = None - if module_name in sys.modules: - logger.debug("Module already loaded, reusing: %s", module_name) - return as_component(sys.modules[module_name]) + if is_package: + # For packages, the parent is the directory containing this package + package_dir = module_path.parent # The package directory itself + parent_of_package = package_dir.parent + parent_init = parent_of_package / "__init__.py" + else: + # For regular files, check if the containing directory is a package + package_dir = module_path.parent + parent_init = package_dir / "__init__.py" - # Create spec - logger.debug("Creating module spec for: %s", module_name) + if parent_init.exists() and not is_package: + # This file is inside a package - load the parent package first + logger.debug("Module is inside a package, loading parent: %s", package_dir) + parent_package = _load_module(package_dir) + parent_package_name = parent_package.__name__ + full_module_name = f"{parent_package_name}.{module_simple_name}" + logger.debug("Full module name: %s", full_module_name) + else: + full_module_name = module_simple_name + + # Add the appropriate directory to sys.path + if is_package: + # For packages, add the parent of the package directory + path_to_add = module_path.parent.parent.as_posix() + else: + # For files inside a package, the parent package should have set up sys.path + # For standalone files, add the parent directory + if parent_package is None: + path_to_add = module_path.parent.as_posix() + else: + path_to_add = None # Already set by parent package + + if path_to_add and path_to_add not in sys.path: + logger.debug("Adding to sys.path: %s", path_to_add) + sys.path.insert(0, path_to_add) + + # Check if module is already loaded + if full_module_name in sys.modules: + logger.debug("Module already loaded, reusing: %s", full_module_name) + return sys.modules[full_module_name] + + # Create spec with proper submodule search locations for packages + logger.debug("Creating module spec for: %s", full_module_name) spec = importlib.util.spec_from_file_location( - module_name, + full_module_name, module_path.as_posix(), submodule_search_locations=[module_path.parent.as_posix()] - if module_path.name == "__init__.py" + if is_package else None ) @@ -66,12 +399,162 @@ def compile_component(name: Path) -> Component: # Create module with the desired name module = importlib.util.module_from_spec(spec) - # Guarantee module.__name__ == module_name - sys.modules[module_name] = module + # Set __package__ for relative imports to work + if is_package: + module.__package__ = full_module_name + elif parent_package_name: + module.__package__ = parent_package_name + else: + module.__package__ = "" + + logger.debug("Module __package__ set to: %s", module.__package__) + + # Register in sys.modules before executing (needed for circular imports) + sys.modules[full_module_name] = module + + # If this is a submodule, also register it as an attribute of the parent + if parent_package is not None: + setattr(parent_package, module_simple_name, module) # Execute module code - logger.debug("Executing module: %s", module_name) + logger.debug("Executing module: %s", full_module_name) loader.exec_module(module) - logger.debug("Component compiled successfully: %s", module_name) - return as_component(module) + return module + + +def compile_component( + component_ref: Union[str, Path], + require_manifest: bool = True, +) -> Component: + """ + Dynamically load a component from a file or directory path. + + IMPORTANT: All components MUST have a manifest entry in .awioc/manifest.yaml. + - Package components: my_package/.awioc/manifest.yaml + - Single-file components: parent_dir/.awioc/manifest.yaml + + Component references can be in the format: + - "path/to/module" - load module as component + - "path/to/module:MyClass" - load MyClass from module + - "path/to/module:obj.attr" - load nested attribute from module + - "path/to/module:factory()" - call factory() to get component + - "@pot-name/component" - load from pot repository + - "@pot-name/component:ClassName()" - load specific class from pot + + :param component_ref: Path or reference string to the component. + :param require_manifest: If True (default), raise error if no manifest found. + :return: The loaded component. + :raises FileNotFoundError: If the component path cannot be found. + :raises AttributeError: If the reference cannot be resolved. + :raises RuntimeError: If no manifest entry exists for the component. + """ + # Convert to string if Path + if isinstance(component_ref, Path): + component_ref = str(component_ref) + + logger.debug("Compiling component from reference: %s", component_ref) + + # Check for pot reference (@pot-name/component) + if component_ref.startswith("@"): + pot_result = _resolve_pot_reference(component_ref) + if pot_result is None: + raise ValueError(f"Invalid pot reference: {component_ref}") + path, reference = pot_result + logger.debug("Resolved pot reference to: %s (class: %s)", path, reference) + else: + # Parse the reference + path, reference = _parse_component_reference(component_ref) + + # Resolve path for manifest lookup + resolved_path = path.resolve() if not path.is_absolute() else path + + # Try to get metadata from manifest BEFORE loading Python + manifest_result = _get_manifest_metadata(resolved_path, reference) + use_manifest = manifest_result is not None + + if require_manifest and not use_manifest: + raise RuntimeError( + f"No manifest entry found for component '{component_ref}'. " + f"Expected manifest at: {resolved_path / AWIOC_DIR / MANIFEST_FILENAME} " + f"or {resolved_path.parent / AWIOC_DIR / MANIFEST_FILENAME}. " + f"Create a manifest with: awioc generate manifest " + ) + + # Use resolved paths from manifest if available + if use_manifest: + metadata, manifest_path, resolved_file_path, resolved_class_ref = manifest_result + # Use manifest's file path and class reference if not explicitly provided + # But if the file is __init__.py, keep loading the directory as a package + if resolved_file_path.exists() and resolved_file_path.name != "__init__.py": + path = resolved_file_path + logger.debug("Using manifest file path: %s", path) + if resolved_class_ref and not reference: + reference = resolved_class_ref + logger.debug("Using manifest class reference: %s", reference) + + # Load the module (executes Python code) + module = _load_module(path) + + # Resolve the reference if provided + if reference: + logger.debug("Resolving reference '%s' in module", reference) + component_obj = _resolve_reference(module, reference) + logger.debug("Component resolved successfully: %s:%s", path, reference) + else: + component_obj = module + logger.debug("Component compiled successfully: %s", path) + + # Apply metadata from manifest (manifest is required) + if use_manifest: + _attach_manifest_metadata(component_obj, metadata, manifest_path) + logger.debug("Applied metadata from manifest: %s", manifest_path) + + # Store the original source reference for serialization + # For pot references, keep the @pot/component format + # For file paths, store the original reference string + component_obj.__metadata__["_source_ref"] = component_ref + + # Ensure the returned object has, at least, the Optionals of Component protocol + if not hasattr(component_obj, "initialize"): + component_obj.initialize = None + if not hasattr(component_obj, "shutdown"): + component_obj.shutdown = None + if not hasattr(component_obj, "wait"): + component_obj.wait = None + + return cast(Component, component_obj) + + +def compile_components_from_manifest( + directory: Path, +) -> list[Component]: + """ + Load all components defined in a directory's .awioc/manifest.yaml. + + This function loads the manifest and compiles each component defined in it. + + :param directory: Path to the directory containing .awioc/manifest.yaml. + :return: List of loaded components. + :raises FileNotFoundError: If .awioc/manifest.yaml doesn't exist. + """ + manifest = load_manifest(directory) + components = [] + + for entry in manifest.components: + # Build component reference + file_path = directory / entry.file + if entry.class_name: + component_ref = f"{file_path}:{entry.class_name}()" + else: + component_ref = str(file_path) + + try: + component = compile_component(component_ref, require_manifest=False) + components.append(component) + logger.debug("Loaded component: %s", entry.name) + except Exception as e: + logger.error("Failed to load component '%s': %s", entry.name, e) + raise + + return components diff --git a/src/awioc/project.py b/src/awioc/project.py new file mode 100644 index 0000000..98e0d19 --- /dev/null +++ b/src/awioc/project.py @@ -0,0 +1,614 @@ +"""AWIOC Project API. + +This module provides a high-level API for working with AWIOC projects, +including manifest reading, modification, and component compilation. + +Example usage: + from awioc import is_awioc_project, open_project, create_project + + # Check if a path is an AWIOC project + if is_awioc_project("./my_plugin"): + project = open_project("./my_plugin") + print(f"Project: {project.name} v{project.version}") + + # List components + for comp in project.components: + print(f" - {comp.name}") + + # Compile and use components + components = project.compile_components() + + # Create a new project + project = create_project("./new_plugin", name="My Plugin", version="1.0.0") + project.save() +""" + +import logging +from pathlib import Path +from typing import Optional, Union, Iterator + +import yaml + +from .loader.manifest import ( + AWIOC_DIR, + MANIFEST_FILENAME, + PluginManifest, + ComponentEntry, + ComponentConfigRef, + get_manifest_path, + has_awioc_dir, + load_manifest as _load_manifest, +) + +logger = logging.getLogger(__name__) + +__all__ = [ + "AWIOCProject", + "is_awioc_project", + "open_project", + "create_project", +] + + +def is_awioc_project(path: Union[str, Path]) -> bool: + """Check if a path is an AWIOC project (has .awioc/manifest.yaml). + + Args: + path: Path to check (file or directory) + + Returns: + True if the path contains a valid AWIOC project structure + + Example: + >>> is_awioc_project("./my_plugin") + True + >>> is_awioc_project("./random_folder") + False + """ + path = Path(path).resolve() + + if path.is_file(): + # For files, check parent directory + path = path.parent + + return has_awioc_dir(path) + + +def open_project(path: Union[str, Path]) -> "AWIOCProject": + """Open an existing AWIOC project. + + Args: + path: Path to the project directory (or a file within it) + + Returns: + AWIOCProject instance for working with the project + + Raises: + FileNotFoundError: If the path doesn't contain a valid AWIOC project + + Example: + >>> project = open_project("./my_plugin") + >>> print(project.name) + "My Plugin" + """ + path = Path(path).resolve() + + if path.is_file(): + path = path.parent + + if not has_awioc_dir(path): + raise FileNotFoundError( + f"Not an AWIOC project: {path}. " + f"Missing {AWIOC_DIR}/{MANIFEST_FILENAME}" + ) + + return AWIOCProject(path) + + +def create_project( + path: Union[str, Path], + name: Optional[str] = None, + version: str = "1.0.0", + description: str = "", + overwrite: bool = False, +) -> "AWIOCProject": + """Create a new AWIOC project. + + Creates the .awioc directory and manifest.yaml file. + + Args: + path: Path where to create the project + name: Project name (defaults to directory name) + version: Project version + description: Project description + overwrite: If True, overwrite existing manifest + + Returns: + AWIOCProject instance for the new project + + Raises: + FileExistsError: If project already exists and overwrite=False + + Example: + >>> project = create_project("./my_plugin", name="My Plugin") + >>> project.save() + """ + path = Path(path).resolve() + + # Create directory if it doesn't exist + path.mkdir(parents=True, exist_ok=True) + + # Check for existing project + if has_awioc_dir(path) and not overwrite: + raise FileExistsError( + f"AWIOC project already exists at {path}. " + "Use overwrite=True to replace it." + ) + + # Create .awioc directory + awioc_dir = path / AWIOC_DIR + awioc_dir.mkdir(exist_ok=True) + + # Create manifest + manifest = PluginManifest( + manifest_version="1.0", + name=name or path.name, + version=version, + description=description, + components=[], + ) + + project = AWIOCProject(path, manifest=manifest) + project.save() + + logger.info("Created AWIOC project at %s", path) + return project + + +class AWIOCProject: + """Represents an AWIOC project with manifest management capabilities. + + This class provides a high-level interface for: + - Reading project and component metadata + - Modifying components in the manifest + - Saving changes back to disk + - Compiling components for use + + Attributes: + path: The project root directory + manifest: The underlying PluginManifest model + + Example: + >>> project = open_project("./my_plugin") + >>> print(f"{project.name} v{project.version}") + >>> for comp in project.components: + ... print(f" Component: {comp.name}") + """ + + def __init__( + self, + path: Path, + manifest: Optional[PluginManifest] = None, + ): + """Initialize an AWIOCProject. + + Args: + path: Path to the project directory + manifest: Optional pre-loaded manifest (loads from disk if None) + """ + self._path = path.resolve() + self._manifest = manifest or _load_manifest(path) + self._dirty = False # Track unsaved changes + + # ------------------------------------------------------------------------- + # Properties - Project metadata + # ------------------------------------------------------------------------- + + @property + def path(self) -> Path: + """The project root directory.""" + return self._path + + @property + def manifest_path(self) -> Path: + """Path to the manifest.yaml file.""" + return get_manifest_path(self._path) + + @property + def manifest(self) -> PluginManifest: + """The underlying PluginManifest model.""" + return self._manifest + + @property + def name(self) -> str: + """Project name.""" + return self._manifest.name or self._path.name + + @name.setter + def name(self, value: str): + """Set project name.""" + self._manifest.name = value + self._dirty = True + + @property + def version(self) -> Optional[str]: + """Project version.""" + return self._manifest.version + + @version.setter + def version(self, value: str): + """Set project version.""" + self._manifest.version = value + self._dirty = True + + @property + def description(self) -> Optional[str]: + """Project description.""" + return self._manifest.description + + @description.setter + def description(self, value: str): + """Set project description.""" + self._manifest.description = value + self._dirty = True + + @property + def manifest_version(self) -> str: + """Manifest schema version.""" + return self._manifest.manifest_version + + @property + def is_dirty(self) -> bool: + """True if there are unsaved changes.""" + return self._dirty + + # ------------------------------------------------------------------------- + # Component access + # ------------------------------------------------------------------------- + + @property + def components(self) -> list[ComponentEntry]: + """List of all components in the project.""" + return self._manifest.components + + def __len__(self) -> int: + """Number of components in the project.""" + return len(self._manifest.components) + + def __iter__(self) -> Iterator[ComponentEntry]: + """Iterate over components.""" + return iter(self._manifest.components) + + def __contains__(self, name: str) -> bool: + """Check if a component exists by name.""" + return self.get_component(name) is not None + + def get_component(self, name: str) -> Optional[ComponentEntry]: + """Get a component by name. + + Args: + name: Component name to find + + Returns: + ComponentEntry if found, None otherwise + """ + return self._manifest.get_component(name) + + def get_component_by_class( + self, + class_name: str, + file: Optional[str] = None, + ) -> Optional[ComponentEntry]: + """Get a component by class name. + + Args: + class_name: The class name to find + file: Optional file path to narrow search + + Returns: + ComponentEntry if found, None otherwise + """ + for comp in self._manifest.components: + if comp.class_name == class_name: + if file is None or comp.file == file: + return comp + return None + + # ------------------------------------------------------------------------- + # Component modification + # ------------------------------------------------------------------------- + + def add_component( + self, + name: str, + file: str, + class_name: Optional[str] = None, + version: str = "1.0.0", + description: str = "", + wire: bool = False, + wirings: Optional[list[str]] = None, + requires: Optional[list[str]] = None, + config: Optional[Union[str, dict, list]] = None, + ) -> ComponentEntry: + """Add a new component to the manifest. + + Args: + name: Component name + file: Relative path to the Python file + class_name: Class name within the file (None for module components) + version: Component version + description: Component description + wire: Enable automatic dependency injection + wirings: List of wiring specifications + requires: List of required component names + config: Config model reference(s) + + Returns: + The created ComponentEntry + + Raises: + ValueError: If a component with the same name already exists + """ + if self.get_component(name) is not None: + raise ValueError(f"Component '{name}' already exists in manifest") + + # Normalize config + config_normalized = None + if config is not None: + if isinstance(config, str): + config_normalized = [ComponentConfigRef(model=config)] + elif isinstance(config, dict): + config_normalized = [ComponentConfigRef(**config)] + elif isinstance(config, list): + config_normalized = [ + ComponentConfigRef(**c) if isinstance(c, dict) + else ComponentConfigRef(model=c) + for c in config + ] + + entry = ComponentEntry( + name=name, + file=file, + class_name=class_name, + version=version, + description=description, + wire=wire, + wirings=wirings or [], + requires=requires or [], + config=config_normalized, + ) + + self._manifest.components.append(entry) + self._dirty = True + + logger.debug("Added component '%s' to manifest", name) + return entry + + def remove_component(self, name: str) -> bool: + """Remove a component from the manifest. + + Args: + name: Name of the component to remove + + Returns: + True if component was removed, False if not found + """ + for i, comp in enumerate(self._manifest.components): + if comp.name == name: + del self._manifest.components[i] + self._dirty = True + logger.debug("Removed component '%s' from manifest", name) + return True + return False + + def update_component( + self, + name: str, + *, + new_name: Optional[str] = None, + version: Optional[str] = None, + description: Optional[str] = None, + file: Optional[str] = None, + class_name: Optional[str] = None, + wire: Optional[bool] = None, + wirings: Optional[list[str]] = None, + requires: Optional[list[str]] = None, + config: Optional[Union[str, dict, list]] = None, + ) -> Optional[ComponentEntry]: + """Update an existing component in the manifest. + + Only provided parameters will be updated. Pass None to keep existing value. + + Args: + name: Name of the component to update + new_name: New name for the component + version: New version + description: New description + file: New file path + class_name: New class name + wire: New wire setting + wirings: New wirings list + requires: New requires list + config: New config reference(s) + + Returns: + Updated ComponentEntry, or None if not found + """ + entry = self.get_component(name) + if entry is None: + return None + + # Find index for replacement + idx = self._manifest.components.index(entry) + + # Build update dict + updates = {} + if new_name is not None: + updates["name"] = new_name + if version is not None: + updates["version"] = version + if description is not None: + updates["description"] = description + if file is not None: + updates["file"] = file + if class_name is not None: + updates["class_name"] = class_name + if wire is not None: + updates["wire"] = wire + if wirings is not None: + updates["wirings"] = wirings + if requires is not None: + updates["requires"] = requires + if config is not None: + # Normalize config + if isinstance(config, str): + updates["config"] = [ComponentConfigRef(model=config)] + elif isinstance(config, dict): + updates["config"] = [ComponentConfigRef(**config)] + elif isinstance(config, list): + updates["config"] = [ + ComponentConfigRef(**c) if isinstance(c, dict) + else ComponentConfigRef(model=c) + for c in config + ] + + if updates: + # Create updated entry + current_data = entry.model_dump(by_alias=False) + current_data.update(updates) + new_entry = ComponentEntry(**current_data) + self._manifest.components[idx] = new_entry + self._dirty = True + logger.debug("Updated component '%s' in manifest", name) + return new_entry + + return entry + + # ------------------------------------------------------------------------- + # Persistence + # ------------------------------------------------------------------------- + + def save(self) -> None: + """Save the manifest to disk. + + Writes the manifest to .awioc/manifest.yaml, creating the + directory structure if necessary. + """ + # Ensure .awioc directory exists + awioc_dir = self._path / AWIOC_DIR + awioc_dir.mkdir(exist_ok=True) + + # Convert manifest to dict for YAML serialization + data = self._manifest.model_dump( + by_alias=True, # Use 'class' instead of 'class_name' + exclude_none=True, + exclude_defaults=False, + ) + + # Clean up empty lists and None values for cleaner YAML + data = self._clean_manifest_data(data) + + # Write YAML + manifest_path = self.manifest_path + with open(manifest_path, "w", encoding="utf-8") as f: + yaml.dump( + data, + f, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ) + + self._dirty = False + logger.info("Saved manifest to %s", manifest_path) + + def reload(self) -> None: + """Reload the manifest from disk, discarding unsaved changes.""" + self._manifest = _load_manifest(self._path) + self._dirty = False + logger.debug("Reloaded manifest from %s", self.manifest_path) + + def _clean_manifest_data(self, data: dict) -> dict: + """Clean up manifest data for YAML serialization.""" + # Remove empty lists and None values from components + if "components" in data: + cleaned_components = [] + for comp in data["components"]: + cleaned = {} + for key, value in comp.items(): + # Keep required fields and non-empty values + if key in ("name", "file"): + cleaned[key] = value + elif value is not None and value != [] and value != "": + # Special handling for defaults + if key == "version" and value == "0.0.0": + continue + if key == "wire" and value is False: + continue + cleaned[key] = value + cleaned_components.append(cleaned) + data["components"] = cleaned_components + + return data + + # ------------------------------------------------------------------------- + # Component compilation + # ------------------------------------------------------------------------- + + def compile_components(self) -> list: + """Compile all components from the manifest. + + Loads and compiles all components defined in the manifest, + returning them ready for registration with an IOC container. + + Returns: + List of compiled component instances + + Raises: + ImportError: If a component module cannot be loaded + AttributeError: If a component class doesn't exist + """ + from .loader.module_loader import compile_components_from_manifest + + return compile_components_from_manifest(self._path) + + def compile_component(self, name: str): + """Compile a single component by name. + + Args: + name: Name of the component to compile + + Returns: + Compiled component instance + + Raises: + ValueError: If component not found in manifest + ImportError: If component module cannot be loaded + """ + from .bootstrap import compile_component + + entry = self.get_component(name) + if entry is None: + raise ValueError(f"Component '{name}' not found in manifest") + + # Build component reference + if entry.class_name: + ref = f"{self._path / entry.file}:{entry.class_name}()" + else: + ref = str(self._path / entry.file) + + return compile_component(ref) + + # ------------------------------------------------------------------------- + # Utility methods + # ------------------------------------------------------------------------- + + def __repr__(self) -> str: + return ( + f"AWIOCProject(path={self._path!r}, " + f"name={self.name!r}, " + f"components={len(self)})" + ) + + def __str__(self) -> str: + return f"{self.name} v{self.version or '?'} ({len(self)} components)" diff --git a/tests/awioc/commands/__init__.py b/tests/awioc/commands/__init__.py new file mode 100644 index 0000000..80064ce --- /dev/null +++ b/tests/awioc/commands/__init__.py @@ -0,0 +1 @@ +"""Tests for AWIOC CLI commands.""" diff --git a/tests/awioc/commands/test_add_remove.py b/tests/awioc/commands/test_add_remove.py new file mode 100644 index 0000000..a48fe46 --- /dev/null +++ b/tests/awioc/commands/test_add_remove.py @@ -0,0 +1,369 @@ +"""Tests for the add and remove commands.""" + +import pytest +import yaml + +from src.awioc.commands.add import AddCommand +from src.awioc.commands.base import CommandContext +from src.awioc.commands.remove import RemoveCommand + + +class TestAddCommand: + """Tests for AddCommand class.""" + + @pytest.fixture + def command(self): + """Create an AddCommand instance.""" + return AddCommand() + + @pytest.fixture + def config_file(self, tmp_path): + """Create a temporary config file.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text(yaml.dump({ + "components": { + "app": "my_app:MyApp()", + "plugins": [], + "libraries": {} + } + }), encoding="utf-8") + return config_path + + def test_command_properties(self, command): + """Test command properties.""" + assert command.name == "add" + assert "plugins or libraries" in command.description + assert "awioc add" in command.help_text + + @pytest.mark.asyncio + async def test_execute_no_args(self, command): + """Test execute with no args.""" + ctx = CommandContext(command="add", args=[]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_execute_unknown_type(self, command): + """Test execute with unknown component type.""" + ctx = CommandContext(command="add", args=["unknown", "path"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_add_plugin_no_path(self, command, config_file, monkeypatch): + """Test add plugin without path.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="add", args=["plugin"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_add_plugin_config_not_found(self, command, tmp_path, monkeypatch): + """Test add plugin when config doesn't exist.""" + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="add", args=["plugin", "my_plugin.py"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_add_plugin_success(self, command, config_file, monkeypatch): + """Test add plugin successfully.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="add", args=["plugin", "plugins/my_plugin.py"]) + result = await command.execute(ctx) + assert result == 0 + + # Verify plugin was added + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert "plugins/my_plugin.py" in config["components"]["plugins"] + + @pytest.mark.asyncio + async def test_add_plugin_already_exists(self, command, config_file, monkeypatch): + """Test add plugin that already exists.""" + monkeypatch.chdir(config_file.parent) + + # Add plugin first + ctx = CommandContext(command="add", args=["plugin", "my_plugin.py"]) + await command.execute(ctx) + + # Try to add again + result = await command.execute(ctx) + assert result == 0 # Should succeed but warn + + @pytest.mark.asyncio + async def test_add_plugin_creates_plugins_section(self, command, tmp_path, monkeypatch): + """Test add plugin creates plugins section if missing.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text(yaml.dump({"components": {"app": "app:App()"}})) + monkeypatch.chdir(tmp_path) + + ctx = CommandContext(command="add", args=["plugin", "my_plugin.py"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert "plugins" in config["components"] + + @pytest.mark.asyncio + async def test_add_plugin_creates_components_section(self, command, tmp_path, monkeypatch): + """Test add plugin creates components section if missing.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text(yaml.dump({})) + monkeypatch.chdir(tmp_path) + + ctx = CommandContext(command="add", args=["plugin", "my_plugin.py"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert "components" in config + assert "plugins" in config["components"] + + @pytest.mark.asyncio + async def test_add_plugin_with_config_path(self, command, config_file): + """Test add plugin with explicit config path.""" + ctx = CommandContext( + command="add", + args=["plugin", "my_plugin.py"], + config_path=str(config_file) + ) + result = await command.execute(ctx) + assert result == 0 + + @pytest.mark.asyncio + async def test_add_library_no_args(self, command, config_file, monkeypatch): + """Test add library without enough args.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="add", args=["library"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_add_library_only_name(self, command, config_file, monkeypatch): + """Test add library with only name.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="add", args=["library", "db"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_add_library_config_not_found(self, command, tmp_path, monkeypatch): + """Test add library when config doesn't exist.""" + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="add", args=["library", "db", "database.py"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_add_library_success(self, command, config_file, monkeypatch): + """Test add library successfully.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="add", args=["library", "db", "libs/database.py"]) + result = await command.execute(ctx) + assert result == 0 + + # Verify library was added + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["components"]["libraries"]["db"] == "libs/database.py" + + @pytest.mark.asyncio + async def test_add_library_update_existing(self, command, config_file, monkeypatch): + """Test add library updates existing entry.""" + monkeypatch.chdir(config_file.parent) + + # Add library first + ctx = CommandContext(command="add", args=["library", "db", "old_path.py"]) + await command.execute(ctx) + + # Update with new path + ctx = CommandContext(command="add", args=["library", "db", "new_path.py"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["components"]["libraries"]["db"] == "new_path.py" + + @pytest.mark.asyncio + async def test_add_library_creates_sections(self, command, tmp_path, monkeypatch): + """Test add library creates missing sections.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text(yaml.dump({})) + monkeypatch.chdir(tmp_path) + + ctx = CommandContext(command="add", args=["library", "db", "database.py"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert "components" in config + assert "libraries" in config["components"] + + +class TestRemoveCommand: + """Tests for RemoveCommand class.""" + + @pytest.fixture + def command(self): + """Create a RemoveCommand instance.""" + return RemoveCommand() + + @pytest.fixture + def config_file(self, tmp_path): + """Create a temporary config file with plugins and libraries.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text(yaml.dump({ + "components": { + "app": "my_app:MyApp()", + "plugins": ["plugin1.py", "plugin2.py", "plugin3.py"], + "libraries": { + "db": "libs/database.py", + "cache": "libs/cache.py" + } + } + }), encoding="utf-8") + return config_path + + def test_command_properties(self, command): + """Test command properties.""" + assert command.name == "remove" + assert "plugins or libraries" in command.description + assert "awioc remove" in command.help_text + + @pytest.mark.asyncio + async def test_execute_no_args(self, command): + """Test execute with no args.""" + ctx = CommandContext(command="remove", args=[]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_execute_unknown_type(self, command): + """Test execute with unknown component type.""" + ctx = CommandContext(command="remove", args=["unknown", "path"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_remove_plugin_no_identifier(self, command, config_file, monkeypatch): + """Test remove plugin without identifier.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="remove", args=["plugin"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_remove_plugin_config_not_found(self, command, tmp_path, monkeypatch): + """Test remove plugin when config doesn't exist.""" + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="remove", args=["plugin", "my_plugin.py"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_remove_plugin_no_plugins(self, command, tmp_path, monkeypatch): + """Test remove plugin when no plugins are configured.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text(yaml.dump({"components": {}})) + monkeypatch.chdir(tmp_path) + + ctx = CommandContext(command="remove", args=["plugin", "my_plugin.py"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_remove_plugin_by_path(self, command, config_file, monkeypatch): + """Test remove plugin by path.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="remove", args=["plugin", "plugin2.py"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert "plugin2.py" not in config["components"]["plugins"] + assert len(config["components"]["plugins"]) == 2 + + @pytest.mark.asyncio + async def test_remove_plugin_by_index(self, command, config_file, monkeypatch): + """Test remove plugin by index.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="remove", args=["plugin", "0"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert "plugin1.py" not in config["components"]["plugins"] + assert len(config["components"]["plugins"]) == 2 + + @pytest.mark.asyncio + async def test_remove_plugin_index_out_of_range(self, command, config_file, monkeypatch): + """Test remove plugin with index out of range.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="remove", args=["plugin", "99"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_remove_plugin_not_found(self, command, config_file, monkeypatch): + """Test remove plugin that doesn't exist.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="remove", args=["plugin", "nonexistent.py"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_remove_library_no_name(self, command, config_file, monkeypatch): + """Test remove library without name.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="remove", args=["library"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_remove_library_config_not_found(self, command, tmp_path, monkeypatch): + """Test remove library when config doesn't exist.""" + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="remove", args=["library", "db"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_remove_library_no_libraries(self, command, tmp_path, monkeypatch): + """Test remove library when no libraries are configured.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text(yaml.dump({"components": {}})) + monkeypatch.chdir(tmp_path) + + ctx = CommandContext(command="remove", args=["library", "db"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_remove_library_success(self, command, config_file, monkeypatch): + """Test remove library successfully.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="remove", args=["library", "db"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert "db" not in config["components"]["libraries"] + assert "cache" in config["components"]["libraries"] + + @pytest.mark.asyncio + async def test_remove_library_not_found(self, command, config_file, monkeypatch): + """Test remove library that doesn't exist.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="remove", args=["library", "nonexistent"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_remove_plugin_with_config_path(self, command, config_file): + """Test remove plugin with explicit config path.""" + ctx = CommandContext( + command="remove", + args=["plugin", "plugin1.py"], + config_path=str(config_file) + ) + result = await command.execute(ctx) + assert result == 0 diff --git a/tests/awioc/commands/test_base.py b/tests/awioc/commands/test_base.py new file mode 100644 index 0000000..a251ae6 --- /dev/null +++ b/tests/awioc/commands/test_base.py @@ -0,0 +1,176 @@ +"""Tests for the base command module.""" + +import pytest + +from src.awioc.commands.base import ( + CommandContext, + Command, + BaseCommand, + register_command, + get_registered_commands, +) + + +class TestCommandContext: + """Tests for CommandContext dataclass.""" + + def test_default_values(self): + """Test CommandContext default values.""" + ctx = CommandContext(command="test") + assert ctx.command == "test" + assert ctx.args == [] + assert ctx.verbose == 0 + assert ctx.config_path is None + assert ctx.context is None + assert ctx.extra == {} + + def test_with_all_values(self): + """Test CommandContext with all values specified.""" + ctx = CommandContext( + command="test", + args=["arg1", "arg2"], + verbose=2, + config_path="/path/to/config.yaml", + context="production", + extra={"key": "value"} + ) + assert ctx.command == "test" + assert ctx.args == ["arg1", "arg2"] + assert ctx.verbose == 2 + assert ctx.config_path == "/path/to/config.yaml" + assert ctx.context == "production" + assert ctx.extra == {"key": "value"} + + def test_is_dataclass(self): + """Test that CommandContext is a dataclass.""" + ctx = CommandContext(command="test") + assert hasattr(ctx, "__dataclass_fields__") + + +class TestCommandProtocol: + """Tests for Command protocol.""" + + def test_protocol_is_runtime_checkable(self): + """Test that Command protocol is runtime checkable.""" + + class ValidCommand: + @property + def name(self) -> str: + return "valid" + + @property + def description(self) -> str: + return "Valid command" + + @property + def help_text(self) -> str: + return "Help text" + + async def execute(self, ctx: CommandContext) -> int: + return 0 + + cmd = ValidCommand() + assert isinstance(cmd, Command) + + +class TestBaseCommand: + """Tests for BaseCommand abstract class.""" + + def test_help_text_default(self): + """Test that help_text defaults to description.""" + + class TestCommand(BaseCommand): + @property + def name(self) -> str: + return "test" + + @property + def description(self) -> str: + return "Test description" + + async def execute(self, ctx: CommandContext) -> int: + return 0 + + cmd = TestCommand() + assert cmd.help_text == "Test description" + + def test_custom_help_text(self): + """Test that help_text can be overridden.""" + + class TestCommand(BaseCommand): + @property + def name(self) -> str: + return "test" + + @property + def description(self) -> str: + return "Test description" + + @property + def help_text(self) -> str: + return "Custom help text" + + async def execute(self, ctx: CommandContext) -> int: + return 0 + + cmd = TestCommand() + assert cmd.help_text == "Custom help text" + + @pytest.mark.asyncio + async def test_execute_implementation(self): + """Test that execute can be implemented.""" + + class TestCommand(BaseCommand): + @property + def name(self) -> str: + return "test" + + @property + def description(self) -> str: + return "Test description" + + async def execute(self, ctx: CommandContext) -> int: + return 42 + + cmd = TestCommand() + ctx = CommandContext(command="test") + result = await cmd.execute(ctx) + assert result == 42 + + +class TestCommandRegistry: + """Tests for command registry functions.""" + + def test_register_command_decorator(self): + """Test that register_command decorator works.""" + initial_count = len(get_registered_commands()) + + @register_command("test_unique_command") + class TestUniqueCommand(BaseCommand): + @property + def name(self) -> str: + return "test_unique_command" + + @property + def description(self) -> str: + return "Test" + + async def execute(self, ctx: CommandContext) -> int: + return 0 + + registry = get_registered_commands() + assert "test_unique_command" in registry + assert registry["test_unique_command"] == TestUniqueCommand + + def test_get_registered_commands_returns_copy(self): + """Test that get_registered_commands returns a copy.""" + registry1 = get_registered_commands() + registry2 = get_registered_commands() + + # Should be equal but not the same object + assert registry1 == registry2 + assert registry1 is not registry2 + + # Modifying one shouldn't affect the other + registry1["fake"] = None + assert "fake" not in get_registered_commands() diff --git a/tests/awioc/commands/test_config.py b/tests/awioc/commands/test_config.py new file mode 100644 index 0000000..7565ad4 --- /dev/null +++ b/tests/awioc/commands/test_config.py @@ -0,0 +1,364 @@ +"""Tests for the config command.""" + +import pytest +import yaml + +from src.awioc.commands.base import CommandContext +from src.awioc.commands.config import ConfigCommand + + +class TestConfigCommand: + """Tests for ConfigCommand class.""" + + @pytest.fixture + def command(self): + """Create a ConfigCommand instance.""" + return ConfigCommand() + + @pytest.fixture + def config_file(self, tmp_path): + """Create a temporary config file.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text(yaml.dump({ + "components": { + "app": "my_app:MyApp()" + }, + "server": { + "host": "127.0.0.1", + "port": 8080, + "debug": True + }, + "features": { + "enabled": False + } + }), encoding="utf-8") + return config_path + + def test_command_properties(self, command): + """Test command properties.""" + assert command.name == "config" + assert "configuration" in command.description + assert "awioc config" in command.help_text + + @pytest.mark.asyncio + async def test_show_config(self, command, config_file, monkeypatch, capsys): + """Test showing all configuration.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=[]) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + assert "server:" in captured.out + assert "port: 8080" in captured.out + + @pytest.mark.asyncio + async def test_show_config_file_not_found(self, command, tmp_path, monkeypatch): + """Test show config when file doesn't exist.""" + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="config", args=[]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_show_config_invalid_yaml(self, command, tmp_path, monkeypatch): + """Test show config with invalid YAML.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text("invalid: yaml: content: {[", encoding="utf-8") + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="config", args=[]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_get_config_no_key(self, command, config_file, monkeypatch): + """Test get config without key.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["get"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_get_config_simple_key(self, command, config_file, monkeypatch, capsys): + """Test get config with simple key.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["get", "server.port"]) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + assert "8080" in captured.out + + @pytest.mark.asyncio + async def test_get_config_nested_dict(self, command, config_file, monkeypatch, capsys): + """Test get config with nested dict result.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["get", "server"]) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + assert "host:" in captured.out + assert "port:" in captured.out + + @pytest.mark.asyncio + async def test_get_config_key_not_found(self, command, config_file, monkeypatch): + """Test get config with key not found.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["get", "nonexistent.key"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_get_config_file_not_found(self, command, tmp_path, monkeypatch): + """Test get config when file doesn't exist.""" + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="config", args=["get", "server.port"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_get_config_invalid_yaml(self, command, tmp_path, monkeypatch): + """Test get config with invalid YAML.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text("invalid: yaml: {[", encoding="utf-8") + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="config", args=["get", "key"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_set_config_no_args(self, command, config_file, monkeypatch): + """Test set config without enough args.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_set_config_only_key(self, command, config_file, monkeypatch): + """Test set config with only key.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set", "key"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_set_config_integer(self, command, config_file, monkeypatch): + """Test set config with integer value.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set", "server.port", "9000"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["server"]["port"] == 9000 + + @pytest.mark.asyncio + async def test_set_config_float(self, command, config_file, monkeypatch): + """Test set config with float value.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set", "server.timeout", "1.5"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["server"]["timeout"] == 1.5 + + @pytest.mark.asyncio + async def test_set_config_boolean_true(self, command, config_file, monkeypatch): + """Test set config with boolean true value.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set", "features.enabled", "true"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["features"]["enabled"] is True + + @pytest.mark.asyncio + async def test_set_config_boolean_false(self, command, config_file, monkeypatch): + """Test set config with boolean false value.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set", "server.debug", "false"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["server"]["debug"] is False + + @pytest.mark.asyncio + async def test_set_config_boolean_yes(self, command, config_file, monkeypatch): + """Test set config with yes/no values.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set", "features.enabled", "yes"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["features"]["enabled"] is True + + @pytest.mark.asyncio + async def test_set_config_boolean_no(self, command, config_file, monkeypatch): + """Test set config with no value.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set", "features.enabled", "no"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["features"]["enabled"] is False + + @pytest.mark.asyncio + async def test_set_config_string_quoted(self, command, config_file, monkeypatch): + """Test set config with quoted string.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set", "server.host", '"localhost"']) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["server"]["host"] == "localhost" + + @pytest.mark.asyncio + async def test_set_config_string_single_quoted(self, command, config_file, monkeypatch): + """Test set config with single quoted string.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set", "server.host", "'localhost'"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["server"]["host"] == "localhost" + + @pytest.mark.asyncio + async def test_set_config_json_array(self, command, config_file, monkeypatch): + """Test set config with JSON array.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set", "server.hosts", '["a", "b"]']) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["server"]["hosts"] == ["a", "b"] + + @pytest.mark.asyncio + async def test_set_config_new_nested_key(self, command, config_file, monkeypatch): + """Test set config creates nested structure.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["set", "new.nested.key", "value"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert config["new"]["nested"]["key"] == "value" + + @pytest.mark.asyncio + async def test_set_config_file_not_found(self, command, tmp_path, monkeypatch): + """Test set config when file doesn't exist.""" + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="config", args=["set", "key", "value"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_set_config_invalid_yaml(self, command, tmp_path, monkeypatch): + """Test set config with invalid YAML.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text("invalid: yaml: {[", encoding="utf-8") + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="config", args=["set", "key", "value"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_unset_config_no_key(self, command, config_file, monkeypatch): + """Test unset config without key.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["unset"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_unset_config_success(self, command, config_file, monkeypatch): + """Test unset config successfully.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["unset", "server.debug"]) + result = await command.execute(ctx) + assert result == 0 + + config = yaml.safe_load(config_file.read_text(encoding="utf-8")) + assert "debug" not in config["server"] + + @pytest.mark.asyncio + async def test_unset_config_key_not_found(self, command, config_file, monkeypatch): + """Test unset config with key not found.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["unset", "nonexistent.key"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_unset_config_nested_not_found(self, command, config_file, monkeypatch): + """Test unset config with nested path not found.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["unset", "server.missing.key"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_unset_config_file_not_found(self, command, tmp_path, monkeypatch): + """Test unset config when file doesn't exist.""" + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="config", args=["unset", "key"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_unset_config_invalid_yaml(self, command, tmp_path, monkeypatch): + """Test unset config with invalid YAML.""" + config_path = tmp_path / "ioc.yaml" + config_path.write_text("invalid: yaml: {[", encoding="utf-8") + monkeypatch.chdir(tmp_path) + ctx = CommandContext(command="config", args=["unset", "key"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_unknown_subcommand(self, command, config_file, monkeypatch): + """Test unknown subcommand.""" + monkeypatch.chdir(config_file.parent) + ctx = CommandContext(command="config", args=["unknown"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_with_config_path(self, command, config_file, capsys): + """Test with explicit config path.""" + ctx = CommandContext( + command="config", + args=["get", "server.port"], + config_path=str(config_file) + ) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + assert "8080" in captured.out + + def test_parse_value_on_off(self, command): + """Test _parse_value with on/off values.""" + assert command._parse_value("on") is True + assert command._parse_value("off") is False + + def test_parse_value_plain_string(self, command): + """Test _parse_value with plain string.""" + assert command._parse_value("hello world") == "hello world" + + def test_get_nested_non_dict(self, command): + """Test _get_nested with non-dict intermediate value.""" + obj = {"server": "not a dict"} + result = command._get_nested(obj, "server.port") + assert result is None + + def test_set_nested_overwrite_non_dict(self, command): + """Test _set_nested overwrites non-dict value.""" + obj = {"server": "not a dict"} + command._set_nested(obj, "server.port", 8080) + assert obj["server"]["port"] == 8080 diff --git a/tests/awioc/commands/test_generate.py b/tests/awioc/commands/test_generate.py new file mode 100644 index 0000000..a2f4e71 --- /dev/null +++ b/tests/awioc/commands/test_generate.py @@ -0,0 +1,512 @@ +"""Tests for the generate command.""" + +import pytest +import yaml + +from src.awioc.commands.base import CommandContext +from src.awioc.commands.generate import ( + GenerateCommand, + _extract_decorator_metadata, + _extract_module_metadata, + _scan_python_file, + _generate_manifest, + _ast_literal_eval, +) +from src.awioc.loader.manifest import AWIOC_DIR, MANIFEST_FILENAME + + +class TestAstLiteralEval: + """Tests for _ast_literal_eval function.""" + + def test_constant_value(self): + """Test evaluating a constant.""" + import ast + node = ast.parse("42").body[0].value + result = _ast_literal_eval(node) + assert result == 42 + + def test_string_value(self): + """Test evaluating a string.""" + import ast + node = ast.parse('"hello"').body[0].value + result = _ast_literal_eval(node) + assert result == "hello" + + def test_list_value(self): + """Test evaluating a list.""" + import ast + node = ast.parse("[1, 2, 3]").body[0].value + result = _ast_literal_eval(node) + assert result == [1, 2, 3] + + def test_tuple_value(self): + """Test evaluating a tuple.""" + import ast + node = ast.parse("(1, 2)").body[0].value + result = _ast_literal_eval(node) + assert result == [1, 2] # Returns as list + + def test_set_value(self): + """Test evaluating a set.""" + import ast + node = ast.parse("{1, 2, 3}").body[0].value + result = _ast_literal_eval(node) + assert result == {1, 2, 3} + + def test_dict_value(self): + """Test evaluating a dict.""" + import ast + node = ast.parse('{"a": 1, "b": 2}').body[0].value + result = _ast_literal_eval(node) + assert result == {"a": 1, "b": 2} + + def test_name_reference(self): + """Test evaluating a name reference.""" + import ast + node = ast.parse("MyClass").body[0].value + result = _ast_literal_eval(node) + assert result == ":MyClass" + + def test_attribute_reference(self): + """Test evaluating an attribute reference.""" + import ast + node = ast.parse("module.Class").body[0].value + result = _ast_literal_eval(node) + assert result == "module:Class" + + def test_nested_attribute_reference(self): + """Test evaluating a nested attribute reference.""" + import ast + node = ast.parse("pkg.module.Class").body[0].value + result = _ast_literal_eval(node) + assert result == "pkg:module:Class" + + def test_bool_value(self): + """Test evaluating boolean values.""" + import ast + true_node = ast.parse("True").body[0].value + assert _ast_literal_eval(true_node) is True + + false_node = ast.parse("False").body[0].value + assert _ast_literal_eval(false_node) is False + + def test_none_value(self): + """Test evaluating None.""" + import ast + node = ast.parse("None").body[0].value + result = _ast_literal_eval(node) + # None is a constant in Python 3.8+ + assert result is None + + +class TestExtractDecoratorMetadata: + """Tests for _extract_decorator_metadata function.""" + + def test_extract_simple_decorator(self, temp_dir): + """Test extracting metadata from simple @as_component decorator.""" + code = ''' +from awioc import as_component + +@as_component +class MyComponent: + pass +''' + import ast + tree = ast.parse(code) + class_node = [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)][0] + + result = _extract_decorator_metadata(class_node) + + assert result is not None + assert result["name"] == "MyComponent" + assert result["class"] == "MyComponent" + + def test_extract_decorator_with_args(self, temp_dir): + """Test extracting metadata from @as_component with arguments.""" + code = ''' +from awioc import as_component + +@as_component(name="custom_name", version="1.0.0", description="Test", wire=True) +class MyComponent: + pass +''' + import ast + tree = ast.parse(code) + class_node = [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)][0] + + result = _extract_decorator_metadata(class_node) + + assert result is not None + assert result["name"] == "custom_name" + assert result["version"] == "1.0.0" + assert result["description"] == "Test" + assert result["wire"] is True + + def test_no_as_component_decorator(self): + """Test that no metadata is returned for non-as_component decorators.""" + code = ''' +@other_decorator +class MyComponent: + pass +''' + import ast + tree = ast.parse(code) + class_node = [n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)][0] + + result = _extract_decorator_metadata(class_node) + + assert result is None + + +class TestExtractModuleMetadata: + """Tests for _extract_module_metadata function.""" + + def test_extract_module_metadata(self): + """Test extracting __metadata__ dict from module.""" + code = ''' +__metadata__ = { + "name": "module_component", + "version": "2.0.0", + "description": "A module component", + "wire": True, +} +''' + import ast + tree = ast.parse(code) + + result = _extract_module_metadata(tree) + + assert result is not None + assert result["name"] == "module_component" + assert result["version"] == "2.0.0" + assert result["description"] == "A module component" + assert result["wire"] is True + + def test_no_module_metadata(self): + """Test that None is returned when no __metadata__ exists.""" + code = ''' +def some_function(): + pass +''' + import ast + tree = ast.parse(code) + + result = _extract_module_metadata(tree) + + assert result is None + + +class TestScanPythonFile: + """Tests for _scan_python_file function.""" + + def test_scan_module_based_component(self, temp_dir): + """Test scanning a module-based component.""" + file_path = temp_dir / "module_component.py" + file_path.write_text(''' +__metadata__ = { + "name": "database_plugin", + "version": "1.0.0", + "description": "Database plugin", + "wire": True, +} + +async def initialize(): + pass +''') + + components = _scan_python_file(file_path) + + assert len(components) == 1 + assert components[0]["name"] == "database_plugin" + assert components[0]["version"] == "1.0.0" + assert components[0]["wire"] is True + + def test_scan_class_based_component(self, temp_dir): + """Test scanning a class-based component.""" + file_path = temp_dir / "class_component.py" + file_path.write_text(''' +from awioc import as_component + +@as_component(name="my_plugin", version="2.0.0", wire=True) +class MyPlugin: + async def initialize(self): + pass +''') + + components = _scan_python_file(file_path) + + assert len(components) == 1 + assert components[0]["name"] == "my_plugin" + assert components[0]["version"] == "2.0.0" + assert components[0]["class"] == "MyPlugin" + assert components[0]["wire"] is True + + def test_scan_multiple_classes(self, temp_dir): + """Test scanning file with multiple class components.""" + file_path = temp_dir / "multi_class.py" + file_path.write_text(''' +from awioc import as_component + +@as_component(name="plugin1", version="1.0.0") +class Plugin1: + pass + +@as_component(name="plugin2", version="2.0.0") +class Plugin2: + pass +''') + + components = _scan_python_file(file_path) + + assert len(components) == 2 + names = {c["name"] for c in components} + assert names == {"plugin1", "plugin2"} + + def test_scan_empty_file(self, temp_dir): + """Test scanning an empty file.""" + file_path = temp_dir / "empty.py" + file_path.write_text("") + + components = _scan_python_file(file_path) + + assert components == [] + + def test_scan_file_without_components(self, temp_dir): + """Test scanning a file with no components.""" + file_path = temp_dir / "no_components.py" + file_path.write_text(''' +def helper_function(): + return 42 + +class RegularClass: + pass +''') + + components = _scan_python_file(file_path) + + assert components == [] + + def test_scan_invalid_python(self, temp_dir): + """Test scanning invalid Python file.""" + file_path = temp_dir / "invalid.py" + file_path.write_text("this is not valid { python") + + components = _scan_python_file(file_path) + + assert components == [] + + +class TestGenerateManifest: + """Tests for _generate_manifest function.""" + + def test_generate_from_directory(self, temp_dir): + """Test generating manifest from a directory.""" + # Create some component files + (temp_dir / "plugin_a.py").write_text(''' +__metadata__ = { + "name": "plugin_a", + "version": "1.0.0", + "description": "Plugin A", + "wire": True, +} +''') + (temp_dir / "plugin_b.py").write_text(''' +from awioc import as_component + +@as_component(name="plugin_b", version="2.0.0") +class PluginB: + pass +''') + + manifest = _generate_manifest(temp_dir) + + assert manifest["manifest_version"] == "1.0" + assert manifest["name"] == temp_dir.name + assert len(manifest["components"]) == 2 + + names = {c["name"] for c in manifest["components"]} + assert names == {"plugin_a", "plugin_b"} + + def test_generate_skips_private_files(self, temp_dir): + """Test that private files (starting with _) are skipped.""" + (temp_dir / "_private.py").write_text(''' +__metadata__ = {"name": "private", "version": "1.0.0"} +''') + (temp_dir / "__init__.py").write_text(''' +__metadata__ = {"name": "init", "version": "1.0.0"} +''') + (temp_dir / "public.py").write_text(''' +__metadata__ = {"name": "public", "version": "1.0.0"} +''') + + manifest = _generate_manifest(temp_dir) + + assert len(manifest["components"]) == 1 + assert manifest["components"][0]["name"] == "public" + + def test_generate_empty_directory(self, temp_dir): + """Test generating manifest from empty directory.""" + manifest = _generate_manifest(temp_dir) + + assert manifest["components"] == [] + + +class TestGenerateCommand: + """Tests for GenerateCommand class.""" + + @pytest.fixture + def command(self): + """Create GenerateCommand instance.""" + return GenerateCommand() + + def test_command_properties(self, command): + """Test command properties.""" + assert command.name == "generate" + assert "manifest" in command.description.lower() + assert "manifest" in command.help_text.lower() + + @pytest.mark.asyncio + async def test_execute_no_args(self, command): + """Test execute with no arguments shows help.""" + ctx = CommandContext(command="generate", args=[]) + + result = await command.execute(ctx) + + assert result == 1 # Error exit code + + @pytest.mark.asyncio + async def test_execute_manifest_dry_run(self, command, temp_dir): + """Test execute manifest with --dry-run.""" + # Create a component file + (temp_dir / "plugin.py").write_text(''' +__metadata__ = { + "name": "test_plugin", + "version": "1.0.0", + "description": "Test", + "wire": True, +} +''') + + ctx = CommandContext( + command="generate", + args=["manifest", str(temp_dir), "--dry-run"], + ) + + result = await command.execute(ctx) + + assert result == 0 + # .awioc/manifest.yaml should NOT be created in dry-run mode + assert not (temp_dir / AWIOC_DIR / MANIFEST_FILENAME).exists() + + @pytest.mark.asyncio + async def test_execute_manifest_creates_awioc_dir(self, command, temp_dir): + """Test execute manifest creates .awioc directory and file.""" + (temp_dir / "plugin.py").write_text(''' +__metadata__ = { + "name": "test_plugin", + "version": "1.0.0", + "description": "Test", +} +''') + + ctx = CommandContext( + command="generate", + args=["manifest", str(temp_dir)], + ) + + result = await command.execute(ctx) + + assert result == 0 + assert (temp_dir / AWIOC_DIR).exists() + assert (temp_dir / AWIOC_DIR / MANIFEST_FILENAME).exists() + + # Verify content + manifest = yaml.safe_load((temp_dir / AWIOC_DIR / MANIFEST_FILENAME).read_text()) + assert len(manifest["components"]) == 1 + assert manifest["components"][0]["name"] == "test_plugin" + + @pytest.mark.asyncio + async def test_execute_manifest_fails_if_exists(self, command, temp_dir): + """Test execute fails if .awioc/manifest already exists.""" + awioc_dir = temp_dir / AWIOC_DIR + awioc_dir.mkdir() + (awioc_dir / MANIFEST_FILENAME).write_text("existing: true") + (temp_dir / "plugin.py").write_text(''' +__metadata__ = {"name": "test", "version": "1.0.0"} +''') + + ctx = CommandContext( + command="generate", + args=["manifest", str(temp_dir)], + ) + + result = await command.execute(ctx) + + assert result == 1 # Error exit code + + @pytest.mark.asyncio + async def test_execute_manifest_force_overwrites(self, command, temp_dir): + """Test execute with --force overwrites existing manifest.""" + awioc_dir = temp_dir / AWIOC_DIR + awioc_dir.mkdir() + (awioc_dir / MANIFEST_FILENAME).write_text("existing: true") + (temp_dir / "plugin.py").write_text(''' +__metadata__ = {"name": "test", "version": "1.0.0"} +''') + + ctx = CommandContext( + command="generate", + args=["manifest", str(temp_dir), "--force"], + ) + + result = await command.execute(ctx) + + assert result == 0 + manifest = yaml.safe_load((awioc_dir / MANIFEST_FILENAME).read_text()) + assert manifest["components"][0]["name"] == "test" + + @pytest.mark.asyncio + async def test_execute_manifest_custom_output(self, command, temp_dir): + """Test execute with custom output path.""" + (temp_dir / "plugin.py").write_text(''' +__metadata__ = {"name": "test", "version": "1.0.0"} +''') + output_path = temp_dir / "custom_manifest.yaml" + + ctx = CommandContext( + command="generate", + args=["manifest", str(temp_dir), "-o", str(output_path)], + ) + + result = await command.execute(ctx) + + assert result == 0 + assert output_path.exists() + # Default .awioc path should not exist when custom output is used + assert not (temp_dir / AWIOC_DIR / MANIFEST_FILENAME).exists() + + @pytest.mark.asyncio + async def test_execute_manifest_nonexistent_directory(self, command, temp_dir): + """Test execute with non-existent directory.""" + ctx = CommandContext( + command="generate", + args=["manifest", str(temp_dir / "nonexistent")], + ) + + result = await command.execute(ctx) + + assert result == 1 # Error exit code + + @pytest.mark.asyncio + async def test_execute_manifest_no_components_found(self, command, temp_dir): + """Test execute with directory containing no components.""" + (temp_dir / "not_a_component.py").write_text("x = 42") + + ctx = CommandContext( + command="generate", + args=["manifest", str(temp_dir)], + ) + + result = await command.execute(ctx) + + assert result == 0 # Success but warning diff --git a/tests/awioc/commands/test_info.py b/tests/awioc/commands/test_info.py new file mode 100644 index 0000000..21f30df --- /dev/null +++ b/tests/awioc/commands/test_info.py @@ -0,0 +1,266 @@ +"""Tests for the info command.""" + +import pytest +import yaml + +from src.awioc.commands.base import CommandContext +from src.awioc.commands.info import InfoCommand +from src.awioc.loader.manifest import AWIOC_DIR, MANIFEST_FILENAME + + +class TestInfoCommand: + """Tests for InfoCommand class.""" + + @pytest.fixture + def command(self): + """Create InfoCommand instance.""" + return InfoCommand() + + @pytest.fixture + def sample_ioc_yaml(self, temp_dir): + """Create a sample ioc.yaml file.""" + config = { + "components": { + "app": "app.py:MyApp()", + "libraries": { + "db": "libs/db.py", + "cache": "libs/cache.py", + }, + "plugins": [ + "plugins/plugin_a.py", + "plugins/plugin_b.py", + ], + }, + } + config_path = temp_dir / "ioc.yaml" + config_path.write_text(yaml.dump(config)) + return config_path + + def test_command_properties(self, command): + """Test command properties.""" + assert command.name == "info" + assert "project" in command.description.lower() + assert "--show-manifest" in command.help_text + + @pytest.mark.asyncio + async def test_execute_no_config(self, command, temp_dir, monkeypatch): + """Test execute when no config file exists.""" + monkeypatch.chdir(temp_dir) + ctx = CommandContext(command="info", args=[]) + + result = await command.execute(ctx) + + assert result == 1 # Error exit code + + @pytest.mark.asyncio + async def test_execute_with_config(self, command, temp_dir, sample_ioc_yaml, monkeypatch): + """Test execute with valid config.""" + monkeypatch.chdir(temp_dir) + ctx = CommandContext(command="info", args=[]) + + result = await command.execute(ctx) + + assert result == 0 + + @pytest.mark.asyncio + async def test_execute_with_custom_config_path(self, command, temp_dir): + """Test execute with custom config path.""" + config = {"components": {"app": "app.py"}} + config_path = temp_dir / "custom.yaml" + config_path.write_text(yaml.dump(config)) + + ctx = CommandContext( + command="info", + args=[], + config_path=str(config_path), + ) + + result = await command.execute(ctx) + + assert result == 0 + + @pytest.mark.asyncio + async def test_execute_detects_manifest(self, command, temp_dir, monkeypatch): + """Test that info command detects manifest files.""" + # Create config + plugins_dir = temp_dir / "plugins" + plugins_dir.mkdir() + + # Use relative path like in real ioc.yaml + config = { + "components": { + "app": "app.py", + "plugins": ["plugins"], + }, + } + config_path = temp_dir / "ioc.yaml" + config_path.write_text(yaml.dump(config)) + + # Create manifest in plugins directory's .awioc folder + manifest = { + "manifest_version": "1.0", + "components": [ + {"name": "plugin1", "file": "plugin1.py"}, + ], + } + awioc_dir = plugins_dir / AWIOC_DIR + awioc_dir.mkdir() + (awioc_dir / MANIFEST_FILENAME).write_text(yaml.dump(manifest)) + + monkeypatch.chdir(temp_dir) + ctx = CommandContext(command="info", args=[]) + + result = await command.execute(ctx) + + assert result == 0 + + @pytest.mark.asyncio + async def test_execute_show_manifest_flag(self, command, temp_dir, monkeypatch, capsys): + """Test that --show-manifest displays manifest details.""" + # Create config + plugins_dir = temp_dir / "plugins" + plugins_dir.mkdir() + + # Use relative path like in real ioc.yaml + config = { + "components": { + "app": "app.py", + "plugins": ["plugins"], + }, + } + config_path = temp_dir / "ioc.yaml" + config_path.write_text(yaml.dump(config)) + + # Create manifest in .awioc folder + manifest = { + "manifest_version": "1.0", + "components": [ + {"name": "test_plugin", "version": "1.0.0", "file": "test.py"}, + ], + } + awioc_dir = plugins_dir / AWIOC_DIR + awioc_dir.mkdir() + (awioc_dir / MANIFEST_FILENAME).write_text(yaml.dump(manifest)) + + monkeypatch.chdir(temp_dir) + ctx = CommandContext(command="info", args=["--show-manifest"]) + + result = await command.execute(ctx) + + assert result == 0 + captured = capsys.readouterr() + assert "test_plugin" in captured.out + + @pytest.mark.asyncio + async def test_execute_with_verbose(self, command, temp_dir, monkeypatch): + """Test execute with verbose flag.""" + config = { + "components": {"app": "app.py"}, + "database": {"host": "localhost", "port": 5432}, + } + config_path = temp_dir / "ioc.yaml" + config_path.write_text(yaml.dump(config)) + + monkeypatch.chdir(temp_dir) + ctx = CommandContext(command="info", args=[], verbose=1) + + result = await command.execute(ctx) + + assert result == 0 + + +class TestInfoCommandHelpers: + """Tests for InfoCommand helper methods.""" + + @pytest.fixture + def command(self): + """Create InfoCommand instance.""" + return InfoCommand() + + def test_check_path_existing_file(self, command, temp_dir): + """Test _check_path with existing file.""" + file_path = temp_dir / "component.py" + file_path.write_text("# component") + + result = command._check_path("component.py", temp_dir) + + assert result is True + + def test_check_path_nonexistent(self, command, temp_dir): + """Test _check_path with non-existent file.""" + result = command._check_path("nonexistent.py", temp_dir) + + assert result is False + + def test_check_path_with_class_reference(self, command, temp_dir): + """Test _check_path with path:ClassName syntax.""" + file_path = temp_dir / "component.py" + file_path.write_text("# component") + + result = command._check_path("component.py:MyClass()", temp_dir) + + assert result is True + + def test_check_path_local_reference(self, command, temp_dir): + """Test _check_path with local :Reference syntax.""" + result = command._check_path(":MyClass()", temp_dir) + + assert result is True # Local references always return True + + def test_check_path_with_manifest_existing(self, command, temp_dir): + """Test _check_path_with_manifest with manifest.""" + plugins_dir = temp_dir / "plugins" + plugins_dir.mkdir() + awioc_dir = plugins_dir / AWIOC_DIR + awioc_dir.mkdir() + (awioc_dir / MANIFEST_FILENAME).write_text("manifest_version: '1.0'") + + # Use relative path like in actual ioc.yaml + exists, manifest_path = command._check_path_with_manifest( + "plugins", temp_dir + ) + + assert exists is True + assert manifest_path is not None + + def test_check_path_with_manifest_no_manifest(self, command, temp_dir): + """Test _check_path_with_manifest without manifest.""" + plugins_dir = temp_dir / "plugins" + plugins_dir.mkdir() + + # Use relative path like in actual ioc.yaml + exists, manifest_path = command._check_path_with_manifest( + "plugins", temp_dir + ) + + assert exists is True + assert manifest_path is None + + def test_check_path_with_manifest_pot_reference(self, command, temp_dir): + """Test _check_path_with_manifest with pot reference.""" + exists, manifest_path = command._check_path_with_manifest( + "@my-pot/component", temp_dir + ) + + assert exists is True + assert manifest_path is None + + def test_get_config_path_default(self, command): + """Test _get_config_path with default path.""" + ctx = CommandContext(command="info", args=[]) + + result = command._get_config_path(ctx) + + assert result.name == "ioc.yaml" + + def test_get_config_path_custom(self, command, temp_dir): + """Test _get_config_path with custom path.""" + ctx = CommandContext( + command="info", + args=[], + config_path=str(temp_dir / "custom.yaml"), + ) + + result = command._get_config_path(ctx) + + assert result.name == "custom.yaml" diff --git a/tests/awioc/commands/test_init.py b/tests/awioc/commands/test_init.py new file mode 100644 index 0000000..c48fb0f --- /dev/null +++ b/tests/awioc/commands/test_init.py @@ -0,0 +1,318 @@ +"""Tests for the init command.""" + +import pytest +import yaml + +from src.awioc.commands.base import CommandContext +from src.awioc.commands.init import ( + InitCommand, + to_snake_case, + to_pascal_case, +) +from src.awioc.loader.manifest import AWIOC_DIR, MANIFEST_FILENAME + + +class TestToSnakeCase: + """Tests for to_snake_case function.""" + + def test_simple_name(self): + """Test simple lowercase name.""" + assert to_snake_case("myapp") == "myapp" + + def test_spaces_to_underscores(self): + """Test spaces are converted to underscores.""" + assert to_snake_case("my app") == "my_app" + + def test_hyphens_to_underscores(self): + """Test hyphens are converted to underscores.""" + assert to_snake_case("my-app") == "my_app" + + def test_camelcase_to_snake(self): + """Test CamelCase is converted to snake_case.""" + assert to_snake_case("MyApp") == "my_app" + assert to_snake_case("myApp") == "my_app" + + def test_mixed_input(self): + """Test mixed input with spaces and capitals.""" + assert to_snake_case("My Cool App") == "my_cool_app" + + def test_removes_special_chars(self): + """Test special characters are removed.""" + assert to_snake_case("my@app!") == "myapp" + + def test_already_snake_case(self): + """Test already snake_case input.""" + assert to_snake_case("my_cool_app") == "my_cool_app" + + +class TestToPascalCase: + """Tests for to_pascal_case function.""" + + def test_simple_name(self): + """Test simple lowercase name.""" + assert to_pascal_case("myapp") == "Myapp" + + def test_spaces_to_pascal(self): + """Test spaces are handled correctly.""" + assert to_pascal_case("my app") == "MyApp" + + def test_hyphens_to_pascal(self): + """Test hyphens are handled correctly.""" + assert to_pascal_case("my-app") == "MyApp" + + def test_underscores_to_pascal(self): + """Test underscores are handled correctly.""" + assert to_pascal_case("my_app") == "MyApp" + + def test_mixed_input(self): + """Test mixed input.""" + assert to_pascal_case("my cool app") == "MyCoolApp" + + def test_already_pascal(self): + """Test already PascalCase input.""" + assert to_pascal_case("MyApp") == "Myapp" # Each word is capitalized + + +class TestInitCommand: + """Tests for InitCommand class.""" + + @pytest.fixture + def command(self): + """Create InitCommand instance.""" + return InitCommand() + + def test_command_properties(self, command): + """Test command properties.""" + assert command.name == "init" + assert "initialize" in command.description.lower() or "project" in command.description.lower() + assert "--name" in command.help_text + assert "--force" in command.help_text + + @pytest.mark.asyncio + async def test_execute_creates_all_files(self, command, temp_dir): + """Test execute creates all expected files.""" + ctx = CommandContext( + command="init", + args=[str(temp_dir), "--name", "My App"], + ) + + result = await command.execute(ctx) + + assert result == 0 + # Check all files exist + assert (temp_dir / "ioc.yaml").exists() + assert (temp_dir / "my_app.py").exists() + assert (temp_dir / "__init__.py").exists() + assert (temp_dir / AWIOC_DIR / MANIFEST_FILENAME).exists() + assert (temp_dir / ".env").exists() + assert (temp_dir / "plugins").is_dir() + + @pytest.mark.asyncio + async def test_execute_default_name(self, command, temp_dir): + """Test execute with default app name.""" + ctx = CommandContext( + command="init", + args=[str(temp_dir)], + ) + + result = await command.execute(ctx) + + assert result == 0 + # Default name is "My App" -> my_app.py + assert (temp_dir / "my_app.py").exists() + + @pytest.mark.asyncio + async def test_execute_class_name_format(self, command, temp_dir): + """Test that class names follow Component pattern.""" + ctx = CommandContext( + command="init", + args=[str(temp_dir), "--name", "Test Service"], + ) + + result = await command.execute(ctx) + + assert result == 0 + + # Check app file content has correct class name + app_content = (temp_dir / "test_service.py").read_text() + assert "class TestServiceComponent:" in app_content + + # Check __init__.py exports the component + init_content = (temp_dir / "__init__.py").read_text() + assert "from .test_service import TestServiceComponent" in init_content + assert '__all__ = ["TestServiceComponent"]' in init_content + + @pytest.mark.asyncio + async def test_execute_ioc_yaml_content(self, command, temp_dir): + """Test ioc.yaml has correct content.""" + ctx = CommandContext( + command="init", + args=[str(temp_dir), "--name", "My Service"], + ) + + result = await command.execute(ctx) + + assert result == 0 + config = yaml.safe_load((temp_dir / "ioc.yaml").read_text()) + assert "components" in config + assert config["components"]["app"] == "my_service:MyServiceComponent()" + + @pytest.mark.asyncio + async def test_execute_manifest_content(self, command, temp_dir): + """Test .awioc/manifest.yaml has correct content.""" + ctx = CommandContext( + command="init", + args=[str(temp_dir), "--name", "Cool App"], + ) + + result = await command.execute(ctx) + + assert result == 0 + manifest = yaml.safe_load( + (temp_dir / AWIOC_DIR / MANIFEST_FILENAME).read_text() + ) + assert manifest["manifest_version"] == "1.0" + assert manifest["name"] == "Cool App" + assert len(manifest["components"]) == 1 + component = manifest["components"][0] + assert component["name"] == "Cool App" + assert component["file"] == "cool_app.py" + assert component["class"] == "CoolAppComponent" + assert component["wire"] is True + + @pytest.mark.asyncio + async def test_execute_skips_existing_files(self, command, temp_dir): + """Test execute skips existing files without --force.""" + # Create existing file + (temp_dir / "ioc.yaml").write_text("existing: true") + + ctx = CommandContext( + command="init", + args=[str(temp_dir), "--name", "My App"], + ) + + result = await command.execute(ctx) + + assert result == 0 + # Original content should be preserved + assert (temp_dir / "ioc.yaml").read_text() == "existing: true" + # Other files should still be created + assert (temp_dir / "my_app.py").exists() + + @pytest.mark.asyncio + async def test_execute_force_overwrites(self, command, temp_dir): + """Test execute with --force overwrites existing files.""" + # Create existing file + (temp_dir / "ioc.yaml").write_text("existing: true") + + ctx = CommandContext( + command="init", + args=[str(temp_dir), "--name", "My App", "--force"], + ) + + result = await command.execute(ctx) + + assert result == 0 + # File should be overwritten + content = (temp_dir / "ioc.yaml").read_text() + assert "existing: true" not in content + assert "components:" in content + + @pytest.mark.asyncio + async def test_execute_creates_target_directory(self, command, temp_dir): + """Test execute creates target directory if it doesn't exist.""" + new_dir = temp_dir / "new_project" + + ctx = CommandContext( + command="init", + args=[str(new_dir), "--name", "New Project"], + ) + + result = await command.execute(ctx) + + assert result == 0 + assert new_dir.exists() + assert (new_dir / "ioc.yaml").exists() + + @pytest.mark.asyncio + async def test_execute_in_current_dir(self, command, temp_dir, monkeypatch): + """Test execute in current directory when no path specified.""" + monkeypatch.chdir(temp_dir) + + ctx = CommandContext( + command="init", + args=["--name", "Current App"], + ) + + result = await command.execute(ctx) + + assert result == 0 + assert (temp_dir / "ioc.yaml").exists() + assert (temp_dir / "current_app.py").exists() + + @pytest.mark.asyncio + async def test_execute_app_component_methods(self, command, temp_dir): + """Test generated app component has required methods.""" + ctx = CommandContext( + command="init", + args=[str(temp_dir), "--name", "Test App"], + ) + + result = await command.execute(ctx) + + assert result == 0 + content = (temp_dir / "test_app.py").read_text() + assert "async def initialize" in content + assert "async def wait" in content + assert "async def shutdown" in content + assert "@inject" in content + assert "_shutdown_event" in content + + @pytest.mark.asyncio + async def test_execute_different_name_formats(self, command, temp_dir): + """Test various app name formats produce correct output.""" + test_cases = [ + ("SimpleApp", "simple_app.py", "SimpleappComponent"), + ("my-service", "my_service.py", "MyServiceComponent"), + ("web_api", "web_api.py", "WebApiComponent"), + ("Super Cool App", "super_cool_app.py", "SuperCoolAppComponent"), + ] + + for idx, (name, expected_file, expected_class) in enumerate(test_cases): + project_dir = temp_dir / f"project_{idx}" + + ctx = CommandContext( + command="init", + args=[str(project_dir), "--name", name], + ) + + result = await command.execute(ctx) + + assert result == 0, f"Failed for name: {name}" + assert (project_dir / expected_file).exists(), f"Missing {expected_file} for name: {name}" + + content = (project_dir / expected_file).read_text() + assert f"class {expected_class}:" in content, f"Missing class {expected_class} for name: {name}" + + @pytest.mark.asyncio + async def test_execute_all_files_skipped_returns_success(self, command, temp_dir): + """Test returns success even when all files already exist.""" + # Create all files that would be created + (temp_dir / "ioc.yaml").write_text("existing") + (temp_dir / "my_app.py").write_text("existing") + (temp_dir / "__init__.py").write_text("existing") + (temp_dir / ".env").write_text("existing") + awioc_dir = temp_dir / AWIOC_DIR + awioc_dir.mkdir() + (awioc_dir / MANIFEST_FILENAME).write_text("existing") + (temp_dir / "plugins").mkdir() + + ctx = CommandContext( + command="init", + args=[str(temp_dir), "--name", "My App"], + ) + + result = await command.execute(ctx) + + # Should return success (no error) + assert result == 0 diff --git a/tests/awioc/commands/test_pot.py b/tests/awioc/commands/test_pot.py new file mode 100644 index 0000000..6375d6b --- /dev/null +++ b/tests/awioc/commands/test_pot.py @@ -0,0 +1,772 @@ +"""Tests for the pot command.""" + +import shutil +from unittest.mock import patch + +import pytest +import yaml + +from src.awioc.commands.base import CommandContext +from src.awioc.commands.pot import ( + PotCommand, + get_pot_dir, + get_pot_path, + load_pot_manifest, + save_pot_manifest, + extract_component_metadata, + resolve_pot_component, + POT_MANIFEST_FILENAME, +) + + +class TestPotHelperFunctions: + """Tests for pot helper functions.""" + + def test_get_pot_dir_creates_directory(self, tmp_path): + """Test that get_pot_dir creates the pot directory.""" + with patch('src.awioc.commands.pot.DEFAULT_POT_DIR', tmp_path / "pots"): + pot_dir = get_pot_dir() + assert pot_dir.exists() + assert pot_dir == tmp_path / "pots" + + def test_get_pot_path(self, tmp_path): + """Test get_pot_path returns correct path.""" + with patch('src.awioc.commands.pot.DEFAULT_POT_DIR', tmp_path / "pots"): + pot_path = get_pot_path("my-pot") + assert pot_path == tmp_path / "pots" / "my-pot" + + def test_load_pot_manifest_missing_file(self, tmp_path): + """Test load_pot_manifest returns default when file is missing.""" + pot_path = tmp_path / "my-pot" + pot_path.mkdir() + manifest = load_pot_manifest(pot_path) + assert manifest["manifest_version"] == "1.0" + assert manifest["name"] == "my-pot" + assert manifest["components"] == {} + + def test_load_pot_manifest_existing_file(self, tmp_path): + """Test load_pot_manifest reads existing file.""" + pot_path = tmp_path / "my-pot" + pot_path.mkdir() + manifest_path = pot_path / POT_MANIFEST_FILENAME + manifest_data = { + "manifest_version": "1.0", + "name": "my-pot", + "version": "2.0.0", + "components": {"comp1": {"name": "Component 1", "version": "1.0.0"}} + } + manifest_path.write_text(yaml.dump(manifest_data), encoding="utf-8") + + manifest = load_pot_manifest(pot_path) + assert manifest["version"] == "2.0.0" + assert "comp1" in manifest["components"] + + def test_load_pot_manifest_adds_version_if_missing(self, tmp_path): + """Test load_pot_manifest adds manifest_version if missing.""" + pot_path = tmp_path / "my-pot" + pot_path.mkdir() + manifest_path = pot_path / POT_MANIFEST_FILENAME + manifest_data = {"name": "my-pot", "components": {}} + manifest_path.write_text(yaml.dump(manifest_data), encoding="utf-8") + + manifest = load_pot_manifest(pot_path) + assert manifest["manifest_version"] == "1.0" + + def test_save_pot_manifest(self, tmp_path): + """Test save_pot_manifest writes file correctly.""" + pot_path = tmp_path / "my-pot" + pot_path.mkdir() + manifest = { + "manifest_version": "1.0", + "name": "my-pot", + "components": {"comp1": {"name": "Test"}} + } + save_pot_manifest(pot_path, manifest) + + manifest_path = pot_path / POT_MANIFEST_FILENAME + assert manifest_path.exists() + loaded = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + assert loaded["name"] == "my-pot" + + def test_extract_component_metadata_module_level(self, tmp_path): + """Test extract_component_metadata for module with __metadata__.""" + component_file = tmp_path / "component.py" + component_file.write_text(''' +__metadata__ = { + "name": "Test Component", + "version": "1.0.0", + "description": "A test component" +} +''', encoding="utf-8") + + metadata = extract_component_metadata(component_file) + assert metadata is not None + assert metadata["name"] == "Test Component" + assert metadata["version"] == "1.0.0" + + def test_extract_component_metadata_class_based(self, tmp_path): + """Test extract_component_metadata for class with __metadata__.""" + # Use unique filename to avoid module caching issues + component_file = tmp_path / "class_component_test.py" + component_file.write_text(''' +class MyComponent: + __metadata__ = { + "name": "Class Component", + "version": "2.0.0", + "description": "A class component" + } +''', encoding="utf-8") + + metadata = extract_component_metadata(component_file) + assert metadata is not None + assert metadata["name"] == "Class Component" + assert metadata["class_name"] == "MyComponent" + + def test_extract_component_metadata_no_metadata(self, tmp_path): + """Test extract_component_metadata returns None for no metadata.""" + # Use unique filename to avoid module caching issues + component_file = tmp_path / "no_meta_component.py" + component_file.write_text('x = 1', encoding="utf-8") + + metadata = extract_component_metadata(component_file) + assert metadata is None + + def test_resolve_pot_component_invalid_format(self, tmp_path): + """Test resolve_pot_component with invalid format.""" + result = resolve_pot_component("not-a-pot-ref") + assert result is None + + def test_resolve_pot_component_missing_slash(self, tmp_path): + """Test resolve_pot_component with missing slash.""" + result = resolve_pot_component("@potonly") + assert result is None + + def test_resolve_pot_component_pot_not_found(self, tmp_path): + """Test resolve_pot_component when pot doesn't exist.""" + with patch('src.awioc.commands.pot.DEFAULT_POT_DIR', tmp_path / "pots"): + (tmp_path / "pots").mkdir() + result = resolve_pot_component("@nonexistent/component") + assert result is None + + def test_resolve_pot_component_component_not_found(self, tmp_path): + """Test resolve_pot_component when component doesn't exist.""" + with patch('src.awioc.commands.pot.DEFAULT_POT_DIR', tmp_path / "pots"): + pot_path = tmp_path / "pots" / "my-pot" + pot_path.mkdir(parents=True) + save_pot_manifest(pot_path, {"manifest_version": "1.0", "components": {}}) + + result = resolve_pot_component("@my-pot/missing") + assert result is None + + +class TestPotCommand: + """Tests for PotCommand class.""" + + @pytest.fixture + def command(self): + """Create a PotCommand instance.""" + return PotCommand() + + @pytest.fixture + def temp_pot_dir(self, tmp_path): + """Create a temporary pot directory.""" + pot_dir = tmp_path / "pots" + pot_dir.mkdir() + with patch('src.awioc.commands.pot.DEFAULT_POT_DIR', pot_dir): + yield pot_dir + + def test_command_properties(self, command): + """Test command properties.""" + assert command.name == "pot" + assert "component repositories" in command.description + assert "pot" in command.help_text + + @pytest.mark.asyncio + async def test_execute_no_args_shows_help(self, command, capsys): + """Test execute with no args shows help.""" + ctx = CommandContext(command="pot", args=[]) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + assert "Manage component repositories" in captured.out + + @pytest.mark.asyncio + async def test_execute_help_subcommand(self, command, capsys): + """Test execute with help subcommand.""" + ctx = CommandContext(command="pot", args=["help"]) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + assert "awioc pot" in captured.out + + @pytest.mark.asyncio + async def test_execute_unknown_subcommand(self, command, capsys): + """Test execute with unknown subcommand.""" + ctx = CommandContext(command="pot", args=["unknown"]) + result = await command.execute(ctx) + assert result == 0 # Shows help + + @pytest.mark.asyncio + async def test_pot_init_no_name(self, command, temp_pot_dir, capsys): + """Test pot init without name.""" + ctx = CommandContext(command="pot", args=["init"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_init_invalid_name(self, command, temp_pot_dir, capsys): + """Test pot init with invalid name.""" + ctx = CommandContext(command="pot", args=["init", "invalid@name!"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_init_success(self, command, temp_pot_dir, capsys): + """Test pot init creates pot successfully.""" + ctx = CommandContext(command="pot", args=["init", "test-pot"]) + result = await command.execute(ctx) + assert result == 0 + assert (temp_pot_dir / "test-pot").exists() + assert (temp_pot_dir / "test-pot" / POT_MANIFEST_FILENAME).exists() + + @pytest.mark.asyncio + async def test_pot_init_already_exists(self, command, temp_pot_dir, capsys): + """Test pot init when pot already exists.""" + (temp_pot_dir / "existing-pot").mkdir() + ctx = CommandContext(command="pot", args=["init", "existing-pot"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_list_no_pots(self, command, temp_pot_dir, capsys): + """Test pot list when no pots exist.""" + # Remove the pots directory to test empty state + shutil.rmtree(temp_pot_dir) + ctx = CommandContext(command="pot", args=["list"]) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + assert "No pots" in captured.out + + @pytest.mark.asyncio + async def test_pot_list_shows_pots(self, command, temp_pot_dir, capsys): + """Test pot list shows available pots.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "name": "my-pot", + "version": "1.0.0", + "description": "Test pot", + "components": {} + }) + + ctx = CommandContext(command="pot", args=["list"]) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + assert "my-pot" in captured.out + + @pytest.mark.asyncio + async def test_pot_list_specific_pot(self, command, temp_pot_dir, capsys): + """Test pot list for specific pot shows components.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "name": "my-pot", + "version": "1.0.0", + "components": { + "comp1": {"name": "Component 1", "version": "1.0.0", "description": "Test"} + } + }) + + ctx = CommandContext(command="pot", args=["list", "my-pot"]) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + # Check for component key name in output + assert "comp1" in captured.out + + @pytest.mark.asyncio + async def test_pot_list_nonexistent_pot(self, command, temp_pot_dir, capsys): + """Test pot list for nonexistent pot.""" + ctx = CommandContext(command="pot", args=["list", "nonexistent"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_push_no_args(self, command, temp_pot_dir, capsys): + """Test pot push without arguments.""" + ctx = CommandContext(command="pot", args=["push"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_push_file_not_found(self, command, temp_pot_dir, capsys): + """Test pot push with nonexistent file.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, {"manifest_version": "1.0", "components": {}}) + + ctx = CommandContext(command="pot", args=["push", "/nonexistent/file.py", "--pot", "my-pot"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_push_no_pot_available(self, command, temp_pot_dir, capsys, tmp_path): + """Test pot push when no pots are available.""" + component_file = tmp_path / "component.py" + component_file.write_text('__metadata__ = {"name": "Test", "version": "1.0.0"}') + + ctx = CommandContext(command="pot", args=["push", str(component_file)]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_push_single_file(self, command, temp_pot_dir, tmp_path, capsys): + """Test pot push for single file component.""" + # Create pot + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, {"manifest_version": "1.0", "components": {}}) + + # Create component + component_file = tmp_path / "my_component.py" + component_file.write_text(''' +__metadata__ = { + "name": "My Component", + "version": "1.0.0", + "description": "Test component" +} +''', encoding="utf-8") + + ctx = CommandContext(command="pot", args=["push", str(component_file), "--pot", "my-pot"]) + result = await command.execute(ctx) + assert result == 0 + + # Verify component was pushed + manifest = load_pot_manifest(pot_path) + assert "my-component" in manifest["components"] + + @pytest.mark.asyncio + async def test_pot_push_directory(self, command, temp_pot_dir, tmp_path, capsys): + """Test pot push for directory component.""" + # Create pot + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, {"manifest_version": "1.0", "components": {}}) + + # Create component directory + component_dir = tmp_path / "my_component" + component_dir.mkdir() + (component_dir / "__init__.py").write_text(''' +class MyComponent: + __metadata__ = { + "name": "Dir Component", + "version": "2.0.0", + "description": "Directory component" + } +''', encoding="utf-8") + + ctx = CommandContext(command="pot", args=["push", str(component_dir), "--pot", "my-pot"]) + result = await command.execute(ctx) + assert result == 0 + + @pytest.mark.asyncio + async def test_pot_push_no_metadata(self, command, temp_pot_dir, tmp_path, capsys): + """Test pot push for component without metadata.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, {"manifest_version": "1.0", "components": {}}) + + component_file = tmp_path / "no_meta.py" + component_file.write_text('x = 1', encoding="utf-8") + + ctx = CommandContext(command="pot", args=["push", str(component_file), "--pot", "my-pot"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_push_auto_select_single_pot(self, command, temp_pot_dir, tmp_path, capsys): + """Test pot push auto-selects when only one pot exists.""" + pot_path = temp_pot_dir / "only-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, {"manifest_version": "1.0", "components": {}}) + + component_file = tmp_path / "comp.py" + component_file.write_text('__metadata__ = {"name": "Test", "version": "1.0.0"}') + + ctx = CommandContext(command="pot", args=["push", str(component_file)]) + result = await command.execute(ctx) + assert result == 0 + + @pytest.mark.asyncio + async def test_pot_push_multiple_pots_no_selection(self, command, temp_pot_dir, tmp_path, capsys): + """Test pot push fails when multiple pots exist without selection.""" + (temp_pot_dir / "pot1").mkdir() + (temp_pot_dir / "pot2").mkdir() + save_pot_manifest(temp_pot_dir / "pot1", {"manifest_version": "1.0", "components": {}}) + save_pot_manifest(temp_pot_dir / "pot2", {"manifest_version": "1.0", "components": {}}) + + component_file = tmp_path / "comp.py" + component_file.write_text('__metadata__ = {"name": "Test", "version": "1.0.0"}') + + ctx = CommandContext(command="pot", args=["push", str(component_file)]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_update_no_args(self, command, temp_pot_dir, capsys): + """Test pot update without arguments.""" + ctx = CommandContext(command="pot", args=["update"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_update_invalid_format(self, command, temp_pot_dir, capsys): + """Test pot update with invalid format.""" + ctx = CommandContext(command="pot", args=["update", "invalid"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_update_pot_not_found(self, command, temp_pot_dir, capsys): + """Test pot update when pot doesn't exist.""" + ctx = CommandContext(command="pot", args=["update", "nonexistent/comp"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_update_component_not_found(self, command, temp_pot_dir, capsys): + """Test pot update when component doesn't exist.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, {"manifest_version": "1.0", "components": {}}) + + ctx = CommandContext(command="pot", args=["update", "my-pot/missing"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_update_from_source(self, command, temp_pot_dir, tmp_path, capsys): + """Test pot update from source path.""" + # Create pot with component + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + (pot_path / "comp.py").write_text('__metadata__ = {"name": "Old", "version": "1.0.0"}') + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "components": {"comp": {"name": "Old", "version": "1.0.0", "path": "comp.py"}} + }) + + # Create updated source + source_file = tmp_path / "updated.py" + source_file.write_text('__metadata__ = {"name": "Updated", "version": "2.0.0"}') + + ctx = CommandContext(command="pot", args=["update", "my-pot/comp", str(source_file)]) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + assert "2.0.0" in captured.out + + @pytest.mark.asyncio + async def test_pot_update_refresh_metadata(self, command, temp_pot_dir, capsys): + """Test pot update refreshes metadata from existing files.""" + import uuid + unique_name = f"comp_{uuid.uuid4().hex[:8]}" + + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + comp_file = pot_path / f"{unique_name}.py" + comp_file.write_text(f'__metadata__ = {{"name": "Refreshed", "version": "3.0.0"}}') + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "components": {unique_name: {"name": "Old", "version": "1.0.0", "path": f"{unique_name}.py"}} + }) + + ctx = CommandContext(command="pot", args=["update", f"my-pot/{unique_name}"]) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + assert "Refreshed" in captured.out # Check name was updated + + @pytest.mark.asyncio + async def test_pot_update_with_at_syntax(self, command, temp_pot_dir, capsys): + """Test pot update with @pot/component syntax.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + (pot_path / "comp.py").write_text('__metadata__ = {"name": "Test", "version": "1.0.0"}') + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "components": {"comp": {"name": "Test", "version": "1.0.0", "path": "comp.py"}} + }) + + ctx = CommandContext(command="pot", args=["update", "@my-pot/comp"]) + result = await command.execute(ctx) + assert result == 0 + + @pytest.mark.asyncio + async def test_pot_remove_no_args(self, command, temp_pot_dir, capsys): + """Test pot remove without arguments.""" + ctx = CommandContext(command="pot", args=["remove"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_remove_invalid_format(self, command, temp_pot_dir, capsys): + """Test pot remove with invalid format.""" + ctx = CommandContext(command="pot", args=["remove", "invalid"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_remove_pot_not_found(self, command, temp_pot_dir, capsys): + """Test pot remove when pot doesn't exist.""" + ctx = CommandContext(command="pot", args=["remove", "nonexistent/comp"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_remove_component_not_found(self, command, temp_pot_dir, capsys): + """Test pot remove when component doesn't exist.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, {"manifest_version": "1.0", "components": {}}) + + ctx = CommandContext(command="pot", args=["remove", "my-pot/missing"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_remove_success(self, command, temp_pot_dir, capsys): + """Test pot remove succeeds.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + comp_file = pot_path / "comp.py" + comp_file.write_text('x = 1') + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "components": {"comp": {"name": "Test", "path": "comp.py"}} + }) + + ctx = CommandContext(command="pot", args=["remove", "my-pot/comp"]) + result = await command.execute(ctx) + assert result == 0 + assert not comp_file.exists() + + @pytest.mark.asyncio + async def test_pot_remove_directory(self, command, temp_pot_dir, capsys): + """Test pot remove for directory component.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + comp_dir = pot_path / "comp" + comp_dir.mkdir() + (comp_dir / "__init__.py").write_text('x = 1') + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "components": {"comp": {"name": "Test", "path": "comp"}} + }) + + ctx = CommandContext(command="pot", args=["remove", "my-pot/comp"]) + result = await command.execute(ctx) + assert result == 0 + assert not comp_dir.exists() + + @pytest.mark.asyncio + async def test_pot_info_no_args(self, command, temp_pot_dir, capsys): + """Test pot info without arguments.""" + ctx = CommandContext(command="pot", args=["info"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_info_invalid_format(self, command, temp_pot_dir, capsys): + """Test pot info with invalid format.""" + ctx = CommandContext(command="pot", args=["info", "invalid"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_info_pot_not_found(self, command, temp_pot_dir, capsys): + """Test pot info when pot doesn't exist.""" + ctx = CommandContext(command="pot", args=["info", "nonexistent/comp"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_info_component_not_found(self, command, temp_pot_dir, capsys): + """Test pot info when component doesn't exist.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, {"manifest_version": "1.0", "components": {}}) + + ctx = CommandContext(command="pot", args=["info", "my-pot/missing"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_info_success(self, command, temp_pot_dir, capsys): + """Test pot info shows component details.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "components": { + "comp": { + "name": "Test Component", + "version": "1.5.0", + "description": "A test component", + "path": "comp.py", + "class_name": "TestClass" + } + } + }) + + ctx = CommandContext(command="pot", args=["info", "my-pot/comp"]) + result = await command.execute(ctx) + assert result == 0 + captured = capsys.readouterr() + assert "Test Component" in captured.out + assert "1.5.0" in captured.out + + @pytest.mark.asyncio + async def test_pot_delete_no_args(self, command, temp_pot_dir, capsys): + """Test pot delete without arguments.""" + ctx = CommandContext(command="pot", args=["delete"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_delete_pot_not_found(self, command, temp_pot_dir, capsys): + """Test pot delete when pot doesn't exist.""" + ctx = CommandContext(command="pot", args=["delete", "nonexistent"]) + result = await command.execute(ctx) + assert result == 1 + + @pytest.mark.asyncio + async def test_pot_delete_success(self, command, temp_pot_dir, capsys): + """Test pot delete succeeds.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, {"manifest_version": "1.0", "components": {}}) + + ctx = CommandContext(command="pot", args=["delete", "my-pot"]) + result = await command.execute(ctx) + assert result == 0 + assert not pot_path.exists() + + @pytest.mark.asyncio + async def test_pot_delete_with_components(self, command, temp_pot_dir, capsys): + """Test pot delete warns about components.""" + pot_path = temp_pot_dir / "my-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "components": {"comp1": {}, "comp2": {}} + }) + + ctx = CommandContext(command="pot", args=["delete", "my-pot"]) + result = await command.execute(ctx) + assert result == 0 + assert not pot_path.exists() + + +class TestResolvePotComponent: + """Tests for resolve_pot_component function.""" + + def test_non_pot_reference_returns_none(self): + """Test that non-pot references return None.""" + result = resolve_pot_component("some/path/to/module.py") + assert result is None + + def test_invalid_pot_reference_without_slash(self): + """Test invalid pot reference without slash returns None.""" + result = resolve_pot_component("@invalid-ref") + assert result is None + + def test_pot_not_found_returns_none(self, tmp_path): + """Test that non-existent pot returns None.""" + with patch('src.awioc.commands.pot.get_pot_path') as mock_get_pot: + mock_get_pot.return_value = tmp_path / "nonexistent" + result = resolve_pot_component("@nonexistent/component") + assert result is None + + def test_component_not_in_pot_returns_none(self, tmp_path): + """Test component not found in pot returns None.""" + pot_path = tmp_path / "test-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "components": {"other": {"path": "other.py"}} + }) + + with patch('src.awioc.commands.pot.get_pot_path') as mock_get_pot: + mock_get_pot.return_value = pot_path + result = resolve_pot_component("@test-pot/missing") + assert result is None + + def test_component_file_not_found_returns_none(self, tmp_path): + """Test component file not found returns None.""" + pot_path = tmp_path / "test-pot" + pot_path.mkdir() + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "components": {"comp": {"path": "comp.py"}} + }) + + with patch('src.awioc.commands.pot.get_pot_path') as mock_get_pot: + mock_get_pot.return_value = pot_path + result = resolve_pot_component("@test-pot/comp") + assert result is None + + def test_resolve_valid_component(self, tmp_path): + """Test resolving a valid component.""" + pot_path = tmp_path / "test-pot" + pot_path.mkdir() + component_file = pot_path / "comp.py" + component_file.write_text("class MyComp: pass") + save_pot_manifest(pot_path, { + "manifest_version": "1.0", + "components": {"comp": {"path": "comp.py"}} + }) + + with patch('src.awioc.commands.pot.get_pot_path') as mock_get_pot: + mock_get_pot.return_value = pot_path + result = resolve_pot_component("@test-pot/comp") + assert result is not None + assert result == component_file + + +class TestExtractComponentMetadataEdgeCases: + """Additional edge case tests for extract_component_metadata.""" + + def test_extract_class_based_metadata(self, tmp_path): + """Test extracting metadata from class-based component with __metadata__.""" + component_file = tmp_path / "class_comp.py" + component_file.write_text(''' +class MyComponent: + __metadata__ = { + "name": "Class Component", + "version": "2.0.0", + "description": "A class-based component", + } +''') + metadata = extract_component_metadata(component_file) + assert metadata is not None + assert metadata["name"] == "Class Component" + assert metadata["class_name"] == "MyComponent" + + def test_extract_returns_none_for_dict_without_metadata(self, tmp_path): + """Test that empty file returns None.""" + component_file = tmp_path / "empty.py" + component_file.write_text("x = 1") + metadata = extract_component_metadata(component_file) + assert metadata is None + + def test_extract_raises_for_syntax_error(self, tmp_path): + """Test that syntax error in file raises exception.""" + component_file = tmp_path / "broken.py" + component_file.write_text("def broken(: pass") + with pytest.raises(SyntaxError): + extract_component_metadata(component_file) diff --git a/tests/awioc/commands/test_run.py b/tests/awioc/commands/test_run.py new file mode 100644 index 0000000..8bfc61c --- /dev/null +++ b/tests/awioc/commands/test_run.py @@ -0,0 +1,243 @@ +"""Tests for the run command.""" + +import os +from unittest.mock import patch, MagicMock, AsyncMock + +import pytest + +from src.awioc.commands.base import CommandContext +from src.awioc.commands.run import RunCommand + + +class TestRunCommand: + """Tests for RunCommand class.""" + + @pytest.fixture + def command(self): + """Create a RunCommand instance.""" + return RunCommand() + + def test_command_properties(self, command): + """Test command properties.""" + assert command.name == "run" + assert "AWIOC application" in command.description + assert "awioc run" in command.help_text + + @pytest.mark.asyncio + async def test_execute_sets_config_path_env(self, command): + """Test execute sets CONFIG_PATH environment variable.""" + mock_api = MagicMock() + mock_app = MagicMock() + mock_app.wait = None + mock_api.provided_app.return_value = mock_app + mock_api.provided_libs.return_value = [] + mock_api.provided_plugins.return_value = [] + + with patch('src.awioc.commands.run.initialize_ioc_app', return_value=mock_api), \ + patch('src.awioc.commands.run.initialize_components', new_callable=AsyncMock) as mock_init, \ + patch('src.awioc.commands.run.wait_for_components', new_callable=AsyncMock), \ + patch('src.awioc.commands.run.shutdown_components', new_callable=AsyncMock), \ + patch.dict(os.environ, {}, clear=True): + mock_init.return_value = [] + + ctx = CommandContext(command="run", args=[], config_path="/path/to/config.yaml") + await command.execute(ctx) + + assert os.environ.get("CONFIG_PATH") == "/path/to/config.yaml" + + @pytest.mark.asyncio + async def test_execute_sets_context_env(self, command): + """Test execute sets CONTEXT environment variable.""" + mock_api = MagicMock() + mock_app = MagicMock() + mock_app.wait = None + mock_api.provided_app.return_value = mock_app + mock_api.provided_libs.return_value = [] + mock_api.provided_plugins.return_value = [] + + with patch('src.awioc.commands.run.initialize_ioc_app', return_value=mock_api), \ + patch('src.awioc.commands.run.initialize_components', new_callable=AsyncMock) as mock_init, \ + patch('src.awioc.commands.run.wait_for_components', new_callable=AsyncMock), \ + patch('src.awioc.commands.run.shutdown_components', new_callable=AsyncMock), \ + patch.dict(os.environ, {}, clear=True): + mock_init.return_value = [] + + ctx = CommandContext(command="run", args=[], context="production") + await command.execute(ctx) + + assert os.environ.get("CONTEXT") == "production" + + @pytest.mark.asyncio + async def test_execute_initializes_app(self, command): + """Test execute initializes the application.""" + mock_api = MagicMock() + mock_app = MagicMock() + mock_app.wait = None + mock_api.provided_app.return_value = mock_app + mock_api.provided_libs.return_value = [] + mock_api.provided_plugins.return_value = [] + + with patch('src.awioc.commands.run.initialize_ioc_app', return_value=mock_api) as mock_bootstrap, \ + patch('src.awioc.commands.run.initialize_components', new_callable=AsyncMock) as mock_init, \ + patch('src.awioc.commands.run.wait_for_components', new_callable=AsyncMock) as mock_wait, \ + patch('src.awioc.commands.run.shutdown_components', new_callable=AsyncMock) as mock_shutdown: + mock_init.return_value = [] + + ctx = CommandContext(command="run", args=[]) + result = await command.execute(ctx) + + assert result == 0 + mock_bootstrap.assert_called_once() + mock_wait.assert_called_once_with(mock_app) + mock_shutdown.assert_called_once_with(mock_app) + + @pytest.mark.asyncio + async def test_execute_initializes_libs(self, command): + """Test execute initializes libraries.""" + mock_api = MagicMock() + mock_app = MagicMock() + mock_app.wait = None + mock_lib1 = MagicMock() + mock_lib2 = MagicMock() + mock_api.provided_app.return_value = mock_app + mock_api.provided_libs.return_value = [mock_lib1, mock_lib2] + mock_api.provided_plugins.return_value = [] + + with patch('src.awioc.commands.run.initialize_ioc_app', return_value=mock_api), \ + patch('src.awioc.commands.run.initialize_components', new_callable=AsyncMock) as mock_init, \ + patch('src.awioc.commands.run.wait_for_components', new_callable=AsyncMock), \ + patch('src.awioc.commands.run.shutdown_components', new_callable=AsyncMock): + mock_init.return_value = [] + + ctx = CommandContext(command="run", args=[]) + result = await command.execute(ctx) + + assert result == 0 + # Called for app, libs, and plugins + assert mock_init.call_count == 3 + + @pytest.mark.asyncio + async def test_execute_library_initialization_error(self, command): + """Test execute returns error on library initialization failure.""" + mock_api = MagicMock() + mock_app = MagicMock() + mock_app.wait = None + mock_api.provided_app.return_value = mock_app + mock_api.provided_libs.return_value = [MagicMock()] + mock_api.provided_plugins.return_value = [] + + with patch('src.awioc.commands.run.initialize_ioc_app', return_value=mock_api), \ + patch('src.awioc.commands.run.initialize_components', new_callable=AsyncMock) as mock_init, \ + patch('src.awioc.commands.run.wait_for_components', new_callable=AsyncMock), \ + patch('src.awioc.commands.run.shutdown_components', new_callable=AsyncMock): + # First call for app succeeds, second call for libs returns exception + mock_init.side_effect = [ + None, # App initialization + [Exception("Library init failed")], # Library initialization + ] + + ctx = CommandContext(command="run", args=[]) + result = await command.execute(ctx) + + assert result == 1 + + @pytest.mark.asyncio + async def test_execute_plugin_initialization_error_continues(self, command): + """Test execute continues on plugin initialization failure.""" + mock_api = MagicMock() + mock_app = MagicMock() + mock_app.wait = None + mock_api.provided_app.return_value = mock_app + mock_api.provided_libs.return_value = [] + mock_api.provided_plugins.return_value = [MagicMock()] + + with patch('src.awioc.commands.run.initialize_ioc_app', return_value=mock_api), \ + patch('src.awioc.commands.run.initialize_components', new_callable=AsyncMock) as mock_init, \ + patch('src.awioc.commands.run.wait_for_components', new_callable=AsyncMock), \ + patch('src.awioc.commands.run.shutdown_components', new_callable=AsyncMock): + # First call for app, second for libs succeeds, third for plugins returns exception + mock_init.side_effect = [ + None, # App initialization + [], # Library initialization (no exceptions) + [Exception("Plugin init failed")], # Plugin initialization (but we continue) + ] + + ctx = CommandContext(command="run", args=[]) + result = await command.execute(ctx) + + # Plugin errors are logged but don't stop execution + assert result == 0 + + @pytest.mark.asyncio + async def test_execute_shutdown_called_on_success(self, command): + """Test shutdown is called on successful completion.""" + mock_api = MagicMock() + mock_app = MagicMock() + mock_app.wait = None + mock_api.provided_app.return_value = mock_app + mock_api.provided_libs.return_value = [] + mock_api.provided_plugins.return_value = [] + + with patch('src.awioc.commands.run.initialize_ioc_app', return_value=mock_api), \ + patch('src.awioc.commands.run.initialize_components', new_callable=AsyncMock) as mock_init, \ + patch('src.awioc.commands.run.wait_for_components', new_callable=AsyncMock), \ + patch('src.awioc.commands.run.shutdown_components', new_callable=AsyncMock) as mock_shutdown: + mock_init.return_value = [] + + ctx = CommandContext(command="run", args=[]) + await command.execute(ctx) + + mock_shutdown.assert_called_once_with(mock_app) + + @pytest.mark.asyncio + async def test_execute_shutdown_called_on_exception(self, command): + """Test shutdown is called even when exception occurs.""" + mock_api = MagicMock() + mock_app = MagicMock() + mock_api.provided_app.return_value = mock_app + mock_api.provided_libs.return_value = [] + mock_api.provided_plugins.return_value = [] + + with patch('src.awioc.commands.run.initialize_ioc_app', return_value=mock_api), \ + patch('src.awioc.commands.run.initialize_components', new_callable=AsyncMock) as mock_init, \ + patch('src.awioc.commands.run.wait_for_components', new_callable=AsyncMock) as mock_wait, \ + patch('src.awioc.commands.run.shutdown_components', new_callable=AsyncMock) as mock_shutdown: + mock_init.return_value = [] + mock_wait.side_effect = Exception("Wait failed") + + ctx = CommandContext(command="run", args=[]) + + with pytest.raises(Exception, match="Wait failed"): + await command.execute(ctx) + + # Shutdown should still be called + mock_shutdown.assert_called_once_with(mock_app) + + @pytest.mark.asyncio + async def test_execute_no_config_or_context(self, command): + """Test execute without config_path or context.""" + mock_api = MagicMock() + mock_app = MagicMock() + mock_app.wait = None + mock_api.provided_app.return_value = mock_app + mock_api.provided_libs.return_value = [] + mock_api.provided_plugins.return_value = [] + + original_env = os.environ.copy() + + with patch('src.awioc.commands.run.initialize_ioc_app', return_value=mock_api), \ + patch('src.awioc.commands.run.initialize_components', new_callable=AsyncMock) as mock_init, \ + patch('src.awioc.commands.run.wait_for_components', new_callable=AsyncMock), \ + patch('src.awioc.commands.run.shutdown_components', new_callable=AsyncMock): + mock_init.return_value = [] + + # Clear relevant env vars + os.environ.pop("CONFIG_PATH", None) + os.environ.pop("CONTEXT", None) + + ctx = CommandContext(command="run", args=[]) + result = await command.execute(ctx) + + assert result == 0 + # Environment should not have these set since they weren't provided + assert "CONFIG_PATH" not in os.environ or os.environ.get("CONFIG_PATH") == original_env.get("CONFIG_PATH") diff --git a/tests/awioc/components/test_events.py b/tests/awioc/components/test_events.py new file mode 100644 index 0000000..453bc5d --- /dev/null +++ b/tests/awioc/components/test_events.py @@ -0,0 +1,877 @@ +import asyncio + +import pytest + +from src.awioc.components.events import ( + ComponentEvent, + on_event, + emit, + clear_handlers, + _handlers, +) +from src.awioc.components.lifecycle import ( + initialize_components, + shutdown_components, +) +from src.awioc.components.metadata import Internals + + +class TestComponentEvent: + """Tests for ComponentEvent enum.""" + + def test_all_events_defined(self): + """Test all expected events are defined.""" + assert ComponentEvent.BEFORE_INITIALIZE.value == "before_initialize" + assert ComponentEvent.AFTER_INITIALIZE.value == "after_initialize" + assert ComponentEvent.BEFORE_SHUTDOWN.value == "before_shutdown" + assert ComponentEvent.AFTER_SHUTDOWN.value == "after_shutdown" + + def test_event_count(self): + """Test correct number of events.""" + assert len(ComponentEvent) == 4 + + +class TestOnEvent: + """Tests for on_event function.""" + + @pytest.fixture(autouse=True) + def clear_all_handlers(self): + """Clear handlers before and after each test.""" + clear_handlers() + yield + clear_handlers() + + @pytest.fixture + def simple_component(self): + """Create a simple component for testing.""" + + class SimpleComponent: + __metadata__ = { + "name": "simple", + "version": "1.0.0", + "description": "Simple test component", + "requires": set(), + "_internals": Internals() + } + initialized = False + + async def initialize(self): + self.initialized = True + return True + + async def shutdown(self): + self.initialized = False + + return SimpleComponent() + + def test_decorator_registers_handler(self): + """Test that decorator registers handler.""" + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def my_handler(component): + pass + + assert ComponentEvent.AFTER_INITIALIZE in _handlers + assert len(_handlers[ComponentEvent.AFTER_INITIALIZE]) == 1 + + def test_decorator_returns_original_function(self): + """Test that decorator returns the original function.""" + + def my_handler(component): + pass + + result = on_event(ComponentEvent.AFTER_INITIALIZE)(my_handler) + assert result is my_handler + + def test_direct_call_registers_handler(self): + """Test direct call with handler argument.""" + + def my_handler(component): + pass + + on_event(ComponentEvent.BEFORE_SHUTDOWN, handler=my_handler) + + assert ComponentEvent.BEFORE_SHUTDOWN in _handlers + assert len(_handlers[ComponentEvent.BEFORE_SHUTDOWN]) == 1 + + def test_multiple_handlers_same_event(self): + """Test registering multiple handlers for same event.""" + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def handler1(component): + pass + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def handler2(component): + pass + + assert len(_handlers[ComponentEvent.AFTER_INITIALIZE]) == 2 + + def test_handlers_different_events(self): + """Test registering handlers for different events.""" + + @on_event(ComponentEvent.BEFORE_INITIALIZE) + def handler1(component): + pass + + @on_event(ComponentEvent.AFTER_SHUTDOWN) + def handler2(component): + pass + + assert len(_handlers[ComponentEvent.BEFORE_INITIALIZE]) == 1 + assert len(_handlers[ComponentEvent.AFTER_SHUTDOWN]) == 1 + + def test_check_function_stored(self): + """Test that check function is stored with handler.""" + + def my_check(c): + return True + + @on_event(ComponentEvent.AFTER_INITIALIZE, check=my_check) + def my_handler(component): + pass + + registered = _handlers[ComponentEvent.AFTER_INITIALIZE][0] + assert registered.check is my_check + + +class TestEmit: + """Tests for emit function.""" + + @pytest.fixture(autouse=True) + def clear_all_handlers(self): + """Clear handlers before and after each test.""" + clear_handlers() + yield + clear_handlers() + + @pytest.fixture + def simple_component(self): + """Create a simple component for testing.""" + + class SimpleComponent: + __metadata__ = { + "name": "test_component", + "version": "1.0.0", + "description": "Test component", + "requires": set(), + "_internals": Internals() + } + + return SimpleComponent() + + @pytest.mark.asyncio + async def test_emit_calls_sync_handler(self, simple_component): + """Test emit calls synchronous handler.""" + called = [] + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def my_handler(component): + called.append(component) + + await emit(simple_component, ComponentEvent.AFTER_INITIALIZE) + + assert len(called) == 1 + assert called[0] is simple_component + + @pytest.mark.asyncio + async def test_emit_calls_async_handler(self, simple_component): + """Test emit calls asynchronous handler.""" + called = [] + + @on_event(ComponentEvent.AFTER_INITIALIZE) + async def my_handler(component): + await asyncio.sleep(0) + called.append(component) + + await emit(simple_component, ComponentEvent.AFTER_INITIALIZE) + + assert len(called) == 1 + assert called[0] is simple_component + + @pytest.mark.asyncio + async def test_emit_no_handlers(self, simple_component): + """Test emit with no registered handlers.""" + # Should not raise + await emit(simple_component, ComponentEvent.AFTER_INITIALIZE) + + @pytest.mark.asyncio + async def test_emit_multiple_handlers_order(self, simple_component): + """Test handlers are called in registration order.""" + order = [] + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def handler1(component): + order.append(1) + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def handler2(component): + order.append(2) + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def handler3(component): + order.append(3) + + await emit(simple_component, ComponentEvent.AFTER_INITIALIZE) + + assert order == [1, 2, 3] + + @pytest.mark.asyncio + async def test_emit_handler_exception_propagates(self, simple_component): + """Test that handler exceptions propagate.""" + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def failing_handler(component): + raise ValueError("Handler failed") + + with pytest.raises(ValueError, match="Handler failed"): + await emit(simple_component, ComponentEvent.AFTER_INITIALIZE) + + @pytest.mark.asyncio + async def test_emit_async_handler_exception_propagates(self, simple_component): + """Test that async handler exceptions propagate.""" + + @on_event(ComponentEvent.AFTER_INITIALIZE) + async def failing_handler(component): + raise ValueError("Async handler failed") + + with pytest.raises(ValueError, match="Async handler failed"): + await emit(simple_component, ComponentEvent.AFTER_INITIALIZE) + + +class TestCheckFunction: + """Tests for check function filtering.""" + + @pytest.fixture(autouse=True) + def clear_all_handlers(self): + """Clear handlers before and after each test.""" + clear_handlers() + yield + clear_handlers() + + @pytest.fixture + def component_a(self): + """Create component A.""" + + class ComponentA: + __metadata__ = { + "name": "component_a", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + return ComponentA() + + @pytest.fixture + def component_b(self): + """Create component B.""" + + class ComponentB: + __metadata__ = { + "name": "component_b", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + return ComponentB() + + @pytest.mark.asyncio + async def test_check_true_calls_handler(self, component_a): + """Test handler is called when check returns True.""" + called = [] + + @on_event(ComponentEvent.AFTER_INITIALIZE, check=lambda c: True) + def my_handler(component): + called.append(component) + + await emit(component_a, ComponentEvent.AFTER_INITIALIZE) + + assert len(called) == 1 + + @pytest.mark.asyncio + async def test_check_false_skips_handler(self, component_a): + """Test handler is skipped when check returns False.""" + called = [] + + @on_event(ComponentEvent.AFTER_INITIALIZE, check=lambda c: False) + def my_handler(component): + called.append(component) + + await emit(component_a, ComponentEvent.AFTER_INITIALIZE) + + assert len(called) == 0 + + @pytest.mark.asyncio + async def test_check_filters_by_name(self, component_a, component_b): + """Test check function can filter by component name.""" + called = [] + + @on_event( + ComponentEvent.AFTER_INITIALIZE, + check=lambda c: c.__metadata__["name"] == "component_a" + ) + def my_handler(component): + called.append(component.__metadata__["name"]) + + await emit(component_a, ComponentEvent.AFTER_INITIALIZE) + await emit(component_b, ComponentEvent.AFTER_INITIALIZE) + + assert called == ["component_a"] + + @pytest.mark.asyncio + async def test_no_check_calls_for_all(self, component_a, component_b): + """Test handler without check is called for all components.""" + called = [] + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def my_handler(component): + called.append(component.__metadata__["name"]) + + await emit(component_a, ComponentEvent.AFTER_INITIALIZE) + await emit(component_b, ComponentEvent.AFTER_INITIALIZE) + + assert "component_a" in called + assert "component_b" in called + + @pytest.mark.asyncio + async def test_mixed_handlers_with_and_without_check(self, component_a, component_b): + """Test mixing handlers with and without check functions.""" + all_calls = [] + filtered_calls = [] + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def all_handler(component): + all_calls.append(component.__metadata__["name"]) + + @on_event( + ComponentEvent.AFTER_INITIALIZE, + check=lambda c: c.__metadata__["name"] == "component_b" + ) + def filtered_handler(component): + filtered_calls.append(component.__metadata__["name"]) + + await emit(component_a, ComponentEvent.AFTER_INITIALIZE) + await emit(component_b, ComponentEvent.AFTER_INITIALIZE) + + assert all_calls == ["component_a", "component_b"] + assert filtered_calls == ["component_b"] + + +class TestClearHandlers: + """Tests for clear_handlers function.""" + + @pytest.fixture(autouse=True) + def clear_all_handlers(self): + """Clear handlers before and after each test.""" + clear_handlers() + yield + clear_handlers() + + def test_clear_all_handlers(self): + """Test clearing all handlers.""" + + @on_event(ComponentEvent.BEFORE_INITIALIZE) + def handler1(c): + pass + + @on_event(ComponentEvent.AFTER_SHUTDOWN) + def handler2(c): + pass + + clear_handlers() + + assert len(_handlers) == 0 + + def test_clear_specific_event(self): + """Test clearing handlers for specific event.""" + + @on_event(ComponentEvent.BEFORE_INITIALIZE) + def handler1(c): + pass + + @on_event(ComponentEvent.AFTER_SHUTDOWN) + def handler2(c): + pass + + clear_handlers(ComponentEvent.BEFORE_INITIALIZE) + + assert ComponentEvent.BEFORE_INITIALIZE not in _handlers + assert ComponentEvent.AFTER_SHUTDOWN in _handlers + + def test_clear_nonexistent_event(self): + """Test clearing event with no handlers doesn't raise.""" + clear_handlers(ComponentEvent.BEFORE_SHUTDOWN) + # Should not raise + + +class TestLifecycleIntegration: + """Tests for event integration with component lifecycle.""" + + @pytest.fixture(autouse=True) + def clear_all_handlers(self): + """Clear handlers before and after each test.""" + clear_handlers() + yield + clear_handlers() + + @pytest.fixture + def simple_component(self): + """Create a simple component for testing.""" + + class SimpleComponent: + __metadata__ = { + "name": "lifecycle_test", + "version": "1.0.0", + "description": "Lifecycle test component", + "requires": set(), + "_internals": Internals() + } + initialized = False + + async def initialize(self): + self.initialized = True + return True + + async def shutdown(self): + self.initialized = False + + return SimpleComponent() + + @pytest.mark.asyncio + async def test_before_initialize_event(self, simple_component): + """Test BEFORE_INITIALIZE event fires before component initializes.""" + events = [] + + @on_event(ComponentEvent.BEFORE_INITIALIZE) + def handler(component): + events.append(("before", component.__metadata__["_internals"].is_initialized)) + + await initialize_components(simple_component) + + assert events == [("before", False)] + + @pytest.mark.asyncio + async def test_after_initialize_event(self, simple_component): + """Test AFTER_INITIALIZE event fires after component initializes.""" + events = [] + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def handler(component): + events.append(("after", component.__metadata__["_internals"].is_initialized)) + + await initialize_components(simple_component) + + assert events == [("after", True)] + + @pytest.mark.asyncio + async def test_before_shutdown_event(self, simple_component): + """Test BEFORE_SHUTDOWN event fires before component shuts down.""" + await initialize_components(simple_component) + + events = [] + + @on_event(ComponentEvent.BEFORE_SHUTDOWN) + def handler(component): + events.append(("before", component.__metadata__["_internals"].is_initialized)) + + await shutdown_components(simple_component) + + assert events == [("before", True)] + + @pytest.mark.asyncio + async def test_after_shutdown_event(self, simple_component): + """Test AFTER_SHUTDOWN event fires after component shuts down.""" + await initialize_components(simple_component) + + events = [] + + @on_event(ComponentEvent.AFTER_SHUTDOWN) + def handler(component): + events.append(("after", component.__metadata__["_internals"].is_initialized)) + + await shutdown_components(simple_component) + + assert events == [("after", False)] + + @pytest.mark.asyncio + async def test_full_lifecycle_events_order(self, simple_component): + """Test full lifecycle events fire in correct order.""" + events = [] + + @on_event(ComponentEvent.BEFORE_INITIALIZE) + def before_init(c): + events.append("before_init") + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def after_init(c): + events.append("after_init") + + @on_event(ComponentEvent.BEFORE_SHUTDOWN) + def before_shutdown(c): + events.append("before_shutdown") + + @on_event(ComponentEvent.AFTER_SHUTDOWN) + def after_shutdown(c): + events.append("after_shutdown") + + await initialize_components(simple_component) + await shutdown_components(simple_component) + + assert events == [ + "before_init", + "after_init", + "before_shutdown", + "after_shutdown" + ] + + @pytest.mark.asyncio + async def test_event_with_check_in_lifecycle(self): + """Test check function works during lifecycle.""" + + class ComponentA: + __metadata__ = { + "name": "comp_a", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + initialize = None + shutdown = None + + class ComponentB: + __metadata__ = { + "name": "comp_b", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + initialize = None + shutdown = None + + comp_a = ComponentA() + comp_b = ComponentB() + + called_for = [] + + @on_event( + ComponentEvent.AFTER_INITIALIZE, + check=lambda c: c.__metadata__["name"] == "comp_a" + ) + def only_a_handler(component): + called_for.append(component.__metadata__["name"]) + + await initialize_components(comp_a) + await initialize_components(comp_b) + + assert called_for == ["comp_a"] + + @pytest.mark.asyncio + async def test_async_handler_in_lifecycle(self, simple_component): + """Test async handlers work in lifecycle.""" + results = [] + + @on_event(ComponentEvent.AFTER_INITIALIZE) + async def async_handler(component): + await asyncio.sleep(0.01) + results.append("async_done") + + await initialize_components(simple_component) + + assert results == ["async_done"] + + +class TestComponentSpecificEventHandlers: + """Tests for component-specific event handlers (on_before_initialize, etc.).""" + + @pytest.fixture(autouse=True) + def clear_all_handlers(self): + """Clear handlers before and after each test.""" + clear_handlers() + yield + clear_handlers() + + @pytest.mark.asyncio + async def test_on_before_initialize_called(self): + """Test on_before_initialize attribute is called before initialize.""" + events = [] + + class ComponentWithHandler: + __metadata__ = { + "name": "handler_component", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + def on_before_initialize(self): + events.append("on_before_initialize") + + async def initialize(self): + events.append("initialize") + + shutdown = None + + comp = ComponentWithHandler() + await initialize_components(comp) + + assert events == ["on_before_initialize", "initialize"] + + @pytest.mark.asyncio + async def test_on_after_initialize_called(self): + """Test on_after_initialize attribute is called after initialize.""" + events = [] + + class ComponentWithHandler: + __metadata__ = { + "name": "handler_component", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + async def initialize(self): + events.append("initialize") + + def on_after_initialize(self): + events.append("on_after_initialize") + + shutdown = None + + comp = ComponentWithHandler() + await initialize_components(comp) + + assert events == ["initialize", "on_after_initialize"] + + @pytest.mark.asyncio + async def test_on_before_shutdown_called(self): + """Test on_before_shutdown attribute is called before shutdown.""" + events = [] + + class ComponentWithHandler: + __metadata__ = { + "name": "handler_component", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + async def initialize(self): + pass + + def on_before_shutdown(self): + events.append("on_before_shutdown") + + async def shutdown(self): + events.append("shutdown") + + comp = ComponentWithHandler() + await initialize_components(comp) + await shutdown_components(comp) + + assert events == ["on_before_shutdown", "shutdown"] + + @pytest.mark.asyncio + async def test_on_after_shutdown_called(self): + """Test on_after_shutdown attribute is called after shutdown.""" + events = [] + + class ComponentWithHandler: + __metadata__ = { + "name": "handler_component", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + async def initialize(self): + pass + + async def shutdown(self): + events.append("shutdown") + + def on_after_shutdown(self): + events.append("on_after_shutdown") + + comp = ComponentWithHandler() + await initialize_components(comp) + await shutdown_components(comp) + + assert events == ["shutdown", "on_after_shutdown"] + + @pytest.mark.asyncio + async def test_async_component_handlers(self): + """Test async component-specific handlers work.""" + events = [] + + class ComponentWithAsyncHandlers: + __metadata__ = { + "name": "async_handler_component", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + async def on_before_initialize(self): + await asyncio.sleep(0.01) + events.append("async_on_before_initialize") + + async def initialize(self): + events.append("initialize") + + shutdown = None + + comp = ComponentWithAsyncHandlers() + await initialize_components(comp) + + assert events == ["async_on_before_initialize", "initialize"] + + @pytest.mark.asyncio + async def test_all_component_handlers_full_lifecycle(self): + """Test all component-specific handlers in a full lifecycle.""" + events = [] + + class ComponentWithAllHandlers: + __metadata__ = { + "name": "full_handler_component", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + def on_before_initialize(self): + events.append("on_before_initialize") + + async def initialize(self): + events.append("initialize") + + def on_after_initialize(self): + events.append("on_after_initialize") + + def on_before_shutdown(self): + events.append("on_before_shutdown") + + async def shutdown(self): + events.append("shutdown") + + def on_after_shutdown(self): + events.append("on_after_shutdown") + + comp = ComponentWithAllHandlers() + await initialize_components(comp) + await shutdown_components(comp) + + assert events == [ + "on_before_initialize", + "initialize", + "on_after_initialize", + "on_before_shutdown", + "shutdown", + "on_after_shutdown" + ] + + @pytest.mark.asyncio + async def test_component_handler_called_before_global_handlers(self): + """Test component-specific handlers are called before global handlers.""" + events = [] + + class ComponentWithHandler: + __metadata__ = { + "name": "handler_component", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + def on_after_initialize(self): + events.append("component_handler") + + initialize = None + shutdown = None + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def global_handler(component): + events.append("global_handler") + + comp = ComponentWithHandler() + await initialize_components(comp) + + assert events == ["component_handler", "global_handler"] + + @pytest.mark.asyncio + async def test_component_handler_exception_propagates(self): + """Test component handler exceptions propagate.""" + + class ComponentWithFailingHandler: + __metadata__ = { + "name": "failing_handler_component", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + def on_before_initialize(self): + raise ValueError("Component handler failed") + + initialize = None + shutdown = None + + comp = ComponentWithFailingHandler() + + with pytest.raises(ValueError, match="Component handler failed"): + await initialize_components(comp) + + @pytest.mark.asyncio + async def test_non_callable_attribute_ignored(self): + """Test non-callable attributes named like handlers are ignored.""" + events = [] + + class ComponentWithNonCallable: + __metadata__ = { + "name": "non_callable_component", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + on_before_initialize = "not a function" + + async def initialize(self): + events.append("initialize") + + shutdown = None + + comp = ComponentWithNonCallable() + await initialize_components(comp) + + # Should only have initialize, not crash on non-callable + assert events == ["initialize"] + + @pytest.mark.asyncio + async def test_component_without_handlers_still_works(self): + """Test components without handler attributes still work normally.""" + events = [] + + class NormalComponent: + __metadata__ = { + "name": "normal_component", + "version": "1.0.0", + "requires": set(), + "_internals": Internals() + } + + async def initialize(self): + events.append("initialize") + + async def shutdown(self): + events.append("shutdown") + + @on_event(ComponentEvent.AFTER_INITIALIZE) + def global_handler(component): + events.append("global_handler") + + comp = NormalComponent() + await initialize_components(comp) + + assert events == ["initialize", "global_handler"] diff --git a/tests/awioc/components/test_lifecycle.py b/tests/awioc/components/test_lifecycle.py index b9d0cb0..8ea7aef 100644 --- a/tests/awioc/components/test_lifecycle.py +++ b/tests/awioc/components/test_lifecycle.py @@ -1,6 +1,4 @@ import pytest -import asyncio -import logging from src.awioc.components.lifecycle import ( initialize_components, @@ -94,7 +92,12 @@ async def test_initialize_returns_components(self, simple_component): @pytest.mark.asyncio async def test_initialize_with_dependency(self): - """Test initializing component with uninitialized dependency.""" + """Test initializing component that declares dependencies by name. + + Note: Dependencies are now declared by name (strings). The actual + dependency initialization order is managed by registration order, + not by initialize_components checking dependency status. + """ dep = type("Dep", (), { "__metadata__": { "name": "dep", @@ -110,18 +113,19 @@ async def test_initialize_with_dependency(self): "__metadata__": { "name": "comp", "version": "1.0.0", - "requires": {dep}, + "requires": {"dep"}, # Name-based requires "_internals": Internals() }, "initialize": None, "shutdown": None })() - # Dependency not initialized, so comp shouldn't initialize + # Initialize the component - it will initialize regardless of dependency status + # because dependency order is handled at registration time, not initialization result = await initialize_components(comp) - # Should not be initialized because dep is not initialized - assert comp.__metadata__["_internals"].is_initialized is False + # Component should be initialized + assert comp.__metadata__["_internals"].is_initialized is True @pytest.mark.asyncio async def test_initialize_aborted(self): diff --git a/tests/awioc/components/test_lifecycle_extended.py b/tests/awioc/components/test_lifecycle_extended.py index 40fabaf..4a08446 100644 --- a/tests/awioc/components/test_lifecycle_extended.py +++ b/tests/awioc/components/test_lifecycle_extended.py @@ -1,9 +1,12 @@ +import asyncio + import pytest -import logging from src.awioc.components.lifecycle import ( register_plugin, unregister_plugin, + shutdown_components, + wait_for_components, ) from src.awioc.components.metadata import Internals from src.awioc.container import AppContainer, ContainerInterface @@ -208,3 +211,136 @@ async def shutdown(self): assert comp1.shutdown_called assert comp2.shutdown_called + + +class TestShutdownAlreadyShuttingDown: + """Tests for component already shutting down.""" + + @pytest.mark.asyncio + async def test_shutdown_component_already_shutting_down(self): + """Test shutting down a component that's already shutting down.""" + + class ShuttingDownComponent: + __metadata__ = { + "name": "shutting_down", + "version": "1.0.0", + "requires": set(), + "_internals": Internals(is_initialized=True, is_shutting_down=True) + } + shutdown_called = False + + async def shutdown(self): + self.shutdown_called = True + + comp = ShuttingDownComponent() + await shutdown_components(comp) + + # Shutdown should be skipped because it's already shutting down + assert comp.shutdown_called is False + + +class TestShutdownExceptionGroup: + """Tests for shutdown with exceptions.""" + + @pytest.mark.asyncio + async def test_shutdown_raises_exception_group(self): + """Test that shutdown raises ExceptionGroup when exceptions occur.""" + + class FailingComponent: + __metadata__ = { + "name": "failing", + "version": "1.0.0", + "requires": set(), + "_internals": Internals(is_initialized=True) + } + + async def shutdown(self): + raise ValueError("Shutdown failed") + + comp = FailingComponent() + + with pytest.raises((ExceptionGroup, ValueError)): + await shutdown_components(comp, return_exceptions=False) + + +class TestWaitForComponents: + """Tests for wait_for_components function.""" + + @pytest.mark.asyncio + async def test_wait_for_component_with_wait_method(self): + """Test waiting for a component with a wait method.""" + + class WaitableComponent: + __metadata__ = { + "name": "waitable", + "version": "1.0.0", + "requires": set(), + "_internals": Internals(is_initialized=True) + } + waited = False + + async def wait(self): + self.waited = True + + comp = WaitableComponent() + + # Run wait in background and cancel it + task = asyncio.create_task(wait_for_components(comp)) + await asyncio.sleep(0.01) + task.cancel() + + try: + await task + except asyncio.CancelledError: + pass + + assert comp.waited + + @pytest.mark.asyncio + async def test_wait_for_component_without_wait_method(self): + """Test waiting for a component without a wait method uses default sleep.""" + + class NoWaitComponent: + __metadata__ = { + "name": "no_wait", + "version": "1.0.0", + "requires": set(), + "_internals": Internals(is_initialized=True) + } + wait = None + + comp = NoWaitComponent() + + # Run wait in background and cancel it after a short time + task = asyncio.create_task(wait_for_components(comp)) + await asyncio.sleep(0.1) + task.cancel() + + try: + await task + except asyncio.CancelledError: + pass + + @pytest.mark.asyncio + async def test_wait_for_component_cancelled(self): + """Test that wait_for_components handles CancelledError properly.""" + + class WaitableComponent: + __metadata__ = { + "name": "waitable", + "version": "1.0.0", + "requires": set(), + "_internals": Internals(is_initialized=True) + } + + async def wait(self): + await asyncio.sleep(10) + + comp = WaitableComponent() + + task = asyncio.create_task(wait_for_components(comp)) + await asyncio.sleep(0.01) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task diff --git a/tests/awioc/components/test_metadata.py b/tests/awioc/components/test_metadata.py index 00c14f4..1489c5b 100644 --- a/tests/awioc/components/test_metadata.py +++ b/tests/awioc/components/test_metadata.py @@ -1,11 +1,16 @@ -import pytest +from datetime import datetime + +from pydantic import BaseModel from src.awioc.components.metadata import ( ComponentTypes, Internals, ComponentMetadata, AppMetadata, + RegistrationInfo, + metadata, ) +from src.awioc.config.base import Settings class TestComponentTypes: @@ -145,3 +150,206 @@ def test_app_metadata_extends_component_metadata(self): assert "version" in metadata assert "description" in metadata assert "base_config" in metadata + + +class TestRegistrationInfo: + """Tests for RegistrationInfo dataclass.""" + + def test_registration_info_str_minimal(self): + """Test __str__ with minimal fields.""" + reg = RegistrationInfo( + registered_by="test_module", + registered_at=datetime(2024, 1, 15, 10, 30, 0) + ) + result = str(reg) + assert "by 'test_module'" in result + assert "2024-01-15" in result + + def test_registration_info_str_with_file(self): + """Test __str__ with file but no line number.""" + reg = RegistrationInfo( + registered_by="test_module", + registered_at=datetime(2024, 1, 15, 10, 30, 0), + file="/path/to/file.py" + ) + result = str(reg) + assert "by 'test_module'" in result + assert "from /path/to/file.py" in result + + def test_registration_info_str_with_file_and_line(self): + """Test __str__ with file and line number.""" + reg = RegistrationInfo( + registered_by="test_module", + registered_at=datetime(2024, 1, 15, 10, 30, 0), + file="/path/to/file.py", + line=42 + ) + result = str(reg) + assert "by 'test_module'" in result + assert "from /path/to/file.py:42" in result + + def test_registration_info_str_format(self): + """Test __str__ returns properly formatted string.""" + reg = RegistrationInfo( + registered_by="my_component", + registered_at=datetime(2024, 6, 20, 14, 0, 0), + file="component.py", + line=100 + ) + result = str(reg) + assert result.startswith("RegistrationInfo(") + assert result.endswith(")") + + +class TestMetadataFunction: + """Tests for metadata() function.""" + + def test_metadata_minimal(self): + """Test metadata with minimal required fields.""" + meta = metadata( + name="test", + version="1.0.0", + description="Test component" + ) + assert meta["name"] == "test" + assert meta["version"] == "1.0.0" + assert meta["description"] == "Test component" + assert meta["wire"] is True + assert meta["wirings"] == set() + assert meta["requires"] == set() + assert meta["config"] == set() + assert meta["_internals"] is None + + def test_metadata_with_wirings_list(self): + """Test metadata with wirings as a list (converts to set).""" + meta = metadata( + name="test", + version="1.0.0", + description="Test", + wirings=["module1", "module2"] + ) + assert meta["wirings"] == {"module1", "module2"} + assert isinstance(meta["wirings"], set) + + def test_metadata_with_requires_list(self): + """Test metadata with requires as a list (converts types to names).""" + # Component with metadata containing name + mock_component = type("MockComponent", (), {"__metadata__": {"name": "dep_component"}})() + + meta = metadata( + name="test", + version="1.0.0", + description="Test", + requires=[mock_component] + ) + # Component types are converted to their names + assert "dep_component" in meta["requires"] + assert isinstance(meta["requires"], set) + + def test_metadata_with_requires_string_names(self): + """Test metadata with requires as string names directly.""" + meta = metadata( + name="test", + version="1.0.0", + description="Test", + requires=["dep1", "dep2"] + ) + assert "dep1" in meta["requires"] + assert "dep2" in meta["requires"] + assert isinstance(meta["requires"], set) + + def test_metadata_with_single_config_model(self): + """Test metadata with a single config model (wraps in set).""" + + class MyConfig(BaseModel): + value: str = "default" + + meta = metadata( + name="test", + version="1.0.0", + description="Test", + config=MyConfig + ) + assert MyConfig in meta["config"] + assert len(meta["config"]) == 1 + assert isinstance(meta["config"], set) + + def test_metadata_with_multiple_config_models(self): + """Test metadata with multiple config models as list.""" + + class Config1(BaseModel): + a: str = "" + + class Config2(BaseModel): + b: int = 0 + + meta = metadata( + name="test", + version="1.0.0", + description="Test", + config=[Config1, Config2] + ) + assert Config1 in meta["config"] + assert Config2 in meta["config"] + assert len(meta["config"]) == 2 + + def test_metadata_with_base_config(self): + """Test metadata with base_config (creates AppMetadata).""" + meta = metadata( + name="my_app", + version="1.0.0", + description="My App", + base_config=Settings + ) + assert "base_config" in meta + assert meta["base_config"] == Settings + + def test_metadata_with_wire_false(self): + """Test metadata with wire=False.""" + meta = metadata( + name="test", + version="1.0.0", + description="Test", + wire=False + ) + assert meta["wire"] is False + + def test_metadata_with_extra_kwargs(self): + """Test metadata with additional kwargs.""" + meta = metadata( + name="test", + version="1.0.0", + description="Test", + custom_field="custom_value" + ) + assert meta["custom_field"] == "custom_value" + + def test_metadata_wirings_none_becomes_empty_set(self): + """Test metadata with None wirings becomes empty set.""" + meta = metadata( + name="test", + version="1.0.0", + description="Test", + wirings=None + ) + assert meta["wirings"] == set() + + def test_metadata_requires_none_becomes_empty_set(self): + """Test metadata with None requires becomes empty set.""" + meta = metadata( + name="test", + version="1.0.0", + description="Test", + requires=None + ) + assert meta["requires"] == set() + + def test_metadata_config_none_becomes_empty_set(self): + """Test metadata with None config becomes empty set.""" + meta = metadata( + name="test", + version="1.0.0", + description="Test", + config=None + ) + assert meta["config"] == set() diff --git a/tests/awioc/components/test_registry.py b/tests/awioc/components/test_registry.py index 27dabf6..4cf5268 100644 --- a/tests/awioc/components/test_registry.py +++ b/tests/awioc/components/test_registry.py @@ -1,12 +1,16 @@ +from datetime import datetime + import pytest +from src.awioc.components.metadata import Internals, RegistrationInfo from src.awioc.components.registry import ( as_component, component_requires, component_internals, component_str, + component_registration, + clean_module_name, ) -from src.awioc.components.metadata import Internals class MockComponent: @@ -15,7 +19,7 @@ class MockComponent: "name": "mock", "version": "1.0.0", "description": "Mock component", - "requires": set() + "requires": set() # Now stores component names (strings) } initialize = None shutdown = None @@ -36,7 +40,7 @@ class PlainClass: # Name uses __qualname__ which includes the enclosing scope assert "PlainClass" in result.__metadata__["name"] assert result.__metadata__["version"] == "0.0.0" - assert result.__metadata__["wire"] is False + assert result.__metadata__["wire"] is True def test_preserves_existing_metadata(self): """Test as_component preserves existing metadata.""" @@ -238,3 +242,89 @@ class SemVer: result = component_str(SemVer()) assert result == "semver v10.20.30" + + +class TestComponentRegistration: + """Tests for component_registration function.""" + + def test_returns_registration_info(self): + """Test component_registration returns RegistrationInfo.""" + reg_info = RegistrationInfo( + registered_by="test_module", + registered_at=datetime.now(), + file="test.py", + line=10 + ) + + class WithRegistration: + __metadata__ = { + "name": "test", + "_internals": Internals(registration=reg_info) + } + + result = component_registration(WithRegistration()) + assert result is reg_info + + def test_returns_none_without_internals(self): + """Test component_registration returns None when no internals.""" + + class NoInternals: + __metadata__ = {"name": "test"} + + result = component_registration(NoInternals()) + assert result is None + + def test_returns_none_with_none_internals(self): + """Test component_registration returns None when internals is None.""" + + class NoneInternals: + __metadata__ = {"name": "test", "_internals": None} + + result = component_registration(NoneInternals()) + assert result is None + + def test_returns_none_without_registration(self): + """Test component_registration returns None when no registration.""" + + class NoRegistration: + __metadata__ = { + "name": "test", + "_internals": Internals() + } + + result = component_registration(NoRegistration()) + assert result is None + + +class TestCleanModuleName: + """Tests for clean_module_name function.""" + + def test_removes_init(self): + """Test clean_module_name removes __init__.""" + result = clean_module_name("__init__.dashboard") + assert result == "dashboard" + + def test_removes_main(self): + """Test clean_module_name removes __main__.""" + result = clean_module_name("__main__.app") + assert result == "app" + + def test_removes_both(self): + """Test clean_module_name removes both __init__ and __main__.""" + result = clean_module_name("__init__.__main__.module") + assert result == "module" + + def test_preserves_normal_names(self): + """Test clean_module_name preserves normal module names.""" + result = clean_module_name("my.package.module") + assert result == "my.package.module" + + def test_empty_string_returns_unknown(self): + """Test clean_module_name returns 'unknown' for empty string.""" + result = clean_module_name("") + assert result == "unknown" + + def test_only_init_returns_original(self): + """Test clean_module_name with only __init__ returns original.""" + result = clean_module_name("__init__") + assert result == "__init__" diff --git a/tests/awioc/config/test_models.py b/tests/awioc/config/test_models.py index 202b660..8b47164 100644 --- a/tests/awioc/config/test_models.py +++ b/tests/awioc/config/test_models.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest + from src.awioc.config.models import IOCComponentsDefinition, IOCBaseConfig @@ -9,19 +10,19 @@ class TestIOCComponentsDefinition: def test_minimal_definition(self): """Test minimal valid definition.""" - definition = IOCComponentsDefinition(app=Path("./app")) - assert definition.app == Path("./app") + definition = IOCComponentsDefinition(app="./app") + assert "app" in definition.app assert definition.libraries == {} assert definition.plugins == [] def test_full_definition(self): """Test definition with all fields.""" definition = IOCComponentsDefinition( - app=Path("./app"), - libraries={"db": Path("./libs/db"), "cache": Path("./libs/cache")}, - plugins=[Path("./plugins/auth"), Path("./plugins/logging")] + app="./app", + libraries={"db": "./libs/db", "cache": "./libs/cache"}, + plugins=["./plugins/auth", "./plugins/logging"] ) - assert definition.app == Path("./app") + assert "app" in definition.app assert len(definition.libraries) == 2 assert len(definition.plugins) == 2 @@ -33,7 +34,18 @@ def test_definition_from_dict(self): "plugins": ["./plugin1"] } definition = IOCComponentsDefinition.model_validate(data) - assert definition.app == Path("./app") + assert "app" in definition.app + + def test_definition_with_reference(self): + """Test definition with path:reference syntax.""" + definition = IOCComponentsDefinition( + app="./app:MyApp", + libraries={"db": "./libs/db:DatabaseLibrary"}, + plugins=["./plugins/auth:AuthPlugin()"] + ) + assert "app" in definition.app and ":MyApp" in definition.app + assert ":DatabaseLibrary" in definition.libraries["db"] + assert ":AuthPlugin()" in definition.plugins[0] def test_definition_requires_app(self): """Test that app is required.""" @@ -78,3 +90,34 @@ def test_inherits_from_settings(self): from src.awioc.config.base import Settings config = IOCBaseConfig() assert isinstance(config, Settings) + + def test_add_sources_at_specific_index(self): + """Test add_sources with specific index.""" + from src.awioc.config.models import _sources + + # Clear any existing sources first + _sources.clear() + + def source1(x): + return None + + def source2(x): + return None + + def source3(x): + return None + + # Add source1 at end + IOCBaseConfig.add_sources(source1) + # Add source2 at end + IOCBaseConfig.add_sources(source2) + # Add source3 at index 0 + IOCBaseConfig.add_sources(source3, index=0) + + # source3 should be first + assert _sources[0] is source3 + assert source1 in _sources + assert source2 in _sources + + # Clean up + _sources.clear() diff --git a/tests/awioc/config/test_registry.py b/tests/awioc/config/test_registry.py index 44163c4..09e13bc 100644 --- a/tests/awioc/config/test_registry.py +++ b/tests/awioc/config/test_registry.py @@ -1,5 +1,5 @@ -import pytest import pydantic +import pytest from src.awioc.config.registry import ( _CONFIGURATIONS, @@ -81,3 +81,60 @@ class AutoPrefixConfig(pydantic.BaseModel): # Should use the test module name as prefix assert any("test" in key for key in _CONFIGURATIONS.keys()) + + +class TestClearConfigurationsSpecific: + """Tests for clear_configurations with specific prefixes.""" + + def test_clear_specific_prefix(self): + """Test clearing a specific configuration prefix.""" + + @register_configuration(prefix="specific_a") + class SpecificA(pydantic.BaseModel): + pass + + @register_configuration(prefix="specific_b") + class SpecificB(pydantic.BaseModel): + pass + + assert "specific_a" in _CONFIGURATIONS + assert "specific_b" in _CONFIGURATIONS + + # Clear only specific_a + clear_configurations(prefixes=["specific_a"]) + + assert "specific_a" not in _CONFIGURATIONS + assert "specific_b" in _CONFIGURATIONS + + # Cleanup + clear_configurations() + + def test_clear_multiple_specific_prefixes(self): + """Test clearing multiple specific prefixes.""" + + @register_configuration(prefix="multi_a") + class MultiA(pydantic.BaseModel): + pass + + @register_configuration(prefix="multi_b") + class MultiB(pydantic.BaseModel): + pass + + @register_configuration(prefix="multi_c") + class MultiC(pydantic.BaseModel): + pass + + # Clear a and b but not c + clear_configurations(prefixes=["multi_a", "multi_b"]) + + assert "multi_a" not in _CONFIGURATIONS + assert "multi_b" not in _CONFIGURATIONS + assert "multi_c" in _CONFIGURATIONS + + # Cleanup + clear_configurations() + + def test_clear_nonexistent_prefix(self): + """Test clearing a prefix that doesn't exist doesn't raise.""" + # Should not raise + clear_configurations(prefixes=["nonexistent_prefix"]) diff --git a/tests/awioc/di/test_providers.py b/tests/awioc/di/test_providers.py index d6cf7ab..f18b99e 100644 --- a/tests/awioc/di/test_providers.py +++ b/tests/awioc/di/test_providers.py @@ -1,7 +1,7 @@ -import pytest -from unittest.mock import MagicMock, patch, Mock -import pydantic +from unittest.mock import patch, Mock +import pydantic +import pytest from dependency_injector.wiring import Provide from src.awioc.di import providers as providers_module @@ -905,7 +905,7 @@ class ExtendedLibrary: __metadata__ = { "name": "extended_lib", "version": "1.0.0", - "requires": {core_lib}, # Dependency declared here + "requires": {"core_lib"}, # Dependency declared by name "wire": False, } initialize = None @@ -934,8 +934,14 @@ def extended_operation(self): })() interface.set_app(app) - # When registering extended_lib, core_lib's internals are initialized through dependency chain - # We only register the extended_lib; core_lib is handled as a dependency + + # Register core_lib first with its component name as the key + # This allows extended_lib to find it by name when "core_lib" is looked up + interface.register_libraries( + ("core_lib", core_lib), + ) + + # Now register extended_lib which requires "core_lib" interface.register_libraries( (ExtendedLibrary, extended_lib), ) @@ -945,4 +951,64 @@ def extended_operation(self): assert retrieved_extended.extended_operation() == "extended_core" # The dependency (core_lib) should have its internals initialized - assert "_internals" in core_lib.__metadata__ \ No newline at end of file + assert "_internals" in core_lib.__metadata__ + + # extended_lib should be tracked in core_lib's required_by + from src.awioc.components.registry import component_internals + assert extended_lib in component_internals(core_lib).required_by + + +class TestGetPluginFunction: + """Tests for get_plugin function.""" + + def test_get_plugin_exists(self): + """Test get_plugin function exists.""" + from src.awioc.di.providers import get_plugin + assert callable(get_plugin) + + def test_get_plugin_returns_provide_marker(self): + """Test get_plugin returns a Provide marker.""" + from src.awioc.di.providers import get_plugin + result = get_plugin("some_plugin") + assert isinstance(result, Provide) + + def test_get_plugin_with_different_names(self): + """Test get_plugin with different plugin names.""" + from src.awioc.di.providers import get_plugin + result1 = get_plugin("plugin_a") + result2 = get_plugin("plugin_b") + assert isinstance(result1, Provide) + assert isinstance(result2, Provide) + + +class TestGetComponentFunction: + """Tests for get_component function.""" + + def test_get_component_exists(self): + """Test get_component function exists.""" + from src.awioc.di.providers import get_component + assert callable(get_component) + + def test_get_component_with_name_returns_provide_marker(self): + """Test get_component with name returns a Provide marker.""" + from src.awioc.di.providers import get_component + result = get_component("some_component") + assert isinstance(result, Provide) + + def test_get_component_without_name_returns_provide_marker(self): + """Test get_component without name returns a Provide marker.""" + from src.awioc.di.providers import get_component + # When called without args, it inspects the calling module + result = get_component() + assert isinstance(result, Provide) + + def test_get_component_no_module_raises(self): + """Test get_component raises when module cannot be determined.""" + from src.awioc.di.providers import get_component + with patch('src.awioc.di.providers.inspect') as mock_inspect: + mock_frame = Mock() + mock_inspect.stack.return_value = [None, (mock_frame,)] + mock_inspect.getmodule.return_value = None + + with pytest.raises(RuntimeError, match="Cannot determine calling component"): + get_component() diff --git a/tests/awioc/di/test_wiring.py b/tests/awioc/di/test_wiring.py index 4b3ae59..00e62bd 100644 --- a/tests/awioc/di/test_wiring.py +++ b/tests/awioc/di/test_wiring.py @@ -1,12 +1,12 @@ -import pytest from types import ModuleType -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pydantic +import pytest -from src.awioc.di.wiring import wire, inject_dependencies +from src.awioc.config.registry import _CONFIGURATIONS, clear_configurations from src.awioc.container import AppContainer, ContainerInterface -from src.awioc.config.registry import _CONFIGURATIONS, register_configuration, clear_configurations +from src.awioc.di.wiring import wire, inject_dependencies class TestInjectDependencies: @@ -99,6 +99,37 @@ class EmptyConfigComponent: # Should not raise inject_dependencies(container_interface, components=[EmptyConfigComponent()]) + def test_inject_dependencies_uses_all_components_when_none(self, container_interface): + """Test inject_dependencies uses all container components when components is None.""" + clear_configurations() + + class TestConfig(pydantic.BaseModel): + value: str = "test" + + class AppComponent: + __name__ = "app" + __module__ = "test" + __package__ = None + __metadata__ = { + "name": "test_app", + "version": "1.0.0", + "requires": set(), + "config": {TestConfig} + } + + async def initialize(self): + pass + + async def shutdown(self): + pass + + container_interface.set_app(AppComponent()) + + # Call without specifying components - should use container.components + inject_dependencies(container_interface, components=None) + + assert "test_app" in _CONFIGURATIONS + class TestWire: """Tests for wire function.""" @@ -168,46 +199,77 @@ def test_wire_handles_module_type_components(self, container_interface): def test_wire_with_relative_wirings(self, container_interface): """Test wire with relative wirings.""" - class ComponentWithWirings: - __name__ = "comp" - __module__ = "my_package.component" - __package__ = "my_package" - __metadata__ = { - "name": "comp", - "version": "1.0.0", - "wire": True, - "wirings": {"submodule", "another"} - } - - container_interface.raw_container().wire = MagicMock() - - wire(container_interface, components=[ComponentWithWirings()]) - - call_args = container_interface.raw_container().wire.call_args - modules = call_args.kwargs.get('modules', set()) - assert "my_package.submodule" in modules - assert "my_package.another" in modules + import sys + from types import ModuleType + + # Create fake modules + fake_component = ModuleType("my_package.component") + fake_submodule = ModuleType("my_package.submodule") + fake_another = ModuleType("my_package.another") + sys.modules["my_package.component"] = fake_component + sys.modules["my_package.submodule"] = fake_submodule + sys.modules["my_package.another"] = fake_another + + try: + class ComponentWithWirings: + __name__ = "comp" + __module__ = "my_package.component" + __package__ = "my_package" + __metadata__ = { + "name": "comp", + "version": "1.0.0", + "wire": True, + "wirings": {"submodule", "another"} + } + + container_interface.raw_container().wire = MagicMock() + + wire(container_interface, components=[ComponentWithWirings()]) + + call_args = container_interface.raw_container().wire.call_args + modules = call_args.kwargs.get('modules', set()) + module_names = {m.__name__ for m in modules} + assert "my_package.submodule" in module_names + assert "my_package.another" in module_names + finally: + del sys.modules["my_package.component"] + del sys.modules["my_package.submodule"] + del sys.modules["my_package.another"] def test_wire_handles_string_wirings(self, container_interface): """Test wire with string wirings (not iterable check).""" - class StringWiring: - __name__ = "str_wire" - __module__ = "str_module" - __package__ = "pkg" - __metadata__ = { - "name": "str", - "version": "1.0.0", - "wire": True, - "wirings": "single_module" # String, not set - } - - container_interface.raw_container().wire = MagicMock() - - wire(container_interface, components=[StringWiring()]) - - call_args = container_interface.raw_container().wire.call_args - modules = call_args.kwargs.get('modules', set()) - assert "pkg.single_module" in modules + import sys + from types import ModuleType + + # Create fake modules + fake_str_module = ModuleType("str_module") + fake_single_module = ModuleType("pkg.single_module") + sys.modules["str_module"] = fake_str_module + sys.modules["pkg.single_module"] = fake_single_module + + try: + class StringWiring: + __name__ = "str_wire" + __module__ = "str_module" + __package__ = "pkg" + __metadata__ = { + "name": "str", + "version": "1.0.0", + "wire": True, + "wirings": "single_module" # String, not set + } + + container_interface.raw_container().wire = MagicMock() + + wire(container_interface, components=[StringWiring()]) + + call_args = container_interface.raw_container().wire.call_args + modules = call_args.kwargs.get('modules', set()) + module_names = {m.__name__ for m in modules} + assert "pkg.single_module" in module_names + finally: + del sys.modules["str_module"] + del sys.modules["pkg.single_module"] def test_wire_uses_all_components_when_none_specified(self, container_interface): """Test wire uses all components when none specified.""" @@ -236,3 +298,87 @@ async def shutdown(self): wire(container_interface, components=None) container_interface.raw_container().wire.assert_called_once() + + def test_wire_derives_package_from_module_for_package_components(self, container_interface): + """Test wire derives package correctly when component is in a package (__init__.py).""" + import sys + from types import ModuleType + + # Create a fake module that is a package (has __path__) + fake_package = ModuleType("myapp.components.mycomponent") + fake_package.__path__ = ["/fake/path"] # Makes it a package + fake_logic = ModuleType("myapp.components.mycomponent.logic") + fake_routes = ModuleType("myapp.components.mycomponent.routes") + sys.modules["myapp.components.mycomponent"] = fake_package + sys.modules["myapp.components.mycomponent.logic"] = fake_logic + sys.modules["myapp.components.mycomponent.routes"] = fake_routes + + try: + class PackageComponent: + __name__ = "MyComponent" + __module__ = "myapp.components.mycomponent" # Component defined in __init__.py + # No __package__ attribute set + __metadata__ = { + "name": "mycomponent", + "version": "1.0.0", + "wire": True, + "wirings": {"logic", "routes"} # Should resolve to myapp.components.mycomponent.logic + } + + container_interface.raw_container().wire = MagicMock() + + wire(container_interface, components=[PackageComponent()]) + + call_args = container_interface.raw_container().wire.call_args + modules = call_args.kwargs.get('modules', set()) + module_names = {m.__name__ for m in modules} + # Wirings should be relative to the package itself, not its parent + assert "myapp.components.mycomponent.logic" in module_names + assert "myapp.components.mycomponent.routes" in module_names + finally: + # Clean up + del sys.modules["myapp.components.mycomponent"] + del sys.modules["myapp.components.mycomponent.logic"] + del sys.modules["myapp.components.mycomponent.routes"] + + def test_wire_derives_package_from_module_for_file_components(self, container_interface): + """Test wire derives package correctly when component is in a regular file.""" + import sys + from types import ModuleType + + # Create a fake module that is NOT a package (no __path__) + fake_module = ModuleType("myapp.components.mycomponent") + fake_logic = ModuleType("myapp.components.logic") + fake_routes = ModuleType("myapp.components.routes") + # No __path__ attribute - it's a regular .py file + sys.modules["myapp.components.mycomponent"] = fake_module + sys.modules["myapp.components.logic"] = fake_logic + sys.modules["myapp.components.routes"] = fake_routes + + try: + class FileComponent: + __name__ = "MyComponent" + __module__ = "myapp.components.mycomponent" # Component defined in mycomponent.py + # No __package__ attribute set + __metadata__ = { + "name": "mycomponent", + "version": "1.0.0", + "wire": True, + "wirings": {"logic", "routes"} # Should resolve to myapp.components.logic + } + + container_interface.raw_container().wire = MagicMock() + + wire(container_interface, components=[FileComponent()]) + + call_args = container_interface.raw_container().wire.call_args + modules = call_args.kwargs.get('modules', set()) + module_names = {m.__name__ for m in modules} + # Wirings should be relative to the parent package + assert "myapp.components.logic" in module_names + assert "myapp.components.routes" in module_names + finally: + # Clean up + del sys.modules["myapp.components.mycomponent"] + del sys.modules["myapp.components.logic"] + del sys.modules["myapp.components.routes"] diff --git a/tests/awioc/loader/test_manifest.py b/tests/awioc/loader/test_manifest.py new file mode 100644 index 0000000..1d7402c --- /dev/null +++ b/tests/awioc/loader/test_manifest.py @@ -0,0 +1,400 @@ +"""Tests for the manifest loader module.""" + +import pytest +import yaml + +from src.awioc.loader.manifest import ( + AWIOC_DIR, + MANIFEST_FILENAME, + ComponentConfigRef, + ComponentEntry, + PluginManifest, + find_manifest, + load_manifest, + manifest_to_metadata, +) + + +class TestComponentConfigRef: + """Tests for ComponentConfigRef model.""" + + def test_create_with_model_only(self): + """Test creating config ref with model only.""" + ref = ComponentConfigRef(model="module:ClassName") + assert ref.model == "module:ClassName" + assert ref.prefix is None + + def test_create_with_model_and_prefix(self): + """Test creating config ref with model and prefix.""" + ref = ComponentConfigRef(model="module:ClassName", prefix="custom") + assert ref.model == "module:ClassName" + assert ref.prefix == "custom" + + def test_rejects_extra_fields(self): + """Test that extra fields are rejected.""" + with pytest.raises(ValueError): + ComponentConfigRef(model="module:Class", extra_field="value") + + +class TestComponentEntry: + """Tests for ComponentEntry model.""" + + def test_create_minimal_entry(self): + """Test creating entry with minimal required fields.""" + entry = ComponentEntry(name="my_component", file="component.py") + assert entry.name == "my_component" + assert entry.file == "component.py" + assert entry.version == "0.0.0" + assert entry.description == "" + assert entry.class_name is None + assert entry.wire is False + assert entry.wirings == [] + assert entry.requires == [] + assert entry.config is None + + def test_create_full_entry(self): + """Test creating entry with all fields.""" + entry = ComponentEntry( + name="full_component", + version="1.2.3", + description="A full component", + file="full.py", + **{"class": "FullComponent"}, + wire=True, + wirings=["module1", "module2"], + requires=["dep1", "dep2"], + config=[{"model": "full:Config", "prefix": "fc"}], + ) + assert entry.name == "full_component" + assert entry.version == "1.2.3" + assert entry.description == "A full component" + assert entry.file == "full.py" + assert entry.class_name == "FullComponent" + assert entry.wire is True + assert entry.wirings == ["module1", "module2"] + assert entry.requires == ["dep1", "dep2"] + assert len(entry.config) == 1 + assert entry.config[0].model == "full:Config" + + def test_class_alias(self): + """Test that 'class' field works as alias for class_name.""" + data = {"name": "test", "file": "test.py", "class": "TestClass"} + entry = ComponentEntry(**data) + assert entry.class_name == "TestClass" + + def test_config_normalization_single_dict(self): + """Test that single config dict is normalized to list.""" + entry = ComponentEntry( + name="test", + file="test.py", + config={"model": "test:Config"}, + ) + assert isinstance(entry.config, list) + assert len(entry.config) == 1 + assert entry.config[0].model == "test:Config" + + def test_config_normalization_string(self): + """Test that string config is normalized to list with model.""" + entry = ComponentEntry( + name="test", + file="test.py", + config="test:Config", + ) + assert isinstance(entry.config, list) + assert len(entry.config) == 1 + assert entry.config[0].model == "test:Config" + + def test_get_config_list_empty(self): + """Test get_config_list with no config.""" + entry = ComponentEntry(name="test", file="test.py") + assert entry.get_config_list() == [] + + def test_get_config_list_with_config(self): + """Test get_config_list with config.""" + entry = ComponentEntry( + name="test", + file="test.py", + config=[{"model": "test:Config"}], + ) + configs = entry.get_config_list() + assert len(configs) == 1 + assert configs[0].model == "test:Config" + + +class TestPluginManifest: + """Tests for PluginManifest model.""" + + def test_create_empty_manifest(self): + """Test creating empty manifest with defaults.""" + manifest = PluginManifest() + assert manifest.manifest_version == "1.0" + assert manifest.name is None + assert manifest.version is None + assert manifest.description is None + assert manifest.components == [] + + def test_create_full_manifest(self): + """Test creating manifest with all fields.""" + manifest = PluginManifest( + manifest_version="1.0", + name="my-plugins", + version="2.0.0", + description="My plugin collection", + components=[ + {"name": "plugin1", "file": "plugin1.py"}, + {"name": "plugin2", "file": "plugin2.py", "class": "Plugin2"}, + ], + ) + assert manifest.manifest_version == "1.0" + assert manifest.name == "my-plugins" + assert manifest.version == "2.0.0" + assert manifest.description == "My plugin collection" + assert len(manifest.components) == 2 + assert manifest.components[0].name == "plugin1" + assert manifest.components[1].class_name == "Plugin2" + + def test_get_component_found(self): + """Test get_component when component exists.""" + manifest = PluginManifest( + components=[ + {"name": "plugin1", "file": "plugin1.py"}, + {"name": "plugin2", "file": "plugin2.py"}, + ] + ) + component = manifest.get_component("plugin2") + assert component is not None + assert component.name == "plugin2" + + def test_get_component_not_found(self): + """Test get_component when component doesn't exist.""" + manifest = PluginManifest( + components=[{"name": "plugin1", "file": "plugin1.py"}] + ) + component = manifest.get_component("nonexistent") + assert component is None + + def test_get_component_by_file_found(self): + """Test get_component_by_file when file exists.""" + manifest = PluginManifest( + components=[ + {"name": "plugin1", "file": "plugin1.py"}, + {"name": "plugin2", "file": "plugin2.py", "class": "Plugin2"}, + ] + ) + component = manifest.get_component_by_file("plugin2.py") + assert component is not None + assert component.name == "plugin2" + + def test_get_component_by_file_with_class(self): + """Test get_component_by_file with class filter.""" + manifest = PluginManifest( + components=[ + {"name": "plugin1", "file": "multi.py", "class": "Plugin1"}, + {"name": "plugin2", "file": "multi.py", "class": "Plugin2"}, + ] + ) + component = manifest.get_component_by_file("multi.py", "Plugin2") + assert component is not None + assert component.name == "plugin2" + + def test_get_component_by_file_not_found(self): + """Test get_component_by_file when file doesn't exist.""" + manifest = PluginManifest( + components=[{"name": "plugin1", "file": "plugin1.py"}] + ) + component = manifest.get_component_by_file("nonexistent.py") + assert component is None + + +class TestLoadManifest: + """Tests for load_manifest function.""" + + def test_load_valid_manifest(self, temp_dir): + """Test loading a valid manifest file from .awioc directory.""" + awioc_dir = temp_dir / AWIOC_DIR + awioc_dir.mkdir() + manifest_content = { + "manifest_version": "1.0", + "name": "test-plugins", + "version": "1.0.0", + "components": [ + {"name": "plugin1", "file": "plugin1.py", "wire": True}, + ], + } + manifest_path = awioc_dir / MANIFEST_FILENAME + manifest_path.write_text(yaml.dump(manifest_content)) + + manifest = load_manifest(temp_dir) + + assert manifest.manifest_version == "1.0" + assert manifest.name == "test-plugins" + assert len(manifest.components) == 1 + assert manifest.components[0].name == "plugin1" + assert manifest.components[0].wire is True + + def test_load_manifest_not_found(self, temp_dir): + """Test that loading missing manifest raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError, match="Manifest not found"): + load_manifest(temp_dir) + + def test_load_empty_manifest(self, temp_dir): + """Test loading empty manifest file.""" + awioc_dir = temp_dir / AWIOC_DIR + awioc_dir.mkdir() + manifest_path = awioc_dir / MANIFEST_FILENAME + manifest_path.write_text("") + + manifest = load_manifest(temp_dir) + + assert manifest.manifest_version == "1.0" + assert manifest.components == [] + + def test_load_manifest_with_config(self, temp_dir): + """Test loading manifest with config references.""" + awioc_dir = temp_dir / AWIOC_DIR + awioc_dir.mkdir() + manifest_content = { + "manifest_version": "1.0", + "components": [ + { + "name": "db_plugin", + "file": "db.py", + "config": [ + {"model": "db:DatabaseConfig", "prefix": "database"}, + ], + }, + ], + } + manifest_path = awioc_dir / MANIFEST_FILENAME + manifest_path.write_text(yaml.dump(manifest_content)) + + manifest = load_manifest(temp_dir) + + assert len(manifest.components) == 1 + configs = manifest.components[0].get_config_list() + assert len(configs) == 1 + assert configs[0].model == "db:DatabaseConfig" + assert configs[0].prefix == "database" + + +class TestFindManifest: + """Tests for find_manifest function.""" + + def test_find_manifest_in_directory(self, temp_dir): + """Test finding manifest in a directory's .awioc folder.""" + awioc_dir = temp_dir / AWIOC_DIR + awioc_dir.mkdir() + manifest_path = awioc_dir / MANIFEST_FILENAME + manifest_path.write_text("manifest_version: '1.0'") + + result = find_manifest(temp_dir) + + assert result is not None + assert result == manifest_path + + def test_find_manifest_for_file(self, temp_dir): + """Test finding manifest for a file (in parent's .awioc directory).""" + awioc_dir = temp_dir / AWIOC_DIR + awioc_dir.mkdir() + manifest_path = awioc_dir / MANIFEST_FILENAME + manifest_path.write_text("manifest_version: '1.0'") + + file_path = temp_dir / "component.py" + file_path.write_text("# component") + + result = find_manifest(file_path) + + assert result is not None + assert result == manifest_path + + def test_find_manifest_not_found(self, temp_dir): + """Test that find_manifest returns None when no manifest exists.""" + result = find_manifest(temp_dir) + assert result is None + + def test_find_manifest_in_subdirectory(self, temp_dir): + """Test finding manifest in subdirectory's .awioc folder.""" + subdir = temp_dir / "plugins" + subdir.mkdir() + awioc_dir = subdir / AWIOC_DIR + awioc_dir.mkdir() + manifest_path = awioc_dir / MANIFEST_FILENAME + manifest_path.write_text("manifest_version: '1.0'") + + result = find_manifest(subdir) + + assert result is not None + assert result == manifest_path + + +class TestManifestToMetadata: + """Tests for manifest_to_metadata function.""" + + def test_basic_metadata_conversion(self, temp_dir): + """Test converting basic entry to metadata.""" + entry = ComponentEntry( + name="test_component", + version="1.0.0", + description="Test description", + file="test.py", + wire=True, + ) + manifest_path = temp_dir / MANIFEST_FILENAME + + metadata = manifest_to_metadata(entry, manifest_path) + + assert metadata["name"] == "test_component" + assert metadata["version"] == "1.0.0" + assert metadata["description"] == "Test description" + assert metadata["wire"] is True + assert metadata["_manifest_path"] == str(manifest_path) + + def test_metadata_with_wirings(self, temp_dir): + """Test converting entry with wirings.""" + entry = ComponentEntry( + name="test", + file="test.py", + wirings=["module1", "module2"], + ) + manifest_path = temp_dir / MANIFEST_FILENAME + + metadata = manifest_to_metadata(entry, manifest_path) + + assert metadata["wirings"] == {"module1", "module2"} + + def test_metadata_with_requires(self, temp_dir): + """Test converting entry with requires.""" + entry = ComponentEntry( + name="test", + file="test.py", + requires=["dep1", "dep2"], + ) + manifest_path = temp_dir / MANIFEST_FILENAME + + metadata = manifest_to_metadata(entry, manifest_path) + + assert metadata["requires"] == {"dep1", "dep2"} + + def test_metadata_with_config_refs(self, temp_dir): + """Test converting entry with config refs.""" + entry = ComponentEntry( + name="test", + file="test.py", + config=[{"model": "test:Config", "prefix": "tc"}], + ) + manifest_path = temp_dir / MANIFEST_FILENAME + + metadata = manifest_to_metadata(entry, manifest_path) + + assert len(metadata["_config_refs"]) == 1 + assert metadata["_config_refs"][0]["model"] == "test:Config" + assert metadata["_config_refs"][0]["prefix"] == "tc" + + def test_metadata_empty_wirings_is_empty_set(self, temp_dir): + """Test that empty wirings results in empty set.""" + entry = ComponentEntry(name="test", file="test.py", wirings=[]) + manifest_path = temp_dir / MANIFEST_FILENAME + + metadata = manifest_to_metadata(entry, manifest_path) + + assert metadata["wirings"] == set() diff --git a/tests/awioc/loader/test_manifest_extended.py b/tests/awioc/loader/test_manifest_extended.py new file mode 100644 index 0000000..431a26a --- /dev/null +++ b/tests/awioc/loader/test_manifest_extended.py @@ -0,0 +1,221 @@ +"""Extended tests for the manifest loader module to improve coverage.""" + +import yaml + +from src.awioc.loader.manifest import ( + AWIOC_DIR, + MANIFEST_FILENAME, + ComponentConfigRef, + ComponentEntry, + PluginManifest, + find_manifest, + has_awioc_dir, + load_manifest, + manifest_to_metadata, + resolve_config_models, +) + + +class TestHasAwiocDir: + """Tests for has_awioc_dir function.""" + + def test_returns_true_with_manifest(self, tmp_path): + """Test returns True when .awioc/manifest.yaml exists.""" + awioc_dir = tmp_path / AWIOC_DIR + awioc_dir.mkdir() + (awioc_dir / MANIFEST_FILENAME).write_text("manifest_version: '1.0'") + + assert has_awioc_dir(tmp_path) is True + + def test_returns_false_without_manifest(self, tmp_path): + """Test returns False when .awioc directory is empty.""" + awioc_dir = tmp_path / AWIOC_DIR + awioc_dir.mkdir() + + assert has_awioc_dir(tmp_path) is False + + def test_returns_false_without_awioc_dir(self, tmp_path): + """Test returns False when .awioc directory doesn't exist.""" + assert has_awioc_dir(tmp_path) is False + + +class TestResolveConfigModels: + """Tests for resolve_config_models function.""" + + def test_empty_list(self, tmp_path): + """Test with empty list.""" + result = resolve_config_models([], tmp_path) + assert result == set() + + def test_resolve_valid_model(self, tmp_path): + """Test resolving valid Pydantic model.""" + module_file = tmp_path / "models.py" + module_file.write_text(''' +from pydantic import BaseModel + +class ValidModel(BaseModel): + name: str = "test" +''', encoding="utf-8") + + config_refs = [{"model": "models:ValidModel"}] + result = resolve_config_models(config_refs, tmp_path) + assert len(result) == 1 + + def test_resolve_with_prefix_override(self, tmp_path): + """Test resolving model with prefix override.""" + module_file = tmp_path / "prefixed.py" + module_file.write_text(''' +from pydantic import BaseModel + +class PrefixedModel(BaseModel): + __prefix__ = "default" + value: str = "" +''', encoding="utf-8") + + config_refs = [{"model": "prefixed:PrefixedModel", "prefix": "custom"}] + result = resolve_config_models(config_refs, tmp_path) + assert len(result) == 1 + model = list(result)[0] + assert model.__prefix__ == "custom" + + def test_skip_non_pydantic_model(self, tmp_path, caplog): + """Test skipping non-Pydantic model with warning.""" + import logging + + module_file = tmp_path / "not_pydantic.py" + module_file.write_text(''' +class NotAModel: + pass +''', encoding="utf-8") + + config_refs = [{"model": "not_pydantic:NotAModel"}] + + with caplog.at_level(logging.WARNING): + result = resolve_config_models(config_refs, tmp_path) + assert len(result) == 0 + assert "not a Pydantic BaseModel" in caplog.text + + def test_handle_resolve_error(self, tmp_path, caplog): + """Test handling resolve errors gracefully.""" + import logging + + config_refs = [{"model": "nonexistent_module:Class"}] + + with caplog.at_level(logging.ERROR): + result = resolve_config_models(config_refs, tmp_path) + assert len(result) == 0 + assert "Failed to resolve config model" in caplog.text + + +class TestManifestToMetadata: + """Extended tests for manifest_to_metadata function.""" + + def test_with_config_models(self, tmp_path): + """Test manifest to metadata with config models.""" + # Create config model + config_file = tmp_path / "app_config.py" + config_file.write_text(''' +from pydantic import BaseModel + +class AppConfig(BaseModel): + __prefix__ = "app" + host: str = "localhost" + port: int = 8080 +''', encoding="utf-8") + + # Create manifest + manifest = PluginManifest( + manifest_version="1.0", + name="test_app", + version="1.0.0", + components=[ + ComponentEntry( + name="Test App", + version="1.0.0", + file="app.py", + config=[ComponentConfigRef(model="app_config:AppConfig")] + ) + ] + ) + + metadata = manifest_to_metadata(manifest.components[0], tmp_path) + assert metadata["name"] == "Test App" + assert "_config_refs" in metadata + assert len(metadata["_config_refs"]) == 1 + + +class TestLoadManifest: + """Extended tests for load_manifest function.""" + + def test_load_manifest_with_all_fields(self, tmp_path): + """Test loading manifest with all component fields.""" + awioc_dir = tmp_path / AWIOC_DIR + awioc_dir.mkdir() + + manifest_content = { + "manifest_version": "1.0", + "name": "full_manifest", + "version": "2.0.0", + "description": "A complete manifest", + "components": [ + { + "name": "Full Component", + "version": "1.5.0", + "description": "A component with all fields", + "file": "component.py", + "class": "FullComponent", + "wire": True, + "wirings": ["other_module"], + "requires": ["other_component"], + "config": [{"model": "config:Config"}] + } + ] + } + + (awioc_dir / MANIFEST_FILENAME).write_text( + yaml.dump(manifest_content), encoding="utf-8" + ) + + manifest = load_manifest(tmp_path) + assert manifest is not None + assert manifest.name == "full_manifest" + assert len(manifest.components) == 1 + + component = manifest.components[0] + assert component.name == "Full Component" + assert component.wire is True + assert "other_module" in component.wirings + assert "other_component" in component.requires + + +class TestFindManifest: + """Tests for find_manifest function.""" + + def test_find_manifest_in_current_dir(self, tmp_path): + """Test finding manifest in current directory.""" + awioc_dir = tmp_path / AWIOC_DIR + awioc_dir.mkdir() + (awioc_dir / MANIFEST_FILENAME).write_text("manifest_version: '1.0'") + + path = find_manifest(tmp_path) + assert path is not None + assert path == awioc_dir / MANIFEST_FILENAME + + def test_find_manifest_not_found(self, tmp_path): + """Test finding manifest when not present.""" + path = find_manifest(tmp_path) + assert path is None + + def test_find_manifest_in_parent(self, tmp_path): + """Test finding manifest in parent directory.""" + awioc_dir = tmp_path / AWIOC_DIR + awioc_dir.mkdir() + (awioc_dir / MANIFEST_FILENAME).write_text("manifest_version: '1.0'") + + # Create subdirectory + subdir = tmp_path / "subdir" + subdir.mkdir() + + path = find_manifest(subdir) + assert path is not None + assert path == awioc_dir / MANIFEST_FILENAME diff --git a/tests/awioc/loader/test_module_loader.py b/tests/awioc/loader/test_module_loader.py index 35676f3..3f21303 100644 --- a/tests/awioc/loader/test_module_loader.py +++ b/tests/awioc/loader/test_module_loader.py @@ -1,9 +1,22 @@ -import pytest import sys -from pathlib import Path -from src.awioc.loader.module_loader import compile_component +import pytest +import yaml + from src.awioc.components.protocols import Component +from src.awioc.loader.manifest import AWIOC_DIR, MANIFEST_FILENAME +from src.awioc.loader.module_loader import compile_component + + +def create_manifest(directory, components): + """Helper to create .awioc/manifest.yaml in a directory.""" + awioc_dir = directory / AWIOC_DIR + awioc_dir.mkdir(exist_ok=True) + manifest = { + "manifest_version": "1.0", + "components": components, + } + (awioc_dir / MANIFEST_FILENAME).write_text(yaml.dump(manifest)) class TestCompileComponent: @@ -29,14 +42,15 @@ def test_compile_path_without_suffix(self, temp_dir, reset_sys_modules): """Test compiling path without .py suffix.""" module_path = temp_dir / "no_suffix.py" module_path.write_text(""" -__metadata__ = { - "name": "no_suffix", - "version": "1.0.0", - "description": "Test" -} initialize = None shutdown = None +wait = None """) + # Create manifest + create_manifest(temp_dir, [ + {"name": "no_suffix", "version": "1.0.0", "description": "Test", "file": "no_suffix.py"} + ]) + # Pass path without .py extension path_without_suffix = temp_dir / "no_suffix" result = compile_component(path_without_suffix) @@ -45,10 +59,11 @@ def test_compile_path_without_suffix(self, temp_dir, reset_sys_modules): assert result.__metadata__["name"] == "no_suffix" def test_compile_nonexistent_raises(self, temp_dir): - """Test compiling non-existent path raises FileNotFoundError.""" + """Test compiling non-existent path raises error about manifest.""" nonexistent = temp_dir / "does_not_exist" - with pytest.raises(FileNotFoundError, match="Component not found"): + # With mandatory manifests, non-existent paths fail at manifest lookup + with pytest.raises(RuntimeError, match="No manifest entry found"): compile_component(nonexistent) def test_compile_cached_module(self, sample_component_module, reset_sys_modules): @@ -61,27 +76,15 @@ def test_compile_cached_module(self, sample_component_module, reset_sys_modules) assert result1 is result2 - def test_compile_adds_metadata_if_missing(self, temp_dir, reset_sys_modules): - """Test that metadata is added if missing.""" - module_path = temp_dir / "no_metadata_test.py" - module_path.write_text(""" -# Module without __metadata__ -value = 42 -""") - result = compile_component(module_path) - - assert hasattr(result, "__metadata__") - # Name is derived from __qualname__ or class qualname - assert "name" in result.__metadata__ - assert result.__metadata__["version"] == "0.0.0" - def test_compile_adds_initialize_if_missing(self, temp_dir, reset_sys_modules): """Test that initialize is added if missing.""" module_path = temp_dir / "no_init.py" module_path.write_text(""" -__metadata__ = {"name": "no_init", "version": "1.0.0", "description": ""} # No initialize defined """) + create_manifest(temp_dir, [ + {"name": "no_init", "version": "1.0.0", "description": "", "file": "no_init.py"} + ]) result = compile_component(module_path) assert hasattr(result, "initialize") @@ -91,25 +94,41 @@ def test_compile_adds_shutdown_if_missing(self, temp_dir, reset_sys_modules): """Test that shutdown is added if missing.""" module_path = temp_dir / "no_shutdown.py" module_path.write_text(""" -__metadata__ = {"name": "no_shutdown", "version": "1.0.0", "description": ""} # No shutdown defined """) + create_manifest(temp_dir, [ + {"name": "no_shutdown", "version": "1.0.0", "description": "", "file": "no_shutdown.py"} + ]) result = compile_component(module_path) assert hasattr(result, "shutdown") assert result.shutdown is None + def test_compile_adds_wait_if_missing(self, temp_dir, reset_sys_modules): + """Test that wait is added if missing.""" + module_path = temp_dir / "no_wait.py" + module_path.write_text(""" +# No wait defined +""") + create_manifest(temp_dir, [ + {"name": "no_wait", "version": "1.0.0", "description": "", "file": "no_wait.py"} + ]) + result = compile_component(module_path) + assert hasattr(result, "wait") + assert result.wait is None + def test_compile_preserves_module_attributes(self, temp_dir, reset_sys_modules): """Test that module attributes are preserved.""" module_path = temp_dir / "with_attrs.py" module_path.write_text(""" -__metadata__ = {"name": "with_attrs", "version": "1.0.0", "description": ""} - CONSTANT = "test_value" def helper(): return 42 """) + create_manifest(temp_dir, [ + {"name": "with_attrs", "version": "1.0.0", "description": "", "file": "with_attrs.py"} + ]) result = compile_component(module_path) assert result.CONSTANT == "test_value" @@ -130,15 +149,18 @@ def test_compile_package_has_submodule_search(self, temp_dir, reset_sys_modules) pkg_dir.mkdir() init_path = pkg_dir / "__init__.py" - init_path.write_text(""" -__metadata__ = {"name": "test_pkg", "version": "1.0.0", "description": ""} -""") + init_path.write_text("") sub_path = pkg_dir / "submodule.py" sub_path.write_text(""" VALUE = "submodule_value" """) + # Create manifest inside the package + create_manifest(pkg_dir, [ + {"name": "test_pkg", "version": "1.0.0", "description": "", "file": "__init__.py"} + ]) + result = compile_component(pkg_dir) assert result.__metadata__["name"] == "test_pkg" @@ -149,14 +171,13 @@ def test_compile_package_has_submodule_search(self, temp_dir, reset_sys_modules) class TestCompileComponentEdgeCases: """Edge case tests for compile_component.""" - def test_compile_empty_file(self, temp_dir, reset_sys_modules): - """Test compiling an empty file.""" + def test_compile_empty_file_raises_error(self, temp_dir, reset_sys_modules): + """Test compiling an empty file without manifest raises error.""" module_path = temp_dir / "empty_module.py" module_path.write_text("") - result = compile_component(module_path) - - assert hasattr(result, "__metadata__") + with pytest.raises(RuntimeError, match="No manifest entry found"): + compile_component(module_path) def test_compile_module_with_imports(self, temp_dir, reset_sys_modules): """Test compiling module with standard library imports.""" @@ -166,10 +187,11 @@ def test_compile_module_with_imports(self, temp_dir, reset_sys_modules): import sys from pathlib import Path -__metadata__ = {"name": "with_imports", "version": "1.0.0", "description": ""} - current_dir = Path(__file__).parent """) + create_manifest(temp_dir, [ + {"name": "with_imports", "version": "1.0.0", "description": "", "file": "with_imports.py"} + ]) result = compile_component(module_path) assert result.__metadata__["name"] == "with_imports" @@ -182,8 +204,6 @@ def test_compile_module_with_async_functions(self, temp_dir, reset_sys_modules): module_path.write_text(""" import asyncio -__metadata__ = {"name": "async_module_test", "version": "1.0.0", "description": ""} - async def initialize(): await asyncio.sleep(0) return True @@ -191,8 +211,489 @@ async def initialize(): async def shutdown(): await asyncio.sleep(0) """) + create_manifest(temp_dir, [ + {"name": "async_module_test", "version": "1.0.0", "description": "", "file": "async_module_test.py"} + ]) result = compile_component(module_path) assert result.__metadata__["name"] == "async_module_test" assert asyncio_mod.iscoroutinefunction(result.initialize) assert asyncio_mod.iscoroutinefunction(result.shutdown) + + +class TestCompileComponentWithReference: + """Tests for compile_component with path:reference syntax.""" + + def test_compile_with_class_reference(self, temp_dir, reset_sys_modules): + """Test compiling a class from module using path:ClassName syntax.""" + module_path = temp_dir / "with_class.py" + module_path.write_text(""" +class MyComponent: + initialize = None + shutdown = None +""") + create_manifest(temp_dir, [ + {"name": "my_component", "version": "1.0.0", "description": "A class-based component", + "file": "with_class.py", "class": "MyComponent"} + ]) + result = compile_component(f"{module_path}:MyComponent") + + assert result.__metadata__["name"] == "my_component" + assert result.__metadata__["version"] == "1.0.0" + + def test_compile_with_instance_reference(self, temp_dir, reset_sys_modules): + """Test compiling an instance from module using path:instance syntax.""" + module_path = temp_dir / "with_instance.py" + module_path.write_text(""" +class MyComponent: + initialize = None + shutdown = None + +my_instance = MyComponent() +""") + create_manifest(temp_dir, [ + {"name": "instance_component", "version": "2.0.0", "description": "An instance component", + "file": "with_instance.py", "class": "MyComponent"} + ]) + result = compile_component(f"{module_path}:my_instance") + + assert result.__metadata__["name"] == "instance_component" + assert result.__metadata__["version"] == "2.0.0" + + def test_compile_with_nested_attribute(self, temp_dir, reset_sys_modules): + """Test compiling using nested attribute path:obj.attr syntax.""" + module_path = temp_dir / "with_nested.py" + module_path.write_text(""" +class Container: + class NestedComponent: + initialize = None + shutdown = None + +container = Container() +""") + create_manifest(temp_dir, [ + {"name": "nested_component", "version": "3.0.0", "description": "A nested component", + "file": "with_nested.py", "class": "NestedComponent"} + ]) + result = compile_component(f"{module_path}:Container.NestedComponent") + + assert result.__metadata__["name"] == "nested_component" + assert result.__metadata__["version"] == "3.0.0" + + def test_compile_with_factory_function(self, temp_dir, reset_sys_modules): + """Test compiling using factory function path:factory() syntax.""" + module_path = temp_dir / "with_factory.py" + module_path.write_text(""" +class MyComponent: + def __init__(self, name): + self.initialize = None + self.shutdown = None + +def create_component(): + return MyComponent("factory_component") +""") + create_manifest(temp_dir, [ + {"name": "factory_component", "version": "4.0.0", "description": "A factory-created component", + "file": "with_factory.py", "class": "MyComponent"} + ]) + result = compile_component(f"{module_path}:create_component()") + + assert result.__metadata__["name"] == "factory_component" + assert result.__metadata__["version"] == "4.0.0" + + def test_compile_with_class_instantiation(self, temp_dir, reset_sys_modules): + """Test compiling using class instantiation path:MyClass() syntax.""" + module_path = temp_dir / "with_class_call.py" + module_path.write_text(""" +class MyComponent: + def __init__(self): + self.initialize = None + self.shutdown = None +""") + create_manifest(temp_dir, [ + {"name": "instantiated_component", "version": "5.0.0", "description": "An instantiated component", + "file": "with_class_call.py", "class": "MyComponent"} + ]) + result = compile_component(f"{module_path}:MyComponent()") + + assert result.__metadata__["name"] == "instantiated_component" + assert result.__metadata__["version"] == "5.0.0" + + def test_compile_with_string_path(self, temp_dir, reset_sys_modules): + """Test compiling using string path instead of Path object.""" + module_path = temp_dir / "string_path_test.py" + module_path.write_text(""" +initialize = None +shutdown = None +""") + create_manifest(temp_dir, [ + {"name": "string_path_component", "version": "1.0.0", "description": "Test", "file": "string_path_test.py"} + ]) + result = compile_component(str(module_path)) + + assert result.__metadata__["name"] == "string_path_component" + + def test_compile_reference_missing_attribute_raises(self, temp_dir, reset_sys_modules): + """Test that missing attribute raises AttributeError.""" + module_path = temp_dir / "missing_attr.py" + module_path.write_text(""" +# No NonExistent attribute +value = 42 +""") + create_manifest(temp_dir, [ + {"name": "missing_attr", "version": "1.0.0", "file": "missing_attr.py", "class": "NonExistent"} + ]) + with pytest.raises(AttributeError): + compile_component(f"{module_path}:NonExistent") + + def test_compile_reference_non_callable_raises(self, temp_dir, reset_sys_modules): + """Test that calling non-callable raises TypeError.""" + module_path = temp_dir / "non_callable.py" + module_path.write_text(""" +not_a_function = "just a string" +""") + create_manifest(temp_dir, [ + {"name": "non_callable", "version": "1.0.0", "file": "non_callable.py"} + ]) + with pytest.raises(TypeError, match="is not callable"): + compile_component(f"{module_path}:not_a_function()") + + def test_compile_reference_preserves_methods(self, temp_dir, reset_sys_modules): + """Test that referenced class methods are preserved.""" + module_path = temp_dir / "with_methods.py" + module_path.write_text(""" +class MyComponent: + async def initialize(self): + return True + + async def shutdown(self): + pass + + def custom_method(self): + return "custom_result" +""") + create_manifest(temp_dir, [ + {"name": "method_component", "version": "1.0.0", "description": "Component with methods", + "file": "with_methods.py", "class": "MyComponent"} + ]) + result = compile_component(f"{module_path}:MyComponent") + + assert hasattr(result, "custom_method") + # Class methods need to be called on instance + instance = result() + assert instance.custom_method() == "custom_result" + + def test_compile_path_without_reference_still_works(self, sample_component_module, reset_sys_modules): + """Test that path without reference still loads the module.""" + result = compile_component(sample_component_module) + + assert isinstance(result, Component) + assert result.__metadata__["name"] == "sample_component" + + def test_compile_with_reference_raises_error_if_no_metadata(self, temp_dir, reset_sys_modules): + """Test that missing manifest entry raises error.""" + module_path = temp_dir / "no_metadata_ref.py" + module_path.write_text(""" +class MyComponent: + pass +""") + with pytest.raises(RuntimeError, match="No manifest entry found"): + compile_component(f"{module_path}:MyComponent") + + +class TestCompileComponentDotReference: + """Tests for compile_component with .: syntax (current directory).""" + + def test_compile_dot_reference_resolves_to_package_directory(self, temp_dir, reset_sys_modules, monkeypatch): + """Test that .:Reference resolves . to the package directory.""" + # Create a package with __init__.py + init_path = temp_dir / "__init__.py" + init_path.write_text(""" +class MyApp: + initialize = None + shutdown = None +""") + # Create manifest + create_manifest(temp_dir, [ + {"name": "dot_reference_app", "version": "1.0.0", "description": "App loaded via .:", + "file": "__init__.py", "class": "MyApp"} + ]) + # Change to the temp directory + monkeypatch.chdir(temp_dir) + + # Compile using .:Reference syntax + result = compile_component(".:MyApp") + + assert result.__metadata__["name"] == "dot_reference_app" + assert result.__metadata__["version"] == "1.0.0" + + def test_compile_dot_factory_resolves_to_package_directory(self, temp_dir, reset_sys_modules, monkeypatch): + """Test that .:factory() resolves . to the package directory.""" + init_path = temp_dir / "__init__.py" + init_path.write_text(""" +class MyApp: + def __init__(self): + self.initialize = None + self.shutdown = None + +def create_app(): + return MyApp() +""") + create_manifest(temp_dir, [ + {"name": "factory_dot_app", "version": "2.0.0", "description": "App loaded via .:factory()", + "file": "__init__.py", "class": "MyApp"} + ]) + monkeypatch.chdir(temp_dir) + + result = compile_component(".:create_app()") + + assert result.__metadata__["name"] == "factory_dot_app" + assert result.__metadata__["version"] == "2.0.0" + + def test_compile_dot_without_reference_loads_package(self, temp_dir, reset_sys_modules, monkeypatch): + """Test that . without reference loads the package __init__.py as module.""" + init_path = temp_dir / "__init__.py" + init_path.write_text(""" +initialize = None +shutdown = None +""") + create_manifest(temp_dir, [ + {"name": "dot_package", "version": "3.0.0", "description": "Package loaded via .", + "file": "__init__.py"} + ]) + monkeypatch.chdir(temp_dir) + + result = compile_component(".") + + assert result.__metadata__["name"] == "dot_package" + assert result.__metadata__["version"] == "3.0.0" + + def test_compile_dot_module_name_uses_directory_name(self, temp_dir, reset_sys_modules, monkeypatch): + """Test that . resolves to the actual directory name for module naming.""" + init_path = temp_dir / "__init__.py" + init_path.write_text("") + create_manifest(temp_dir, [ + {"name": "dir_name_test", "version": "1.0.0", "description": "Test", "file": "__init__.py"} + ]) + monkeypatch.chdir(temp_dir) + + compile_component(".") + + # The module should be registered with the actual directory name, not empty string + dir_name = temp_dir.name + assert dir_name in sys.modules + assert dir_name != "" + + +class TestCompileComponentWithManifest: + """Tests for compile_component with manifest.yaml support.""" + + def test_compile_with_manifest_metadata(self, temp_dir, reset_sys_modules): + """Test that manifest metadata is loaded correctly.""" + # Create component file without __metadata__ + module_path = temp_dir / "plugin.py" + module_path.write_text(""" +class MyPlugin: + async def initialize(self): + pass + + async def shutdown(self): + pass +""") + + # Create manifest with metadata in .awioc directory + create_manifest(temp_dir, [ + { + "name": "manifest_plugin", + "version": "2.0.0", + "description": "From manifest", + "file": "plugin.py", + "class": "MyPlugin", + "wire": True, + } + ]) + + result = compile_component(f"{module_path}:MyPlugin()") + + assert result.__metadata__["name"] == "manifest_plugin" + assert result.__metadata__["version"] == "2.0.0" + assert result.__metadata__["description"] == "From manifest" + assert result.__metadata__["wire"] is True + + def test_compile_without_manifest_raises_error(self, temp_dir, reset_sys_modules): + """Test that missing manifest raises error (manifest is mandatory).""" + module_path = temp_dir / "decorated.py" + module_path.write_text(""" +__metadata__ = { + "name": "decorator_component", + "version": "1.0.0", + "description": "From decorator", +} +""") + + with pytest.raises(RuntimeError, match="No manifest entry found"): + compile_component(module_path) + + def test_compile_require_manifest_raises_when_missing(self, temp_dir, reset_sys_modules): + """Test that require_manifest=True raises error when no manifest.""" + module_path = temp_dir / "no_manifest.py" + module_path.write_text(""" +__metadata__ = {"name": "test", "version": "1.0.0"} +""") + + with pytest.raises(RuntimeError, match="No manifest entry found"): + compile_component(module_path, require_manifest=True) + + def test_compile_require_manifest_succeeds_with_manifest(self, temp_dir, reset_sys_modules): + """Test that require_manifest=True works with manifest.""" + module_path = temp_dir / "with_manifest.py" + module_path.write_text(""" +class Component: + pass +""") + + create_manifest(temp_dir, [ + {"name": "required_component", "version": "1.0.0", "file": "with_manifest.py", "class": "Component"} + ]) + + result = compile_component(f"{module_path}:Component", require_manifest=True) + + assert result.__metadata__["name"] == "required_component" + + def test_manifest_metadata_stored_manifest_path(self, temp_dir, reset_sys_modules): + """Test that manifest path is stored in metadata.""" + module_path = temp_dir / "test_plugin.py" + module_path.write_text("class TestPlugin: pass") + + create_manifest(temp_dir, [ + {"name": "test", "version": "1.0.0", "file": "test_plugin.py", "class": "TestPlugin"} + ]) + + result = compile_component(f"{module_path}:TestPlugin") + + assert "_manifest_path" in result.__metadata__ + manifest_path = temp_dir / AWIOC_DIR / MANIFEST_FILENAME + assert str(manifest_path) in result.__metadata__["_manifest_path"] + + def test_compile_directory_with_non_matching_file_name(self, temp_dir, reset_sys_modules): + """Test loading a directory where the file name doesn't match directory name. + + This tests the case like: + - Directory: openai_gpt/ + - Manifest file entry: open_ai.py (not openai_gpt.py or __init__.py) + """ + # Create directory with non-matching file name + pkg_dir = temp_dir / "openai_gpt" + pkg_dir.mkdir() + + # Create component file with different name than directory + (pkg_dir / "open_ai.py").write_text(""" +class OpenAIComponent: + async def initialize(self): + pass +""") + + # Create manifest with the actual file name + create_manifest(pkg_dir, [ + { + "name": "OpenAI AI Library", + "version": "1.0.0", + "description": "OpenAI API integration", + "file": "open_ai.py", + "class": "OpenAIComponent", + } + ]) + + # Compile by directory path (no class reference) + result = compile_component(pkg_dir) + + # Should load the component from the manifest + assert result.__metadata__["name"] == "OpenAI AI Library" + assert result.__class__.__name__ == "OpenAIComponent" + + def test_compile_directory_single_entry_fallback(self, temp_dir, reset_sys_modules): + """Test that single manifest entry is used as fallback for directories.""" + pkg_dir = temp_dir / "my_component" + pkg_dir.mkdir() + + # Create component file with completely different name + (pkg_dir / "impl.py").write_text(""" +class MyImpl: + pass +""") + + # Single entry in manifest + create_manifest(pkg_dir, [ + {"name": "my_impl", "version": "1.0.0", "file": "impl.py", "class": "MyImpl"} + ]) + + result = compile_component(pkg_dir) + + assert result.__metadata__["name"] == "my_impl" + assert result.__class__.__name__ == "MyImpl" + + +class TestCompileComponentsFromManifest: + """Tests for compile_components_from_manifest function.""" + + def test_load_all_components(self, temp_dir, reset_sys_modules): + """Test loading all components from manifest.""" + from src.awioc.loader.module_loader import compile_components_from_manifest + + # Create component files + (temp_dir / "plugin_a.py").write_text(""" +class PluginA: + async def initialize(self): pass +""") + (temp_dir / "plugin_b.py").write_text(""" +class PluginB: + async def initialize(self): pass +""") + + # Create manifest in .awioc directory + create_manifest(temp_dir, [ + {"name": "plugin_a", "version": "1.0.0", "file": "plugin_a.py", "class": "PluginA"}, + {"name": "plugin_b", "version": "2.0.0", "file": "plugin_b.py", "class": "PluginB"}, + ]) + + components = compile_components_from_manifest(temp_dir) + + assert len(components) == 2 + names = {c.__metadata__["name"] for c in components} + assert names == {"plugin_a", "plugin_b"} + + def test_load_module_based_components(self, temp_dir, reset_sys_modules): + """Test loading module-based components from manifest.""" + from src.awioc.loader.module_loader import compile_components_from_manifest + + (temp_dir / "module_plugin.py").write_text(""" +async def initialize(): + pass +""") + + create_manifest(temp_dir, [ + {"name": "module_plugin", "version": "1.0.0", "file": "module_plugin.py"}, + ]) + + components = compile_components_from_manifest(temp_dir) + + assert len(components) == 1 + assert components[0].__metadata__["name"] == "module_plugin" + + def test_missing_manifest_raises_error(self, temp_dir): + """Test that missing manifest raises FileNotFoundError.""" + from src.awioc.loader.module_loader import compile_components_from_manifest + + with pytest.raises(FileNotFoundError, match="Manifest not found"): + compile_components_from_manifest(temp_dir) + + def test_missing_component_file_raises_error(self, temp_dir, reset_sys_modules): + """Test that missing component file raises error.""" + from src.awioc.loader.module_loader import compile_components_from_manifest + + create_manifest(temp_dir, [ + {"name": "missing", "version": "1.0.0", "file": "nonexistent.py"}, + ]) + + with pytest.raises(FileNotFoundError): + compile_components_from_manifest(temp_dir) diff --git a/tests/awioc/loader/test_module_loader_extended.py b/tests/awioc/loader/test_module_loader_extended.py new file mode 100644 index 0000000..1ded5ce --- /dev/null +++ b/tests/awioc/loader/test_module_loader_extended.py @@ -0,0 +1,162 @@ +"""Extended tests for the module_loader module to improve coverage.""" + +import sys + +import pytest +import yaml + +from src.awioc.loader.module_loader import ( + compile_component, + compile_components_from_manifest, + _load_module, + _resolve_pot_reference, + _get_manifest_metadata, +) + + +class TestLoadModule: + """Tests for _load_module function.""" + + def test_load_simple_module(self, tmp_path): + """Test loading a simple Python module.""" + module_file = tmp_path / "simple_module.py" + module_file.write_text(''' +x = 42 +def func(): + return "hello" +''', encoding="utf-8") + + sys.path.insert(0, str(tmp_path)) + try: + module = _load_module(module_file) + assert hasattr(module, 'x') + assert module.x == 42 + finally: + sys.path.remove(str(tmp_path)) + + def test_load_module_from_directory(self, tmp_path): + """Test loading module from directory with __init__.py.""" + pkg_dir = tmp_path / "my_package" + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text(''' +name = "my_package" +''', encoding="utf-8") + + sys.path.insert(0, str(tmp_path)) + try: + module = _load_module(pkg_dir) + assert hasattr(module, 'name') + assert module.name == "my_package" + finally: + sys.path.remove(str(tmp_path)) + + def test_load_module_not_found(self, tmp_path): + """Test loading non-existent module raises error.""" + with pytest.raises(FileNotFoundError): + _load_module(tmp_path / "nonexistent.py") + + def test_load_module_without_suffix(self, tmp_path): + """Test loading module file without .py suffix.""" + module_file = tmp_path / "nosuffix.py" + module_file.write_text("value = 123") + + # Reference without .py extension + nosuffix_path = tmp_path / "nosuffix" + + sys.path.insert(0, str(tmp_path)) + try: + module = _load_module(nosuffix_path) + assert hasattr(module, 'value') + assert module.value == 123 + finally: + sys.path.remove(str(tmp_path)) + + +class TestCompileComponent: + """Tests for compile_component function.""" + + def test_compile_pot_reference(self, tmp_path): + """Test compiling pot reference @pot/component.""" + # This test requires a mock pot setup + # Skip if pot resolution is not available + try: + compile_component("@nonexistent/component") + pytest.fail("Expected error for nonexistent pot") + except (FileNotFoundError, Exception): + pass # Expected + + +class TestResolvePotReference: + """Tests for _resolve_pot_reference function.""" + + def test_non_pot_reference_returns_none(self): + """Test that non-pot references return None.""" + result = _resolve_pot_reference("some/path/to/module.py") + assert result is None + + def test_pot_reference_without_slash_logs_error(self, caplog): + """Test pot reference without slash logs error.""" + import logging + + with caplog.at_level(logging.ERROR): + result = _resolve_pot_reference("@invalid-pot-ref") + assert result is None + assert "Invalid pot reference" in caplog.text + + +class TestGetManifestMetadata: + """Tests for _get_manifest_metadata function.""" + + def test_no_manifest_returns_none(self, tmp_path): + """Test returns None when no manifest exists.""" + file_path = tmp_path / "component.py" + file_path.write_text("x = 1") + result = _get_manifest_metadata(file_path, None) + assert result is None + + def test_manifest_load_error_returns_none(self, tmp_path, caplog): + """Test returns None when manifest cannot be loaded.""" + import logging + + # Create directory structure with invalid manifest + awioc_dir = tmp_path / ".awioc" + awioc_dir.mkdir() + manifest_file = awioc_dir / "manifest.yaml" + manifest_file.write_text("invalid: yaml: {[") + + with caplog.at_level(logging.WARNING): + result = _get_manifest_metadata(tmp_path, None) + # Result may be None due to invalid yaml + # Just verify no exception was raised + + def test_component_not_in_manifest_returns_none(self, tmp_path, caplog): + """Test returns None when component not found in manifest.""" + import logging + + # Create valid manifest but with different component + awioc_dir = tmp_path / ".awioc" + awioc_dir.mkdir() + manifest_file = awioc_dir / "manifest.yaml" + manifest_file.write_text(yaml.dump({ + "manifest_version": "1.0", + "name": "test", + "version": "1.0.0", + "components": [{"name": "Other", "version": "1.0.0", "file": "other.py"}] + })) + + # Create a file that's not in the manifest + file_path = tmp_path / "not_in_manifest.py" + file_path.write_text("x = 1") + + with caplog.at_level(logging.DEBUG): + result = _get_manifest_metadata(file_path, None) + assert result is None + + +class TestCompileComponentsFromManifest: + """Tests for compile_components_from_manifest function.""" + + def test_compile_no_manifest(self, tmp_path): + """Test compiling from directory without manifest.""" + with pytest.raises(FileNotFoundError): + compile_components_from_manifest(tmp_path) diff --git a/tests/awioc/test_api.py b/tests/awioc/test_api.py index e2cc422..0ea2749 100644 --- a/tests/awioc/test_api.py +++ b/tests/awioc/test_api.py @@ -1,5 +1,3 @@ -import pytest - from src.awioc import api @@ -57,10 +55,8 @@ def test_config_exports(self): def test_bootstrap_exports(self): """Test bootstrap exports.""" assert hasattr(api, 'initialize_ioc_app') - assert hasattr(api, 'create_container') assert hasattr(api, 'compile_ioc_app') assert hasattr(api, 'reconfigure_ioc_app') - assert hasattr(api, 'reload_configuration') def test_loader_exports(self): """Test loader exports.""" @@ -80,7 +76,6 @@ def test_imports_from_init(self): # Bootstrap assert hasattr(awioc, 'initialize_ioc_app') - assert hasattr(awioc, 'create_container') # Lifecycle assert hasattr(awioc, 'initialize_components') diff --git a/tests/awioc/test_bootstrap.py b/tests/awioc/test_bootstrap.py index 51035cf..a5ddced 100644 --- a/tests/awioc/test_bootstrap.py +++ b/tests/awioc/test_bootstrap.py @@ -6,31 +6,6 @@ from src.awioc.container import AppContainer, ContainerInterface -class TestCreateContainer: - """Tests for create_container function.""" - - def test_create_container_returns_interface(self): - """Test create_container returns a ContainerInterface.""" - from src.awioc.bootstrap import create_container - interface = create_container() - - assert isinstance(interface, ContainerInterface) - - def test_create_container_has_raw_container(self): - """Test create_container interface has a raw container.""" - from src.awioc.bootstrap import create_container - interface = create_container() - - assert interface.raw_container() is not None - - def test_create_container_sets_api(self): - """Test create_container sets api provider.""" - from src.awioc.bootstrap import create_container - interface = create_container() - - assert interface.raw_container().api() is interface - - class TestBootstrapIntegration: """Integration tests for bootstrap functions.""" diff --git a/tests/awioc/test_bootstrap_comprehensive.py b/tests/awioc/test_bootstrap_comprehensive.py new file mode 100644 index 0000000..62f3041 --- /dev/null +++ b/tests/awioc/test_bootstrap_comprehensive.py @@ -0,0 +1,186 @@ +"""Comprehensive tests for the bootstrap module.""" + +import logging +from pathlib import Path + +from src.awioc.bootstrap import _is_manifest_directory +from src.awioc.config.base import Settings +from src.awioc.config.models import IOCBaseConfig, IOCComponentsDefinition +from src.awioc.container import AppContainer, ContainerInterface + + +class TestIsManifestDirectory: + """Tests for _is_manifest_directory function.""" + + def test_pot_reference_returns_false(self): + """Test pot references return False.""" + assert _is_manifest_directory("@pot/component") is False + assert _is_manifest_directory("@my-pot/my-component") is False + + def test_file_reference_returns_false(self, tmp_path): + """Test file references return False.""" + file = tmp_path / "component.py" + file.write_text("x = 1") + assert _is_manifest_directory(str(file)) is False + + def test_directory_without_manifest_returns_false(self, tmp_path): + """Test directory without manifest returns False.""" + dir_path = tmp_path / "component" + dir_path.mkdir() + assert _is_manifest_directory(str(dir_path)) is False + + def test_directory_with_manifest_returns_true(self, tmp_path): + """Test directory with .awioc/manifest.yaml returns True.""" + dir_path = tmp_path / "component" + awioc_dir = dir_path / ".awioc" + awioc_dir.mkdir(parents=True) + (awioc_dir / "manifest.yaml").write_text("manifest_version: '1.0'") + + assert _is_manifest_directory(str(dir_path)) is True + + def test_nonexistent_path_returns_false(self): + """Test nonexistent path returns False.""" + assert _is_manifest_directory("/nonexistent/path") is False + + +class TestIOCComponentsDefinition: + """Tests for IOCComponentsDefinition.""" + + def test_basic_creation(self): + """Test basic IOCComponentsDefinition creation.""" + comp_def = IOCComponentsDefinition( + app="app:App()", + plugins=["plugin1.py"], + libraries={"db": "db.py"} + ) + assert comp_def.app == "app:App()" + assert comp_def.plugins == ["plugin1.py"] + assert comp_def.libraries == {"db": "db.py"} + + def test_default_values(self): + """Test default values for IOCComponentsDefinition.""" + comp_def = IOCComponentsDefinition(app="app:App()") + assert comp_def.plugins == [] + assert comp_def.libraries == {} + + def test_with_multiple_plugins(self): + """Test with multiple plugins.""" + comp_def = IOCComponentsDefinition( + app="app:App()", + plugins=["plugin1.py", "plugin2.py", "@pot/plugin3"] + ) + assert len(comp_def.plugins) == 3 + + def test_with_multiple_libraries(self): + """Test with multiple libraries.""" + comp_def = IOCComponentsDefinition( + app="app:App()", + libraries={"db": "db.py", "cache": "cache.py", "queue": "queue.py"} + ) + assert len(comp_def.libraries) == 3 + + +class TestIOCBaseConfigModel: + """Tests for IOCBaseConfig model.""" + + def test_default_config_path(self): + """Test default config_path.""" + config = IOCBaseConfig() + assert config.config_path == Path("ioc.yaml") + + def test_default_context(self): + """Test default context is None.""" + config = IOCBaseConfig() + assert config.context is None + + def test_with_custom_config_path(self, tmp_path): + """Test with custom config_path.""" + config_file = tmp_path / "custom.yaml" + config_file.write_text("") + + config = IOCBaseConfig(config_path=str(config_file)) + assert config.config_path == config_file + + def test_with_context(self): + """Test with context.""" + config = IOCBaseConfig(context="production") + assert config.context == "production" + + +class TestContainerInterface: + """Tests for ContainerInterface integration.""" + + def test_create_container_interface(self): + """Test creating a ContainerInterface.""" + container = AppContainer() + interface = ContainerInterface(container) + assert interface is not None + + def test_container_interface_set_app(self): + """Test setting app on ContainerInterface.""" + container = AppContainer() + interface = ContainerInterface(container) + + class TestApp: + __metadata__ = { + "name": "test", + "version": "1.0.0", + "requires": set(), + } + + async def initialize(self): pass + + async def shutdown(self): pass + + interface.set_app(TestApp()) + assert interface.provided_app() is not None + + def test_container_interface_set_config(self): + """Test setting config on ContainerInterface.""" + container = AppContainer() + interface = ContainerInterface(container) + + interface.set_config(Settings()) + assert interface.provided_config() is not None + + def test_container_interface_set_logger(self): + """Test setting logger on ContainerInterface.""" + container = AppContainer() + interface = ContainerInterface(container) + + interface.set_logger(logging.getLogger("test")) + assert interface.provided_logger() is not None + + def test_container_interface_register_plugins(self): + """Test registering plugins on ContainerInterface.""" + container = AppContainer() + interface = ContainerInterface(container) + + class TestPlugin: + __metadata__ = { + "name": "test_plugin", + "version": "1.0.0", + "requires": set(), + } + initialize = None + shutdown = None + + interface.register_plugins(TestPlugin()) + assert len(interface.provided_plugins()) == 1 + + def test_container_interface_register_libraries(self): + """Test registering libraries on ContainerInterface.""" + container = AppContainer() + interface = ContainerInterface(container) + + class TestLib: + __metadata__ = { + "name": "test_lib", + "version": "1.0.0", + "requires": set(), + } + initialize = None + shutdown = None + + interface.register_libraries(("test_lib", TestLib())) + assert len(interface.provided_libs()) == 1 diff --git a/tests/awioc/test_bootstrap_extended.py b/tests/awioc/test_bootstrap_extended.py index be4fcca..0c65c8b 100644 --- a/tests/awioc/test_bootstrap_extended.py +++ b/tests/awioc/test_bootstrap_extended.py @@ -1,13 +1,10 @@ -from pathlib import Path from unittest.mock import MagicMock from src.awioc.bootstrap import ( initialize_ioc_app, compile_ioc_app, reconfigure_ioc_app, - reload_configuration, ) -from src.awioc.config.base import Settings from src.awioc.config.models import IOCBaseConfig, IOCComponentsDefinition from src.awioc.container import AppContainer, ContainerInterface @@ -49,7 +46,6 @@ class MockApp: "name": "mock_app", "version": "1.0.0", "requires": set(), - "base_config": Settings, "wire": False } @@ -73,55 +69,13 @@ async def shutdown(self): assert interface.raw_container().config() is not None -class TestReloadConfiguration: - """Tests for reload_configuration function.""" - - def test_reload_with_app(self, temp_dir): - """Test reload_configuration with an app set.""" - container = AppContainer() - interface = ContainerInterface(container) - - config_path = temp_dir / "config.yaml" - config_path.write_text("") - - ioc_config = IOCBaseConfig(config_path=config_path) - - class MockApp: - __name__ = "mock_app" - __module__ = "test" - __package__ = None - __metadata__ = { - "name": "mock_app", - "version": "1.0.0", - "requires": set(), - "base_config": Settings, - "ioc_config": ioc_config, - "wire": False - } - - async def initialize(self): - pass - - async def shutdown(self): - pass - - interface.set_app(MockApp()) - interface.raw_container().wire = MagicMock() - - # This should not raise - try: - reload_configuration(interface) - except Exception: - pass # Expected to fail due to model_config access - - class TestIOCComponentsDefinition: """Additional tests for IOCComponentsDefinition.""" def test_empty_libraries_and_plugins(self): """Test definition with empty libraries and plugins.""" definition = IOCComponentsDefinition( - app=Path("./app"), + app="./app", libraries={}, plugins=[] ) @@ -131,11 +85,11 @@ def test_empty_libraries_and_plugins(self): def test_multiple_libraries(self): """Test definition with multiple libraries.""" definition = IOCComponentsDefinition( - app=Path("./app"), + app="./app", libraries={ - "lib1": Path("./lib1"), - "lib2": Path("./lib2"), - "lib3": Path("./lib3") + "lib1": "./lib1", + "lib2": "./lib2", + "lib3": "./lib3" } ) assert len(definition.libraries) == 3 @@ -143,11 +97,11 @@ def test_multiple_libraries(self): def test_multiple_plugins(self): """Test definition with multiple plugins.""" definition = IOCComponentsDefinition( - app=Path("./app"), + app="./app", plugins=[ - Path("./plugin1"), - Path("./plugin2"), - Path("./plugin3") + "./plugin1", + "./plugin2", + "./plugin3" ] ) assert len(definition.plugins) == 3 diff --git a/tests/awioc/test_class_based_components.py b/tests/awioc/test_class_based_components.py new file mode 100644 index 0000000..e0f7209 --- /dev/null +++ b/tests/awioc/test_class_based_components.py @@ -0,0 +1,1049 @@ +""" +Comprehensive tests for class-based components. + +These tests verify that class-based components (like HttpServerApp) work correctly +throughout the entire lifecycle: +- Loading and instantiation via :ClassName() syntax +- Registration and unregistration +- Initialization and shutdown +- Event handlers (on_before_initialize, on_after_initialize, etc.) +- Wiring with class instances +- Configuration injection +""" +import asyncio +from unittest.mock import MagicMock + +import pydantic +import pytest +import yaml + +from src.awioc.bootstrap import reconfigure_ioc_app +from src.awioc.components.events import clear_handlers +from src.awioc.components.lifecycle import ( + initialize_components, + shutdown_components, + unregister_plugin, +) +from src.awioc.components.registry import ( + component_internals, + component_initialized, +) +from src.awioc.config.models import IOCBaseConfig +from src.awioc.container import AppContainer, ContainerInterface +from src.awioc.di.wiring import wire, inject_dependencies +from src.awioc.loader.manifest import AWIOC_DIR, MANIFEST_FILENAME +from src.awioc.loader.module_loader import compile_component + + +def create_manifest(directory, components): + """Helper to create .awioc/manifest.yaml in a directory.""" + awioc_dir = directory / AWIOC_DIR + awioc_dir.mkdir(exist_ok=True) + manifest = { + "manifest_version": "1.0", + "components": components, + } + (awioc_dir / MANIFEST_FILENAME).write_text(yaml.dump(manifest)) + + +class TestClassBasedComponentLoading: + """Tests for loading class-based components via :ClassName() syntax.""" + + def test_load_class_instance_with_metadata_as_class_attr(self, temp_dir, reset_sys_modules): + """Test loading a class where __metadata__ is a class attribute.""" + module_path = temp_dir / "class_component.py" + module_path.write_text(""" +class MyServerApp: + def __init__(self): + self.initialized = False + self.shutdown_called = False + + async def initialize(self): + self.initialized = True + return True + + async def shutdown(self): + self.shutdown_called = True +""") + create_manifest(temp_dir, [ + {"name": "my_server_app", "version": "1.0.0", "description": "A server application", + "file": "class_component.py", "class": "MyServerApp", "wire": True} + ]) + result = compile_component(f"{module_path}:MyServerApp()") + + assert result.__metadata__["name"] == "my_server_app" + assert result.__metadata__["version"] == "1.0.0" + assert hasattr(result, "initialize") + assert hasattr(result, "shutdown") + assert result.initialized is False + + def test_load_class_instance_with_instance_metadata(self, temp_dir, reset_sys_modules): + """Test loading a class where metadata is provided from manifest.""" + module_path = temp_dir / "instance_meta.py" + module_path.write_text(""" +class DynamicApp: + def __init__(self): + self.initialized = False + + async def initialize(self): + self.initialized = True + return True + + async def shutdown(self): + pass +""") + create_manifest(temp_dir, [ + {"name": "dynamic_app", "version": "2.0.0", "description": "Dynamic metadata", + "file": "instance_meta.py", "class": "DynamicApp", "wire": False} + ]) + result = compile_component(f"{module_path}:DynamicApp()") + + assert result.__metadata__["name"] == "dynamic_app" + assert result.__metadata__["version"] == "2.0.0" + + def test_load_class_instance_module_attribute_preserved(self, temp_dir, reset_sys_modules): + """Test that __module__ is preserved for class instances.""" + module_path = temp_dir / "module_attr.py" + module_path.write_text(""" +class ModuleAttrApp: + async def initialize(self): + return True + + async def shutdown(self): + pass +""") + create_manifest(temp_dir, [ + {"name": "module_attr_app", "version": "1.0.0", "file": "module_attr.py", "class": "ModuleAttrApp"} + ]) + result = compile_component(f"{module_path}:ModuleAttrApp()") + + # Instance should have __module__ set to the module name + assert hasattr(result, "__module__") or hasattr(result.__class__, "__module__") + + +class TestClassBasedComponentLifecycle: + """Tests for the full lifecycle of class-based components.""" + + @pytest.fixture + def http_server_style_app(self): + """Create a component mimicking HttpServerApp pattern.""" + lifecycle_events = [] + + class HttpStyleApp: + __metadata__ = { + "name": "http_style_app", + "version": "1.0.0", + "description": "HTTP-style application", + "wire": False, + "requires": set(), + } + + def __init__(self): + self._running = False + self._lifecycle_events = lifecycle_events + + async def initialize(self): + self._running = True + self._lifecycle_events.append("initialized") + return True + + async def shutdown(self): + self._running = False + self._lifecycle_events.append("shutdown") + + async def wait(self): + while self._running: + await asyncio.sleep(0.1) + + app = HttpStyleApp() + return app, lifecycle_events + + @pytest.fixture + def app_with_event_handlers(self): + """Create a component with all lifecycle event handlers.""" + lifecycle_events = [] + + class AppWithEvents: + __metadata__ = { + "name": "app_with_events", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + def __init__(self): + self._lifecycle_events = lifecycle_events + + def on_before_initialize(self): + self._lifecycle_events.append("on_before_initialize") + + def on_after_initialize(self): + self._lifecycle_events.append("on_after_initialize") + + def on_before_shutdown(self): + self._lifecycle_events.append("on_before_shutdown") + + def on_after_shutdown(self): + self._lifecycle_events.append("on_after_shutdown") + + async def initialize(self): + self._lifecycle_events.append("initialize") + return True + + async def shutdown(self): + self._lifecycle_events.append("shutdown") + + app = AppWithEvents() + return app, lifecycle_events + + @pytest.fixture + def app_with_async_event_handlers(self): + """Create a component with async lifecycle event handlers.""" + lifecycle_events = [] + + class AppWithAsyncEvents: + __metadata__ = { + "name": "app_with_async_events", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + def __init__(self): + self._lifecycle_events = lifecycle_events + + async def on_before_initialize(self): + await asyncio.sleep(0) + self._lifecycle_events.append("async_on_before_initialize") + + async def on_after_initialize(self): + await asyncio.sleep(0) + self._lifecycle_events.append("async_on_after_initialize") + + async def on_before_shutdown(self): + await asyncio.sleep(0) + self._lifecycle_events.append("async_on_before_shutdown") + + async def on_after_shutdown(self): + await asyncio.sleep(0) + self._lifecycle_events.append("async_on_after_shutdown") + + async def initialize(self): + self._lifecycle_events.append("initialize") + return True + + async def shutdown(self): + self._lifecycle_events.append("shutdown") + + app = AppWithAsyncEvents() + return app, lifecycle_events + + async def test_full_lifecycle(self, http_server_style_app): + """Test complete initialize -> shutdown lifecycle.""" + app, events = http_server_style_app + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + # Initialize + await initialize_components(app) + + assert component_initialized(app) is True + assert app._running is True + assert "initialized" in events + + # Shutdown + await shutdown_components(app) + + assert component_initialized(app) is False + assert app._running is False + assert "shutdown" in events + assert events == ["initialized", "shutdown"] + + async def test_event_handlers_called_in_order(self, app_with_event_handlers): + """Test that all event handlers are called in correct order.""" + app, events = app_with_event_handlers + clear_handlers() + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + # Initialize + await initialize_components(app) + + # Shutdown + await shutdown_components(app) + + # Verify order: on_before_* -> method -> on_after_* + expected = [ + "on_before_initialize", + "initialize", + "on_after_initialize", + "on_before_shutdown", + "shutdown", + "on_after_shutdown", + ] + assert events == expected + + async def test_async_event_handlers(self, app_with_async_event_handlers): + """Test that async event handlers work correctly.""" + app, events = app_with_async_event_handlers + clear_handlers() + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + await initialize_components(app) + await shutdown_components(app) + + expected = [ + "async_on_before_initialize", + "initialize", + "async_on_after_initialize", + "async_on_before_shutdown", + "shutdown", + "async_on_after_shutdown", + ] + assert events == expected + + async def test_initialize_returns_false_aborts(self): + """Test that returning False from initialize aborts initialization.""" + + class AbortingApp: + __metadata__ = { + "name": "aborting_app", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + async def initialize(self): + return False # Abort initialization + + async def shutdown(self): + pass + + app = AbortingApp() + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + await initialize_components(app) + + # Should not be marked as initialized + assert component_initialized(app) is False + + async def test_initialize_exception_propagates(self): + """Test that exceptions in initialize propagate correctly.""" + + class FailingApp: + __metadata__ = { + "name": "failing_app", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + async def initialize(self): + raise ValueError("Initialization failed!") + + async def shutdown(self): + pass + + app = FailingApp() + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + errors = await initialize_components(app, return_exceptions=True) + + assert len(errors) == 1 + assert isinstance(errors[0], ValueError) + + async def test_shutdown_exception_propagates(self): + """Test that exceptions in shutdown propagate correctly.""" + + class ShutdownFailingApp: + __metadata__ = { + "name": "shutdown_failing_app", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + async def initialize(self): + return True + + async def shutdown(self): + raise ValueError("Shutdown failed!") + + app = ShutdownFailingApp() + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + await initialize_components(app) + errors = await shutdown_components(app, return_exceptions=True) + + assert len(errors) == 1 + assert isinstance(errors[0], ValueError) + + +class TestClassBasedComponentRegistration: + """Tests for registering/unregistering class-based components.""" + + @pytest.fixture + def class_based_plugin(self): + """Create a class-based plugin.""" + + class MyPlugin: + __metadata__ = { + "name": "my_plugin", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + def __init__(self): + self.initialized = False + + async def initialize(self): + self.initialized = True + return True + + async def shutdown(self): + self.initialized = False + + return MyPlugin() + + @pytest.fixture + def container_with_app(self): + """Create a container with an app already set.""" + container = AppContainer() + interface = ContainerInterface(container) + + class MainApp: + __metadata__ = { + "name": "main_app", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + async def initialize(self): + return True + + async def shutdown(self): + pass + + app = MainApp() + interface.set_app(app) + interface.raw_container().wire = MagicMock() + + return interface + + async def test_register_class_based_plugin(self, container_with_app, class_based_plugin): + """Test registering a class-based plugin.""" + interface = container_with_app + + interface.register_plugins(class_based_plugin) + + assert class_based_plugin in interface.provided_plugins() + assert "_internals" in class_based_plugin.__metadata__ + + async def test_register_and_initialize_class_plugin(self, container_with_app, class_based_plugin): + """Test registering and initializing a class-based plugin.""" + interface = container_with_app + + interface.register_plugins(class_based_plugin) + await initialize_components(class_based_plugin) + + assert class_based_plugin.initialized is True + assert component_initialized(class_based_plugin) is True + + async def test_unregister_class_based_plugin(self, container_with_app, class_based_plugin): + """Test unregistering a class-based plugin.""" + interface = container_with_app + + interface.register_plugins(class_based_plugin) + await initialize_components(class_based_plugin) + + # Unregister should shutdown and remove + await unregister_plugin(interface, class_based_plugin) + + assert class_based_plugin not in interface.provided_plugins() + assert class_based_plugin.initialized is False + + async def test_full_plugin_cycle(self, container_with_app): + """Test full register -> init -> shutdown -> unregister cycle for class plugin.""" + interface = container_with_app + events = [] + + class LifecyclePlugin: + __metadata__ = { + "name": "lifecycle_plugin", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + async def initialize(self): + events.append("init") + return True + + async def shutdown(self): + events.append("shutdown") + + plugin = LifecyclePlugin() + + # Register + interface.register_plugins(plugin) + assert plugin in interface.provided_plugins() + + # Initialize + await initialize_components(plugin) + assert "init" in events + assert component_initialized(plugin) is True + + # Unregister (should trigger shutdown) + await unregister_plugin(interface, plugin) + assert "shutdown" in events + assert plugin not in interface.provided_plugins() + + +class TestClassBasedComponentWithDependencies: + """Tests for class-based components with dependencies.""" + + async def test_class_component_with_dependency(self): + """Test class component that depends on another component by name.""" + + class DatabaseLib: + __metadata__ = { + "name": "database", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + initialize = None + shutdown = None + + class ServiceApp: + __metadata__ = { + "name": "service_app", + "version": "1.0.0", + "wire": False, + "requires": {"database"}, # Uses name instead of object + } + + def __init__(self, db): + self.db = db + + async def initialize(self): + return True + + async def shutdown(self): + pass + + db = DatabaseLib() + app = ServiceApp(db) + + container = AppContainer() + interface = ContainerInterface(container) + + # Register db first so it's available when app is registered + interface.register_libraries(("database", db)) + interface.set_app(app) + + # Both should have internals set up + assert "_internals" in db.__metadata__ + assert "_internals" in app.__metadata__ + + # db should know app requires it + assert app in component_internals(db).required_by + + async def test_shutdown_blocked_by_dependent_class_component(self): + """Test that base component shutdown is blocked by dependent class component.""" + + class BasePlugin: + __metadata__ = { + "name": "base_plugin", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + async def initialize(self): + return True + + async def shutdown(self): + pass + + base = BasePlugin() + + class DependentPlugin: + __metadata__ = { + "name": "dependent_plugin", + "version": "1.0.0", + "wire": False, + "requires": {"base_plugin"}, # Uses name instead of object + } + + async def initialize(self): + return True + + async def shutdown(self): + pass + + dependent = DependentPlugin() + + container = AppContainer() + interface = ContainerInterface(container) + + # Register base first, then dependent + interface.register_plugins(base) + interface.set_app(dependent) + + # Initialize both + await initialize_components(base, dependent) + + # Try to shutdown base alone - should be blocked + await shutdown_components(base) + + # Base should still be initialized because dependent requires it + assert component_initialized(base) is True + + # Now shutdown dependent first, then base + await shutdown_components(dependent, base) + + assert component_initialized(dependent) is False + assert component_initialized(base) is False + + +class TestClassBasedComponentWiring: + """Tests for wiring with class-based components.""" + + def test_wire_class_based_component(self, temp_dir): + """Test wiring a class-based component.""" + + class WiredApp: + __metadata__ = { + "name": "wired_app", + "version": "1.0.0", + "wire": True, + "wirings": set(), + } + + def __init__(self): + self._container = None + + async def initialize(self): + return True + + async def shutdown(self): + pass + + app = WiredApp() + + container = AppContainer() + interface = ContainerInterface(container) + container.wire = MagicMock() + + interface.set_app(app) + + wire(interface, components=[app]) + + container.wire.assert_called_once() + + def test_wire_class_component_uses_module(self): + """Test that wiring uses __module__ from class instance.""" + + class ModuleAwareApp: + __metadata__ = { + "name": "module_aware_app", + "version": "1.0.0", + "wire": True, + } + + async def initialize(self): + return True + + async def shutdown(self): + pass + + app = ModuleAwareApp() + + container = AppContainer() + interface = ContainerInterface(container) + container.wire = MagicMock() + + interface.set_app(app) + wire(interface, components=[app]) + + # Verify wire was called + container.wire.assert_called_once() + call_kwargs = container.wire.call_args.kwargs + modules = call_kwargs.get("modules", set()) + + # Should include the class's module + assert any("test_class_based_components" in str(m) for m in modules) + + +class TestClassBasedComponentWithConfiguration: + """Tests for class-based components with configuration.""" + + def test_class_component_with_config(self, temp_dir): + """Test class component with configuration model.""" + + class ServerConfig(pydantic.BaseModel): + __prefix__ = "server" + host: str = "127.0.0.1" + port: int = 8080 + + class ConfiguredServerApp: + __metadata__ = { + "name": "configured_server", + "version": "1.0.0", + "wire": False, + "config": ServerConfig, + } + + async def initialize(self): + return True + + async def shutdown(self): + pass + + app = ConfiguredServerApp() + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + inject_dependencies(interface, components=[app]) + + # Config should be registered + from src.awioc.config.registry import _CONFIGURATIONS + assert "server" in _CONFIGURATIONS + + async def test_reconfigure_class_component(self, temp_dir): + """Test reconfiguring a class-based component.""" + + class AppConfig(IOCBaseConfig): + app_name: str = "default" + debug: bool = False + + class ReconfigurableApp: + __metadata__ = { + "name": "reconfigurable_app", + "version": "1.0.0", + "base_config": AppConfig, + "wire": False, + } + + async def initialize(self): + return True + + async def shutdown(self): + pass + + app = ReconfigurableApp() + + config_path = temp_dir / "config.yaml" + config_path.write_text("") + + ioc_config = IOCBaseConfig(config_path=config_path) + + container = AppContainer() + interface = ContainerInterface(container) + container.wire = MagicMock() + + interface.set_app(app) + app.__metadata__["_internals"].ioc_config = ioc_config + + reconfigure_ioc_app(interface, components=[app]) + + # Config should be set + assert interface.raw_container().config() is not None + + +class TestClassBasedComponentFromYamlSyntax: + """Tests simulating loading from ioc.yaml with :ClassName() syntax.""" + + def test_load_app_like_http_server(self, temp_dir, reset_sys_modules): + """Test loading app like the HttpServerApp example.""" + module_path = temp_dir / "http_server.py" + module_path.write_text(''' +class ServerConfig: + """Server configuration.""" + __prefix__ = "server" + host: str = "127.0.0.1" + port: int = 8080 + + +class HttpServerApp: + """HTTP Server application.""" + + def __init__(self): + self._server = None + self._running = False + + async def initialize(self): + self._running = True + return True + + async def wait(self): + import asyncio + while self._running: + await asyncio.sleep(0.1) + + async def shutdown(self): + self._running = False +''') + create_manifest(temp_dir, [ + {"name": "HTTP File Server", "version": "2.0.0", "description": "HTTP File Server with features", + "file": "http_server.py", "class": "HttpServerApp", "wire": True} + ]) + # This simulates what happens when ioc.yaml has: app: :HttpServerApp() + result = compile_component(f"{module_path}:HttpServerApp()") + + assert result.__metadata__["name"] == "HTTP File Server" + assert result.__metadata__["version"] == "2.0.0" + assert result._running is False + assert hasattr(result, "initialize") + assert hasattr(result, "shutdown") + assert hasattr(result, "wait") + + async def test_full_lifecycle_http_server_style(self, temp_dir, reset_sys_modules): + """Test full lifecycle of HTTP server style app loaded from module.""" + clear_handlers() + + module_path = temp_dir / "server_app.py" + module_path.write_text(''' +class ServerApp: + """Server application with all lifecycle hooks.""" + + def __init__(self): + self.events = [] + + def on_before_initialize(self): + self.events.append("before_init") + + def on_after_initialize(self): + self.events.append("after_init") + + def on_before_shutdown(self): + self.events.append("before_shutdown") + + def on_after_shutdown(self): + self.events.append("after_shutdown") + + async def initialize(self): + self.events.append("init") + return True + + async def shutdown(self): + self.events.append("shutdown") +''') + create_manifest(temp_dir, [ + {"name": "server_app", "version": "1.0.0", "file": "server_app.py", "class": "ServerApp", "wire": False} + ]) + app = compile_component(f"{module_path}:ServerApp()") + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + # Initialize + await initialize_components(app) + + assert component_initialized(app) is True + + # Shutdown + await shutdown_components(app) + + assert component_initialized(app) is False + + # Verify event order + expected = [ + "before_init", + "init", + "after_init", + "before_shutdown", + "shutdown", + "after_shutdown", + ] + assert app.events == expected + + +class TestClassBasedComponentEdgeCases: + """Edge case tests for class-based components.""" + + async def test_class_component_without_initialize(self): + """Test class component that has no initialize method.""" + + class NoInitApp: + __metadata__ = { + "name": "no_init_app", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + initialize = None + shutdown = None + + app = NoInitApp() + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + await initialize_components(app) + + # Should still be marked as initialized even without method + assert component_initialized(app) is True + + async def test_class_component_without_shutdown(self): + """Test class component that has no shutdown method.""" + + class NoShutdownApp: + __metadata__ = { + "name": "no_shutdown_app", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + async def initialize(self): + return True + + shutdown = None + + app = NoShutdownApp() + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + await initialize_components(app) + await shutdown_components(app) + + # Should be marked as not initialized after shutdown + assert component_initialized(app) is False + + async def test_double_initialize_ignored(self): + """Test that initializing twice doesn't call initialize again.""" + init_count = [0] + + class CountingApp: + __metadata__ = { + "name": "counting_app", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + async def initialize(self): + init_count[0] += 1 + return True + + async def shutdown(self): + pass + + app = CountingApp() + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + await initialize_components(app) + await initialize_components(app) # Second call + + assert init_count[0] == 1 + + async def test_shutdown_without_initialize(self): + """Test that shutting down without initialize does nothing.""" + shutdown_count = [0] + + class ShutdownOnlyApp: + __metadata__ = { + "name": "shutdown_only_app", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + async def initialize(self): + return True + + async def shutdown(self): + shutdown_count[0] += 1 + + app = ShutdownOnlyApp() + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + # Shutdown without initialize + await shutdown_components(app) + + assert shutdown_count[0] == 0 + + async def test_class_with_wait_method(self): + """Test class component with wait method.""" + wait_called = [False] + wait_cancelled = [False] + + class WaitingApp: + __metadata__ = { + "name": "waiting_app", + "version": "1.0.0", + "wire": False, + "requires": set(), + } + + async def initialize(self): + return True + + async def wait(self): + wait_called[0] = True + try: + await asyncio.sleep(10) + except asyncio.CancelledError: + wait_cancelled[0] = True + raise + + async def shutdown(self): + pass + + app = WaitingApp() + + container = AppContainer() + interface = ContainerInterface(container) + interface.set_app(app) + + await initialize_components(app) + + # Start wait in background + from src.awioc.components.lifecycle import wait_for_components + + wait_task = asyncio.create_task(wait_for_components(app)) + + # Give it a moment to start waiting + await asyncio.sleep(0.01) + + # Cancel it + wait_task.cancel() + try: + await wait_task + except asyncio.CancelledError: + pass + + assert wait_called[0] is True + assert wait_cancelled[0] is True diff --git a/tests/awioc/test_container.py b/tests/awioc/test_container.py index 34c0871..1d9f861 100644 --- a/tests/awioc/test_container.py +++ b/tests/awioc/test_container.py @@ -1,8 +1,10 @@ import logging +from datetime import datetime import pytest from dependency_injector import providers -from src.awioc.components.metadata import Internals, ComponentTypes + +from src.awioc.components.metadata import Internals, ComponentTypes, RegistrationInfo from src.awioc.config.base import Settings from src.awioc.config.models import IOCBaseConfig from src.awioc.container import AppContainer, ContainerInterface @@ -66,7 +68,6 @@ class MockApp: "name": "test_app", "version": "1.0.0", "description": "Test app", - "base_config": Settings, "requires": set() } @@ -206,7 +207,7 @@ def test_app_config_model(self, interface, mock_app): interface.set_app(mock_app) config_model = interface.app_config_model - assert config_model == Settings + assert config_model is IOCBaseConfig def test_app_config_model_returns_default_when_not_defined(self, interface): """Test app_config_model raises when not defined.""" @@ -284,13 +285,15 @@ class Component: } comp = Component() - interface._ContainerInterface__init_component(comp) + registration = RegistrationInfo(registered_by="test", registered_at=datetime.now()) + interface._ContainerInterface__init_component(comp, registration) assert "_internals" in comp.__metadata__ assert isinstance(comp.__metadata__["_internals"], Internals) + assert comp.__metadata__["_internals"].registration == registration def test_init_component_with_requirements(self, interface): - """Test __init_component initializes requirements.""" + """Test __init_component links to already-registered requirements.""" dep = type("Dep", (), { "__metadata__": { "name": "dep", @@ -303,11 +306,18 @@ def test_init_component_with_requirements(self, interface): "__metadata__": { "name": "comp", "version": "1.0.0", - "requires": {dep} + "requires": {"dep"} # Uses name instead of object } })() - interface._ContainerInterface__init_component(comp) + registration = RegistrationInfo(registered_by="test", registered_at=datetime.now()) + + # Register dep first so it's available when comp is initialized + interface._ContainerInterface__init_component(dep, registration) + interface._container.components()["dep"] = providers.Object(dep) + + # Now init comp which requires "dep" + interface._ContainerInterface__init_component(comp, registration) assert "_internals" in dep.__metadata__ assert comp in dep.__metadata__["_internals"].required_by @@ -326,3 +336,156 @@ def test_deinit_component_removes_internals(self, interface): interface._ContainerInterface__deinit_component(comp) assert comp.__metadata__["_internals"] is None + + +class TestContainerInterfaceProviders: + """Tests for ContainerInterface provider methods.""" + + @pytest.fixture + def interface(self): + """Create a ContainerInterface for testing.""" + return ContainerInterface(AppContainer()) + + def test_provided_lib_with_string(self, interface): + """Test provided_lib with string type.""" + + class TestLib: + __metadata__ = { + "name": "test_lib", + "version": "1.0.0", + "description": "Test library", + "requires": set() + } + initialize = None + shutdown = None + + lib_instance = TestLib() + interface.register_libraries(("my_string_key", lib_instance)) + + result = interface.provided_lib("my_string_key") + assert result is lib_instance + + def test_provided_plugin_with_string(self, interface): + """Test provided_plugin with string type.""" + + class TestPlugin: + __metadata__ = { + "name": "test_plugin", + "version": "1.0.0", + "description": "Test plugin", + "requires": set() + } + initialize = None + shutdown = None + + plugin = TestPlugin() + interface.register_plugins(plugin) + + result = interface.provided_plugin("test_plugin") + assert result is plugin + + def test_provided_plugin_with_type(self, interface): + """Test provided_plugin with component type (not string).""" + + class TestPlugin: + __metadata__ = { + "name": "typed_plugin", + "version": "1.0.0", + "description": "Test plugin", + "requires": set() + } + initialize = None + shutdown = None + + plugin = TestPlugin() + interface.register_plugins(plugin) + + # Pass the instance itself (has __metadata__["name"]) + result = interface.provided_plugin(plugin) + assert result is plugin + + def test_provided_plugin_not_found(self, interface): + """Test provided_plugin returns None when not found.""" + result = interface.provided_plugin("nonexistent") + assert result is None + + def test_provided_component_with_name(self, interface): + """Test provided_component with name.""" + + class TestComp: + __metadata__ = { + "name": "test_comp", + "version": "1.0.0", + "requires": set() + } + initialize = None + shutdown = None + + interface.set_app(TestComp()) + + result = interface.provided_component("test_comp") + assert result is not None + assert result.__metadata__["name"] == "test_comp" + + def test_provided_component_not_found(self, interface): + """Test provided_component returns None when not found.""" + result = interface.provided_component("nonexistent") + assert result is None + + +class TestContainerInterfaceDeinit: + """Tests for __deinit_component edge cases.""" + + @pytest.fixture + def interface(self): + """Create a ContainerInterface for testing.""" + return ContainerInterface(AppContainer()) + + def test_deinit_component_no_internals(self, interface): + """Test __deinit_component when component has no _internals.""" + comp = type("Comp", (), { + "__metadata__": { + "name": "comp", + "version": "1.0.0", + "requires": set() + } + })() + + # Should not raise + interface._ContainerInterface__deinit_component(comp) + + def test_deinit_component_none_internals(self, interface): + """Test __deinit_component when _internals is None.""" + comp = type("Comp", (), { + "__metadata__": { + "name": "comp", + "version": "1.0.0", + "requires": set(), + "_internals": None + } + })() + + # Should not raise + interface._ContainerInterface__deinit_component(comp) + + def test_deinit_component_with_uninitialized_requirement(self, interface): + """Test __deinit_component with requirement that's not initialized.""" + dep = type("Dep", (), { + "__metadata__": { + "name": "dep", + "version": "1.0.0", + "requires": set() + } + })() + + comp = type("Comp", (), { + "__metadata__": { + "name": "comp", + "version": "1.0.0", + "requires": {dep}, + "_internals": Internals() + } + })() + + # Should not raise even if dep has no _internals + interface._ContainerInterface__deinit_component(comp) diff --git a/tests/awioc/test_coverage_boost.py b/tests/awioc/test_coverage_boost.py index e72e91c..4218460 100644 --- a/tests/awioc/test_coverage_boost.py +++ b/tests/awioc/test_coverage_boost.py @@ -47,11 +47,6 @@ def test_reconfigure_ioc_app_exists(self): from src.awioc.bootstrap import reconfigure_ioc_app assert callable(reconfigure_ioc_app) - def test_reload_configuration_exists(self): - """Test reload_configuration function exists.""" - from src.awioc.bootstrap import reload_configuration - assert callable(reload_configuration) - class TestDIProvidersModule: """Tests for DI providers module functions.""" diff --git a/tests/awioc/test_integration.py b/tests/awioc/test_integration.py index a6b4d41..b2914b3 100644 --- a/tests/awioc/test_integration.py +++ b/tests/awioc/test_integration.py @@ -13,7 +13,10 @@ import pydantic import pytest -from src.awioc.bootstrap import create_container, reconfigure_ioc_app +import yaml +from pydantic_settings import YamlConfigSettingsSource + +from src.awioc.bootstrap import reconfigure_ioc_app from src.awioc.components.lifecycle import ( initialize_components, shutdown_components, @@ -32,9 +35,21 @@ from src.awioc.config.registry import clear_configurations from src.awioc.container import AppContainer, ContainerInterface from src.awioc.di.wiring import wire, inject_dependencies +from src.awioc.loader.manifest import AWIOC_DIR, MANIFEST_FILENAME from src.awioc.loader.module_loader import compile_component +def create_manifest(directory, components): + """Helper to create .awioc/manifest.yaml in a directory.""" + awioc_dir = directory / AWIOC_DIR + awioc_dir.mkdir(exist_ok=True) + manifest = { + "manifest_version": "1.0", + "components": components, + } + (awioc_dir / MANIFEST_FILENAME).write_text(yaml.dump(manifest)) + + class TestFullApplicationLifecycle: """Integration tests for the complete application lifecycle.""" @@ -194,12 +209,12 @@ async def shutdown(self): @pytest.fixture def dependent_component(self, base_component): - """Create a component that depends on base_component.""" + """Create a component that depends on base_component by name.""" class DependentComponent: __metadata__ = { "name": "dependent", "version": "1.0.0", - "requires": {base_component}, + "requires": {"base"}, # Now uses component name } initialized = False @@ -217,10 +232,11 @@ async def test_dependency_chain_initialization(self, base_component, dependent_c container = AppContainer() interface = ContainerInterface(container) - # Register dependent first (has base as requirement) + # Register base first, then dependent + interface.register_libraries(("base", base_component)) interface.set_app(dependent_component) - # Check that base_component's internals were created and linked + # Check that internals were created and linked assert "_internals" in base_component.__metadata__ assert "_internals" in dependent_component.__metadata__ @@ -240,26 +256,30 @@ async def test_recursive_dependency_resolution(self): })() level2 = type("Level2", (), { - "__metadata__": {"name": "level2", "version": "1.0.0", "requires": {level1}} + "__metadata__": {"name": "level2", "version": "1.0.0", "requires": {"level1"}} # Uses name })() level3 = type("Level3", (), { - "__metadata__": {"name": "level3", "version": "1.0.0", "requires": {level2}} + "__metadata__": {"name": "level3", "version": "1.0.0", "requires": {"level2"}} # Uses name })() container = AppContainer() interface = ContainerInterface(container) + + # Register dependencies first so required_by chains are set up + interface.register_libraries(("level1", level1)) + interface.register_libraries(("level2", level2)) interface.set_app(level3) - # Get all dependencies recursively + # Verify required_by chain is set up correctly + assert level3 in component_internals(level2).required_by + assert level2 in component_internals(level1).required_by + + # Get all dependencies recursively (uses _internals.requires which has resolved objects) all_deps = component_requires(level3, recursive=True) assert level2 in all_deps assert level1 in all_deps - # Verify required_by chain - assert level3 in component_internals(level2).required_by - assert level2 in component_internals(level1).required_by - async def test_shutdown_blocked_by_dependency(self): """Test that shutdown is blocked when component is still required.""" base = type("Base", (), { @@ -269,13 +289,16 @@ async def test_shutdown_blocked_by_dependency(self): })() dependent = type("Dependent", (), { - "__metadata__": {"name": "dependent", "version": "1.0.0", "requires": {base}}, + "__metadata__": {"name": "dependent", "version": "1.0.0", "requires": {"base"}}, # Uses name "initialize": AsyncMock(return_value=True), "shutdown": AsyncMock() })() container = AppContainer() interface = ContainerInterface(container) + + # Register base first, then dependent + interface.register_plugins(base) interface.set_app(dependent) # Initialize both @@ -402,38 +425,14 @@ async def test_unregister_plugin_when_required_raises(self, container_with_app): base_internals = component_internals(base_plugin) base_internals.required_by.add(dependent_plugin) + # Mock internals set initialized to True + component_internals(base_plugin).is_initialized = True + component_internals(dependent_plugin).is_initialized = True + # Try to unregister base_plugin while it's still required with pytest.raises(RuntimeError, match="still required"): await unregister_plugin(interface, base_plugin) - async def test_register_plugin_with_wiring(self, container_with_app): - """Test register_plugin function wires the plugin.""" - interface = container_with_app - - plugin = type("WiredPlugin", (), { - "__name__": "wired_plugin", - "__module__": "test", - "__package__": None, - "__metadata__": { - "name": "wired_plugin", - "version": "1.0.0", - "requires": set(), - "wire": True, - "wirings": set(), - }, - "initialize": AsyncMock(return_value=True), - "shutdown": AsyncMock() - })() - - # Register plugin using register_plugin function - result = await register_plugin(interface, plugin) - - assert result is plugin - assert plugin in interface.provided_plugins() - assert "_internals" in plugin.__metadata__ - # wire should have been called - interface.raw_container().wire.assert_called() - async def test_register_plugin_already_registered_returns_existing(self, container_with_app): """Test registering an already registered plugin returns it without re-registering.""" interface = container_with_app @@ -646,7 +645,7 @@ async def test_register_plugin_with_dependencies(self, container_with_app): "shutdown": AsyncMock() })() - # Create dependent plugin that requires base_plugin + # Create dependent plugin that requires base_plugin by name dependent_plugin = type("DependentPlugin", (), { "__name__": "dependent_plugin", "__module__": "test", @@ -654,14 +653,15 @@ async def test_register_plugin_with_dependencies(self, container_with_app): "__metadata__": { "name": "dependent_plugin", "version": "1.0.0", - "requires": {base_plugin}, + "requires": {"base_plugin"}, # Uses name instead of object "wire": False, }, "initialize": AsyncMock(return_value=True), "shutdown": AsyncMock() })() - # Register dependent plugin (this will also init base_plugin's internals) + # Register base_plugin first, then dependent_plugin + interface.register_plugins(base_plugin) interface.register_plugins(dependent_plugin) # Both should have internals @@ -700,20 +700,17 @@ async def test_unregister_plugins_in_correct_order(self, container_with_app): "__metadata__": { "name": "dependent_plugin", "version": "1.0.0", - "requires": {base_plugin}, + "requires": {"base_plugin"}, # Uses name instead of object "wire": False, }, "initialize": AsyncMock(return_value=True), "shutdown": AsyncMock() })() - # Register dependent plugin first (this initializes base_plugin's internals via dependency resolution) + # Register base first, then dependent + interface.register_plugins(base_plugin) interface.register_plugins(dependent_plugin) - # Manually add base_plugin to plugins map (its internals are already created) - from dependency_injector import providers - interface._plugins_map[base_plugin.__metadata__["name"]] = providers.Object(base_plugin) - # Initialize both await initialize_components(base_plugin, dependent_plugin) @@ -817,7 +814,8 @@ class TestConfigurationFlow: @pytest.fixture def app_with_config(self): """Create an app with configuration.""" - class AppConfig(Settings): + + class AppConfig(IOCBaseConfig): app_name: str = "test_app" debug: bool = False @@ -950,13 +948,12 @@ def test_compile_component_with_py_extension(self, temp_dir, reset_sys_modules): """Test loading component without .py extension.""" module_path = temp_dir / "no_ext_component.py" module_path.write_text(""" -__metadata__ = { - "name": "no_ext_component", - "version": "1.0.0", -} initialize = None shutdown = None """) + create_manifest(temp_dir, [ + {"name": "no_ext_component", "version": "1.0.0", "file": "no_ext_component.py"} + ]) # Load using path without .py extension component = compile_component(temp_dir / "no_ext_component") @@ -964,21 +961,15 @@ def test_compile_component_with_py_extension(self, temp_dir, reset_sys_modules): assert component.__metadata__["name"] == "no_ext_component" def test_compile_component_not_found_raises(self, temp_dir): - """Test that loading non-existent component raises error.""" - with pytest.raises(FileNotFoundError, match="Component not found"): + """Test that loading non-existent component raises error (manifest required).""" + # With mandatory manifests, non-existent paths fail at manifest lookup + with pytest.raises(RuntimeError, match="No manifest entry found"): compile_component(temp_dir / "nonexistent") class TestContainerBootstrap: """Integration tests for container bootstrap functions.""" - def test_create_container_returns_interface(self): - """Test create_container returns a properly configured interface.""" - interface = create_container() - - assert isinstance(interface, ContainerInterface) - assert interface.raw_container() is not None - def test_reconfigure_ioc_app_flow(self, temp_dir): """Test reconfigure_ioc_app configures all components.""" container = AppContainer() @@ -997,7 +988,6 @@ class MockApp: "name": "mock_app", "version": "1.0.0", "requires": set(), - "base_config": Settings, "wire": False, } @@ -1056,36 +1046,50 @@ class ComponentWithConfig: def test_wire_collects_module_names(self): """Test wire collects modules for wiring.""" - container = AppContainer() - interface = ContainerInterface(container) - container.wire = MagicMock() - - class TestComponent: - __name__ = "test_component" - __module__ = "test.module" - __package__ = "test" - __metadata__ = { - "name": "test_component", - "version": "1.0.0", - "requires": set(), - "wire": True, - "wirings": {"submodule"}, - } - initialize = None - shutdown = None + import sys + from types import ModuleType + + # Create fake modules + fake_test_module = ModuleType("test.module") + fake_test_submodule = ModuleType("test.submodule") + sys.modules["test.module"] = fake_test_module + sys.modules["test.submodule"] = fake_test_submodule + + try: + container = AppContainer() + interface = ContainerInterface(container) + container.wire = MagicMock() + + class TestComponent: + __name__ = "test_component" + __module__ = "test.module" + __package__ = "test" + __metadata__ = { + "name": "test_component", + "version": "1.0.0", + "requires": set(), + "wire": True, + "wirings": {"submodule"}, + } + initialize = None + shutdown = None - component = TestComponent() - interface.set_app(component) + component = TestComponent() + interface.set_app(component) - wire(interface, components=[component]) + wire(interface, components=[component]) - # Verify wire was called - container.wire.assert_called_once() - call_args = container.wire.call_args - modules = call_args.kwargs.get("modules") or call_args.args[0] + # Verify wire was called + container.wire.assert_called_once() + call_args = container.wire.call_args + modules = call_args.kwargs.get("modules") or call_args.args[0] + module_names = {m.__name__ for m in modules} - assert "test.module" in modules - assert "test.submodule" in modules + assert "test.module" in module_names + assert "test.submodule" in module_names + finally: + del sys.modules["test.module"] + del sys.modules["test.submodule"] class TestComponentLifecycleEdgeCases: @@ -1344,7 +1348,7 @@ async def test_full_bootstrap_reconfigure_cycle(self, tmp_path): ) # Define app config - class ServiceConfig(Settings): + class ServiceConfig(IOCBaseConfig): service_name: str = "default_service" max_connections: int = 10 @@ -1385,7 +1389,6 @@ def record_config(self, interface): # Configure IOC ioc_config = IOCBaseConfig() - object.__setattr__(ioc_config, 'config_path', config_file) app.__metadata__["_internals"].ioc_config = ioc_config # Initial config from defaults @@ -1398,6 +1401,10 @@ def record_config(self, interface): assert app.config_history[0]["max_connections"] == 10 # Simulate reconfigure_ioc_app behavior + ioc_config.add_sources(lambda x: YamlConfigSettingsSource( + x, + yaml_file=config_file + )) reconfigure_ioc_app(interface, components=[app]) # Record after reconfigure @@ -1682,7 +1689,7 @@ async def test_plugin_hot_reload_pattern(self): async def test_graceful_shutdown_with_dependencies(self): """Test graceful shutdown respects dependency order.""" - # Create components with dependencies + # Create components with dependencies (using names) base = type("Base", (), { "__metadata__": {"name": "base", "version": "1.0.0", "requires": set()}, "initialize": AsyncMock(return_value=True), @@ -1690,19 +1697,23 @@ async def test_graceful_shutdown_with_dependencies(self): })() middle = type("Middle", (), { - "__metadata__": {"name": "middle", "version": "1.0.0", "requires": {base}}, + "__metadata__": {"name": "middle", "version": "1.0.0", "requires": {"base"}}, "initialize": AsyncMock(return_value=True), "shutdown": AsyncMock() })() top = type("Top", (), { - "__metadata__": {"name": "top", "version": "1.0.0", "requires": {middle}}, + "__metadata__": {"name": "top", "version": "1.0.0", "requires": {"middle"}}, "initialize": AsyncMock(return_value=True), "shutdown": AsyncMock() })() container = AppContainer() interface = ContainerInterface(container) + + # Register in order so dependencies are available + interface.register_plugins(base) + interface.register_plugins(middle) interface.set_app(top) # Initialize all diff --git a/tests/awioc/test_project_api.py b/tests/awioc/test_project_api.py new file mode 100644 index 0000000..7e50085 --- /dev/null +++ b/tests/awioc/test_project_api.py @@ -0,0 +1,497 @@ +"""Tests for the AWIOC Project API.""" + +import shutil +import tempfile +from pathlib import Path + +import pytest + +from awioc import ( + AWIOCProject, + is_awioc_project, + open_project, + create_project, +) +from awioc.loader.manifest import AWIOC_DIR, MANIFEST_FILENAME + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for testing.""" + tmp = tempfile.mkdtemp() + yield Path(tmp) + shutil.rmtree(tmp, ignore_errors=True) + + +@pytest.fixture +def sample_project(temp_dir): + """Create a sample AWIOC project for testing.""" + # Create project structure + awioc_dir = temp_dir / AWIOC_DIR + awioc_dir.mkdir() + + # Create manifest + manifest_content = """ +manifest_version: '1.0' +name: test_project +version: 1.0.0 +description: A test project +components: + - name: TestComponent + file: test_component.py + class: TestClass + version: 1.0.0 + description: A test component + wire: true + config: + - model: test_component:TestConfig +""" + (awioc_dir / MANIFEST_FILENAME).write_text(manifest_content.strip()) + + # Create component file + component_content = ''' +"""Test component module.""" + +import pydantic +from awioc import as_component, get_config + + +class TestConfig(pydantic.BaseModel): + __prefix__ = "test" + value: str = "default" + + +@as_component( + name="TestComponent", + version="1.0.0", + description="A test component", + wire=True, + config=TestConfig, +) +class TestClass: + """Test component class.""" + pass +''' + (temp_dir / "test_component.py").write_text(component_content.strip()) + + return temp_dir + + +class TestIsAwlocProject: + """Tests for is_awioc_project function.""" + + def test_returns_true_for_valid_project(self, sample_project): + """Should return True for directory with .awioc/manifest.yaml.""" + assert is_awioc_project(sample_project) is True + + def test_returns_false_for_empty_directory(self, temp_dir): + """Should return False for directory without .awioc.""" + assert is_awioc_project(temp_dir) is False + + def test_returns_false_for_awioc_without_manifest(self, temp_dir): + """Should return False if .awioc exists but manifest.yaml doesn't.""" + (temp_dir / AWIOC_DIR).mkdir() + assert is_awioc_project(temp_dir) is False + + def test_accepts_string_path(self, sample_project): + """Should accept string paths.""" + assert is_awioc_project(str(sample_project)) is True + + def test_works_with_file_path(self, sample_project): + """Should check parent directory when given a file path.""" + file_path = sample_project / "test_component.py" + assert is_awioc_project(file_path) is True + + +class TestOpenProject: + """Tests for open_project function.""" + + def test_opens_existing_project(self, sample_project): + """Should open and return AWIOCProject for valid project.""" + project = open_project(sample_project) + + assert isinstance(project, AWIOCProject) + assert project.name == "test_project" + assert project.version == "1.0.0" + + def test_raises_for_nonexistent_project(self, temp_dir): + """Should raise FileNotFoundError for directory without manifest.""" + with pytest.raises(FileNotFoundError) as exc_info: + open_project(temp_dir) + + assert "Not an AWIOC project" in str(exc_info.value) + + def test_accepts_string_path(self, sample_project): + """Should accept string paths.""" + project = open_project(str(sample_project)) + assert project.name == "test_project" + + def test_works_with_file_path(self, sample_project): + """Should find project from file within project directory.""" + file_path = sample_project / "test_component.py" + project = open_project(file_path) + assert project.name == "test_project" + + +class TestCreateProject: + """Tests for create_project function.""" + + def test_creates_new_project(self, temp_dir): + """Should create a new project with manifest.""" + project_dir = temp_dir / "new_project" + + project = create_project( + project_dir, + name="My New Project", + version="2.0.0", + description="A new project", + ) + + assert project.name == "My New Project" + assert project.version == "2.0.0" + assert project.description == "A new project" + assert is_awioc_project(project_dir) + + def test_creates_directory_if_not_exists(self, temp_dir): + """Should create the project directory if it doesn't exist.""" + project_dir = temp_dir / "nested" / "new_project" + + project = create_project(project_dir) + + assert project_dir.exists() + assert is_awioc_project(project_dir) + + def test_uses_directory_name_if_name_not_provided(self, temp_dir): + """Should use directory name as project name if not specified.""" + project_dir = temp_dir / "my_plugin" + + project = create_project(project_dir) + + assert project.name == "my_plugin" + + def test_raises_if_project_exists(self, sample_project): + """Should raise FileExistsError if project already exists.""" + with pytest.raises(FileExistsError) as exc_info: + create_project(sample_project) + + assert "already exists" in str(exc_info.value) + + def test_overwrites_with_flag(self, sample_project): + """Should overwrite existing project when overwrite=True.""" + project = create_project( + sample_project, + name="Overwritten", + overwrite=True, + ) + + assert project.name == "Overwritten" + assert len(project.components) == 0 # New empty manifest + + +class TestAWIOCProjectProperties: + """Tests for AWIOCProject property access.""" + + def test_path_property(self, sample_project): + """Should return the project path.""" + project = open_project(sample_project) + assert project.path == sample_project.resolve() + + def test_manifest_path_property(self, sample_project): + """Should return the manifest path.""" + project = open_project(sample_project) + expected = sample_project / AWIOC_DIR / MANIFEST_FILENAME + assert project.manifest_path == expected + + def test_name_property(self, sample_project): + """Should return project name.""" + project = open_project(sample_project) + assert project.name == "test_project" + + def test_version_property(self, sample_project): + """Should return project version.""" + project = open_project(sample_project) + assert project.version == "1.0.0" + + def test_description_property(self, sample_project): + """Should return project description.""" + project = open_project(sample_project) + assert project.description == "A test project" + + def test_components_property(self, sample_project): + """Should return list of components.""" + project = open_project(sample_project) + assert len(project.components) == 1 + assert project.components[0].name == "TestComponent" + + def test_len(self, sample_project): + """Should return number of components.""" + project = open_project(sample_project) + assert len(project) == 1 + + def test_iter(self, sample_project): + """Should iterate over components.""" + project = open_project(sample_project) + names = [c.name for c in project] + assert names == ["TestComponent"] + + def test_contains(self, sample_project): + """Should check if component exists by name.""" + project = open_project(sample_project) + assert "TestComponent" in project + assert "NonExistent" not in project + + +class TestAWIOCProjectComponentAccess: + """Tests for AWIOCProject component access methods.""" + + def test_get_component(self, sample_project): + """Should get component by name.""" + project = open_project(sample_project) + comp = project.get_component("TestComponent") + + assert comp is not None + assert comp.name == "TestComponent" + assert comp.class_name == "TestClass" + + def test_get_component_returns_none_for_nonexistent(self, sample_project): + """Should return None for nonexistent component.""" + project = open_project(sample_project) + assert project.get_component("NonExistent") is None + + def test_get_component_by_class(self, sample_project): + """Should get component by class name.""" + project = open_project(sample_project) + comp = project.get_component_by_class("TestClass") + + assert comp is not None + assert comp.name == "TestComponent" + + +class TestAWIOCProjectModification: + """Tests for AWIOCProject modification methods.""" + + def test_add_component(self, sample_project): + """Should add a new component.""" + project = open_project(sample_project) + + comp = project.add_component( + name="NewComponent", + file="new_component.py", + class_name="NewClass", + version="1.0.0", + description="A new component", + ) + + assert comp.name == "NewComponent" + assert len(project) == 2 + assert project.is_dirty is True + + def test_add_component_with_config(self, sample_project): + """Should add component with config reference.""" + project = open_project(sample_project) + + comp = project.add_component( + name="ConfiguredComponent", + file="configured.py", + class_name="ConfiguredClass", + config="configured:MyConfig", + ) + + assert len(comp.get_config_list()) == 1 + assert comp.get_config_list()[0].model == "configured:MyConfig" + + def test_add_component_raises_for_duplicate(self, sample_project): + """Should raise ValueError for duplicate component name.""" + project = open_project(sample_project) + + with pytest.raises(ValueError) as exc_info: + project.add_component( + name="TestComponent", # Already exists + file="duplicate.py", + ) + + assert "already exists" in str(exc_info.value) + + def test_remove_component(self, sample_project): + """Should remove a component.""" + project = open_project(sample_project) + + result = project.remove_component("TestComponent") + + assert result is True + assert len(project) == 0 + assert project.is_dirty is True + + def test_remove_component_returns_false_for_nonexistent(self, sample_project): + """Should return False when removing nonexistent component.""" + project = open_project(sample_project) + result = project.remove_component("NonExistent") + assert result is False + + def test_update_component(self, sample_project): + """Should update component properties.""" + project = open_project(sample_project) + + updated = project.update_component( + "TestComponent", + version="2.0.0", + description="Updated description", + ) + + assert updated is not None + assert updated.version == "2.0.0" + assert updated.description == "Updated description" + assert project.is_dirty is True + + def test_update_component_rename(self, sample_project): + """Should rename a component.""" + project = open_project(sample_project) + + updated = project.update_component( + "TestComponent", + new_name="RenamedComponent", + ) + + assert updated.name == "RenamedComponent" + assert project.get_component("TestComponent") is None + assert project.get_component("RenamedComponent") is not None + + def test_update_component_returns_none_for_nonexistent(self, sample_project): + """Should return None when updating nonexistent component.""" + project = open_project(sample_project) + result = project.update_component("NonExistent", version="2.0.0") + assert result is None + + def test_set_name(self, sample_project): + """Should set project name.""" + project = open_project(sample_project) + project.name = "new_name" + + assert project.name == "new_name" + assert project.is_dirty is True + + def test_set_version(self, sample_project): + """Should set project version.""" + project = open_project(sample_project) + project.version = "2.0.0" + + assert project.version == "2.0.0" + assert project.is_dirty is True + + def test_set_description(self, sample_project): + """Should set project description.""" + project = open_project(sample_project) + project.description = "New description" + + assert project.description == "New description" + assert project.is_dirty is True + + +class TestAWIOCProjectPersistence: + """Tests for AWIOCProject save/reload functionality.""" + + def test_save_persists_changes(self, sample_project): + """Should save changes to disk.""" + project = open_project(sample_project) + project.add_component( + name="NewComponent", + file="new.py", + class_name="NewClass", + ) + project.save() + + # Reload and verify + project2 = open_project(sample_project) + assert len(project2) == 2 + assert project2.get_component("NewComponent") is not None + + def test_save_clears_dirty_flag(self, sample_project): + """Should clear dirty flag after save.""" + project = open_project(sample_project) + project.name = "Modified" + + assert project.is_dirty is True + project.save() + assert project.is_dirty is False + + def test_reload_discards_changes(self, sample_project): + """Should discard unsaved changes on reload.""" + project = open_project(sample_project) + project.add_component( + name="UnsavedComponent", + file="unsaved.py", + ) + + project.reload() + + assert len(project) == 1 + assert project.get_component("UnsavedComponent") is None + assert project.is_dirty is False + + def test_save_creates_clean_yaml(self, sample_project): + """Should save clean YAML without empty values.""" + project = open_project(sample_project) + project.add_component( + name="MinimalComponent", + file="minimal.py", + class_name="MinimalClass", + ) + project.save() + + # Read raw YAML + content = project.manifest_path.read_text() + + # Should not have empty wirings/requires lists + assert "wirings: []" not in content + assert "requires: []" not in content + + +class TestAWIOCProjectCompilation: + """Tests for AWIOCProject component compilation.""" + + def test_compile_component(self, sample_project): + """Should compile a single component by name.""" + project = open_project(sample_project) + component = project.compile_component("TestComponent") + + assert hasattr(component, "__metadata__") + assert component.__metadata__["name"] == "TestComponent" + + def test_compile_component_raises_for_nonexistent(self, sample_project): + """Should raise ValueError for nonexistent component.""" + project = open_project(sample_project) + + with pytest.raises(ValueError) as exc_info: + project.compile_component("NonExistent") + + assert "not found" in str(exc_info.value) + + def test_compile_components(self, sample_project): + """Should compile all components.""" + project = open_project(sample_project) + components = project.compile_components() + + assert len(components) == 1 + assert components[0].__metadata__["name"] == "TestComponent" + + +class TestAWIOCProjectRepr: + """Tests for AWIOCProject string representations.""" + + def test_repr(self, sample_project): + """Should return useful repr.""" + project = open_project(sample_project) + r = repr(project) + + assert "AWIOCProject" in r + assert "test_project" in r + assert "components=1" in r + + def test_str(self, sample_project): + """Should return human-readable string.""" + project = open_project(sample_project) + s = str(project) + + assert "test_project" in s + assert "1.0.0" in s + assert "1 components" in s diff --git a/tests/conftest.py b/tests/conftest.py index 6222ad9..ebb9317 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,51 +55,80 @@ def empty_config_file(temp_dir): @pytest.fixture def sample_component_module(temp_dir): - """Create a sample component module file.""" + """Create a sample component module file with .awioc/manifest.yaml.""" + import yaml + module_path = temp_dir / "sample_component.py" module_path.write_text(""" -__metadata__ = { - "name": "sample_component", - "version": "1.0.0", - "description": "A sample component for testing", - "wire": True, - "wirings": set(), - "requires": set(), - "config": None -} - async def initialize(): return True async def shutdown(): pass + +async def wait(): + pass """) + + # Create .awioc/manifest.yaml + awioc_dir = temp_dir / ".awioc" + awioc_dir.mkdir() + manifest = { + "manifest_version": "1.0", + "components": [ + { + "name": "sample_component", + "version": "1.0.0", + "description": "A sample component for testing", + "file": "sample_component.py", + "wire": True, + } + ], + } + (awioc_dir / "manifest.yaml").write_text(yaml.dump(manifest)) + return module_path @pytest.fixture def sample_component_package(temp_dir): - """Create a sample component package directory.""" + """Create a sample component package directory with .awioc/manifest.yaml.""" + import yaml + pkg_dir = temp_dir / "sample_package" pkg_dir.mkdir() init_path = pkg_dir / "__init__.py" init_path.write_text(""" -__metadata__ = { - "name": "sample_package", - "version": "2.0.0", - "description": "A sample package component", - "wire": False, -} - initialize = None shutdown = None +wait = None """) + + # Create .awioc/manifest.yaml inside the package + awioc_dir = pkg_dir / ".awioc" + awioc_dir.mkdir() + manifest = { + "manifest_version": "1.0", + "components": [ + { + "name": "sample_package", + "version": "2.0.0", + "description": "A sample package component", + "file": "__init__.py", + "wire": False, + } + ], + } + (awioc_dir / "manifest.yaml").write_text(yaml.dump(manifest)) + return pkg_dir @pytest.fixture def sample_app_module(temp_dir): - """Create a sample app module for testing.""" + """Create a sample app module for testing with .awioc/manifest.yaml.""" + import yaml + module_path = temp_dir / "app.py" module_path.write_text(""" from src.awioc.config import Settings @@ -107,20 +136,33 @@ def sample_app_module(temp_dir): class AppConfig(Settings): app_name: str = "test_app" -__metadata__ = { - "name": "test_app", - "version": "1.0.0", - "description": "Test application", - "wire": True, - "base_config": AppConfig, -} - async def initialize(): return True async def shutdown(): pass + +async def wait(): + pass """) + + # Create .awioc/manifest.yaml + awioc_dir = temp_dir / ".awioc" + awioc_dir.mkdir(exist_ok=True) + manifest = { + "manifest_version": "1.0", + "components": [ + { + "name": "test_app", + "version": "1.0.0", + "description": "Test application", + "file": "app.py", + "wire": True, + } + ], + } + (awioc_dir / "manifest.yaml").write_text(yaml.dump(manifest)) + return module_path