From 335b5beccba3e6587033b88be4514c567ddac4cd Mon Sep 17 00:00:00 2001 From: nnsnodnb Date: Wed, 24 Jan 2024 08:58:18 +0900 Subject: [PATCH 1/8] Update clients dataclass frozen attribute --- kalyke/_types.py | 3 +++ kalyke/clients/__init__.py | 8 ++++++++ kalyke/clients/apns.py | 11 ++--------- kalyke/clients/live_activity.py | 2 +- kalyke/clients/voip.py | 15 +++++---------- 5 files changed, 19 insertions(+), 20 deletions(-) create mode 100644 kalyke/_types.py diff --git a/kalyke/_types.py b/kalyke/_types.py new file mode 100644 index 0000000..90d9cc0 --- /dev/null +++ b/kalyke/_types.py @@ -0,0 +1,3 @@ +from httpx._types import CertTypes as HTTPXCertTypes + +CertTypes = HTTPXCertTypes diff --git a/kalyke/clients/__init__.py b/kalyke/clients/__init__.py index aa6bb26..f17a938 100644 --- a/kalyke/clients/__init__.py +++ b/kalyke/clients/__init__.py @@ -1,5 +1,6 @@ import importlib import urllib.parse +from pathlib import Path from typing import Any, Dict, Union from httpx import AsyncClient, Response @@ -10,6 +11,7 @@ class __Client(object): use_sandbox: bool + auth_key_filepath: Union[str, Path] async def send_message( self, @@ -55,3 +57,9 @@ def _handle_error(self, error_json: Dict[str, Any]) -> ApnsProviderException: exception_class = getattr(exceptions_module, reason) return exception_class(error=error_json) + + def _get_auth_key_filepath(self) -> Path: + if isinstance(self.auth_key_filepath, Path): + return self.auth_key_filepath + else: + return Path(self.auth_key_filepath) diff --git a/kalyke/clients/apns.py b/kalyke/clients/apns.py index 560de3d..73947fd 100644 --- a/kalyke/clients/apns.py +++ b/kalyke/clients/apns.py @@ -10,19 +10,12 @@ from . import __Client as BaseClient -@dataclass +@dataclass(frozen=True) class ApnsClient(BaseClient): use_sandbox: bool team_id: str auth_key_id: str auth_key_filepath: Union[str, Path] - _auth_key_filepath: Path = field(init=False) - - def __post_init__(self): - if isinstance(self.auth_key_filepath, Path): - self._auth_key_filepath = self.auth_key_filepath - else: - self._auth_key_filepath = Path(self.auth_key_filepath) def _init_client(self, apns_config: ApnsConfig) -> AsyncClient: headers = apns_config.make_headers() @@ -31,7 +24,7 @@ def _init_client(self, apns_config: ApnsConfig) -> AsyncClient: return client def _make_authorization(self) -> str: - auth_key = self._auth_key_filepath.read_text() + auth_key = self._get_auth_key_filepath().read_text() token = jwt.encode( payload={ "iss": self.team_id, diff --git a/kalyke/clients/live_activity.py b/kalyke/clients/live_activity.py index 288a18a..cf406d7 100644 --- a/kalyke/clients/live_activity.py +++ b/kalyke/clients/live_activity.py @@ -7,7 +7,7 @@ from .apns import ApnsClient -@dataclass +@dataclass(frozen=True) class LiveActivityClient(ApnsClient): async def send_message( self, diff --git a/kalyke/clients/voip.py b/kalyke/clients/voip.py index aaa4a56..d0a19b0 100644 --- a/kalyke/clients/voip.py +++ b/kalyke/clients/voip.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, Union +from typing import Any, Dict, Optional, Union import httpx from httpx import AsyncClient @@ -9,17 +9,12 @@ from . import __Client as BaseClient -@dataclass +@dataclass(frozen=True) class VoIPClient(BaseClient): use_sandbox: bool auth_key_filepath: Union[str, Path] - _auth_key_filepath: Path = field(init=False) - - def __post_init__(self): - if isinstance(self.auth_key_filepath, Path): - self._auth_key_filepath = self.auth_key_filepath - else: - self._auth_key_filepath = Path(self.auth_key_filepath) + key_filepath: Optional[Union[str, Path]] = field(default=None) + password: Optional[str] = field(default=None) async def send_message( self, @@ -36,6 +31,6 @@ async def send_message( def _init_client(self, apns_config: VoIPApnsConfig) -> AsyncClient: headers = apns_config.make_headers() context = httpx.create_ssl_context() - context.load_cert_chain(self._auth_key_filepath) + context.load_cert_chain(self._get_auth_key_filepath()) client = AsyncClient(headers=headers, verify=context, http2=True) return client From b73faa0bc1f01076031148b8ffd8a63d665b5fe5 Mon Sep 17 00:00:00 2001 From: nnsnodnb Date: Wed, 24 Jan 2024 09:18:26 +0900 Subject: [PATCH 2/8] Support cer & key are separated, including password --- kalyke/clients/voip.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/kalyke/clients/voip.py b/kalyke/clients/voip.py index d0a19b0..46709e1 100644 --- a/kalyke/clients/voip.py +++ b/kalyke/clients/voip.py @@ -1,3 +1,4 @@ +import warnings from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, Optional, Union @@ -5,6 +6,7 @@ import httpx from httpx import AsyncClient +from .._types import CertTypes from ..models import VoIPApnsConfig from . import __Client as BaseClient @@ -16,6 +18,10 @@ class VoIPClient(BaseClient): key_filepath: Optional[Union[str, Path]] = field(default=None) password: Optional[str] = field(default=None) + def __post_init__(self) -> None: + if self.key_filepath is None and self.password is not None: + warnings.warn("password is ignored because key_filepath is None.", UserWarning) + async def send_message( self, device_token: str, @@ -30,7 +36,13 @@ async def send_message( def _init_client(self, apns_config: VoIPApnsConfig) -> AsyncClient: headers = apns_config.make_headers() - context = httpx.create_ssl_context() + + cert: CertTypes = ( + self._get_auth_key_filepath().as_posix(), + self.key_filepath.as_posix() if self.key_filepath is not None else None, + self.password if self.key_filepath is not None else None, + ) + context = httpx.create_ssl_context(cert=cert) context.load_cert_chain(self._get_auth_key_filepath()) client = AsyncClient(headers=headers, verify=context, http2=True) return client From d4ba932c5625c8ceab95d1a741116435613b03ae Mon Sep 17 00:00:00 2001 From: nnsnodnb Date: Wed, 24 Jan 2024 09:20:42 +0900 Subject: [PATCH 3/8] Add pem package in dev group --- kalyke/_types.py | 3 --- kalyke/clients/voip.py | 13 +++++-------- poetry.lock | 19 ++++++++++++++++++- pyproject.toml | 1 + 4 files changed, 24 insertions(+), 12 deletions(-) delete mode 100644 kalyke/_types.py diff --git a/kalyke/_types.py b/kalyke/_types.py deleted file mode 100644 index 90d9cc0..0000000 --- a/kalyke/_types.py +++ /dev/null @@ -1,3 +0,0 @@ -from httpx._types import CertTypes as HTTPXCertTypes - -CertTypes = HTTPXCertTypes diff --git a/kalyke/clients/voip.py b/kalyke/clients/voip.py index 46709e1..1b1816c 100644 --- a/kalyke/clients/voip.py +++ b/kalyke/clients/voip.py @@ -6,7 +6,6 @@ import httpx from httpx import AsyncClient -from .._types import CertTypes from ..models import VoIPApnsConfig from . import __Client as BaseClient @@ -36,13 +35,11 @@ async def send_message( def _init_client(self, apns_config: VoIPApnsConfig) -> AsyncClient: headers = apns_config.make_headers() - - cert: CertTypes = ( - self._get_auth_key_filepath().as_posix(), - self.key_filepath.as_posix() if self.key_filepath is not None else None, - self.password if self.key_filepath is not None else None, + context = httpx.create_ssl_context() + context.load_cert_chain( + certfile=self._get_auth_key_filepath(), + keyfile=self.key_filepath, + password=self.password, ) - context = httpx.create_ssl_context(cert=cert) - context.load_cert_chain(self._get_auth_key_filepath()) client = AsyncClient(headers=headers, verify=context, http2=True) return client diff --git a/poetry.lock b/poetry.lock index e3a7344..e33e885 100644 --- a/poetry.lock +++ b/poetry.lock @@ -666,6 +666,23 @@ files = [ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] +[[package]] +name = "pem" +version = "23.1.0" +description = "PEM file parsing in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pem-23.1.0-py3-none-any.whl", hash = "sha256:78bbb1e75b737891350cb9499cbba31da5d59545f360f44163c0bc751cad55d3"}, + {file = "pem-23.1.0.tar.gz", hash = "sha256:06503ff2441a111f853ce4e8b9eb9d5fedb488ebdbf560115d3dd53a1b4afc73"}, +] + +[package.extras] +dev = ["pem[tests,types]", "twisted[tls]"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "twisted[tls]"] +tests = ["certifi", "coverage[toml] (>=5.0.2)", "pretend", "pyopenssl", "pytest"] +types = ["mypy", "twisted", "types-pyopenssl"] + [[package]] name = "platformdirs" version = "4.1.0" @@ -895,4 +912,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "99a037374423c55da87d91dda0c710b971ec28d4577cc448185ad94aa28ca681" +content-hash = "d380fdabb162afc955917a2148e41445fd926117adb6a2fd86659c909bd868ed" diff --git a/pyproject.toml b/pyproject.toml index 1a9db5f..4209ee0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ pytest-cov = "^4.0.0" coveralls = "^3.3.1" pytest-httpx = ">=0.21.3,<0.29.0" pytest-asyncio = ">=0.20.3,<0.24.0" +pem = "^23.1.0" [tool.poetry-dynamic-versioning] enable = true From d135873951600697f4ab120e03c86755ece38699 Mon Sep 17 00:00:00 2001 From: nnsnodnb Date: Wed, 24 Jan 2024 09:44:21 +0900 Subject: [PATCH 4/8] Update warning --- kalyke/clients/voip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kalyke/clients/voip.py b/kalyke/clients/voip.py index 1b1816c..c892932 100644 --- a/kalyke/clients/voip.py +++ b/kalyke/clients/voip.py @@ -19,7 +19,7 @@ class VoIPClient(BaseClient): def __post_init__(self) -> None: if self.key_filepath is None and self.password is not None: - warnings.warn("password is ignored because key_filepath is None.", UserWarning) + warnings.warn(UserWarning("password is ignored because key_filepath is None.")) async def send_message( self, From ad4b2a13fa9611dea83e53685246bd24fa409fe3 Mon Sep 17 00:00:00 2001 From: nnsnodnb Date: Wed, 24 Jan 2024 09:57:48 +0900 Subject: [PATCH 5/8] Update tests --- tests/clients/voip/test_init.py | 54 +++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/clients/voip/test_init.py b/tests/clients/voip/test_init.py index 6970ffd..25ec68a 100644 --- a/tests/clients/voip/test_init.py +++ b/tests/clients/voip/test_init.py @@ -1,3 +1,9 @@ +import tempfile +from pathlib import Path + +import pem +import pytest + from kalyke import VoIPClient @@ -17,3 +23,51 @@ def test_initialize_with_str(auth_key_filepath): ) assert isinstance(client, VoIPClient) + + +def test_initialize_with_key_filepath(auth_key_filepath): + cer, key = pem.parse_file(auth_key_filepath) + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + cer_path = tmpdir_path / "cer.pem" + cer_path.write_bytes(cer.as_bytes()) + key_path = tmpdir_path / "key.pem" + key_path.write_bytes(key.as_bytes()) + + client = VoIPClient( + use_sandbox=True, + auth_key_filepath=cer_path, + key_filepath=key_path, + ) + + assert isinstance(client, VoIPClient) + + +def test_initialize_with_key_filepath_and_password(auth_key_filepath): + cer, key = pem.parse_file(auth_key_filepath) + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + cer_path = tmpdir_path / "cer.pem" + cer_path.write_bytes(cer.as_bytes()) + key_path = tmpdir_path / "key.pem" + key_path.write_bytes(key.as_bytes()) + + client = VoIPClient( + use_sandbox=True, + auth_key_filepath=cer_path, + key_filepath=key_path, + password="password", + ) + + assert isinstance(client, VoIPClient) + + +def test_initialize_with_password(auth_key_filepath): + with pytest.warns(UserWarning, match="password is ignored because key_filepath is None."): + client = VoIPClient( + use_sandbox=True, + auth_key_filepath=auth_key_filepath, + password="password", + ) + + assert isinstance(client, VoIPClient) From 7430dffbebe5414d804326469cc55b6bada476b8 Mon Sep 17 00:00:00 2001 From: nnsnodnb Date: Wed, 24 Jan 2024 10:27:31 +0900 Subject: [PATCH 6/8] Update example/voip.py --- examples/voip.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/voip.py b/examples/voip.py index 3a75e94..ae31d62 100644 --- a/examples/voip.py +++ b/examples/voip.py @@ -1,7 +1,7 @@ import asyncio from pathlib import Path -from kalyke import ApnsConfig, ApnsPushType, VoIPClient +from kalyke import VoIPApnsConfig, VoIPClient client = VoIPClient( use_sandbox=True, @@ -9,7 +9,7 @@ ) payload = {"key": "value"} -config = ApnsConfig(topic="com.example.App.voip", push_type=ApnsPushType.VOIP) +config = VoIPApnsConfig(topic="com.example.App.voip") # Send single VoIP notification From c495694134530526a5e85bbbed186370ee8c5610 Mon Sep 17 00:00:00 2001 From: nnsnodnb Date: Wed, 24 Jan 2024 10:28:36 +0900 Subject: [PATCH 7/8] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 477d2a8..73cdfc4 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ asyncio.run( import asyncio from pathlib import Path -from kalyke import VoIPApnsConfig, ApnsPushType, VoIPClient +from kalyke import VoIPApnsConfig, VoIPClient client = VoIPClient( use_sandbox=True, From 06199e0359299f8f510bbe570cf9680b7727d60b Mon Sep 17 00:00:00 2001 From: nnsnodnb Date: Wed, 24 Jan 2024 10:37:33 +0900 Subject: [PATCH 8/8] Add _get_auth_key_filepath method --- .../client/test_get_auth_key_filepath.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/clients/client/test_get_auth_key_filepath.py diff --git a/tests/clients/client/test_get_auth_key_filepath.py b/tests/clients/client/test_get_auth_key_filepath.py new file mode 100644 index 0000000..5912128 --- /dev/null +++ b/tests/clients/client/test_get_auth_key_filepath.py @@ -0,0 +1,22 @@ +from pathlib import Path + +import pytest + +from kalyke.clients import __Client + + +@pytest.mark.parametrize( + "auth_key_filepath", + [ + Path(__file__).parent.parent / "dummy.p8", + "../dummy.p8", + ], + ids=["Path object", "str"], +) +def test_get_auth_key_filepath(auth_key_filepath): + client = __Client() + client.auth_key_filepath = auth_key_filepath + + actual = client._get_auth_key_filepath() + + assert isinstance(actual, Path)