diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c7b809d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: test (${{ matrix.os }}, py${{ matrix.python }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python: ["3.9", "3.12"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Install + run: python -m pip install --upgrade pip && python -m pip install . + + # The suite is standard-library only and touches no sockets, so it runs + # unchanged on a sandboxed runner with no network egress. + - name: Run tests + run: python -m unittest discover -s tests -v + + - name: Smoke test the CLI + run: | + netprobe --version + netprobe subnet 10.0.0.0/24 + netprobe --json subnet 192.168.1.0/31 + netprobe mac 00:0c:29:ab:cd:ef + shell: bash + + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tools + run: python -m pip install --upgrade pip ruff + + - name: Ruff + run: ruff check . + + - name: Check the package builds + run: | + python -m pip install build + python -m build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8206d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.ruff_cache/ +.venv/ +venv/ + +# Local config - netprobe.ini.example is the tracked template. +netprobe.ini diff --git a/netprobe/cli.py b/netprobe/cli.py index 51836c2..979d5cb 100644 --- a/netprobe/cli.py +++ b/netprobe/cli.py @@ -4,7 +4,7 @@ import argparse import sys -from typing import Callable, Dict, List, Optional +from typing import Callable, List, Optional from . import __version__ diff --git a/netprobe/commands.py b/netprobe/commands.py index 6b1edd4..a1a4264 100644 --- a/netprobe/commands.py +++ b/netprobe/commands.py @@ -4,9 +4,17 @@ # Importing a module here is enough - each one calls cli.register() at import # time. Keep the list alphabetical so merge conflicts stay trivial. -from . import dnscheck, httpcheck, ipmath, latency, macvendor, output, portscan # noqa: F401 +from . import dnscheck, httpcheck, ipmath, latency, macvendor, output, portscan -_MODULES = ["dnscheck", "httpcheck", "ipmath", "latency", "macvendor", "output", "portscan"] +_MODULES = [ + "dnscheck", + "httpcheck", + "ipmath", + "latency", + "macvendor", + "output", + "portscan", +] def loaded() -> list: diff --git a/netprobe/ipmath.py b/netprobe/ipmath.py index 2c692bc..add06cd 100644 --- a/netprobe/ipmath.py +++ b/netprobe/ipmath.py @@ -4,7 +4,7 @@ import argparse import ipaddress -from typing import Dict, Iterator, List, Union +from typing import Dict, List, Union from .cli import register diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..80e7a26 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,14 @@ +line-length = 88 +target-version = "py39" + +[lint] +# "UP" (pyupgrade) is deliberately left out. It rewrites `Dict[str, int]` to +# `dict[str, int]` and `Optional[X]` to `X | None`, which the codebase avoids +# on purpose: 3.9 is the supported floor and the typing-module spelling works +# there at runtime, not only inside `from __future__ import annotations`. +select = ["E", "F", "W", "I", "B", "C4"] + +[lint.per-file-ignores] +# The CLI registry is populated by import side effects, so these imports are +# deliberately "unused". +"netprobe/commands.py" = ["F401"] diff --git a/tests/test_macvendor.py b/tests/test_macvendor.py index ca1623a..e8f3e7c 100644 --- a/tests/test_macvendor.py +++ b/tests/test_macvendor.py @@ -1,105 +1,107 @@ -"""Tests for MAC normalisation, flag bits and OUI lookup.""" - -import re -import unittest - -from netprobe import macvendor - - -class TestNormalize(unittest.TestCase): - def test_every_common_notation_round_trips(self): - for text in ( - "00:0c:29:ab:cd:ef", - "00-0C-29-AB-CD-EF", - "000c.29ab.cdef", - "000C29ABCDEF", - "00 0c 29 ab cd ef", - ): - self.assertEqual(macvendor.normalize(text), "000c29abcdef", text) - - def test_too_short_rejected(self): - with self.assertRaises(ValueError): - macvendor.normalize("00:0c:29:ab:cd") - - def test_too_long_rejected(self): - with self.assertRaises(ValueError): - macvendor.normalize("00:0c:29:ab:cd:ef:11") - - def test_non_hex_rejected(self): - with self.assertRaises(ValueError): - macvendor.normalize("zz:0c:29:ab:cd:ef") - - -class TestFormat(unittest.TestCase): - def test_styles(self): - mac = "000c29abcdef" - self.assertEqual(macvendor.format_mac(mac, "colon"), "00:0c:29:ab:cd:ef") - self.assertEqual(macvendor.format_mac(mac, "hyphen"), "00-0C-29-AB-CD-EF") - self.assertEqual(macvendor.format_mac(mac, "cisco"), "000c.29ab.cdef") - self.assertEqual(macvendor.format_mac(mac, "bare"), "000c29abcdef") - - def test_unknown_style_rejected(self): - with self.assertRaises(ValueError): - macvendor.format_mac("000c29abcdef", "morse") - - -class TestFlagBits(unittest.TestCase): - """First octet: bit 0 is multicast, bit 1 is locally administered.""" - - def test_universally_administered_unicast(self): - self.assertFalse(macvendor.is_locally_administered("00:0c:29:ab:cd:ef")) - self.assertFalse(macvendor.is_multicast("00:0c:29:ab:cd:ef")) - - def test_locally_administered(self): - # 0x02 -> bit 1 set. Typical of randomised phone MACs. - self.assertTrue(macvendor.is_locally_administered("02:11:22:33:44:55")) - self.assertFalse(macvendor.is_multicast("02:11:22:33:44:55")) - - def test_multicast(self): - # 01:00:5e:.. is the IPv4 multicast range. - self.assertTrue(macvendor.is_multicast("01:00:5e:00:00:01")) - - def test_broadcast_is_both(self): - self.assertTrue(macvendor.is_multicast("ff:ff:ff:ff:ff:ff")) - self.assertTrue(macvendor.is_locally_administered("ff:ff:ff:ff:ff:ff")) - - -class TestLookup(unittest.TestCase): - def test_known_vendors(self): - self.assertEqual(macvendor.lookup("00:0c:29:11:22:33")["vendor"], "VMware, Inc.") - self.assertEqual( - macvendor.lookup("b8:27:eb:11:22:33")["vendor"], - "Raspberry Pi Foundation", - ) - - def test_unknown_prefix(self): - self.assertEqual(macvendor.lookup("aa:bb:cc:dd:ee:ff")["vendor"], "unknown") - - def test_randomised_mac_is_annotated_rather_than_just_unknown(self): - row = macvendor.lookup("02:11:22:33:44:55") - self.assertTrue(row["locally_administered"]) - self.assertIn("randomised", str(row["note"])) - - def test_extra_table_overrides_builtin(self): - row = macvendor.lookup("00:0c:29:11:22:33", {"000C29": "Overridden"}) - self.assertEqual(row["vendor"], "Overridden") - - def test_extra_table_does_not_mutate_builtin(self): - macvendor.lookup("00:0c:29:11:22:33", {"000C29": "Overridden"}) - self.assertEqual(macvendor.BUILTIN_OUI["000C29"], "VMware, Inc.") - - def test_oui_is_uppercase_six_hex(self): - self.assertEqual(macvendor.oui("00:0c:29:ab:cd:ef"), "000C29") - - -class TestBuiltinTable(unittest.TestCase): - def test_every_key_is_well_formed(self): - bad = [k for k in macvendor.BUILTIN_OUI if not re.match(r"^[0-9A-F]{6}$", k)] - self.assertEqual(bad, []) - - def test_no_empty_vendor_strings(self): - self.assertTrue(all(v.strip() for v in macvendor.BUILTIN_OUI.values())) - - -if __name__ == "__main__": - unittest.main() +"""Tests for MAC normalisation, flag bits and OUI lookup.""" + +import re +import unittest + +from netprobe import macvendor + + +class TestNormalize(unittest.TestCase): + def test_every_common_notation_round_trips(self): + for text in ( + "00:0c:29:ab:cd:ef", + "00-0C-29-AB-CD-EF", + "000c.29ab.cdef", + "000C29ABCDEF", + "00 0c 29 ab cd ef", + ): + self.assertEqual(macvendor.normalize(text), "000c29abcdef", text) + + def test_too_short_rejected(self): + with self.assertRaises(ValueError): + macvendor.normalize("00:0c:29:ab:cd") + + def test_too_long_rejected(self): + with self.assertRaises(ValueError): + macvendor.normalize("00:0c:29:ab:cd:ef:11") + + def test_non_hex_rejected(self): + with self.assertRaises(ValueError): + macvendor.normalize("zz:0c:29:ab:cd:ef") + + +class TestFormat(unittest.TestCase): + def test_styles(self): + mac = "000c29abcdef" + self.assertEqual(macvendor.format_mac(mac, "colon"), "00:0c:29:ab:cd:ef") + self.assertEqual(macvendor.format_mac(mac, "hyphen"), "00-0C-29-AB-CD-EF") + self.assertEqual(macvendor.format_mac(mac, "cisco"), "000c.29ab.cdef") + self.assertEqual(macvendor.format_mac(mac, "bare"), "000c29abcdef") + + def test_unknown_style_rejected(self): + with self.assertRaises(ValueError): + macvendor.format_mac("000c29abcdef", "morse") + + +class TestFlagBits(unittest.TestCase): + """First octet: bit 0 is multicast, bit 1 is locally administered.""" + + def test_universally_administered_unicast(self): + self.assertFalse(macvendor.is_locally_administered("00:0c:29:ab:cd:ef")) + self.assertFalse(macvendor.is_multicast("00:0c:29:ab:cd:ef")) + + def test_locally_administered(self): + # 0x02 -> bit 1 set. Typical of randomised phone MACs. + self.assertTrue(macvendor.is_locally_administered("02:11:22:33:44:55")) + self.assertFalse(macvendor.is_multicast("02:11:22:33:44:55")) + + def test_multicast(self): + # 01:00:5e:.. is the IPv4 multicast range. + self.assertTrue(macvendor.is_multicast("01:00:5e:00:00:01")) + + def test_broadcast_is_both(self): + self.assertTrue(macvendor.is_multicast("ff:ff:ff:ff:ff:ff")) + self.assertTrue(macvendor.is_locally_administered("ff:ff:ff:ff:ff:ff")) + + +class TestLookup(unittest.TestCase): + def test_known_vendors(self): + self.assertEqual( + macvendor.lookup("00:0c:29:11:22:33")["vendor"], "VMware, Inc." + ) + self.assertEqual( + macvendor.lookup("b8:27:eb:11:22:33")["vendor"], + "Raspberry Pi Foundation", + ) + + def test_unknown_prefix(self): + self.assertEqual(macvendor.lookup("aa:bb:cc:dd:ee:ff")["vendor"], "unknown") + + def test_randomised_mac_is_annotated_rather_than_just_unknown(self): + row = macvendor.lookup("02:11:22:33:44:55") + self.assertTrue(row["locally_administered"]) + self.assertIn("randomised", str(row["note"])) + + def test_extra_table_overrides_builtin(self): + row = macvendor.lookup("00:0c:29:11:22:33", {"000C29": "Overridden"}) + self.assertEqual(row["vendor"], "Overridden") + + def test_extra_table_does_not_mutate_builtin(self): + macvendor.lookup("00:0c:29:11:22:33", {"000C29": "Overridden"}) + self.assertEqual(macvendor.BUILTIN_OUI["000C29"], "VMware, Inc.") + + def test_oui_is_uppercase_six_hex(self): + self.assertEqual(macvendor.oui("00:0c:29:ab:cd:ef"), "000C29") + + +class TestBuiltinTable(unittest.TestCase): + def test_every_key_is_well_formed(self): + bad = [k for k in macvendor.BUILTIN_OUI if not re.match(r"^[0-9A-F]{6}$", k)] + self.assertEqual(bad, []) + + def test_no_empty_vendor_strings(self): + self.assertTrue(all(v.strip() for v in macvendor.BUILTIN_OUI.values())) + + +if __name__ == "__main__": + unittest.main()