diff options
-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, |