1
\$\begingroup\$

I was working on a small python CLI tool using the Click library for the first time and realized I was defining parameters and mappings from them to objects/classes in multiple places and anytime I wanted to make a change it was necessary then to go through and change code all over the place.

For this project a series of dataclasses are essentially first class citizens since they represent objects in external API. The focus of this code review is not how the tool interacts with the API but on how I'm using those dataclasses to automatically generate mappings from CLI parameters so that in the future I can simply add all the necessary dataclasses that match the api spec without having to manually configure the rest.

main.py (the most exciting):

from cli import cli

if __name__ == '__main__':
  cli()

cli.py <-- I want a better way to initially set ctx.obj but haven't done so yet

import models
from clickModelMapper import cmd_from_model
import click

@click.group()
@click.pass_context
def cli(ctx):
    ctx.obj = {}

@cmd_from_model(cli, models.SimpleModel)
def cmd(ctx):
    click.echo(ctx.obj['model'])

models.py

from dataclasses import dataclass

@dataclass
class SimpleModel:
  '''
  Model of a simple object in the external API

  Attributes
  ----------
  key: str
    key for simple obj
  value: str
    value for simple obj
  '''
  key: str = ''
  value: str = ''

clickModelMapper.py

from functools import wraps
from collections.abc import Callable
from formatAttributes import get_attr_descriptions
from dataclasses import fields
import click


def unwrap_bases(cls):
    bases = set()
    for base in cls.__bases__:
        if not base in __builtins__.values():
            bases.add(base)
            bases.update(unwrap_bases(base))

    return bases


def instantiate_model(func: Callable, model: Callable):

    @wraps(func)
    def wrapper(**kwargs):
        ctx = click.get_current_context()
        ctx.obj['model'] = model(**kwargs)
        return func(ctx)

    return wrapper


def cmd_from_model(group: click.Group, model):
    data_attributes = get_attr_descriptions(model)

    for base in unwrap_bases(model):
        try:
            data_attributes |= get_attr_descriptions(base)
        except Exception as e:
            print(e)
            data_attributes = {}

    def map_dataclass_func(func):
        func = instantiate_model(func, model)
        command = click.command(name=func.__name__)(func)
        group.add_command(command)

        for field in fields(model):
            try:
                command = click.option(f'--{field.name}',
                                       help=data_attributes[field.name],
                                       type=field.type)(command)
            except Exception as e:
                print(f"Exception: {str(e)}")
                command = click.option(f'--{field.name}')(command)

        return command

    return map_dataclass_func

formatAttributes.py <-- get_attr_descriptions could be generalized to other doc string formats but works for now

import inspect
import re

def format_attrs(attrs, expected_type=None, **kwargs):
    if expected_type is None:
        return attrs

    try:
        return [
            expected_type(key, value, **kwargs)
            for key, value in attrs.items()
        ]

    except AttributeError:
        try:
            return [expected_type(item, **kwargs) for item in attrs]

        except TypeError:
            return attrs

def get_attr_descriptions(cls):
    attribute_section = re.split('Attributes\n\s*-+', inspect.getdoc(cls))[-1]

    lines = [
        line.strip() for line in attribute_section.split('\n') if line.strip()
    ]

    def parse_attr(line: str):
        return re.split(':', line, maxsplit=1)[0]

    attributes = {
        parse_attr(lines[i]): lines[i + 1] if i + 1 < len(lines) else ''
        for i in range(0, len(lines), 2)
    }

    return attributes

Within the actual cmd commands I'm using a separate APIManager class to actually interact with the api that is more complex but independent from this code, it just expects dataclasses that implement payload methods to appropriately form json objects per the api spec which my real models do. Similarly the format_attrs doesn't have much use in this example but represents the basic logic some more complex and nested objects need.

\$\endgroup\$
3
  • \$\begingroup\$ Are you mixing 2 and 4 spaces for indents in your actual code or is that just an issue with how it has pasted on this website? Your models.py and main.py both have 2 space indents and the other files 4, is this intentional \$\endgroup\$
    – JamesT
    Jul 2, 2023 at 1:02
  • \$\begingroup\$ It's probably in the actual code right now but not intentional, I have a couple computers and guessing formatting between the environments got messed up and "fixed" separately for different files \$\endgroup\$
    – Coupcoup
    Jul 2, 2023 at 1:05
  • 3
    \$\begingroup\$ Just run black -S *.py and it's fixed. (Also, you may want to adjust some editor configs.) \$\endgroup\$
    – J_H
    Jul 2, 2023 at 2:05

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.