Skip to content

Commit

Permalink
Create mypy plugin for improved type checking, update docs
Browse files Browse the repository at this point in the history
  • Loading branch information
sanjacob committed Jan 5, 2024
1 parent 2b455ce commit 73db311
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 15 deletions.
4 changes: 3 additions & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ To get started, see the :ref:`basics` first.

To learn how to test your own API clients, see the :doc:`testing`.

To learn how to use the mypy integration, see the :doc:`typing` section.

.. toctree::
:maxdepth: 2
:caption: Contents:

quick
typing
testing
typing
api_reference


Expand Down
72 changes: 58 additions & 14 deletions docs/typing.rst
Original file line number Diff line number Diff line change
@@ -1,27 +1,71 @@
Type Checking
=============

You should be able to use *mypy* and other type checkers to verify the
correctness of your code. The library itself is checked with the ``--strict``
flag.
Due to the way route parameters are declared with tiny-api-client,
type checking them would be impossible for tools like *mypy* without
knowledge of how this library works.
Therefore, it was necessary to go beyond ordinary type annotations.

For the most part, you should not have issues with type checking except when
passing keyword arguments to your endpoints. Unfortunately you will see the
following error.

mypy Plugin
-----------

As of version ``>=1.2.1``, the library ships with a mypy plugin that
can parse positional route parameters declared in your endpoints and
treat them as keyword-only optional string parameters.

Therefore, errors like this will be highlighted:

::

error: Unexpected keyword argument "arg" for "call" of "Client"
[call-arg]
@get('/users/{user_id}/')
def find_user(self, response: dict[str, str]) -> User:
...

This is due to inherent limitations with the typing spec as of Python 3.12,
and the fact that keyword arguments cannot be concatenated for type checking
purposes. For more information, see `pep`_ 612.
client.find_user(user_name="username")

.. _pep: https://peps.python.org/pep-0612/#concatenating-keyword-parameters
>>> error: Unexpected keyword argument "user_name" for "find_user"
of "MyClient" [call-arg]

Mitigations
-----------
.. note::
The mypy plugin is still in early development, and may not have all
expected features. Additional requests parameters in endpoint calls
are not supported yet.

To enable the plugin, add this to your pyproject.toml, or check the
`mypy_config`_ documentation if you are using a different file format.

::

[mypy]
plugins = ["tiny_api_client.mypy"]


.. _mypy_config: https://mypy.readthedocs.io/en/latest/config_file.html


Without Plugin
--------------

It is possible to type check an API client without the plugin, but be
warned that you won't have positional route parameter checking
whatsoever.

The major issue you will run into is the following:

::

client.my_call(my_arg="...")

error: Unexpected keyword argument "my_arg" for "my_call"
of "Client" [call-arg]

Due to inherent limitations with the typing spec as of Python 3.12, it
is not possible to add arbitrary keyword-only arguments to a decorated
function for type checking purposes. For more information, see
`pep`_ 612.

.. _pep: https://peps.python.org/pep-0612/#concatenating-keyword-parameters

One way around this is to include arbitrary keyword-only arguments in your
endpoint definition. This will let mypy know that the wrapper function can
Expand Down
104 changes: 104 additions & 0 deletions tiny_api_client/mypy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""
mypy plugin for tiny-api-client.
Please activate in your mypy configuration file.
"""

# Copyright (C) 2024, Jacob Sánchez Pérez

# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301 USA

import string
from collections.abc import Callable

from mypy.nodes import ARG_NAMED_OPT, StrExpr
from mypy.options import Options
from mypy.plugin import MethodContext, Plugin
from mypy.types import Type, CallableType


class TinyAPIClientPlugin(Plugin):
"""Companion mypy plugin for tiny-api-client.
Normally, it isn't possible to type check route parameters since
they are defined at runtime, and typing primitives are not capable
of introspection.
This plugin captures the route parameters of every endpoint and
modifies the decorated signature to include said parameters as if
they were factual ones.
"""
def __init__(self, options: Options) -> None:
self._ctx_cache: dict[str, list[str]] = {}
super().__init__(options)

def get_method_hook(self, fullname: str
) -> Callable[[MethodContext], Type] | None:
if fullname == "tiny_api_client.DecoratorFactory.__call__":
return self._factory_callback
if fullname == "tiny_api_client.RequestDecorator.__call__":
return self._decorator_callback
return None

def _factory_callback(self, ctx: MethodContext) -> Type:
"""Capture route positional params passed in decorator factory.
The route argument is captured and parsed for positional
parameters. These parameters are stored in a dictionary
with the line and column as its key.
The parameters are later retrieved in a subsequent call
to the returned decorator.
"""
if len(ctx.args) > 0:
pos = f"{ctx.context.line},{ctx.context.column}"
route_params = []
formatter = string.Formatter()
route = ctx.args[0][0]
assert isinstance(route, StrExpr)

for x in formatter.parse(route.value):
if x[1] is not None:
route_params.append(x[1])
self._ctx_cache[pos] = route_params
return ctx.default_return_type

def _decorator_callback(self, ctx: MethodContext) -> Type:
"""Append route positional parameters to function kw-only args.
The route parameters are retrieved from memory according to the
context, and they are included in the decorated function type
as optional keyword-only parameters.
"""
pos = f"{ctx.context.line},{ctx.context.column}"
default_ret = ctx.default_return_type
assert isinstance(default_ret, CallableType)

# Modify default return type in place (probably fine)
for p in self._ctx_cache[pos]:
default_ret.arg_types.append(
# Since the URL is a string, type of arguments
# should also be string
ctx.api.named_generic_type("builtins.str", [])
)
default_ret.arg_kinds.append(ARG_NAMED_OPT)
default_ret.arg_names.append(p)

return ctx.default_return_type


def plugin(version: str) -> type[Plugin]:
return TinyAPIClientPlugin

0 comments on commit 73db311

Please sign in to comment.