Skip to content

Commit

Permalink
Merge pull request #3 from dan-hipschman-od/refactor2
Browse files Browse the repository at this point in the history
Rename ServiceInterceptor and add status_on_unknown_exception to ExceptionToStatusInterceptor
  • Loading branch information
d5h committed Jul 23, 2020
2 parents bdbc55b + a31ed97 commit 9c92d49
Show file tree
Hide file tree
Showing 18 changed files with 148 additions and 56 deletions.
1 change: 1 addition & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[flake8]
select = B,B9,C,D,E,F,I,S,W
exclude = *_pb2.py,*_pb2_grpc.py
ignore = D107,W503

application-import-names = grpc_interceptor,tests
import-order-style = google
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Coverage
on: push
on: [push, pull_request]
jobs:
coverage:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Tests
on: push
on: [push, pull_request]
jobs:
tests:
strategy:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ $ pip install grpc-interceptor[testing]
To define your own interceptor (we can use `ExceptionToStatusInterceptor` as an example):

```python
from grpc_interceptor.base import Interceptor
from grpc_interceptor.base import ServiceInterceptor

class ExceptionToStatusInterceptor(Interceptor):
class ExceptionToStatusInterceptor(ServiceInterceptor):
def intercept(
self,
method: Callable,
Expand Down
16 changes: 16 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
"""Sphinx configuration."""

import re


project = "grpc-interceptor"
author = "Dan Hipschman"
copyright = f"2020, {author}"
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.napoleon",
]


def setup(app):
"""Sphinx setup."""
app.connect("autodoc-skip-member", skip_member)


def skip_member(app, what, name, obj, skip, options):
"""Ignore ugly auto-generated doc strings from namedtuple."""
doc = getattr(obj, "__doc__", "") or "" # Handle when __doc__ is missing on None
is_namedtuple_docstring = bool(re.fullmatch("Alias for field number [0-9]+", doc))
return is_namedtuple_docstring or skip
9 changes: 5 additions & 4 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ dependencies to a minimum. The only core dependency is the ``grpc`` package, and

The ``grpc_interceptor`` package provides the following:

* An ``Interceptor`` base class, to make it easy to define your own service interceptors.
* A ``ServiceInterceptor`` base class, to make it easy to define your own service interceptors.
* An ``ExceptionToStatusInterceptor`` interceptor, so your service can raise exceptions
that set the gRPC status code correctly (rather than the default of every exception
resulting in an ``UNKNOWN`` status code). This is something for which pretty much any
Expand All @@ -50,13 +50,14 @@ To also install the testing framework:
Usage
-----

To define your own interceptor (we can use ``ExceptionToStatusInterceptor`` as an example):
To define your own interceptor (we can use a simplified version of
``ExceptionToStatusInterceptor`` as an example):

.. code-block:: python
from grpc_interceptor.base import Interceptor
class ExceptionToStatusInterceptor(Interceptor):
class ExceptionToStatusInterceptor(ServiceInterceptor):
def intercept(
self,
Expand Down Expand Up @@ -161,5 +162,5 @@ Limitations
These are the current limitations, although supporting these is possible. Contributions
or requests are welcome.

* ``Interceptor`` currently only supports unary-unary RPCs.
* ``ServiceInterceptor`` currently only supports unary-unary RPCs.
* The package only provides service interceptors.
6 changes: 6 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ grpc_interceptor.exceptions

.. automodule:: grpc_interceptor.exceptions
:members:

grpc_interceptor.testing
------------------------------------

.. automodule:: grpc_interceptor.testing
:members:
11 changes: 10 additions & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import nox


nox.options.sessions = "lint", "mypy", "safety", "tests"
nox.options.sessions = "lint", "mypy", "safety", "tests", "xdoctest"


@nox.session(python=["3.8", "3.7", "3.6"])
Expand All @@ -22,6 +22,15 @@ def tests(session):
session.run("pytest", *args)


@nox.session(python=["3.8", "3.7", "3.6"])
def xdoctest(session) -> None:
"""Run examples with xdoctest."""
args = session.posargs or ["all"]
session.run("poetry", "install", "--no-dev", external=True)
install_with_constraints(session, "xdoctest")
session.run("python", "-m", "xdoctest", "grpc_interceptor", *args)


@nox.session(python="3.8")
def coverage(session):
"""Upload coverage data."""
Expand Down
22 changes: 21 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "grpc-interceptor"
version = "0.9.0"
version = "0.10.0"
description = "Simplifies gRPC interceptors"
license = "MIT"
readme = "README.md"
Expand All @@ -13,7 +13,7 @@ documentation = "https://grpc-interceptor.readthedocs.io"
[tool.poetry.dependencies]
python = "^3.6"
grpcio = "^1.8.0"
protobuf = {version = "^3.6.0", optional = true}
protobuf = {version = ">=3.6.0", optional = true}

[tool.poetry.extras]
testing = ["protobuf"]
Expand All @@ -36,6 +36,7 @@ mypy-protobuf = "^1.23"
flake8-docstrings = "^1.5.0"
sphinx = "^3.1.2"
codecov = "^2.1.8"
xdoctest = "^0.13.0"

[tool.coverage.paths]
source = ["src"]
Expand Down
14 changes: 7 additions & 7 deletions src/grpc_interceptor/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import grpc


class Interceptor(grpc.ServerInterceptor, metaclass=abc.ABCMeta):
class ServiceInterceptor(grpc.ServerInterceptor, metaclass=abc.ABCMeta):
"""Base class for server-side interceptors.
To implement an interceptor, subclass this class and override the intercept method.
Expand Down Expand Up @@ -43,13 +43,13 @@ def intercept(
def intercept_service(self, continuation, handler_call_details):
"""Implementation of grpc.ServerInterceptor.
This is not part of the Interceptor API, but must have a public name. Do not
override it, unless you know what you're doing.
This is not part of the ServiceInterceptor API, but must have a public name.
Do not override it, unless you know what you're doing.
"""
next_handler = continuation(handler_call_details)
# Make sure it's unary_unary:
if next_handler.request_streaming or next_handler.response_streaming:
raise ValueError("Interceptor only handles unary_unary")
raise ValueError("ServiceInterceptor only handles unary_unary")

def invoke_intercept_method(request, context):
next_interceptor_or_implementation = next_handler.unary_unary
Expand Down Expand Up @@ -90,7 +90,7 @@ def fully_qualified_service(self):
Example:
>>> MethodName("foo.bar", "SearchService", "Search").fully_qualified_service
"foo.bar.SearchService"
'foo.bar.SearchService'
"""
return f"{self.package}.{self.service}" if self.package else self.service

Expand All @@ -100,14 +100,14 @@ def parse_method_name(method_name: str) -> MethodName:
Arguments:
method_name: A string of the form "/foo.bar.SearchService/Search", as passed to
Interceptor.intercept().
ServiceInterceptor.intercept().
Returns:
A MethodName object.
Example:
>>> parse_method_name("/foo.bar.SearchService/Search")
MethodName(package="foo.bar", service="SearchService", method="Search")
MethodName(package='foo.bar', service='SearchService', method='Search')
"""
_, package_and_service, method = method_name.split("/")
*maybe_package, service = package_and_service.rsplit(".", maxsplit=1)
Expand Down
27 changes: 24 additions & 3 deletions src/grpc_interceptor/exception_to_status.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
"""ExceptionToStatusInterceptor catches GrpcException and sets the gRPC context."""

from typing import Any, Callable
from typing import Any, Callable, Optional

import grpc

from grpc_interceptor.base import Interceptor
from grpc_interceptor.base import ServiceInterceptor
from grpc_interceptor.exceptions import GrpcException


class ExceptionToStatusInterceptor(Interceptor):
class ExceptionToStatusInterceptor(ServiceInterceptor):
"""An interceptor that catches exceptions and sets the RPC status and details.
ExceptionToStatusInterceptor will catch any subclass of GrpcException and set the
status code and details on the gRPC context.
Args:
status_on_unknown_exception: Specify what to do if an exception which is
not a subclass of GrpcException is raised. If None, do nothing (by
default, grpc will set the status to UNKNOWN). If not None, then the
status code will be set to this value. It must not be OK. The details
will be set to the value of repr(e), where e is the exception. In any
case, the exception will be propagated.
Raises:
ValueError: If status_code is OK.
"""

def __init__(self, status_on_unknown_exception: Optional[grpc.StatusCode] = None):
if status_on_unknown_exception == grpc.StatusCode.OK:
raise ValueError("The status code for unknown exceptions cannot be OK")
self._status_on_unknown_exception = status_on_unknown_exception

def intercept(
self,
method: Callable,
Expand All @@ -29,3 +45,8 @@ def intercept(
context.set_code(e.status_code)
context.set_details(e.details)
raise
except Exception as e:
if self._status_on_unknown_exception is not None:
context.set_code(self._status_on_unknown_exception)
context.set_details(repr(e))
raise
19 changes: 8 additions & 11 deletions src/grpc_interceptor/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ class GrpcException(Exception):
subclasses of GrpcException. Must not be OK, because gRPC will not
raise an RpcError to the client if the status code is OK.
details: A string with additional informantion about the error.
Args:
details: If not None, specifies a custom error message.
status_code: If not None, sets the status code.
Raises:
ValueError: If status_code is OK.
"""

status_code: StatusCode = StatusCode.UNKNOWN
Expand All @@ -30,15 +36,6 @@ class GrpcException(Exception):
def __init__(
self, details: Optional[str] = None, status_code: Optional[StatusCode] = None
):
"""Optionally override the status code and details.
Args:
details: If not None, specifies a custom error message.
status_code: If not None, sets the status code.
Raises:
ValueError: If status_code is OK.
"""
if status_code is not None:
if status_code == StatusCode.OK:
raise ValueError("The status code for an exception cannot be OK")
Expand All @@ -64,8 +61,8 @@ def status_string(self):
The status code as a string.
Example:
GrpcException(status_code=StatusCode.NOT_FOUND).status_string
>>> "NOT_FOUND"
>>> GrpcException(status_code=StatusCode.NOT_FOUND).status_string
'NOT_FOUND'
"""
return self.status_code.name

Expand Down
Empty file added src/grpc_interceptor/py.typed
Empty file.
4 changes: 2 additions & 2 deletions src/grpc_interceptor/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

from typing import Callable

from grpc_interceptor.testing.dummy_client import dummy_client
from grpc_interceptor.testing.dummy_client import dummy_client, DummyService
from grpc_interceptor.testing.protos.dummy_pb2 import DummyRequest, DummyResponse


__all__ = ["dummy_client", "DummyRequest", "DummyResponse", "raises"]
__all__ = ["dummy_client", "DummyRequest", "DummyResponse", "DummyService", "raises"]


def raises(e: Exception) -> Callable:
Expand Down
24 changes: 12 additions & 12 deletions src/grpc_interceptor/testing/dummy_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,25 @@

import grpc

from grpc_interceptor.base import Interceptor
from grpc_interceptor.base import ServiceInterceptor
from grpc_interceptor.testing.protos import dummy_pb2_grpc
from grpc_interceptor.testing.protos.dummy_pb2 import DummyRequest, DummyResponse

SpecialCaseFunction = Callable[[str], str]


class DummyService(dummy_pb2_grpc.DummyServiceServicer):
"""A gRPC service used for testing."""
"""A gRPC service used for testing.
def __init__(self, special_cases: Dict[str, SpecialCaseFunction]):
"""Define the special cases.
Args:
special_cases: A dictionary where the keys are strings, and the values are
functions that take and return strings. The functions can also raise
exceptions. When the Execute method is given a string in the dict, it
will call the function with that string instead, and return the result.
This allows testing special cases, like raising exceptions.
"""

Args:
special_cases: A dictionary where the keys are strings, and the values are
functions that take and return strings. The functions can also raise
exceptions. When the Execute method is given a string in the dict, it
will call the function with that string instead, and return the result.
This allows testing special cases, like raising exceptions.
"""
def __init__(self, special_cases: Dict[str, SpecialCaseFunction]):
self._special_cases = special_cases

def Execute(
Expand All @@ -45,7 +44,8 @@ def Execute(

@contextmanager
def dummy_client(
special_cases: Dict[str, SpecialCaseFunction], interceptors: List[Interceptor],
special_cases: Dict[str, SpecialCaseFunction],
interceptors: List[ServiceInterceptor],
):
"""A context manager that returns a gRPC client connected to a DummyService."""
server = grpc.server(
Expand Down

0 comments on commit 9c92d49

Please sign in to comment.