diff --git a/giftless/auth/github.py b/giftless/auth/github.py index 4154e4a..ee8ff82 100644 --- a/giftless/auth/github.py +++ b/giftless/auth/github.py @@ -8,7 +8,7 @@ from contextlib import AbstractContextManager from operator import attrgetter, itemgetter from threading import Condition, Lock, RLock -from typing import Any +from typing import Any, cast, overload import cachetools.keys import flask @@ -16,8 +16,8 @@ import marshmallow.validate import requests -from giftless.auth import Identity, Unauthorized -from giftless.auth.identity import Permission +from giftless.auth import Unauthorized +from giftless.auth.identity import Identity, Permission _logger = logging.getLogger(__name__) @@ -45,12 +45,24 @@ def _ensure_lock( return existing_lock +@overload +def single_call_method(_method: Callable[..., Any]) -> Callable[..., Any]: ... + + +@overload +def single_call_method( + *, + key: Callable[..., Any] = cachetools.keys.methodkey, + lock: Callable[[Any], AbstractContextManager] | None = None, +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... + + def single_call_method( - _method: Callable[[...], Any] | None = None, + _method: Callable[..., Any] | None = None, *, - key: Callable = cachetools.keys.methodkey, + key: Callable[..., Any] = cachetools.keys.methodkey, lock: Callable[[Any], AbstractContextManager] | None = None, -) -> Callable[[...], Any]: +) -> Callable[..., Any]: """Thread-safe decorator limiting concurrency of an idempotent method call. When multiple threads concurrently call the decorated method with the same arguments (governed by the 'key' callable argument), only the first one @@ -68,9 +80,9 @@ def single_call_method( """ lock = _ensure_lock(lock) - def decorator(method: Callable) -> Callable: + def decorator(method: Callable[..., Any]) -> Callable[..., Any]: # tracking concurrent calls per method arguments - concurrent_calls = {} + concurrent_calls: dict[Any, SingleCallContext] = {} @functools.wraps(method) def wrapper(self: Any, *args: tuple, **kwargs: dict) -> Any: @@ -123,13 +135,13 @@ def wrapper(self: Any, *args: tuple, **kwargs: dict) -> Any: def cachedmethod_threadsafe( cache: Callable[[Any], MutableMapping], - key: Callable = cachetools.keys.methodkey, + key: Callable[..., Any] = cachetools.keys.methodkey, lock: Callable[[Any], AbstractContextManager] | None = None, -) -> Callable: +) -> Callable[..., Any]: """Threadsafe variant of cachetools.cachedmethod.""" lock = _ensure_lock(lock) - def decorator(method: Callable) -> Callable: + def decorator(method: Callable[..., Any]) -> Callable[..., Any]: @cachetools.cachedmethod(cache=cache, key=key, lock=lock) @single_call_method(key=key, lock=lock) @functools.wraps(method) @@ -216,7 +228,7 @@ def make_object( @classmethod def from_dict(cls, data: Mapping[str, Any]) -> "Config": - return cls.Schema().load(data, unknown=ma.RAISE) + return cast(Config, cls.Schema().load(data, unknown=ma.RAISE)) # CORE AUTH @@ -288,19 +300,19 @@ def is_authorized( oid: str | None = None, ) -> bool: permissions = self.permissions(organization, repo) - return permissions and permission in permissions + return permission in permissions if permissions else False def cache_ttl(self, permissions: set[Permission]) -> float: """Return default cache TTL [seconds] for a certain permission set.""" return self._auth_cache.ttu(None, permissions, 0.0) @staticmethod - def cache_key(data: dict) -> tuple: + def cache_key(data: Mapping[str, Any]) -> tuple: """Return caching key from significant fields.""" return cachetools.keys.hashkey(*itemgetter("login", "id")(data)) @classmethod - def from_dict(cls, data: dict, cc: CacheConfig) -> "GithubIdentity": + def from_dict(cls, data: Mapping[str, Any], cc: CacheConfig) -> "GithubIdentity": return cls(*itemgetter("login", "id", "name", "email")(data), cc=cc) @@ -344,26 +356,28 @@ def __init__(self, cfg: Config) -> None: if cfg.api_version: self._api_headers["X-GitHub-Api-Version"] = cfg.api_version # user identities per raw user data (keeping them authorized) - self._user_cache = cachetools.LRUCache(maxsize=cfg.cache.user_max_size) + self._user_cache: MutableMapping[Any, GithubIdentity] = ( + cachetools.LRUCache(maxsize=cfg.cache.user_max_size) + ) # user identities per token (shortcut to the cached entries above) - self._token_cache = cachetools.LRUCache( - maxsize=cfg.cache.token_max_size + self._token_cache: MutableMapping[Any, GithubIdentity] = ( + cachetools.LRUCache(maxsize=cfg.cache.token_max_size) ) self._cache_config = cfg.cache - def _api_get(self, uri: str, ctx: CallContext) -> dict: + def _api_get(self, uri: str, ctx: CallContext) -> Mapping[str, Any]: response = ctx.session.get( f"{self._api_url}{uri}", headers={"Authorization": f"Bearer {ctx.token}"}, ) response.raise_for_status() - return response.json() + return cast(Mapping[str, Any], response.json()) @cachedmethod_threadsafe( attrgetter("_user_cache"), lambda self, data: GithubIdentity.cache_key(data), ) - def _get_user_cached(self, data: dict) -> GithubIdentity: + def _get_user_cached(self, data: Mapping[str, Any]) -> GithubIdentity: """Return internal GitHub user identity from raw GitHub user data [cached per login & id]. """ @@ -385,7 +399,7 @@ def _authenticate(self, ctx: CallContext) -> GithubIdentity: raise Unauthorized(msg) from None # different tokens can bear the same identity - return self._get_user_cached(user_data) + return cast(GithubIdentity, self._get_user_cached(user_data)) @staticmethod def _perm_list(permissions: set[Permission]) -> str: @@ -448,7 +462,7 @@ def __call__(self, request: flask.Request) -> Identity | None: with requests.Session() as session: session.headers.update(self._api_headers) ctx = self.CallContext(request, session) - user = self._authenticate(ctx) + user: GithubIdentity = self._authenticate(ctx) _logger.info(f"Authenticated the user as {user}") self._authorize(ctx, user) return user diff --git a/requirements/dev.in b/requirements/dev.in index f8305f0..e32f3ba 100644 --- a/requirements/dev.in +++ b/requirements/dev.in @@ -15,6 +15,7 @@ types-pytz types-jwt types-python-dateutil types-PyYAML +types-cachetools boto3-stubs botocore-stubs diff --git a/requirements/dev.txt b/requirements/dev.txt index 6ced793..d8030a4 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.12 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --no-emit-index-url --output-file=requirements/dev.txt requirements/dev.in @@ -44,15 +44,17 @@ colorama==0.4.6 commonmark==0.9.1 # via recommonmark coverage[toml]==7.4.0 - # via - # coverage - # pytest-cov + # via pytest-cov distlib==0.3.8 # via virtualenv docutils==0.20.1 # via # recommonmark # sphinx +exceptiongroup==1.2.0 + # via + # -c requirements/main.txt + # pytest filelock==3.13.1 # via # pytest-mypy @@ -199,10 +201,23 @@ sphinxcontrib-qthelp==1.0.7 # via sphinx sphinxcontrib-serializinghtml==1.1.10 # via sphinx +tomli==2.0.1 + # via + # build + # coverage + # mypy + # pip-tools + # pyproject-api + # pyproject-hooks + # pytest + # pytest-env + # tox tox==4.12.0 # via -r requirements/dev.in types-awscrt==0.20.0 # via botocore-stubs +types-cachetools==5.3.0.7 + # via -r requirements/dev.in types-cryptography==3.3.23.2 # via types-jwt types-jwt==0.1.3 @@ -220,6 +235,7 @@ types-s3transfer==0.10.0 typing-extensions==4.9.0 # via # -c requirements/main.txt + # boto3-stubs # mypy urllib3==2.0.7 # via