Architecture

This page explains how getmac’s code is organized, how a lookup flows through it, and a few of the design decisions behind it. It’s for contributors, and for anyone who wants to know what getmac does on their system before using it. For how methods work in detail, see How methods are chosen and used, and for how to add one, see Adding a new method.

Goals and constraints

getmac has one job: return the MAC address of a local network interface, or of a host on the local network, on as many platforms as possible, with nothing to install. That leads to a few constraints the design follows:

  • Pure Python, standard library only. No runtime dependencies and no C-extensions, so getmac can be vendored or installed anywhere Python runs (CPython and PyPy, 3.9 and newer). Instead of calling platform APIs, it gets MACs the way a sysadmin would: by running commands like arp, ip, ifconfig and ipconfig.exe, reading files like /proc/net/arp, and parsing the output with regular expressions. A few methods use standard library features like fcntl and ctypes.

  • Many platforms, one interface. Windows, Linux, macOS, WSL, the BSDs, Solaris, HP-UX, Android, Docker, and BusyBox all have different commands and files for the same information. The caller never sees the difference.

  • No privileges needed. Everything getmac does works as a normal user, with one exception: some methods, such as arping, need root. They’re tried when they’re installed, and if one is installed but doesn’t have the privileges it needs, that lookup returns None (see Do I need root or admin?).

  • Fail quietly, by default. A lookup that can’t find a MAC returns None and logs why. getmac only raises when something is wrong with how it was called, or when no method at all works on the platform (see Errors and exceptions).

This replaced the pre-0.9.0 approach of throwing every method at the wall until one stuck, which was slow (a miss could take seconds), prone to false positives, and hard to test. The rewrite summary from the 0.9.0 release has the full story.

Where things live

getmac is a small package with a handful of modules:

Method isn’t exported from the top-level package, so import it with from getmac.getmac import Method.

How a lookup works

  1. Normalize the arguments. get_mac_address() accepts one of interface, ip, ip6, or hostname. bytes are decoded as UTF-8, and ipaddress objects are converted to strings (an IPv6 object passed as ip is treated as ip6, and a network raises ValueError). localhost and 127.0.0.1 return 00:00:00:00:00:00 without doing anything else. A hostname is resolved to an IPv4 address with the system resolver.

  2. Refresh the ARP/NDP table, if asked. Operating systems only know the MAC of a host they’ve talked to recently. For a remote host, when network_request is true (the default), getmac sends something to get the host into the table. For IPv4, on Linux it reads the ARP table file (ArpFile) first, and if the host is there, nothing is sent. If it isn’t, getmac uses a method that makes a real ARP request (CtypesHost on Windows, or ArpingHost) if one is available, and only then falls back to sending one empty UDP packet to the host on settings.PORT (55555 by default), which makes the OS resolve the host and add it to its table. IPv6 goes straight to the UDP packet. ARP_TIMEOUT controls how long getmac keeps checking the table afterwards. With network_request=False, getmac sends nothing and only reads what’s already in the table. IPv4 lookups and network requests has the exact sequence.

  3. Pick a method. Each way of getting a MAC is a Method subclass with a method_type (ip4, ip6, ip for both, iface, or default_iface), the platforms it supports, and whether it sends network traffic. initialize_method_cache() filters the METHODS registry by type and by the detected platform (or OVERRIDE_PLATFORM), creates each candidate, and calls its test(), which is a cheap check such as “does this command exist” or “can this file be read”. The first method that passes is cached for that type, and the others are kept as fallbacks. This happens once per method type per process, which is why the first call is slower than the rest.

  4. Run it, and fall back if it fails. get_by_method() calls the cached method’s get(arg). If get() raises, or the method marks itself unusable, getmac logs the problem, drops the method, and tries the next fallback. If the last fallback fails too, the lookup returns None. A RuntimeError comes from an earlier point: initialize_method_cache() raises it when no method matches the type and platform, or when none of the matching methods pass test(). That means the platform isn’t supported, or something changed underneath getmac (see Errors and exceptions).

  5. Clean the result. Whatever the method found (a line of command output, the contents of a file, or a struct from a system call) goes through clean_mac(), which returns a lowercase colon-separated MAC, or None if the value isn’t a valid MAC.

