Adding a new method¶
getmac finds MAC addresses using methods, which are subclasses of Method that implement a technique for getting a MAC, such as running a command or reading a file. Supporting a new command, file, or platform means adding a new method.
To use a custom method in your own code without changing getmac, see Using the module API instead. For setting up a development environment and the rules for pull requests, see Contributing to getmac.
The examples on this page use a hypothetical IpAddrIface method, which gets the MAC address of an interface on Linux from the output of ip addr show <iface>. It isn’t part of getmac.
Collect sample output¶
Method tests run against real command output saved in tests/samples/, so start by capturing output on the platform the method is for. In this context, a “command” would be ip addr on Linux.
scripts/collect_samples.py runs the commands that getmac uses on the current platform, and saves the output of each one to a file in tests/samples/<platform>_<version>/ (e.g. tests/samples/ubuntu_24.04/). Exit codes and error messages are logged to collect_samples.log in the same directory. The script only needs Python, so it can be copied to a machine that doesn’t have getmac. A copy outside the repo saves to ./samples/<platform>_<version>/ instead (or the directory given with --output-root).
python scripts/collect_samples.py --dry-run # list what would be collected
python scripts/collect_samples.py # Run the collection
Alternatively, you can run the command by hand and save the output with tee:
mkdir -p tests/samples/ubuntu_24.04
ip addr show ens33 | tee tests/samples/ubuntu_24.04/ip_addr_show_ens33.out
Guidelines¶
Samples go in a directory for the OS and version, such as
ubuntu_18.04,macos_10.12.6orwindows_10.Files are named after the command line with a
.outextension, with spaces replaced by underscores, e.g.arp_-an.out.Redact MACs, IPs, and hostnames you don’t want to be public, but keep the output format intact. Locally administered MACs (
02:00:00:00:00:01) and documentation IP ranges (192.0.2.0/24) make good replacements.Windows samples must go in a directory whose name starts with
windows, so.gitattributescan preserve their Windows line endings (CRLF).Output taken from other projects goes in
tests/samples/third_party/<project>/, along with that project’s license, and gets credited in the README.You should also capture what the command does for something that doesn’t exist, along with the exit code (
echo $?when running it by hand). For example,ip addr show eth9printsDevice "eth9" does not exist.and exits with code 1.
Write the method¶
Methods currently live in getmac/getmac.py. Add the new class next to the methods with the same method_type. getmac/getmac.py already imports everything the example needs.
class IpAddrIface(Method):
"""
Uses the ``ip addr show <iface>`` command to get the MAC address of an interface on Linux.
Man page: `ip-address (8) <https://man7.org/linux/man-pages/man8/ip-address.8.html>`__
"""
platforms = {"linux"}
method_type = "iface"
def test(self) -> bool:
return utils.check_command("ip")
def get(self, arg: str) -> Optional[str]:
try:
output = utils.popen("ip", f"addr show {arg}")
except CalledProcessError as err:
# 'Device "eth9" does not exist.' exits with code 1
if err.returncode == 1:
return None
raise # Any other exit code means "ip" itself failed
# The MAC is on the line after "<index>: <iface>: <FLAGS> ...".
# "(?:@\w+)?" allows for container interface names like "eth0@if7".
regex = r"^\d+: " + re.escape(arg) + r"(?:@\w+)?:.*\n\s+link/\S+ " + consts.MAC_RE_COLON
return utils.search(regex, output, flags=re.MULTILINE)
Start the docstring with what the method uses and on which platform, and link to useful documentation (if relevant). The docstring appears in the API reference automatically.
Class attributes¶
platformsThe platforms the method works on, from
VALID_PLATFORM_NAMES. getmac compares these with the detected platform,PLATFORM(the lowercase result ofplatform.system()), or withOVERRIDE_PLATFORMif it’s set. Only list platforms where you’ve seen the method work."other"is special. Methods that list it are used on platforms that have no methods of that type of their own.method_typeWhat the method looks up.
ip4,ip6andip(both) methods are given an IP address and return that host’s MACifacemethods are given an interface name ("eth0") and return its MACdefault_ifacemethods are given an empty string and return the name of the default interface.
Method types lists where each type is used.
network_requestSet this to
Trueifget()sends packets on the network, asArpingHostandCtypesHostdo.
The class name matters too. It’s what FORCE_METHOD and --force-method match against (ignoring case), and it shows up in log messages, so renaming a method later can break things for users.
Follow the existing naming patterns, or use your own if none of the below apply:
...Ifacefor interface lookups...Exefor Windows executables...Filefor reading files...Hostfor methods that send packetsDefaultIface...for default interface methods
test()¶
test() is a cheap check that the method could work on this system. getmac calls it the first time it needs a method of that type, so it shouldn’t run the command or do anything slow.
Common checks are:
utils.check_command("ip"): the command is on thePATHand executable (a cached wrapper aroundshutil.which()).utils.check_path(path): a file or directory exists and is readable.Importing a standard library module inside a
tryblock, asFcntlIfacedoes.
If test() raises an exception, it’s treated as if it returned False.
Some things can only be found out by running the command, such as which arguments the installed version accepts. Methods like IpLinkIface and ArpVariousArgs check this on their first get() call and cache the result.
get()¶
get() does the lookup. It returns the MAC address as a string, or None if it wasn’t found. The MAC doesn’t need to be pretty, since get_mac_address() passes it through clean_mac().
default_iface methods don’t use the argument, so they’re declared as def get(self, arg: str = "") -> Optional[str]: # noqa: ARG002.
Return None when the interface or host isn’t there.
Raise an exception or set self.unusable = True when the method itself doesn’t work on this system (see Falling back during a lookup).
Helpers¶
utils.popen(command, args)runs a command and returns its output (stdout only) as a string.argsis a single string. A non-zero exit code raisesCalledProcessError. Always call it asutils.popen(...)so tests can patchgetmac.utils.popen.utils.read_file(path)returns a file’s contents, orNoneif it can’t be read.utils.search(regex, text)returns the first capture group of the first match, orNone. Use non-capturing groups ((?:...)) for everything except the MAC address.consts.MAC_RE_COLON,consts.MAC_RE_DASH(Windows style), andconsts.MAC_RE_SHORT(allows single-digit octets, like58:6d:8f:7:c9:94) match a MAC address in a single capture group.Log with
gvars.log, and wrap detailed debug messages inif settings.DEBUG:.
When parsing output:
Escape the argument with
re.escape()before putting it in a regular expression.Make sure a partial argument can’t match:
ethmust not matcheth0, and10.0.0.1must not match10.0.0.10.Don’t let a match run on into the next interface or host in the output, especially with
re.DOTALL.
Register it in METHODS¶
Add the class to METHODS, the list after the method classes in getmac/getmac.py:
METHODS: list[type[Method]] = [
...
IpLinkIface,
IpAddrIface,
NetstatIface,
...
]
The position in the list matters. getmac uses the first method of a type that passes test() on the current platform, in METHODS order, and keeps the others that pass as fallbacks (see Choosing a method).
Put faster and more reliable methods first: reading a file or calling a library before running a command (which spawns a process and is slow), and specific commands before generic catch-alls.
Write tests¶
Method tests go in tests/test_methods.py. They must not run real commands or depend on what’s installed on the system. Instead, load the sample with the get_sample fixture and patch the appropriate functions with mocker.patch.
Most methods have two tests: one parametrized over the samples, and one for edge cases. tests/test_methods.py already imports everything these need. Additionally, the sample tests are usually wrapped in benchmark(...) to run performance benchmarks (when enabled, e.g. pdm run benchmark).
@pytest.mark.parametrize(
("mac", "iface", "sample_file"),
[
("02:00:00:00:00:01", "ens33", "ubuntu_24.04/ip_addr_show_ens33.out"),
("08:00:27:e8:81:6f", "eth0", "ubuntu_12.04/ip_a.out"),
("00:00:00:00:00:00", "lo", "ubuntu_12.04/ip_a.out"),
],
)
def test_ipaddriface_samples(benchmark, mocker, get_sample, mac, iface, sample_file):
content = get_sample(sample_file)
mocker.patch("getmac.utils.popen", return_value=content)
assert mac == benchmark(getmac.IpAddrIface().get, arg=iface)
# Partial and unknown interface names must not match
for wrong_iface in ("eth", "th0", "ens3", "ens333", "lo0"):
assert getmac.IpAddrIface().get(wrong_iface) is None
def test_ipaddriface_edge_cases(mocker):
mocker.patch("getmac.utils.check_command", return_value=False)
assert getmac.IpAddrIface().test() is False
utils.check_command.assert_called_once_with("ip")
mocker.patch("getmac.utils.popen", return_value="")
assert getmac.IpAddrIface().get("eth0") is None
utils.popen.assert_called_once_with("ip", "addr show eth0")
# Exit code 1 means "not found", any other code is raised
cpe = CalledProcessError(cmd="ip", returncode=1)
mocker.patch("getmac.utils.popen", side_effect=cpe)
assert getmac.IpAddrIface().get("eth9") is None
cpe = CalledProcessError(cmd="ip", returncode=255)
mocker.patch("getmac.utils.popen", side_effect=cpe)
with pytest.raises(CalledProcessError):
getmac.IpAddrIface().get("eth0")
Things to cover:
The expected value is what
get()returns, beforeclean_mac().Partial and wrong names or addresses return
None.test()checks the right command or path (assert_called_once_with).Empty output, exit codes, and anything that sets
unusable.
Tests run in parallel and in random order, so only change global state (settings, caches, platform flags in consts) with mocker, which undoes the change after the test, e.g. mocker.patch.object(consts, "DARWIN", True).
To run just the new tests:
pdm run test tests/test_methods.py -k ipaddriface
Try it on a real system¶
pdm run getmac -dddd -i ens33 --force-method IpAddrIface
-dddd logs the command and its raw output.
-dd logs which methods were considered, which passed test(), and which one was chosen:
pdm run getmac -dd -i ens33
--override-platform makes getmac choose methods as if it were running on a different platform.
Finish up¶
Add the command, file, or library to “Commands and techniques by platform” in
README.md, and update “Platforms currently supported” if needed.Add an entry to
CHANGELOG.mdunder “Added” for the upcoming release, in plain language.Follow the checklist in Contributing to getmac and open a pull request!