Merge pull request 'feat(netbox): Add NetBox API client with v4.4 compatibility' (#22) from feat/netbox-data-model into main

Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
2026-01-09 14:26:03 +00:00
5 changed files with 1406 additions and 0 deletions

391
docs/netbox-data-model.md Normal file
View File

@@ -0,0 +1,391 @@
# NetBox Data Model for Fabric Orchestrator
This document describes how fabric intent is represented in NetBox for the fabric-orchestrator to consume.
## Overview
The fabric-orchestrator uses NetBox as the **source of truth** for EVPN-VXLAN fabric configuration. Instead of using ConfigContexts, we leverage NetBox's native data models plus the [NetBox BGP Plugin](https://github.com/netbox-community/netbox-bgp) for comprehensive fabric representation.
### Requirements
- NetBox 4.4.x
- NetBox BGP Plugin v0.17.x
---
## Data Model Summary
### Native NetBox Models
| Feature | NetBox Model | API Endpoint |
|---------|--------------|--------------|
| **Devices** | `dcim.Device` | `/api/dcim/devices/` |
| **Interfaces** | `dcim.Interface` | `/api/dcim/interfaces/` |
| **LAGs/Port-Channels** | `dcim.Interface` (type=LAG) | `/api/dcim/interfaces/` |
| **VLANs** | `ipam.VLAN` | `/api/ipam/vlans/` |
| **VLAN Groups** | `ipam.VLANGroup` | `/api/ipam/vlan-groups/` |
| **VRFs** | `ipam.VRF` | `/api/ipam/vrfs/` |
| **Route Targets** | `ipam.RouteTarget` | `/api/ipam/route-targets/` |
| **IP Addresses** | `ipam.IPAddress` | `/api/ipam/ip-addresses/` |
| **Prefixes** | `ipam.Prefix` | `/api/ipam/prefixes/` |
| **ASNs** | `ipam.ASN` | `/api/ipam/asns/` |
| **L2VPN (EVPN)** | `vpn.L2VPN` | `/api/vpn/l2vpns/` |
| **L2VPN Terminations** | `vpn.L2VPNTermination` | `/api/vpn/l2vpn-terminations/` |
### NetBox BGP Plugin Models
| Feature | Plugin Model | API Endpoint |
|---------|--------------|--------------|
| **BGP Sessions** | `netbox_bgp.BGPSession` | `/api/plugins/bgp/sessions/` |
| **BGP Peer Groups** | `netbox_bgp.PeerGroup` | `/api/plugins/bgp/peer-groups/` |
| **BGP Communities** | `netbox_bgp.Community` | `/api/plugins/bgp/communities/` |
| **Routing Policies** | `netbox_bgp.RoutingPolicy` | `/api/plugins/bgp/routing-policies/` |
| **Prefix Lists** | `netbox_bgp.PrefixList` | `/api/plugins/bgp/prefix-lists/` |
| **AS Path Lists** | `netbox_bgp.ASPathList` | `/api/plugins/bgp/as-path-lists/` |
---
## Custom Fields Reference
NetBox doesn't natively support all fabric-specific configurations. The following custom fields must be created to support MLAG, ASN assignment, and VRF extensions.
### Complete Custom Fields List
#### Device Custom Fields
| Field Name | Label | Type | Required | Description |
|------------|-------|------|----------|-------------|
| `asn` | ASN | Integer | No | BGP Autonomous System Number assigned to this device |
| `mlag_domain_id` | MLAG Domain ID | Text | No | MLAG domain identifier (e.g., "leafs") |
| `mlag_peer_address` | MLAG Peer Address | Text | No | MLAG peer IP address |
| `mlag_local_address` | MLAG Local Address | Text | No | MLAG local IP address |
| `mlag_virtual_mac` | MLAG Virtual MAC | Text | No | Shared virtual-router MAC (e.g., "c001.cafe.babe") |
#### Interface Custom Fields
| Field Name | Label | Type | Required | Description |
|------------|-------|------|----------|-------------|
| `mlag_peer_link` | MLAG Peer Link | Boolean | No | Marks interface as MLAG peer-link |
| `mlag_id` | MLAG ID | Integer | No | MLAG port-channel ID for host-facing LAGs |
#### VRF Custom Fields
| Field Name | Label | Type | Required | Description |
|------------|-------|------|----------|-------------|
| `l3vni` | L3 VNI | Integer | No | Layer 3 VNI for EVPN symmetric IRB |
| `vrf_vlan` | VRF VLAN | Integer | No | VLAN ID used for L3 VNI SVI |
#### IP Address Custom Fields
| Field Name | Label | Type | Required | Description |
|------------|-------|------|----------|-------------|
| `virtual_ip` | Virtual IP | Boolean | No | Marks IP as anycast/virtual IP (shared across MLAG pair) |
### Custom Field Creation via API
```bash
# Example: Create ASN custom field on Device
curl -X POST "https://netbox.example.com/api/extras/custom-fields/" \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"content_types": ["dcim.device"],
"name": "asn",
"label": "ASN",
"type": "integer",
"required": false,
"description": "BGP Autonomous System Number assigned to this device"
}'
# Example: Create MLAG Peer Link custom field on Interface
curl -X POST "https://netbox.example.com/api/extras/custom-fields/" \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"content_types": ["dcim.interface"],
"name": "mlag_peer_link",
"label": "MLAG Peer Link",
"type": "boolean",
"required": false,
"description": "Marks interface as MLAG peer-link"
}'
```
---
## Fabric Component Mapping
### Devices and Roles
| Fabric Role | NetBox Device Role | Description |
|-------------|-------------------|-------------|
| Spine | `spine` | Spine switches (AS 65000) |
| Leaf | `leaf` | Leaf switches (AS 65001-65004) |
**Example Device Setup:**
- `spine1`, `spine2` - Device Role: spine
- `leaf1` through `leaf8` - Device Role: leaf
### Interfaces
| Interface Type | NetBox Interface Type | Usage |
|----------------|----------------------|-------|
| Physical uplinks | `1000base-t` / `10gbase-x-sfpp` | Spine-Leaf connections |
| Loopback0 | `virtual` | BGP Router-ID |
| Loopback1 | `virtual` | VTEP source (shared by MLAG pair) |
| Port-Channel | `lag` | MLAG peer-link, host bonds |
| Vxlan1 | `virtual` | VXLAN tunnel interface |
| SVI | `virtual` | VLAN interfaces (Vlan40, etc.) |
### IP Addressing Scheme
Based on the reference topology:
| Purpose | Prefix | Example |
|---------|--------|---------|
| Spine-Leaf P2P (Spine1) | `10.0.1.0/24` | `10.0.1.0/31`, `10.0.1.2/31`, ... |
| Spine-Leaf P2P (Spine2) | `10.0.2.0/24` | `10.0.2.0/31`, `10.0.2.2/31`, ... |
| MLAG iBGP Peer | `10.0.3.0/24` | `10.0.3.0/31` per MLAG pair |
| MLAG Peer VLAN 4090 | `10.0.199.0/24` | `/31` per MLAG pair |
| Loopback0 (Router-ID) | `10.0.250.0/24` | Spine: `.1-.2`, Leaf: `.11-.18` |
| Loopback1 (VTEP) | `10.0.255.0/24` | `.11-.14` (shared per MLAG pair) |
---
## BGP Configuration
### ASN Assignments
| Device(s) | ASN | Type | Custom Field |
|-----------|-----|------|--------------|
| spine1, spine2 | 65000 | eBGP | `asn: 65000` |
| leaf1, leaf2 | 65001 | iBGP pair | `asn: 65001` |
| leaf3, leaf4 | 65002 | iBGP pair | `asn: 65002` |
| leaf5, leaf6 | 65003 | iBGP pair | `asn: 65003` |
| leaf7, leaf8 | 65004 | iBGP pair | `asn: 65004` |
### BGP Sessions (Plugin)
#### Underlay eBGP Sessions
Each leaf has eBGP sessions to both spines over point-to-point interfaces:
| Local Device | Local IP | Remote Device | Remote IP | Remote ASN |
|--------------|----------|---------------|-----------|------------|
| leaf1 | 10.0.1.1 | spine1 | 10.0.1.0 | 65000 |
| leaf1 | 10.0.2.1 | spine2 | 10.0.2.0 | 65000 |
#### Underlay iBGP Sessions
Each MLAG pair has an iBGP session for redundancy:
| Local Device | Local IP | Remote Device | Remote IP | Notes |
|--------------|----------|---------------|-----------|-------|
| leaf1 | 10.0.3.0 | leaf2 | 10.0.3.1 | next-hop-self |
#### Overlay EVPN Sessions
EVPN sessions use loopback addresses with `ebgp-multihop 3`:
| Local Device | Local IP (Lo0) | Remote Device | Remote IP (Lo0) | AFI/SAFI |
|--------------|----------------|---------------|-----------------|----------|
| leaf1 | 10.0.250.11 | spine1 | 10.0.250.1 | L2VPN EVPN |
| leaf1 | 10.0.250.11 | spine2 | 10.0.250.2 | L2VPN EVPN |
### BGP Peer Groups (Plugin)
| Peer Group | Purpose | Key Settings |
|------------|---------|--------------|
| `underlay` | eBGP to spines | `remote-as 65000`, `maximum-routes 12000` |
| `underlay_ibgp` | iBGP to MLAG peer | `next-hop-self` |
| `evpn` | EVPN overlay | `update-source Loopback0`, `ebgp-multihop 3`, `send-community extended` |
---
## VXLAN / EVPN Configuration
### L2VPN for EVPN VNI Mappings
NetBox's L2VPN model represents EVPN instances:
| L2VPN Name | Type | Identifier (VNI) | Description |
|------------|------|------------------|-------------|
| `VLAN40-L2VNI` | EVPN | 100040 | L2 VXLAN for VLAN 40 |
| `VRF-gold` | EVPN | 100001 | L3 VNI for VRF gold |
### L2VPN Terminations
Link VLANs to L2VPNs on specific devices:
| L2VPN | Device | Interface/VLAN |
|-------|--------|----------------|
| `VLAN40-L2VNI` | leaf1 | VLAN 40 |
| `VLAN40-L2VNI` | leaf2 | VLAN 40 |
| `VLAN40-L2VNI` | leaf5 | VLAN 40 |
| `VLAN40-L2VNI` | leaf6 | VLAN 40 |
### Route Targets
| VRF/VLAN | Import RT | Export RT |
|----------|-----------|-----------|
| VLAN 40 | `40:100040` | `40:100040` |
| VRF gold | `1:100001` | `1:100001` |
---
## MLAG Configuration
MLAG configuration uses Custom Fields since NetBox doesn't have native MLAG support.
### MLAG Pair Configuration
| Device | Domain ID | Local IP | Peer IP | Virtual MAC |
|--------|-----------|----------|---------|-------------|
| leaf1 | leafs | 10.0.199.254 | 10.0.199.255 | c001.cafe.babe |
| leaf2 | leafs | 10.0.199.255 | 10.0.199.254 | c001.cafe.babe |
### MLAG-Related VLANs
| VLAN | Name | Purpose | Trunk Group |
|------|------|---------|-------------|
| 4090 | mlag-peer | MLAG peer communication | mlag-peer |
| 4091 | mlag-ibgp | iBGP between MLAG peers | mlag-peer |
---
## VRF Configuration
### VRF Definition
| VRF Name | RD | Import RT | Export RT | L3 VNI (custom field) |
|----------|-------|-----------|-----------|----------------------|
| gold | `10.0.250.X:1` | `1:100001` | `1:100001` | 100001 |
> Note: RD uses device's Loopback0 IP for uniqueness
### VRF Interface Assignment
SVIs are assigned to VRFs in NetBox. VRF membership is tracked via IP addresses assigned to interfaces:
| Interface | VRF | IP Address | Virtual IP (custom field) |
|-----------|-----|------------|---------------------------|
| Vlan34 (leaf3) | gold | 10.34.34.2/24 | No |
| Vlan34 (leaf4) | gold | 10.34.34.3/24 | No |
| Vlan34 (leaf3) | gold | 10.34.34.1/24 | Yes (anycast) |
| Vlan78 (leaf7) | gold | 10.78.78.2/24 | No |
| Vlan78 (leaf8) | gold | 10.78.78.3/24 | No |
| Vlan78 (leaf7) | gold | 10.78.78.1/24 | Yes (anycast) |
---
## Data Retrieval Strategy
The fabric-orchestrator retrieves intent from NetBox using pynetbox:
```python
import pynetbox
nb = pynetbox.api('http://netbox.example.com', token='your-token')
# Get all leaf devices
leaves = nb.dcim.devices.filter(role='leaf')
# Get device ASN from custom field
device = nb.dcim.devices.get(name='leaf1')
asn = device.custom_fields.get('asn')
# Get BGP sessions for a device
bgp_sessions = nb.plugins.bgp.sessions.filter(device='leaf1')
# Get L2VPNs (EVPN)
l2vpns = nb.vpn.l2vpns.filter(type='evpn')
# Get VLAN-to-VNI mappings via L2VPN terminations
terminations = nb.vpn.l2vpn_terminations.filter(l2vpn='VLAN40-L2VNI')
# Get VRF with L3 VNI
vrf = nb.ipam.vrfs.get(name='gold')
l3vni = vrf.custom_fields.get('l3vni')
```
---
## Relationship Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ NetBox │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌────────────────┐ ┌───────────────┐ │
│ │ Device │─────│ Interface │─────│ IP Address │ │
│ │ (leaf1) │ │ (Loopback0) │ │(10.0.250.11) │ │
│ │ cf:asn │ │ cf:mlag_peer_ │ │ cf:virtual_ip │ │
│ └────────────┘ │ link │ └───────────────┘ │
│ │ └────────────────┘ │
│ │ │
│ │ custom_fields │
│ ▼ │
│ ┌────────────┐ │
│ │ cf:asn │ │
│ │ (65001) │ │
│ └────────────┘ │
│ │
│ ┌──────────────────┐ ┌───────────────────┐ │
│ │ BGP Session │─────│ Peer Group │ │
│ │ (plugin) │ │ (plugin) │ │
│ └──────────────────┘ └───────────────────┘ │
│ │
│ ┌────────────┐ ┌───────────────────────┐ ┌────────────┐ │
│ │ VLAN │─────│ L2VPN Termination │─────│ L2VPN │ │
│ │ (40) │ │ │ │ (EVPN) │ │
│ └────────────┘ └───────────────────────┘ └────────────┘ │
│ │ │
│ │ identifier │
│ ▼ │
│ ┌────────────┐ │
│ │ 100040 │ │
│ │ (VNI) │ │
│ └────────────┘ │
│ │
│ ┌────────────┐ ┌───────────────────┐ │
│ │ VRF │─────│ Route Target │ │
│ │ (gold) │ │ (1:100001) │ │
│ │ cf:l3vni │ └───────────────────┘ │
│ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## API Filter Compatibility Notes
### Working Filters (NetBox 4.4)
| Endpoint | Filter | Example |
|----------|--------|---------|
| `/api/dcim/devices/` | `role`, `name`, `site` | `?role=leaf` |
| `/api/dcim/interfaces/` | `device`, `type`, `name` | `?device=leaf1&type=virtual` |
| `/api/ipam/ip-addresses/` | `device`, `interface`, `vrf` | `?device=leaf1&vrf=gold` |
| `/api/ipam/vlans/` | `vid`, `site`, `group` | `?vid=40` |
| `/api/vpn/l2vpns/` | `type`, `name` | `?type=evpn` |
| `/api/vpn/l2vpn-terminations/` | `l2vpn`, `vlan_id` | `?l2vpn=VLAN40-L2VNI` |
| `/api/plugins/bgp/sessions/` | `device`, `peer_group` | `?device=leaf1` |
### Non-Working Filters (Require Workarounds)
| Endpoint | Invalid Filter | Workaround |
|----------|----------------|------------|
| `/api/ipam/asns/` | `device` | Use custom field `asn` on Device |
| `/api/vpn/l2vpn-terminations/` | `device` | Filter by `vlan_id` then correlate |
| `/api/dcim/interfaces/` | `vrf` | Query IPs with VRF filter, get interface IDs |
---
## Next Steps
1. **Custom Fields Setup** - Create all custom fields listed above in NetBox
2. **Data Population** - Enter fabric topology data into NetBox
3. **NetBox Client** - Update `src/netbox/client.py` to use custom fields
4. **Validation** - Verify data retrieval matches expected fabric config

View File

@@ -7,6 +7,7 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"click>=8.1.0", "click>=8.1.0",
"pygnmi>=0.8.0", "pygnmi>=0.8.0",
"pynetbox>=7.5.0",
"rich>=13.0.0", "rich>=13.0.0",
] ]

