Skip to content

gh-141647: fix IP address/network comparison methods#141842

Open
picnixz wants to merge 4 commits intopython:mainfrom
picnixz:fix/ipaddress/instance-checks-141647
Open

gh-141647: fix IP address/network comparison methods#141842
picnixz wants to merge 4 commits intopython:mainfrom
picnixz:fix/ipaddress/instance-checks-141647

Conversation

@picnixz
Copy link
Copy Markdown
Member

@picnixz picnixz commented Nov 22, 2025

I think this patch should cover the cases. I'm unsure whether this will break more stuff or not but I do know that using < and __lt__ return different boolean results which can break many things.

@serhiy-storchaka I really want a second eye here so PTAL whenever you've got time. TiA!

@picnixz picnixz force-pushed the fix/ipaddress/instance-checks-141647 branch from bd5eeda to ca36ea4 Compare November 22, 2025 15:34
Copy link
Copy Markdown
Member

@serhiy-storchaka serhiy-storchaka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make benchmarks for equality and order comparison.

Comment thread Lib/ipaddress.py
Comment thread Lib/ipaddress.py Outdated
Comment thread Lib/ipaddress.py Outdated
Comment thread Lib/ipaddress.py Outdated
@picnixz
Copy link
Copy Markdown
Member Author

picnixz commented Nov 29, 2025

So I'm a bit surprised but here are the numbers:

+---------------------------------------------+---------------+-----------------------+
| Benchmark                                   | ipaddress-ref | ipaddress-new         |
+=============================================+===============+=======================+
| address.__eq__[1.2.3.4,5.6.7.8]             | 39.7 ns       | 41.0 ns: 1.03x slower |
+---------------------------------------------+---------------+-----------------------+
| interface.__eq__[192.0.2.0/32,192.0.2.0/32] | 242 ns        | 264 ns: 1.09x slower  |
+---------------------------------------------+---------------+-----------------------+
| interface.__lt__[192.0.2.0/32,192.0.8.0/32] | 344 ns        | 317 ns: 1.09x faster  |
+---------------------------------------------+---------------+-----------------------+
| interface.__eq__[192.0.2.0/32,192.0.2.0/24] | 253 ns        | 245 ns: 1.03x faster  |
+---------------------------------------------+---------------+-----------------------+
| interface.__lt__[192.0.8.0/32,192.0.8.0/32] | 389 ns        | 362 ns: 1.07x faster  |
+---------------------------------------------+---------------+-----------------------+
| network.__lt__[192.0.2.0/32,192.0.8.0/32]   | 160 ns        | 167 ns: 1.04x slower  |
+---------------------------------------------+---------------+-----------------------+
| network.__eq__[192.0.8.0/32,192.0.2.0/24]   | 80.4 ns       | 76.4 ns: 1.05x faster |
+---------------------------------------------+---------------+-----------------------+
| network.__eq__[192.0.2.0/24,192.0.2.0/24]   | 140 ns        | 134 ns: 1.05x faster  |
+---------------------------------------------+---------------+-----------------------+
| network.__lt__[192.0.2.0/24,192.0.2.0/24]   | 173 ns        | 166 ns: 1.04x faster  |
+---------------------------------------------+---------------+-----------------------+
| mixed.__lt__[5.6.7.8,1.2.3.4/32]            | 416 ns        | 387 ns: 1.07x faster  |
+---------------------------------------------+---------------+-----------------------+
| mixed.__lt__[5.6.7.8/32,5.6.7.8/32]         | 372 ns        | 398 ns: 1.07x slower  |
+---------------------------------------------+---------------+-----------------------+
| Geometric mean                              | (ref)         | 1.00x faster          |
+---------------------------------------------+---------------+-----------------------+

