Skip to content

Commit 168de9a

Browse files
committed
Experiment: Use typing-extensions.
The main thing gained here is the ability to use Unpack to properly hint the large set of optional keyword arguments accepted by Akismet, but it's possible other interesting uses will spring up.
1 parent 41211c6 commit 168de9a

File tree

7 files changed

+143
-68
lines changed

7 files changed

+143
-68
lines changed

noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ def lint_pylint(session: nox.Session) -> None:
323323
324324
"""
325325
# Pylint requires that all dependencies be importable during the run.
326-
session.install("httpx", "pylint")
326+
session.install("httpx", "typing-extensions", "pylint")
327327
session.run(f"python{session.python}", "-Im", "pylint", "--version")
328328
session.run(f"python{session.python}", "-Im", "pylint", "src/", "tests/")
329329
clean()

pdm.lock

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ name = "akismet"
2929
description = "A Python interface to the Akismet spam-filtering service."
3030
dependencies = [
3131
"httpx",
32+
"typing-extensions",
3233
]
3334
keywords = ["akismet", "spam", "spam-filtering"]
3435
license = { text = "BSD-3-Clause" }
@@ -81,6 +82,7 @@ source-includes = [
8182
".readthedocs.yaml",
8283
"AUTHORS",
8384
"CONTRIBUTING.rst",
85+
"Makefile",
8486
"docs/",
8587
"noxfile.py",
8688
"pdm.lock",

src/akismet/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
# SPDX-License-Identifier: BSD-3-Clause
9191

9292
from ._async_client import AsyncClient
93-
from ._common import USER_AGENT, CheckResponse, Config
93+
from ._common import USER_AGENT, AkismetArguments, CheckResponse, Config
9494
from ._exceptions import (
9595
AkismetError,
9696
APIKeyError,
@@ -106,6 +106,7 @@
106106
__all__ = [
107107
"APIKeyError",
108108
"Akismet",
109+
"AkismetArguments",
109110
"AkismetError",
110111
"AsyncClient",
111112
"CheckResponse",

src/akismet/_async_client.py

Lines changed: 55 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,29 @@
99
from typing import TYPE_CHECKING, Literal, Optional, Type, Union
1010

1111
import httpx
12-
13-
from . import _common, _exceptions
12+
from typing_extensions import Self, Unpack
13+
14+
from . import _exceptions
15+
from ._common import (
16+
_API_URL,
17+
_API_V11,
18+
_API_V12,
19+
_COMMENT_CHECK,
20+
_KEY_SITES,
21+
_OPTIONAL_KEYS,
22+
_REQUEST_METHODS,
23+
_SUBMISSION_RESPONSE,
24+
_SUBMIT_HAM,
25+
_SUBMIT_SPAM,
26+
_USAGE_LIMIT,
27+
_VERIFY_KEY,
28+
AkismetArguments,
29+
CheckResponse,
30+
_configuration_error,
31+
_get_async_http_client,
32+
_protocol_error,
33+
_try_discover_config,
34+
)
1435

1536
if TYPE_CHECKING: # pragma: no cover
1637
import akismet
@@ -139,15 +160,15 @@ def __init__(
139160
You will almost always want to use :meth:`validated_client` instead.
140161
141162
"""
142-
self._config = config if config is not None else _common._try_discover_config()
143-
self._http_client = http_client or _common._get_async_http_client()
163+
self._config = config if config is not None else _try_discover_config()
164+
self._http_client = http_client or _get_async_http_client()
144165

145166
@classmethod
146167
async def validated_client(
147168
cls,
148169
config: Optional["akismet.Config"] = None,
149170
http_client: Optional[httpx.AsyncClient] = None,
150-
) -> "AsyncClient":
171+
) -> Self:
151172
"""
152173
Constructor of :class:`AsyncClient`.
153174
@@ -186,7 +207,7 @@ async def validated_client(
186207
# alternative constructor in order to achieve API consistency.
187208
instance = cls(config=config, http_client=http_client)
188209
if not await instance.verify_key():
189-
_common._configuration_error(instance._config)
210+
_configuration_error(instance._config)
190211
return instance
191212

192213
# Async context-manager protocol.
@@ -198,7 +219,7 @@ async def __aenter__(self) -> "AsyncClient":
198219
199220
"""
200221
if not await self.verify_key():
201-
_common._configuration_error(self._config)
222+
_configuration_error(self._config)
202223
return self
203224