36
src/netbox/__init__.py Normal file
View File

@@ -0,0 +1,36 @@
"""
NetBox Client Module for Fabric Orchestrator.
This module provides a NetBox API client for fetching fabric intent data
using pynetbox.
Usage:
from src.netbox import FabricNetBoxClient
client = FabricNetBoxClient(
url="http://netbox.local",
token="your-token"
)
# Get all leaf devices
leaves = client.get_leaf_devices()
# Get complete intent for a device
intent = client.get_device_intent("leaf1")
"""
from .client import (
FabricNetBoxClient,
NetBoxAPIError,
NetBoxConnectionError,
NetBoxError,
NetBoxNotFoundError,
)
__all__ = [
"FabricNetBoxClient",
"NetBoxError",
"NetBoxConnectionError",
"NetBoxNotFoundError",
"NetBoxAPIError",
]

855
src/netbox/client.py Normal file
View File

@@ -0,0 +1,855 @@
"""
NetBox API Client for Fabric Orchestrator.
This module provides a client for fetching fabric intent data from NetBox
using pynetbox. It retrieves device, interface, BGP, EVPN, and VRF configuration
to determine the desired network state.
Usage:
from src.netbox import FabricNetBoxClient
client = FabricNetBoxClient(
url="http://netbox.local",
token="your-token"
)
# Get all leaf devices
leaves = client.get_leaf_devices()
# Get complete intent for a device
intent = client.get_device_intent("leaf1")
"""
from __future__ import annotations
import logging
import os
from typing import Any
import pynetbox
from pynetbox.core.query import RequestError
# =============================================================================
# Exceptions
# =============================================================================
class NetBoxError(Exception):
"""Base exception for NetBox operations."""
pass
class NetBoxConnectionError(NetBoxError):
"""Raised when connection to NetBox fails."""
pass
class NetBoxNotFoundError(NetBoxError):
"""Raised when requested resource is not found."""
pass
class NetBoxAPIError(NetBoxError):
"""Raised when NetBox API returns an error."""
pass
# =============================================================================
# Logger
# =============================================================================
logger = logging.getLogger(__name__)
# =============================================================================
# NetBox Client
# =============================================================================
class FabricNetBoxClient:
"""
NetBox API client for fabric intent retrieval.
Provides methods to fetch device, interface, BGP, EVPN, and VRF
configuration from NetBox to determine desired network state.
Supports configuration via environment variables:
- NETBOX_URL: NetBox server URL
- NETBOX_TOKEN: API token for authentication
Example:
client = FabricNetBoxClient(
url="http://netbox.local",
token="your-token"
)
leaves = client.get_leaf_devices()
"""
def __init__(
self,
url: str | None = None,
token: str | None = None,
threading: bool = False,
):
"""
Initialize NetBox client.
Args:
url: NetBox server URL (or NETBOX_URL env var)
token: API token (or NETBOX_TOKEN env var)
threading: Enable threading for requests (default: False)
Raises:
NetBoxConnectionError: If URL or token is not provided
"""
self.url = url or os.environ.get("NETBOX_URL")
self.token = token or os.environ.get("NETBOX_TOKEN")
if not self.url:
raise NetBoxConnectionError(
"NetBox URL not provided. Set NETBOX_URL environment variable or pass url parameter."
)
if not self.token:
raise NetBoxConnectionError(
"NetBox token not provided. Set NETBOX_TOKEN environment variable or pass token parameter."
)
logger.debug(f"Connecting to NetBox at {self.url}")
try:
self._api = pynetbox.api(self.url, token=self.token, threading=threading)
# Test connection by fetching API status
self._api.status()
logger.info(f"Successfully connected to NetBox at {self.url}")
except Exception as e:
raise NetBoxConnectionError(f"Failed to connect to NetBox at {self.url}: {e}") from e
# =========================================================================
# Device Methods
# =========================================================================
def get_device(self, name: str) -> dict[str, Any]:
"""
Get a device by name.
Args:
name: Device name
Returns:
Device data as dictionary
Raises:
NetBoxNotFoundError: If device not found
"""
logger.debug(f"Fetching device: {name}")
try:
device = self._api.dcim.devices.get(name=name)
if not device:
raise NetBoxNotFoundError(f"Device not found: {name}")
return dict(device)
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch device {name}: {e}") from e
def get_devices_by_role(self, role: str) -> list[dict[str, Any]]:
"""
Get all devices with a specific role.
Args:
role: Device role slug (e.g., 'leaf', 'spine')
Returns:
List of device dictionaries
"""
logger.debug(f"Fetching devices with role: {role}")
try:
devices = self._api.dcim.devices.filter(role=role)
return [dict(d) for d in devices]
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch devices with role {role}: {e}") from e
def get_leaf_devices(self) -> list[dict[str, Any]]:
"""
Get all leaf devices.
Returns:
List of leaf device dictionaries
"""
return self.get_devices_by_role("leaf")
def get_spine_devices(self) -> list[dict[str, Any]]:
"""
Get all spine devices.
Returns:
List of spine device dictionaries
"""
return self.get_devices_by_role("spine")
def get_device_asn(self, device_name: str) -> int | None:
"""
Get ASN assigned to a device from custom field.
Note: NetBox 4.4 does not support filtering ASNs by device.
ASN is stored as a custom field on the Device object.
Args:
device_name: Device name
Returns:
ASN number or None if not assigned
"""
logger.debug(f"Fetching ASN for device: {device_name}")
device = self.get_device(device_name)
return device.get("custom_fields", {}).get("asn")
# =========================================================================
# Interface Methods
# =========================================================================
def get_device_interfaces(
self, device_name: str, interface_type: str | None = None
) -> list[dict[str, Any]]:
"""
Get interfaces for a device.
Args:
device_name: Device name
interface_type: Optional interface type filter (e.g., 'virtual', 'lag', '1000base-t')
Returns:
List of interface dictionaries
"""
logger.debug(f"Fetching interfaces for device: {device_name}, type: {interface_type}")
try:
filters = {"device": device_name}
if interface_type:
filters["type"] = interface_type
interfaces = self._api.dcim.interfaces.filter(**filters)
return [dict(i) for i in interfaces]
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch interfaces for {device_name}: {e}") from e
def get_device_loopbacks(self, device_name: str) -> list[dict[str, Any]]:
"""
Get loopback interfaces for a device.
Args:
device_name: Device name
Returns:
List of loopback interface dictionaries
"""
logger.debug(f"Fetching loopbacks for device: {device_name}")
try:
interfaces = self._api.dcim.interfaces.filter(device=device_name, type="virtual")
loopbacks = [dict(i) for i in interfaces if i.name.lower().startswith("loopback")]
return loopbacks
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch loopbacks for {device_name}: {e}") from e
def get_device_lags(self, device_name: str) -> list[dict[str, Any]]:
"""
Get LAG/Port-Channel interfaces for a device.
Args:
device_name: Device name
Returns:
List of LAG interface dictionaries
"""
logger.debug(f"Fetching LAGs for device: {device_name}")
return self.get_device_interfaces(device_name, interface_type="lag")
def get_device_svis(self, device_name: str) -> list[dict[str, Any]]:
"""
Get SVI (VLAN) interfaces for a device.
Args:
device_name: Device name
Returns:
List of SVI interface dictionaries
"""
logger.debug(f"Fetching SVIs for device: {device_name}")
try:
interfaces = self._api.dcim.interfaces.filter(device=device_name, type="virtual")
svis = [dict(i) for i in interfaces if i.name.lower().startswith("vlan")]
return svis
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch SVIs for {device_name}: {e}") from e
# =========================================================================
# IP Address Methods
# =========================================================================
def get_interface_ips(
self, device_name: str, interface_name: str
) -> list[dict[str, Any]]:
"""
Get IP addresses assigned to an interface.
Args:
device_name: Device name
interface_name: Interface name
Returns:
List of IP address dictionaries
"""
logger.debug(f"Fetching IPs for {device_name}:{interface_name}")
try:
ips = self._api.ipam.ip_addresses.filter(
device=device_name, interface=interface_name
)
return [dict(ip) for ip in ips]
except RequestError as e:
raise NetBoxAPIError(
f"Failed to fetch IPs for {device_name}:{interface_name}: {e}"
) from e
def get_device_ips(self, device_name: str) -> list[dict[str, Any]]:
"""
Get all IP addresses for a device.
Args:
device_name: Device name
Returns:
List of IP address dictionaries with interface info
"""
logger.debug(f"Fetching all IPs for device: {device_name}")
try:
ips = self._api.ipam.ip_addresses.filter(device=device_name)
return [dict(ip) for ip in ips]
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch IPs for {device_name}: {e}") from e
# =========================================================================
# BGP Methods (Plugin)
# =========================================================================
def get_bgp_sessions(self, device_name: str | None = None) -> list[dict[str, Any]]:
"""
Get BGP sessions from NetBox BGP plugin.
Args:
device_name: Optional device name filter
Returns:
List of BGP session dictionaries
"""
logger.debug(f"Fetching BGP sessions for device: {device_name or 'all'}")
try:
if device_name:
sessions = self._api.plugins.bgp.sessions.filter(device=device_name)
else:
sessions = self._api.plugins.bgp.sessions.all()
return [dict(s) for s in sessions]
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch BGP sessions: {e}") from e
except AttributeError:
logger.warning("NetBox BGP plugin not available")
return []
def get_bgp_peer_groups(self, device_name: str | None = None) -> list[dict[str, Any]]:
"""
Get BGP peer groups from NetBox BGP plugin.
Args:
device_name: Optional device name filter
Returns:
List of BGP peer group dictionaries
"""
logger.debug(f"Fetching BGP peer groups for device: {device_name or 'all'}")
try:
if device_name:
peer_groups = self._api.plugins.bgp.peer_groups.filter(device=device_name)
else:
peer_groups = self._api.plugins.bgp.peer_groups.all()
return [dict(pg) for pg in peer_groups]
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch BGP peer groups: {e}") from e
except AttributeError:
logger.warning("NetBox BGP plugin not available")
return []
# =========================================================================
# VLAN Methods
# =========================================================================
def get_vlans(self, site: str | None = None) -> list[dict[str, Any]]:
"""
Get VLANs.
Args:
site: Optional site filter
Returns:
List of VLAN dictionaries
"""
logger.debug(f"Fetching VLANs for site: {site or 'all'}")
try:
if site:
vlans = self._api.ipam.vlans.filter(site=site)
else:
vlans = self._api.ipam.vlans.all()
return [dict(v) for v in vlans]
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch VLANs: {e}") from e
def get_vlan(self, vlan_id: int, site: str | None = None) -> dict[str, Any] | None:
"""
Get a specific VLAN by ID.
Args:
vlan_id: VLAN ID
site: Optional site filter
Returns:
VLAN dictionary or None if not found
"""
logger.debug(f"Fetching VLAN {vlan_id}")
try:
filters = {"vid": vlan_id}
if site:
filters["site"] = site
vlans = self._api.ipam.vlans.filter(**filters)
vlan_list = list(vlans)
if vlan_list:
return dict(vlan_list[0])
return None
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch VLAN {vlan_id}: {e}") from e
# =========================================================================
# L2VPN/EVPN Methods
# =========================================================================
def get_l2vpns(self, l2vpn_type: str = "evpn") -> list[dict[str, Any]]:
"""
Get L2VPNs (EVPN instances).
Args:
l2vpn_type: L2VPN type filter (default: 'evpn')
Returns:
List of L2VPN dictionaries with VNI identifiers
"""
logger.debug(f"Fetching L2VPNs of type: {l2vpn_type}")
try:
l2vpns = self._api.vpn.l2vpns.filter(type=l2vpn_type)
return [dict(l2vpn) for l2vpn in l2vpns]
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch L2VPNs: {e}") from e
def get_l2vpn_terminations(
self, device_name: str | None = None, l2vpn_name: str | None = None
) -> list[dict[str, Any]]:
"""
Get L2VPN terminations (VLAN-to-device mappings).
Note: NetBox 4.4 does not support filtering L2VPN terminations by device.
L2VPN terminations link to VLANs, not devices directly.
The device_name parameter is accepted for API compatibility but ignored.
Args:
device_name: Ignored - not supported by NetBox API
l2vpn_name: Optional L2VPN name filter
Returns:
List of L2VPN termination dictionaries
"""
if device_name:
logger.warning(
f"device_name filter '{device_name}' ignored - "
"L2VPN terminations cannot be filtered by device in NetBox 4.4"
)
logger.debug(f"Fetching L2VPN terminations for l2vpn: {l2vpn_name or 'all'}")
try:
filters = {}
if l2vpn_name:
filters["l2vpn"] = l2vpn_name
if filters:
terminations = self._api.vpn.l2vpn_terminations.filter(**filters)
else:
terminations = self._api.vpn.l2vpn_terminations.all()
return [dict(t) for t in terminations]
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch L2VPN terminations: {e}") from e
def get_vni_for_vlan(self, vlan_id: int) -> int | None:
"""
Get VNI for a VLAN from L2VPN configuration.
Args:
vlan_id: VLAN ID
Returns:
VNI number or None if not mapped
"""
logger.debug(f"Fetching VNI for VLAN {vlan_id}")
try:
# Get L2VPN terminations for this VLAN
terminations = self._api.vpn.l2vpn_terminations.filter(vlan_id=vlan_id)
term_list = list(terminations)
if term_list:
l2vpn = term_list[0].l2vpn
if l2vpn and hasattr(l2vpn, "identifier"):
return l2vpn.identifier
return None
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch VNI for VLAN {vlan_id}: {e}") from e
# =========================================================================
# VRF Methods
# =========================================================================
def get_vrfs(self) -> list[dict[str, Any]]:
"""
Get all VRFs with route targets.
Returns:
List of VRF dictionaries including import/export route targets
"""
logger.debug("Fetching all VRFs")
try:
vrfs = self._api.ipam.vrfs.all()
return [dict(v) for v in vrfs]
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch VRFs: {e}") from e
def get_vrf(self, name: str) -> dict[str, Any] | None:
"""
Get a VRF by name.
Args:
name: VRF name
Returns:
VRF dictionary or None if not found
"""
logger.debug(f"Fetching VRF: {name}")
try:
vrf = self._api.ipam.vrfs.get(name=name)
if vrf:
return dict(vrf)
return None
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch VRF {name}: {e}") from e
def get_device_vrf_interfaces(
self, device_name: str, vrf_name: str
) -> list[dict[str, Any]]:
"""
Get interfaces assigned to a VRF on a device.
Note: NetBox 4.4 does not support filtering interfaces by VRF directly.
VRF is assigned to IP addresses, not interfaces. This method queries
IP addresses with VRF filter and extracts the associated interfaces.
Args:
device_name: Device name
vrf_name: VRF name
Returns:
List of interface dictionaries assigned to the VRF
"""
logger.debug(f"Fetching VRF {vrf_name} interfaces for device: {device_name}")
try:
# Get IPs in this VRF for this device
ips = self._api.ipam.ip_addresses.filter(device=device_name, vrf=vrf_name)
# Extract unique interface IDs
interface_ids = set()
for ip in ips:
if ip.assigned_object_type == "dcim.interface" and ip.assigned_object_id:
interface_ids.add(ip.assigned_object_id)
# Fetch interfaces
if not interface_ids:
return []
interfaces = []
for iid in interface_ids:
iface = self._api.dcim.interfaces.get(id=iid)
if iface:
interfaces.append(dict(iface))
return interfaces
except RequestError as e:
raise NetBoxAPIError(
f"Failed to fetch VRF interfaces for {device_name}/{vrf_name}: {e}"
) from e
def get_route_targets(self) -> list[dict[str, Any]]:
"""
Get all route targets.
Returns:
List of route target dictionaries
"""
logger.debug("Fetching all route targets")
try:
rts = self._api.ipam.route_targets.all()
return [dict(rt) for rt in rts]
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch route targets: {e}") from e
# =========================================================================
# MLAG Methods (Custom Fields)
# =========================================================================
def get_device_mlag_config(self, device_name: str) -> dict[str, Any]:
"""
Get MLAG configuration from device custom fields.
Custom fields expected:
- mlag_domain_id: MLAG domain identifier
- mlag_peer_address: MLAG peer IP address
- mlag_local_address: MLAG local IP address
- mlag_virtual_mac: Shared virtual-router MAC
Args:
device_name: Device name
Returns:
MLAG configuration dictionary
"""
logger.debug(f"Fetching MLAG config for device: {device_name}")
device = self.get_device(device_name)
custom_fields = device.get("custom_fields", {}) or {}
return {
"domain_id": custom_fields.get("mlag_domain_id"),
"peer_address": custom_fields.get("mlag_peer_address"),
"local_address": custom_fields.get("mlag_local_address"),
"virtual_mac": custom_fields.get("mlag_virtual_mac"),
}
def get_mlag_peer_link(self, device_name: str) -> dict[str, Any] | None:
"""
Get MLAG peer-link interface for a device.
Looks for interface with mlag_peer_link custom field set to True.
Args:
device_name: Device name
Returns:
Peer-link interface dictionary or None
"""
logger.debug(f"Fetching MLAG peer-link for device: {device_name}")
try:
interfaces = self._api.dcim.interfaces.filter(device=device_name)
for iface in interfaces:
cf = iface.custom_fields or {}
if cf.get("mlag_peer_link"):
return dict(iface)
return None
except RequestError as e:
raise NetBoxAPIError(
f"Failed to fetch MLAG peer-link for {device_name}: {e}"
) from e
def get_interface_mlag_id(
self, device_name: str, interface_name: str
) -> int | None:
"""
Get MLAG ID from interface custom field.
Args:
device_name: Device name
interface_name: Interface name
Returns:
MLAG ID or None if not assigned
"""
logger.debug(f"Fetching MLAG ID for {device_name}:{interface_name}")
try:
iface = self._api.dcim.interfaces.get(device=device_name, name=interface_name)
if iface:
cf = iface.custom_fields or {}
return cf.get("mlag_id")
return None
except RequestError as e:
raise NetBoxAPIError(
f"Failed to fetch MLAG ID for {device_name}:{interface_name}: {e}"
) from e
# =========================================================================
# VRF Custom Field Methods
# =========================================================================
def get_vrf_l3vni(self, vrf_name: str) -> int | None:
"""
Get L3 VNI from VRF custom field.
Args:
vrf_name: VRF name
Returns:
L3 VNI number or None if not assigned
"""
logger.debug(f"Fetching L3 VNI for VRF: {vrf_name}")
vrf = self.get_vrf(vrf_name)
if vrf:
return vrf.get("custom_fields", {}).get("l3vni")
return None
# =========================================================================
# Virtual IP Methods (Custom Fields)
# =========================================================================
def get_virtual_ips(self, device_name: str | None = None) -> list[dict[str, Any]]:
"""
Get IP addresses marked as virtual/anycast IPs.
Retrieves IPs where the custom field `virtual_ip` is True.
These are typically anycast IPs shared across an MLAG pair.
Args:
device_name: Optional device name filter
Returns:
List of virtual IP address dictionaries
"""
logger.debug(f"Fetching virtual IPs for device: {device_name or 'all'}")
try:
if device_name:
ips = self._api.ipam.ip_addresses.filter(device=device_name)
else:
ips = self._api.ipam.ip_addresses.all()
virtual_ips = []
for ip in ips:
cf = ip.custom_fields or {}
if cf.get("virtual_ip"):
virtual_ips.append(dict(ip))
return virtual_ips
except RequestError as e:
raise NetBoxAPIError(f"Failed to fetch virtual IPs: {e}") from e
# =========================================================================
# Aggregated Intent Methods
# =========================================================================
def get_device_intent(self, device_name: str) -> dict[str, Any]:
"""
Get complete fabric intent for a single device.
Aggregates all relevant configuration data for a device including:
- Device info and custom fields
- Interfaces (physical, loopback, LAG, SVI)
- IP addresses
- BGP sessions and peer groups
- VLAN assignments
- L2VPN/EVPN terminations
- VRF assignments
- MLAG configuration
Args:
device_name: Device name
Returns:
Complete device intent dictionary
"""
logger.info(f"Building complete intent for device: {device_name}")
# Get device info
device = self.get_device(device_name)
# Get interfaces
all_interfaces = self.get_device_interfaces(device_name)
loopbacks = self.get_device_loopbacks(device_name)
lags = self.get_device_lags(device_name)
svis = self.get_device_svis(device_name)
# Get IP addresses
ip_addresses = self.get_device_ips(device_name)
# Get ASN
asn = self.get_device_asn(device_name)
# Get BGP configuration
bgp_sessions = self.get_bgp_sessions(device_name)
bgp_peer_groups = self.get_bgp_peer_groups(device_name)
# Get L2VPN terminations for this device
l2vpn_terminations = self.get_l2vpn_terminations(device_name=device_name)
# Get MLAG configuration
mlag_config = self.get_device_mlag_config(device_name)
mlag_peer_link = self.get_mlag_peer_link(device_name)
# Build intent dictionary
intent = {
"device": {
"name": device.get("name"),
"role": device.get("role", {}).get("slug") if device.get("role") else None,
"platform": (
device.get("platform", {}).get("slug") if device.get("platform") else None
),
"site": device.get("site", {}).get("slug") if device.get("site") else None,
"custom_fields": device.get("custom_fields", {}),
},
"asn": asn,
"interfaces": {
"all": all_interfaces,
"loopbacks": loopbacks,
"lags": lags,
"svis": svis,
},
"ip_addresses": ip_addresses,
"bgp": {
"sessions": bgp_sessions,
"peer_groups": bgp_peer_groups,
},
"l2vpn_terminations": l2vpn_terminations,
"mlag": {
"config": mlag_config,
"peer_link": mlag_peer_link,
},
}
logger.debug(f"Built intent for {device_name} with {len(all_interfaces)} interfaces")
return intent
def get_fabric_intent(self) -> dict[str, Any]:
"""
Get complete fabric intent for all devices.
Returns:
Dictionary with fabric-wide intent including:
- All devices with their intent
- Fabric-wide VLANs
- Fabric-wide L2VPNs
- Fabric-wide VRFs
"""
logger.info("Building complete fabric intent")
# Get all devices
spine_devices = self.get_spine_devices()
leaf_devices = self.get_leaf_devices()
# Get fabric-wide configuration
vlans = self.get_vlans()
l2vpns = self.get_l2vpns()
vrfs = self.get_vrfs()
# Build device intents
device_intents = {}
for device in spine_devices + leaf_devices:
name = device.get("name")
if name:
device_intents[name] = self.get_device_intent(name)
return {
"devices": device_intents,
"vlans": vlans,
"l2vpns": l2vpns,
"vrfs": vrfs,
}