This benchmark uses the _check_ip_version additional call and Python 3.14 from uv (since the changes are only in pure Python, it's fine to switch branches). I've also tried those "slower" cases but it's really within the real of noise IMO.

Benchmark script
import ipaddress
import itertools
import operator

import pyperf

seen = set()

def bench_eq(runner, label, x, y, seen):
    name = f"{label}.__eq__[{x},{y}]"
    if name not in seen:
        runner.bench_func(name, operator.__eq__, x, y)
        seen.add(name)

def bench_lt(runner, label, x, y, seen):
    name = f"{label}.__lt__[{x},{y}]"
    if name not in seen:
        runner.bench_func(name, operator.__lt__, x, y)
        seen.add(name)


if __name__ == "__main__":
    runner = pyperf.Runner()
    args = runner.parse_args()
    seen = set()

    for x, y in itertools.combinations_with_replacement(
        [
            ipaddress.IPv4Address("1.2.3.4"),
            ipaddress.IPv4Address("5.6.7.8"),
        ], 2
    ):
        bench_eq(runner, "address", x, y, seen)
        bench_lt(runner, "address", x, y, seen)

    for x, y in itertools.combinations_with_replacement(
        [
            ipaddress.IPv4Interface("192.0.2.0"),
            ipaddress.IPv4Interface("192.0.8.0"),
            ipaddress.IPv4Interface("192.0.2.0/24"),
            ipaddress.IPv4Interface("192.0.8.0/32"),
        ], 2
    ):
        bench_eq(runner, "interface", x, y, seen)
        bench_lt(runner, "interface", x, y, seen)

    for x, y in itertools.combinations_with_replacement(
        [
            ipaddress.IPv4Network("192.0.2.0"),
            ipaddress.IPv4Network("192.0.8.0"),
            ipaddress.IPv4Network("192.0.2.0/24"),
            ipaddress.IPv4Network("192.0.8.0/32"),
        ], 2
    ):
        bench_eq(runner, "network", x, y, seen)
        bench_lt(runner, "network", x, y, seen)

    for x, y in itertools.combinations_with_replacement(
        [
            ipaddress.IPv4Address("1.2.3.4"),
            ipaddress.IPv4Address("5.6.7.8"),
            ipaddress.IPv4Interface("1.2.3.4"),
            ipaddress.IPv4Interface("5.6.7.8"),
            ipaddress.IPv4Interface("1.2.3.4/24"),
            ipaddress.IPv4Interface("5.6.7.8/32"),
        ], 2
    ):
        bench_lt(runner, "mixed", x, y, seen)

Copy link
Copy Markdown
Member

@serhiy-storchaka serhiy-storchaka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is a documentation and an implementation bugs in collapse_addresses(). They cab considered a separate issue(s).

Comment thread Lib/ipaddress.py
nets = []

# split IP addresses and networks
# split IP addresses/interfaces and networks
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the documentation, all input objects should be networks. The code supports addresses (this is undocumented behavior). Support of interfaces is a new feature.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is an online docs issue as well. We allow ordering with interfaces and adresses (or some weird mixed comparisons that I do not remember)

Comment thread Lib/ipaddress.py
Comment on lines +341 to +342
if nets:
_check_ip_version(nets[-1], ip)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will happen if we have separate networks and addresses of different versions? There is a bug here, this is a separate issue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIR different versions are not allowed with comparisons. So I guess Inshould consider the first item being compared to as the one who gives the version to use.

Comment thread Lib/ipaddress.py
# compare interfaces by their network first
return (self.network < other.network
or (self.network == other.network and address_less))
return address_less
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be False if unassociated address is considered less than all interfaces.

Comment thread Lib/ipaddress.py
Comment on lines +727 to +728
# should __contains__ actually implement subnet_of()
# and supernet_of() instead?
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A subset is usually not considered to be an element of the superset.

There is a weird behavior of strings, but it is an exception.

Copy link
Copy Markdown
Member Author

@picnixz picnixz Dec 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah but since there was a branch with "dealing with other networks" I wondered why we even supported that check. Asking whether network A is contained in another network B rings like "A is a subnet of B" to me.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is just to prevent interpretation of a network as an address, since they share common attributes and base classes. It is a guard against incorrect application of the Liskov substitution principle.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see! ok I will remove that comment then now that I know why we did this check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If _BaseNetwork is not a subclass of _BaseAddress, we can remove this check.

Copy link
Copy Markdown
Member Author

@picnixz picnixz Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_BaseNetwork is a subclass of _IPAddressBase. However, people doing net_a in net_b will now get a AttributeError (probably) instead of "False". So maybe it's better to keep the check.

Comment thread Lib/ipaddress.py
Comment on lines +727 to +728
# should __contains__ actually implement subnet_of()
# and supernet_of() instead?
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If _BaseNetwork is not a subclass of _BaseAddress, we can remove this check.

Comment thread Lib/ipaddress.py
self.version == other.version
and (other._ip & self.netmask._ip) == self.network_address._ip
)
return NotImplemented
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is NotImplemented supported for __contains__?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmh, I was under the impression that dunders needed to raise NotImplemented for the generic unsupported case but maybe a TypeError should be raised directly?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmh, I was under the impression that dunders needed to raise NotImplemented for the generic unsupported case but maybe a TypeError should be raised directly?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants