diff --git a/build/lib/jaraco/py.typed b/build/lib/jaraco/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/jaraco/xkcd.py b/build/lib/jaraco/xkcd.py new file mode 100644 index 0000000..6f183bb --- /dev/null +++ b/build/lib/jaraco/xkcd.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import contextlib +import datetime +import importlib +import itertools +import os +import pathlib +import random +from collections.abc import Mapping +from typing import TYPE_CHECKING, TypeVar + +import cachecontrol +from cachecontrol import heuristics +from cachecontrol.caches import file_cache +from requests_toolbelt import sessions + +import jaraco.text +from jaraco.collections import dict_map +from jaraco.functools import except_ + +if TYPE_CHECKING: + from _typeshed import ConvertibleToInt, StrPath + from typing_extensions import Self + + _ConvertibleToIntT = TypeVar("_ConvertibleToIntT", bound=ConvertibleToInt) +else: + _ConvertibleToIntT = TypeVar("_ConvertibleToIntT") + +_T = TypeVar("_T") +_VT_co = TypeVar("_VT_co", covariant=True) + + +def make_cache(path: StrPath | None = None) -> file_cache.FileCache: + default = pathlib.Path('~/.cache/xkcd').expanduser() + path = os.environ.get('XKCD_CACHE_DIR', path or default) + return file_cache.FileCache(path) # type: ignore[arg-type] # FileCache is using too restrictive pathlib.Path + + +session = cachecontrol.CacheControl( + sessions.BaseUrlSession('https://xkcd.com/'), + heuristic=heuristics.ExpiresAfter(days=365 * 20), + cache=make_cache(), +) + + +class Comic: + def __init__(self, number: int) -> None: + if not self._404(number): + self._load(number) + + def _404(self, number: int) -> Self | None: + """ + The 404 comic is not found. + >>> Comic(404) + Comic(404) + >>> print(Comic(404)) + xkcd 404:Not Found (None) + >>> print(Comic(404).date) + 2008-04-01 + """ + if number != 404: + return None + + self.num = 404 + self.title = "Not Found" + self.img = None + self.year = 2008 + self.month = 4 + self.day = 1 + return self + + def _load(self, number: int) -> None: + resp = session.get(f'{number}/info.0.json') + resp.raise_for_status() + vars(self).update(self._fix_numbers(resp.json())) + + @property + def date(self) -> datetime.date: + """ + >>> print(Comic(1).date) + 2006-01-01 + """ + return datetime.date(self.year, self.month, self.day) + + @staticmethod + def _fix_numbers(ob: Mapping[_T, _VT_co]) -> dict[_T, _VT_co | int]: + """ + Given a dict-like object ob, ensure any integers are integers. + """ + safe_int = except_(TypeError, ValueError, use='args[0]')(int) + return dict_map(safe_int, ob) # type: ignore[no-untyped-call, no-any-return] # jaraco/jaraco.collections#14 + + @classmethod + def latest(cls) -> Self: + headers = {'Cache-Control': 'no-cache'} + resp = session.get('info.0.json', headers=headers) + resp.raise_for_status() + return cls(resp.json()['num']) + + @classmethod + def all(cls) -> map[Self]: + latest = cls.latest() + return map(cls, range(latest.number, 0, -1)) + + @classmethod + def random(cls) -> Self: + """ + Return a randomly-selected comic. + + >>> Comic.random() + Comic(...) + """ + latest = cls.latest() + return cls(random.randint(1, latest.number)) + + @classmethod + def search(cls, text: str) -> Self | None: + """ + Find a comic with the matching text + + >>> print(Comic.search('password strength')) + xkcd 936:Password Strength \ +(https://imgs.xkcd.com/comics/password_strength.png) + >>> Comic.search('Horse battery') + Comic(2241) + >>> Comic.search('ISO 8601') + Comic(2562) + >>> Comic.search('2013-02-27').title + 'ISO 8601' + >>> Comic.search('2020-12-25').title + 'Wrapping Paper' + """ + matches = (comic for comic in cls.all() if text in comic.full_text) + return next(matches, None) + + @property + def number(self) -> int: + return self.num + + @property + def full_text(self) -> jaraco.text.FoldedCase: + """ + >>> comic = Comic.random() + >>> str(comic.date) in comic.full_text + True + """ + values = itertools.chain(vars(self).values(), [self.date]) + return jaraco.text.FoldedCase('|'.join(map(str, values))) + + def __repr__(self) -> str: + return f'{self.__class__.__name__}({self.number})' + + def __str__(self) -> str: + return f'xkcd {self.number}:{self.title} ({self.img})' + + +with contextlib.suppress(ImportError): + if TYPE_CHECKING: + import pmxbot.core as core + else: + core = importlib.import_module('pmxbot.core') + + @core.command() # type: ignore[misc] # pragma: no cover + def xkcd(rest: str | None) -> Comic | None: + return Comic.search(rest) if rest else Comic.random() # pragma: no cover diff --git a/docs/conf.py b/docs/conf.py index 3215048..1436a57 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,6 +30,7 @@ # Be strict about any broken references nitpicky = True +nitpick_ignore = [] # Include Python intersphinx mapping to prevent failures # jaraco/skeleton#51 @@ -40,3 +41,11 @@ # Preserve authored syntax for defaults autodoc_preserve_defaults = True + + +# jaraco/jaraco.xkcd#1 +nitpick_ignore += [ + ('py:class', 'jaraco.text.FoldedCase'), + ('py:class', 'StrPath'), + ('py:class', 'file_cache.FileCache'), +] diff --git a/jaraco/py.typed b/jaraco/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/jaraco/xkcd.py b/jaraco/xkcd.py index 4edf9e3..6f183bb 100644 --- a/jaraco/xkcd.py +++ b/jaraco/xkcd.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import contextlib import datetime import importlib @@ -5,6 +7,8 @@ import os import pathlib import random +from collections.abc import Mapping +from typing import TYPE_CHECKING, TypeVar import cachecontrol from cachecontrol import heuristics @@ -15,11 +19,22 @@ from jaraco.collections import dict_map from jaraco.functools import except_ +if TYPE_CHECKING: + from _typeshed import ConvertibleToInt, StrPath + from typing_extensions import Self + + _ConvertibleToIntT = TypeVar("_ConvertibleToIntT", bound=ConvertibleToInt) +else: + _ConvertibleToIntT = TypeVar("_ConvertibleToIntT") + +_T = TypeVar("_T") +_VT_co = TypeVar("_VT_co", covariant=True) + -def make_cache(path=None): +def make_cache(path: StrPath | None = None) -> file_cache.FileCache: default = pathlib.Path('~/.cache/xkcd').expanduser() path = os.environ.get('XKCD_CACHE_DIR', path or default) - return file_cache.FileCache(path) + return file_cache.FileCache(path) # type: ignore[arg-type] # FileCache is using too restrictive pathlib.Path session = cachecontrol.CacheControl( @@ -30,10 +45,11 @@ def make_cache(path=None): class Comic: - def __init__(self, number): - self._404(number) or self._load(number) + def __init__(self, number: int) -> None: + if not self._404(number): + self._load(number) - def _404(self, number): + def _404(self, number: int) -> Self | None: """ The 404 comic is not found. >>> Comic(404) @@ -44,25 +60,23 @@ def _404(self, number): 2008-04-01 """ if number != 404: - return - - vars(self).update( - num=404, - title="Not Found", - img=None, - year=2008, - month=4, - day=1, - ) + return None + + self.num = 404 + self.title = "Not Found" + self.img = None + self.year = 2008 + self.month = 4 + self.day = 1 return self - def _load(self, number): + def _load(self, number: int) -> None: resp = session.get(f'{number}/info.0.json') resp.raise_for_status() vars(self).update(self._fix_numbers(resp.json())) @property - def date(self): + def date(self) -> datetime.date: """ >>> print(Comic(1).date) 2006-01-01 @@ -70,27 +84,27 @@ def date(self): return datetime.date(self.year, self.month, self.day) @staticmethod - def _fix_numbers(ob): + def _fix_numbers(ob: Mapping[_T, _VT_co]) -> dict[_T, _VT_co | int]: """ Given a dict-like object ob, ensure any integers are integers. """ safe_int = except_(TypeError, ValueError, use='args[0]')(int) - return dict_map(safe_int, ob) + return dict_map(safe_int, ob) # type: ignore[no-untyped-call, no-any-return] # jaraco/jaraco.collections#14 @classmethod - def latest(cls): + def latest(cls) -> Self: headers = {'Cache-Control': 'no-cache'} resp = session.get('info.0.json', headers=headers) resp.raise_for_status() return cls(resp.json()['num']) @classmethod - def all(cls): + def all(cls) -> map[Self]: latest = cls.latest() return map(cls, range(latest.number, 0, -1)) @classmethod - def random(cls): + def random(cls) -> Self: """ Return a randomly-selected comic. @@ -101,7 +115,7 @@ def random(cls): return cls(random.randint(1, latest.number)) @classmethod - def search(cls, text): + def search(cls, text: str) -> Self | None: """ Find a comic with the matching text @@ -121,11 +135,11 @@ def search(cls, text): return next(matches, None) @property - def number(self): + def number(self) -> int: return self.num @property - def full_text(self): + def full_text(self) -> jaraco.text.FoldedCase: """ >>> comic = Comic.random() >>> str(comic.date) in comic.full_text @@ -134,16 +148,19 @@ def full_text(self): values = itertools.chain(vars(self).values(), [self.date]) return jaraco.text.FoldedCase('|'.join(map(str, values))) - def __repr__(self): + def __repr__(self) -> str: return f'{self.__class__.__name__}({self.number})' - def __str__(self): + def __str__(self) -> str: return f'xkcd {self.number}:{self.title} ({self.img})' with contextlib.suppress(ImportError): - core = importlib.import_module('pmxbot.core') + if TYPE_CHECKING: + import pmxbot.core as core + else: + core = importlib.import_module('pmxbot.core') - @core.command() # type: ignore # pragma: no cover - def xkcd(rest): + @core.command() # type: ignore[misc] # pragma: no cover + def xkcd(rest: str | None) -> Comic | None: return Comic.search(rest) if rest else Comic.random() # pragma: no cover diff --git a/mypy.ini b/mypy.ini index 83b0d15..f97433a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,6 @@ [mypy] # Is the project well-typed? -strict = False +strict = True # Early opt-in even when strict = False warn_unused_ignores = True @@ -12,3 +12,19 @@ explicit_package_bases = True # Disable overload-overlap due to many false-positives disable_error_code = overload-overlap + +# jaraco/jaraco.text#17 +[mypy-jaraco.text.*] +ignore_missing_imports = True + +# jaraco/tempora#35 +[mypy-tempora.*] +ignore_missing_imports = True + +# pmxbot/pmxbot#113 +[mypy-pmxbot.*] +ignore_missing_imports = True + +# requests/toolbelt#279 +[mypy-requests_toolbelt.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index bd97927..05c5bd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,3 @@ type = [ [tool.setuptools_scm] - - -[tool.pytest-enabler.mypy] -# Disabled due to jaraco/skeleton#143 diff --git a/test_caching.py b/test_caching.py index f256faf..3297f0b 100644 --- a/test_caching.py +++ b/test_caching.py @@ -1,18 +1,20 @@ import pytest +from py.path import local # type: ignore[import-untyped] from tempora import timing from jaraco import xkcd @pytest.fixture -def fresh_cache(tmpdir, monkeypatch): +def fresh_cache(tmpdir: local, monkeypatch: pytest.MonkeyPatch) -> None: adapter = xkcd.session.get_adapter('http://') cache = xkcd.make_cache(tmpdir / 'xkcd') monkeypatch.setattr(adapter, 'cache', cache) monkeypatch.setattr(adapter.controller, 'cache', cache) -def test_requests_cached(fresh_cache): +@pytest.mark.usefixtures("fresh_cache") +def test_requests_cached() -> None: """ A second pass loading Comics should be substantially faster than the first.