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.
models.py
andmain.py
both have 2 space indents and the other files 4, is this intentional \$\endgroup\$