Mastering kwargs In Python

Posted by Ibrahim Cikrikcioglu on December 31, 2023 · 19 mins read

Recently, I have been working on refactoring a pretty complex repository for a ML usecase. It has a complex configuration system, multiple applications reading from the central configuration and extensive usage of inheritance. Especially, the modeling part is quite diverse. The inference application needs to take care of all those different models with a generic and flexible code design. Under such complex projects, it is very important to strike a balance between readibility and flexibility. In general, clear and explicit code is often prized for its maintainability and readability. However, there are scenarios where flexibility is equally important, and this is where **kwargs play a crucial role. It allows functions and methods to accept an arbitrary number of arguments, offering a level of flexibility that can be very beneficial in certain situations. In this post, I will explore various scenarios where **kwargs can enhance your classes and code structures, ranging from inheritance and futureproofing to factory patterns and argument forwarding.

Scenario 1: Allowing for Optional Arguments of Base Class upon Child Initialization

When dealing with class hierarchies, it’s often necessary to pass arguments from a child class to its parent class. Using **kwargs, we can easily pass optional arguments to the base class without the child class having to be explicitly aware of these parameters. This approach enhances code maintainability and adaptability to changes in the base class.

Consider a base class Preprocessor that performs some preprocessing with optional filters:

class Preprocessor:
    def __init__(self, format, validate=True):
        self.format = format
        self.validate = validate

    def preprocess(self, data):
        if self.validate:
            self._validate_data(data)
        # Preprocessing logic
        print(f"Preprocessing data with format {self.format}")

    def _validate_data(self, data):
        # Validation logic
        print("Validating data")

Now, let’s create a child class ImageProcessor that extends Preprocessor and adds additional functionality like image filters. The child class needs to pass the optional validate parameter to the base class:

class ImageProcessor(Preprocessor):
    def __init__(self, format, filters, enhance=True, **kwargs):
        super().__init__(format, **kwargs)
        self.filters = filters
        self.enhance = enhance
        
    def preprocess(self, data):
        data = super().preprocess(data)
        data = self.apply_filter(data)
        if self.enhance:
            print("Adding some enhancing logic here")
        return data

    def apply_filter(self, data):
        for f in self.filters:
            data = f(data)
        return data

By using **kwargs in the ImageProcessor constructor, we can flexibly initialize the object with any optional arguments defined in the Preprocessor class:

image_processor = ImageProcessor("csv", ["brightness", "contrast"], enhance=False, validate=False)

With this approach, the child class ImageProcessor can be initialized with the validate flag set to either True or False, showcasing the flexibility afforded by **kwargs.

Scenario 2: Building Futureproof Classes

A key advantage of using **kwargs is the ability to future-proof your classes. By including **kwargs in the constructor of a base class, you open the door to adding new features and settings without disrupting existing code. This approach is especially valuable in scenarios where your classes are a part of a library or framework used by others, as it ensures backward compatibility.

Initially, consider a Chart class with basic functionality, but without **kwargs:

class Chart:
    def __init__(self, title, data_source):
        self.title = title
        self.data_source = data_source

    def draw(self):
        # Basic drawing logic
        print(f"Drawing {self.title} chart with data from {self.data_source}")

Now, let’s say we develop a subclass BarChart:

class BarChart(Chart):
    def __init__(self, title, data_source, bar_width, bar_color):
        super().__init__(title, data_source)
        self.bar_width = bar_width
        self.bar_color = bar_color

    def draw(self):
        # Specialized drawing logic for bar chart
        print(f"Drawing Bar Chart: {self.title}")
        print(f"Width: {self.bar_width}, Color: {self.bar_color}")

As the codebase evolves, we decide to add a new feature, such as a background color. Without **kwargs, updating the Chart class and all its subclasses can lead to compatibility issues:

class Chart:
    def __init__(self, title, data_source, background_color):
        self.title = title
        self.data_source = data_source
        self.background_color = background_color

    def draw(self):
        print(f"Drawing {self.title} chart on {self.background_color} background with data from {self.data_source}")

The BarChart class must also be updated, which can break existing code that uses the older API:

class BarChart(Chart):
    def __init__(self, title, data_source, bar_width, bar_color, background_color):
        super().__init__(title, data_source, background_color)
        self.bar_width = bar_width
        self.bar_color = bar_color

In the absence of **kwargs, we need to update the function signature, which prevents the existing users from using the new feature unless they refactor their code. However, with **kwargs, we can elegantly solve this issue. By including **kwargs in the Chart class’s constructor, we can add new features without changing the function signature:

class Chart:
    def __init__(self, title, data_source, **kwargs):
        self.title = title
        self.data_source = data_source
        self.chart_settings = kwargs  # Store additional settings

    def draw(self):
        print(f"Drawing {self.title} chart with data from {self.data_source}")
        print("Additional settings:", self.chart_settings)

The BarChart class, with its own specific settings and those passed through **kwargs, becomes:

class BarChart(Chart):
    def __init__(self, title, data_source, bar_width, bar_color, **kwargs):
        super().__init__(title, data_source, **kwargs)
        self.bar_width = bar_width
        self.bar_color = bar_color

    def draw(self):
        print(f"Drawing Bar Chart: {self.title}")
        print(f"Width: {self.bar_width}, Color: {self.bar_color}")
        if 'background_color' in self.chart_settings:
            print(f"Modify background color with {self.chart_settings['background_color']} ")