204225
async def __aexit__(
@@ -215,7 +236,7 @@ async def __aexit__(
215236

216237
async def _request(
217238
self,
218-
method: _common._REQUEST_METHODS,
239+
method: _REQUEST_METHODS,
219240
version: str,
220241
endpoint: str,
221242
data: dict,
@@ -244,7 +265,7 @@ async def _request(
244265
request_kwarg = "data" if method == "POST" else "params"
245266
try:
246267
response = await handler(
247-
f"{_common._API_URL}/{version}/{endpoint}", **{request_kwarg: data}
268+
f"{_API_URL}/{version}/{endpoint}", **{request_kwarg: data}
248269
)
249270
response.raise_for_status()
250271
except httpx.HTTPStatusError as exc:
@@ -260,7 +281,7 @@ async def _request(
260281
# Since it's possible to construct a client without performing up-front API key
261282
# validation, we have to watch out here for the possibility that we're making
262283
# requests with an invalid key, and raise the appropriate exception.
263-
if endpoint != _common._VERIFY_KEY and response.text == "invalid":
284+
if endpoint != _VERIFY_KEY and response.text == "invalid":
264285
raise _exceptions.APIKeyError(
265286
"Akismet API key and/or site URL are invalid."
266287
)
@@ -309,7 +330,7 @@ async def _post_request(
309330
optional argument names.
310331
311332
"""
312-
unknown_args = [k for k in kwargs if k not in _common._OPTIONAL_KEYS]
333+
unknown_args = [k for k in kwargs if k not in _OPTIONAL_KEYS]
313334
if unknown_args:
314335
raise _exceptions.UnknownArgumentError(
315336
f"Received unknown argument(s) for Akismet operation {endpoint}: "
@@ -337,17 +358,17 @@ async def _submit(self, endpoint: str, user_ip: str, **kwargs: str) -> bool:
337358
338359
"""
339360
response = await self._post_request(
340-
_common._API_V11, endpoint, user_ip=user_ip, **kwargs
361+
_API_V11, endpoint, user_ip=user_ip, **kwargs
341362
)
342-
if response.text == _common._SUBMISSION_RESPONSE:
363+
if response.text == _SUBMISSION_RESPONSE:
343364
return True
344-
_common._protocol_error(endpoint, response)
365+
_protocol_error(endpoint, response)
345366

346367
# Public methods corresponding to the methods of the Akismet API.
347368
# ----------------------------------------------------------------------------
348369

349370
async def comment_check(
350-
self, user_ip: str, **kwargs: str
371+
self, user_ip: str, **kwargs: Unpack[AkismetArguments]
351372
) -> "akismet.CheckResponse":
352373
"""
353374
Check a piece of user-submitted content to determine whether it is spam.
@@ -392,17 +413,19 @@ async def comment_check(
392413
393414
"""
394415
response = await self._post_request(
395-
_common._API_V11, _common._COMMENT_CHECK, user_ip=user_ip, **kwargs
416+
_API_V11, _COMMENT_CHECK, user_ip=user_ip, **kwargs
396417
)
397418
if response.text == "true":
398419
if response.headers.get("X-akismet-pro-tip", "") == "discard":
399-
return _common.CheckResponse.DISCARD
400-
return _common.CheckResponse.SPAM
420+
return CheckResponse.DISCARD
421+
return CheckResponse.SPAM
401422
if response.text == "false":
402-
return _common.CheckResponse.HAM
403-
_common._protocol_error(_common._COMMENT_CHECK, response)
423+
return CheckResponse.HAM
424+
_protocol_error(_COMMENT_CHECK, response)
404425

405-
async def submit_ham(self, user_ip: str, **kwargs: str) -> bool:
426+
async def submit_ham(
427+
self, user_ip: str, **kwargs: Unpack[AkismetArguments]
428+
) -> bool:
406429
"""
407430
Inform Akismet that a piece of user-submitted comment is not spam.
408431
@@ -440,9 +463,11 @@ async def submit_ham(self, user_ip: str, **kwargs: str) -> bool:
440463
received from the Akismet API.
441464
442465
"""
443-
return await self._submit(_common._SUBMIT_HAM, user_ip, **kwargs)
466+
return await self._submit(_SUBMIT_HAM, user_ip, **kwargs)
444467

445-
async def submit_spam(self, user_ip: str, **kwargs: str) -> bool:
468+
async def submit_spam(
469+
self, user_ip: str, **kwargs: Unpack[AkismetArguments]
470+
) -> bool:
446471
"""
447472
Inform Akismet that a piece of user-submitted comment is spam.
448473
@@ -480,9 +505,10 @@ async def submit_spam(self, user_ip: str, **kwargs: str) -> bool:
480505
received from the Akismet API.
481506
482507
"""
483-
return await self._submit(_common._SUBMIT_SPAM, user_ip, **kwargs)
508+
return await self._submit(_SUBMIT_SPAM, user_ip, **kwargs)
484509

485-
async def key_sites( # pylint: disable=too-many-positional-arguments,too-many-arguments
510+
async def key_sites(
511+
# pylint: disable=too-many-positional-arguments,too-many-arguments
486512
self,
487513
month: Optional[str] = None,
488514
url_filter: Optional[str] = None,
@@ -531,7 +557,7 @@ async def key_sites( # pylint: disable=too-many-positional-arguments,too-many-a
531557
):
532558
if value is not None:
533559
params[argument] = value
534-
response = await self._get_request(_common._API_V12, _common._KEY_SITES, params)
560+
response = await self._get_request(_API_V12, _KEY_SITES, params)
535561
if result_format == "csv":
536562
return response.text
537563
return response.json()
@@ -546,7 +572,7 @@ async def usage_limit(self) -> dict:
546572
547573
"""
548574
response = await self._get_request(
549-
_common._API_V12, _common._USAGE_LIMIT, params={"api_key": self._config.key}
575+
_API_V12, _USAGE_LIMIT, params={"api_key": self._config.key}
550576
)
551577
return response.json()
552578

@@ -575,10 +601,10 @@ async def verify_key(
575601
if not all([key, url]):
576602
key, url = self._config
577603
response = await self._request(
578-
"POST", _common._API_V11, _common._VERIFY_KEY, {"key": key, "blog": url}
604+
"POST", _API_V11, _VERIFY_KEY, {"key": key, "blog": url}
579605
)
580606
if response.text == "valid":
581607
return True
582608
if response.text == "invalid":
583609
return False
584-
_common._protocol_error(_common._VERIFY_KEY, response)
610+
_protocol_error(_VERIFY_KEY, response)

src/akismet/_common.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
import os
1010
import sys
1111
import textwrap
12-
import typing
1312
from importlib.metadata import version
13+
from typing import Literal, NamedTuple, NoReturn, TypedDict
1414

1515
import httpx
1616

@@ -24,7 +24,7 @@
2424
_API_V12 = "1.2"
2525
_COMMENT_CHECK = "comment-check"
2626
_KEY_SITES = "key-sites"
27-
_REQUEST_METHODS = typing.Literal["GET", "POST"] # pylint: disable=invalid-name
27+
_REQUEST_METHODS = Literal["GET", "POST"] # pylint: disable=invalid-name
2828
_SUBMISSION_RESPONSE = "Thanks for making the web a better place."
2929
_SUBMIT_HAM = "submit-ham"
3030
_SUBMIT_SPAM = "submit-spam"
@@ -81,7 +81,7 @@ class CheckResponse(enum.IntEnum):
8181
DISCARD = 2
8282

8383

84-
class Config(typing.NamedTuple):
84+
class Config(NamedTuple):
8585
"""
8686
A :func:`~collections.namedtuple` representing Akismet configuration, consisting
8787
of a key and a URL.
@@ -96,11 +96,37 @@ class Config(typing.NamedTuple):
9696
url: str
9797

9898

99+
class AkismetArguments(TypedDict, total=False):
100+
"""
101+
A :class:`~typing.TypedDict` representing the optional keyword arguments accepted by
102+
most Akismet API operations.
103+
104+
"""
105+
106+
blog_charset: str
107+
blog_lang: str
108+
comment_author: str
109+
comment_author_email: str
110+
comment_author_url: str
111+
comment_content: str
112+
comment_context: str
113+
comment_date_gmt: str
114+
comment_post_modified_gmt: str
115+
comment_type: str
116+
honeypot_field_name: str
117+
is_test: bool
118+
permalink: str
119+
recheck_reason: str
120+
referrer: str
121+
user_agent: str
122+
user_role: str
123+
124+
99125
# Private helper functions.
100126
# -------------------------------------------------------------------------------
101127

102128

103-
def _configuration_error(config: Config) -> typing.NoReturn:
129+
def _configuration_error(config: Config) -> NoReturn:
104130
"""
105131
Raise an appropriate exception for invalid configuration.
106132
@@ -133,7 +159,7 @@ def _get_sync_http_client() -> httpx.Client:
133159
return httpx.Client(headers={"User-Agent": USER_AGENT}, timeout=_TIMEOUT)
134160

135161

136-
def _protocol_error(operation: str, response: httpx.Response) -> typing.NoReturn:
162+
def _protocol_error(operation: str, response: httpx.Response) -> NoReturn:
137163
"""
138164
Raise an appropriate exception for unexpected API responses.
139165

0 commit comments

Comments
 (0)