From 8b686ec80e159c68751dd899a5487724ddf79e63 Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Wed, 12 Nov 2025 13:36:07 +0100 Subject: [PATCH] Initial commit for profile Radian --- pyproject.toml | 37 +++++++ radian/components/base_component.py | 121 +++++++++++++++++++++ radian/components/component_manager.py | 125 +++++++++++++++++++++ radian/config/components.yaml | 16 +++ radian/core/main_controller.py | 75 +++++++++++-- radian/gui/main_window.py | 132 +++++++++++++++-------- radian_plugins/test_component/adapter.py | 115 ++++++++++++++++++++ 7 files changed, 572 insertions(+), 49 deletions(-) create mode 100644 pyproject.toml create mode 100644 radian/components/component_manager.py create mode 100644 radian/config/components.yaml create mode 100644 radian_plugins/test_component/adapter.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c8f9d95 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +# pyproject.toml + +# This section defines the build system requirements for the project. +# setuptools is a standard choice. +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +# This section contains the core metadata about your project. +[project] +name = "radian-framework" +version = "0.1.0" +authors = [ + { name = "Luca Vallongo", email = "luca.vallongo@gmail.com" }, +] +description = "A framework for Radar Data Integration & Analysis." +readme = "README.md" # Optional: if you have a README file +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", # Choose a license + "Operating System :: OS Independent", + "Development Status :: 3 - Alpha", +] + +# This is the most important part for our use case. +# It lists the external libraries that RADIAN depends on to run. +dependencies = [ + "PyYAML", # For reading the .yaml configuration files +] + +# This section tells setuptools where to find your actual Python packages. +[tool.setuptools] +packages = ["radian"] + +[tool.setuptools.package-data] +radian = ["py.typed"] \ No newline at end of file diff --git a/radian/components/base_component.py b/radian/components/base_component.py index e69de29..41f93cc 100644 --- a/radian/components/base_component.py +++ b/radian/components/base_component.py @@ -0,0 +1,121 @@ +# radian/components/base_component.py + +from abc import ABC, abstractmethod +from typing import Dict, Any, Optional +import tkinter as tk + +# A type alias for configuration dictionaries to improve code readability. +ConfigType = Dict[str, Any] + +class BaseComponent(ABC): + """ + Abstract Base Class for all RADIAN components. + + This class defines a common interface (a "contract") that each external + tool must implement via an adapter class to be managed by the RADIAN framework. + It ensures that RADIAN can interact with any component in a standardized way. + """ + + def __init__(self): + # Each component instance will manage its own configuration. + self.config: ConfigType = self.get_default_config() + + @abstractmethod + def get_name(self) -> str: + """ + Returns the user-friendly name of the component. + This name will be displayed in the RADIAN GUI. + + Returns: + The string name of the component. + """ + pass + + @abstractmethod + def get_description(self) -> str: + """ + Returns a brief description of what the component does. + This can be used for tooltips or help text in the GUI. + + Returns: + A short string description. + """ + pass + + @abstractmethod + def get_default_config(self) -> ConfigType: + """ + Returns a dictionary with the default configuration parameters + for the component. This is used to initialize the component's state. + + Returns: + A dictionary representing the default configuration. + """ + pass + + def set_config(self, config: ConfigType) -> None: + """ + Applies a new configuration to the component. This method can be + overridden if complex logic is needed when setting a configuration. + + Args: + config: A dictionary with the configuration to apply. + """ + self.config = config + + def get_current_config(self) -> ConfigType: + """ + Returns the current configuration of the component. + + Returns: + The current configuration dictionary. + """ + return self.config + + @abstractmethod + def get_config_ui(self, parent: tk.Frame) -> Optional[tk.Frame]: + """ + Creates and returns a Tkinter Frame containing the component's + specific configuration UI elements. RADIAN will place this frame + in the main content area. + + If a component has no configurable parameters, this method can + return None. + + Args: + parent: The parent Tkinter widget (a Frame) where the UI + should be built. + + Returns: + A Tkinter Frame with all the necessary configuration widgets, + or None if no GUI configuration is needed. + """ + pass + + @abstractmethod + def run(self, input_data: Optional[Any] = None) -> Any: + """ + Executes the main logic of the component. + This is the core function for workflow execution. It can accept data + from a previous component and return data for the next one. + + Args: + input_data: Data passed from a previous component in a workflow. + + Returns: + The output of the component's processing, to be passed to the + next component in a workflow, or None if there is no output. + """ + pass + + def get_icon_path(self) -> Optional[str]: + """ + Returns the absolute path to the component's icon file (e.g., .png, .ico). + The recommended size is 32x32 or 64x64. + + If not implemented or returns None, a default icon will be used. + + Returns: + A string representing the absolute path to the icon, or None. + """ + return None \ No newline at end of file diff --git a/radian/components/component_manager.py b/radian/components/component_manager.py new file mode 100644 index 0000000..7fe651f --- /dev/null +++ b/radian/components/component_manager.py @@ -0,0 +1,125 @@ +# radian/components/component_manager.py + +import sys +import importlib.util +import inspect +from pathlib import Path +from typing import Dict, Optional, Type + +import yaml + +from radian.utils import logger +from radian.components.base_component import BaseComponent + +log = logger.get_logger(__name__) + +# The expected name of the file containing the adapter class within a component's project. +ADAPTER_FILENAME = "adapter.py" +# The expected name of the adapter class. This can be made more flexible later if needed. +ADAPTER_CLASS_NAME = "RadianAdapter" + + +class ComponentManager: + """ + Discovers, loads, and manages all RADIAN components (plugins). + + It reads a configuration file to find components and dynamically loads + them, making them available to the rest of the application. + """ + def __init__(self, config_path: Path): + self.config_path = config_path + self.components: Dict[str, BaseComponent] = {} + log.info(f"ComponentManager initialized with config path: {config_path}") + + def load_components(self): + """ + Loads all components specified in the configuration file. + This is the main entry point for the manager. + """ + log.info("Starting component discovery and loading...") + try: + with open(self.config_path, 'r', encoding='utf-8') as f: + config = yaml.safe_load(f) + except FileNotFoundError: + log.error(f"Component configuration file not found at: {self.config_path}") + return + except yaml.YAMLError as e: + log.error(f"Error parsing component YAML file: {e}") + return + + if not config or 'components' not in config: + log.warning("Component configuration file is empty or missing 'components' key.") + return + + for name, comp_config in config['components'].items(): + if 'path' in comp_config: + log.info(f"Found component '{name}' configured with a local path.") + self._load_component_from_path(name, Path(comp_config['path'])) + else: + log.warning(f"Component '{name}' is missing a 'path' key. Skipping.") + + log.info(f"Component loading complete. {len(self.components)} components loaded.") + + def _load_component_from_path(self, name: str, component_path: Path): + """Dynamically loads a single component from a local directory path.""" + adapter_file_path = component_path / ADAPTER_FILENAME + + if not adapter_file_path.is_file(): + log.error(f"Component '{name}': Adapter file '{ADAPTER_FILENAME}' not found in {component_path}. Skipping.") + return + + try: + # Create a unique module name to avoid conflicts + module_name = f"radian.external_components.{name}" + + # Create a module spec from the file path + spec = importlib.util.spec_from_file_location(module_name, adapter_file_path) + if not spec or not spec.loader: + log.error(f"Component '{name}': Could not create module spec from {adapter_file_path}.") + return + + # Create a new module object + module = importlib.util.module_from_spec(spec) + + # Add the component's root directory to sys.path TEMPORARILY + # so that its internal imports (e.g., from . import logic) work. + sys.path.insert(0, str(component_path)) + + # Execute the module's code + spec.loader.exec_module(module) + + # IMPORTANT: Remove the path from sys.path to avoid side effects + sys.path.pop(0) + + # Find the adapter class within the loaded module + AdapterClass = self._find_adapter_class(module) + + if AdapterClass: + instance = AdapterClass() + self.components[name] = instance + log.info(f"Successfully loaded and instantiated component: '{name}'") + else: + log.error(f"Component '{name}': No valid adapter class inheriting from BaseComponent found in {adapter_file_path}.") + + except Exception as e: + # Clean up path if an error occurred during exec_module + if str(component_path) in sys.path: + sys.path.remove(str(component_path)) + log.error(f"Failed to load component '{name}' from {component_path}. Error: {e}", exc_info=True) + + def _find_adapter_class(self, module) -> Optional[Type[BaseComponent]]: + """Inspects a module to find a class that inherits from BaseComponent.""" + for name, obj in inspect.getmembers(module, inspect.isclass): + # Check if it's a class defined in this module (not imported) + # and if it's a subclass of BaseComponent (but not BaseComponent itself) + if obj is not BaseComponent and issubclass(obj, BaseComponent): + return obj + return None + + def get_component(self, name: str) -> Optional[BaseComponent]: + """Returns the loaded instance of a component by its name.""" + return self.components.get(name) + + def get_all_components(self) -> Dict[str, BaseComponent]: + """Returns a dictionary of all loaded components.""" + return self.components \ No newline at end of file diff --git a/radian/config/components.yaml b/radian/config/components.yaml new file mode 100644 index 0000000..8b651ef --- /dev/null +++ b/radian/config/components.yaml @@ -0,0 +1,16 @@ +# config/components.yaml + +components: + test_component: + # !!! IMPORTANT: Use the actual path to the 'test_component' FOLDER you created !!! + path: "C:/src/____GitProjects/RADIAN/radian_plugins/test_component" + + # Comment out the other components for now, as they don't have an adapter.py yet. + # + # radar_data_reader: + # path: "C:/path/to/your/radar_data_reader_project" + # git_repo: "https://github.com/your_username/radar_data_reader.git" + # + # flight_monitor: + # path: "C:/path/to/your/FlightMonitor_project" + # git_repo: "https://github.com/your_username/FlightMonitor.git" \ No newline at end of file diff --git a/radian/core/main_controller.py b/radian/core/main_controller.py index 79b7b62..5b25b84 100644 --- a/radian/core/main_controller.py +++ b/radian/core/main_controller.py @@ -1,8 +1,9 @@ # radian/core/main_controller.py - +from pathlib import Path from radian.gui.main_window import MainWindow from radian.utils import logger from radian.config.logging_config import LOGGING_CONFIG +from radian.components.component_manager import ComponentManager # Get a logger for this specific module log = logger.get_logger(__name__) @@ -16,25 +17,48 @@ class MainController: """ def __init__(self): self.app: MainWindow | None = None + self.component_manager: ComponentManager | None = None def run(self): - """ - Initializes and starts the RADIAN application. - """ # 1. Initialize the main window (the GUI) self.app = MainWindow() # 2. Set up the centralized logging system self._setup_logging() + + # 3. Initialize and run the component manager + self._load_components() - # 3. Set up a graceful shutdown procedure + # 4. Populate the GUI with the loaded components + self._populate_gui_with_components() + + # 5. Set up a graceful shutdown procedure self.app.protocol("WM_DELETE_WINDOW", self._on_shutdown) + log.info("RADIAN application started.") + log.info("RADIAN application started.") log.info("Welcome to the Radar Data Integration & Analysis Network.") - # 4. Start the Tkinter main event loop + # --- TEST: Log i componenti caricati --- + if self.component_manager: + loaded_names = self.component_manager.get_all_components().keys() + if loaded_names: + log.info(f"Available components: {list(loaded_names)}") + else: + log.warning("No components were loaded. Check config/components.yaml and paths.") + + # 5. Start the Tkinter main event loop self.app.mainloop() + + def _load_components(self): + """Initializes the ComponentManager and loads all plugins.""" + log.info("Initializing Component Manager...") + # Assume config file is in 'config/components.yaml' relative to the project root + # This needs a robust way to find the project root. For now, let's assume... + config_path = Path(__file__).parent.parent / "config" / "components.yaml" + self.component_manager = ComponentManager(config_path.resolve()) + self.component_manager.load_components() def _setup_logging(self): """Initializes the basic logger and adds the Tkinter handler.""" @@ -63,4 +87,41 @@ class MainController: # Destroy the main window if self.app: - self.app.destroy() \ No newline at end of file + self.app.destroy() + + def _populate_gui_with_components(self): + """Gets components from the manager and adds them to the rich GUI list.""" + if not self.component_manager or not self.app: + return + + all_components = self.component_manager.get_all_components() + + for comp_id, component in all_components.items(): + self.app.add_component_to_list( + component_id=comp_id, + name=component.get_name(), + icon_path=component.get_icon_path(), + callback=self._on_component_selected # Passiamo il metodo come callback + ) + + def _on_component_selected(self, component_id: str): # Ora riceve l'id direttamente + """Called when a user clicks on a component widget in the list.""" + if not self.app or not self.component_manager: + return + + component = self.component_manager.get_component(component_id) + if not component: + return + + log.info(f"Component '{component.get_name()}' selected.") + + # Deseleziona gli altri (feedback visivo) + # (Implementazione più avanzata in futuro) + + component_ui_frame = component.get_config_ui(self.app.content_frame) + + if component_ui_frame: + self.app.display_component_ui(component_ui_frame) + else: + self.app.clear_content_area() + log.info(f"Component '{component.get_name()}' has no configuration UI.") \ No newline at end of file diff --git a/radian/gui/main_window.py b/radian/gui/main_window.py index f55e705..a163692 100644 --- a/radian/gui/main_window.py +++ b/radian/gui/main_window.py @@ -2,71 +2,119 @@ import tkinter as tk from tkinter import ttk +from typing import Dict, Any, Optional, Callable +from PIL import Image, ImageTk # Assicurati di aver installato Pillow: pip install Pillow class MainWindow(tk.Tk): - """ - The main window of the RADIAN application. - - It sets up the main application frame, including the layout for - the components' UI and the logging area. - """ def __init__(self): super().__init__() - # --- Basic Window Configuration --- self.title("RADIAN - Radar Data Integration & Analysis Network") self._set_fullscreen() - # --- Main Layout Structure --- - # The main container frame + # Store PhotoImage objects to prevent garbage collection + self.photo_images: Dict[str, ImageTk.PhotoImage] = {} + main_frame = ttk.Frame(self) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - # Configure the grid layout (2 rows, 1 column) - main_frame.rowconfigure(0, weight=3) # Content area takes 3/4 of the space - main_frame.rowconfigure(1, weight=1) # Log area takes 1/4 of the space - main_frame.columnconfigure(0, weight=1) + main_frame.rowconfigure(0, weight=1) + main_frame.columnconfigure(0, weight=1, minsize=250) # Colonna lista, peso minore ma con minsize + main_frame.columnconfigure(1, weight=5) - # --- Create Widgets --- - self._create_content_area(main_frame) - self._create_log_area(main_frame) + self._create_components_list_area(main_frame) + + right_panel = ttk.Frame(main_frame) + right_panel.grid(row=0, column=1, sticky="nsew") + right_panel.rowconfigure(0, weight=3) + right_panel.rowconfigure(1, weight=1) + right_panel.columnconfigure(0, weight=1) + self._create_content_area(right_panel) + self._create_log_area(right_panel) + def _set_fullscreen(self): - """Sets the window to a maximized state.""" - # 'zoomed' is the standard state for a maximized window in Tkinter. - # This is generally more reliable across different systems than using attributes. self.state('zoomed') - + + def _create_components_list_area(self, parent: ttk.Frame): + list_frame = ttk.LabelFrame(parent, text="Components", padding="10") + list_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + list_frame.rowconfigure(0, weight=1) + list_frame.columnconfigure(0, weight=1) + + # --- Nuovo approccio con Canvas e Frame scrollabile --- + canvas = tk.Canvas(list_frame) + scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) + + self.scrollable_frame = ttk.Frame(canvas) + + self.scrollable_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + + canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.grid(row=0, column=0, sticky="nsew") + scrollbar.grid(row=0, column=1, sticky="ns") + + def add_component_to_list(self, component_id: str, name: str, icon_path: Optional[str], callback: Callable): + """Adds a single, rich component widget to the scrollable list.""" + + # Frame per il singolo componente + comp_frame = ttk.Frame(self.scrollable_frame, padding=5) + comp_frame.pack(fill="x", expand=True, pady=2) + + # Carica l'icona + try: + if icon_path: + img = Image.open(icon_path).resize((48, 48), Image.Resampling.LANCZOS) + photo = ImageTk.PhotoImage(img) + self.photo_images[component_id] = photo # Salva il riferimento! + icon_label = ttk.Label(comp_frame, image=photo) + icon_label.grid(row=0, column=0, rowspan=2, padx=(0, 10)) + else: + # Placeholder se non c'è icona + icon_label = ttk.Label(comp_frame, text="⚫", font=("Segoe UI", 24)) + icon_label.grid(row=0, column=0, rowspan=2, padx=(0, 10)) + except Exception as e: + print(f"Error loading icon for {name}: {e}") + icon_label = ttk.Label(comp_frame, text="!", font=("Segoe UI", 24)) + icon_label.grid(row=0, column=0, rowspan=2, padx=(0, 10)) + + # Nome del componente + name_label = ttk.Label(comp_frame, text=name, font=("Segoe UI", 12, "bold")) + name_label.grid(row=0, column=1, sticky="w") + + # --- Eventi di Click --- + # Usa una lambda per passare l'id del componente al callback + click_handler = lambda e, c_id=component_id: callback(c_id) + comp_frame.bind("", click_handler) + name_label.bind("", click_handler) + icon_label.bind("", click_handler) + def _create_content_area(self, parent: ttk.Frame): - """Creates the frame that will host the components' UI.""" content_frame = ttk.LabelFrame(parent, text="Component View", padding="10") content_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) - - # This is a placeholder. In the future, this frame will be populated - # by the ComponentManager with the UI of the selected component. - placeholder_label = ttk.Label(content_frame, text="Component UI will be displayed here.") - placeholder_label.pack(expand=True) - self.content_frame = content_frame def _create_log_area(self, parent: ttk.Frame): - """Creates the scrollable text widget for logging.""" log_frame = ttk.LabelFrame(parent, text="System Log", padding="10") log_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) - - # Configure grid to make the text widget fill the frame log_frame.rowconfigure(0, weight=1) log_frame.columnconfigure(0, weight=1) - - # The text widget for displaying logs - log_text = tk.Text(log_frame, wrap=tk.WORD, state=tk.DISABLED, height=10) - log_text.grid(row=0, column=0, sticky="nsew") - - # The scrollbar for the text widget - scrollbar = ttk.Scrollbar(log_frame, orient=tk.VERTICAL, command=log_text.yview) + log_text_widget = tk.Text(log_frame, wrap=tk.WORD, state=tk.DISABLED, height=10) + log_text_widget.grid(row=0, column=0, sticky="nsew") + scrollbar = ttk.Scrollbar(log_frame, orient=tk.VERTICAL, command=log_text_widget.yview) scrollbar.grid(row=0, column=1, sticky="ns") - - # Link the scrollbar to the text widget - log_text.configure(yscrollcommand=scrollbar.set) - - self.log_text = log_text \ No newline at end of file + log_text_widget.configure(yscrollcommand=scrollbar.set) + self.log_text = log_text_widget + + def clear_content_area(self): + for widget in self.content_frame.winfo_children(): + widget.destroy() + + def display_component_ui(self, component_ui_frame: tk.Frame): + self.clear_content_area() + component_ui_frame.pack(fill=tk.BOTH, expand=True) \ No newline at end of file diff --git a/radian_plugins/test_component/adapter.py b/radian_plugins/test_component/adapter.py new file mode 100644 index 0000000..21cdabb --- /dev/null +++ b/radian_plugins/test_component/adapter.py @@ -0,0 +1,115 @@ +# C:/radian_plugins/test_component/adapter.py + +import tkinter as tk +from tkinter import ttk +from typing import Optional +from pathlib import Path + +# This import works because RADIAN was installed in editable mode. +from radian.components.base_component import BaseComponent, ConfigType +from radian.utils import logger + +# Each component should have its own logger instance. +log = logger.get_logger(__name__) + + +class RadianAdapter(BaseComponent): + """ + A dummy component adapter for testing the RADIAN loading mechanism. + + This class implements the full BaseComponent contract. + """ + def __init__(self): + log.info("TestComponent's adapter is being initialized...") + # The call to super().__init__() is important as it sets up + # the initial configuration by calling get_default_config(). + super().__init__() + # NOTE: Tkinter variables are now created inside get_config_ui + # to ensure they are fresh each time the UI is built. + + def get_name(self) -> str: + """Returns the user-friendly name of the component.""" + return "Test Component" + + def get_description(self) -> str: + """Returns a brief description of what the component does.""" + return "A dummy component for demonstrating and testing the plugin system." + + def get_default_config(self) -> ConfigType: + """Defines the default configuration parameters for this component.""" + return { + "test_parameter_1": "default_string_value", + "test_parameter_2": True + } + + def get_icon_path(self) -> Optional[str]: + """Returns the path to this component's icon.""" + # The icon is expected to be in the same directory as this adapter file. + icon_path = Path(__file__).parent / "test_component.png" + if icon_path.exists(): + return str(icon_path) + log.warning(f"Icon not found at: {icon_path}") + return None + + def get_config_ui(self, parent: tk.Frame) -> Optional[tk.Frame]: + """Creates the configuration UI for this test component.""" + log.debug(f"Creating config UI for '{self.get_name()}'") + + frame = ttk.Frame(parent, padding="10") + frame.columnconfigure(1, weight=1) + + # --- Initialize Tkinter variables locally within this method --- + # This ensures they are new for each UI creation and avoids conflicts + # with previously destroyed widgets. + param1_var = tk.StringVar() + param2_var = tk.BooleanVar() + + # --- UI Widgets --- + label1 = ttk.Label(frame, text="Test Parameter 1 (String):") + label1.grid(row=0, column=0, padx=5, pady=5, sticky="w") + + entry1 = ttk.Entry(frame, textvariable=param1_var) + entry1.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + check2 = ttk.Checkbutton(frame, text="Enable Test Feature", variable=param2_var) + check2.grid(row=1, column=0, columnspan=2, padx=5, pady=5, sticky="w") + + # --- Nested helper functions to manage UI state --- + # These functions have access to the local tkinter variables. + def update_ui_from_config(): + """Helper to sync the UI with the current self.config state.""" + param1_var.set(self.config.get("test_parameter_1", "")) + param2_var.set(self.config.get("test_parameter_2", False)) + + def update_config_from_ui(*args): + """Helper to sync self.config with the current UI state.""" + self.config["test_parameter_1"] = param1_var.get() + self.config["test_parameter_2"] = param2_var.get() + log.info(f"TestComponent config updated from UI: {self.config}") + + # --- Load current config into UI variables --- + update_ui_from_config() + + # --- Bind UI changes back to the config dictionary --- + param1_var.trace_add("write", update_config_from_ui) + param2_var.trace_add("write", update_config_from_ui) + + return frame + + def run(self, input_data: Optional[any] = None) -> any: + """Executes the dummy logic of this component.""" + log.info(f"--- Running '{self.get_name()}' ---") + if input_data: + log.info(f"Received input data: {input_data}") + + log.info(f"Using configuration: {self.config}") + + if self.config.get("test_parameter_2"): + log.info("Test Feature is ENABLED.") + else: + log.info("Test Feature is DISABLED.") + + output_message = f"Output from {self.get_name()} with param1='{self.config.get('test_parameter_1')}'" + log.info("--- Finished running ---") + + return output_message \ No newline at end of file