diff options
| author | Romain Gonçalves <me@rgoncalves.se> | 2022-10-03 21:13:02 +0200 | 
|---|---|---|
| committer | Romain Gonçalves <me@rgoncalves.se> | 2022-10-03 21:13:02 +0200 | 
| commit | 447a74aa4150c44d948be9ee9b4e6a7f924d0738 (patch) | |
| tree | 3bf31077a555891987e5b6e05ad92f1f79347a26 | |
| download | pydanclick-trunk.tar.gz | |
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | pydanclick/__init__.py | 0 | ||||
| -rw-r--r-- | pydanclick/core.py | 154 | ||||
| -rw-r--r-- | pyproject.toml | 30 | ||||
| -rw-r--r-- | setup.cfg | 64 | 
5 files changed, 250 insertions, 0 deletions
| diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac8d7b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +dist diff --git a/pydanclick/__init__.py b/pydanclick/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/pydanclick/__init__.py diff --git a/pydanclick/core.py b/pydanclick/core.py new file mode 100644 index 0000000..406db22 --- /dev/null +++ b/pydanclick/core.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import click +import inspect + +from typing import Any +from pydantic import BaseModel, Field +from typing import Callable, Type + + +class Command(click.Command): +    """ +    Override of click.Command, specifically for pydantic integration. +    """ + +    def invoke(self, ctx: click.Context) -> Any: +        """ +        Callback with serialized click options to pydantic object(s). +        """ +        if self.callback is None: +            return + +        arguments: dict = {} +        for ( +            argument, +            parameter +        ) in get_processable_arguments(ctx.command.callback): +            arguments = ( +                arguments | {argument: parameter.annotation(**ctx.params)} +            ) + +        ctx.invoke(self.callback, **arguments) + + +class CliSchema(BaseModel): +    class DefinitionSchema(BaseModel): +        title: str +        description: str +        enum: list[str] + +    class PropertySchema(BaseModel): +        type: str +        title: None | str +        description: None | str +        minLength: None | int +        maxLength: None | int +        _ref: CliSchema.DefinitionSchema | None = Field(alias="$ref") + +        class Config: +            extra = "allow" + +    title: str +    properties: dict[str, CliSchema.PropertySchema] +    required: list[str] +    description: None | str +    definitions: None | dict[str, CliSchema.DefinitionSchema] + + +def get_signature_arguments( +    func: Callable +) -> list[tuple[str, inspect.Parameter]]: +    """ +    Return the arguments of a function. +    """ +    return [ +        parameter for parameter in inspect.signature(func).parameters.items() +    ] + + +def is_valid_schema_annotation(annotation: Type) -> bool: +    """ +    Filter an annotation with typing/pydantic implementation. +    """ +    return ( +        annotation != inspect._empty and issubclass(annotation, BaseModel) +    ) + + +def get_processable_arguments( +    func: Callable +) -> list[tuple[str, inspect.Parameter]]: +    """ +    Return the arguments of a function that supports pydantic. +    """ +    return list(filter( +        lambda argument: is_valid_schema_annotation(argument[1].annotation), +        get_signature_arguments(func) +    )) + + +def generate_cli_option( +    schema: CliSchema, +    title: str, +    parameter: CliSchema.PropertySchema, +    *, +    key_prefix: None | str = "" +) -> Callable: +    """ +    Generate the option information of a click.Command. +    """ +    is_required = True if title in schema.required else False +    is_flag = False +    option_type: None | Type = None +    option_title = title.lower().replace("_", "-") + +    # breakpoint() +    # option_min = parameter.minLength +    # option_max = parameter.maxLength +    # option_extra = {} + +    match parameter.type: +        case "integer": +            option_type = int +            # if option_min or option_max: +            #     option_type = click.IntRange(option_min, option_max) +        case "number": +            option_type = float +        case "string": +            option_type = str +        case "boolean": +            option_type = bool +            is_flag = True + +    return click.option( +        f"--{key_prefix}{option_title}", +        help=parameter.description, +        required=is_required, +        is_flag=is_flag, +        type=option_type, +        show_default=True, +        show_envvar=True, +        # **option_extra, +    ) + + +def generate_cli_options(key_prefix="o") -> Callable: +    """ +    Generate the option(s) of a click.Command from a pydantic schema. +    """ +    key_prefix = f"{key_prefix}-" if key_prefix is not None else "" + +    def _(function: Callable): +        for function_argument in get_processable_arguments(function): +            schema = CliSchema(**function_argument[1].annotation.schema()) +            properties = schema.properties.items() + +            for title, parameter in properties: +                function = generate_cli_option( +                    schema, title, parameter, key_prefix=key_prefix +                )(function) + +        return function + +    return _ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1744abd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[tool.poetry] +name = "pydanclick" +version = "0.1.0" +description = "Back and forth serialization with click and pydantic!" +authors = ["Romain Gonçalves <me@rgoncalves.se>"] +packages = [ +    { include = "pydanclick" }, +] + + +[tool.poetry.dependencies] +python = "^3.10" +pydantic = "^1.9.1" +typing-extensions = "^4.3.0" + +[tool.poetry.dev-dependencies] +ipython = "^8.4.0" +flake8 = "^4.0.1" +black = "^22.6.0" +isort = "^5.10.1" + +[tool.poetry.group.dev.dependencies] +mypy = "^0.981" + +[tool.mypy] +plugins = "pydantic.mypy" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8be44a6 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,64 @@ +[darglint] +strictness = short +docstring_style = google + +[flake8] +max-complexity = 10 +max-line-length = 79 +docstring-convention = google +select = +    # flake8-annotations +    ANN, +    # flake8-bugbear +    B, +    # flake8-bugbear +    B9, +    # flake8-black +    BLK, +    # mccabe +    C, +    # flake8-docstrings +    D, +    # darglint +    DAR, +    # pycodestyle +    E, +    # pyflakes +    F, +    # flake8-isort +    I, +    # flake8-bandit +    S, +    # pycodestyle +    W +ignore = +    # Missing type annotation for '*args' +    ANN002, +    # Missing type annotation for '**kwargs' +    ANN003, +    # Missing type annotation for 'self' in method. +    ANN101, +    # Missing type annotation for 'cls' in classmethod. +    ANN102, +    # Abstract base class with no abstract method. Remember to use +    # @abstractmethod, @abstractclassmethod, and/or +    # @abstractproperty decorators. +    B024, +    # Use r”“” if any backslashes in a docstring +    # (Click uses backslashes to format CLI help) +    D301, +    # Whitespace before ':' (does not work well with Black) +    E203, +    # Line too long (82 > 79 characters) +    # (Already checked by flake8-bugbear B950 with a margin of 10%) +    # https://github.com/PyCQA/flake8-bugbear#opinionated-warnings +    E501, +    # XML minidom insecure +    S408, +    # Probable insecure usage of temp file/directory. +    S108, +    # Line break occurred before a binary operator +    # (does not work well with Black) +    W503, +    # XML minidom insecure +    S408, |