Repository: networktocode/netutils

Issue:
Few ntc-templates inconsistencies 
https://github.com/networktocode/netutils/blob/11f5e88963873e929c292d929a9c3fc3e142a0ce/netutils/lib_mapper.py#L260

- [ ] Need to fix `cisco_xe` to map to `cisco_ios` for ntc-templates.
- [ ] Need to fix `cisco_wlc` to map to `cisco_wlc_ssh` for ntc-templates.

Hints from issue comments:
@jvanderaa: @jeffkala what if we change `cisco_wlc_ssh` to `cisco_wlc` in NTC Templates?@jvanderaa: @jeffkala this may be fixed by #620? 

Solution patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 58a3706..22da9e4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,9 +14,9 @@ jobs:
       INVOKE_LOCAL: "True"
     steps:
       - name: "Check out repository code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
       - name: "Setup environment"
-        uses: "networktocode/gh-action-setup-poetry-environment@v2"
+        uses: "networktocode/gh-action-setup-poetry-environment@v6"
         with:
           python-version: "3.13"
       - name: "Linting: black"
@@ -27,9 +27,9 @@ jobs:
       INVOKE_LOCAL: "True"
     steps:
       - name: "Check out repository code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
       - name: "Setup environment"
-        uses: "networktocode/gh-action-setup-poetry-environment@v2"
+        uses: "networktocode/gh-action-setup-poetry-environment@v6"
         with:
           python-version: "3.13"
       - name: "Linting: bandit"
@@ -42,9 +42,9 @@ jobs:
       INVOKE_LOCAL: "True"
     steps:
       - name: "Check out repository code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
       - name: "Setup environment"
-        uses: "networktocode/gh-action-setup-poetry-environment@v2"
+        uses: "networktocode/gh-action-setup-poetry-environment@v6"
         with:
           python-version: "3.13"
       - name: "Type-Hints: mypy"
@@ -57,9 +57,9 @@ jobs:
       INVOKE_LOCAL: "True"
     steps:
       - name: "Check out repository code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
       - name: "Setup environment"
-        uses: "networktocode/gh-action-setup-poetry-environment@v2"
+        uses: "networktocode/gh-action-setup-poetry-environment@v6"
         with:
           python-version: "3.13"
       - name: "Linting: pydocstyle"
@@ -72,9 +72,9 @@ jobs:
       INVOKE_LOCAL: "True"
     steps:
       - name: "Check out repository code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
       - name: "Setup environment"
-        uses: "networktocode/gh-action-setup-poetry-environment@v2"
+        uses: "networktocode/gh-action-setup-poetry-environment@v6"
         with:
           python-version: "3.13"
       - name: "Linting: flake8"
@@ -87,9 +87,9 @@ jobs:
       INVOKE_LOCAL: "True"
     steps:
       - name: "Check out repository code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
       - name: "Setup environment"
-        uses: "networktocode/gh-action-setup-poetry-environment@v2"
+        uses: "networktocode/gh-action-setup-poetry-environment@v6"
         with:
           python-version: "3.13"
       - name: "Linting: yamllint"
@@ -106,9 +106,9 @@ jobs:
       PYTHON_VER: "${{ matrix.python-version }}"
     steps:
       - name: "Check out repository code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
       - name: "Setup environment"
-        uses: "networktocode/gh-action-setup-poetry-environment@v2"
+        uses: "networktocode/gh-action-setup-poetry-environment@v6"
       - name: "Get image version"
         run: "echo IMAGE_VER=`poetry version -s`-py${{ matrix.python-version }} >> $GITHUB_ENV"
       - name: "Set up Docker Buildx"
@@ -142,9 +142,9 @@ jobs:
       PYTHON_VER: "${{ matrix.python-version }}"
     steps:
       - name: "Check out repository code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
       - name: "Setup environment"
-        uses: "networktocode/gh-action-setup-poetry-environment@v2"
+        uses: "networktocode/gh-action-setup-poetry-environment@v6"
       - name: "Get image version"
         run: "echo IMAGE_VER=`poetry version -s`-py${{ matrix.python-version }} >> $GITHUB_ENV"
       - name: "Set up Docker Buildx"
@@ -179,9 +179,9 @@ jobs:
       PYTHON_VER: "${{ matrix.python-version }}"
     steps:
       - name: "Check out repository code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
       - name: "Setup environment"
