diff --git a/.gitignore b/.gitignore index 883cb9a..fc95e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ env27 *,cover htmlcov venv -.cache \ No newline at end of file +.cache +.mypy_cache \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index b53da57..24ecec3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ sudo: false language: python python: - - pypy - 3.4 - 3.5 - 3.6 @@ -17,5 +16,7 @@ jobs: env: TOXENV=flake8 - python: 3.6 env: TOXENV=pylint + - python: 3.6 + env: TOXENV=mypy after_success: - if test "$TOXENV" = coverage ; then pip install coveralls; coveralls ; fi diff --git a/CHANGES.txt b/CHANGES.txt index d475eae..8f88b32 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,7 +1,6 @@ Changes ========================= - 1.3 ------------------------- diff --git a/newsfragments/13.feature b/newsfragments/13.feature new file mode 100644 index 0000000..26e7d93 --- /dev/null +++ b/newsfragments/13.feature @@ -0,0 +1,2 @@ +support mypy typings + diff --git a/setup.py b/setup.py index 9ad8c25..6e78dbd 100644 --- a/setup.py +++ b/setup.py @@ -10,18 +10,21 @@ "python-coveralls", "flake8", "pylint", + "mypy", ] here = os.path.dirname(__file__) readme = None changes = None + def _read(name): try: return open(os.path.join(here, name)).read() - except: + except Exception: return "" + readme = _read("README.rst") changes = _read("CHANGES.txt") @@ -39,7 +42,9 @@ def _read(name): long_description=readme + "\n" + changes, test_suite="webdispatch", license="MIT", - install_requires=[], + install_requires=[ + "typing; python_version < '3.5'", + ], tests_require=tests_require, extras_require={ "testing": tests_require, diff --git a/tox.ini b/tox.ini index b503a4b..90d5236 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py34,py35,py36,pypy,coverage,flake8,pylint +envlist = py34,py35,py36,coverage,flake8,pylint,mypy [testenv] commands = @@ -23,3 +23,9 @@ basepython = python3.6 commands = pip install -e .[testing] -c constraints.txt pylint webdispatch + +[testenv:mypy] +basepython = python3.6 +commands = + pip install -e .[testing] -c constraints.txt + mypy --ignore-missing-imports webdispatch diff --git a/webdispatch/base.py b/webdispatch/base.py index d8ea94f..437fbd2 100644 --- a/webdispatch/base.py +++ b/webdispatch/base.py @@ -1,22 +1,26 @@ """ base dispatchers """ +from typing import Dict, Any, Callable, Iterable class DispatchBase(object): """ Base class for dispatcher application""" - def __init__(self, applications=None, extra_environ=None): + def __init__( + self, + applications: Dict[str, Callable] = None, + extra_environ: Dict[str, Any] = None) -> None: if applications is None: - self.applications = {} + self.applications = {} # type: Dict[str, Callable] else: self.applications = applications if extra_environ is None: - self.extra_environ = {} + self.extra_environ = {} # type: Dict[str, Any] else: self.extra_environ = extra_environ - def register_app(self, name, app=None): + def register_app(self, name: str, app: Callable = None) -> Callable: """ register dispatchable wsgi application""" if app is None: def dec(app): @@ -26,20 +30,27 @@ def dec(app): return app return dec self.applications[name] = app + return None - def get_extra_environ(self): + def get_extra_environ(self) -> Dict[str, Any]: """ returns for environ values for wsgi environ""" return self.extra_environ - def detect_view_name(self, environ): # pragma: nocover + def detect_view_name( + self, environ: Dict[str, Any]) -> str: # pragma: nocover """ must returns view name for request """ raise NotImplementedError() - def on_view_not_found(self, environ, start_response): # pragma: nocover + def on_view_not_found( + self, + environ: Dict[str, Any], + start_response: Callable) -> Iterable[bytes]: # pragma: nocover """ called when view is not found""" raise NotImplementedError() - def __call__(self, environ, start_response): + def __call__(self, + environ: Dict[str, Any], + start_response: Callable) -> Iterable[bytes]: extra_environ = self.get_extra_environ() environ.update(extra_environ) view_name = self.detect_view_name(environ) diff --git a/webdispatch/methoddispatcher.py b/webdispatch/methoddispatcher.py index e43f4fa..6215387 100644 --- a/webdispatch/methoddispatcher.py +++ b/webdispatch/methoddispatcher.py @@ -1,7 +1,7 @@ """ methoddispatcher """ - +from typing import Dict, Any, Iterable, Callable, List, Tuple from wsgiref.util import application_uri from .base import DispatchBase @@ -9,16 +9,19 @@ class MethodDispatcher(DispatchBase): """ dispatch applications with request method. """ - def __init__(self, **kwargs): + def __init__(self, **kwargs) -> None: super(MethodDispatcher, self).__init__() for name, app in kwargs.items(): self.register_app(name, app) - def detect_view_name(self, environ): + def detect_view_name(self, environ: Dict[str, Any]) -> str: """ convert request method to view name """ return environ['REQUEST_METHOD'].lower() - def on_view_not_found(self, _, start_response): + def on_view_not_found( + self, _, + start_response: Callable[[str, List[Tuple[str, str]]], None], + ) -> Iterable[bytes]: """ called when valid view is not found """ start_response( @@ -27,7 +30,7 @@ def on_view_not_found(self, _, start_response): return [b"Method Not Allowed"] -def action_handler_adapter(handler_cls, action_name): +def action_handler_adapter(handler_cls: type, action_name: str) -> Callable: """ wraps class to wsgi application dispathing action""" if not hasattr(handler_cls(), action_name): @@ -44,11 +47,11 @@ def wsgiapp(environ, start_response): class ActionDispatcher(DispatchBase): """ wsgi application dispatching actions to registered classes""" - def __init__(self, action_var_name='action'): + def __init__(self, action_var_name: str = 'action') -> None: super(ActionDispatcher, self).__init__() self.action_var_name = action_var_name - def register_actionhandler(self, action_handler): + def register_actionhandler(self, action_handler: type) -> None: """ register class as action handler """ for k in action_handler.__dict__: if k.startswith('_'): @@ -56,12 +59,15 @@ def register_actionhandler(self, action_handler): app = action_handler_adapter(action_handler, k) self.register_app(k, app) - def detect_view_name(self, environ): + def detect_view_name(self, environ: Dict[str, Any]) -> str: """ get view name from routing args """ urlvars = environ.get('wsgiorg.routing_args', [(), {}])[1] return urlvars.get(self.action_var_name) - def on_view_not_found(self, environ, start_response): + def on_view_not_found( + self, environ: Dict[str, Any], + start_response: Callable[[str, List[Tuple[str, str]]], None], + ) -> Iterable[bytes]: """ called when action is not found """ start_response( "404 Not Found", diff --git a/webdispatch/mixins.py b/webdispatch/mixins.py index 162d1b6..de103be 100644 --- a/webdispatch/mixins.py +++ b/webdispatch/mixins.py @@ -1,15 +1,19 @@ """ useful mixin classes """ +from typing import Dict, Any # noqa pylint: disable=unused-import +from .urldispatcher import URLGenerator class URLMapperMixin(object): """ mixin to add :meth:`generate_url` method. """ - def generate_url(self, name, **kwargs): + environ = {} # type: Dict[str, Any] + + def generate_url(self, name: str, **kwargs) -> str: """ generate url with urlgenerator used by urldispatch""" return self.urlmapper.generate(name, **kwargs) @property - def urlmapper(self): + def urlmapper(self) -> URLGenerator: """ get urlmapper object from wsgi environ """ return self.environ['webdispatch.urlgenerator'] diff --git a/webdispatch/tests/test_uritemplate.py b/webdispatch/tests/test_uritemplate.py index f327d3c..59f4381 100644 --- a/webdispatch/tests/test_uritemplate.py +++ b/webdispatch/tests/test_uritemplate.py @@ -127,8 +127,8 @@ def test_match_empty(self): result = target.match(path) - compare(result["matchdict"], dict()) - compare(result["matchlength"], 0) + compare(result.matchdict, dict()) + compare(result.matchlength, 0) def test_wildcard(self): """ test matching pattern including wildcard""" @@ -136,8 +136,8 @@ def test_wildcard(self): target = self._make_one(path) result = target.match("hoge/egg/bacon") - compare(result["matchdict"], dict(var1="egg")) - compare(result["matchlength"], 9) + compare(result.matchdict, dict(var1="egg")) + compare(result.matchlength, 9) def test_match_no_match(self): """ test no mathing""" @@ -153,8 +153,8 @@ def test_match_match_one(self): target = self._make_one(path) result = target.match("a") - compare(result["matchdict"], dict(var1="a")) - compare(result["matchlength"], 1) + compare(result.matchdict, dict(var1="a")) + compare(result.matchlength, 1) def test_match_match_complex_word(self): """ test matching a string""" @@ -162,7 +162,7 @@ def test_match_match_complex_word(self): target = self._make_one(path) result = target.match("abc") - compare(result["matchdict"], dict(var1="abc")) + compare(result.matchdict, dict(var1="abc")) def test_match_match_many(self): """ test matching pattern including two vars """ @@ -170,7 +170,7 @@ def test_match_match_many(self): target = self._make_one(path) result = target.match("a/users/egg") - compare(result["matchdict"], dict(var1="a", var2="egg")) + compare(result.matchdict, dict(var1="a", var2="egg")) def test_match_conveter(self): """ test matching pattern including specified converter """ @@ -178,7 +178,7 @@ def test_match_conveter(self): target = self._make_one(path) result = target.match("1/users/egg") - compare(result["matchdict"], dict(var1=1, var2="egg")) + compare(result.matchdict, dict(var1=1, var2="egg")) def test_match_conveter_error(self): """ test matching pattern including specified converter """ @@ -198,7 +198,7 @@ def test_match_custom_conveter(self): target = self._make_one(path, converters=converters) result = target.match("1/users/20140420") - compare(result["matchdict"], + compare(result.matchdict, dict(var1="1", var2=datetime(2014, 4, 20))) def test_substitue(self): diff --git a/webdispatch/tests/test_urldispatcher.py b/webdispatch/tests/test_urldispatcher.py index 1b48322..5eac2be 100644 --- a/webdispatch/tests/test_urldispatcher.py +++ b/webdispatch/tests/test_urldispatcher.py @@ -60,12 +60,14 @@ def test_lookup_none(self): def test_lookup(self): """ test looking up basic usage """ + from webdispatch.uritemplate import MatchResult target = self._make_one() target.add('testing-route', 'a') result = target.lookup('a') - compare(result, {'name': 'testing-route', - 'matchdict': {}, - 'matchlength': 1}) + compare(result, C(MatchResult, + name='testing-route', + matchdict={}, + matchlength=1)) def test_generate(self): """ test generating url """ diff --git a/webdispatch/uritemplate.py b/webdispatch/uritemplate.py index 6533263..1f1c3bf 100644 --- a/webdispatch/uritemplate.py +++ b/webdispatch/uritemplate.py @@ -5,32 +5,50 @@ from datetime import datetime import re import string +from typing import ( # noqa pylint: disable=unused-import + Any, + Dict, + Callable, + Tuple, +) VARS_PT = re.compile(r"{(?P[a-zA-Z0-9_]+)" r"(:(?P[a-zA-Z0-9_]+))?}", re.X) -META_CHARS = ("\\", ".", "^", "$", "*", "+", "|", "?", "(", ")", "[", "]") +META_CHARS = ( + "\\", + ".", + "^", + "$", + "*", + "+", + "|", + "?", + "(", + ")", + "[", + "]") # type: Tuple[str, ...] DEFAULT_CONVERTERS = { 'int': int, 'date': lambda s: datetime.strptime(s, '%Y-%m-%d'), 'date_ym': lambda s: datetime.strptime(s, '%Y-%m'), -} +} # type: Dict[str, Callable] -def regex_replacer(matched): +def regex_replacer(matched) -> str: """ replace url placeholder to regex pattern""" values = matched.groupdict() return "(?P<" + values['varname'] + r">[\w-]+)" -def template_replacer(matched): +def template_replacer(matched) -> str: """ replace url placeholder to template interpolation""" values = matched.groupdict() return "${" + values['varname'] + "}" -def pattern_to_regex(pattern): +def pattern_to_regex(pattern: str) -> str: """ convert url patten to regex """ if pattern and pattern[-1] == "*": pattern = pattern[:-1] @@ -43,12 +61,14 @@ def pattern_to_regex(pattern): return "^" + VARS_PT.sub(regex_replacer, pattern) + end -def pattern_to_template(pattern): +def pattern_to_template(pattern: str) -> str: """ convert url pattern to string template""" return VARS_PT.sub(template_replacer, pattern) -def detect_converters(pattern, converter_dict, default=str): +def detect_converters(pattern: str, + converter_dict: Dict[str, Callable], + default: Callable = str): """ detect pairs of varname and converter from pattern""" converters = {} for matched in VARS_PT.finditer(pattern): @@ -63,11 +83,29 @@ class URITemplateFormatException(Exception): """ raised when uri template format error duaring""" +class MatchResult: + """ result of parsing url """ + def __init__(self, matchdict: Dict[str, Any], matchlength: int) -> None: + self.name = None # type: str + self.matchdict = matchdict + self.matchlength = matchlength + + def new_named_args(self, cur_named_args: Dict[str, Any]) -> Dict[str, Any]: + """ create new named args updating current name args""" + named_args = cur_named_args.copy() + named_args.update(self.matchdict) + return named_args + + def split_path_info(self, path_info: str) -> Tuple[str, str]: + """ split path_info to new script_name and new path_info""" + return path_info[:self.matchlength], path_info[self.matchlength:] + + class URITemplate(object): """ parsing and generating url with patterned """ - def __init__(self, tmpl_pattern, - converters=None): + def __init__(self, tmpl_pattern: str, + converters=None) -> None: if tmpl_pattern.endswith('*') and not tmpl_pattern.endswith('/*'): raise URITemplateFormatException('wildcard must be after slash.') @@ -79,11 +117,11 @@ def __init__(self, tmpl_pattern, self.converters = detect_converters( tmpl_pattern, converters) - def match(self, path_info): + def match(self, path_info: str) -> MatchResult: """ parse path_info and detect urlvars of url pattern """ matched = self.regex.match(path_info) if matched is None: - return matched + return None matchlength = len(matched.group(0)) matchdict = matched.groupdict() @@ -92,10 +130,10 @@ def match(self, path_info): except ValueError: return None - return {"matchdict": matchdict, - "matchlength": matchlength} + return MatchResult(matchdict, + matchlength) - def convert_values(self, matchdict): + def convert_values(self, matchdict: Dict[str, str]) -> Dict[str, Any]: """ convert values of ``matchdict`` with converter this object has.""" @@ -105,6 +143,6 @@ def convert_values(self, matchdict): converted[varname] = converter(value) return converted - def substitute(self, values): + def substitute(self, values: Dict[str, Any]) -> str: """ generate url with url template""" return self.template.substitute(values) diff --git a/webdispatch/urldispatcher.py b/webdispatch/urldispatcher.py index 44c1180..5eda492 100644 --- a/webdispatch/urldispatcher.py +++ b/webdispatch/urldispatcher.py @@ -3,8 +3,15 @@ """ from collections import OrderedDict from wsgiref.util import application_uri - -from .uritemplate import URITemplate +from typing import ( # noqa + Any, + Callable, + Dict, + List, + Tuple, + Iterable, +) +from .uritemplate import URITemplate, MatchResult from .base import DispatchBase @@ -12,27 +19,28 @@ class URLMapper(object): """ find application matched url pattern. """ - def __init__(self, converters=None): - self.patterns = OrderedDict() + def __init__(self, converters: Dict[str, Callable] = None) -> None: + self.patterns = OrderedDict() # type: Dict[str, URITemplate] self.converters = converters - def add(self, name, pattern): + def add(self, name: str, pattern: str) -> None: """ add url pattern for name """ self.patterns[name] = URITemplate( pattern, converters=self.converters) - def lookup(self, path_info): + def lookup(self, path_info: str) -> MatchResult: """ lookup url match for path_info """ for name, pattern in self.patterns.items(): match = pattern.match(path_info) if match is None: continue - match["name"] = name + match.name = name return match + return None - def generate(self, name, **kwargs): + def generate(self, name: str, **kwargs: Dict[str, str]) -> str: """ generate url for named url pattern with kwargs """ template = self.patterns[name] @@ -43,18 +51,18 @@ class URLGenerator(object): """ generate url form parameters and url patterns. """ - def __init__(self, environ, urlmapper): + def __init__(self, environ: Dict[str, Any], urlmapper: URLMapper) -> None: self.environ = environ self.urlmapper = urlmapper self.application_uri = application_uri(environ) - def generate(self, name, **kwargs): + def generate(self, name: str, **kwargs): """ generate full qualified url for named url pattern with kwargs """ path = self.urlmapper.generate(name, **kwargs) return self.make_full_qualified_url(path) - def make_full_qualified_url(self, path): + def make_full_qualified_url(self, path: str) -> str: """ append application url to path""" return self.application_uri.rstrip('/') + '/' + path.lstrip('/') @@ -64,25 +72,27 @@ class URLDispatcher(DispatchBase): """ def __init__(self, - applications=None, - extra_environ=None, - **kwargs): + *, + applications: Dict[str, Callable] = None, + extra_environ: Dict[str, Any] = None, + converters: Dict[str, Callable] = None, + urlmapper: URLMapper = None, + prefix: str = "") -> None: super(URLDispatcher, self).__init__( applications=applications, extra_environ=extra_environ) - converters = kwargs.get('converters') - if 'urlmapper' in kwargs: - self.urlmapper = kwargs['urlmapper'] + if urlmapper: + self.urlmapper = urlmapper else: self.urlmapper = URLMapper(converters=converters) - self.prefix = kwargs.get('prefix', '') + self.prefix = prefix - def add_url(self, name, pattern, application): + def add_url(self, name: str, pattern: str, application: Callable) -> None: """ add url pattern dispatching to application""" self.urlmapper.add(name, self.prefix + pattern) self.register_app(name, application) - def add_subroute(self, pattern): + def add_subroute(self, pattern: str) -> "URLDispatcher": """ create new URLDispatcher routed by pattern """ return URLDispatcher( urlmapper=self.urlmapper, @@ -90,7 +100,7 @@ def add_subroute(self, pattern): applications=self.applications, extra_environ=self.extra_environ) - def detect_view_name(self, environ): + def detect_view_name(self, environ: Dict[str, Any]) -> str: """ detect view name from environ """ script_name = environ.get('SCRIPT_NAME', '') path_info = environ.get('PATH_INFO', '') @@ -98,23 +108,28 @@ def detect_view_name(self, environ): if match is None: return None - extra_path_info = path_info[match["matchlength"]:] - pos_args = [] - named_args = match["matchdict"] - cur_pos, cur_named = environ.get('wsgiorg.routing_args', ((), {})) + splited = match.split_path_info(path_info) + extra_path_info = splited[1] + pos_args = [] # type: List[str] + + routing_args = environ.get('wsgiorg.routing_args', ((), {})) + (cur_pos, cur_named) = routing_args new_pos = list(cur_pos) + list(pos_args) - new_named = cur_named.copy() - new_named.update(named_args) + new_named = match.new_named_args(cur_named) environ['wsgiorg.routing_args'] = (new_pos, new_named) environ['webdispatch.urlmapper'] = self.urlmapper urlgenerator = URLGenerator(environ, self.urlmapper) environ['webdispatch.urlgenerator'] = urlgenerator - environ['SCRIPT_NAME'] = script_name + path_info[:match["matchlength"]] + environ['SCRIPT_NAME'] = script_name + splited[0] environ['PATH_INFO'] = extra_path_info - return match["name"] + return match.name - def on_view_not_found(self, environ, start_response): + def on_view_not_found( + self, + environ: Dict[str, Any], + start_response: Callable[[str, List[Tuple[str, str]]], None], + ) -> Iterable[bytes]: """ called when views not found""" start_response('404 Not Found', [('Content-type', 'text/plain')]) return [b'Not found']