Skip to content

Commit 44bf9b7

Browse files
authored
feat: first version (#2)
1 parent 19782fd commit 44bf9b7

12 files changed

+285
-22
lines changed

README.md

+15-3
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@
3535

3636
Cache construction of ipaddress objects
3737

38+
## Design
39+
40+
This module keeps a cache of IPAddress objects and caches the properties on them.
41+
42+
It it useful when you frequently see the same ip addresses over and over and
43+
do not want to pay the overhead of constructing IPAddress objects over and over
44+
or checking their properties.
45+
3846
## Installation
3947

4048
Install this via pip (or your favourite package manager):
@@ -43,10 +51,14 @@ Install this via pip (or your favourite package manager):
4351

4452
## Usage
4553

46-
Start by importing it:
47-
4854
```python
49-
import cached_ipaddress
55+
from cached_ipaddress import cached_ip_addresses
56+
57+
ip = cached_ip_addresses("127.0.0.1")
58+
assert ip.is_loopback is False
59+
60+
invalid = cached_ip_addresses("invalid")
61+
assert invalid is None
5062
```
5163

5264
## Contributors ✨

build_ext.py

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Build optional cython modules."""
2+
3+
import logging
4+
import os
5+
from distutils.command.build_ext import build_ext
6+
from typing import Any
7+
8+
_LOGGER = logging.getLogger(__name__)
9+
10+
11+
class BuildExt(build_ext):
12+
"""BuildExt."""
13+
14+
def build_extensions(self) -> None:
15+
"""Build extensions."""
16+
try:
17+
super().build_extensions()
18+
except Exception:
19+
_LOGGER.debug("Failed to build extensions", exc_info=True)
20+
pass
21+
22+
23+
def build(setup_kwargs: Any) -> None:
24+
"""Build optional cython modules."""
25+
if os.environ.get("SKIP_CYTHON", False):
26+
return
27+
try:
28+
from Cython.Build import cythonize
29+
30+
setup_kwargs.update(
31+
{
32+
"ext_modules": cythonize(
33+
[
34+
"src/cached_ipaddress/ipaddress.py",
35+
],
36+
compiler_directives={"language_level": "3"}, # Python 3
37+
),
38+
"cmdclass": {"build_ext": BuildExt},
39+
}
40+
)
41+
setup_kwargs["exclude_package_data"] = {
42+
pkg: ["*.c"] for pkg in setup_kwargs["packages"]
43+
}
44+
except Exception:
45+
if os.environ.get("REQUIRE_CYTHON"):
46+
raise
47+
pass

pyproject.toml

+6-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ packages = [
2121
"Bug Tracker" = "https://github.com/bdraco/cached-ipaddress/issues"
2222
"Changelog" = "https://github.com/bdraco/cached-ipaddress/blob/main/CHANGELOG.md"
2323

24+
[tool.poetry.build]
25+
generate-setup-file = true
26+
script = "build_ext.py"
27+
2428
[tool.poetry.dependencies]
2529
python = "^3.7"
2630

@@ -125,5 +129,6 @@ module = "tests.*"
125129
allow_untyped_defs = true
126130

127131
[build-system]
128-
requires = ["poetry-core>=1.0.0"]
132+
# 1.5.2 required for https://github.com/python-poetry/poetry/issues/7505
133+
requires = ['setuptools>=65.4.1', 'wheel', 'Cython>=3.0.5', "poetry-core>=1.5.2"]
129134
build-backend = "poetry.core.masonry.api"

setup.py

-9
This file was deleted.

src/cached_ipaddress/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
__version__ = "0.0.1"
2+
3+
from .ipaddress import cached_ip_addresses
4+
5+
__all__ = ["cached_ip_addresses"]

src/cached_ipaddress/backports/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Functools backports from standard lib."""
2+
from __future__ import annotations
3+
4+
import sys
5+
import types
6+
from collections.abc import Callable
7+
from typing import Any, Generic, TypeVar, overload
8+
9+
_T = TypeVar("_T")
10+
11+
12+
class cached_property(Generic[_T]): # pylint: disable=invalid-name
13+
"""
14+
Backport of Python 3.12's cached_property.
15+
16+
Includes https://github.com/python/cpython/pull/101890/files
17+
"""
18+
19+
def __init__(self, func: Callable[[Any], _T]) -> None:
20+
"""Initialize."""
21+
self.func: Callable[[Any], _T] = func
22+
self.attrname: str | None = None
23+
self.__doc__ = func.__doc__
24+
25+
def __set_name__(self, owner: type[Any], name: str) -> None:
26+
"""Set name."""
27+
if self.attrname is None:
28+
self.attrname = name
29+
elif name != self.attrname:
30+
raise TypeError(
31+
"Cannot assign the same cached_property to two different names "
32+
f"({self.attrname!r} and {name!r})."
33+
)
34+
35+
@overload
36+
def __get__(self, instance: None, owner: type[Any] | None = None) -> Any:
37+
...
38+
39+
@overload
40+
def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T:
41+
...
42+
43+
def __get__(self, instance: Any | None, owner: type[Any] | None = None) -> _T | Any:
44+
"""Get."""
45+
if instance is None:
46+
return self
47+
if self.attrname is None:
48+
raise TypeError(
49+
"Cannot use cached_property instance without "
50+
"calling __set_name__ on it."
51+
)
52+
try:
53+
cache = instance.__dict__
54+
# not all objects have __dict__ (e.g. class defines slots)
55+
except AttributeError:
56+
msg = (
57+
f"No '__dict__' attribute on {type(instance).__name__!r} "
58+
f"instance to cache {self.attrname!r} property."
59+
)
60+
raise TypeError(msg) from None
61+
val = self.func(instance)
62+
try:
63+
cache[self.attrname] = val
64+
except TypeError:
65+
msg = (
66+
f"The '__dict__' attribute on {type(instance).__name__!r} instance "
67+
f"does not support item assignment "
68+
f"for caching {self.attrname!r} property."
69+
)
70+
raise TypeError(msg) from None
71+
return val
72+
73+
if sys.version_info >= (3, 9):
74+
__class_getitem__ = classmethod(types.GenericAlias) # type: ignore
75+
else:
76+
77+
def __class_getitem__(
78+
cls: type[cached_property],
79+
cls_item: Any,
80+
) -> type[cached_property]:
81+
"""Get item."""
82+
return cls

src/cached_ipaddress/ipaddress.pxd

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import cython
2+
3+
cdef object cached_ip_addresses_wrapper
4+
cdef object AddressValueError
5+
cdef object NetmaskValueError

src/cached_ipaddress/ipaddress.py

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Base implementation."""
2+
3+
from functools import lru_cache
4+
from ipaddress import AddressValueError, IPv4Address, IPv6Address, NetmaskValueError
5+
from typing import Optional, Union
6+
7+
from .backports.functools import cached_property
8+
9+
10+
class CachedIPv4Address(IPv4Address):
11+
def __str__(self) -> str:
12+
"""Return the string representation of the IPv4 address."""
13+
return self._str
14+
15+
@cached_property
16+
def _str(self) -> str:
17+
"""Return the string representation of the IPv4 address."""
18+
return super().__str__()
19+
20+
@cached_property
21+
def is_link_local(self) -> bool: # type: ignore[override]
22+
"""Return True if this is a link-local address."""
23+
return super().is_link_local
24+
25+
@cached_property
26+
def is_unspecified(self) -> bool: # type: ignore[override]
27+
"""Return True if this is an unspecified address."""
28+
return super().is_unspecified
29+
30+
@cached_property
31+
def is_loopback(self) -> bool: # type: ignore[override]
32+
"""Return True if this is a loopback address."""
33+
return super().is_loopback
34+
35+
36+
class CachedIPv6Address(IPv6Address):
37+
def __str__(self) -> str:
38+
"""Return the string representation of the IPv6 address."""
39+
return self._str
40+
41+
@cached_property
42+
def _str(self) -> str:
43+
"""Return the string representation of the IPv6 address."""
44+
return super().__str__()
45+
46+
@cached_property
47+
def is_link_local(self) -> bool: # type: ignore[override]
48+
"""Return True if this is a link-local address."""
49+
return super().is_link_local
50+
51+
@cached_property
52+
def is_unspecified(self) -> bool: # type: ignore[override]
53+
"""Return True if this is an unspecified address."""
54+
return super().is_unspecified
55+
56+
@cached_property
57+
def is_loopback(self) -> bool: # type: ignore[override]
58+
"""Return True if this is a loopback address."""
59+
return super().is_loopback
60+
61+
62+
@lru_cache(maxsize=512)
63+
def _cached_ip_addresses(
64+
address: Union[str, bytes, int]
65+
) -> Optional[Union[IPv4Address, IPv6Address]]:
66+
"""Cache IP addresses."""
67+
try:
68+
return CachedIPv4Address(address)
69+
except (AddressValueError, NetmaskValueError):
70+
pass
71+
72+
try:
73+
return CachedIPv6Address(address)
74+
except (AddressValueError, NetmaskValueError):
75+
return None
76+
77+
78+
cached_ip_addresses_wrapper = _cached_ip_addresses
79+
cached_ip_addresses = cached_ip_addresses_wrapper
80+
81+
__all__ = ("cached_ip_addresses",)

src/cached_ipaddress/main.py

-3
This file was deleted.

tests/test_ipaddress.py

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Unit tests for cached_ipaddress.ipaddress."""
2+
3+
from cached_ipaddress import ipaddress
4+
5+
6+
def test_cached_ip_addresses_wrapper():
7+
"""Test the cached_ip_addresses_wrapper."""
8+
assert ipaddress.cached_ip_addresses("") is None
9+
assert ipaddress.cached_ip_addresses("foo") is None
10+
assert (
11+
str(
12+
ipaddress.cached_ip_addresses(
13+
b"&\x06(\x00\x02 \x00\x01\x02H\x18\x93%\xc8\x19F"
14+
)
15+
)
16+
== "2606:2800:220:1:248:1893:25c8:1946"
17+
)
18+
assert ipaddress.cached_ip_addresses("::1") == ipaddress.IPv6Address("::1")
19+
20+
ipv4 = ipaddress.cached_ip_addresses("169.254.0.0")
21+
assert ipv4 is not None
22+
assert ipv4.is_link_local is True
23+
assert ipv4.is_unspecified is False
24+
assert ipv4.is_loopback is False
25+
assert str(ipv4) == "169.254.0.0"
26+
assert str(ipv4) == "169.254.0.0"
27+
28+
ipv4 = ipaddress.cached_ip_addresses("0.0.0.0") # noqa: S104
29+
assert ipv4 is not None
30+
assert ipv4.is_link_local is False
31+
assert ipv4.is_unspecified is True
32+
assert ipv4.is_loopback is False
33+
34+
ipv6 = ipaddress.cached_ip_addresses("fe80::1")
35+
assert ipv6 is not None
36+
assert ipv6.is_link_local is True
37+
assert ipv6.is_unspecified is False
38+
assert ipv6.is_loopback is False
39+
40+
ipv6 = ipaddress.cached_ip_addresses("0:0:0:0:0:0:0:0")
41+
assert ipv6 is not None
42+
assert ipv6.is_link_local is False
43+
assert ipv6.is_unspecified is True
44+
assert ipv6.is_loopback is False
45+
assert str(ipv6) == "::"

tests/test_main.py

-6
This file was deleted.

0 commit comments

Comments
 (0)