From 574c0f85a64153802ca2587a9dc79486e1cf0a12 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 16 Aug 2024 11:26:19 +0100 Subject: [PATCH] Modernize (#52) * modernising * linting, fix mypy, fix ci * pre-commit * remove uv from CI * fix deps and coverage * fix test deps * drop 3.9 and 3.13 * drop 3.9 from ci, use correct timezone --- .github/workflows/ci.yml | 85 ++++++++++-------- .gitignore | 2 +- .pre-commit-config.yaml | 32 +++++++ Makefile | 36 +++++--- README.md | 2 +- aioaws/_utils.py | 17 ++-- aioaws/core.py | 37 ++++---- aioaws/s3.py | 44 ++++----- aioaws/ses.py | 73 +++++++-------- aioaws/sns.py | 14 +-- aioaws/sqs.py | 15 ++-- aioaws/testing.py | 10 ++- aioaws/version.py | 2 +- pyproject.toml | 53 +++++------ requirements/all.txt | 3 + requirements/linting.in | 3 + requirements/linting.txt | 16 ++++ requirements/pyproject.txt | 76 ++++++++++++++++ requirements/tests.in | 12 +++ requirements/tests.txt | 159 +++++++++++++++++++++++++++++++++ tests/conftest.py | 2 +- tests/dummy_server.py | 3 +- tests/requirements-linting.txt | 5 -- tests/requirements.txt | 8 -- tests/test_s3.py | 12 +-- tests/test_ses.py | 84 ++++++++--------- tests/test_sqs.py | 12 +-- 27 files changed, 568 insertions(+), 249 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 requirements/all.txt create mode 100644 requirements/linting.in create mode 100644 requirements/linting.txt create mode 100644 requirements/pyproject.txt create mode 100644 requirements/tests.in create mode 100644 requirements/tests.txt delete mode 100644 tests/requirements-linting.txt delete mode 100644 tests/requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a1c1a8..89fb8b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,13 +9,32 @@ on: pull_request: {} jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - run: pip install -r requirements/linting.txt + - run: pip install -r requirements/pyproject.txt + + - uses: pre-commit/action@v3.0.0 + with: + extra_args: --all-files + env: + SKIP: no-commit-to-branch + test: name: test py${{ matrix.python-version }} on ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu, windows, macos] - python-version: ['3.8', '3.9', '3.10', '3.11'] + os: [ubuntu, macos] + python-version: ['3.10', '3.11', '3.12'] runs-on: ${{ matrix.os }}-latest @@ -24,15 +43,16 @@ jobs: OS: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: set up python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - - run: pip install -U wheel - - run: pip install -r tests/requirements.txt + - run: pip install -r requirements/tests.txt + - run: pip install -r requirements/pyproject.txt - run: pip install . - run: pip freeze @@ -49,53 +69,46 @@ jobs: file: ./coverage.xml env_vars: PYTHON,OS - lint: + check: # This job does nothing and is only used for the branch protection + if: always() + needs: [lint, test] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-python@v4 - with: - python-version: '3.9' - - - run: pip install -r tests/requirements-linting.txt - - run: pip install . - - - run: make lint - - run: make mypy + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + id: all-green + with: + jobs: ${{ toJSON(needs) }} - deploy: - needs: - - test - - lint + release: + needs: [check] if: "success() && startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest + environment: release + + permissions: + id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: set up python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.12' - name: install - run: pip install -U twine build packaging + run: pip install -U build - name: check version id: check-version - run: python <(curl -Ls https://gist.githubusercontent.com/samuelcolvin/4e1ad439c5489e8d6478cdee3eb952ef/raw/check_version.py) - env: - VERSION_PATH: 'aioaws/version.py' + uses: samuelcolvin/check-python-version@v3.2 + with: + version_file_path: 'aioaws/version.py' - name: build run: python -m build - - run: twine check dist/* - - - name: upload to pypi - run: twine upload dist/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.pypi_token }} + - name: Upload package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index caa9752..1d79548 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ *.py[cod] .idea/ -env/ +/env/ /env*/ .coverage .cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..603812c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: no-commit-to-branch + - id: check-yaml + args: ['--unsafe'] + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-added-large-files + +- repo: local + hooks: + - id: format + name: format + entry: make format + types: [python] + language: system + pass_filenames: false + - id: lint + name: lint + entry: make lint + types: [python] + language: system + pass_filenames: false + - id: mypy + name: mypy + entry: make mypy + types: [python] + language: system + pass_filenames: false diff --git a/Makefile b/Makefile index 5448e0b..89a3ff3 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,36 @@ .DEFAULT_GOAL := all -isort = isort aioaws tests -black = black aioaws tests -ruff = ruff aioaws tests .PHONY: install install: - python -m pip install -U setuptools pip - pip install -U -r requirements.txt - pip install -U -r tests/requirements-linting.txt - pip install -e . + pip install -U pip pre-commit pip-tools + pip install -r requirements/all.txt + pre-commit install + +.PHONY: refresh-lockfiles +refresh-lockfiles: + @echo "Replacing requirements/*.txt files using pip-compile" + find requirements/ -name '*.txt' ! -name 'all.txt' -type f -delete + make update-lockfiles + +.PHONY: update-lockfiles +update-lockfiles: + @echo "Updating requirements/*.txt files using pip-compile" + pip-compile --strip-extras -q -o requirements/linting.txt requirements/linting.in + pip-compile --strip-extras -q -o requirements/tests.txt -c requirements/linting.txt requirements/tests.in + pip-compile --strip-extras -q -o requirements/pyproject.txt \ + -c requirements/linting.txt -c requirements/tests.txt \ + pyproject.toml + pip install --dry-run -r requirements/all.txt .PHONY: format format: - $(isort) - $(black) - $(ruff) --fix --exit-zero + ruff check --fix-only aioaws tests + ruff format aioaws tests .PHONY: lint lint: - $(ruff) - $(isort) --check-only --df - $(black) --check --diff + ruff check aioaws tests + ruff format --check aioaws tests .PHONY: mypy mypy: diff --git a/README.md b/README.md index 26db104..5be9f59 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ async def ses_webhook(request: Request): except SnsWebhookError as e: debug(message=e.message, details=e.details, headers=e.headers) raise ... - + debug(webhook_info) ... ``` diff --git a/aioaws/_utils.py b/aioaws/_utils.py index 691fda5..b922869 100644 --- a/aioaws/_utils.py +++ b/aioaws/_utils.py @@ -1,6 +1,7 @@ import asyncio +from collections.abc import Coroutine, Iterable from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Coroutine, Iterable, List, Optional +from typing import TYPE_CHECKING, Any from httpx import Response @@ -34,7 +35,7 @@ def to_unix_s(dt: datetime) -> int: def utcnow() -> datetime: - return datetime.utcnow().replace(tzinfo=timezone.utc) + return datetime.now(tz=timezone.utc) class ManyTasks: @@ -45,9 +46,9 @@ class ManyTasks: __slots__ = '_tasks' def __init__(self) -> None: - self._tasks: List[asyncio.Task[Any]] = [] + self._tasks: list[asyncio.Task[Any]] = [] - def add(self, coroutine: Coroutine[Any, Any, List[str]], *, name: Optional[str] = None) -> None: + def add(self, coroutine: Coroutine[Any, Any, list[str]], *, name: str | None = None) -> None: task = asyncio.create_task(coroutine, name=name) self._tasks.append(task) @@ -67,7 +68,13 @@ def pretty_xml(response_xml: bytes) -> str: def pretty_response(r: Response) -> None: # pragma: no cover - from devtools import debug + try: + from devtools import debug + except ImportError: + from pprint import pprint + + def debug(**kwargs: Any) -> None: + pprint(kwargs) debug( status=r.status_code, diff --git a/aioaws/core.py b/aioaws/core.py index cf3bb18..30668d0 100644 --- a/aioaws/core.py +++ b/aioaws/core.py @@ -3,9 +3,10 @@ import hmac import logging from binascii import hexlify +from collections.abc import Generator from datetime import datetime from functools import reduce -from typing import TYPE_CHECKING, Any, Dict, Generator, List, Literal, Optional, Tuple +from typing import TYPE_CHECKING, Any, Literal from urllib.parse import quote as url_quote from httpx import URL, AsyncClient, Auth, Request, Response @@ -61,7 +62,7 @@ def __init__(self, client: AsyncClient, config: 'BaseConfigProtocol', service: L def endpoint(self) -> str: return f'{self.schema}://{self.host}' - async def get(self, path: str = '', *, params: Optional[Dict[str, Any]] = None) -> Response: + async def get(self, path: str = '', *, params: dict[str, Any] | None = None) -> Response: return await self.request('GET', path=path, params=params) async def raw_post( @@ -69,9 +70,9 @@ async def raw_post( url: str, *, expected_status: int, - params: Optional[Dict[str, Any]] = None, - data: Optional[Dict[str, str]] = None, - files: Optional[Dict[str, bytes]] = None, + params: dict[str, Any] | None = None, + data: dict[str, str] | None = None, + files: dict[str, bytes] | None = None, ) -> Response: r = await self.client.post(url, params=params, data=data, files=files) if r.status_code == expected_status: @@ -85,9 +86,9 @@ async def post( self, path: str = '', *, - params: Optional[Dict[str, Any]] = None, - data: Optional[bytes] = None, - content_type: Optional[str] = None, + params: dict[str, Any] | None = None, + data: bytes | None = None, + content_type: str | None = None, ) -> Response: return await self.request('POST', path=path, params=params, data=data, content_type=content_type) @@ -96,9 +97,9 @@ async def request( method: Literal['GET', 'POST'], *, path: str, - params: Optional[Dict[str, Any]], - data: Optional[bytes] = None, - content_type: Optional[str] = None, + params: dict[str, Any] | None, + data: bytes | None = None, + content_type: str | None = None, ) -> Response: url = URL(f'{self.endpoint}{path}', params=[(k, v) for k, v in sorted((params or {}).items())]) r = await self.client.request( @@ -129,14 +130,14 @@ def add_signed_download_params(self, method: Literal['GET', 'POST'], url: URL, e _, signature = self._auth.aws4_signature(now, method, url, {'host': self.host}, 'UNSIGNED-PAYLOAD') return url.copy_add_param('X-Amz-Signature', signature) - def upload_extra_conditions(self, dt: datetime) -> List[Dict[str, str]]: + def upload_extra_conditions(self, dt: datetime) -> list[dict[str, str]]: return [ {'x-amz-credential': self._auth.aws4_credential(dt)}, {'x-amz-algorithm': _AUTH_ALGORITHM}, {'x-amz-date': _aws4_x_amz_date(dt)}, ] - def signed_upload_fields(self, dt: datetime, string_to_sign: str) -> Dict[str, str]: + def signed_upload_fields(self, dt: datetime, string_to_sign: str) -> dict[str, str]: return { 'X-Amz-Algorithm': _AUTH_ALGORITHM, 'X-Amz-Credential': self._auth.aws4_credential(dt), @@ -163,9 +164,9 @@ def auth_headers( method: Literal['GET', 'POST'], url: URL, *, - data: Optional[bytes] = None, - content_type: Optional[str] = None, - ) -> Dict[str, str]: + data: bytes | None = None, + content_type: str | None = None, + ) -> dict[str, str]: now = utcnow() data = data or b'' content_type = content_type or _CONTENT_TYPE @@ -188,8 +189,8 @@ def auth_headers( return headers def aws4_signature( - self, dt: datetime, method: Literal['GET', 'POST'], url: URL, headers: Dict[str, str], payload_hash: str - ) -> Tuple[str, str]: + self, dt: datetime, method: Literal['GET', 'POST'], url: URL, headers: dict[str, str], payload_hash: str + ) -> tuple[str, str]: header_keys = sorted(headers) signed_headers = ';'.join(header_keys) canonical_request_parts = ( diff --git a/aioaws/s3.py b/aioaws/s3.py index 3b0c0e5..d44d15b 100644 --- a/aioaws/s3.py +++ b/aioaws/s3.py @@ -1,15 +1,17 @@ import base64 +import builtins import json import mimetypes import re +from collections.abc import AsyncIterable from dataclasses import dataclass from datetime import datetime, timedelta from itertools import chain -from typing import TYPE_CHECKING, Any, AsyncIterable, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any from xml.etree import ElementTree from httpx import URL, AsyncClient -from pydantic import BaseModel, validator +from pydantic import BaseModel, ConfigDict, field_validator from ._utils import ManyTasks, pretty_xml, utcnow from .core import AwsClient, RequestError @@ -33,25 +35,25 @@ class S3Config: aws_region: str aws_s3_bucket: str # custom host to connect with - aws_host: Optional[str] = None + aws_host: str | None = None + + +def alias_generator(string: str) -> str: + return ''.join(word.capitalize() for word in string.split('_')) class S3File(BaseModel): + model_config = ConfigDict(alias_generator=alias_generator) key: str last_modified: datetime size: int e_tag: str storage_class: str - @validator('e_tag') + @field_validator('e_tag') def set_ts_now(cls, v: str) -> str: return v.strip('"') - class Config: - @classmethod - def alias_generator(cls, string: str) -> str: - return ''.join(word.capitalize() for word in string.split('_')) - class S3Client: __slots__ = '_config', '_aws_client' @@ -60,7 +62,7 @@ def __init__(self, http_client: AsyncClient, config: 'S3ConfigProtocol'): self._aws_client = AwsClient(http_client, config, 's3') self._config = config - async def list(self, prefix: Optional[str] = None) -> AsyncIterable[S3File]: + async def list(self, prefix: str | None = None) -> AsyncIterable[S3File]: """ List S3 files with the given prefix. @@ -75,7 +77,7 @@ async def list(self, prefix: Optional[str] = None) -> AsyncIterable[S3File]: xml_root = ElementTree.fromstring(xmlns_re.sub(b'', r.content)) for c in xml_root.findall('Contents'): - yield S3File.parse_obj({v.tag: v.text for v in c}) + yield S3File.model_validate({v.tag: v.text for v in c}) if (t := xml_root.find('IsTruncated')) is not None and t.text == 'false': break @@ -84,7 +86,7 @@ async def list(self, prefix: Optional[str] = None) -> AsyncIterable[S3File]: else: raise RuntimeError(f'unexpected response from S3:\n{pretty_xml(r.content)}') - async def delete(self, *files: Union[str, S3File]) -> List[str]: + async def delete(self, *files: str | S3File) -> builtins.list[str]: """ Delete one or more files, based on keys. """ @@ -96,7 +98,7 @@ async def delete(self, *files: Union[str, S3File]) -> List[str]: results = await tasks.finish() return list(chain(*results)) - async def upload(self, file_path: str, content: bytes, *, content_type: Optional[str] = None) -> None: + async def upload(self, file_path: str, content: bytes, *, content_type: str | None = None) -> None: assert not file_path.startswith('/'), 'file_path must not start with /' parts = file_path.rsplit('/', 1) @@ -108,11 +110,11 @@ async def upload(self, file_path: str, content: bytes, *, content_type: Optional filename=parts[-1], content_type=content_type or 'application/octet-stream', size=len(content), - expires=datetime.utcnow() + timedelta(minutes=30), + expires=utcnow() + timedelta(minutes=30), ) await self._aws_client.raw_post(d['url'], expected_status=204, data=d['fields'], files={'file': content}) - async def delete_recursive(self, prefix: Optional[str]) -> List[str]: + async def delete_recursive(self, prefix: str | None) -> builtins.list[str]: """ Delete files starting with a specific prefix. """ @@ -129,7 +131,7 @@ async def delete_recursive(self, prefix: Optional[str]) -> List[str]: results = await tasks.finish() return list(chain(*results)) - async def _delete_1000_files(self, *files: Union[str, S3File]) -> List[str]: + async def _delete_1000_files(self, *files: str | S3File) -> builtins.list[str]: """ https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html """ @@ -144,7 +146,7 @@ async def _delete_1000_files(self, *files: Union[str, S3File]) -> List[str]: xml_root = ElementTree.fromstring(xmlns_re.sub(b'', r.content)) return [k.find('Key').text for k in xml_root] # type: ignore - def signed_download_url(self, path: str, version: Optional[str] = None, max_age: int = 30) -> str: + def signed_download_url(self, path: str, version: str | None = None, max_age: int = 30) -> str: """ Sign a path to authenticate download. @@ -159,7 +161,7 @@ def signed_download_url(self, path: str, version: Optional[str] = None, max_age: url = url.copy_add_param('v', version) return str(url) - async def download(self, file: Union[str, S3File], version: Optional[str] = None) -> bytes: + async def download(self, file: str | S3File, version: str | None = None) -> bytes: if isinstance(file, str): path = file else: @@ -180,8 +182,8 @@ def signed_upload_url( content_type: str, size: int, content_disp: bool = True, - expires: Optional[datetime] = None, - ) -> Dict[str, Any]: + expires: datetime | None = None, + ) -> dict[str, Any]: """ https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html """ @@ -219,7 +221,7 @@ def signed_upload_url( return dict(url=f'{self._aws_client.endpoint}/', fields=fields) -def to_key(sf: Union[S3File, str]) -> str: +def to_key(sf: S3File | str) -> str: if isinstance(sf, str): return sf elif isinstance(sf, S3File): diff --git a/aioaws/ses.py b/aioaws/ses.py index 5715028..7e1e7a3 100644 --- a/aioaws/ses.py +++ b/aioaws/ses.py @@ -3,6 +3,7 @@ import logging import mimetypes import re +from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime from email.encoders import encode_base64 @@ -10,12 +11,12 @@ from email.mime.base import MIMEBase from email.utils import formataddr from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Literal, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Literal, Optional from urllib.parse import urlencode import aiofiles from httpx import AsyncClient -from pydantic.datetime_parse import parse_datetime +from pydantic import TypeAdapter from . import sns from .core import AwsClient @@ -37,21 +38,21 @@ class SesConfig: @dataclass class SesAttachment: - file: Union[Path, bytes] - name: Optional[str] = None - mime_type: Optional[str] = None - content_id: Optional[str] = None + file: Path | bytes + name: str | None = None + mime_type: str | None = None + content_id: str | None = None @dataclass class SesRecipient: email: str - first_name: Optional[str] = None - last_name: Optional[str] = None + first_name: str | None = None + last_name: str | None = None def display(self) -> str: if self.first_name and self.last_name: - name: Optional[str] = f'{self.first_name} {self.last_name}' + name: str | None = f'{self.first_name} {self.last_name}' elif self.first_name or self.last_name: name = self.first_name or self.last_name else: @@ -68,29 +69,28 @@ def __init__(self, http_client: AsyncClient, config: 'BaseConfigProtocol'): async def send_email( self, - e_from: Union[str, SesRecipient], + e_from: str | SesRecipient, subject: str, - to: Optional[List[Union[str, SesRecipient]]] = None, + to: list[str | SesRecipient] | None = None, text_body: str = '', - html_body: Optional[str] = None, + html_body: str | None = None, *, - cc: Optional[List[Union[str, SesRecipient]]] = None, - bcc: Optional[List[Union[str, SesRecipient]]] = None, - attachments: Optional[List[SesAttachment]] = None, - unsubscribe_link: Optional[str] = None, - configuration_set: Optional[str] = None, - message_tags: Optional[Dict[str, Any]] = None, - smtp_headers: Optional[Dict[str, str]] = None, + cc: list[str | SesRecipient] | None = None, + bcc: list[str | SesRecipient] | None = None, + attachments: list[SesAttachment] | None = None, + unsubscribe_link: str | None = None, + configuration_set: str | None = None, + message_tags: dict[str, Any] | None = None, + smtp_headers: dict[str, str] | None = None, ) -> str: - email_msg = EmailMessage() email_msg['Subject'] = subject e_from_recipient = as_recipient(e_from) email_msg['From'] = e_from_recipient.display() - to_r: List[SesRecipient] = [] - cc_r: List[SesRecipient] = [] - bcc_r: List[SesRecipient] = [] + to_r: list[SesRecipient] = [] + cc_r: list[SesRecipient] = [] + bcc_r: list[SesRecipient] = [] if to: to_r = [as_recipient(r) for r in to] email_msg['To'] = ', '.join(r.display() for r in to_r) @@ -133,9 +133,9 @@ async def send_raw_email( e_from: str, email_msg: EmailMessage, *, - to: List[SesRecipient], - cc: List[SesRecipient], - bcc: List[SesRecipient], + to: list[SesRecipient], + cc: list[SesRecipient], + bcc: list[SesRecipient], ) -> str: if not any((to, cc, bcc)): raise TypeError('either "to", "cc", or "bcc" must be provided when sending emails') @@ -164,14 +164,14 @@ def add_addresses(name: str, addresses: Iterable[str]) -> None: return m.group(1).decode() -def as_recipient(r: Union[str, SesRecipient]) -> SesRecipient: +def as_recipient(r: str | SesRecipient) -> SesRecipient: if isinstance(r, SesRecipient): return r else: return SesRecipient(r) -async def prepare_attachment(a: SesAttachment) -> Tuple[MIMEBase, int]: +async def prepare_attachment(a: SesAttachment) -> tuple[MIMEBase, int]: filename = a.name if filename is None and isinstance(a.file, Path): filename = a.file.name @@ -200,19 +200,22 @@ async def prepare_attachment(a: SesAttachment) -> Tuple[MIMEBase, int]: return msg, len(data) +DateTimeParser = TypeAdapter(datetime) + + @dataclass class SesWebhookInfo: message_id: str event_type: Literal['send', 'delivery', 'open', 'click', 'bounce', 'complaint'] - timestamp: Optional[datetime] + timestamp: datetime | None unsubscribe: bool - details: Dict[str, Any] - tags: Dict[str, str] - full_message: Dict[str, Any] - request_data: Dict[str, Any] + details: dict[str, Any] + tags: dict[str, str] + full_message: dict[str, Any] + request_data: dict[str, Any] @classmethod - async def build(cls, request_body: Union[str, bytes], http_client: AsyncClient) -> Optional['SesWebhookInfo']: + async def build(cls, request_body: str | bytes, http_client: AsyncClient) -> Optional['SesWebhookInfo']: payload = await sns.verify_webhook(request_body, http_client) if not payload: # happens legitimately for subscription confirmation webhooks @@ -248,7 +251,7 @@ async def build(cls, request_body: Union[str, bytes], http_client: AsyncClient) return cls( message_id=message_id, event_type=event_type, - timestamp=timestamp and parse_datetime(timestamp), + timestamp=timestamp and DateTimeParser.validate_strings(timestamp), unsubscribe=unsubscribe, tags={k: v[0] for k, v in tags.items()}, details=details, diff --git a/aioaws/sns.py b/aioaws/sns.py index f9137f6..0a2afcc 100644 --- a/aioaws/sns.py +++ b/aioaws/sns.py @@ -2,21 +2,21 @@ import json import logging import re -from typing import Any, Dict, Literal, Optional, Tuple, Union +from typing import Any, Literal from cryptography import x509 from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding from httpx import AsyncClient -from pydantic import BaseModel, Field, HttpUrl, ValidationError, validator +from pydantic import BaseModel, Field, HttpUrl, ValidationError, field_validator __all__ = 'SnsWebhookError', 'SnsPayload', 'verify_webhook' logger = logging.getLogger('aioaws.sns') class SnsWebhookError(ValueError): - def __init__(self, message: str, details: Any = None, headers: Optional[Dict[str, str]] = None): + def __init__(self, message: str, details: Any = None, headers: dict[str, str] | None = None): super().__init__(message) self.message = message self.details = details @@ -29,14 +29,14 @@ class SnsPayload(BaseModel): signature: bytes = Field(..., alias='Signature') subscribe_url: HttpUrl = Field(None, alias='SubscribeURL') message: str = Field(str, alias='Message') - request_data: Dict[str, Any] + request_data: dict[str, Any] - @validator('signature', pre=True) + @field_validator('signature', mode='before') def base64_signature(cls, sig: str) -> bytes: return base64.b64decode(sig) -async def verify_webhook(request_body: Union[str, bytes], http_client: AsyncClient) -> Optional[SnsPayload]: +async def verify_webhook(request_body: str | bytes, http_client: AsyncClient) -> SnsPayload | None: try: request_data = json.loads(request_body) except ValueError as e: @@ -75,7 +75,7 @@ async def verify_signature(payload: SnsPayload, http_client: AsyncClient) -> Non def get_message(payload: SnsPayload) -> bytes: - keys: Tuple[str, ...] + keys: tuple[str, ...] if payload.type == 'Notification': keys = 'Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type' else: diff --git a/aioaws/sqs.py b/aioaws/sqs.py index 4521305..0363be8 100644 --- a/aioaws/sqs.py +++ b/aioaws/sqs.py @@ -1,6 +1,7 @@ +from collections.abc import AsyncIterator, Iterable, Mapping from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass -from typing import Any, AsyncIterator, Iterable, Mapping, Optional, Union +from typing import Any from httpx import AsyncClient, Timeout from pydantic import BaseModel, Field @@ -23,8 +24,8 @@ class SQSMessage(BaseModel): class PollConfig(BaseModel): - wait_time: int = Field(10, gt=0) - max_messages: int = Field(1, ge=1, le=10) + wait_time: int = Field(default=10, gt=0) + max_messages: int = Field(default=1, ge=1, le=10) @dataclass @@ -48,7 +49,7 @@ def __init__( *, client: AsyncClient, ) -> None: - self._queue_name_or_url: Union[_QueueName, _QueueURL] + self._queue_name_or_url: _QueueName | _QueueURL if queue_name_or_url[:4] == 'http': self._queue_name_or_url = _QueueURL(queue_name_or_url) else: @@ -94,7 +95,7 @@ async def _get_queue_url(self) -> str: async def poll( self, *, - config: Optional[PollConfig] = None, + config: PollConfig | None = None, ) -> AsyncIterator[Iterable[SQSMessage]]: config = config or PollConfig() queue_url = await self._get_queue_url() @@ -119,7 +120,7 @@ async def poll( ) resp.raise_for_status() yield [ - SQSMessage.construct( + SQSMessage.model_construct( message_id=message_data['MessageId'], receipt_handle=message_data['ReceiptHandle'], md5_of_body=message_data['MD5OfBody'], @@ -167,7 +168,7 @@ async def create_sqs_client( queue: str, auth: AWSAuthConfig, *, - client: Optional[AsyncClient] = None, + client: AsyncClient | None = None, ) -> AsyncIterator[SQSClient]: async with AsyncExitStack() as stack: if client is None: diff --git a/aioaws/testing.py b/aioaws/testing.py index 63d0077..d36312f 100644 --- a/aioaws/testing.py +++ b/aioaws/testing.py @@ -1,19 +1,20 @@ import base64 +from collections.abc import Iterable from email import message_from_bytes from email.header import decode_header as _decode_header -from typing import Any, Dict, Iterable, Optional +from typing import Any from uuid import uuid4 __all__ = 'ses_email_data', 'ses_send_response' -def ses_email_data(data: Dict[str, str]) -> Dict[str, Any]: +def ses_email_data(data: dict[str, str]) -> dict[str, Any]: """ Convert raw email body data to a useful representation of an email for testing. """ msg_raw = base64.b64decode(data['RawMessage.Data']) msg = message_from_bytes(msg_raw) - d: Dict[str, Any] = {} + d: dict[str, Any] = {} for k, v in msg.items(): if k != 'Content-Type': d[k] = ''.join(decode_header(v)) @@ -21,6 +22,7 @@ def ses_email_data(data: Dict[str, str]) -> Dict[str, Any]: d['payload'] = [] for part in msg.walk(): if payload := part.get_payload(decode=True): + assert isinstance(payload, bytes), f'expected payload to be bytes, got {type(payload)}' part_info = {'Content-Type': part.get_content_type(), 'payload': payload.decode().replace('\r\n', '\n')} for key in 'Content-Disposition', 'Content-ID': if cd := part[key]: @@ -30,7 +32,7 @@ def ses_email_data(data: Dict[str, str]) -> Dict[str, Any]: return {'body': dict(data), 'email': d} -def ses_send_response(message_id: Optional[str] = None, request_id: Optional[str] = None) -> str: +def ses_send_response(message_id: str | None = None, request_id: str | None = None) -> str: """ Dummy response to SendRawEmail SES endpoint """ diff --git a/aioaws/version.py b/aioaws/version.py index 54a921e..f8a5353 100644 --- a/aioaws/version.py +++ b/aioaws/version.py @@ -1,3 +1,3 @@ __all__ = ['VERSION'] -VERSION = '0.14' +VERSION = '0.15' diff --git a/pyproject.toml b/pyproject.toml index c491ec1..952bee7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,19 +7,18 @@ path = "aioaws/version.py" [project] name = "aioaws" -description = "Asyncio compatible SDK for aws services" +description = "Data validation and settings management using python 3.6 type hinting" authors = [{name = "Samuel Colvin", email = "s@muelcolvin.com"}] -license-files = { paths = ["LICENSE"] } +license = "MIT" readme = "README.md" classifiers = [ "Development Status :: 5 - Production/Stable", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", @@ -31,12 +30,13 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet", ] -requires-python = ">=3.8" +requires-python = ">=3.10" dependencies = [ - "aiofiles>=0.5.0", - "cryptography>=3.1.1", - "httpx>=0.23.3", - "pydantic>=1.8.2", + "aiofiles>=24", + "cryptography>=43", + "httpx>=0.27", + "pydantic>=2.8", + "pydantic-settings>=2.4.0" ] dynamic = ["version"] @@ -46,10 +46,23 @@ Funding = "https://github.com/sponsors/samuelcolvin" Source = "https://github.com/samuelcolvin/aioaws" Changelog = "https://github.com/samuelcolvin/aioaws/releases" +[tool.ruff] +line-length = 120 +lint.extend-select = ["Q", "RUF100", "C90", "UP", "I"] +lint.ignore = ["E721"] +lint.flake8-quotes = {inline-quotes = "single", multiline-quotes = "double"} +lint.mccabe = { max-complexity = 14 } +lint.pydocstyle = { convention = "google" } +format.quote-style = "single" +target-version = "py310" + [tool.pytest.ini_options] testpaths = "tests" asyncio_mode = "auto" -filterwarnings = ["error"] +filterwarnings = [ + "error", + "ignore:.*web.AppKey.*", +] [tool.coverage.run] source = ["aioaws"] @@ -65,26 +78,6 @@ exclude_lines = [ "@overload", ] -[tool.ruff] -line-length = 120 -extend-select = ["Q"] -flake8-quotes = {inline-quotes = "single", multiline-quotes = "double"} -update-check = false - -[tool.black] -color = true -line-length = 120 -target-version = ["py39"] -skip-string-normalization = true - -[tool.isort] -line_length = 120 -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -combine_as_imports = true -color_output = true - [tool.mypy] strict = true warn_return_any = false diff --git a/requirements/all.txt b/requirements/all.txt new file mode 100644 index 0000000..62d6458 --- /dev/null +++ b/requirements/all.txt @@ -0,0 +1,3 @@ +-r ./linting.txt +-r ./tests.txt +-r ./pyproject.txt diff --git a/requirements/linting.in b/requirements/linting.in new file mode 100644 index 0000000..c34390b --- /dev/null +++ b/requirements/linting.in @@ -0,0 +1,3 @@ +mypy +ruff +types-aiofiles diff --git a/requirements/linting.txt b/requirements/linting.txt new file mode 100644 index 0000000..7dd041c --- /dev/null +++ b/requirements/linting.txt @@ -0,0 +1,16 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --output-file=requirements/linting.txt --strip-extras requirements/linting.in +# +mypy==1.11.1 + # via -r requirements/linting.in +mypy-extensions==1.0.0 + # via mypy +ruff==0.6.0 + # via -r requirements/linting.in +types-aiofiles==24.1.0.20240626 + # via -r requirements/linting.in +typing-extensions==4.12.2 + # via mypy diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt new file mode 100644 index 0000000..6f10b4c --- /dev/null +++ b/requirements/pyproject.txt @@ -0,0 +1,76 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --constraint=requirements/linting.txt --constraint=requirements/tests.txt --output-file=requirements/pyproject.txt --strip-extras pyproject.toml +# +aiofiles==24.1.0 + # via aioaws (pyproject.toml) +annotated-types==0.7.0 + # via + # -c requirements/tests.txt + # pydantic +anyio==4.4.0 + # via + # -c requirements/tests.txt + # httpx +certifi==2024.7.4 + # via + # -c requirements/tests.txt + # httpcore + # httpx +cffi==1.17.0 + # via + # -c requirements/tests.txt + # cryptography +cryptography==43.0.0 + # via aioaws (pyproject.toml) +h11==0.14.0 + # via + # -c requirements/tests.txt + # httpcore +httpcore==1.0.5 + # via + # -c requirements/tests.txt + # httpx +httpx==0.27.0 + # via + # -c requirements/tests.txt + # aioaws (pyproject.toml) +idna==3.7 + # via + # -c requirements/tests.txt + # anyio + # httpx +pycparser==2.22 + # via + # -c requirements/tests.txt + # cffi +pydantic==2.8.2 + # via + # -c requirements/tests.txt + # aioaws (pyproject.toml) + # pydantic-settings +pydantic-core==2.20.1 + # via + # -c requirements/tests.txt + # pydantic +pydantic-settings==2.4.0 + # via + # -c requirements/tests.txt + # aioaws (pyproject.toml) +python-dotenv==1.0.1 + # via + # -c requirements/tests.txt + # pydantic-settings +sniffio==1.3.1 + # via + # -c requirements/tests.txt + # anyio + # httpx +typing-extensions==4.12.2 + # via + # -c requirements/linting.txt + # -c requirements/tests.txt + # pydantic + # pydantic-core diff --git a/requirements/tests.in b/requirements/tests.in new file mode 100644 index 0000000..fe43ba9 --- /dev/null +++ b/requirements/tests.in @@ -0,0 +1,12 @@ +aiohttp +async-timeout +coverage[toml] +foxglove-web +dirty-equals +pillow +pygments +pytest +pytest-mock +pytest-pretty +pytest-asyncio +requests diff --git a/requirements/tests.txt b/requirements/tests.txt new file mode 100644 index 0000000..9eab37d --- /dev/null +++ b/requirements/tests.txt @@ -0,0 +1,159 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --constraint=requirements/linting.txt --output-file=requirements/tests.txt --strip-extras requirements/tests.in +# +aiodns==3.2.0 + # via foxglove-web +aiohappyeyeballs==2.3.6 + # via aiohttp +aiohttp==3.10.3 + # via -r requirements/tests.in +aiosignal==1.3.1 + # via aiohttp +annotated-types==0.7.0 + # via pydantic +anyio==4.4.0 + # via + # httpx + # starlette +arq==0.26.0 + # via foxglove-web +async-timeout==4.0.3 + # via -r requirements/tests.in +asyncpg==0.29.0 + # via foxglove-web +attrs==24.2.0 + # via aiohttp +bcrypt==4.2.0 + # via foxglove-web +buildpg==0.4 + # via foxglove-web +certifi==2024.7.4 + # via + # httpcore + # httpx + # requests + # sentry-sdk +cffi==1.17.0 + # via pycares +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via + # arq + # typer + # uvicorn +coverage==7.6.1 + # via -r requirements/tests.in +dirty-equals==0.8.0 + # via -r requirements/tests.in +fastapi==0.112.1 + # via foxglove-web +foxglove-web==0.0.39 + # via -r requirements/tests.in +frozenlist==1.4.1 + # via + # aiohttp + # aiosignal +h11==0.14.0 + # via + # httpcore + # uvicorn +hiredis==3.0.0 + # via redis +httpcore==1.0.5 + # via httpx +httpx==0.27.0 + # via foxglove-web +idna==3.7 + # via + # anyio + # httpx + # requests + # yarl +iniconfig==2.0.0 + # via pytest +itsdangerous==2.2.0 + # via foxglove-web +markdown-it-py==3.0.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +multidict==6.0.5 + # via + # aiohttp + # yarl +packaging==24.1 + # via pytest +pillow==10.4.0 + # via -r requirements/tests.in +pluggy==1.5.0 + # via pytest +pycares==4.4.0 + # via aiodns +pycparser==2.22 + # via cffi +pydantic==2.8.2 + # via + # fastapi + # foxglove-web + # pydantic-settings +pydantic-core==2.20.1 + # via pydantic +pydantic-settings==2.4.0 + # via foxglove-web +pygments==2.18.0 + # via + # -r requirements/tests.in + # rich +pytest==8.3.2 + # via + # -r requirements/tests.in + # pytest-asyncio + # pytest-mock + # pytest-pretty +pytest-asyncio==0.23.8 + # via -r requirements/tests.in +pytest-mock==3.14.0 + # via -r requirements/tests.in +pytest-pretty==1.2.0 + # via -r requirements/tests.in +python-dotenv==1.0.1 + # via pydantic-settings +redis==4.6.0 + # via arq +requests==2.32.3 + # via -r requirements/tests.in +rich==13.7.1 + # via + # pytest-pretty + # typer +sentry-sdk==2.13.0 + # via foxglove-web +shellingham==1.5.4 + # via typer +sniffio==1.3.1 + # via + # anyio + # httpx +starlette==0.38.2 + # via fastapi +typer==0.12.3 + # via foxglove-web +typing-extensions==4.12.2 + # via + # -c requirements/linting.txt + # fastapi + # pydantic + # pydantic-core + # typer +urllib3==2.2.2 + # via + # requests + # sentry-sdk +uvicorn==0.30.6 + # via foxglove-web +yarl==1.9.4 + # via aiohttp diff --git a/tests/conftest.py b/tests/conftest.py index f127187..79ed476 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ from dataclasses import dataclass import pytest -from foxglove.test_server import DummyServer, create_dummy_server +from foxglove.testing import DummyServer, create_dummy_server from httpx import URL, AsyncClient from . import dummy_server diff --git a/tests/dummy_server.py b/tests/dummy_server.py index ec0c973..6d93fa7 100644 --- a/tests/dummy_server.py +++ b/tests/dummy_server.py @@ -1,5 +1,4 @@ import re -from typing import List from xml.etree import ElementTree from aiohttp import web @@ -44,7 +43,7 @@ async def s3_root(request: web.Request): prefix = request.url.query.get('prefix', '') next_token: str = '' truncated: bool = False - files: List[str] + files: list[str] if prefix == 'broken': files = ['/broken/foo.png', '/broken/bar.png'] truncated = True diff --git a/tests/requirements-linting.txt b/tests/requirements-linting.txt deleted file mode 100644 index 3486205..0000000 --- a/tests/requirements-linting.txt +++ /dev/null @@ -1,5 +0,0 @@ -black==22.12.0 -isort[colors]==5.11.4 -mypy==0.991 -ruff==0.0.217 -types-aiofiles==22.1.0.4 diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index 506b560..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -aiohttp==3.8.3 -foxglove-web==0.0.36 -coverage==7.0.4 -dirty-equals==0.5.0 -pytest==7.2.0 -pytest-asyncio==0.20.3 -pytest-mock==3.10.0 -pytest-pretty==0.0.1 diff --git a/tests/test_s3.py b/tests/test_s3.py index e383a61..3bbabc2 100644 --- a/tests/test_s3.py +++ b/tests/test_s3.py @@ -4,7 +4,7 @@ import pytest from dirty_equals import IsNow, IsStr -from foxglove.test_server import DummyServer +from foxglove.testing import DummyServer from httpx import AsyncClient from aioaws.core import RequestError @@ -109,7 +109,7 @@ async def test_list(client: AsyncClient): s3 = S3Client(client, S3Config('testing', 'testing', 'testing', 'testing')) files = [f async for f in s3.list()] assert len(files) == 3 - assert files[0].dict() == dict( + assert files[0].model_dump() == dict( key='/foo.html', last_modified=datetime(2032, 1, 1, 12, 34, 56, tzinfo=timezone.utc), size=123, @@ -169,7 +169,7 @@ async def test_list_bad(client: AsyncClient): def test_to_key(): assert to_key('foobar') == 'foobar' - assert to_key(S3File.construct(key='spam')) == 'spam' + assert to_key(S3File.model_construct(key='spam')) == 'spam' with pytest.raises(TypeError, match='must be a string or S3File object'): to_key(123) @@ -230,19 +230,19 @@ async def test_real_upload(real_aws: AWS): await s3.upload(path, b'this is a test') try: - files = [f.dict() async for f in s3.list(f'{run_prefix}/')] + files = [f.model_dump() async for f in s3.list(f'{run_prefix}/')] # debug(files) assert len(files) == 1 assert files[0] == { 'key': path, - 'last_modified': IsNow(delta=10, tz='utc'), + 'last_modified': IsNow(delta=10, tz='UTC'), 'size': 14, 'e_tag': '54b0c58c7ce9f2a8b551351102ee0938', 'storage_class': 'STANDARD', } finally: assert await s3.delete(path) == [path] - assert [f.dict() async for f in s3.list(f'{run_prefix}/')] == [] + assert [f.model_dump() async for f in s3.list(f'{run_prefix}/')] == [] @pytest.mark.asyncio diff --git a/tests/test_ses.py b/tests/test_ses.py index e4c0dc4..13142d9 100644 --- a/tests/test_ses.py +++ b/tests/test_ses.py @@ -5,7 +5,7 @@ import pytest from dirty_equals import IsStr -from foxglove.test_server import DummyServer +from foxglove.testing import DummyServer from httpx import AsyncClient from aioaws.ses import SesAttachment, SesClient, SesConfig, SesRecipient, SesWebhookInfo @@ -78,22 +78,22 @@ async def test_send_email_attachment(client: AsyncClient, aws: DummyServer): assert re.fullmatch( ( - br'Subject: test with attachment =\?utf-8\?b\?wqPCo8Kj\?= more\n' - br'From: testing@sender\.com\n' - br'To: testing@recipient\.com\n' - br'MIME-Version: 1\.0\n' - br'Content-Type: multipart/mixed; boundary="===============(\d+)=="\n\n' - br'--===============\1==\n' - br'Content-Type: text/plain; charset="utf-8"\n' - br'Content-Transfer-Encoding: 7bit\n\n' - br'this is a test email\n\n' - br'--===============(\d+)==\n' - br'Content-Type: text/plain\n' - br'MIME-Version: 1\.0\n' - br'Content-Transfer-Encoding: base64\n' - br'Content-Disposition: attachment; filename="testing\.txt"\n\n' - br'c29tZSBiaW5hcnkgZGF0YQ==\n\n' - br'--===============\2==--\n' + rb'Subject: test with attachment =\?utf-8\?b\?wqPCo8Kj\?= more\n' + rb'From: testing@sender\.com\n' + rb'To: testing@recipient\.com\n' + rb'MIME-Version: 1\.0\n' + rb'Content-Type: multipart/mixed; boundary="===============(\d+)=="\n\n' + rb'--===============\1==\n' + rb'Content-Type: text/plain; charset="utf-8"\n' + rb'Content-Transfer-Encoding: 7bit\n\n' + rb'this is a test email\n\n' + rb'--===============(\d+)==\n' + rb'Content-Type: text/plain\n' + rb'MIME-Version: 1\.0\n' + rb'Content-Transfer-Encoding: base64\n' + rb'Content-Disposition: attachment; filename="testing\.txt"\n\n' + rb'c29tZSBiaW5hcnkgZGF0YQ==\n\n' + rb'--===============\2==--\n' ), raw_body, ) @@ -189,31 +189,31 @@ async def test_attachment_email_with_html(client: AsyncClient, aws: DummyServer) raw_body = base64.b64decode(eml['body']['RawMessage.Data'].encode()) assert re.fullmatch( ( - br'Subject: the subject\n' - br'From: testing@sender\.com\n' - br'To: testing@recipient\.com\n' - br'MIME-Version: 1\.0\n' - br'Content-Type: multipart/mixed; boundary="===============(\d+)=="\n\n' - br'--===============\1==\n' - br'Content-Type: multipart/alternative;\n' - br' boundary="===============(\d+)=="\n\n' - br'--===============\2==\n' - br'Content-Type: text/plain; charset="utf-8"\n' - br'Content-Transfer-Encoding: 7bit\n\n' - br'this is a test email\n\n' - br'--===============\2==\n' - br'Content-Type: text/html; charset="utf-8"\n' - br'Content-Transfer-Encoding: 7bit\n' - br'MIME-Version: 1\.0\n\n' - br'this is the html body\.\n\n' - br'--===============\2==--\n\n' - br'--===============\1==\n' - br'Content-Type: text/plain\n' - br'MIME-Version: 1\.0\n' - br'Content-Transfer-Encoding: base64\n' - br'Content-Disposition: attachment; filename="testing\.txt"\n\n' - br'c29tZSBhdHRhY2htZW50\n\n' - br'--===============\1==--\n' + rb'Subject: the subject\n' + rb'From: testing@sender\.com\n' + rb'To: testing@recipient\.com\n' + rb'MIME-Version: 1\.0\n' + rb'Content-Type: multipart/mixed; boundary="===============(\d+)=="\n\n' + rb'--===============\1==\n' + rb'Content-Type: multipart/alternative;\n' + rb' boundary="===============(\d+)=="\n\n' + rb'--===============\2==\n' + rb'Content-Type: text/plain; charset="utf-8"\n' + rb'Content-Transfer-Encoding: 7bit\n\n' + rb'this is a test email\n\n' + rb'--===============\2==\n' + rb'Content-Type: text/html; charset="utf-8"\n' + rb'Content-Transfer-Encoding: 7bit\n' + rb'MIME-Version: 1\.0\n\n' + rb'this is the html body\.\n\n' + rb'--===============\2==--\n\n' + rb'--===============\1==\n' + rb'Content-Type: text/plain\n' + rb'MIME-Version: 1\.0\n' + rb'Content-Transfer-Encoding: base64\n' + rb'Content-Disposition: attachment; filename="testing\.txt"\n\n' + rb'c29tZSBhdHRhY2htZW50\n\n' + rb'--===============\1==--\n' ), raw_body, ) diff --git a/tests/test_sqs.py b/tests/test_sqs.py index 7aaa217..5bdef56 100644 --- a/tests/test_sqs.py +++ b/tests/test_sqs.py @@ -1,4 +1,4 @@ -from typing import AsyncGenerator, List, Optional +from collections.abc import AsyncGenerator import pytest from httpx import AsyncClient, MockTransport, Request, Response @@ -9,7 +9,7 @@ async def test_poll_from_queue_url() -> None: - async def stateful_handler() -> AsyncGenerator[Optional[Response], Request]: + async def stateful_handler() -> AsyncGenerator[Response | None, Request]: req = yield None request_url = req.url.copy_with(params={}) assert request_url == queue_url @@ -57,7 +57,7 @@ async def stateful_handler() -> AsyncGenerator[Optional[Response], Request]: client=client, ) - messages: List[SQSMessage] = [] + messages: list[SQSMessage] = [] # receive 1 batch of messages async for received_messages in sqs.poll(): @@ -77,7 +77,7 @@ async def stateful_handler() -> AsyncGenerator[Optional[Response], Request]: async def test_change_visbility_timeout() -> None: - async def stateful_handler() -> AsyncGenerator[Optional[Response], Request]: + async def stateful_handler() -> AsyncGenerator[Response | None, Request]: req = yield None request_url = req.url.copy_with(params={}) assert request_url == queue_url @@ -150,7 +150,7 @@ async def stateful_handler() -> AsyncGenerator[Optional[Response], Request]: async def test_delete_message() -> None: - async def stateful_handler() -> AsyncGenerator[Optional[Response], Request]: + async def stateful_handler() -> AsyncGenerator[Response | None, Request]: req = yield None request_url = req.url.copy_with(params={}) assert request_url == queue_url @@ -223,7 +223,7 @@ async def stateful_handler() -> AsyncGenerator[Optional[Response], Request]: async def test_get_queue_url() -> None: - async def stateful_handler() -> AsyncGenerator[Optional[Response], Request]: + async def stateful_handler() -> AsyncGenerator[Response | None, Request]: req = yield None # check that we request the queue url request_url = req.url.copy_with(params={})