├── MANIFEST.in ├── Makefile ├── LICENSE ├── setup.py ├── README.rst ├── test.py └── macaddress.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: 2 | python setup.py sdist bdist_wheel 3 | 4 | clean: 5 | rm -rf __pycache__ *.py[oc] build *.egg-info dist MANIFEST 6 | 7 | test: 8 | pytest test.py 9 | PYTHONPATH=. pytest README.rst 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Alexander Kozhevnikov 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted. 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 7 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 8 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 9 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 10 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 11 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 12 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | from distutils.core import setup 9 | 10 | from macaddress import __doc__, __version__ 11 | 12 | project_directory = os.path.abspath(os.path.dirname(__file__)) 13 | readme_path = os.path.join(project_directory, 'README.rst') 14 | 15 | readme_file = open(readme_path) 16 | try: 17 | long_description = readme_file.read() 18 | finally: 19 | readme_file.close() 20 | 21 | setup( 22 | name='macaddress', 23 | version=__version__, 24 | description=__doc__.split('\n')[0], 25 | long_description=long_description, 26 | license='0BSD', 27 | url='https://github.com/mentalisttraceur/python-macaddress', 28 | author='Alexander Kozhevnikov', 29 | author_email='mentalisttraceur@gmail.com', 30 | classifiers=[ 31 | 'Development Status :: 5 - Production/Stable', 32 | 'Programming Language :: Python :: 3', 33 | 'Operating System :: OS Independent', 34 | ], 35 | py_modules=['macaddress'], 36 | ) 37 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | macaddress 2 | ========== 3 | 4 | A module for handling hardware identifiers like MAC addresses. 5 | 6 | This module makes it easy to: 7 | 8 | 1. check if a string represents a valid MAC address, or a similar 9 | hardware identifier like an EUI-64, OUI, etc, 10 | 11 | 2. convert between string and binary forms of MAC addresses and 12 | other hardware identifiers, 13 | 14 | and so on. 15 | 16 | Heavily inspired by the ``ipaddress`` module, but not yet quite 17 | as featureful. 18 | 19 | 20 | Versioning 21 | ---------- 22 | 23 | This library's version numbers follow the `SemVer 2.0.0 24 | specification `_. 25 | 26 | 27 | Installation 28 | ------------ 29 | 30 | :: 31 | 32 | pip install macaddress 33 | 34 | 35 | Usage 36 | ----- 37 | 38 | Import: 39 | 40 | .. code:: python 41 | 42 | >>> import macaddress 43 | 44 | Classes are provided for the common hardware identifier 45 | types: ``EUI48`` (also available as ``MAC``), ``EUI64``, 46 | ``OUI``, and so on. If those aren't enough, you can 47 | easily define others with just a few lines of code. 48 | 49 | 50 | Parse or Validate String 51 | ~~~~~~~~~~~~~~~~~~~~~~~~ 52 | 53 | When only one address type is valid: 54 | ```````````````````````````````````` 55 | 56 | All provided classes support the standard and common formats. 57 | For example, the ``EUI48`` class supports the following 58 | formats: 59 | 60 | .. code:: python 61 | 62 | >>> macaddress.EUI48('01-23-45-67-89-ab') 63 | EUI48('01-23-45-67-89-AB') 64 | >>> macaddress.EUI48('01:23:45:67:89:ab') 65 | EUI48('01-23-45-67-89-AB') 66 | >>> macaddress.EUI48('0123.4567.89ab') 67 | EUI48('01-23-45-67-89-AB') 68 | >>> macaddress.EUI48('0123456789ab') 69 | EUI48('01-23-45-67-89-AB') 70 | 71 | You can inspect what formats a hardware address class supports 72 | by looking at its ``formats`` attribute: 73 | 74 | .. code:: python 75 | 76 | >>> macaddress.OUI.formats 77 | ('xx-xx-xx', 'xx:xx:xx', 'xxxxxx') 78 | 79 | Each ``x`` in the format string matches one hexadecimal 80 | "digit", and all other characters are matched literally. 81 | 82 | If the string does not match one of the formats, a 83 | ``ValueError`` is raised: 84 | 85 | .. code:: python 86 | 87 | >>> try: 88 | ... macaddress.MAC('foo bar') 89 | ... except ValueError as error: 90 | ... print(error) 91 | ... 92 | 'foo bar' cannot be parsed as EUI48 93 | 94 | If you need to parse in a format that isn't supported, 95 | you can define a subclass and add the formats: 96 | 97 | .. code:: python 98 | 99 | >>> class MAC(macaddress.MAC): 100 | ... formats = macaddress.MAC.formats + ( 101 | ... 'xx-xx-xx-xx-xx-xx-', 102 | ... 'xx:xx:xx:xx:xx:xx:', 103 | ... 'xxxx.xxxx.xxxx.', 104 | ... ) 105 | ... 106 | >>> MAC('01-02-03-04-05-06-') 107 | MAC('01-02-03-04-05-06') 108 | 109 | >>> class MAC(macaddress.MAC): 110 | ... formats = macaddress.MAC.formats + ( 111 | ... 'xxx-xxx-xxx-xxx', 112 | ... 'xxx xxx xxx xxx', 113 | ... 'xxx:xxx:xxx:xxx', 114 | ... 'xxx.xxx.xxx.xxx', 115 | ... ) 116 | ... 117 | >>> MAC('012 345 678 9AB') 118 | MAC('01-23-45-67-89-AB') 119 | 120 | When multiple address types are valid: 121 | `````````````````````````````````````` 122 | 123 | There is also a ``parse`` function for when you have a string 124 | which might be one of several classes: 125 | 126 | .. code:: python 127 | 128 | >>> from macaddress import EUI48, EUI64, OUI 129 | 130 | >>> macaddress.parse('01:02:03', OUI, EUI48) 131 | OUI('01-02-03') 132 | >>> macaddress.parse('01:02:03:04:05:06', OUI, EUI48, EUI64) 133 | EUI48('01-02-03-04-05-06') 134 | >>> macaddress.parse('010203040506', EUI64, EUI48) 135 | EUI48('01-02-03-04-05-06') 136 | >>> macaddress.parse('0102030405060708', EUI64, EUI48, OUI) 137 | EUI64('01-02-03-04-05-06-07-08') 138 | 139 | If the input string cannot be parsed as any of 140 | the given classes, a ``ValueError`` is raised: 141 | 142 | .. code:: python 143 | 144 | >>> try: 145 | ... macaddress.parse('01:23', EUI48, OUI) 146 | ... except ValueError as error: 147 | ... print(error) 148 | ... 149 | '01:23' cannot be parsed as EUI48 or OUI 150 | >>> try: 151 | ... macaddress.parse('01:23', EUI48, OUI, EUI64) 152 | ... except ValueError as error: 153 | ... print(error) 154 | ... 155 | '01:23' cannot be parsed as EUI48, OUI, or EUI64 156 | 157 | Note that the message of the ``ValueError`` tries to be helpful 158 | for developers, but it is not localized, nor is its exact text 159 | part of the official public interface covered by SemVer. 160 | 161 | 162 | Parse from Bytes 163 | ~~~~~~~~~~~~~~~~ 164 | 165 | All ``macaddress`` classes can be constructed from raw bytes: 166 | 167 | .. code:: python 168 | 169 | >>> macaddress.MAC(b'abcdef') 170 | EUI48('61-62-63-64-65-66') 171 | >>> macaddress.OUI(b'abc') 172 | OUI('61-62-63') 173 | 174 | If the byte string is the wrong size, a ``ValueError`` is raised: 175 | 176 | .. code:: python 177 | 178 | >>> try: 179 | ... macaddress.MAC(b'\x01\x02\x03') 180 | ... except ValueError as error: 181 | ... print(error) 182 | ... 183 | b'\x01\x02\x03' has wrong length for EUI48 184 | 185 | 186 | Parse from Integers 187 | ~~~~~~~~~~~~~~~~~~~ 188 | 189 | All ``macaddress`` classes can be constructed from raw integers: 190 | 191 | .. code:: python 192 | 193 | >>> macaddress.MAC(0x010203ffeedd) 194 | EUI48('01-02-03-FF-EE-DD') 195 | >>> macaddress.OUI(0x010203) 196 | OUI('01-02-03') 197 | 198 | Note that the least-significant bit of the integer value maps 199 | to the last bit in the address type, so the same integer has 200 | a different meaning depending on the class you use it with: 201 | 202 | .. code:: python 203 | 204 | >>> macaddress.MAC(1) 205 | EUI48('00-00-00-00-00-01') 206 | >>> macaddress.OUI(1) 207 | OUI('00-00-01') 208 | 209 | If the integer is too large for the hardware identifier class 210 | that you're trying to construct, a ``ValueError`` is raised: 211 | 212 | .. code:: python 213 | 214 | >>> try: 215 | ... macaddress.OUI(1_000_000_000) 216 | ... except ValueError as error: 217 | ... print(error) 218 | ... 219 | 1000000000 is too big for OUI 220 | 221 | 222 | Get as String 223 | ~~~~~~~~~~~~~ 224 | 225 | .. code:: python 226 | 227 | >>> mac = macaddress.MAC('01-02-03-0A-0B-0C') 228 | >>> str(mac) 229 | '01-02-03-0A-0B-0C' 230 | 231 | For simple cases of changing the output format, you 232 | can just compose string operations: 233 | 234 | .. code:: python 235 | 236 | >>> str(mac).replace('-', ':') 237 | '01:02:03:0A:0B:0C' 238 | >>> str(mac).replace('-', '') 239 | '0102030A0B0C' 240 | >>> str(mac).lower() 241 | '01-02-03-0a-0b-0c' 242 | 243 | For more complicated cases, you can define a subclass 244 | with the desired output format as the first format: 245 | 246 | .. code:: python 247 | 248 | >>> class MAC(macaddress.MAC): 249 | ... formats = ( 250 | ... 'xxx xxx xxx xxx', 251 | ... ) + macaddress.MAC.formats 252 | ... 253 | >>> MAC(mac) 254 | MAC('010 203 0A0 B0C') 255 | 256 | 257 | Get as Bytes 258 | ~~~~~~~~~~~~ 259 | 260 | .. code:: python 261 | 262 | >>> mac = macaddress.MAC('61-62-63-04-05-06') 263 | >>> bytes(mac) 264 | b'abc\x04\x05\x06' 265 | 266 | 267 | Get as Integer 268 | ~~~~~~~~~~~~~~ 269 | 270 | .. code:: python 271 | 272 | >>> mac = macaddress.MAC('01-02-03-04-05-06') 273 | >>> int(mac) 274 | 1108152157446 275 | >>> int(mac) == 0x010203040506 276 | True 277 | 278 | 279 | Get the OUI 280 | ~~~~~~~~~~~ 281 | 282 | Most classes supplied by this module have the ``oui`` 283 | attribute, which returns their first three bytes as 284 | an OUI object: 285 | 286 | .. code:: python 287 | 288 | >>> macaddress.MAC('01:02:03:04:05:06').oui 289 | OUI('01-02-03') 290 | 291 | 292 | Compare 293 | ~~~~~~~ 294 | 295 | Equality 296 | ```````` 297 | 298 | All ``macaddress`` classes support equality comparisons: 299 | 300 | .. code:: python 301 | 302 | >>> macaddress.OUI('01-02-03') == macaddress.OUI('01:02:03') 303 | True 304 | >>> macaddress.OUI('01-02-03') == macaddress.OUI('ff-ee-dd') 305 | False 306 | >>> macaddress.OUI('01-02-03') != macaddress.CDI32('01-02-03-04') 307 | True 308 | >>> macaddress.OUI('01-02-03') != macaddress.CDI32('01-02-03-04').oui 309 | False 310 | 311 | Ordering 312 | ```````` 313 | 314 | All ``macaddress`` classes support total 315 | ordering. The comparisons are designed to 316 | intuitively sort identifiers that start 317 | with the same bits next to each other: 318 | 319 | .. code:: python 320 | 321 | >>> some_values = [ 322 | ... macaddress.MAC('ff-ee-dd-01-02-03'), 323 | ... macaddress.MAC('ff-ee-00-99-88-77'), 324 | ... macaddress.MAC('ff-ee-dd-01-02-04'), 325 | ... macaddress.OUI('ff-ee-dd'), 326 | ... ] 327 | >>> for x in sorted(some_values): 328 | ... print(x) 329 | FF-EE-00-99-88-77 330 | FF-EE-DD 331 | FF-EE-DD-01-02-03 332 | FF-EE-DD-01-02-04 333 | 334 | 335 | Define New Types 336 | ~~~~~~~~~~~~~~~~ 337 | 338 | If this library does not provide a hardware address 339 | type that you need, you can easily define your own. 340 | 341 | For example, this is all it takes to define 342 | IP-over-InfiniBand link-layer addresses: 343 | 344 | .. code:: python 345 | 346 | class InfiniBand(macaddress.HWAddress): 347 | size = 20 * 8 # size in bits; 20 octets 348 | 349 | formats = ( 350 | 'xx-xx-xx-xx-xx-xx-xx-xx-xx-xx-xx-xx-xx-xx-xx-xx-xx-xx-xx-xx', 351 | 'xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx', 352 | 'xxxx.xxxx.xxxx.xxxx.xxxx.xxxx.xxxx.xxxx.xxxx.xxxx', 353 | 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 354 | # or whatever formats you want to support 355 | ) 356 | # All formats are tried when parsing from string, 357 | # and the first format is used when stringifying. 358 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import weakref 2 | 3 | from hypothesis import given 4 | from hypothesis.strategies import ( 5 | binary, 6 | booleans, 7 | characters, 8 | composite, 9 | from_regex, 10 | integers, 11 | lists, 12 | one_of, 13 | sampled_from, 14 | text, 15 | ) 16 | import reprshed 17 | import pytest 18 | 19 | from macaddress import * 20 | 21 | 22 | @composite 23 | def _addresses(draw, random_formats=0): 24 | Class = draw(_address_classes(random_formats)) 25 | address_as_an_integer = draw(_address_integers(Class.size)) 26 | return Class(address_as_an_integer) 27 | 28 | 29 | @composite 30 | def _addresses_with_several_random_formats(draw): 31 | random_formats = draw(integers(min_value=2, max_value=8)) 32 | return draw(_addresses(random_formats=random_formats)) 33 | 34 | 35 | @composite 36 | def _address_classes_and_invalid_integers(draw): 37 | Class = draw(_address_classes()) 38 | invalid_integer = draw(one_of( 39 | integers(max_value=-1), 40 | integers(min_value=(1 << Class.size)), 41 | )) 42 | return (Class, invalid_integer) 43 | 44 | 45 | @composite 46 | def _address_classes_and_invalid_bytes(draw): 47 | Class = draw(_address_classes()) 48 | size_in_bytes = (Class.size + 7) >> 3 49 | invalid_byte_string = draw(one_of( 50 | binary(max_size=size_in_bytes-1), 51 | binary(min_size=size_in_bytes+1), 52 | )) 53 | return (Class, invalid_byte_string) 54 | 55 | 56 | @composite 57 | def _address_classes_and_invalid_strings(draw): 58 | Class = draw(_address_classes()) 59 | size_in_nibbles = (Class.size + 3) >> 2 60 | invalid_string = draw(one_of( 61 | text(characters(), max_size=size_in_nibbles-1), 62 | text(characters(), min_size=size_in_nibbles+1), 63 | from_regex('[^0-9A-Fa-f]'), 64 | )) 65 | return (Class, invalid_string) 66 | 67 | 68 | @composite 69 | def _lists_of_distinctly_formatted_addresses(draw): 70 | return draw(lists( 71 | _addresses(random_formats=1), 72 | min_size=2, 73 | max_size=8, 74 | unique_by=lambda address: address.formats[0], 75 | )) 76 | 77 | 78 | @composite 79 | def _lists_of_distinctly_sized_addresses(draw): 80 | return draw(lists( 81 | _addresses(), 82 | min_size=2, 83 | max_size=8, 84 | unique_by=lambda address: (address.size + 7) >> 3, 85 | )) 86 | 87 | 88 | @composite 89 | def _address_classes(draw, random_formats=0): 90 | address_sizes = integers(min_value=1, max_value=64) 91 | size_in_bits = draw(address_sizes) 92 | size_in_nibbles = (size_in_bits + 3) >> 2 93 | 94 | if random_formats > 0: 95 | format_strings = draw(lists( 96 | _address_format_strings(size_in_nibbles), 97 | min_size=random_formats, 98 | max_size=random_formats, 99 | )) 100 | else: 101 | format_string = 'x' * size_in_nibbles 102 | format_strings = (format_string,) 103 | 104 | class_should_be_slotted = draw(booleans()) 105 | 106 | class Class(HWAddress): 107 | if class_should_be_slotted: 108 | __slots__ = () 109 | size = size_in_bits 110 | formats = format_strings 111 | def __repr__(self): 112 | return reprshed.impure( 113 | self, 114 | size=type(self).size, 115 | formats=type(self).formats, 116 | slots=class_should_be_slotted, 117 | address=self._address, 118 | ) 119 | 120 | return Class 121 | 122 | 123 | def _address_integers(size_in_bits): 124 | return integers(min_value=0, max_value=((1 << size_in_bits) - 1)) 125 | 126 | 127 | _address_format_characters = sampled_from('x-:.') 128 | 129 | 130 | @composite 131 | def _address_format_strings(draw, size_in_nibbles): 132 | characters = [] 133 | while size_in_nibbles: 134 | character = draw(_address_format_characters) 135 | if character == 'x': 136 | size_in_nibbles -= 1 137 | characters.append(character) 138 | return ''.join(characters) 139 | 140 | 141 | @given(_addresses()) 142 | def test_int(address): 143 | Class = type(address) 144 | assert Class(int(address)) == address 145 | 146 | 147 | @given(_address_classes_and_invalid_integers()) 148 | def test_int_value_error(Class_and_integer): 149 | Class, integer = Class_and_integer 150 | with pytest.raises(ValueError): 151 | Class(integer) 152 | 153 | 154 | @given(_addresses()) 155 | def test_bytes(address): 156 | Class = type(address) 157 | assert Class(bytes(address)) == address 158 | 159 | 160 | @given(_address_classes_and_invalid_bytes()) 161 | def test_bytes_value_error(Class_and_bytes): 162 | Class, byte_string = Class_and_bytes 163 | with pytest.raises(ValueError): 164 | Class(byte_string) 165 | 166 | 167 | @given(_addresses(random_formats=1)) 168 | def test_str(address): 169 | Class = type(address) 170 | assert Class(str(address)) == address 171 | 172 | 173 | @given(_address_classes_and_invalid_strings()) 174 | def test_str_value_error(Class_and_string): 175 | Class, string = Class_and_string 176 | with pytest.raises(ValueError): 177 | Class(string) 178 | 179 | 180 | @given(_address_classes()) 181 | def test_str_x_literal_value_error(Class): 182 | size_in_nibbles = (Class.size + 3) >> 2 183 | with pytest.raises(ValueError): 184 | Class('x' * size_in_nibbles) 185 | 186 | 187 | @given(_addresses_with_several_random_formats()) 188 | def test_str_alternatives(address): 189 | Class = type(address) 190 | formats = Class.formats 191 | for format in formats: 192 | # Override instance formats to make this format the only 193 | # format, because it will stringify using the first one. 194 | Class.formats = (format,) 195 | # Format to string using the newly chosen format: 196 | formatted = str(address) 197 | # Restore the original formats for comparison, so that 198 | # the test verifies that the constructor parses each 199 | # alternate format whether or not it is the first one: 200 | Class.formats = formats 201 | assert Class(formatted) == address 202 | 203 | 204 | @given(_addresses()) 205 | def test_copy_construction(address): 206 | Class = type(address) 207 | assert Class(address) == address 208 | class ChildClass(Class): 209 | pass 210 | assert Class(ChildClass(address)) == address 211 | 212 | 213 | @given(_addresses()) 214 | def test_copy_construction_wrong_type(address): 215 | Class = type(address) 216 | class SiblingClass(HWAddress): 217 | size = Class.size 218 | with pytest.raises(TypeError): 219 | SiblingClass(address) 220 | address2 = SiblingClass(int(address)) 221 | with pytest.raises(TypeError): 222 | Class(address2) 223 | 224 | 225 | @given(_addresses(random_formats=1)) 226 | def test_parse_str(address): 227 | Class = type(address) 228 | assert parse(str(address), Class) == address 229 | 230 | 231 | @given(_lists_of_distinctly_formatted_addresses()) 232 | def test_parse_str_alternatives(addresses): 233 | classes = [type(address) for address in addresses] 234 | for address in addresses: 235 | assert parse(str(address), *classes) == address 236 | 237 | 238 | @given(_addresses()) 239 | def test_parse_bytes(address): 240 | Class = type(address) 241 | assert parse(bytes(address), Class) == address 242 | 243 | 244 | @given(_lists_of_distinctly_sized_addresses()) 245 | def test_parse_bytes_alternatives(addresses): 246 | classes = [type(address) for address in addresses] 247 | for address in addresses: 248 | assert parse(bytes(address), *classes) == address 249 | 250 | 251 | @given(_addresses()) 252 | def test_parse_passthrough(address): 253 | Class = type(address) 254 | assert parse(address, Class) == address 255 | 256 | 257 | @given(_addresses(), _addresses()) 258 | def test_equality(address1, address2): 259 | assert (address1 == address2) == (_key(address1) == _key(address2)) 260 | assert (address1 != address2) == (_key(address1) != _key(address2)) 261 | 262 | 263 | @given(_addresses(), _addresses()) 264 | def test_ordering(address1, address2): 265 | assert (address1 < address2) == (_key(address1) < _key(address2)) 266 | assert (address1 <= address2) == (_key(address1) <= _key(address2)) 267 | assert (address1 > address2) == (_key(address1) > _key(address2)) 268 | assert (address1 >= address2) == (_key(address1) >= _key(address2)) 269 | 270 | 271 | def _key(address): 272 | return (_bits(address), id(type(address))) 273 | 274 | 275 | def _bits(address): 276 | size = address.size 277 | address = int(address) 278 | bits = [] 279 | while size: 280 | least_significant_bit = address & 1 281 | bits.append(least_significant_bit) 282 | address >>= 1 283 | size -= 1 284 | return ''.join(map(str, reversed(bits))) 285 | 286 | 287 | @given(_addresses(), _addresses()) 288 | def test_comparison_consistency(address1, address2): 289 | eq = (address1 == address2) 290 | ne = (address1 != address2) 291 | lt = (address1 < address2) 292 | le = (address1 <= address2) 293 | gt = (address1 > address2) 294 | ge = (address1 >= address2) 295 | assert eq == (not ne) 296 | assert lt == (not ge) 297 | assert gt == (not le) 298 | assert eq == (ge and le) 299 | assert ne == (lt or gt) 300 | 301 | 302 | @given(_addresses(), _addresses()) 303 | def test_hash(address1, address2): 304 | Class = type(address1) 305 | assert hash(Class(address1)) == hash(address1) 306 | if address1 == address2: 307 | assert hash(address1) == hash(address2) 308 | 309 | 310 | @given(_addresses()) 311 | def test_repr(address): 312 | Class = type(address) 313 | del Class.__repr__ 314 | assert eval(repr(address)) == address 315 | 316 | 317 | @given(_addresses()) 318 | def test_repr_no_formats(address): 319 | Class = type(address) 320 | del Class.__repr__ 321 | del Class.formats 322 | assert eval(repr(address)) == address 323 | 324 | 325 | @given(_addresses()) 326 | def test_str_no_formats(address): 327 | Class = type(address) 328 | del Class.formats 329 | with pytest.raises(TypeError): 330 | str(address) 331 | with pytest.raises(TypeError): 332 | Class("") 333 | 334 | 335 | @given(_addresses()) 336 | def test_weak_reference(address): 337 | weakref.ref(address) 338 | 339 | 340 | def test_type_errors(): 341 | class Dummy: 342 | pass 343 | for thing in (None, [], {}, object, object(), Dummy, Dummy()): 344 | with pytest.raises(TypeError): 345 | MAC(thing) 346 | with pytest.raises(TypeError): 347 | parse(thing, MAC, OUI) 348 | with pytest.raises(TypeError): 349 | parse(thing) 350 | 351 | 352 | def test_equality_not_implemented(): 353 | class Dummy: 354 | pass 355 | for thing in (None, [], {}, object, object(), Dummy, Dummy()): 356 | assert MAC(0).__eq__(thing) is NotImplemented 357 | assert MAC(0).__ne__(thing) is NotImplemented 358 | 359 | 360 | def test_provided_classes(): 361 | for Class in OUI, CDI32, CDI40, MAC, EUI48, EUI60, EUI64: 362 | for format in Class.formats: 363 | assert (Class.size + 3) >> 2 == sum(1 for x in format if x == 'x') 364 | -------------------------------------------------------------------------------- /macaddress.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: 0BSD 2 | # Copyright 2021 Alexander Kozhevnikov 3 | 4 | """Like ``ipaddress``, but for hardware identifiers such as MAC addresses.""" 5 | 6 | __all__ = ( 7 | 'HWAddress', 8 | 'OUI', 9 | 'CDI32', 'CDI40', 10 | 'MAC', 11 | 'EUI48', 'EUI60', 'EUI64', 12 | 'parse', 13 | ) 14 | __version__ = '2.0.2' 15 | 16 | 17 | from functools import total_ordering as _total_ordering 18 | 19 | 20 | _HEX_DIGITS = "0123456789ABCDEFabcdef" 21 | 22 | 23 | def _name(obj): 24 | return type(obj).__name__ 25 | 26 | 27 | def _class_names_in_proper_english(classes): 28 | class_names = [cls.__name__ for cls in classes] 29 | number_of_classes = len(classes) 30 | if number_of_classes < 2: 31 | return class_names[0] 32 | elif number_of_classes == 2: 33 | return ' or '.join(class_names) 34 | else: 35 | class_names[-1] = 'or ' + class_names[-1] 36 | return ', '.join(class_names) 37 | 38 | 39 | def _type_error(value, *classes): 40 | class_names = _class_names_in_proper_english(classes) 41 | return TypeError(repr(value) + ' has wrong type for ' + class_names) 42 | 43 | 44 | def _value_error(value, error, *classes): 45 | class_names = _class_names_in_proper_english(classes) 46 | return ValueError(repr(value) + ' ' + error + ' ' + class_names) 47 | 48 | 49 | @_total_ordering 50 | class HWAddress: 51 | """Base class for hardware addresses. 52 | 53 | Can be subclassed to create new address types 54 | by just defining a couple class attribures. 55 | 56 | Attributes: 57 | size: An integer defined by each subclass to specify the size 58 | (in bits) of the hardware address. 59 | formats: A sequence of format strings defined by each subclass 60 | to specify what formats the class can parse. The first 61 | format string is also used for ``repr`` and ``str`` output. 62 | Each "x" in each format string stands for one hexadecimal 63 | digit. All other characters are literal. For example, for 64 | MAC addresses, the format strings are "xx-xx-xx-xx-xx-xx", 65 | "xx:xx:xx:xx:xx:xx", "xxxx.xxxx.xxxx", and "xxxxxxxxxxxx". 66 | """ 67 | 68 | __slots__ = ('_address', '__weakref__') 69 | 70 | formats = () 71 | 72 | def __init__(self, address): 73 | """Initialize the hardware address object with the address given. 74 | 75 | Arguments: 76 | address: An ``int``, ``bytes``, or ``str`` representation of 77 | the address, or another instance of an address which is 78 | either the same class, a subclass, or a superclass. If a 79 | string, the ``formats`` attribute of the class is used 80 | to parse it. If a byte string, it is read in big-endian. 81 | If an integer, its value bytes in big-endian are used as 82 | the address bytes. 83 | 84 | Raises: 85 | TypeError: If ``address`` is not one of the valid types. 86 | ValueError: If ``address`` is a string but does not match 87 | one of the formats, if ``address`` is a byte string 88 | but does not match the size, or if ``address`` is an 89 | integer with a value that is negative or too big. 90 | """ 91 | if isinstance(address, int): 92 | overflow = 1 << type(self).size 93 | if address >= overflow: 94 | raise _value_error(address, 'is too big for', type(self)) 95 | if address < 0: 96 | raise ValueError('hardware address cannot be negative') 97 | self._address = address 98 | elif isinstance(address, bytes): 99 | length = len(address) 100 | size_in_bytes = (type(self).size + 7) >> 3 101 | if length != size_in_bytes: 102 | raise _value_error(address, 'has wrong length for', type(self)) 103 | offset = (8 - type(self).size) & 7 104 | self._address = int.from_bytes(address, 'big') >> offset 105 | elif isinstance(address, str) and len(type(self).formats): 106 | self._address, _ = _parse(address, type(self)) 107 | # Subclass being "cast" to superclass: 108 | elif isinstance(address, type(self)): 109 | self._address = int(address) 110 | # Superclass being "cast" to subclass: 111 | elif (isinstance(address, HWAddress) 112 | and isinstance(self, type(address))): 113 | self._address = int(address) 114 | else: 115 | raise _type_error(address, type(self)) 116 | 117 | def __repr__(self): 118 | """Represent the hardware address as an unambiguous string.""" 119 | try: 120 | address = repr(str(self)) 121 | except TypeError: 122 | address = _hex(int(self), type(self).size) 123 | return _name(self) + '(' + address + ')' 124 | 125 | def __str__(self): 126 | """Get the canonical human-readable string of this hardware address.""" 127 | formats = type(self).formats 128 | if not len(formats): 129 | raise TypeError(_name(self) + ' has no string format') 130 | result = [] 131 | offset = (4 - type(self).size) & 3 132 | unconsumed_address_value = int(self) << offset 133 | for character in reversed(formats[0]): 134 | if character == 'x': 135 | nibble = unconsumed_address_value & 0xf 136 | result.append(_HEX_DIGITS[nibble]) 137 | unconsumed_address_value >>= 4 138 | else: 139 | result.append(character) 140 | return ''.join(reversed(result)) 141 | 142 | def __bytes__(self): 143 | """Get the big-endian byte string of this hardware address.""" 144 | offset = (8 - type(self).size) & 7 145 | size_in_bytes = (type(self).size + 7) >> 3 146 | return (int(self) << offset).to_bytes(size_in_bytes, 'big') 147 | 148 | def __int__(self): 149 | """Get the raw integer value of this hardware address.""" 150 | return self._address 151 | 152 | def __eq__(self, other): 153 | """Check if this hardware address is equal to another. 154 | 155 | Hardware addresses are equal if they are instances of the 156 | same class, and their raw bit strings are the same. 157 | """ 158 | if not isinstance(other, HWAddress): 159 | return NotImplemented 160 | return type(self) == type(other) and int(self) == int(other) 161 | 162 | def __lt__(self, other): 163 | """Check if this hardware address is before another. 164 | 165 | Hardware addresses are sorted by their raw bit strings, 166 | regardless of the exact hardware address class or size. 167 | 168 | For example: ``OUI('00-00-00') < CDI32('00-00-00-00')``, 169 | and they both are less than ``OUI('00-00-01')``. 170 | 171 | This order intuitively groups address prefixes like OUIs 172 | with (and just in front of) addresses like MAC addresses 173 | which have that prefix when sorting a list of them. 174 | """ 175 | if not isinstance(other, HWAddress): 176 | return NotImplemented 177 | class1 = type(self) 178 | class2 = type(other) 179 | size1 = class1.size 180 | size2 = class2.size 181 | bits1 = int(self) 182 | bits2 = int(other) 183 | if size1 > size2: 184 | bits2 <<= size1 - size2 185 | else: 186 | bits1 <<= size2 - size1 187 | return (bits1, size1, id(class1)) < (bits2, size2, id(class2)) 188 | 189 | def __hash__(self): 190 | """Get the hash of this hardware address.""" 191 | return hash((type(self), int(self))) 192 | 193 | 194 | def _hex(integer, bits): 195 | # Like the built-in function ``hex`` but pads the 196 | # output to ``bits`` worth of hex characters. 197 | # 198 | # Examples: 199 | # (integer=5, bits=32) -> '0x00000005' 200 | # (integer=0x1234, bits=32) -> '0x00001234' 201 | # (integer=0x1234, bits=16) -> '0x1234' 202 | return '0x' + hex((1 << (bits+3)) | integer)[3:] 203 | 204 | 205 | class OUI(HWAddress): 206 | """Organizationally Unique Identifier.""" 207 | 208 | __slots__ = () 209 | 210 | size = 24 211 | 212 | formats = ( 213 | 'xx-xx-xx', 214 | 'xx:xx:xx', 215 | 'xxxxxx', 216 | ) 217 | 218 | 219 | class _StartsWithOUI(HWAddress): 220 | __slots__ = () 221 | 222 | @property 223 | def oui(self): 224 | """Get the OUI part of this hardware address.""" 225 | return OUI(int(self) >> (type(self).size - OUI.size)) 226 | 227 | 228 | class CDI32(_StartsWithOUI): 229 | """32-bit Context Dependent Identifier (CDI-32).""" 230 | 231 | __slots__ = () 232 | 233 | size = 32 234 | 235 | formats = ( 236 | 'xx-xx-xx-xx', 237 | 'xx:xx:xx:xx', 238 | 'xxxxxxxx', 239 | ) 240 | 241 | 242 | class CDI40(_StartsWithOUI): 243 | """40-bit Context Dependent Identifier (CDI-40).""" 244 | 245 | __slots__ = () 246 | 247 | size = 40 248 | 249 | formats = ( 250 | 'xx-xx-xx-xx-xx', 251 | 'xx:xx:xx:xx:xx', 252 | 'xxxxxxxxxx', 253 | ) 254 | 255 | 256 | class EUI48(_StartsWithOUI): 257 | """48-Bit Extended Unique Identifier (EUI-48). 258 | 259 | EUI-48 is also the modern official name for what 260 | many people are used to calling a "MAC address". 261 | """ 262 | 263 | __slots__ = () 264 | 265 | size = 48 266 | 267 | formats = ( 268 | 'xx-xx-xx-xx-xx-xx', 269 | 'xx:xx:xx:xx:xx:xx', 270 | 'xxxx.xxxx.xxxx', 271 | 'xxxxxxxxxxxx', 272 | ) 273 | 274 | 275 | MAC = EUI48 276 | 277 | 278 | class EUI60(_StartsWithOUI): 279 | """60-Bit Extended Unique Identifier (EUI-60).""" 280 | 281 | __slots__ = () 282 | 283 | size = 60 284 | 285 | formats = ( 286 | 'x.x.x.x.x.x.x.x.x.x.x.x.x.x.x', 287 | 'xx-xx-xx.x.x.x.x.x.x.x.x.x', 288 | 'xxxxxxxxxxxxxxx', 289 | ) 290 | 291 | 292 | class EUI64(_StartsWithOUI): 293 | """64-Bit Extended Unique Identifier (EUI-64).""" 294 | 295 | __slots__ = () 296 | 297 | size = 64 298 | 299 | formats = ( 300 | 'xx-xx-xx-xx-xx-xx-xx-xx', 301 | 'xx:xx:xx:xx:xx:xx:xx:xx', 302 | 'xxxx.xxxx.xxxx.xxxx', 303 | 'xxxxxxxxxxxxxxxx', 304 | ) 305 | 306 | 307 | def parse(value, *classes): 308 | """Try parsing a value as several hardware address classes at once. 309 | 310 | This lets you just write 311 | 312 | address = macaddress.parse(user_input, EUI64, EUI48, ...) 313 | 314 | instead of all of this: 315 | 316 | try: 317 | address = macaddress.EUI64(user_input) 318 | except ValueError: 319 | try: 320 | address = macaddress.EUI48(user_input) 321 | except ValueError: 322 | ... 323 | 324 | Arguments: 325 | value: The value to parse as a hardware address. Either a 326 | string, byte string, or an instance of one of the classes. 327 | *classes: HWAddress subclasses to try to parse the string as. 328 | If the input address could parse as more than one of the 329 | classes, it is parsed as the first one. 330 | 331 | Returns: 332 | HWAddress: The parsed hardware address if the value argument 333 | was a string or byte string, or the value argument itself 334 | if it was already an instance of one of the classes. 335 | 336 | Raises: 337 | TypeError: If the value is not one of the valid types, 338 | or if no classes were passed in. 339 | ValueError: If the value could not be parsed as any 340 | of the given classes. 341 | """ 342 | if not classes: 343 | raise TypeError('parse() requires at least one class argument') 344 | if isinstance(value, str): 345 | address, cls = _parse(value, *classes) 346 | return cls(address) 347 | elif isinstance(value, bytes): 348 | max_size = len(value) * 8 349 | min_size = max_size - 7 350 | for cls in classes: 351 | if min_size <= cls.size <= max_size: 352 | return cls(value) 353 | raise _value_error(value, 'has wrong length for', *classes) 354 | elif isinstance(value, classes): 355 | return value 356 | raise _type_error(value, *classes) 357 | 358 | 359 | def _parse(string, *classes): 360 | length = len(string) 361 | if length < 1: 362 | raise ValueError('hardware address cannot be an empty string') 363 | candidates = {} 364 | for cls in classes: 365 | for format_ in cls.formats: 366 | if len(format_) == length: 367 | candidates.setdefault(format_, cls) 368 | candidates = sorted(candidates.items()) 369 | address = 0 370 | start = 0 371 | end = len(candidates) 372 | for index in range(length): 373 | character = string[index] 374 | if character in _HEX_DIGITS: 375 | address <<= 4 376 | address += int(character, 16) 377 | character = 'x' 378 | elif character == 'x': 379 | character = '' 380 | while start < end and candidates[start][0][index] < character: 381 | start += 1 382 | while start < end and candidates[end - 1][0][index] > character: 383 | end -= 1 384 | if start >= end: 385 | raise _value_error(string, 'cannot be parsed as', *classes) 386 | _, cls = candidates[start] 387 | offset = (4 - cls.size) & 3 388 | address >>= offset 389 | return address, cls 390 | --------------------------------------------------------------------------------