summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorRomain Gonçalves <me@rgoncalves.se>2022-10-03 21:13:02 +0200
committerRomain Gonçalves <me@rgoncalves.se>2022-10-03 21:13:02 +0200
commit447a74aa4150c44d948be9ee9b4e6a7f924d0738 (patch)
tree3bf31077a555891987e5b6e05ad92f1f79347a26
downloadpydanclick-trunk.tar.gz
wip: initHEADtrunk
-rw-r--r--.gitignore2
-rw-r--r--pydanclick/__init__.py0
-rw-r--r--pydanclick/core.py154
-rw-r--r--pyproject.toml30
-rw-r--r--setup.cfg64
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,
remember that computers suck.