diff --git a/README.md b/README.md index 496b5bf82..192371a1c 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,21 @@ pypi: https://pypi.org/project/aio.run.runner --- +#### [aio.web](aio.web) + +version: 0.1.0.dev0 + +pypi: https://pypi.org/project/aio.web + +##### requirements: + +- [abstracts](https://pypi.org/project/abstracts) >=0.0.12 +- [aiohttp](https://pypi.org/project/aiohttp) +- [pyyaml](https://pypi.org/project/pyyaml) + +--- + + #### [dependatool](dependatool) version: 0.2.3.dev0 diff --git a/aio.web/BUILD b/aio.web/BUILD new file mode 100644 index 000000000..b42c30a6c --- /dev/null +++ b/aio.web/BUILD @@ -0,0 +1,2 @@ + +toolshed_package("aio.web") diff --git a/aio.web/README.rst b/aio.web/README.rst new file mode 100644 index 000000000..3e6f2e53f --- /dev/null +++ b/aio.web/README.rst @@ -0,0 +1,5 @@ + +aio.web +======= + +Web utils for asyncio. diff --git a/aio.web/VERSION b/aio.web/VERSION new file mode 100644 index 000000000..0d4d12494 --- /dev/null +++ b/aio.web/VERSION @@ -0,0 +1 @@ +0.1.0-dev diff --git a/aio.web/aio/web/BUILD b/aio.web/aio/web/BUILD new file mode 100644 index 000000000..8a9f69137 --- /dev/null +++ b/aio.web/aio/web/BUILD @@ -0,0 +1,20 @@ + +toolshed_library( + "aio.web", + dependencies=[ + "//deps:reqs#abstracts", + "//deps:reqs#aiohttp", + "//deps:reqs#pyyaml", + ], + sources=[ + "__init__.py", + "abstract/__init__.py", + "abstract/downloader.py", + "abstract/repository.py", + "downloader.py", + "exceptions.py", + "interface.py", + "repository.py", + "typing.py", + ], +) diff --git a/aio.web/aio/web/__init__.py b/aio.web/aio/web/__init__.py new file mode 100644 index 000000000..6c9f02e07 --- /dev/null +++ b/aio.web/aio/web/__init__.py @@ -0,0 +1,22 @@ + +from .abstract import ( + ADownloader, + AChecksumDownloader, + ARepositoryMirrors, + ARepositoryRequest) +from .interface import ( + IDownloader, + IChecksumDownloader, + IRepositoryMirrors, + IRepositoryRequest) + + +__all__ = ( + "ADownloader", + "AChecksumDownloader", + "ARepositoryMirrors", + "ARepositoryRequest", + "IDownloader", + "IChecksumDownloader", + "IRepositoryMirrors", + "IRepositoryRequest") diff --git a/aio.web/aio/web/abstract/__init__.py b/aio.web/aio/web/abstract/__init__.py new file mode 100644 index 000000000..e096ad400 --- /dev/null +++ b/aio.web/aio/web/abstract/__init__.py @@ -0,0 +1,9 @@ +from .downloader import ADownloader, AChecksumDownloader +from .repository import ARepositoryRequest, ARepositoryMirrors + + +__all__ = ( + "ADownloader", + "AChecksumDownloader", + "ARepositoryMirrors", + "ARepositoryRequest") diff --git a/aio.web/aio/web/abstract/downloader.py b/aio.web/aio/web/abstract/downloader.py new file mode 100644 index 000000000..8b2f6e7dc --- /dev/null +++ b/aio.web/aio/web/abstract/downloader.py @@ -0,0 +1,43 @@ + +import hashlib + +import aiohttp + +import abstracts + +from aio.web import exceptions, interface + + +@abstracts.implementer(interface.IDownloader) +class ADownloader(metaclass=abstracts.Abstraction): + + def __init__(self, url: str) -> None: + self.url = url + + async def download(self) -> bytes: + """Download content from the interwebs.""" + async with aiohttp.ClientSession() as session: + async with session.get(self.url) as resp: + return await resp.content.read() + + +@abstracts.implementer(interface.IChecksumDownloader) +class AChecksumDownloader(ADownloader, metaclass=abstracts.Abstraction): + + def __init__(self, url: str, sha: str) -> None: + super().__init__(url) + self.sha = sha + + async def checksum(self, content: bytes) -> None: + """Download content from the interwebs.""" + # do this in a thread + m = hashlib.sha256() + m.update(content) + if m.digest().hex() != self.sha: + raise exceptions.ChecksumError( + f"Bad checksum, {m.digest().hex()}, expected {self.sha}") + + async def download(self) -> bytes: + content = await super().download() + await self.checksum(content) + return content diff --git a/aio.web/aio/web/abstract/repository.py b/aio.web/aio/web/abstract/repository.py new file mode 100644 index 000000000..c1a84dd89 --- /dev/null +++ b/aio.web/aio/web/abstract/repository.py @@ -0,0 +1,89 @@ + +import pathlib +import re +from functools import cached_property + +import yaml + +from aiohttp import web + +import abstracts + +from aio.web import exceptions, interface + + +@abstracts.implementer(interface.IRepositoryRequest) +class ARepositoryRequest(metaclass=abstracts.Abstraction): + + def __init__(self, url, config, request): + self._url = url + self.config = config + self.request = request + + @property + def requested_repo(self): + return ( + f"{self.request.match_info['owner']}" + f"/{self.request.match_info['repo']}") + + @property + def url(self) -> str: + return f"https://{self._url}/{self.requested_repo}/{self.path}" + + @property + def path(self): + return self.matched["path"] + + @property + def sha(self): + return self.matched["sha"] + + @cached_property + def matched(self) -> dict: + for repo in self.config: + if not re.match(repo, self.requested_repo): + continue + + for path, sha in self.config[repo].items(): + if path == self.request.match_info["extra"]: + return dict(path=path, sha=sha) + return {} + + @property # type: ignore + @abstracts.interfacemethod + def downloader_class(self): + raise NotImplementedError + + async def fetch(self): + content = await self.downloader_class(self.url, self.sha).download() + response = web.Response(body=content) + response.headers["cache-control"] = "max-age=31536000" + return response + + def match(self): + if not self.matched: + raise exceptions.MatchError() + return self + + +@abstracts.implementer(interface.IRepositoryMirrors) +class ARepositoryMirrors(metaclass=abstracts.Abstraction): + + def __init__(self, config_path): + self.config_path = config_path + + @cached_property + def config(self): + return yaml.safe_load(pathlib.Path(self.config_path).read_text()) + + @property # type: ignore + @abstracts.interfacemethod + def request_class(self): + raise NotImplementedError + + async def match(self, request): + host = request.match_info['host'] + if host not in self.config: + raise exceptions.MatchError() + upstream_request = self.request_class(host, self.config[host], request) + return upstream_request.match() diff --git a/aio.web/aio/web/downloader.py b/aio.web/aio/web/downloader.py new file mode 100644 index 000000000..4f65e0ccd --- /dev/null +++ b/aio.web/aio/web/downloader.py @@ -0,0 +1,14 @@ + +import abstracts + +from aio.web import abstract + + +@abstracts.implementer(abstract.ADownloader) +class Downloader: + pass + + +@abstracts.implementer(abstract.AChecksumDownloader) +class ChecksumDownloader: + pass diff --git a/aio.web/aio/web/exceptions.py b/aio.web/aio/web/exceptions.py new file mode 100644 index 000000000..441d0489e --- /dev/null +++ b/aio.web/aio/web/exceptions.py @@ -0,0 +1,11 @@ + +class ChecksumError(Exception): + pass + + +class DownloadError(Exception): + pass + + +class MatchError(Exception): + pass diff --git a/aio.web/aio/web/interface.py b/aio.web/aio/web/interface.py new file mode 100644 index 000000000..30d4273e9 --- /dev/null +++ b/aio.web/aio/web/interface.py @@ -0,0 +1,28 @@ + +from aiohttp import web + +import abstracts + + +class IDownloader(metaclass=abstracts.Interface): + + @abstracts.interfacemethod + async def download(self) -> web.Response: + """Download content from the interwebs.""" + raise NotImplementedError + + +class IChecksumDownloader(IDownloader, metaclass=abstracts.Interface): + + @abstracts.interfacemethod + async def checksum(self, content: bytes) -> bool: + """Checksum some content.""" + raise NotImplementedError + + +class IRepositoryRequest(metaclass=abstracts.Interface): + pass + + +class IRepositoryMirrors(metaclass=abstracts.Interface): + pass diff --git a/aio.web/aio/web/py.typed b/aio.web/aio/web/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/aio.web/aio/web/repository.py b/aio.web/aio/web/repository.py new file mode 100644 index 000000000..c0a4bfb50 --- /dev/null +++ b/aio.web/aio/web/repository.py @@ -0,0 +1,20 @@ + +import abstracts + +from aio.web import abstract, downloader + + +@abstracts.implementer(abstract.ARepositoryRequest) +class RepositoryRequest: + + @property + def downloader_class(self): + return downloader.ChecksumDownloader + + +@abstracts.implementer(abstract.ARepositoryMirrors) +class RepositoryMirrors: + + @property + def request_class(self): + return RepositoryRequest diff --git a/aio.web/aio/web/typing.py b/aio.web/aio/web/typing.py new file mode 100644 index 000000000..e69de29bb diff --git a/aio.web/setup.cfg b/aio.web/setup.cfg new file mode 100644 index 000000000..f12a65423 --- /dev/null +++ b/aio.web/setup.cfg @@ -0,0 +1,55 @@ +[metadata] +name = aio.web +version = file: VERSION +author = Ryan Northey +author_email = ryan@synca.io +maintainer = Ryan Northey +maintainer_email = ryan@synca.io +license = Apache Software License 2.0 +url = https://github.com/envoyproxy/toolshed/tree/main/aio.web +description = A collection of functional utils for asyncio +long_description = file: README.rst +classifiers = + Development Status :: 4 - Beta + Framework :: Pytest + Intended Audience :: Developers + Topic :: Software Development :: Testing + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: Implementation :: CPython + Operating System :: OS Independent + License :: OSI Approved :: Apache Software License + +[options] +python_requires = >=3.8 +py_modules = aio.web +packages = find_namespace: +install_requires = + abstracts>=0.0.12 + aiohttp + pyyaml + +[options.extras_require] +test = + pytest + pytest-asyncio + pytest-coverage + pytest-iters + pytest-patches +lint = flake8 +types = + mypy +publish = wheel + +[options.package_data] +* = py.typed + +[options.packages.find] +include = aio.* +exclude = + build.* + tests.* + dist.* diff --git a/aio.web/setup.py b/aio.web/setup.py new file mode 100644 index 000000000..1f6a64b9c --- /dev/null +++ b/aio.web/setup.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python + +from setuptools import setup # type:ignore + +setup() diff --git a/aio.web/tests/BUILD b/aio.web/tests/BUILD new file mode 100644 index 000000000..74b0cb056 --- /dev/null +++ b/aio.web/tests/BUILD @@ -0,0 +1,10 @@ + +toolshed_tests( + "aio.web", + dependencies=[ + "//deps:reqs#abstracts", + "//deps:reqs#aiohttp", + "//deps:reqs#pyyaml", + "//deps:reqs#pytest-asyncio", + ], +) diff --git a/aio.web/tests/test_downloader.py b/aio.web/tests/test_downloader.py new file mode 100644 index 000000000..57d9e0c53 --- /dev/null +++ b/aio.web/tests/test_downloader.py @@ -0,0 +1,36 @@ + +from aio.web import abstract, downloader + + +def test_downloader_constructor(patches): + args = tuple(f"ARG{i}" for i in range(0, 3)) + kwargs = {f"K{i}": f"V{i}" for i in range(0, 3)} + patched = patches( + "abstract.ADownloader.__init__", + prefix="aio.web.downloader") + + with patched as (m_super, ): + m_super.return_value = None + dl = downloader.Downloader(*args, **kwargs) + + assert isinstance(dl, abstract.ADownloader) + assert ( + m_super.call_args + == [args, kwargs]) + + +def test_checksum_downloader_constructor(patches): + args = tuple(f"ARG{i}" for i in range(0, 3)) + kwargs = {f"K{i}": f"V{i}" for i in range(0, 3)} + patched = patches( + "abstract.AChecksumDownloader.__init__", + prefix="aio.web.downloader") + + with patched as (m_super, ): + m_super.return_value = None + dl = downloader.ChecksumDownloader(*args, **kwargs) + + assert isinstance(dl, abstract.AChecksumDownloader) + assert ( + m_super.call_args + == [args, kwargs]) diff --git a/aio.web/tests/test_interface.py b/aio.web/tests/test_interface.py new file mode 100644 index 000000000..8680581da --- /dev/null +++ b/aio.web/tests/test_interface.py @@ -0,0 +1,12 @@ + +import pytest + +from aio import web + + +@pytest.mark.parametrize( + "interface", + [web.IDownloader, + web.IChecksumDownloader]) +async def test_interfaces(iface, interface): + await iface(interface).check()