Repository: brettcannon/gidgethub (python)

Issue:
Add type hints
The latest release of mypy now has the appropriate support to type hint gidgethub (as long as you pass `--ignore-missing-imports`).

Pull request:
Add type hints
Closes #33 

Patch:
diff --git a/.travis.yml b/.travis.yml
index dd10f8c..431c12d 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -12,11 +12,10 @@ matrix:
 
 before_install:
   - pip install -U ".[dev]"
-  - pip install -U pytest-cov sphinx
+  - pip install -U pytest-cov sphinx mypy
 script:
   - pytest --cov=gidgethub
-  # As of mypy 0.501, PEP 525 is not supported.
-  #- mypy --ignore-missing-imports gidgethub
+  - mypy --ignore-missing-imports --strict gidgethub/*.py
   - sphinx-build -nW -q -b html -b linkcheck -d docs/_build/doctrees docs docs/_build/html
 after_success:
   - bash <(curl -s https://codecov.io/bash)
diff --git a/README.rst b/README.rst
index 554cba3..a75470d 100644
--- a/README.rst
+++ b/README.rst
@@ -68,6 +68,8 @@ Changelog
 - Introduce ``gidgethub.routing`` to help route webhook events to registered
   asynchronous callbacks.
 
+- Add type hints.
+
 
 2.2.0
 '''''''''''''''''''
diff --git a/gidgethub/__init__.py b/gidgethub/__init__.py
index e56ac92..e1984f8 100644
--- a/gidgethub/__init__.py
+++ b/gidgethub/__init__.py
@@ -1,4 +1,5 @@
 import http
+from typing import Any
 
 
 class GitHubException(Exception):
@@ -16,7 +17,7 @@ class HTTPException(GitHubException):
 
     """A general exception to represent HTTP responses."""
 
-    def __init__(self, status_code, *args):
+    def __init__(self, status_code: http.HTTPStatus, *args: Any) -> None:
         self.status_code = status_code
         if args:
             super().__init__(*args)
@@ -41,7 +42,9 @@ class RateLimitExceeded(BadRequest):
 
     """Request rejected due to the rate limit being exceeded."""
 
-    def __init__(self, rate_limit, *args):
+    # Technically rate_limit is of type gidgethub.sansio.RateLimit, but a
+    # circular import comes about if you try to properly declare it.
+    def __init__(self, rate_limit: Any, *args: Any) -> None:
         self.rate_limit = rate_limit
 
         if not args:
@@ -59,7 +62,7 @@ class InvalidField(BadRequest):
     invalid are stored in the errors attribute.
     """
 
-    def __init__(self, errors, *args):
+    def __init__(self, errors: Any, *args: Any) -> None:
         """Store the error details."""
         self.errors = errors
         super().__init__(http.HTTPStatus.UNPROCESSABLE_ENTITY, *args)
diff --git a/gidgethub/abc.py b/gidgethub/abc.py
index 0f80555..268959e 100644
--- a/gidgethub/abc.py
+++ b/gidgethub/abc.py
@@ -1,6 +1,7 @@
 """Provide an abstract base class for easier requests."""
 import abc
 import json
+from typing import Any, AsyncGenerator, Dict, Mapping, Tuple, Optional
 
 from . import sansio
 
@@ -9,20 +10,22 @@ class GitHubAPI(abc.ABC):
 
     """Provide an idiomatic API for making calls to GitHub's API."""
 
-    def __init__(self, requester, *, oauth_token=None):
+    def __init__(self, requester: str, *, oauth_token: str = None) -> None:
         self.requester = requester
         self.oauth_token = oauth_token
-        self.rate_limit: sansio.RateLimit = None
+        self.rate_limit: Optional[sansio.RateLimit] = None
 
     @abc.abstractmethod
-    async def _request(self, method, url, headers, body=b''):
+    async def _request(self, method: str, url: str, headers: Mapping,
+                       body: bytes = b'') -> Tuple[int, Mapping, bytes]:
         """Make an HTTP request."""
 
     @abc.abstractmethod
-    async def sleep(self, seconds):
+    async def sleep(self, seconds: float) -> None:
         """Sleep for the specified number of seconds."""
 
-    async def _make_request(self, method, url, url_vars, data, accept):
+    async def _make_request(self, method: str, url: str, url_vars: Dict,
+                            data: Any, accept: str) -> Tuple[bytes, Optional[str]]:
         """Construct and make an HTTP request."""
         filled_url = sansio.format_url(url, url_vars)
         request_headers = sansio.create_headers(self.requester, accept=accept,
@@ -42,12 +45,14 @@ class GitHubAPI(abc.ABC):
         data, self.rate_limit, more = sansio.decipher_response(*response)
         return data, more
 
-    async def getitem(self, url, url_vars={}, *, accept=sansio.accept_format()):
+    async def getitem(self, url: str, url_vars: Dict = {},
+                      *, accept: str = sansio.accept_format()) -> Any:
         """Send a GET request for a single item to the specified endpoint."""
         data, _ = await self._make_request("GET", url, url_vars, b"", accept)
         return data
 
-    async def getiter(self, url, url_vars={}, *, accept=sansio.accept_format()):
+    async def getiter(self, url: str, url_vars: Dict = {},
+                      *, accept: str = sansio.accept_format()) -> AsyncGenerator[Any, None]:
         """Return an async iterable for all the items at a specified endpoint."""
         data, more = await self._make_request("GET", url, url_vars, b"", accept)
         for item in data:
@@ -57,17 +62,21 @@ class GitHubAPI(abc.ABC):
             async for item in self.getiter(more, url_vars, accept=accept):
                 yield item
 
-    async def post(self, url, url_vars={}, *, data, accept=sansio.accept_format()):
+    async def post(self, url: str, url_vars: Dict = {}, *, data: Any,
+                   accept: str = sansio.accept_format()) -> Any:
         data, _ = await self._make_request("POST", url, url_vars, data, accept)
         return data
 
-    async def patch(self, url, url_vars={}, *, data, accept=sansio.accept_format()):
+    async def patch(self, url: str, url_vars: Dict = {}, *, data: Any,
+                    accept: str = sansio.accept_format()) -> Any:
         data, _ = await self._make_request("PATCH", url, url_vars, data, accept)
         return data
 
-    async def put(self, url, url_vars={}, *, data=b"", accept=sansio.accept_format()):
+    async def put(self, url: str, url_vars: Dict = {}, *, data: Any = b"",
+                  accept: str = sansio.accept_format()) -> Any:
         data, _ = await self._make_request("PUT", url, url_vars, data, accept)
         return data
 
-    async def delete(self, url, url_vars={}, *, accept=sansio.accept_format()):
+    async def delete(self, url: str, url_vars: Dict = {}, *,
+                     accept: str = sansio.accept_format()) -> None:
         await self._make_request("DELETE", url, url_vars, b"", accept)
diff --git a/gidgethub/aiohttp.py b/gidgethub/aiohttp.py
index 44e30ee..3db6c29 100644
--- a/gidgethub/aiohttp.py
+++ b/gidgethub/aiohttp.py
@@ -1,4 +1,5 @@
 import asyncio
+from typing import Mapping, Tuple
 
 import aiohttp
 
@@ -7,14 +8,16 @@ from . import abc as gh_abc
 
 class GitHubAPI(gh_abc.GitHubAPI):
 
-    def __init__(self, session, requester, *, oauth_token=None):
+    def __init__(self, session: aiohttp.ClientSession, requester: str,
+                 *, oauth_token: str = None) -> None:
         self._session = session
         super().__init__(requester, oauth_token=oauth_token)
 
-    async def _request(self, method, url, headers, body=b''):
+    async def _request(self, method: str, url: str, headers: Mapping,
+                       body: bytes = b'') -> Tuple[int, Mapping, bytes]:
         async with self._session.request(method, url, headers=headers,
                                          data=body) as response:
             return response.status, response.headers, await response.read()
 
-    async def sleep(self, seconds):
+    async def sleep(self, seconds: float) -> None:
         await asyncio.sleep(seconds)
diff --git a/gidgethub/routing.py b/gidgethub/routing.py
index aa0e1f2..5fbe312 100644
--- a/gidgethub/routing.py
+++ b/gidgethub/routing.py
@@ -1,23 +1,32 @@
-from typing import Any, Callable, Dict, List
+from typing import Any, Awaitable, Callable, Dict, List, Optional
+
+from . import sansio
+
+
+AsyncCallback = Callable[[sansio.Event], Awaitable[None]]
 
 
 class Router:
 
     """Route webhook events to registered functions."""
 
-    def __init__(self, *other_routers):
+    def __init__(self, *other_routers: "Router") -> None:
         """Instantiate a new router (possibly from other routers)."""
+        self._shallow_routes: Dict[str, List[AsyncCallback]] = {}
         # event type -> data key -> data value -> callbacks
-        self._routes: Dict[str, Dict[str, Dict[Any, List[Callable]]]] = {}
+        self._deep_routes: Dict[str, Dict[str, Dict[Any, List[AsyncCallback]]]] = {}
         for other_router in other_routers:
-            for event_type, data_details in other_router._routes.items():
+            for event_type, callbacks in other_router._shallow_routes.items():
+                for callback in callbacks:
+                    self.add(callback, event_type)
+            for event_type, data_details in other_router._deep_routes.items():
                 for data_key, data_specifics in data_details.items():
                     for data_value, callbacks in data_specifics.items():
+                        detail = {data_key: data_value}
                         for callback in callbacks:
-                            detail = {data_key: data_value}
                             self.add(callback, event_type, **detail)
 
-    def add(self, func, event_type, **data_detail):
+    def add(self, func: AsyncCallback, event_type: str, **data_detail: Any) -> None:
         """Add a new route.
 
         After registering 'func' for the specified event_type, an
@@ -31,39 +40,40 @@ class Router:
                             "supported up to one level deep; "
                             f"{len(data_detail)} levels specified")
         elif not data_detail:
-            data_key = data_value = None
+            callbacks = self._shallow_routes.setdefault(event_type, [])
+            callbacks.append(func)
         else:
             data_key, data_value = data_detail.popitem()
-        data_details = self._routes.setdefault(event_type, {})
-        specific_detail = data_details.setdefault(data_key, {})
-        callbacks = specific_detail.setdefault(data_value, [])
-        callbacks.append(func)
+            data_details = self._deep_routes.setdefault(event_type, {})
+            specific_detail = data_details.setdefault(data_key, {})
+            callbacks = specific_detail.setdefault(data_value, [])
+            callbacks.append(func)
 
-    def register(self, event_type, **data_detail):
+    def register(self, event_type: str,
+                 **data_detail: Any) -> Callable[[AsyncCallback], AsyncCallback]:
         """Decorator to apply the add() method to a function."""
-        def decorator(func):
+        def decorator(func: AsyncCallback) -> AsyncCallback:
             self.add(func, event_type, **data_detail)
             return func
         return decorator
 
-    async def dispatch(self, event):
+    async def dispatch(self, event: sansio.Event) -> None:
         """Dispatch an event to all registered function(s)."""
+
+        found_callbacks = []
         try:
-            details = self._routes[event.event]
+            found_callbacks.extend(self._shallow_routes[event.event])
         except KeyError:
-            return
-        found_callbacks = []
+            pass
         try:
-            # The "no data details" case.
-            found_callbacks.extend(details[None][None])
+            details = self._deep_routes[event.event]
         except KeyError:
             pass
-        for data_key, data_values in details.items():
-            if data_key is None:
-                continue
-            elif data_key in event.data:
-                event_value = event.data[data_key]
-                if event_value in data_values:
-                    found_callbacks.extend(data_values[event_value])
+        else:
+            for data_key, data_values in details.items():
+                if data_key in event.data:
+                    event_value = event.data[data_key]
+                    if event_value in data_values:
+                        found_callbacks.extend(data_values[event_value])
         for callback in found_callbacks:
             await callback(event)
diff --git a/gidgethub/sansio.py b/gidgethub/sansio.py
index be34ddc..d2545ba 100644
--- a/gidgethub/sansio.py
+++ b/gidgethub/sansio.py
@@ -12,6 +12,7 @@ import hmac
 import http
 import json
 import re
+from typing import Any, Dict, Mapping, Optional, Tuple, Type
 import urllib.parse
 
 import uritemplate
@@ -20,7 +21,7 @@ from . import (BadRequest, GitHubBroken, HTTPException, InvalidField,
                RateLimitExceeded, RedirectionException, ValidationFailure)
 
 
-def _parse_content_type(content_type):
+def _parse_content_type(content_type: Optional[str]) -> Tuple[Optional[str], str]:
     """Tease out the content-type and character encoding.
 
     A default character encoding of UTF-8 is used, so the content-type
@@ -35,7 +36,8 @@ def _parse_content_type(content_type):
         return type_, encoding
 
 
-def _decode_body(content_type, body, *, strict=False):
+def _decode_body(content_type: Optional[str], body: bytes,
+                 *, strict: bool = False) -> Any:
     """Decode an HTTP body based on the specified content type.
 
     If 'strict' is true, then raise ValueError if the content type
@@ -55,7 +57,7 @@ def _decode_body(content_type, body, *, strict=False):
     return decoded_body
 
 
-def validate_event(payload, *, signature, secret):
+def validate_event(payload: bytes, *, signature: str, secret: str) -> None:
     """Validate the signature of a webhook event."""
     # https://developer.github.com/webhooks/securing/#validating-payloads-from-github
     signature_prefix = "sha1="
@@ -73,7 +75,7 @@ class Event:
 
     """Details of a GitHub webhook event."""
 
-    def __init__(self, data, *, event, delivery_id):
+    def __init__(self, data: Any, *, event: str, delivery_id: str) -> None:
         # https://developer.github.com/v3/activity/events/types/
         # https://developer.github.com/webhooks/#delivery-headers
         self.data = data
@@ -85,7 +87,8 @@ class Event:
         self.delivery_id = delivery_id
 
     @classmethod
-    def from_http(cls, headers, body, *, secret=None):
+    def from_http(cls, headers: Mapping, body: bytes,
+                  *, secret: str = None) -> "Event":
         """Construct an event from HTTP headers and JSON body data.
 
         The mapping providing the headers is expected to support lowercase keys.
@@ -119,7 +122,7 @@ class Event:
                    delivery_id=headers["x-github-delivery"])
 
 
-def accept_format(*, version="v3", media=None, json=True):
+def accept_format(*, version: str = "v3", media: str = None, json: bool = True) -> str:
     """Construct the specification of the format that a request should return.
 
     The version argument defaults to v3 of the GitHub API and is applicable to
@@ -141,7 +144,8 @@ def accept_format(*, version="v3", media=None, json=True):
     return accept
 
 
-def create_headers(requester, *, accept=accept_format(), oauth_token=None):
+def create_headers(requester: str, *, accept: str = accept_format(),
+                   oauth_token: str = None) -> Dict[str, str]:
     """Create a dict representing GitHub-specific header fields.
 
     The user agent is set according to who the requester is. GitHub asks it be
@@ -190,7 +194,7 @@ class RateLimit:
 
     # https://developer.github.com/v3/#rate-limiting
 
-    def __init__(self, *, limit, remaining, reset_epoch):
+    def __init__(self, *, limit: int, remaining: int, reset_epoch: float) -> None:
         """Instantiate a RateLimit object.
 
         The reset_epoch argument should be in seconds since the UTC epoch.
@@ -204,7 +208,7 @@ class RateLimit:
         self.reset_datetime = datetime.datetime.fromtimestamp(reset_epoch,
                                                               datetime.timezone.utc)
 
-    def __bool__(self):
+    def __bool__(self) -> bool:
         """True if requests are remaining or the reset datetime has passed."""
         if self.remaining > 0:
             return True
@@ -213,7 +217,7 @@ class RateLimit:
             return now > self.reset_datetime
 
     @classmethod
-    def from_http(cls, headers):
+    def from_http(cls, headers: Mapping) -> "RateLimit":
         """Gather rate limit information from HTTP headers.
 
         The mapping providing the headers is expected to support lowercase
@@ -228,7 +232,7 @@ class RateLimit:
 _link_re = re.compile(r'\<(?P<uri>[^>]+)\>;\s*'
                       r'(?P<param_type>\w+)="(?P<param_value>\w+)"(,\s*)?')
 
-def _next_link(link):
+def _next_link(link: Optional[str]) -> Optional[str]:
     # https://developer.github.com/v3/#pagination
     # https://tools.ietf.org/html/rfc5988
     if link is None:
@@ -241,7 +245,8 @@ def _next_link(link):
         return None
 
 
-def decipher_response(status_code, headers, body):
+def decipher_response(status_code: int, headers: Mapping,
+                      body: bytes) -> Tuple[Any, RateLimit, Optional[str]]:
     """Decipher an HTTP response for a GitHub API request.
 
     The mapping providing the headers is expected to support lowercase keys.
@@ -270,6 +275,7 @@ def decipher_response(status_code, headers, body):
             message = data["message"]
         except (TypeError, KeyError):
             message = None
+        exc_type: Type[HTTPException]
         if status_code >= 500:
             exc_type = GitHubBroken
         elif status_code >= 400:
@@ -288,6 +294,7 @@ def decipher_response(status_code, headers, body):
         else:
             exc_type = HTTPException
         status_code_enum = http.HTTPStatus(status_code)
+        args: Tuple
         if message:
             args = status_code_enum, message
         else:
@@ -297,7 +304,7 @@ def decipher_response(status_code, headers, body):
 
 DOMAIN = "https://api.github.com"
 
-def format_url(url, url_vars):
+def format_url(url: str, url_vars: Mapping[str, Any]) -> str:
     """Construct a URL for the GitHub API.
 
     The URL may be absolute or relative. In the latter case the appropriate
@@ -307,4 +314,5 @@ def format_url(url, url_vars):
     The dict provided in url_vars is used in URI template formatting.
     """
     url = urllib.parse.urljoin(DOMAIN, url)  # Works even if 'url' is fully-qualified.
-    return uritemplate.expand(url, var_dict=url_vars)
+    expanded_url: str = uritemplate.expand(url, var_dict=url_vars)
+    return expanded_url
diff --git a/gidgethub/tornado.py b/gidgethub/tornado.py
index dedb9bd..8a584e1 100644
--- a/gidgethub/tornado.py
+++ b/gidgethub/tornado.py
@@ -1,3 +1,5 @@
+from typing import Mapping, Optional, Tuple
+
 from tornado import gen
 from tornado import httpclient
 
@@ -6,17 +8,20 @@ from . import abc as gh_abc
 
 class GitHubAPI(gh_abc.GitHubAPI):
 
-    async def _request(self, method, url, headers, body=b''):
+    async def _request(self, method: str, url: str, headers: Mapping,
+                       body: bytes = b'') -> Tuple[int, Mapping, bytes]:
         """Make an HTTP request."""
         if method == "GET" and not body:
-            body = None
-        request = httpclient.HTTPRequest(url, method, headers, body)
+            real_body = None
+        else:
+            real_body = body
+        request = httpclient.HTTPRequest(url, method, headers, real_body)
         # Since Tornado has designed AsyncHTTPClient to be a singleton, there's
         # no reason not to simply instantiate it every time.
         client = httpclient.AsyncHTTPClient()
         response = await client.fetch(request, raise_error=False)
         return response.code, response.headers, response.body
 
-    async def sleep(self, seconds):
+    async def sleep(self, seconds: float) -> None:
         """Sleep for the specified number of seconds."""
         await gen.sleep(seconds)
diff --git a/gidgethub/treq.py b/gidgethub/treq.py
index 84bdfaa..0bd74e5 100644
--- a/gidgethub/treq.py
+++ b/gidgethub/treq.py
@@ -1,4 +1,4 @@
-from typing import Mapping, Tuple
+from typing import Any, Mapping, Tuple
 
 from twisted.internet import defer
 from twisted.web.http_headers import Headers
@@ -9,12 +9,13 @@ from . import abc as gh_abc
 
 
 class GitHubAPI(gh_abc.GitHubAPI):
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
         from twisted.internet import reactor
         self._reactor = reactor
         super().__init__(*args, **kwargs)
 
-    async def _request(self, method, url, headers, body=b''):
+    async def _request(self, method: str, url: str, headers: Mapping,
+                       body: bytes = b'') -> Tuple[int, Mapping, bytes]:
         # We need to encode the headers to a format that Twisted will like.
         # As a note: treq will set a content-length even if we do, so we need
         # to strip any content-length header.
@@ -36,7 +37,7 @@ class GitHubAPI(gh_abc.GitHubAPI):
         }
         return response.code, response_headers, await response.content()
 
-    async def sleep(self, seconds):
+    async def sleep(self, seconds: float) -> None:
         d = defer.Deferred()
         self._reactor.callLater(seconds, d.callback, None)
         await d


Test patch:
diff --git a/gidgethub/test/test_routing.py b/gidgethub/test/test_routing.py
index a2a6a75..fdf70fc 100644
--- a/gidgethub/test/test_routing.py
+++ b/gidgethub/test/test_routing.py
@@ -104,12 +104,16 @@ async def test_dispatching():
 @pytest.mark.asyncio
 async def test_router_copy():
     router = routing.Router()
-    callback = Callback()
-    router.add(callback.meth, "something", extra=42)
+    deep_callback = Callback()
+    shallow_callback = Callback()
+    router.add(deep_callback.meth, "something", extra=42)
+    router.add(shallow_callback.meth, "something")
     event = sansio.Event({"extra": 42}, event="something", delivery_id="1234")
     await router.dispatch(event)
-    assert callback.called
-    callback.called = False
+    assert deep_callback.called
+    assert shallow_callback.called
+    deep_callback.called = shallow_callback.called = False
     other_router = routing.Router(router)
     await other_router.dispatch(event)
-    assert callback.called
+    assert deep_callback.called
+    assert shallow_callback.called