This design allows for a flexible and backward-compatible approach to class evolution. New features can be added without breaking existing implementations, and the **kwargs mechanism ensures that subclasses can adapt to changes in the base class without requiring code modifications.

Scenario 3: Creating Factory Functions with **kwargs

The use of **kwargs is particularly beneficial in factory patterns, a common design pattern in software engineering, especially in scenarios like machine learning where different models require different initialization parameters. Utilizing **kwargs in such patterns allows for a single, unified interface to create diverse objects, each with its unique configuration, enhancing flexibility and code reusability.

Consider an example where we are building a machine learning system that requires various models, each with distinct initialization parameters:

from sklearn.neural_network import MLPClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.svm import SVC

def create_model(model_type, **kwargs):
    if model_type == 'neural_network':
        return MLPClassifier(**kwargs)
    elif model_type == 'decision_tree':
        return DecisionTreeClassifier(**kwargs)
    elif model_type == 'svm':
        return SVC(**kwargs)
    else:
        raise ValueError("Unknown model type")

# Creating different models with specific parameters
neural_network = create_model('neural_network', hidden_layer_sizes=(100,), activation='relu', max_iter=300)
decision_tree = create_model('decision_tree', max_depth=5, criterion='entropy')
svm_model = create_model('svm', C=1.0, kernel='linear')
  • Flexibility in Model Creation: This approach allows us to instantiate various machine learning models with different parameters through a single function, create_model.
  • Ease of Extension: Adding new model types to the factory function is straightforward, which is beneficial as new algorithms and techniques are introduced.
  • Centralized Model Management: Centralizing model creation logic in one place makes the codebase cleaner, more maintainable, and easier to understand, especially in larger projects.

This scenario showcases how **kwargs facilitates the creation of a flexible and versatile factory function, capable of handling a wide range of object types and configurations. Such an approach is highly beneficial in fields like machine learning, where the diversity of models and their configurations is vast.

Scenario 4: Argument Forwarding with **kwargs

In Python, one of the practical uses of **kwargs is in argument forwarding. This technique is particularly useful when a function or method acts as a wrapper or forwarder to another function, simplifying the propagation of arguments. It’s especially handy when the wrapper function doesn’t need to handle those arguments directly.

Let’s illustrate this with a simple example. Suppose we have a basic function process_data, which accepts various parameters.

def process_data(data, format="JSON", optimize=False):
    # Processing logic
    print(f"Processing {data} as {format}, optimize={optimize}")
    # Return some result
    return "processed_data"

Now, imagine we want to create a wrapper function that logs the call details but doesn’t modify the original function’s signature.

Wrapper Function Using **kwargs for Argument Forwarding

The wrapper function logging_wrapper can log the details and forward the arguments to the process_data function without needing to know the specifics of its parameters.

def logging_wrapper(*args, **kwargs):
    print(f"Calling process_data with args: {args}, kwargs: {kwargs}")
    return process_data(*args, **kwargs)

# Using the wrapper function
result = logging_wrapper("my_data", format="XML", optimize=True)
  • Simplified Argument Propagation: logging_wrapper can handle any arguments meant for process_data, making it versatile and adaptable.
  • Flexibility: This method allows the wrapper to be used with any function that has a similar signature to process_data.
  • Non-intrusive Design: process_data remains unchanged, and additional functionality is added without modifying the original behavior.

This example demonstrates how **kwargs can be effectively utilized for argument forwarding in wrapper functions. It’s a prevalent pattern in situations where additional functionality, like logging or preprocessing, needs to be added to existing functions without changing their signatures. The use of **kwargs maintains the cleanliness and flexibility of the code, making it a valuable tool in the Python programmer’s toolkit.

Use kwargs Carefully

Avoid Overuse and Misuse: While **kwargs provides flexibility, its overuse can lead to code that is hard to understand and debug. It’s important to use **kwargs prudently. Over-reliance on **kwargs can obscure what your function or class actually requires or accepts, making the code less intuitive and more difficult for others (or even yourself at a later time) to work with.

Explicit is Better Than Implicit: In line with the Zen of Python, being explicit about what your functions and methods expect can often be more beneficial than using **kwargs. While **kwargs is useful for certain situations, explicitly defining parameters makes the code more readable and easier to understand.

I suggest using it with type hinting. This way, we can specify the type of arguments our functions are expected to receive. This can greatly enhance the readibility.

from typing import TypedDict
from typing import List

class ChartSettings(TypedDict, total=False):
    title: str
    data_source: str
    background_color: str
    bar_width: int


class Chart:
    def __init__(self, title: str, data: List[float], **kwargs: ChartSettings):
        self.title = title
        self.data = data
        self.settings = kwargs
        

By using TypedDict, we explicitly specify the allowed optional arguments. Also, total=False parameter in TypedDict indicates that not all keys are required, making it flexible for optional settings.

Conclusion

In this blog, we had a deep dive on the use of kwargs, exploring its utility and best practices. Through various scenarios, from enhancing class inheritance to designing futureproof and adaptable classes, we’ve seen how kwargs can significantly simplify and streamline our code. Note that these are merely the scenarios that I find particularly helpful. You are more than welcomed to share your own scenarios and usecases where kwargs is a game changer!