├── src ├── ouilookup │ ├── exceptions.py │ ├── models │ │ ├── __init__.py │ │ └── ouidata.py │ ├── utils │ │ ├── __init__.py │ │ ├── output.py │ │ ├── temppath.py │ │ └── logger.py │ ├── __init__.py │ ├── cli │ │ └── __init__.py │ └── main.py └── bin │ └── ouilookup ├── tests ├── test_import.py ├── __init__.py ├── test_trivial.py └── test_functions.py ├── .gitignore ├── .flake8 ├── .github └── workflows │ └── build-tests.yml ├── LICENSE ├── pyproject.toml └── readme.md /src/ouilookup/exceptions.py: -------------------------------------------------------------------------------- 1 | class OuiLookupException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /tests/test_import.py: -------------------------------------------------------------------------------- 1 | def test_import(): 2 | exec("from ouilookup import *") 3 | -------------------------------------------------------------------------------- /src/ouilookup/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .ouidata import OuiData, OuiDataMeta, OuiDataVendor 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | sys.path.append(os.path.join(os.path.dirname(__file__), "..", "src")) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .dmypy.json 3 | .venv/ 4 | *.egg-info/ 5 | /.vscode 6 | /build 7 | /dist 8 | poetry.lock 9 | 10 | dev/ 11 | .idea/ 12 | -------------------------------------------------------------------------------- /src/ouilookup/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .logger import logger_get, logger_setlevel 2 | from .output import output 3 | from .temppath import temppath_create, temppath_delete 4 | -------------------------------------------------------------------------------- /tests/test_trivial.py: -------------------------------------------------------------------------------- 1 | import ouilookup 2 | 3 | 4 | def test_name_exist(): 5 | assert ouilookup.__name__ is not None 6 | 7 | 8 | def test_version_exist(): 9 | assert ouilookup.__version__ is not None 10 | -------------------------------------------------------------------------------- /src/ouilookup/utils/output.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def output(data, indent=2, sort_keys=False): 5 | if type(data) is str: 6 | print(data) 7 | else: 8 | print(json.dumps(data, indent=indent, sort_keys=sort_keys)) 9 | -------------------------------------------------------------------------------- /src/bin/ouilookup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os.path 4 | import re 5 | import sys 6 | 7 | sys.path.append(os.path.join(os.path.dirname(__file__), "..")) 8 | from ouilookup.cli import cli 9 | 10 | if __name__ == "__main__": 11 | sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) 12 | sys.exit(cli()) 13 | -------------------------------------------------------------------------------- /src/ouilookup/utils/temppath.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import shutil 4 | import tempfile 5 | 6 | 7 | def temppath_create(pathname_prefix="", pathname="", random_length=8) -> str: 8 | if not pathname: 9 | pathname = "".join(random.choice("abcdefghijklmnopqrstuvwxyz0123456789") for _ in range(random_length)) 10 | 11 | if pathname_prefix: 12 | pathname = f"{pathname_prefix}{pathname}" 13 | 14 | full_path = os.path.join(tempfile.gettempdir(), pathname) 15 | os.makedirs(full_path, exist_ok=True) 16 | 17 | return full_path 18 | 19 | 20 | def temppath_delete(path): 21 | if not os.path.isdir(path): 22 | return 23 | shutil.rmtree(path) 24 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | 4 | # Black can yield formatted code that triggers these Flake8 warnings. 5 | ignore = 6 | 7 | # Line break occurred before a binary operator (W503) - https://www.flake8rules.com/rules/W503.html 8 | W503 9 | 10 | # Line break occurred after a binary operator (W504) - https://www.flake8rules.com/rules/W504.html 11 | W504 12 | 13 | # Whitespace before ':' (E203) - https://www.flake8rules.com/rules/E203.html 14 | E203 15 | 16 | per-file-ignores = 17 | 18 | # Module imported but unused (F401) - https://www.flake8rules.com/rules/F401.html 19 | src/**/__init__.py: F401 20 | 21 | # trailing whitespace (W291) - https://www.flake8rules.com/rules/W291.html 22 | src/ouilookup/cli/__init__.py: W291 23 | -------------------------------------------------------------------------------- /src/ouilookup/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf8 -*- 2 | # Copyright (c) 2018-2023 Nicholas de Jong 3 | 4 | __author__ = "Nicholas de Jong " 5 | __version__ = "0.3.1" 6 | __title__ = "ouilookup" 7 | __logger_name__ = "ouilookup" 8 | 9 | import os 10 | 11 | __data_filename__ = "ouilookup.json" 12 | __data_source_url__ = "https://standards-oui.ieee.org/oui/oui.txt" 13 | __data_path_localuser__ = os.path.abspath(os.path.expanduser("~/.local/ouilookup")) 14 | __data_path_package__ = os.path.abspath(os.path.join(os.path.dirname(__file__), "data")) 15 | __data_path_system__ = "/var/lib/ouilookup" 16 | __data_path_defaults__ = [__data_path_localuser__, __data_path_package__, __data_path_system__] 17 | 18 | from .utils.logger import logger_get 19 | 20 | __logger_level__ = "debug" if os.getenv("OUILOOKUP_DEBUG", "").lower().startswith(("true", "yes", "enable")) else "info" 21 | logger_get(name=__logger_name__, loglevel=__logger_level__) 22 | 23 | from .main import OuiLookup # noqa: E402 24 | -------------------------------------------------------------------------------- /.github/workflows/build-tests.yml: -------------------------------------------------------------------------------- 1 | 2 | name: build tests 3 | on: [push] 4 | 5 | jobs: 6 | 7 | test: 8 | name: "Test package" 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.10"] 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: NiklasRosenstein/slap@gha/install/v1 16 | 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v4 19 | with: { python-version: "${{ matrix.python-version }}" } 20 | 21 | - name: Create a venv to operate within using slap-cli 22 | run: | 23 | slap venv --create tester 24 | 25 | - name: Install, test and build a package to install 26 | run: | 27 | slap install --use-venv=tester 28 | slap test --use-venv=tester 29 | mkdir build-tester 30 | slap publish --build-directory build-tester --dry 31 | 32 | - name: Install the package from whl and run the application 33 | run: | 34 | pip install build-tester/*.whl 35 | ouilookup --status 36 | -------------------------------------------------------------------------------- /src/ouilookup/models/ouidata.py: -------------------------------------------------------------------------------- 1 | import time 2 | from dataclasses import asdict, dataclass 3 | from datetime import datetime 4 | 5 | 6 | @dataclass 7 | class OuiDataMeta: 8 | timestamp: datetime 9 | source_url: str 10 | source_data_file: str 11 | source_bytes: int 12 | source_md5: str 13 | source_sha1: str 14 | source_sha256: str 15 | vendor_count: int 16 | 17 | def as_dict(self): 18 | data = {} 19 | for k, v in asdict(self).items(): 20 | if k == "timestamp": 21 | data[k] = time.strftime("%Y-%m-%dT%H:%M:%S+00:00", v) 22 | else: 23 | data[k] = str(v) 24 | return data 25 | 26 | 27 | @dataclass 28 | class OuiDataVendor: 29 | vendor: str 30 | hwaddr_prefix: str 31 | 32 | 33 | @dataclass 34 | class OuiData: 35 | meta: OuiDataMeta 36 | vendors: list[OuiDataVendor] 37 | 38 | def serialize(self): 39 | data = {"meta": self.meta.as_dict(), "vendors": {}} 40 | for vendor in self.vendors: 41 | data["vendors"][vendor.hwaddr_prefix] = vendor.vendor 42 | return data 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Verb Networks Pty Ltd 2 | Copyright (c) 2018-2023 Nicholas de Jong 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 19 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 22 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /src/ouilookup/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | __logging_format__ = "__name__ [%(levelname)s] %(message)s" 4 | # __logging_format__ = "%(asctime)s | %(levelname)s | __name__ | %(message)s" 5 | __logging_date_format__ = "%Y-%m-%dT%H:%M:%S%z" 6 | 7 | 8 | def logger_get(name: str, loglevel="warning", logfile=None) -> logging.Logger: 9 | logger = logging.getLogger(name) 10 | if logger.handlers: 11 | return logger 12 | 13 | logging_level = __logger_level_int(loglevel) 14 | logger.setLevel(logging_level) 15 | 16 | logging_format = __logging_format__.replace("__name__", name) 17 | logging_formatter = logging.Formatter(fmt=logging_format, datefmt=__logging_date_format__) 18 | 19 | console_handler = logging.StreamHandler() 20 | console_handler.setLevel(logging_level) 21 | console_handler.setFormatter(logging_formatter) 22 | 23 | logger.addHandler(console_handler) 24 | 25 | try: 26 | if logfile: 27 | file_handler = logging.FileHandler(filename=logfile) 28 | file_handler.setLevel(logging_level) 29 | file_handler.setFormatter(logging_formatter) 30 | logger.addHandler(file_handler) 31 | except (FileNotFoundError, PermissionError): 32 | raise PermissionError(f"Unable to write to logfile at: {logfile}") 33 | 34 | return logger 35 | 36 | 37 | def logger_setlevel(name: str, loglevel: str) -> None: 38 | logger = logging.getLogger(name) 39 | logging_level = __logger_level_int(loglevel) 40 | 41 | logger.setLevel(logging_level) 42 | for handler in logger.handlers: 43 | handler.setLevel(logging_level) 44 | 45 | return None 46 | 47 | 48 | def __logger_level_int(loglevel) -> int: 49 | logging_level = logging.getLevelName(loglevel.upper()) 50 | try: 51 | int(logging_level) 52 | except ValueError: 53 | raise ValueError(f"Unknown loglevel requested: {loglevel}") 54 | 55 | return int(logging_level) 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "ouilookup" 7 | version = "0.3.1" 8 | description = "A Python module (and CLI tool) for looking up hardware MAC addresses from the OUI source at ieee.org." 9 | authors = ["Nicholas de Jong "] 10 | license = "BSD-2-Clause" 11 | readme = "readme.md" 12 | packages = [{ include = "ouilookup", from = "src" }] 13 | classifiers = [ 14 | "Environment :: Console", 15 | "Intended Audience :: System Administrators", 16 | "Topic :: System :: Networking :: Monitoring", 17 | "Topic :: Software Development :: Libraries :: Python Modules", 18 | "License :: OSI Approved :: BSD License", 19 | ] 20 | keywords = ["ouilookup", "oui", "mac", "mac-address", "hw-address", "ether", "ethernet"] 21 | 22 | [tool.poetry.urls] 23 | "Bug Tracker" = "https://github.com/ndejong/ouilookup/issues" 24 | Documentation = "https://github.com/ndejong/ouilookup" 25 | Homepage = "https://pypi.org/project/ouilookup/" 26 | Repository = "https://github.com/ndejong/ouilookup" 27 | 28 | [tool.poetry.scripts] 29 | ouilookup = "ouilookup.cli:cli" 30 | 31 | [tool.poetry.dependencies] 32 | python = "^3.6" 33 | 34 | [tool.poetry.dev-dependencies] 35 | black = "^23.3" # https://pypi.org/project/black/#history 36 | flake8 = "^6.0" # https://pypi.org/project/flake8/#history 37 | isort = "^5.12" # https://pypi.org/project/isort/#history 38 | pytest = "^7.3" # https://pypi.org/project/pytest/#history 39 | safety = "2.4.0b1" # https://pypi.org/project/safety/#history 40 | 41 | [tool.slap] 42 | typed = false 43 | release.branch = "dev" 44 | 45 | [tool.slap.test] 46 | pytest = "pytest tests/ -vv" 47 | check = "slap check" 48 | isort = "isort src/ tests/ --check-only" 49 | black = "black src/ tests/ --check" 50 | flake8 = "flake8 src/ tests/" 51 | safety = "pip freeze | safety check --stdin --output=text" 52 | 53 | [tool.slap.run] 54 | format = "black src/ tests/ && isort src/ tests/" 55 | 56 | [tool.isort] 57 | profile = "black" 58 | line_length = 120 59 | combine_as_imports = true 60 | 61 | [tool.black] 62 | line-length = 120 63 | -------------------------------------------------------------------------------- /tests/test_functions.py: -------------------------------------------------------------------------------- 1 | from ouilookup import OuiLookup 2 | 3 | 4 | def test_ouilookup_query(): 5 | OL = OuiLookup() 6 | data = OL.query("00:00:aa:00:00:00") 7 | 8 | assert type(data) is list 9 | assert len(data) == 1 10 | assert type(data[0]) is dict 11 | assert "0000AA000000" in data[0] 12 | assert data[0]["0000AA000000"] == "XEROX CORPORATION" 13 | 14 | 15 | def test_ouilookup_query_multi(): 16 | OL = OuiLookup() 17 | data = OL.query("00:00:01:00:00:00 00-00-10-00-00-00 000011000000") 18 | print(data) 19 | 20 | assert type(data) is list 21 | assert len(data) == 3 22 | assert "000001000000" in data[0] 23 | assert "000010000000" in data[1] 24 | assert "000011000000" in data[2] 25 | assert data[0]["000001000000"] == "XEROX CORPORATION" 26 | assert data[1]["000010000000"] == "SYTEK INC." 27 | assert data[2]["000011000000"] == "NORMEREL SYSTEMES" 28 | 29 | 30 | def test_ouilookup_query_multi2(): 31 | OL = OuiLookup() 32 | data = OL.query("00:00:01:00:00:00, 00-00-10-00-00-00,000011000000") 33 | print(data) 34 | 35 | assert type(data) is list 36 | assert len(data) == 3 37 | assert "000001000000" in data[0] 38 | assert "000010000000" in data[1] 39 | assert "000011000000" in data[2] 40 | assert data[0]["000001000000"] == "XEROX CORPORATION" 41 | assert data[1]["000010000000"] == "SYTEK INC." 42 | assert data[2]["000011000000"] == "NORMEREL SYSTEMES" 43 | 44 | 45 | def test_ouilookup_query_multi3(): 46 | OL = OuiLookup() 47 | data = OL.query(["00:00:01:00:00:00", "00-00-10-00-00-00,000011000000"]) 48 | print(data) 49 | 50 | assert type(data) is list 51 | assert len(data) == 3 52 | assert "000001000000" in data[0] 53 | assert "000010000000" in data[1] 54 | assert "000011000000" in data[2] 55 | assert data[0]["000001000000"] == "XEROX CORPORATION" 56 | assert data[1]["000010000000"] == "SYTEK INC." 57 | assert data[2]["000011000000"] == "NORMEREL SYSTEMES" 58 | 59 | 60 | def test_ouilookup_status(): 61 | OL = OuiLookup() 62 | data = OL.status() 63 | 64 | assert type(data) is dict 65 | assert "data_file" in data 66 | assert "source_bytes" in data 67 | assert "vendor_count" in data 68 | -------------------------------------------------------------------------------- /src/ouilookup/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from ouilookup import OuiLookup 4 | 5 | from .. import __data_filename__, __data_path_defaults__, __data_source_url__, __logger_name__, __title__, __version__ 6 | from ..utils import logger_setlevel, output 7 | 8 | 9 | def cli(): 10 | parser = cli_argparser() 11 | args = parser.parse_args() 12 | 13 | if args.debug: 14 | logger_setlevel(name=__logger_name__, loglevel="debug") 15 | 16 | ouilookup = OuiLookup(data_file=args.data_file) 17 | 18 | if args.update is True: 19 | output(ouilookup.update(), sort_keys=True) 20 | 21 | elif args.update_local: 22 | output(ouilookup.update(source_data_file=args.update_local), sort_keys=True) 23 | 24 | elif args.status is True: 25 | output(ouilookup.status(), sort_keys=True) 26 | 27 | elif args.query is not False: 28 | output(ouilookup.query(expression=args.query)) 29 | 30 | else: 31 | parser.print_help() 32 | exit(1) 33 | 34 | 35 | def cli_argparser() -> argparse.ArgumentParser: 36 | parser = argparse.ArgumentParser( 37 | description="{} v{}".format(__title__, __version__), 38 | add_help=True, 39 | epilog=""" 40 | A CLI tool for interfacing with the OuiLookup module that provides CLI access the query(), 41 | update() and status() functions. Outputs at the CLI are JSON formatted allowing for easy chaining with 42 | other toolchains. The update() function updates directly from "standards-oui.ieee.org". 43 | """, 44 | ) 45 | 46 | parser_group0 = parser.add_mutually_exclusive_group() 47 | parser_group0.add_argument( 48 | "-q", 49 | "--query", 50 | required=False, 51 | default=False, 52 | type=str, 53 | nargs="*", 54 | metavar="", 55 | help=f"Query to locate matching MAC hardware address(es) from the oui {__data_filename__} data file. " 56 | f"Addresses may be expressed in formats with or without ':' or '-' separators. Use a space or comma " 57 | f"between addresses to query for more than one item in a single query.", 58 | ) 59 | parser_group0.add_argument( 60 | "-s", 61 | "--status", 62 | required=False, 63 | default=False, 64 | action="store_true", 65 | help=f"Return status metadata about the {__data_filename__} data file.", 66 | ) 67 | parser_group0.add_argument( 68 | "-u", 69 | "--update", 70 | required=False, 71 | default=False, 72 | action="store_true", 73 | help=f"Download the latest from {__data_source_url__} then parse and save as a {__data_filename__} data file.", 74 | ) 75 | parser_group0.add_argument( 76 | "-ul", 77 | "--update-local", 78 | metavar="", 79 | required=False, 80 | help=f"Supply a local oui.txt then parse and save as a {__data_filename__} data file.", 81 | ) 82 | 83 | parser_group1 = parser.add_argument_group() 84 | parser_group1.add_argument( 85 | "-d", "--debug", required=False, default=False, action="store_true", help="Enable debug logging" 86 | ) 87 | parser_group1.add_argument( 88 | "-df", 89 | "--data-file", 90 | metavar="", 91 | required=False, 92 | help=f"Use a data file that is not in the default data file search paths: {', '.join(__data_path_defaults__)}", 93 | ) 94 | 95 | return parser 96 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ouilookup 2 | 3 | [![PyPi](https://img.shields.io/pypi/v/ouilookup.svg)](https://pypi.python.org/pypi/ouilookup/) 4 | [![Python Versions](https://img.shields.io/pypi/pyversions/ouilookup.svg)](https://github.com/ndejong/ouilookup/) 5 | [![build tests](https://github.com/ndejong/ouilookup/actions/workflows/build-tests.yml/badge.svg)](https://github.com/ndejong/ouilookup/actions/workflows/build-tests.yml) 6 | [![License](https://img.shields.io/github/license/ndejong/ouilookup.svg)](https://github.com/ndejong/ouilookup) 7 | 8 | A CLI tool and Python module for looking up hardware MAC addresses from the published OUI source list at ieee.org. 9 | 10 | ## Project 11 | * https://github.com/ndejong/ouilookup/ 12 | 13 | ## Install 14 | #### via PyPi 15 | ```bash 16 | pip3 install ouilookup 17 | ``` 18 | 19 | ## Versions 20 | Legacy versions based on year-date (eg v2018.2) have been hard-deprecated in favour of a backward incompatible 21 | standard versioning scheme (eg v0.2.0). 22 | 23 | ## CLI usage 24 | ```text 25 | usage: ouilookup [-h] [-q [ ...] | -s | -u | -ul ] [-d] [-df ] 26 | 27 | ouilookup v0.3.0 28 | 29 | options: 30 | -h, --help show this help message and exit 31 | -q [ ...], --query [ ...] 32 | Query to locate matching MAC hardware address(es) from 33 | the oui ouilookup.json data file. Addresses may be 34 | expressed in formats with or without ':' or '-' 35 | separators. Use a space or comma between addresses to 36 | query for more than one item in a single query. 37 | -s, --status Return status metadata about the ouilookup.json data 38 | file. 39 | -u, --update Download the latest from 40 | https://standards-oui.ieee.org/oui/oui.txt then parse 41 | and save as a ouilookup.json data file. 42 | -ul , --update-local 43 | Supply a local oui.txt then parse and save as a 44 | ouilookup.json data file. 45 | 46 | -d, --debug Enable debug logging 47 | -df , --data-file 48 | Use a data file that is not in the default data file 49 | search paths: /home//.local/ouilookup, 50 | /ouilookup/data, /var/lib/ouilookup 51 | 52 | A CLI tool for interfacing with the OuiLookup module that provides CLI access 53 | the query(), update() and status() functions. Outputs at the CLI are JSON 54 | formatted allowing for easy chaining with other toolchains. The update() 55 | function updates directly from "standards-oui.ieee.org". 56 | ``` 57 | 58 | ## Python3 Module usage 59 | 60 | ```console 61 | >>> from OuiLookup import OuiLookup 62 | 63 | >>> OuiLookup().query('00:00:aa:00:00:00') 64 | [{'0000AA000000': 'XEROX CORPORATION'}] 65 | 66 | >>> OuiLookup().query(['00:00:01:00:00:00','00-00-10-00-00-00','000011000000']) 67 | [{'000001000000': 'XEROX CORPORATION'}, {'000010000000': 'SYTEK INC.'}, {'000011000000': 'NORMEREL SYSTEMES'}] 68 | 69 | >>> OuiLookup().update() 70 | {'timestamp': '2023-05-13T14:11:17+00:00', 'source_url': 'https://standards-oui.ieee.org/oui/oui.txt', 'source_data_file': '/tmp/ouilookup-qm5aq0dk/oui.txt', 'source_bytes': '5468392', 'source_md5': '55a434f90da0c24c1a4fcfefe5b2b64b', 'source_sha1': 'dd5e8849ab8c65b2fb12c4b5aef290afee6bbfcd', 'source_sha256': 'af7e4bb1394109f4faad814074d3a6d5b792078074549a5d554c0904612c0bfc', 'vendor_count': '33808', 'data_file': '~/.local/ouilookup/ouilookup.json'} 71 | >>> OuiLookup().status() 72 | {'timestamp': '2023-05-13T14:11:17+00:00', 'source_url': 'https://standards-oui.ieee.org/oui/oui.txt', 'source_data_file': '/tmp/ouilookup-qm5aq0dk/oui.txt', 'source_bytes': '5468392', 'source_md5': '55a434f90da0c24c1a4fcfefe5b2b64b', 'source_sha1': 'dd5e8849ab8c65b2fb12c4b5aef290afee6bbfcd', 'source_sha256': 'af7e4bb1394109f4faad814074d3a6d5b792078074549a5d554c0904612c0bfc', 'vendor_count': '33808', 'data_file': '~/.local/ouilookup/ouilookup.json'} 73 | ``` 74 | 75 | ## Authors 76 | * [Nicholas de Jong](https://nicholasdejong.com) 77 | 78 | ## License 79 | BSD-2-Clause - see LICENSE file for full details. 80 | 81 | NB: License change from Apache-2.0 to BSD-2-Clause in February 2020 at version 0.2.0 82 | -------------------------------------------------------------------------------- /src/ouilookup/main.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import os 4 | import time 5 | import urllib.request 6 | from functools import lru_cache 7 | 8 | from . import __data_filename__, __data_path_defaults__, __data_source_url__, __logger_name__ 9 | from .exceptions import OuiLookupException 10 | from .models import OuiData, OuiDataMeta, OuiDataVendor 11 | from .utils import logger_get, temppath_create, temppath_delete 12 | 13 | logger = logger_get(__logger_name__) 14 | 15 | 16 | class OuiLookup: 17 | data_file = None 18 | 19 | def __init__(self, data_file=None): 20 | if data_file: 21 | self.data_file = data_file 22 | else: 23 | self.data_file = self.__find_data_file(data_paths=__data_path_defaults__) 24 | 25 | def query(self, expression): 26 | logger.debug(f"OuiLookup.query({expression=})") 27 | 28 | if isinstance(expression, str): 29 | expression = [expression] 30 | 31 | terms = [] 32 | for expression_item in expression: 33 | term = ( 34 | expression_item.strip() 35 | .replace(":", "") 36 | .replace("-", "") 37 | .replace(".", "") 38 | .replace(",", " ") 39 | .upper() 40 | .split(" ") 41 | ) 42 | terms += term 43 | logger.debug(f"Query expression normalized to terms [{', '.join(terms)}]") 44 | 45 | response = [] 46 | data = self.__load_data_file() 47 | 48 | for term in terms: 49 | if len(term) < 1: 50 | continue 51 | term_found = False 52 | for vendor_key, vendor_name in data["vendors"].items(): 53 | if term.startswith(vendor_key): 54 | response.append({term: vendor_name}) 55 | term_found = True 56 | break 57 | if term_found is not True: 58 | response.append({term: None}) 59 | 60 | return response 61 | 62 | def status(self): 63 | logger.debug("OuiLookup.status()") 64 | data = self.__load_data_file() 65 | return {**data["meta"], **{"data_file": self.data_file}} 66 | 67 | def update(self, data_file=None, source_data_file=None): 68 | logger.debug(f"OuiLookup.update({data_file=}, {source_data_file=})") 69 | 70 | if not self.data_file and not data_file: 71 | data_file = os.path.join(__data_path_defaults__[0], __data_filename__) 72 | logger.debug(f"OuiLookup.update() - setting data_file to {data_file!r}") 73 | elif not data_file: 74 | data_file = self.data_file 75 | logger.debug(f"OuiLookup.update() - setting data_file to {data_file!r}") 76 | 77 | temp_path = temppath_create(pathname_prefix="ouilookup-") 78 | 79 | if source_data_file is None: 80 | source_data_file = os.path.join(temp_path, "oui.txt") 81 | logger.debug(f"OuiLookup.update() - download source data file from {__data_source_url__!r}") 82 | try: 83 | urllib.request.urlretrieve(__data_source_url__, source_data_file) 84 | except Exception as e: 85 | raise OuiLookupException(f"Unable to download from data source {str(e)}") 86 | else: 87 | logger.debug(f"OuiLookup.update() - using supplied source data file {source_data_file!r}") 88 | 89 | if not os.path.isfile(source_data_file): 90 | raise OuiLookupException(f"Unable to locate source data file {source_data_file!r}") 91 | 92 | with open(source_data_file, "rb") as f: 93 | oui_rawdata = f.read() 94 | 95 | metadata = OuiDataMeta( 96 | timestamp=time.gmtime(os.path.getmtime(source_data_file)), 97 | source_bytes=len(oui_rawdata), 98 | source_data_file=source_data_file, 99 | source_md5=hashlib.md5(oui_rawdata).hexdigest(), 100 | source_sha1=hashlib.sha1(oui_rawdata).hexdigest(), 101 | source_sha256=hashlib.sha256(oui_rawdata).hexdigest(), 102 | source_url=__data_source_url__, 103 | vendor_count=0, 104 | ) 105 | 106 | temppath_delete(temp_path) 107 | 108 | vendors = [] 109 | for oui_line in oui_rawdata.decode("utf8").replace("\r", "").split("\n"): 110 | if "(hex)" in oui_line and "-" in oui_line: 111 | address = oui_line[0 : oui_line.find("(hex)")].rstrip(" ").replace("-", "") 112 | name = oui_line[oui_line.find("(hex)") :].replace("(hex)", "").replace("\t", "").strip() 113 | vendors.append(OuiDataVendor(vendor=name, hwaddr_prefix=address)) 114 | metadata.vendor_count += 1 115 | 116 | oui_data = OuiData(meta=metadata, vendors=vendors) 117 | 118 | logger.debug(f"OuiLookup.update() - saving new data file to {data_file!r}") 119 | os.makedirs(os.path.dirname(data_file), exist_ok=True) 120 | with open(data_file, "w") as f: 121 | f.write(json.dumps(oui_data.serialize(), indent=" ", sort_keys=True)) 122 | 123 | return {**metadata.as_dict(), **{"data_file": data_file}} 124 | 125 | @lru_cache 126 | def __load_data_file(self, data_file=None): 127 | if not data_file: 128 | data_file = self.data_file 129 | 130 | logger.debug(f"OuiLookup.__load_data_file({data_file=})") 131 | 132 | if not data_file or not os.path.isfile(data_file): 133 | raise OuiLookupException(f"Unable to locate OuiLookup data file {data_file!r}") 134 | 135 | with open(data_file, "r") as f: 136 | return json.load(f) 137 | 138 | def __find_data_file(self, data_paths): 139 | logger.debug(f"OuiLookup.__find_data_file({data_paths=})") 140 | 141 | for data_path in [os.path.abspath(os.path.expanduser(x)) for x in set(data_paths) if os.path.isdir(x)]: 142 | data_file = os.path.join(data_path, __data_filename__) 143 | if os.path.isfile(data_file): 144 | return data_file 145 | 146 | logger.warning(f"Unable to locate any {__data_filename__!r} data file in {data_paths!r}") 147 | --------------------------------------------------------------------------------