diff --git a/docs/source/autodoc/engineer.rst b/docs/source/autodoc/engineer.rst new file mode 100644 index 00000000..56a90eb2 --- /dev/null +++ b/docs/source/autodoc/engineer.rst @@ -0,0 +1,8 @@ +``engineer`` - Chat command router +================================== + +.. currentmodule:: royalnet.engineer +.. automodule:: royalnet.engineer + :members: + :undoc-members: + :imported-members: diff --git a/docs/source/autodoc/index.rst b/docs/source/autodoc/index.rst index 02e0da8a..b498f537 100644 --- a/docs/source/autodoc/index.rst +++ b/docs/source/autodoc/index.rst @@ -10,6 +10,7 @@ It may be incomplete or outdated, as it is automatically updated. alchemist campaigns + engineer lazy scrolls types diff --git a/poetry.lock b/poetry.lock index beca6916..565a1829 100644 --- a/poetry.lock +++ b/poetry.lock @@ -150,6 +150,19 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "pydantic" +version = "1.7.3" +description = "Data validation and settings management using python 3.6 type hinting" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.extras] +dotenv = ["python-dotenv (>=0.10.4)"] +email = ["email-validator (>=1.0.3)"] +typing_extensions = ["typing-extensions (>=3.7.2)"] + [[package]] name = "pygments" version = "2.7.2" @@ -405,7 +418,7 @@ socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] [metadata] lock-version = "1.0" python-versions = "^3.8" -content-hash = "232cea18b2973090dfb3fe4215ef84e87abac749c8d0b50f556ec39610871cc5" +content-hash = "4363aefc0ea9322445ee375edce430712159829b5ca40561eea7cd74bbf0a7bd" [metadata.files] alabaster = [ @@ -503,6 +516,30 @@ py = [ {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, ] +pydantic = [ + {file = "pydantic-1.7.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c59ea046aea25be14dc22d69c97bee629e6d48d2b2ecb724d7fe8806bf5f61cd"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a4143c8d0c456a093387b96e0f5ee941a950992904d88bc816b4f0e72c9a0009"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:d8df4b9090b595511906fa48deda47af04e7d092318bfb291f4d45dfb6bb2127"}, + {file = "pydantic-1.7.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:514b473d264671a5c672dfb28bdfe1bf1afd390f6b206aa2ec9fed7fc592c48e"}, + {file = "pydantic-1.7.3-cp36-cp36m-win_amd64.whl", hash = "sha256:dba5c1f0a3aeea5083e75db9660935da90216f8a81b6d68e67f54e135ed5eb23"}, + {file = "pydantic-1.7.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:59e45f3b694b05a69032a0d603c32d453a23f0de80844fb14d55ab0c6c78ff2f"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:5b24e8a572e4b4c18f614004dda8c9f2c07328cb5b6e314d6e1bbd536cb1a6c1"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:b2b054d095b6431cdda2f852a6d2f0fdec77686b305c57961b4c5dd6d863bf3c"}, + {file = "pydantic-1.7.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:025bf13ce27990acc059d0c5be46f416fc9b293f45363b3d19855165fee1874f"}, + {file = "pydantic-1.7.3-cp37-cp37m-win_amd64.whl", hash = "sha256:6e3874aa7e8babd37b40c4504e3a94cc2023696ced5a0500949f3347664ff8e2"}, + {file = "pydantic-1.7.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e682f6442ebe4e50cb5e1cfde7dda6766fb586631c3e5569f6aa1951fd1a76ef"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:185e18134bec5ef43351149fe34fda4758e53d05bb8ea4d5928f0720997b79ef"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:f5b06f5099e163295b8ff5b1b71132ecf5866cc6e7f586d78d7d3fd6e8084608"}, + {file = "pydantic-1.7.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:24ca47365be2a5a3cc3f4a26dcc755bcdc9f0036f55dcedbd55663662ba145ec"}, + {file = "pydantic-1.7.3-cp38-cp38-win_amd64.whl", hash = "sha256:d1fe3f0df8ac0f3a9792666c69a7cd70530f329036426d06b4f899c025aca74e"}, + {file = "pydantic-1.7.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6864844b039805add62ebe8a8c676286340ba0c6d043ae5dea24114b82a319e"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ecb54491f98544c12c66ff3d15e701612fc388161fd455242447083350904730"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:ffd180ebd5dd2a9ac0da4e8b995c9c99e7c74c31f985ba090ee01d681b1c4b95"}, + {file = "pydantic-1.7.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8d72e814c7821125b16f1553124d12faba88e85405b0864328899aceaad7282b"}, + {file = "pydantic-1.7.3-cp39-cp39-win_amd64.whl", hash = "sha256:475f2fa134cf272d6631072554f845d0630907fce053926ff634cc6bc45bf1af"}, + {file = "pydantic-1.7.3-py3-none-any.whl", hash = "sha256:38be427ea01a78206bcaf9a56f835784afcba9e5b88fbdce33bbbfbcd7841229"}, + {file = "pydantic-1.7.3.tar.gz", hash = "sha256:213125b7e9e64713d16d988d10997dabc6a1f73f3991e1ff8e35ebb1409c7dc9"}, +] pygments = [ {file = "Pygments-2.7.2-py3-none-any.whl", hash = "sha256:88a0bbcd659fcb9573703957c6b9cff9fab7295e6e76db54c9d00ae42df32773"}, {file = "Pygments-2.7.2.tar.gz", hash = "sha256:381985fcc551eb9d37c52088a32914e00517e57f4a21609f48141ba08e193fa0"}, diff --git a/pyproject.toml b/pyproject.toml index 308d766f..2df57225 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ license = "AGPL-3.0-or-later" python = "^3.8" sqlalchemy = "^1.3.19" toml = "^0.10.1" +pydantic = "^1.7.3" [tool.poetry.dev-dependencies] pytest = "^6.1.1" diff --git a/royalnet/engineer/__init__.py b/royalnet/engineer/__init__.py new file mode 100644 index 00000000..b4d52257 --- /dev/null +++ b/royalnet/engineer/__init__.py @@ -0,0 +1,6 @@ +""" +A chatbot command router inspired by :mod:`fastapi`. +""" + +from .params import * +from .router import * diff --git a/royalnet/engineer/params.py b/royalnet/engineer/params.py new file mode 100644 index 00000000..178f8a8c --- /dev/null +++ b/royalnet/engineer/params.py @@ -0,0 +1,82 @@ +from royalnet.types import * +import pydantic +import inspect + + +class ModelConfig(pydantic.BaseConfig): + """ + A :mod:`pydantic` model config which allows for arbitrary types. + """ + arbitrary_types_allowed = True + + +def parameter_to_field(param: inspect.Parameter, **kwargs) -> Tuple[type, pydantic.fields.FieldInfo]: + """ + Convert a :class:`inspect.Parameter` to a type-field :class:`tuple`, which can be easily passed to + :func:`pydantic.create_model`. + + If the parameter is already a :class:`pydantic.FieldInfo` (created by :func:`pydantic.Field`), it will be + returned as the value, without creating a new model. + + :param param: The :class:`inspect.Parameter` to convert. + :param kwargs: Additional kwargs to pass to the field. + :return: A :class:`tuple`, where the first element is a :class:`type` and the second is a :class:`pydantic.Field`. + """ + if isinstance(param.default, pydantic.fields.FieldInfo): + return ( + param.annotation, + param.default + ) + else: + return ( + param.annotation, + pydantic.Field( + default=param.default if param.default else None, + title=param.name, + **kwargs, + ), + ) + + +def signature_to_model(f: Callable, __config__: pydantic.BaseConfig = ModelConfig, **extra_params): + """ + Convert the signature of a function to a pydantic model. + + Arguments starting with ``_`` are ignored. + + :param f: The function to use the signature of. + :param __config__: The config the pydantic model should use. + :param extra_params: Extra parameters to be added to the model. + :return: The created pydantic model. + """ + name: str = f.__name__ + signature: inspect.Signature = inspect.signature(f) + + params = {key: parameter_to_field(value) for key, value in signature.parameters if not key.startswith("_")} + + model: Type[pydantic.BaseModel] = pydantic.create_model(name, + __config__=ModelConfig, + **params, + **extra_params) + return model + + +def function_with_model(__config__: pydantic.BaseConfig = ModelConfig, **extra_params): + """ + A decorator that adds the property ``.model`` to the wrapped function. + + :param __config__: The config the pydantic model should use. + :param extra_params: Extra parameters to be added to the model. + """ + def decorator(f: Callable): + f.model = signature_to_model(f, __config__=__config__, **extra_params) + return f + return decorator + + +__all__ = ( + "ModelConfig", + "parameter_to_field", + "signature_to_model", + "function_with_model", +) diff --git a/royalnet/engineer/router.py b/royalnet/engineer/router.py new file mode 100644 index 00000000..ea24f093 --- /dev/null +++ b/royalnet/engineer/router.py @@ -0,0 +1,82 @@ +import functools +import pydantic +from royalnet.types import * +from . import params + + +class EngineerRouter: + """ + A class that handles the registration and call of commands and the validation of their parameters. + """ + + def __init__(self): + self.commands = {} + + def add_command(self, f: Callable, name: Optional[str]): + """ + Add a command to the router. + + :param name: The name of the command (``start``, ``settings``, etc). If not specified, it will use the name + of the wrapped function. + :param f: The function that should be executed when the command is called. It must have a ``.model`` property. + + .. seealso:: :meth:`.command`, :func:`.params.function_with_model` + """ + name = name if name else f.__name__ + f._model = params.signature_to_model(f) + self.commands[name] = f + + def command(self, name: Optional[str] = None): + """ + A decorator factory to add a command to the router. + + .. code-block:: python + + @command() + def ping(): + print("Pong!") + + .. code-block:: python + + @command(name="ping") + def xyzzy(): + print("Pong!") + + :param name: The name of the command (``start``, ``settings``, etc). If not specified, it will use the name + of the wrapped function. + :return: The decorated function. + + .. seealso:: :meth:`.add_command` + """ + + def decorator(f: Callable): + + @functools.wraps(f) + @params.function_with_model() + def decorated(*args, **kwargs): + return f(*args, **kwargs) + + self.add_command(f=decorated, name=name) + return decorated + + return decorator + + def call(self, __name: str, **kwargs): + model_params = {} + extra_params = {} + for key, value in kwargs.items(): + if key.startswith("_"): + extra_params[key] = value + else: + model_params[key] = value + + f = self.commands[__name] + # noinspection PyPep8Naming + Model: Type[pydantic.BaseModel] = f.model + model: pydantic.BaseModel = Model(**model_params) + return f(**model.dict(), **extra_params) + + +__all__ = ( + "EngineerRouter", +) diff --git a/royalnet/engineer/tests/__init__.py b/royalnet/engineer/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/royalnet/engineer/tests/test_params.py b/royalnet/engineer/tests/test_params.py new file mode 100644 index 00000000..705b841b --- /dev/null +++ b/royalnet/engineer/tests/test_params.py @@ -0,0 +1,40 @@ +import pytest +import inspect +import pydantic +import pydantic.fields +import royalnet.engineer as re + + +@pytest.fixture +def a_random_function(): + def f(big_f: str, _hidden: int) -> str: + return big_f + return f + + +def test_parameter_to_field(a_random_function): + signature = inspect.signature(a_random_function) + parameter = signature.parameters["big_f"] + fieldinfo = re.parameter_to_field(parameter) + assert isinstance(fieldinfo, pydantic.fields.FieldInfo) + assert fieldinfo.default == parameter.default == str + assert fieldinfo.title == parameter.name == "big_f" + + +def test_signature_to_model(a_random_function): + Model = re.signature_to_model(a_random_function) + assert callable(Model) + + model = Model(big_f="banana") + assert isinstance(model, pydantic.BaseModel) + assert model.big_f == "banana" + assert model.dict() == {"big_f": "banana"} + + with pytest.raises(pydantic.ValidationError): + Model() + + with pytest.raises(pydantic.ValidationError): + Model(big_f="exists", _hidden="no") + + with pytest.raises(pydantic.ValidationError): + Model(big_f=1) diff --git a/royalnet/engineer/tests/test_router.py b/royalnet/engineer/tests/test_router.py new file mode 100644 index 00000000..e69de29b