|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: Dynamic Module Loading in Python |
| 4 | +date: 2024-10-24 19:01 |
| 5 | +categories: [Guides, Software] |
| 6 | +tags: [guide, development] |
| 7 | +--- |
| 8 | + |
| 9 | +In the Modular Biped Project I wanted to achieve a **cleaner, scalable**, and **modular design** where new modules can be added or removed without needing to modify `main.py`. To achieve this I wanted to refactor the initiation logic into **module-specific files**. This approach allows each module to handle its own initialization independently, making `main.py` generic and less dependent on changes as new modules are introduced. |
| 10 | + |
| 11 | +# Dynamically Loading Python Modules: A Flexible Architecture for Configurable Module Loading |
| 12 | + |
| 13 | +In modern applications, flexibility and scalability are key to adapting to new requirements and functionalities. One effective way to manage growing complexity is to design an architecture that dynamically loads modules based on configuration files—enabling the system to remain adaptable without altering core logic. In this article, we will discuss how to build such a system in Python, allowing modules to be dynamically loaded at runtime, based on YAML configuration files. We'll also explore how to extend the system by writing new modules that fit seamlessly into this architecture. |
| 14 | + |
| 15 | +## The Problem: Static Module Loading |
| 16 | + |
| 17 | +In many Python applications, modules are statically imported and initialized. This approach works fine for small applications, but as the number of modules grows, managing them manually in the main script can become a nightmare. Every time a module is added, removed, or modified, you need to update the `main.py` file or other related scripts. |
| 18 | + |
| 19 | +Consider the following scenario: |
| 20 | +- You have a robotics system that uses multiple modules like actuators, sensors, and camera systems. |
| 21 | +- The system is configurable using YAML files, with some modules needing multiple instances (e.g., multiple servo motors). |
| 22 | +- You want the system to automatically load and initialize modules based on whether they are enabled in the configuration files, without having to modify the core application. |
| 23 | + |
| 24 | +## Enter: Dynamic Module Loading |
| 25 | + |
| 26 | +In our approach, we will design an architecture that dynamically loads Python modules based on the contents of YAML configuration files. Each module will be able to accept configuration parameters passed dynamically as `**kwargs`, allowing flexibility in how modules are instantiated and configured. |
| 27 | + |
| 28 | +## The Architecture: Dynamic Module Loader |
| 29 | + |
| 30 | +Our architecture revolves around three key components: |
| 31 | +1. **YAML Configuration Files**: Define which modules are enabled and their configuration details. |
| 32 | +2. **ModuleLoader Class**: Handles the discovery and dynamic loading of modules based on the YAML configuration. |
| 33 | +3. **Module Initialization with `**kwargs`**: Ensures that configuration options are passed dynamically to module constructors. |
| 34 | + |
| 35 | +### Folder Structure |
| 36 | + |
| 37 | +``` |
| 38 | +my_project/ |
| 39 | +│ |
| 40 | +├── config/ |
| 41 | +│ ├── servos.yml # Configuration for servo modules |
| 42 | +│ └── buzzer.yml # Configuration for buzzer module |
| 43 | +│ |
| 44 | +├── modules/ |
| 45 | +│ ├── actuators/ |
| 46 | +│ │ └── servo.py # Implementation of the Servo class |
| 47 | +│ │ |
| 48 | +│ ├── sensor/ |
| 49 | +│ │ └── motion_sensor.py # Implementation of the MotionSensor class |
| 50 | +│ │ |
| 51 | +│ ├── output/ |
| 52 | +│ │ ├── buzzer.py # Implementation of the Buzzer class |
| 53 | +│ │ └── speaker.py # Implementation of the Speaker class (if needed) |
| 54 | +│ │ |
| 55 | +│ └── ... # Other module directories as needed |
| 56 | +│ |
| 57 | +├── main.py # Entry point for the application |
| 58 | +├── module_loader.py # Contains the ModuleLoader class |
| 59 | +└── requirements.txt # List of dependencies for the project |
| 60 | +``` |
| 61 | + |
| 62 | +### 1. YAML Configuration Files |
| 63 | + |
| 64 | +Each module has a corresponding YAML configuration file that defines whether the module is enabled and what parameters it should use. For example, a `servos.yml` file might configure multiple servo motor instances: |
| 65 | + |
| 66 | +```yaml |
| 67 | +servos: |
| 68 | + enabled: true |
| 69 | + path: "modules/actuators/servo" |
| 70 | + instances: |
| 71 | + - name: "leg_l_hip" |
| 72 | + id: 0 |
| 73 | + pin: 9 |
| 74 | + range: [0, 180] |
| 75 | + start_pos: 40 |
| 76 | + - name: "leg_l_knee" |
| 77 | + id: 1 |
| 78 | + pin: 10 |
| 79 | + range: [0, 180] |
| 80 | + start_pos: 10 |
| 81 | +``` |
| 82 | +
|
| 83 | +In this example, the `servos` module is enabled, and two instances (`leg_l_hip` and `leg_l_knee`) are defined with their respective configurations. The `path` points to where the module is located within the project folder. |
| 84 | + |
| 85 | +### 2. ModuleLoader Class |
| 86 | + |
| 87 | +The `ModuleLoader` class is responsible for reading these YAML files, dynamically loading the modules, and creating instances based on the configuration provided. |
| 88 | + |
| 89 | +Here’s an implementation of `ModuleLoader`: |
| 90 | + |
| 91 | +```python |
| 92 | +import os |
| 93 | +import yaml |
| 94 | +import importlib.util |
| 95 | +
|
| 96 | +class ModuleLoader: |
| 97 | + def __init__(self, config_folder='config'): |
| 98 | + self.config_folder = config_folder |
| 99 | + self.modules = self.load_yaml_files() |
| 100 | +
|
| 101 | + def load_yaml_files(self): |
| 102 | + """Load and parse YAML files from the config folder.""" |
| 103 | + config_files = [os.path.join(self.config_folder, f) for f in os.listdir(self.config_folder) if f.endswith('.yml')] |
| 104 | + loaded_modules = [] |
| 105 | + for file_path in config_files: |
| 106 | + with open(file_path, 'r') as stream: |
| 107 | + try: |
| 108 | + config = yaml.safe_load(stream) |
| 109 | + for module_name, module_config in config.items(): |
| 110 | + if module_config.get('enabled', False): |
| 111 | + loaded_modules.append(module_config) |
| 112 | + except yaml.YAMLError as e: |
| 113 | + print(f"Error loading {file_path}: {e}") |
| 114 | + return loaded_modules |
| 115 | +
|
| 116 | + def load_modules(self): |
| 117 | + """Dynamically load and instantiate the modules based on the config.""" |
| 118 | + instances = {} # Use a dictionary to store instances for easy access |
| 119 | + for module in self.modules: |
| 120 | + module_path = module['path'] # e.g., "modules/actuators/servo" |
| 121 | + module_name = module_path.split('/')[-1] # e.g., "servo" |
| 122 | + instances_config = module.get('instances', [module.get('config')]) # Get all instances or config |
| 123 | +
|
| 124 | + # Dynamically load the module |
| 125 | + spec = importlib.util.spec_from_file_location(module_name, f"{module_path}/{module_name}.py") |
| 126 | + mod = importlib.util.module_from_spec(spec) |
| 127 | + spec.loader.exec_module(mod) |
| 128 | +
|
| 129 | + # Create instances of the module |
| 130 | + for instance_config in instances_config: |
| 131 | + # Pass the instance config to the module's __init__ method as **kwargs |
| 132 | + instance_name = instance_config.get('name') # Use the instance name as the key |
| 133 | + instance = getattr(mod, module_name.capitalize())(**instance_config) |
| 134 | +
|
| 135 | + # Store the instance in the dictionary |
| 136 | + instances[instance_name] = instance |
| 137 | +
|
| 138 | + return instances # Return the dictionary of instances |
| 139 | +
|
| 140 | +``` |
| 141 | + |
| 142 | +#### How It Works: |
| 143 | +1. **Loading YAML Files**: The `ModuleLoader` reads all YAML files from the `config` folder and loads only the modules that are marked as `enabled`. |
| 144 | +2. **Dynamic Module Loading**: Using Python’s `importlib`, the `ModuleLoader` dynamically loads the Python files based on the module path in the YAML. |
| 145 | +3. **Module Initialization**: The `ModuleLoader` passes the configuration for each instance as `**kwargs` to the module’s constructor. |
| 146 | + |
| 147 | +### 3. Module Initialization with `**kwargs` |
| 148 | + |
| 149 | +For each module, the constructor (`__init__` method) should be designed to accept `**kwargs`, which allows it to handle configurations flexibly. Let’s look at an example module for controlling servo motors. |
| 150 | + |
| 151 | +```python |
| 152 | +class Servo: |
| 153 | + def __init__(self, **kwargs): |
| 154 | + self.pin = kwargs.get('pin') # Required, no default |
| 155 | + self.name = kwargs.get('name') # Required, no default |
| 156 | + self.range = kwargs.get('range', [0, 180]) # Optional with default value |
| 157 | + self.id = kwargs.get('id', 0) # Optional with default value |
| 158 | + self.start = kwargs.get('start_pos', 50) # Optional with default value |
| 159 | + print(f"Initializing Servo {self.name} on pin {self.pin} with range {self.range} and start {self.start}") |
| 160 | +``` |
| 161 | + |
| 162 | +Here, the `**kwargs` argument makes the `Servo` class highly flexible, allowing it to accept any configuration that is passed from the YAML file. Using `kwargs.get()`, we retrieve specific configuration parameters and assign default values when necessary. |
| 163 | + |
| 164 | +### 4. Simplified `main.py` |
| 165 | + |
| 166 | +Here, the `main.py` script is simplified to focus on the dynamic loading and initialization of modules, without needing to hard-code specific modules or configurations. Any changes to modules or configurations are now handled entirely via the YAML files. |
| 167 | + |
| 168 | +If you wanted direct access to the instance created by the model loader, you can do that too! In the `main.py` file, when the `ModuleLoader` loads the modules, we have them ready for use: |
| 169 | + |
| 170 | +```python |
| 171 | +from module_loader import ModuleLoader |
| 172 | +
|
| 173 | +def main(): |
| 174 | + # Load all enabled modules |
| 175 | + module_loader = ModuleLoader(config_folder='config') |
| 176 | + module_instances = module_loader.load_modules() |
| 177 | +
|
| 178 | + # Access instances by name |
| 179 | + my_module = module_instances.get("my_module") |
| 180 | + if my_module: |
| 181 | + my_module.start() # Assuming there's a start method |
| 182 | +
|
| 183 | +if __name__ == '__main__': |
| 184 | + main() |
| 185 | +
|
| 186 | +``` |
| 187 | + |
| 188 | +Interacting with your module via pubsub is also possible, and avoids adding business logic to the main.py file. |
| 189 | + |
| 190 | +```python |
| 191 | +from pubsub import pub |
| 192 | +pub.sendMessage('mytopic', data='somedata') # Publish to a topic |
| 193 | +pub.subscribe(self.handler_method, 'anothertopic') # subscribe to another topic |
| 194 | +``` |
| 195 | + |
| 196 | +## Writing a New Module for This Architecture |
| 197 | + |
| 198 | +To add a new module to this architecture, follow these simple steps: |
| 199 | + |
| 200 | +### 1. Create a Python Module |
| 201 | + |
| 202 | +Create a new Python class for your module. Ensure that the `__init__` method accepts `**kwargs` for dynamic configuration. |
| 203 | + |
| 204 | +#### Example: `buzzer.py` |
| 205 | + |
| 206 | +```python |
| 207 | +class Buzzer: |
| 208 | + def __init__(self, **kwargs): |
| 209 | + self.pin = kwargs.get('pin') # Required, no default |
| 210 | + print(f"Initializing Buzzer on pin {self.pin}") |
| 211 | + |
| 212 | + def buzz(self): |
| 213 | + print(f"Buzzer on pin {self.pin} is buzzing!") |
| 214 | +``` |
| 215 | + |
| 216 | +### 2. Define a YAML Configuration |
| 217 | + |
| 218 | +Create a corresponding YAML configuration file in the `config` folder that specifies the module's configuration. For example, the `buzzer.yml` file might look like this: |
| 219 | + |
| 220 | +```yaml |
| 221 | +buzzer: |
| 222 | + enabled: true |
| 223 | + path: "modules/output/buzzer" |
| 224 | + config: |
| 225 | + pin: 26 |
| 226 | +``` |
| 227 | + |
| 228 | +### 3. Load the Module Dynamically |
| 229 | + |
| 230 | +With the `ModuleLoader` system in place, the module will be automatically loaded when the application runs, provided that it is enabled in its YAML configuration. No changes are needed in `main.py` or elsewhere in the core logic. |
| 231 | + |
| 232 | +## Conclusion |
| 233 | + |
| 234 | +By introducing dynamic module loading based on YAML configuration files, we’ve built an architecture that is flexible, scalable, and easy to extend. This approach allows developers to add new modules, enable or disable them, and configure multiple instances of the same module—all without modifying the core application code. |
| 235 | + |
| 236 | +### Key Benefits: |
| 237 | +- **Scalability**: Easily add or remove modules without changing the core logic. |
| 238 | +- **Flexibility**: Modules can accept any configuration parameters dynamically using `**kwargs`. |
| 239 | +- **Maintainability**: Centralized management of configuration via YAML files. |
| 240 | + |
| 241 | +Now, when your system grows or needs new modules, you can simply drop in the new module and corresponding YAML file, making your Python application truly dynamic and adaptable. |
0 commit comments