With no arguments, getmac finds the default interface first (step 3 with default_iface methods, which read the routing table), then looks up its MAC. If that interface has no MAC (a VPN tunnel, for example), the first interface with a MAC that isn’t a loopback interface is used.

Three kinds of methods

The METHODS list in getmac/getmac.py is the registry, and its order is the order of preference within each type. Methods come in three kinds:

  • Files. On Linux and Android, /sys/class/net/<iface>/address gives an interface’s MAC, /proc/net/arp is the ARP table, and /proc/net/route is the routing table. These are the fastest methods, so they’re preferred where they exist.

  • System calls. fcntl with SIOCGIFHWADDR on Linux, and ctypes calls into iphlpapi.dll on Windows. No subprocess, no parsing of text.

  • Commands. ip, arp, ndp, ifconfig, netstat, route, arping, lanscan, networksetup, getmac.exe, ipconfig.exe, netsh.exe, and wmic.exe. getmac runs the command with subprocess.check_output() (never through a shell), discards stderr, and searches stdout with a regular expression written for that command’s output format. arping is tried when it’s installed (test() only checks that the command exists), so if it’s installed but getmac doesn’t have the privileges to use it (it usually needs root), the lookup returns None instead of falling back to the next method. See Do I need root or admin?.

Each method is small and independent: it declares what it supports, checks whether it can run, and parses one format. A new platform or command version gets a new method and a new fixture of real command output in tests/samples/, rather than a change to an existing method’s regular expression. That keeps a fix for one platform from breaking another.

Platform detection and PATH

Constants detects the platform once, at import time, from platform.uname(). PLATFORM is wsl on WSL1, linux on Linux, WSL2, and Android, and otherwise the lowercased system name (darwin, windows, freebsd, sunos, and so on). Methods are filtered on it. other isn’t something getmac detects, it’s the bucket of generic methods getmac falls back to (with a RuntimeWarning) when nothing matches. The names a method can list are in Method.VALID_PLATFORM_NAMES: android, darwin, linux, windows, wsl, openbsd, freebsd, netbsd, dragonfly, sunos, hp-ux, and other (see Platforms). OVERRIDE_PLATFORM replaces the detected value, which is how the test suite exercises every platform’s methods on one machine.

Commands are found on a search path (gvars.PATH) that’s copied from the process’s PATH at import time. On POSIX systems /sbin and /usr/sbin are added, since arp, ip, and ifconfig often live there and a normal user’s PATH may not include them. On Windows, Python Scripts directories are removed, so a pip-installed getmac.exe (this package’s own command) or ping.exe can’t be picked instead of the Windows command of the same name. That only changes getmac’s internal copy. Your shell’s PATH isn’t touched, which is why Troubleshooting still tells you to add Scripts to it. Commands run with a copy of the environment that sets LC_ALL=C, so their output isn’t translated. See Environment variables.

Singleton classes for global state

All of getmac’s global state lives in one module, getmac.variables, as attributes of three classes that are each instantiated exactly once:

getmac.py, utils.py and __main__.py all import the same three objects (from .variables import consts, gvars, settings), so every module reads and writes one shared copy of each value, instead of each module keeping its own.

Why objects, instead of the module-level variables that 0.9.x used?

  • No global keyword. Code changes an attribute (gvars.DEFAULT_IFACE = ...) instead of rebinding a module-level name, so functions don’t need global declarations.

  • Changes are seen everywhere. When you set settings.DEBUG = 2 (or the CLI does, from --debug), all of getmac sees it. A module-level variable copied with from ... import DEBUG would keep its old value in every module that imported it.

  • Tests are simpler. One patch, such as mocker.patch.object(consts, "WINDOWS", True), changes the value everywhere it’s used, and is undone automatically when the test ends.

  • Better docs. Each attribute has a type annotation and a docstring, and the classes are rendered in the API, which makes it clear which values are settings for users to change and which are internal.

  • Room to grow. An attribute can become a property that validates or lazily computes its value, and the singletons could later be replaced by instances passed to functions (as planned for OVERRIDE_PLATFORM), without changing the settings.NAME style of access in the code that uses them.