123
uv.lock generated
View File

@@ -2,6 +2,15 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "2.0.0" version = "2.0.0"
@@ -59,6 +68,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
] ]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.1" version = "8.3.1"
@@ -152,6 +218,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
{ name = "pygnmi" }, { name = "pygnmi" },
{ name = "pynetbox" },
{ name = "rich" }, { name = "rich" },
] ]
@@ -164,6 +231,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "click", specifier = ">=8.1.0" }, { name = "click", specifier = ">=8.1.0" },
{ name = "pygnmi", specifier = ">=0.8.0" }, { name = "pygnmi", specifier = ">=0.8.0" },
{ name = "pynetbox", specifier = ">=7.5.0" },
{ name = "rich", specifier = ">=13.0.0" }, { name = "rich", specifier = ">=13.0.0" },
] ]
@@ -211,6 +279,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" },
] ]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "4.0.0" version = "4.0.0"
@@ -232,6 +309,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
] ]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]] [[package]]
name = "protobuf" name = "protobuf"
version = "6.33.2" version = "6.33.2"
@@ -280,6 +366,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/d8/1958e627d66f5f0c8dbdb1962850bc9e256fc65129ceb0212c0cb3e0d121/pygnmi-0.8.15-py3-none-any.whl", hash = "sha256:e72859070c7dc3618b578e48c76521db6d627b64a92b496ab886f3ff93a1a396", size = 35561, upload-time = "2025-03-10T22:15:32.981Z" }, { url = "https://files.pythonhosted.org/packages/cd/d8/1958e627d66f5f0c8dbdb1962850bc9e256fc65129ceb0212c0cb3e0d121/pygnmi-0.8.15-py3-none-any.whl", hash = "sha256:e72859070c7dc3618b578e48c76521db6d627b64a92b496ab886f3ff93a1a396", size = 35561, upload-time = "2025-03-10T22:15:32.981Z" },
] ]
[[package]]
name = "pynetbox"
version = "7.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/0b/695021a23c373991d07c1e4cb510287a521318cfc4b29f68ebbecb19fcd2/pynetbox-7.5.0.tar.gz", hash = "sha256:780064c800fb8c079c9828df472203146442ed3dd0b522a28a501204eb00c066", size = 73850, upload-time = "2025-05-20T16:03:03.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/b7/a24bc58f0e27f0cd847bf14ffbe9722604f5abe3ec9c12dd8f89cb965be8/pynetbox-7.5.0-py3-none-any.whl", hash = "sha256:ab755a0020c0abb09b4d24c8f8ba89df26f04fa56c35de73302e29a39352f031", size = 35808, upload-time = "2025-05-20T16:03:02.445Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.2.0" version = "14.2.0"
@@ -327,3 +441,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
] ]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]