mirror of
https://github.com/RYGhub/royalnet.git
synced 2024-11-22 19:14:20 +00:00
✨ Implement engineer module for commands routing
This commit is contained in:
parent
162049cb67
commit
7a6d65724e
10 changed files with 258 additions and 1 deletions
8
docs/source/autodoc/engineer.rst
Normal file
8
docs/source/autodoc/engineer.rst
Normal file
|
@ -0,0 +1,8 @@
|
|||
``engineer`` - Chat command router
|
||||
==================================
|
||||
|
||||
.. currentmodule:: royalnet.engineer
|
||||
.. automodule:: royalnet.engineer
|
||||
:members:
|
||||
:undoc-members:
|
||||
:imported-members:
|
|
@ -10,6 +10,7 @@ It may be incomplete or outdated, as it is automatically updated.
|
|||
|
||||
alchemist
|
||||
campaigns
|
||||
engineer
|
||||
lazy
|
||||
scrolls
|
||||
types
|
||||
|
|
39
poetry.lock
generated
39
poetry.lock
generated
|
@ -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"},
|
||||
|
|
|
@ -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"
|
||||
|
|
6
royalnet/engineer/__init__.py
Normal file
6
royalnet/engineer/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
"""
|
||||
A chatbot command router inspired by :mod:`fastapi`.
|
||||
"""
|
||||
|
||||
from .params import *
|
||||
from .router import *
|
82
royalnet/engineer/params.py
Normal file
82
royalnet/engineer/params.py
Normal file
|
@ -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",
|
||||
)
|
82
royalnet/engineer/router.py
Normal file
82
royalnet/engineer/router.py
Normal file
|
@ -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",
|
||||
)
|
0
royalnet/engineer/tests/__init__.py
Normal file
0
royalnet/engineer/tests/__init__.py
Normal file
40
royalnet/engineer/tests/test_params.py
Normal file
40
royalnet/engineer/tests/test_params.py
Normal file
|
@ -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)
|
0
royalnet/engineer/tests/test_router.py
Normal file
0
royalnet/engineer/tests/test_router.py
Normal file
Loading…
Reference in a new issue