From 9f4a6535fd4953721605455614a8798d9247c968 Mon Sep 17 00:00:00 2001 From: kiraum Date: Thu, 19 Dec 2024 23:46:41 +0100 Subject: [PATCH] feat(irrexplorer-cli): init --- .github/dependabot.yml | 11 +++ .github/workflows/linter.yml | 63 +++++++++++++ .pre-commit-config.yaml | 44 +++++++++ README.md | 19 +++- irrexplorer_cli/__init__.py | 0 irrexplorer_cli/irrexplorer.py | 161 +++++++++++++++++++++++++++++++++ irrexplorer_cli/main.py | 70 ++++++++++++++ irrexplorer_cli/models.py | 42 +++++++++ irrexplorer_cli/py.typed | 0 pyproject.toml | 72 +++++++++++++++ tests/__init__.py | 0 tests/test_cli.py | 45 +++++++++ tests/test_irrexplorer.py | 73 +++++++++++++++ tests/test_main.py | 42 +++++++++ tests/test_models.py | 50 ++++++++++ 15 files changed, 691 insertions(+), 1 deletion(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/linter.yml create mode 100644 .pre-commit-config.yaml create mode 100644 irrexplorer_cli/__init__.py create mode 100644 irrexplorer_cli/irrexplorer.py create mode 100644 irrexplorer_cli/main.py create mode 100644 irrexplorer_cli/models.py create mode 100644 irrexplorer_cli/py.typed create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_irrexplorer.py create mode 100644 tests/test_main.py create mode 100644 tests/test_models.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9d866e3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..307892e --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,63 @@ +name: Test (linter/formatter) + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.13"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install --upgrade uv + uv pip install --system --break-system-packages -r requirements.txt + + - name: Check YAML files + run: | + pre-commit run check-yaml --all-files + + - name: Check file endings + run: | + pre-commit run end-of-file-fixer --all-files + + - name: Check trailing whitespace + run: | + pre-commit run trailing-whitespace --all-files + + - name: Check TOML files + run: | + pre-commit run check-toml --all-files + + - name: Check large files + run: | + pre-commit run check-added-large-files --all-files + + - name: Run isort + run: | + isort --check --diff $(git ls-files '*.py') + + - name: Run black + run: | + black --check --diff $(git ls-files '*.py') + + - name: Run ruff + run: | + ruff check $(git ls-files '*.py') + + - name: Run mypy + run: | + mypy $(git ls-files '*.py') + + - name: Run pylint + run: | + pylint --rcfile=pyproject.toml $(git ls-files '*.py') + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..79d87c6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,44 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: 'v4.5.0' + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-toml + - id: check-added-large-files + +- repo: local + hooks: + - id: isort + name: isort + entry: isort + language: system + types: [python] + + - id: black + name: black + entry: black + language: python + types_or: [python, pyi] + + - id: ruff + name: ruff + entry: ruff check + language: python + types_or: [python, pyi] + + - id: mypy + name: mypy + entry: mypy + language: python + types_or: [python, pyi] + require_serial: true + + - id: pylint + name: pylint + entry: pylint + language: system + types: [python] + args: + - --rcfile=pyproject.toml diff --git a/README.md b/README.md index 14fba05..81180f2 100644 --- a/README.md +++ b/README.md @@ -1 +1,18 @@ -# irrexplorer-cli \ No newline at end of file +# irrexplorer-cli + +Here's how to compile and install the package using uv: + +uv pip install --editable ".[dev]" + +Copied + +Execute + +For a production build without development dependencies: + +uv pip install . + + + + +pytest tests/ -v --cov=irrexplorer_cli diff --git a/irrexplorer_cli/__init__.py b/irrexplorer_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/irrexplorer_cli/irrexplorer.py b/irrexplorer_cli/irrexplorer.py new file mode 100644 index 0000000..8e72cef --- /dev/null +++ b/irrexplorer_cli/irrexplorer.py @@ -0,0 +1,161 @@ +"""Core functionality for IRR Explorer CLI.""" + +from typing import Dict, List + +import backoff +import httpx +from rich.columns import Columns +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from .models import PrefixInfo + + +class IrrExplorer: + """IRR Explorer API client for prefix information retrieval.""" + + def __init__(self, base_url: str = "https://irrexplorer.nlnog.net") -> None: + """Initialize IRR Explorer client with base URL.""" + self.base_url = base_url + self.client = httpx.AsyncClient() + + @backoff.on_exception(backoff.expo, (httpx.HTTPError, httpx.RequestError), max_tries=3, max_time=30) + async def fetch_prefix_info(self, prefix: str) -> List[PrefixInfo]: + """Fetch prefix information from IRR Explorer API.""" + url = f"{self.base_url}/api/prefixes/prefix/{prefix}" + response = await self.client.get(url) + response.raise_for_status() + return [PrefixInfo(**item) for item in response.json()] + + async def close(self) -> None: + """Close the HTTP client connection.""" + await self.client.aclose() + + +class IrrDisplay: + """Display handler for IRR Explorer prefix information.""" + + def __init__(self) -> None: + """Initialize display handler with Rich console.""" + self.console = Console() + + async def create_prefix_panel(self, info: PrefixInfo) -> Panel: + """Create Rich panel with prefix information.""" + table = Table(show_header=True, header_style="bold cyan", expand=True) + table.add_column("Property") + table.add_column("Value") + + # Add basic info asynchronously + await self.add_basic_info(table, info) + await self.add_bgp_origins(table, info) + await self.add_rpki_routes(table, info) + await self.add_irr_routes(table, info) + await self.add_status(table, info) + await self.add_messages(table, info) + + return Panel( + table, + title=f"[bold]{info.prefix}[/bold]", + expand=False, + border_style=await self.get_status_style(info.categoryOverall), + ) + + async def sort_and_group_panels(self, prefix_infos: List[PrefixInfo]) -> List[Panel]: + """Sort and group prefix information panels by status category.""" + status_groups: Dict[str, List[Panel]] = {"success": [], "warning": [], "error": [], "danger": [], "info": []} + + sorted_infos = sorted(prefix_infos, key=lambda x: (-int(x.prefix.split("/")[1]), x.categoryOverall)) + + for info in sorted_infos: + panel = await self.create_prefix_panel(info) + status_groups[info.categoryOverall].append(panel) + + return [ + panel for status in ["success", "warning", "error", "danger", "info"] for panel in status_groups[status] + ] + + async def add_basic_info(self, table: Table, info: PrefixInfo) -> None: + """Add basic prefix information to the display table.""" + table.add_row("Prefix", info.prefix) + table.add_row("RIR", info.rir) + + async def add_bgp_origins(self, table: Table, info: PrefixInfo) -> None: + """Add BGP origin information to the display table.""" + bgp_origins = ", ".join(f"AS{asn}" for asn in info.bgpOrigins) if info.bgpOrigins else "None" + table.add_row("BGP Origins", bgp_origins) + + async def add_rpki_routes(self, table: Table, info: PrefixInfo) -> None: + """Add RPKI route information to the display table.""" + if info.rpkiRoutes: + rpki_rows = [ + f"AS{route.asn} (MaxLength: {route.rpkiMaxLength}, Status: {route.rpkiStatus})" + for route in info.rpkiRoutes + ] + table.add_row("RPKI Routes", "\n".join(rpki_rows)) + + async def add_irr_routes(self, table: Table, info: PrefixInfo) -> None: + """Add IRR route information to the display table.""" + if info.irrRoutes: + irr_rows = [ + f"{db}: AS{route.asn} ({route.rpkiStatus})" for db, routes in info.irrRoutes.items() for route in routes + ] + if irr_rows: + table.add_row("IRR Routes", "\n".join(irr_rows)) + + async def add_status(self, table: Table, info: PrefixInfo) -> None: + """Add status information to the display table.""" + status_style = await self.get_status_style(info.categoryOverall) + table.add_row("Status", Text(info.categoryOverall, style=status_style)) + + async def add_messages(self, table: Table, info: PrefixInfo) -> None: + """Add message information to the display table.""" + if info.messages: + messages = "\n".join(f"• {msg.text}" for msg in info.messages) + table.add_row("Messages", messages) + + async def get_status_style(self, category: str) -> str: + """Get Rich style color based on status category.""" + return {"success": "green", "warning": "yellow", "error": "red", "danger": "red", "info": "blue"}.get( + category, "white" + ) + + async def display_prefix_info(self, direct_overlaps: List[PrefixInfo]) -> None: + """Display prefix information in Rich panels.""" + direct_panels = await self.sort_and_group_panels(direct_overlaps) + + direct_columns = Columns(direct_panels, equal=True, expand=True) + self.console.print( + Panel( + direct_columns, + title=f"[bold]Directly overlapping prefixes of {direct_overlaps[0].prefix}[/bold]", + expand=False, + ) + ) + + least_specific = None + for info in direct_overlaps: + if "/" in info.prefix: + _, mask = info.prefix.split("/") + if least_specific is None or int(mask) < int(least_specific.split("/")[1]): + least_specific = info.prefix + + if least_specific: + try: + explorer = IrrExplorer() + all_overlaps = await explorer.fetch_prefix_info(least_specific) + all_panels = await self.sort_and_group_panels(all_overlaps) + all_columns = Columns(all_panels, equal=True, expand=True) + + self.console.print("\n") + self.console.print( + Panel( + all_columns, + title=f"[bold]All overlaps of least specific match {least_specific}[/bold]", + expand=False, + ) + ) + await explorer.close() + except (httpx.HTTPError, ValueError, RuntimeError) as e: + self.console.print(f"[red]Error fetching overlaps for {least_specific}: {e}[/red]") diff --git a/irrexplorer_cli/main.py b/irrexplorer_cli/main.py new file mode 100644 index 0000000..4ea975b --- /dev/null +++ b/irrexplorer_cli/main.py @@ -0,0 +1,70 @@ +"""Command-line interface for IRR Explorer queries.""" + +import asyncio +import json +from importlib.metadata import version +from typing import Optional + +import typer +from rich.console import Console + +from irrexplorer_cli.irrexplorer import IrrDisplay, IrrExplorer + +__version__ = version("irrexplorer-cli") + +app = typer.Typer( + help="CLI tool to query IRR Explorer data for prefix information", + no_args_is_help=True, + context_settings={"help_option_names": ["-h", "--help"]}, +) +console = Console() + + +def version_display(display_version: bool) -> None: + """Display version information and exit.""" + if display_version: + print(f"[bold]IRR Explorer CLI[/bold] version: {__version__}") + raise typer.Exit() + + +@app.callback() +def callback( + _: bool = typer.Option(None, "--version", "-v", callback=version_display, is_eager=True), +) -> None: + """Query IRR Explorer for prefix information.""" + + +@app.command(no_args_is_help=True) +def query( + ctx: typer.Context, + prefix: str = typer.Argument( + None, + help="IP prefix to query (e.g., 5.57.21.0/24)", + ), + output_format: Optional[str] = typer.Option( + None, + "--format", + "-f", + help="Output format (table, json)", + ), +) -> None: + """Query IRR Explorer for prefix information.""" + if not prefix: + ctx.get_help() + raise typer.Exit() + asyncio.run(async_query(prefix, output_format)) + + +async def async_query(prefix: str, output_format: Optional[str] = None) -> None: + """Execute asynchronous prefix query and display results.""" + explorer = IrrExplorer() + display = IrrDisplay() + try: + results = await explorer.fetch_prefix_info(prefix) + if output_format == "json": + json_data = [result.model_dump() for result in results] + print(json.dumps(json_data, indent=2)) + else: + await display.display_prefix_info(results) + finally: + await explorer.close() diff --git a/irrexplorer_cli/models.py b/irrexplorer_cli/models.py new file mode 100644 index 0000000..0cba1a6 --- /dev/null +++ b/irrexplorer_cli/models.py @@ -0,0 +1,42 @@ +"""Data models for IRR Explorer API responses.""" + +from typing import Dict, List, Optional + +from pydantic import BaseModel + + +class BaseRoute(BaseModel): + """Base model for route information.""" + + rpkiStatus: str + rpkiMaxLength: Optional[int] + asn: int + rpslText: str + rpslPk: str + + +class RpkiRoute(BaseRoute): + """RPKI route information model.""" + + +class IrrRoute(BaseRoute): + """IRR route information model.""" + + +class Message(BaseModel): + """Message model for API responses.""" + + text: str + category: str + + +class PrefixInfo(BaseModel): + """Prefix information model containing route and status details.""" + + prefix: str + rir: str + bgpOrigins: List[int] + rpkiRoutes: List[RpkiRoute] + irrRoutes: Dict[str, List[IrrRoute]] + categoryOverall: str + messages: List[Message] diff --git a/irrexplorer_cli/py.typed b/irrexplorer_cli/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..82b93a0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,72 @@ +[project] +name = "irrexplorer-cli" +version = "0.0.1" +description = "CLI tool to query IRR Explorer data" +authors = [{name = "kiraum", email = "tfgoncalves@xpto.it" }] +dependencies = [ + "httpx", + "rich", + "typer", + "pydantic", + "backoff", + "asyncio" +] +requires-python = ">=3.13" + +[project.optional-dependencies] +dev = [ + "pytest", + "black", + "ruff", + "mypy", + "pytest-asyncio", + "respx", + "pytest-cov", + "isort", + "pylint" +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build] +packages = ["irrexplorer_cli"] +include = [ + "irrexplorer_cli/py.typed", +] + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 120 + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "B"] + +[tool.mypy] +python_version = "3.13" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +check_untyped_defs = true + +[tool.black] +line-length = 120 + +[project.scripts] +irrexplorer = "irrexplorer_cli.main:app" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.pylint] +max-line-length = 120 +disable = [] +enable = "all" +good-names = ["i", "j", "k", "ex", "Run", "_", "id", "f"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..786ec35 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,45 @@ +"""Test suite for CLI functionality.""" + +from unittest.mock import patch + +import httpx +import pytest +from typer.testing import CliRunner + +from irrexplorer_cli.irrexplorer import IrrExplorer +from irrexplorer_cli.main import app + +runner = CliRunner() + + +def test_query_command_http_error() -> None: + """Test HTTP error handling in query command.""" + with patch("irrexplorer_cli.irrexplorer.IrrExplorer.fetch_prefix_info") as mock_fetch: + mock_fetch.side_effect = httpx.HTTPError("Test error") + result = runner.invoke(app, ["query", "5.57.21.0/24"]) + assert "Error fetching data" in result.stdout + + +@pytest.mark.asyncio +async def test_explorer_close_error() -> None: + """Test error handling during explorer client closure.""" + explorer = IrrExplorer() + with patch.object(explorer.client, "aclose") as mock_close: + mock_close.side_effect = RuntimeError("Close error") + try: + await explorer.close() + except RuntimeError as e: + assert str(e) == "Close error" + + +def test_callback_without_version() -> None: + """Test callback execution without version flag.""" + result = runner.invoke(app) + assert not result.exit_code + + +def test_query_empty_prefix() -> None: + """Test query command with empty prefix""" + result = runner.invoke(app, ["query", ""]) + assert "Usage: root query" in result.stdout + assert "IP prefix to query" in result.stdout diff --git a/tests/test_irrexplorer.py b/tests/test_irrexplorer.py new file mode 100644 index 0000000..9ef4279 --- /dev/null +++ b/tests/test_irrexplorer.py @@ -0,0 +1,73 @@ +"""Test suite for IRR Explorer core functionality.""" + +from unittest.mock import patch + +import pytest + +from irrexplorer_cli.irrexplorer import IrrDisplay, IrrExplorer +from irrexplorer_cli.models import PrefixInfo + + +@pytest.mark.asyncio +async def test_fetch_prefix_info() -> None: + """Test successful prefix information retrieval.""" + explorer = IrrExplorer() + result = await explorer.fetch_prefix_info("5.57.21.0/24") + assert isinstance(result, list) + assert all(isinstance(item, PrefixInfo) for item in result) + await explorer.close() + + +@pytest.mark.asyncio +async def test_display_prefix_info() -> None: + """Test prefix information display formatting.""" + display = IrrDisplay() + test_data = [ + PrefixInfo( + prefix="5.57.21.0/24", + rir="RIPE", + bgpOrigins=[12345], + rpkiRoutes=[], + irrRoutes={}, + categoryOverall="success", + messages=[], + ) + ] + await display.display_prefix_info(test_data) + + +@pytest.mark.asyncio +async def test_create_prefix_panel() -> None: + """Test panel creation with prefix information.""" + display = IrrDisplay() + test_info = PrefixInfo( + prefix="5.57.21.0/24", + rir="RIPE", + bgpOrigins=[12345], + rpkiRoutes=[], + irrRoutes={}, + categoryOverall="success", + messages=[], + ) + panel = await display.create_prefix_panel(test_info) + assert panel is not None + + +@pytest.mark.asyncio +async def test_display_prefix_info_least_specific_error() -> None: + """Test error handling for least specific prefix display.""" + display = IrrDisplay() + test_data = [ + PrefixInfo( + prefix="5.57.0.0/16", # Less specific prefix + rir="RIPE", + bgpOrigins=[12345], + rpkiRoutes=[], + irrRoutes={}, + categoryOverall="success", + messages=[], + ) + ] + with patch("irrexplorer_cli.irrexplorer.IrrExplorer.fetch_prefix_info") as mock_fetch: + mock_fetch.side_effect = Exception("Test error") + await display.display_prefix_info(test_data) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..dcf2f45 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,42 @@ +"""Test suite for main CLI entry points.""" + +from typer.testing import CliRunner + +from irrexplorer_cli.main import app + +runner = CliRunner() + + +def test_query_command() -> None: + """Test prefix query command execution.""" + result = runner.invoke(app, ["query", "5.57.21.0/24"]) + assert not result.exit_code + assert "Directly overlapping prefixes" in result.stdout + assert "5.57.21.0/24" in result.stdout + + +def test_query_command_invalid_prefix() -> None: + """Test query command with invalid prefix.""" + result = runner.invoke(app, ["query", "invalid"]) + assert "Error fetching data" in result.stdout + + +def test_version_command() -> None: + """Test version command output.""" + result = runner.invoke(app, ["--version"]) + assert not result.exit_code + assert "IRR Explorer CLI" in result.stdout + + +def test_help_command() -> None: + """Test help command display.""" + result = runner.invoke(app, ["--help"]) + assert not result.exit_code + assert "Usage" in result.stdout + assert "Options" in result.stdout + + +def test_query_without_prefix() -> None: + """Test query command without prefix argument.""" + result = runner.invoke(app, ["query"]) + assert "Usage" in result.stdout diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..e2cfee9 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,50 @@ +"""Test suite for data models.""" + +from irrexplorer_cli.models import Message, PrefixInfo, RpkiRoute + + +def test_prefix_info_model() -> None: + """Test PrefixInfo model validation and instantiation.""" + data = { + "rpkiRoutes": [ + { + "rpkiMaxLength": 24, + "rpslText": "route: 5.57.21.0/24", + "rpslPk": "5.57.21.0/24AS19905/ML24", + "rpkiStatus": "VALID", + "asn": 19905, + } + ], + "bgpOrigins": [202196], + "irrRoutes": {}, + "categoryOverall": "warning", + "prefix": "5.57.21.0/24", + "rir": "RIPE NCC", + "messages": [{"category": "warning", "text": "Test message"}], + } + + prefix_info = PrefixInfo.model_validate(data) + + # Test basic fields + assert prefix_info.prefix == "5.57.21.0/24" + assert prefix_info.rir == "RIPE NCC" + assert prefix_info.bgpOrigins == [202196] + assert prefix_info.categoryOverall == "warning" + + # Test RPKI routes + assert len(prefix_info.rpkiRoutes) == 1 + rpki_route = prefix_info.rpkiRoutes[0] + assert isinstance(rpki_route, RpkiRoute) + assert rpki_route.rpkiMaxLength == 24 + assert rpki_route.asn == 19905 + assert rpki_route.rpkiStatus == "VALID" + + # Test messages + assert len(prefix_info.messages) == 1 + message = prefix_info.messages[0] + assert isinstance(message, Message) + assert message.category == "warning" + assert message.text == "Test message" + + # Test IRR routes + assert prefix_info.irrRoutes == {}