Dynamic Module Loading in Python
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.
Dynamically Loading Python Modules: A Flexible Architecture for Configurable Module Loading
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.
The Problem: Static Module Loading
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.
Consider the following scenario:
- You have a robotics system that uses multiple modules like actuators, sensors, and camera systems.
- The system is configurable using YAML files, with some modules needing multiple instances (e.g., multiple servo motors).
- 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.
Enter: Dynamic Module Loading
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.
The Architecture: Dynamic Module Loader
Our architecture revolves around three key components:
- YAML Configuration Files: Define which modules are enabled and their configuration details.
- ModuleLoader Class: Handles the discovery and dynamic loading of modules based on the YAML configuration.
- Module Initialization with
**kwargs
: Ensures that configuration options are passed dynamically to module constructors.
Folder Structure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
my_project/
│
├── config/
│ ├── servos.yml # Configuration for servo modules
│ └── buzzer.yml # Configuration for buzzer module
│
├── modules/
│ ├── actuators/
│ │ └── servo.py # Implementation of the Servo class
│ │
│ ├── sensor/
│ │ └── motion_sensor.py # Implementation of the MotionSensor class
│ │
│ ├── output/
│ │ ├── buzzer.py # Implementation of the Buzzer class
│ │ └── speaker.py # Implementation of the Speaker class (if needed)
│ │
│ └── ... # Other module directories as needed
│
├── main.py # Entry point for the application
├── module_loader.py # Contains the ModuleLoader class
└── requirements.txt # List of dependencies for the project
1. YAML Configuration Files
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
servos:
enabled: true
path: "modules.actuators.servo.Servo" # Include class name here
instances:
- name: "leg_l_hip"
id: 0
pin: 9
range: [0, 180]
start: 40
- name: "leg_l_knee"
id: 1
pin: 10
range: [0, 180]
start: 10
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.
2. ModuleLoader Class
The ModuleLoader
class is responsible for reading these YAML files, dynamically loading the modules, and creating instances based on the configuration provided.
Here’s an implementation of ModuleLoader
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import os
import yaml
import importlib.util
from pubsub import pub
class ModuleLoader:
def __init__(self, config_folder='config'):
"""
ModuleLoader class
:param config_folder: folder containing the module configuration files
Example config file:
config/modules.yml
---
buzzer:
enabled: true # Required
path: "modules.audio.buzzer.Buzzer" # Required
config: # Passed as **kwargs to the module's __init__ method
pin: 27
name: 'buzzer'
Example:
loader = ModuleLoader()
modules = loader.load_modules()
Reference module once loaded:
translator_inst = modules['Translator']
"""
self.config_folder = config_folder
self.modules = self.load_yaml_files()
def load_yaml_files(self):
"""Load and parse YAML files from the config folder."""
config_files = [os.path.join(self.config_folder, f) for f in os.listdir(self.config_folder) if f.endswith('.yml')]
loaded_modules = []
for file_path in config_files:
with open(file_path, 'r') as stream:
try:
config = yaml.safe_load(stream)
for module_name, module_config in config.items():
if module_config.get('enabled', False):
loaded_modules.append(module_config)
except yaml.YAMLError as e:
print(f"Error loading {file_path}: {e}")
return loaded_modules
def load_modules(self):
"""Dynamically load and instantiate the modules based on the config."""
instances = {} # Use a dictionary to store instances for easy access
for module in self.modules:
print(f"Enabling {module['path']}")
# get path excluding the last part
module_path = module['path'].rsplit('.', 1)[0].replace('.', '/') # e.g., "modules.servo"
module_name = module['path'].split('.')[-1] # e.g., "Servo"
instances_config = module.get('instances', [module.get('config')]) # Get all instances or just use config
if instances_config[0] is None:
instances_config = [{}]
# Dynamically load the module
spec = importlib.util.spec_from_file_location(module_name, f"{module_path}.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# Create instances of the module
for instance_config in instances_config:
# Pass the instance config to the module's __init__ method as **kwargs
instance_name = module_name + '_' + instance_config.get('name') if instance_config.get('name') is not None else module_name # Use the module name and instance name as the key or module_name if single instance
instance = getattr(mod, module_name)(**instance_config)
# Store the instance in the dictionary
instances[instance_name] = instance
pub.sendMessage('log', msg=f"[ModuleLoader] Loaded module: {module_name} instance: {instance_name}")
print("All modules loaded")
return instances # Return the dictionary of instances
How It Works:
- Loading YAML Files: The
ModuleLoader
reads all YAML files from theconfig
folder and loads only the modules that are marked asenabled
. - Dynamic Module Loading: Using Python’s
importlib
, theModuleLoader
dynamically loads the Python files based on the module path in the YAML. - Module Initialization: The
ModuleLoader
passes the configuration for each instance as**kwargs
to the module’s constructor.
3. Module Initialization with **kwargs
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.
1
2
3
4
5
6
7
8
class Servo:
def __init__(self, **kwargs):
self.pin = kwargs.get('pin') # Required, no default
self.name = kwargs.get('name') # Required, no default
self.range = kwargs.get('range', [0, 180]) # Optional with default value
self.id = kwargs.get('id', 0) # Optional with default value
self.start = kwargs.get('start_pos', 50) # Optional with default value
print(f"Initializing Servo {self.name} on pin {self.pin} with range {self.range} and start {self.start}")
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.
4. Simplified main.py
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.
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from module_loader import ModuleLoader
def main():
# Dynamically load and initialize modules
loader = ModuleLoader(config_folder="config")
module_instances = loader.load_modules()
# Access instances by name
vision = module_instances['vision'] # Access the instance by name
leg_servo = module_instances['Servo_leg_l_hip'] # Access one of multiple instances (the module name is prepended to the instance name in this case) )
if __name__ == '__main__':
main()
Interacting with your module via pubsub is also possible, and avoids adding business logic to the main.py file.
1
2
3
from pubsub import pub
pub.sendMessage('mytopic', data='somedata') # Publish to a topic
pub.subscribe(self.handler_method, 'anothertopic') # subscribe to another topic
This is the primary communication method between modules in the Modular Biped Project. A timing loop fires events at intervals, triggering functionality in the modules.
Writing a New Module for This Architecture
To add a new module to this architecture, follow these simple steps:
1. Create a Python Module
Create a new Python class for your module. Ensure that the __init__
method accepts **kwargs
for dynamic configuration.
Example: buzzer.py
1
2
3
4
5
6
7
8
9
class Buzzer:
def __init__(self, **kwargs):
self.pin = kwargs.get('pin') # Required, no default
print(f"Initializing Buzzer on pin {self.pin}")
pub.subscribe(self.buzz, 'buzz') # Subscribe to the buzz topic
def buzz(self):
# Buzzer functionality goes here
print(f"Buzzer on pin {self.pin} is buzzing!")
2. Define a YAML Configuration
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:
1
2
3
4
5
buzzer:
enabled: true
path: "modules.output.buzzer"
config:
pin: 27
3. Load the Module Dynamically
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.
Conclusion
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.
Key Benefits:
- Scalability: Easily add or remove modules without changing the core logic.
- Flexibility: Modules can accept any configuration parameters dynamically using
**kwargs
. - Maintainability: Centralized management of configuration via YAML files.
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.