This content originally appeared on DEV Community and was authored by Konstantinos Andreou
Introduction
When building modular Python applications, you often need a way to automatically discover and load classes without manually updating imports every time a new feature is added. Think of plugin systems, task runners, or contract/submission workflows — adding a new class should just work without touching the loader logic.
In this tutorial, I’ll walk you through a utility class I built — BaseDynamicLoader
— that scans a given module, discovers all subclasses of a target base class, and makes them available for use in your application. This approach eliminates repetitive boilerplate and enables your code to scale dynamically as new modules and classes are introduced.
We’ll break down how the loader works, why it’s useful, and how you can extend it to fit your own project.
Prerequisites
Before diving in, make sure you’re comfortable with:
- Python modules and imports (importlib)
- Object-oriented programming concepts (inheritance, subclasses)
- Basic file system traversal (os.walk)
The Problem to Solve
Let’s say we have a growing number of subclasses spread across multiple files. Each time we add a new class normally we would have to update a central registry or import list. Instead, you want your system to discover classes automatically and make them available.
Consider the following project structure for our example. We will explain each file in detail later on.
app
├── classes
│ ├── __init__.py
│ ├── dynamicLoader.py
│ ├── animal.py
│ └── animalLoader.py
├── animals
│ ├── __init__.py
│ ├── dog.py
│ ├── cat.py
│ ├── cow.py
│ └── # ...other animals can be added here
└── main.py
- This project implements a number of animals each in a different file, inheriting from the base class
Animal
. This is a simple base class that defines common properties and methods for all animals:
# app/classes/animal.py
class Animal:
def __init__(self, name: str, species: str):
self.name = name
self.species = species
self.sound = None
def make_sound(self):
if self.sound:
print(f"{self.name} the {self.species} says {self.sound}")
- Now let’s implement the different animals. I will not write here the full code for each animal, as this isn’t the purpose of the post but you can follow the same pattern as the
Dog
class.
# app/animals/dog.py
from app.classes.animal import Animal
class Dog(Animal):
def __init__(self, name: str):
super().__init__(name, species="Dog")
self.sound = "Bark"
Now it’s time that we dive into the implementation of the dynamic loader:
The BaseDynamicLoader
Class
The BaseDynamicLoader
class provides a reusable framework for automatically discovering and loading subclasses of a given base class within a specified module. It works by first importing the target module (defined as the MODULE
attribute in subclasses) and then scanning all of its Python files using os.walk
. For each file, it dynamically imports the module with importlib.import_module
, inspects its contents, and checks whether any defined objects are subclasses of the target base class (class_to_find
, which in this example is bound to Animal
). If a valid subclass is found, it is added to an internal dictionary _found_classes
, keyed by the class name and mapped to the class object itself. Logging is integrated throughout the process to provide visibility into which classes were found and loaded, making debugging easier. By encapsulating this discovery mechanism in a single class, you eliminate the need to manually import and register new subclasses every time the codebase grows, turning your application into a more extensible and plugin-friendly system.
# app/classes/dynamicLoader.py
import importlib, os
from typing import List, Dict, TypeVar
import logging
from app.classes.animal import Animal
logger = logging.getLogger()
T = TypeVar("T", bound=Animal)
class BaseDynamicLoader:
MODULE: str
def __init__(self, class_to_find: T):
self.module = importlib.import_module(self.MODULE)
self.class_to_find = class_to_find
self._found_classes = self.__dynamic_class_loader()
def __dynamic_class_loader(self) -> Dict[str, T]:
"""
Dynamically load all classes classes from the module.
"""
found_classes: Dict[str, T] = {}
logger.info(
f"{self.__class__.__name__}: Loading {self.class_to_find.__name__} classes from <{self.MODULE}>"
)
root_dir = self.module.__path__[0]
for root, _, files in os.walk(root_dir):
for file in files:
# Check if the file is a Python file and not a special file
if file.endswith(".py") and not file.startswith("__"):
mod_name = os.path.splitext(file)[0]
module = importlib.import_module(f"{self.MODULE}.{mod_name}")
for cls in dir(module):
# Check if the class is a subclass of class_to_find
if (
isinstance(getattr(module, cls), type)
and issubclass(getattr(module, cls), self.class_to_find)
and getattr(module, cls) is not self.class_to_find
):
# Check if the class is a class_to_find
logger.info(
f"{self.__class__.__name__}: Found {self.class_to_find.__name__} class <{cls}> in <{file}>"
)
found_classes[cls] = getattr(module, cls)
return found_classes
How the Loader Works (Step by Step)
-
Initialize the Loader
- You create a subclass of BaseDynamicLoader and define the MODULE you want to scan.
- You pass in the base class you’re interested in (e.g., Animal).
-
Import the Target Module
- The loader imports the module dynamically using
importlib.import_module(self.MODULE)
.
- The loader imports the module dynamically using
-
Walk Through the Module’s Files
- It traverses the directory where the module lives with
os.walk
, looking for.py
files that are not special files (__init__.py
, etc.).
- It traverses the directory where the module lives with
-
Import Each Submodule
- For every Python file, it imports the submodule dynamically.
-
Inspect Classes
- It uses
dir(module)
to look at every attribute in the submodule. - If the attribute is a class and it’s a subclass of the target base class (but not the base class itself), it’s a match.
- It uses
-
Register the Class
- Matching classes are stored in
_found_classes
, a dictionary mapping the class name to the class object.
- Matching classes are stored in
-
Log the Process
- Throughout the process, the loader logs what it is scanning and which subclasses were found.
Using the BaseDynamicLoader
class to implement our loader
Now, what we need to do is create a concrete implementation of the BaseDynamicLoader
class for our specific use case. This involves defining the MODULE
attribute and the base class we want to find subclasses of.
Here’s how we can do it:
# app/classes/animalLoader.py
from app.classes.dynamicLoader import BaseDynamicLoader
from app.classes.animal import Animal
from typing import List
class AnimalLoader(BaseDynamicLoader):
MODULE = "app.animals"
def __init__(self):
super().__init__(class_to_find=Animal)
@property
def animals(self) -> List[type[Animal]]:
return [cls for cls in self._found_classes.values()]
The AnimalLoader
class is a concrete implementation of the generic BaseDynamicLoader
. Its role is to specialize the base loader so that it specifically discovers all subclasses of the Animal
class inside the app.animals
module. By defining the MODULE
attribute as "app.animals"
, the loader knows exactly where to look for new classes. In the constructor (__init__
), it calls super().__init__(class_to_find=Animal)
, which initializes the base class logic and starts the discovery process, searching for all subclasses of Animal
.
Additionally, AnimalLoader
exposes a convenient animals
property, which returns a list of all the discovered subclasses as class objects. This makes it easy to iterate over them, instantiate them, or otherwise integrate them into the application without having to interact directly with the internal _found_classes
dictionary.
In practice, this means you can simply drop a new subclass of Animal into the app/animals/
folder—for example, Dog, Cat, or Bird—and the AnimalLoader
will automatically find and load it. You never have to update your loader or maintain a manual registry. This keeps your application modular, extensible, and easy to maintain as new animals (or plugins, in other contexts) are introduced.
Loader usage
# main.py
from app.classes.animalLoader import AnimalLoader
loader = AnimalLoader()
for i, animal in enumerate(loader.animals):
animal_instance = animal(f"Buddy {i}")
animal_instance.make_sound()
Running the above snippet will produce the following output:
Buddy 0 the Cat says Meow
Buddy 1 the Cow says Moo
Buddy 2 the Dog says Bark
This content originally appeared on DEV Community and was authored by Konstantinos Andreou