-        uses: "networktocode/gh-action-setup-poetry-environment@v2"
+        uses: "networktocode/gh-action-setup-poetry-environment@v6"
       - name: "Get image version"
         run: "echo IMAGE_VER=`poetry version -s`-py${{ matrix.python-version }} >> $GITHUB_ENV"
       - name: "Set up Docker Buildx"
@@ -216,7 +216,7 @@ jobs:
     if: "startsWith(github.ref, 'refs/tags/v')"
     steps:
       - name: "Check out repository code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
       - name: "Set up Python"
         uses: "actions/setup-python@v2"
         with:
@@ -245,7 +245,7 @@ jobs:
     if: "startsWith(github.ref, 'refs/tags/v')"
     steps:
       - name: "Check out repository code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
       - name: "Set up Python"
         uses: "actions/setup-python@v2"
         with:
diff --git a/.github/workflows/oui_filepull.yml b/.github/workflows/oui_filepull.yml
index bc78d92..3547431 100644
--- a/.github/workflows/oui_filepull.yml
+++ b/.github/workflows/oui_filepull.yml
@@ -13,7 +13,7 @@ jobs:
     steps:
       # Checkout repo
       - name: "Check out code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
         with:
           ref: "develop"
       # Delete old branch if it exists
@@ -43,7 +43,7 @@ jobs:
     steps:
       # Checkout repo
       - name: "Check out code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
         with:
           ref: "OUI_Updates"
       # Create PR from branch created above into develop
diff --git a/.github/workflows/proto_filepull.yml b/.github/workflows/proto_filepull.yml
index f41c445..a6ab31a 100644
--- a/.github/workflows/proto_filepull.yml
+++ b/.github/workflows/proto_filepull.yml
@@ -13,7 +13,7 @@ jobs:
     steps:
       # Checkout repo
       - name: "Check out code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
         with:
           ref: "develop"
       # Delete old branch if it exists
@@ -46,7 +46,7 @@ jobs:
     steps:
       # Checkout repo
       - name: "Check out code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
         with:
           ref: "PROTO_Updates"
       # Create PR from branch created above into develop
diff --git a/.github/workflows/proto_num_filepull.yml b/.github/workflows/proto_num_filepull.yml
index ced8694..4001131 100644
--- a/.github/workflows/proto_num_filepull.yml
+++ b/.github/workflows/proto_num_filepull.yml
@@ -13,7 +13,7 @@ jobs:
     steps:
       # Checkout repo
       - name: "Check out code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
         with:
           ref: "develop"
       # Delete old branch if it exists
@@ -46,7 +46,7 @@ jobs:
     steps:
       # Checkout repo
       - name: "Check out code"
-        uses: "actions/checkout@v2"
+        uses: "actions/checkout@v4"
         with:
           ref: "PROTO_NUM_Updates"
       # Create PR from branch created above into develop
diff --git a/docs/requirements.txt b/docs/requirements.txt
index ff233ee..81af021 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,9 +1,10 @@
-mkdocs==1.5.2
+mkdocs==1.6.0
 # Material for MkDocs theme
-mkdocs-material==9.2.4
+mkdocs-material==9.5.32
 mkdocs-python-classy==0.1.3
 # Render custom markdown for version added/changed/remove notes
 mkdocs-version-annotations==1.0.0
 # Automatic documentation from sources, for MkDocs
-mkdocstrings==0.22.0
-mkdocstrings-python==1.6.0
\ No newline at end of file
+mkdocstrings==0.25.2
+mkdocstrings-python==1.10.8
+griffe==1.1.1
\ No newline at end of file
diff --git a/docs/user/include_jinja_list.md b/docs/user/include_jinja_list.md
index a00ded5..ba2c37d 100644
--- a/docs/user/include_jinja_list.md
+++ b/docs/user/include_jinja_list.md
@@ -52,7 +52,10 @@
 | is_ip_within | netutils.ip.is_ip_within |
 | is_netmask | netutils.ip.is_netmask |
 | is_network | netutils.ip.is_network |
+| is_reversible_wildcardmask | netutils.ip.is_reversible_wildcardmask |
 | netmask_to_cidr | netutils.ip.netmask_to_cidr |
