Skip to content

Commit 895e51b

Browse files
authored
add netmask <> wildcardmask utils (#616)
* add netmask <> wildcardmask utils
1 parent 547c342 commit 895e51b

File tree

8 files changed

+161
-26
lines changed

8 files changed

+161
-26
lines changed

.github/workflows/ci.yml

+20-20
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ jobs:
1414
INVOKE_LOCAL: "True"
1515
steps:
1616
- name: "Check out repository code"
17-
uses: "actions/checkout@v2"
17+
uses: "actions/checkout@v4"
1818
- name: "Setup environment"
19-
uses: "networktocode/gh-action-setup-poetry-environment@v2"
19+
uses: "networktocode/gh-action-setup-poetry-environment@v6"
2020
with:
2121
python-version: "3.13"
2222
- name: "Linting: black"
@@ -27,9 +27,9 @@ jobs:
2727
INVOKE_LOCAL: "True"
2828
steps:
2929
- name: "Check out repository code"
30-
uses: "actions/checkout@v2"
30+
uses: "actions/checkout@v4"
3131
- name: "Setup environment"
32-
uses: "networktocode/gh-action-setup-poetry-environment@v2"
32+
uses: "networktocode/gh-action-setup-poetry-environment@v6"
3333
with:
3434
python-version: "3.13"
3535
- name: "Linting: bandit"
@@ -42,9 +42,9 @@ jobs:
4242
INVOKE_LOCAL: "True"
4343
steps:
4444
- name: "Check out repository code"
45-
uses: "actions/checkout@v2"
45+
uses: "actions/checkout@v4"
4646
- name: "Setup environment"
47-
uses: "networktocode/gh-action-setup-poetry-environment@v2"
47+
uses: "networktocode/gh-action-setup-poetry-environment@v6"
4848
with:
4949
python-version: "3.13"
5050
- name: "Type-Hints: mypy"
@@ -57,9 +57,9 @@ jobs:
5757
INVOKE_LOCAL: "True"
5858
steps:
5959
- name: "Check out repository code"
60-
uses: "actions/checkout@v2"
60+
uses: "actions/checkout@v4"
6161
- name: "Setup environment"
62-
uses: "networktocode/gh-action-setup-poetry-environment@v2"
62+
uses: "networktocode/gh-action-setup-poetry-environment@v6"
6363
with:
6464
python-version: "3.13"
6565
- name: "Linting: pydocstyle"
@@ -72,9 +72,9 @@ jobs:
7272
INVOKE_LOCAL: "True"
7373
steps:
7474
- name: "Check out repository code"
75-
uses: "actions/checkout@v2"
75+
uses: "actions/checkout@v4"
7676
- name: "Setup environment"
77-
uses: "networktocode/gh-action-setup-poetry-environment@v2"
77+
uses: "networktocode/gh-action-setup-poetry-environment@v6"
7878
with:
7979
python-version: "3.13"
8080
- name: "Linting: flake8"
@@ -87,9 +87,9 @@ jobs:
8787
INVOKE_LOCAL: "True"
8888
steps:
8989
- name: "Check out repository code"
90-
uses: "actions/checkout@v2"
90+
uses: "actions/checkout@v4"
9191
- name: "Setup environment"
92-
uses: "networktocode/gh-action-setup-poetry-environment@v2"
92+
uses: "networktocode/gh-action-setup-poetry-environment@v6"
9393
with:
9494
python-version: "3.13"
9595
- name: "Linting: yamllint"
@@ -106,9 +106,9 @@ jobs:
106106
PYTHON_VER: "${{ matrix.python-version }}"
107107
steps:
108108
- name: "Check out repository code"
109-
uses: "actions/checkout@v2"
109+
uses: "actions/checkout@v4"
110110
- name: "Setup environment"
111-
uses: "networktocode/gh-action-setup-poetry-environment@v2"
111+
uses: "networktocode/gh-action-setup-poetry-environment@v6"
112112
- name: "Get image version"
113113
run: "echo IMAGE_VER=`poetry version -s`-py${{ matrix.python-version }} >> $GITHUB_ENV"
114114
- name: "Set up Docker Buildx"
@@ -142,9 +142,9 @@ jobs:
142142
PYTHON_VER: "${{ matrix.python-version }}"
143143
steps:
144144
- name: "Check out repository code"
145-
uses: "actions/checkout@v2"
145+
uses: "actions/checkout@v4"
146146
- name: "Setup environment"
147-
uses: "networktocode/gh-action-setup-poetry-environment@v2"
147+
uses: "networktocode/gh-action-setup-poetry-environment@v6"
148148
- name: "Get image version"
149149
run: "echo IMAGE_VER=`poetry version -s`-py${{ matrix.python-version }} >> $GITHUB_ENV"
150150
- name: "Set up Docker Buildx"
@@ -179,9 +179,9 @@ jobs:
179179
PYTHON_VER: "${{ matrix.python-version }}"
180180
steps:
181181
- name: "Check out repository code"
182-
uses: "actions/checkout@v2"
182+
uses: "actions/checkout@v4"
183183
- name: "Setup environment"
184-
uses: "networktocode/gh-action-setup-poetry-environment@v2"
184+
uses: "networktocode/gh-action-setup-poetry-environment@v6"
185185
- name: "Get image version"
186186
run: "echo IMAGE_VER=`poetry version -s`-py${{ matrix.python-version }} >> $GITHUB_ENV"
187187
- name: "Set up Docker Buildx"
@@ -216,7 +216,7 @@ jobs:
216216
if: "startsWith(github.ref, 'refs/tags/v')"
217217
steps:
218218
- name: "Check out repository code"
219-
uses: "actions/checkout@v2"
219+
uses: "actions/checkout@v4"
220220
- name: "Set up Python"
221221
uses: "actions/setup-python@v2"
222222
with:
@@ -245,7 +245,7 @@ jobs:
245245
if: "startsWith(github.ref, 'refs/tags/v')"
246246
steps:
247247
- name: "Check out repository code"
248-
uses: "actions/checkout@v2"
248+
uses: "actions/checkout@v4"
249249
- name: "Set up Python"
250250
uses: "actions/setup-python@v2"
251251
with:

.github/workflows/oui_filepull.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
steps:
1414
# Checkout repo
1515
- name: "Check out code"
16-
uses: "actions/checkout@v2"
16+
uses: "actions/checkout@v4"
1717
with:
1818
ref: "develop"
1919
# Delete old branch if it exists
@@ -43,7 +43,7 @@ jobs:
4343
steps:
4444
# Checkout repo
4545
- name: "Check out code"
46-
uses: "actions/checkout@v2"
46+
uses: "actions/checkout@v4"
4747
with:
4848
ref: "OUI_Updates"
4949
# Create PR from branch created above into develop

.github/workflows/proto_filepull.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
steps:
1414
# Checkout repo
1515
- name: "Check out code"
16-
uses: "actions/checkout@v2"
16+
uses: "actions/checkout@v4"
1717
with:
1818
ref: "develop"
1919
# Delete old branch if it exists
@@ -46,7 +46,7 @@ jobs:
4646
steps:
4747
# Checkout repo
4848
- name: "Check out code"
49-
uses: "actions/checkout@v2"
49+
uses: "actions/checkout@v4"
5050
with:
5151
ref: "PROTO_Updates"
5252
# Create PR from branch created above into develop

.github/workflows/proto_num_filepull.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
steps:
1414
# Checkout repo
1515
- name: "Check out code"
16-
uses: "actions/checkout@v2"
16+
uses: "actions/checkout@v4"
1717
with:
1818
ref: "develop"
1919
# Delete old branch if it exists
@@ -46,7 +46,7 @@ jobs:
4646
steps:
4747
# Checkout repo
4848
- name: "Check out code"
49-
uses: "actions/checkout@v2"
49+
uses: "actions/checkout@v4"
5050
with:
5151
ref: "PROTO_NUM_Updates"
5252
# Create PR from branch created above into develop

docs/user/include_jinja_list.md

+3
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@
5252
| is_ip_within | netutils.ip.is_ip_within |
5353
| is_netmask | netutils.ip.is_netmask |
5454
| is_network | netutils.ip.is_network |
55+
| is_reversible_wildcardmask | netutils.ip.is_reversible_wildcardmask |
5556
| netmask_to_cidr | netutils.ip.netmask_to_cidr |
57+
| netmask_to_wildcardmask | netutils.ip.netmask_to_wildcardmask |
58+
| wildcardmask_to_netmask | netutils.ip.wildcardmask_to_netmask |
5659
| get_napalm_getters | netutils.lib_helpers.get_napalm_getters |
5760
| get_oui | netutils.mac.get_oui |
5861
| is_valid_mac | netutils.mac.is_valid_mac |

netutils/ip.py

+79
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,37 @@ def is_netmask(netmask: str) -> bool:
369369
return False
370370

371371

372+
def is_reversible_wildcardmask(wildcardmask: str) -> bool:
373+
"""Verifies whether a wildcard mask is valid or not.
374+
375+
Args:
376+
wildcardmask: A wildcard mask
377+
378+
Returns:
379+
True if the wildcard mask is valid. Otherwise false.
380+
381+
Examples:
382+
>>> from netutils.ip import is_reversible_wildcardmask
383+
>>> is_reversible_wildcardmask('0.0.0.255')
384+
True
385+
>>> is_reversible_wildcardmask('0.0.255.0')
386+
False
387+
"""
388+
try:
389+
parts = wildcardmask.split(".")
390+
if len(parts) != 4 or any(not p.isdigit() for p in parts):
391+
return False
392+
octets = [int(p) for p in parts]
393+
if any(o < 0 or o > 255 for o in octets):
394+
return False
395+
except ValueError:
396+
return False
397+
398+
inverted_octets = [255 - o for o in octets]
399+
inverted_str = ".".join(str(i) for i in inverted_octets)
400+
return is_netmask(inverted_str)
401+
402+
372403
def is_network(ip_network: str) -> bool:
373404
"""Verifies whether or not a string is a valid IP Network with a Mask.
374405
@@ -650,3 +681,51 @@ def get_ips_sorted(ips: t.Union[str, t.List[str]], sort_type: str = "network") -
650681
return [str(ip) for ip in sorted_list]
651682
except ValueError as err:
652683
raise ValueError(f"Invalid IP of {sort_type} input: {err}") from err
684+
685+
686+
def netmask_to_wildcardmask(netmask: str) -> str:
687+
"""
688+
Convert a standard IPv4 netmask to its wildcardmask.
689+
690+
Args:
691+
netmask (str): The IPv4 netmask (e.g. "255.255.255.0").
692+
693+
Returns:
694+
str: The corresponding wildcardmask (e.g. "0.0.0.255").
695+
696+
Examples:
697+
>>> from netutils.ip import netmask_to_wildcardmask
698+
>>> netmask_to_wildcardmask("255.255.255.0")
699+
'0.0.0.255'
700+
>>> netmask_to_wildcardmask("255.255.0.0")
701+
'0.0.255.255'
702+
"""
703+
if is_netmask(netmask):
704+
octets: t.List[int] = [int(o) for o in netmask.split(".")]
705+
inverted = [255 - octet for octet in octets]
706+
return ".".join(str(i) for i in inverted)
707+
raise ValueError("Subnet mask is not valid.")
708+
709+
710+
def wildcardmask_to_netmask(wildcardmask: str) -> str:
711+
"""
712+
Convert a wildcardmask to its corresponding IPv4 netmask.
713+
714+
Args:
715+
wildcardmask (str): The IPv4 wildcardmask (e.g. "0.0.0.255").
716+
717+
Returns:
718+
str: The corresponding netmask (e.g. "255.255.255.0").
719+
720+
Examples:
721+
>>> from netutils.ip import wildcardmask_to_netmask
722+
>>> wildcardmask_to_netmask("0.0.0.255")
723+
'255.255.255.0'
724+
>>> wildcardmask_to_netmask("0.0.255.255")
725+
'255.255.0.0'
726+
"""
727+
if is_reversible_wildcardmask(wildcardmask):
728+
octets: t.List[int] = [int(o) for o in wildcardmask.split(".")]
729+
inverted = [255 - octet for octet in octets]
730+
return ".".join(str(i) for i in inverted)
731+
raise ValueError("Wildcard mask is not valid.")

netutils/utils.py

+3
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,13 @@
3838
"is_ip_range": "ip.is_ip_range",
3939
"is_ip_within": "ip.is_ip_within",
4040
"is_netmask": "ip.is_netmask",
41+
"is_reversible_wildcardmask": "ip.is_reversible_wildcardmask",
4142
"is_network": "ip.is_network",
4243
"netmask_to_cidr": "ip.netmask_to_cidr",
4344
"cidr_to_netmask": "ip.cidr_to_netmask",
4445
"cidr_to_netmaskv6": "ip.cidr_to_netmaskv6",
46+
"netmask_to_wildcardmask": "ip.netmask_to_wildcardmask",
47+
"wildcardmask_to_netmask": "ip.wildcardmask_to_netmask",
4548
"get_all_host": "ip.get_all_host",
4649
"get_broadcast_address": "ip.get_broadcast_address",
4750
"get_first_usable": "ip.get_first_usable",

tests/unit/test_ip.py

+50
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,14 @@
444444
{"sent": {"netmask": "ffff:ffff:ffff:ffff:ffff::"}, "received": True},
445445
]
446446

447+
IS_REVERSIBLE_WILDCARDMASK = [
448+
{"sent": {"wildcardmask": "0.0.0.255"}, "received": True},
449+
{"sent": {"wildcardmask": "0.0.0.0"}, "received": True},
450+
{"sent": {"wildcardmask": "0.0.0.1"}, "received": True},
451+
{"sent": {"wildcardmask": "0.0.0.2"}, "received": False},
452+
{"sent": {"wildcardmask": "0.0.255.0"}, "received": False},
453+
]
454+
447455
NETMASK_CIDR = [
448456
{"sent": {"netmask": "255.255.255.0"}, "received": 24},
449457
{"sent": {"netmask": "255.192.0.0"}, "received": 10},
@@ -464,6 +472,28 @@
464472
{"sent": {"cidr": 80}, "received": "ffff:ffff:ffff:ffff:ffff::"},
465473
]
466474

475+
NETMASK_WILDCARDMASK = [
476+
{"sent": {"netmask": "255.255.255.254"}, "received": "0.0.0.1"},
477+
{"sent": {"netmask": "255.255.255.252"}, "received": "0.0.0.3"},
478+
{"sent": {"netmask": "255.255.255.0"}, "received": "0.0.0.255"},
479+
{"sent": {"netmask": "255.255.254.0"}, "received": "0.0.1.255"},
480+
{"sent": {"netmask": "255.255.252.0"}, "received": "0.0.3.255"},
481+
{"sent": {"netmask": "255.255.0.0"}, "received": "0.0.255.255"},
482+
{"sent": {"netmask": "255.254.0.0"}, "received": "0.1.255.255"},
483+
{"sent": {"netmask": "255.0.0.0"}, "received": "0.255.255.255"},
484+
]
485+
486+
WILDCARDMASK_NETMASK = [
487+
{"sent": {"wildcardmask": "0.0.0.1"}, "received": "255.255.255.254"},
488+
{"sent": {"wildcardmask": "0.0.0.3"}, "received": "255.255.255.252"},
489+
{"sent": {"wildcardmask": "0.0.0.255"}, "received": "255.255.255.0"},
490+
{"sent": {"wildcardmask": "0.0.1.255"}, "received": "255.255.254.0"},
491+
{"sent": {"wildcardmask": "0.0.3.255"}, "received": "255.255.252.0"},
492+
{"sent": {"wildcardmask": "0.0.255.255"}, "received": "255.255.0.0"},
493+
{"sent": {"wildcardmask": "0.1.255.255"}, "received": "255.254.0.0"},
494+
{"sent": {"wildcardmask": "0.255.255.255"}, "received": "255.0.0.0"},
495+
]
496+
467497
COUNT_BITS = [
468498
{"sent": 0, "received": 0},
469499
{"sent": 234, "received": 5},
@@ -604,6 +634,11 @@ def test_is_netmask(data):
604634
assert ip.is_netmask(**data["sent"]) == data["received"]
605635

606636

637+
@pytest.mark.parametrize("data", IS_REVERSIBLE_WILDCARDMASK)
638+
def test_is_reversible_wildcardmask(data):
639+
assert ip.is_reversible_wildcardmask(**data["sent"]) == data["received"]
640+
641+
607642
@pytest.mark.parametrize("data", NETMASK_CIDR)
608643
def test_netmask_to_cidr(data):
609644
assert ip.netmask_to_cidr(**data["sent"]) == data["received"]
@@ -637,6 +672,21 @@ def test_cidr_to_netmask_fail():
637672
ip.cidr_to_netmask(**data)
638673

639674

675+
@pytest.mark.parametrize("data", NETMASK_WILDCARDMASK)
676+
def test_netmask_to_wildcardmask(data):
677+
assert ip.netmask_to_wildcardmask(**data["sent"]) == data["received"]
678+
679+
680+
@pytest.mark.parametrize("data", WILDCARDMASK_NETMASK)
681+
def test_wildcardmask_to_netmask(data):
682+
assert ip.wildcardmask_to_netmask(**data["sent"]) == data["received"]
683+
684+
685+
def test_wildcardmask_to_netmask_invalid():
686+
with pytest.raises(ValueError, match="Wildcard mask is not valid."):
687+
ip.wildcardmask_to_netmask("0.0.255.0")
688+
689+
640690
@pytest.mark.parametrize("data", GET_PEER)
641691
def test_get_peer_ip(data):
642692
assert ip.get_peer_ip(**data["sent"]) == data["received"]

0 commit comments

Comments
 (0)