Search code examples
pythonoopdesign-patterns

What is the best design pattern when creating an interface for functions that can return a variety of responses?


I am writing parsers for GNU/Linux commands. For example:

from pydantic import BaseModel

from mmiac.core.system import run_cmd
from mmiac.core.parsing import create_key


class HostnamectlResponse(BaseModel):
    static_hostname: str
    transient_hostname: str | None
    pretty_hostname: str | None
    icon_name: str | None
    chassis: str | None
    deployment: str | None
    location: str | None
    virtualization: str | None
    cpe_os_name: str | None
    operating_system: str | None
    os_support_end: str | None
    os_support_expired: str | None
    os_support_remaining: str | None
    kernel: str | None
    machine_id: str | None
    boot_id: str | None
    kernel: str | None
    hardware_vendor: str | None
    hardware_model: str | None
    firmware_version: str | None
    firmware_date: str | None
    

def parse() -> dict:
    """Parses output from hostnamectl command.
    
    The only required field is static hostname. Output fields from hostnamectl can be found below:
    https://github.com/systemd/systemd/blob/main/src/hostname/hostnamectl.c
    """
    content = run_cmd("hostnamectl").split("\n")
    data = {}
    for line in content:
        try:
            k, v = line.strip().split(":", 1)
        except ValueError:
            continue
        k = create_key(k)
        data[k] = v.strip()
    return HostnamectlResponse(**data)

I was thinking about making each command parser a class, but at the moment I feel like a function suffices. I imagine I'll create some sort of dispatch table that dynamically loads modules in that have a parse() method defined then call the functions using that.

But anyway, my question is related to the response. I'd love to make it such that the response is generic, but unfortunately, ps output is much different than lspci (and so on). I'm curious if there is a design pattern that I can reference, or anything really, when the functions you create have a common interface (parse()) but lack a common response.

Beyond the question itself, I appreciate any input here with regards to the design of this. My goal here is to make using the library as intuitive as possible without having to constantly reference docs.


Solution

  • This seems to be a decent place for the Proxy pattern:

    1. The parser generates a dictionary containing the key-value pairs of the command output.
    2. Return a CommandResponseProxy (or whatever you want to call it) from the parser passing it the dictionary
    3. Access command output from an access method of the CommandResponseProxy
    4. Optionally provide a concrete class representing the dictionary

    Proxy is a structural design pattern that provides a surrogate that controls access to another object. The sections below provide more details if needed.

    #1. You seem to already have this

    #2. CommandResponseProxy

    Definition:

        class CommandResponseProxy:
            def __init__(self, command_response_dict):
                self._response = command_response_dict
        
            def __getitem__(self, key):
                # Additinal functionality or access control logic
                # For example, you can perform validation, transformations, or anything else
                value = self._response[key]
                return value
    

    Return from the parser:

        # return HostnamectlResponse(**data)
        return CommandResponseProxy(data)
    

    Notice the dictionary is passed directly to the proxy instead of unpacking it with **.

    #3. Access the command output via the proxy:

        for command_to_parse in available_commands_to_parse:
            # where command_to_parse = 'hostnamectl'
            response = parse(command_to_parse)
            print(response['chassis']) # desktop
    

    The CommandResponseProxy is a bit useless as written since its essentially just another dictionary but it provides room for other things such as validation, formatting, or anything else you wish to do with the output from within the proxy.

    #4. Optionally provide a concrete class representing the dictionary

        from typing import Type, TypeVar
        
        T = TypeVar("T")
        
        class CommandResponseProxy:
            #...
                
            def to(self, cls: Type[T]) -> T:
                return cls(**self._response)
    

    The proxy can provide a concrete class representing the command output using the typing library.

    It could be used like:

        # where command_to_parse = 'hostnamectl'
    
        # command response proxy
        response = parse(command_to_parse)
    
        # use a concrete class
        hostnamectlResponse = response.to(HostnamectlResponse)
        print(hostnamectlResponse.chassis) # desktop
    

    You may be able to simplify this depending on your use case. Combine these things into the parse function:

    def parse(command_to_parse, cls: Type[T]) -> T:
        # perform validation on the arguments
        # get command output
        # generate command output dictionary
    
        # where 'data' is the dictionary of command output:
        return cls(**data)
    

    Which could be used like:

        hostnamectlResponse = parse('hostnamectl', HostnamectlResponse)
        print(hostnamectlResponse.chassis) # desktop