Skip to content

Commit

Permalink
Pass mypy with strict type-checking
Browse files Browse the repository at this point in the history
  • Loading branch information
Avasam committed Aug 26, 2024
1 parent 467154d commit 0fcfacf
Show file tree
Hide file tree
Showing 8 changed files with 243 additions and 37 deletions.
Empty file added build/lib/jaraco/py.typed
Empty file.
166 changes: 166 additions & 0 deletions build/lib/jaraco/xkcd.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

# Be strict about any broken references
nitpicky = True
nitpick_ignore = []

# Include Python intersphinx mapping to prevent failures
# jaraco/skeleton#51
Expand All @@ -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'),
]
Empty file added jaraco/py.typed
Empty file.
77 changes: 47 additions & 30 deletions jaraco/xkcd.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
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
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -44,53 +60,51 @@ 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
"""
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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
18 changes: 17 additions & 1 deletion mypy.ini
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
4 changes: 0 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,3 @@ type = [


[tool.setuptools_scm]


[tool.pytest-enabler.mypy]
# Disabled due to jaraco/skeleton#143
Loading

0 comments on commit 0fcfacf

Please sign in to comment.