New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add type hints #80

Merged
merged 13 commits into from Jan 20, 2017
Copy path View file
@@ -3,9 +3,11 @@ cache: pip
python:
- "3.6"
before_install:
- pip install coverage codecov
- pip install coverage codecov mypy-lang typed_ast
install:
- pip install -r requirements.txt
script: coverage run --branch -m unittest discover -t . -s ni/test/
script:
- coverage run --branch -m unittest discover -t . -s ni/test/
- ./mypy.sh
after_success:
- codecov
Copy path View file
@@ -0,0 +1,2 @@
#!/bin/sh
mypy --python-version 3.6 --fast-parser --strict-optional --silent-imports --warn-unused-ignores --warn-redundant-casts --disallow-untyped-defs --disallow-untyped-calls ni/*.py
Copy path View file
@@ -1,6 +1,7 @@
"""Implement a server to check if a contribution is covered by a CLA(s)."""
import asyncio
import http
from typing import Awaitable, Callable
import aiohttp
from aiohttp import web
@@ -11,9 +12,10 @@
from . import ServerHost
def handler(create_client, server, cla_records):
def handler(create_client, server: ni_abc.ServerHost,
cla_records: ni_abc.CLAHost) -> Callable[[web.Request], Awaitable[web.Response]]:
"""Create a closure to handle requests from the contribution host."""
async def respond(request):
async def respond(request: web.Request) -> web.Response:
"""Handle a webhook trigger from the contribution host."""
async with create_client() as client:
try:
Copy path View file
@@ -1,6 +1,7 @@
import abc
import asyncio
import http
from typing import AbstractSet, Any, Optional, Tuple
import aiohttp
from aiohttp import web
@@ -12,7 +13,8 @@ class ResponseExit(Exception):
"""Exception to raise when the current request should immediately exit."""
def __init__(self, *args, status, text=None):
def __init__(self, *args: Any, status: http.HTTPStatus,
text: str = None) -> None:
super().__init__(*args)
self.response = web.Response(status=status.value, text=text)
@@ -31,25 +33,25 @@ class ServerHost(abc.ABC):
"""Abstract base class for the server hosting platform."""
@abc.abstractmethod
def port(self):
def port(self) -> int:
"""Specify the port to bind the listening socket to."""
raise NotImplementedError
@abc.abstractmethod
def contrib_auth_token(self):
def contrib_auth_token(self) -> str:
"""Return the authorization token for the contribution host."""
raise NotImplementedError
@abc.abstractmethod
def user_agent(self):
def user_agent(self) -> Optional[str]:
"""Return the HTTP User-Agent string, or None."""
@abc.abstractmethod
def log_exception(self, exc):
def log_exception(self, exc: BaseException) -> None:
"""Log the exception."""
@abc.abstractmethod
def log(self, message):
def log(self, message: str) -> None:
"""Log the message."""
@@ -59,23 +61,25 @@ class ContribHost(abc.ABC):
@property
@abc.abstractmethod
def route(self):
def route(self) -> Tuple[str, str]:
return '*', '/' # pragma: no cover
@classmethod
@abc.abstractmethod
async def process(cls, server, request):
async def process(cls, server: ServerHost,
request: web.Request) -> "ContribHost":
"""Process a request into a contribution."""
# This method exists because __init__() cannot be a coroutine.
raise ResponseExit(status=http.HTTPStatus.NOT_IMPLEMENTED) # pragma: no cover
@abc.abstractmethod
async def usernames(self, client):
async def usernames(self, client: aiohttp.ClientSession) -> AbstractSet[str]:
"""Return an iterable of all the contributors' usernames."""
return [] # pragma: no cover
return frozenset() # pragma: no cover
@abc.abstractmethod
async def update(self, client, status):
async def update(self, client: aiohttp.ClientSession,
status: Status) -> None:
"""Update the contribution with the status of CLA coverage."""
@@ -84,10 +88,11 @@ class CLAHost(abc.ABC):
"""Abstract base class for the CLA records platform."""
@abc.abstractmethod
async def check(self, client, usernames):
async def check(self, client: aiohttp.ClientSession,
usernames: AbstractSet[str]) -> Status:
"""Check if all of the specified usernames have signed the CLA."""
# While it would technically share more specific information if a
# mapping of {username: Status} was returned, the vast majority of
# cases will be for a single user and thus not worth the added
# complexity to need to worry about it.
return Status.USERNAME_NOT_FOUND # pragma: no cover
return Status.username_not_found # pragma: no cover
Copy path View file
@@ -1,5 +1,8 @@
from http import client
import json
from typing import AbstractSet
import aiohttp
from . import abc as ni_abc
@@ -8,10 +11,11 @@ class Host(ni_abc.CLAHost):
"""CLA record hosting at bugs.python.org."""
def __init__(self, server):
def __init__(self, server: ni_abc.ServerHost) -> None:
self.server = server
async def check(self, aio_client, usernames):
async def check(self, aio_client: aiohttp.ClientSession,
usernames: AbstractSet[str]) -> ni_abc.Status:
base_url = "http://bugs.python.org/user?@template=clacheck&github_names="
url = base_url + ','.join(usernames)
self.server.log("Checking CLA status: " + url)
Copy path View file
@@ -4,12 +4,17 @@
import json
import operator
import random
from typing import AbstractSet, Any, Dict, Optional
from urllib import parse
from aiohttp import hdrs
import aiohttp
from aiohttp import hdrs, web
from . import abc as ni_abc
JSON = Any
JSONDict = Dict[str, Any]
LABEL_PREFIX = 'CLA '
CLA_OK = LABEL_PREFIX + 'signed'
@@ -89,14 +94,16 @@ class Host(ni_abc.ContribHost):
PullRequestEvent.unlabeled.value,
PullRequestEvent.synchronize.value}
def __init__(self, server, event, request):
def __init__(self, server: ni_abc.ServerHost, event: PullRequestEvent,
request: JSONDict) -> None:
"""Represent a contribution."""
self.server = server
self.event = event
self.request = request
@classmethod
async def process(cls, server, request):
async def process(cls, server: ni_abc.ServerHost,
request: web.Request) -> "Host":
"""Process the pull request."""
# https://developer.github.com/webhooks/creating/#content-type
if request.content_type != 'application/json':
@@ -128,16 +135,16 @@ def __init__(self, server, event, request):
raise TypeError(msg)
@staticmethod
def check_response(response):
def check_response(response: web.Response) -> None:
if response.status >= 300:
msg = 'unexpected response for {!r}: {}'.format(response.url,
response.status)
raise client.HTTPException(msg)
def auth_header(self):
def auth_header(self) -> Dict[str, str]:
return {'Authorization': 'token ' + self.server.contrib_auth_token()}
async def get(self, client, url: str):
async def get(self, client: aiohttp.ClientSession, url: str) -> JSON:
"""Make a GET request for some JSON data.
Abstracted out for easy testing w/o requiring internet access.
@@ -147,7 +154,8 @@ def auth_header(self):
self.check_response(response)
return (await response.json())
async def post(self, client, url: str, payload):
async def post(self, client: aiohttp.ClientSession, url: str,
payload: JSON) -> None:
"""Make a POST request with JSON data to a URL."""
encoding = 'utf-8'
encoded_json = json.dumps(payload).encode(encoding)
@@ -161,13 +169,15 @@ def auth_header(self):
async with post_manager as response:
self.check_response(response)
async def delete(self, client, url):
async def delete(self, client: aiohttp.ClientSession,
url: str) -> None:
"""Make a DELETE request to a URL."""
headers = self.auth_header()
async with client.delete(url, headers=headers) as response:
self.check_response(response)
async def usernames(self, client):
async def usernames(self,
client: aiohttp.ClientSession) -> AbstractSet[str]:
"""Return an iterable with all of the contributors' usernames."""
pull_request = self.request['pull_request']
# Start with the author of the pull request.
@@ -197,7 +207,8 @@ def auth_header(self):
logins.add(committer_login)
return frozenset(logins)
async def labels_url(self, client, label=None):
async def labels_url(self, client: aiohttp.ClientSession,
label: str = None) -> str:
"""Construct the URL to the label."""
if not hasattr(self, '_labels_url'):
issue_url = self.request['pull_request']['issue_url']
@@ -209,16 +220,18 @@ def auth_header(self):
mapping = {'/name': quoted_label}
return self._labels_url.format_map(mapping)
async def current_label(self, client):
async def current_label(self,
client: aiohttp.ClientSession) -> Optional[str]:
"""Return the current CLA-related label."""
labels_url = await self.labels_url(client)
all_labels = map(operator.itemgetter('name'),
await self.get(client, labels_url))
cla_labels = (x for x in all_labels if x.startswith(LABEL_PREFIX))
cla_labels = sorted(cla_labels)
cla_labels = [x for x in all_labels if x.startswith(LABEL_PREFIX)]
cla_labels.sort()
return cla_labels[0] if len(cla_labels) > 0 else None
async def set_label(self, client, status):
async def set_label(self, client: aiohttp.ClientSession,
status: ni_abc.Status) -> str:
"""Set the label on the pull request based on the status of the CLA."""
labels_url = await self.labels_url(client)
if status == ni_abc.Status.signed:
@@ -228,7 +241,7 @@ def auth_header(self):
await self.post(client, labels_url, [NO_CLA])
return NO_CLA
async def remove_label(self, client):
async def remove_label(self, client: aiohttp.ClientSession) -> Optional[str]:
"""Remove any CLA-related labels from the pull request."""
cla_label = await self.current_label(client)
if cla_label is None:
@@ -237,7 +250,8 @@ def auth_header(self):
await self.delete(client, deletion_url)
return cla_label
async def comment(self, client, status):
async def comment(self, client: aiohttp.ClientSession,
status: ni_abc.Status) -> Optional[str]:
"""Add an appropriate comment relating to the CLA status."""
comments_url = self.request['pull_request']['comments_url']
if status == ni_abc.Status.signed:
@@ -255,7 +269,8 @@ def auth_header(self):
await self.post(client, comments_url, {'body': message})
return message
async def update(self, client, status):
async def update(self, client: aiohttp.ClientSession,
status: ni_abc.Status) -> None:
if self.event == PullRequestEvent.opened:
await self.set_label(client, status)
await self.comment(client, status)
@@ -278,4 +293,4 @@ def auth_header(self):
else: # pragma: no cover
# Should never be reached.
msg = 'do not know how to update a PR for {}'.format(self.event)
raise RunimeError(msg)
raise RuntimeError(msg)
Copy path View file
@@ -1,6 +1,7 @@
import os
import sys
import traceback
from typing import Optional
from . import abc as ni_abc
@@ -10,24 +11,22 @@ class Host(ni_abc.ServerHost):
"""Server hosting on Heroku."""
@staticmethod
def port():
def port() -> int:
return int(os.environ['PORT'])
@staticmethod
def contrib_auth_token():
def contrib_auth_token() -> str:
return os.environ['GH_AUTH_TOKEN']
@staticmethod
def user_agent():
return os.environ.get('USER_AGENT', None)
def user_agent() -> Optional[str]:
return os.environ.get('USER_AGENT')
@staticmethod
def log_exception(exc):
def log_exception(self, exc: BaseException) -> None:
"""Log an exception and its traceback to stderr."""
traceback.print_exception(type(exc), exc, exc.__traceback__,
file=sys.stderr)
@staticmethod
def log(message):
def log(self, message: str) -> None:
"""Log a message to stderr."""
print(message, file=sys.stderr)
Copy path View file
@@ -2,7 +2,6 @@
from http import client
import json
import unittest
from unittest import mock
import aiohttp
@@ -48,7 +47,7 @@ def test_bad_data(self):
class SessionOnDemand:
"""Role session creation and HTTP requesting in a single object.
aiohttp raises a warning if a ClientSession is created outside of a
coroutine. To avoid this issue, this class acts as an async context
manager which both creates a session and makes a GET request.
Copy path View file
@@ -4,7 +4,6 @@
import json
import pathlib
import unittest
from unittest import mock
from urllib import parse
from aiohttp import hdrs, web
Copy path View file
@@ -1,6 +1,6 @@
import http
import unittest
from unittest import mock
import unittest.mock as mock
from .. import __main__
from .. import abc as ni_abc
Copy path View file
@@ -77,13 +77,13 @@ def delete(self, url, headers=None):
class FakeServerHost(ni_abc.ServerHost):
port = 1234
_port = 1234
auth_token = 'some_auth_token'
user_agent_name = 'Testing-Agent'
def port(self):
"""Specify the port to bind the listening socket to."""
return self.port
return self._port
def contrib_auth_token(self):
return self.auth_token
ProTip! Use n and p to navigate between commits in a pull request.