+| netmask_to_wildcardmask | netutils.ip.netmask_to_wildcardmask |
+| wildcardmask_to_netmask | netutils.ip.wildcardmask_to_netmask |
 | get_napalm_getters | netutils.lib_helpers.get_napalm_getters |
 | get_oui | netutils.mac.get_oui |
 | is_valid_mac | netutils.mac.is_valid_mac |
diff --git a/docs/user/lib_mapper/ntctemplates.md b/docs/user/lib_mapper/ntctemplates.md
index e29f9ba..c84deb8 100644
--- a/docs/user/lib_mapper/ntctemplates.md
+++ b/docs/user/lib_mapper/ntctemplates.md
@@ -33,6 +33,7 @@
 | cisco_s300 | → | cisco_s300 |
 | cisco_tp | → | cisco_tp |
 | cisco_wlc | → | cisco_wlc |
+| cisco_wlc_ssh | → | cisco_wlc |
 | cisco_xe | → | cisco_xe |
 | cisco_xr | → | cisco_xr |
 | cloudgenix_ion | → | cloudgenix_ion |
diff --git a/docs/user/lib_mapper/ntctemplates_reverse.md b/docs/user/lib_mapper/ntctemplates_reverse.md
index 29e636d..7899ef3 100644
--- a/docs/user/lib_mapper/ntctemplates_reverse.md
+++ b/docs/user/lib_mapper/ntctemplates_reverse.md
@@ -32,7 +32,7 @@
 | cisco_nxos | → | cisco_nxos |
 | cisco_s300 | → | cisco_s300 |
 | cisco_tp | → | cisco_tp |
-| cisco_wlc | → | cisco_wlc |
+| cisco_wlc | → | cisco_wlc_ssh |
 | cisco_xe | → | cisco_ios |
 | cisco_xr | → | cisco_xr |
 | cloudgenix_ion | → | cloudgenix_ion |
diff --git a/netutils/ip.py b/netutils/ip.py
index b73b31f..06337c1 100644
--- a/netutils/ip.py
+++ b/netutils/ip.py
@@ -369,6 +369,37 @@ def is_netmask(netmask: str) -> bool:
         return False
 
 
+def is_reversible_wildcardmask(wildcardmask: str) -> bool:
+    """Verifies whether a wildcard mask is valid or not.
+
+    Args:
+        wildcardmask: A wildcard mask
+
+    Returns:
+        True if the wildcard mask is valid. Otherwise false.
+
+    Examples:
+        >>> from netutils.ip import is_reversible_wildcardmask
+        >>> is_reversible_wildcardmask('0.0.0.255')
+        True
+        >>> is_reversible_wildcardmask('0.0.255.0')
+        False
+    """
+    try:
+        parts = wildcardmask.split(".")
+        if len(parts) != 4 or any(not p.isdigit() for p in parts):
+            return False
+        octets = [int(p) for p in parts]
+        if any(o < 0 or o > 255 for o in octets):
+            return False
+    except ValueError:
+        return False
+
+    inverted_octets = [255 - o for o in octets]
+    inverted_str = ".".join(str(i) for i in inverted_octets)
+    return is_netmask(inverted_str)
+
+
 def is_network(ip_network: str) -> bool:
     """Verifies whether or not a string is a valid IP Network with a Mask.
 
@@ -650,3 +681,51 @@ def get_ips_sorted(ips: t.Union[str, t.List[str]], sort_type: str = "network") -
         return [str(ip) for ip in sorted_list]
     except ValueError as err:
         raise ValueError(f"Invalid IP of {sort_type} input: {err}") from err
+
+
+def netmask_to_wildcardmask(netmask: str) -> str:
+    """
+    Convert a standard IPv4 netmask to its wildcardmask.
+
+    Args:
+        netmask (str): The IPv4 netmask (e.g. "255.255.255.0").
+
+    Returns:
+        str: The corresponding wildcardmask (e.g. "0.0.0.255").
+
+    Examples:
+        >>> from netutils.ip import netmask_to_wildcardmask
+        >>> netmask_to_wildcardmask("255.255.255.0")
+        '0.0.0.255'
+        >>> netmask_to_wildcardmask("255.255.0.0")
+        '0.0.255.255'
+    """
+    if is_netmask(netmask):
+        octets: t.List[int] = [int(o) for o in netmask.split(".")]
+        inverted = [255 - octet for octet in octets]
+        return ".".join(str(i) for i in inverted)
+    raise ValueError("Subnet mask is not valid.")
+
+
+def wildcardmask_to_netmask(wildcardmask: str) -> str:
+    """
+    Convert a wildcardmask to its corresponding IPv4 netmask.
+
+    Args:
+        wildcardmask (str): The IPv4 wildcardmask (e.g. "0.0.0.255").
+
+    Returns:
+        str: The corresponding netmask (e.g. "255.255.255.0").
+
+    Examples:
+        >>> from netutils.ip import wildcardmask_to_netmask
+        >>> wildcardmask_to_netmask("0.0.0.255")
+        '255.255.255.0'
+        >>> wildcardmask_to_netmask("0.0.255.255")
+        '255.255.0.0'
+    """
+    if is_reversible_wildcardmask(wildcardmask):
+        octets: t.List[int] = [int(o) for o in wildcardmask.split(".")]
+        inverted = [255 - octet for octet in octets]
+        return ".".join(str(i) for i in inverted)
+    raise ValueError("Wildcard mask is not valid.")
diff --git a/netutils/lib_mapper.py b/netutils/lib_mapper.py
index 79bb608..2e2fe17 100644
--- a/netutils/lib_mapper.py
+++ b/netutils/lib_mapper.py
@@ -261,6 +261,7 @@ NETMIKO_LIB_MAPPER_REVERSE: t.Dict[str, str] = {
 # ntc templates is primarily based on netmiko, so a copy is in order
 _NTCTEMPLATES_LIB_MAPPER = copy.deepcopy(NETMIKO_LIB_MAPPER)
 _NTCTEMPLATES_LIB_MAPPER["aruba_aoscx"] = "aruba_aoscx"
+_NTCTEMPLATES_LIB_MAPPER["cisco_wlc_ssh"] = "cisco_wlc"
 _NTCTEMPLATES_LIB_MAPPER["huawei_vrp"] = "huawei_vrp"
 _NTCTEMPLATES_LIB_MAPPER["vmware_nsxv"] = "vmware_nsxv"
 _NTCTEMPLATES_LIB_MAPPER["watchguard_firebox"] = "watchguard_firebox"
diff --git a/netutils/utils.py b/netutils/utils.py
index 813dad8..8688e45 100644
--- a/netutils/utils.py
+++ b/netutils/utils.py
@@ -38,10 +38,13 @@ _JINJA2_FUNCTION_MAPPINGS = {
     "is_ip_range": "ip.is_ip_range",
     "is_ip_within": "ip.is_ip_within",
     "is_netmask": "ip.is_netmask",
+    "is_reversible_wildcardmask": "ip.is_reversible_wildcardmask",
     "is_network": "ip.is_network",
     "netmask_to_cidr": "ip.netmask_to_cidr",
     "cidr_to_netmask": "ip.cidr_to_netmask",
     "cidr_to_netmaskv6": "ip.cidr_to_netmaskv6",
+    "netmask_to_wildcardmask": "ip.netmask_to_wildcardmask",
+    "wildcardmask_to_netmask": "ip.wildcardmask_to_netmask",
     "get_all_host": "ip.get_all_host",
     "get_broadcast_address": "ip.get_broadcast_address",
     "get_first_usable": "ip.get_first_usable",
diff --git a/pyproject.toml b/pyproject.toml
index 8315258..485e57a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,12 +47,13 @@ pydocstyle = "*"
 toml = "*"
 yamllint = "*"
 mypy = "*"
-mkdocs = "1.5.2"
-mkdocs-material = "9.2.4"
+mkdocs = "1.6.0"
+mkdocs-material = "9.5.32"
 mkdocs-version-annotations = "1.0.0"
-mkdocstrings = "0.22.0"
-mkdocstrings-python = "1.6.0"
+mkdocstrings = "0.25.2"
+mkdocstrings-python =  "1.10.8"
 mkdocs-python-classy = "0.1.3"
+griffe = "1.1.1"
 
 [tool.black]
 line-length = 120


Test patch:
diff --git a/tests/unit/test_ip.py b/tests/unit/test_ip.py
index 28ec4d2..b4743b4 100644
--- a/tests/unit/test_ip.py
+++ b/tests/unit/test_ip.py
@@ -444,6 +444,14 @@ IS_NETMASK = [
     {"sent": {"netmask": "ffff:ffff:ffff:ffff:ffff::"}, "received": True},
 ]
 
+IS_REVERSIBLE_WILDCARDMASK = [
+    {"sent": {"wildcardmask": "0.0.0.255"}, "received": True},
+    {"sent": {"wildcardmask": "0.0.0.0"}, "received": True},
+    {"sent": {"wildcardmask": "0.0.0.1"}, "received": True},
+    {"sent": {"wildcardmask": "0.0.0.2"}, "received": False},
+    {"sent": {"wildcardmask": "0.0.255.0"}, "received": False},
+]
+
 NETMASK_CIDR = [
     {"sent": {"netmask": "255.255.255.0"}, "received": 24},
     {"sent": {"netmask": "255.192.0.0"}, "received": 10},
@@ -464,6 +472,28 @@ CIDR_NETMASK6 = [
     {"sent": {"cidr": 80}, "received": "ffff:ffff:ffff:ffff:ffff::"},
 ]
 
+NETMASK_WILDCARDMASK = [
+    {"sent": {"netmask": "255.255.255.254"}, "received": "0.0.0.1"},
+    {"sent": {"netmask": "255.255.255.252"}, "received": "0.0.0.3"},
+    {"sent": {"netmask": "255.255.255.0"}, "received": "0.0.0.255"},
+    {"sent": {"netmask": "255.255.254.0"}, "received": "0.0.1.255"},
+    {"sent": {"netmask": "255.255.252.0"}, "received": "0.0.3.255"},
+    {"sent": {"netmask": "255.255.0.0"}, "received": "0.0.255.255"},
+    {"sent": {"netmask": "255.254.0.0"}, "received": "0.1.255.255"},
+    {"sent": {"netmask": "255.0.0.0"}, "received": "0.255.255.255"},
+]
+
+WILDCARDMASK_NETMASK = [
+    {"sent": {"wildcardmask": "0.0.0.1"}, "received": "255.255.255.254"},
+    {"sent": {"wildcardmask": "0.0.0.3"}, "received": "255.255.255.252"},
+    {"sent": {"wildcardmask": "0.0.0.255"}, "received": "255.255.255.0"},
+    {"sent": {"wildcardmask": "0.0.1.255"}, "received": "255.255.254.0"},
+    {"sent": {"wildcardmask": "0.0.3.255"}, "received": "255.255.252.0"},
+    {"sent": {"wildcardmask": "0.0.255.255"}, "received": "255.255.0.0"},
+    {"sent": {"wildcardmask": "0.1.255.255"}, "received": "255.254.0.0"},
+    {"sent": {"wildcardmask": "0.255.255.255"}, "received": "255.0.0.0"},
+]
+
 COUNT_BITS = [
     {"sent": 0, "received": 0},
     {"sent": 234, "received": 5},
@@ -604,6 +634,11 @@ def test_is_netmask(data):
     assert ip.is_netmask(**data["sent"]) == data["received"]
 
 
+@pytest.mark.parametrize("data", IS_REVERSIBLE_WILDCARDMASK)
+def test_is_reversible_wildcardmask(data):
+    assert ip.is_reversible_wildcardmask(**data["sent"]) == data["received"]
+
+
 @pytest.mark.parametrize("data", NETMASK_CIDR)
 def test_netmask_to_cidr(data):
     assert ip.netmask_to_cidr(**data["sent"]) == data["received"]
@@ -637,6 +672,21 @@ def test_cidr_to_netmask_fail():
         ip.cidr_to_netmask(**data)
 
 
+@pytest.mark.parametrize("data", NETMASK_WILDCARDMASK)
+def test_netmask_to_wildcardmask(data):
+    assert ip.netmask_to_wildcardmask(**data["sent"]) == data["received"]
+
+
+@pytest.mark.parametrize("data", WILDCARDMASK_NETMASK)
+def test_wildcardmask_to_netmask(data):
+    assert ip.wildcardmask_to_netmask(**data["sent"]) == data["received"]
+
+
+def test_wildcardmask_to_netmask_invalid():
+    with pytest.raises(ValueError, match="Wildcard mask is not valid."):
+        ip.wildcardmask_to_netmask("0.0.255.0")
+
+
 @pytest.mark.parametrize("data", GET_PEER)
 def test_get_peer_ip(data):
     assert ip.get_peer_ip(**data["sent"]) == data["received"]