Compare commits

...

41 Commits

Author SHA1 Message Date
6482011d15 Delete docs/netbox-data-model.md 2026-02-27 12:21:21 +00:00
9e025f0c07 Merge pull request 'feat(infrahub): add Infrahub client for fabric intent (#42)' (#54) from feature/42-infrahub-client into main
Reviewed-on: #54
2026-02-27 12:08:36 +00:00
Damien
8c7e0973e2 Update test_infrahub_client.py parameter name from asn_val to asn
Update uv.lock to include new dependencies
2026-02-27 09:21:41 +01:00
Damien
4bef9303ba Update README.md tables to be more compact
Add .ruff_cache to .gitignore
2026-02-26 15:15:53 +01:00
Damien
8ee15c3228 docs(infrahub): add README with usage examples and API reference
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 15:11:58 +01:00
b31632109a docs: update README to remove Infrahub schema/data references (#42)
Remove all references to local Infrahub schemas, data files, and
.infrahub.yml config. The orchestrator now treats Infrahub as an
external Source of Truth queried via infrahub-sdk. Schema and data
are managed in the arista-evpn-vxlan-clab repository.
2026-02-26 14:05:07 +00:00
Damien
f663cc623c feat(infrahub): add Infrahub client for fabric intent (#42)
- Replace pynetbox with infrahub-sdk>=0.16.0 + pydantic>=2.0 in dependencies
- Add pytest and pytest-asyncio to dev dependencies
- Implement FabricInfrahubClient async client (src/infrahub/client.py)
  - get_device, get_device_vlans, get_device_bgp_config
  - get_device_bgp_peer_groups, get_device_bgp_sessions
  - get_device_vrfs, get_device_vtep, get_device_evpn_instances
  - get_mlag_domain, get_mlag_peer_config
  - TTL-based caching (60s) to avoid redundant SDK queries
  - Async context manager support
- Add Pydantic v2 frozen models for all intent types (src/infrahub/models.py)
- Add custom exception hierarchy (src/infrahub/exceptions.py)
- Add unit tests with fully mocked SDK (tests/test_infrahub_client.py)
  - Tests for correct model return, NotFoundError, branch selection, caching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:03:09 +01:00
21a44db736 Merge pull request 'feature/52-object-files' (#53) from feature/52-object-files into main
Reviewed-on: #53
2026-02-20 15:14:03 +00:00
Damien
6b00b30f26 feat(infrahub): remove infrahub schema 2026-02-20 16:12:28 +01:00
Damien
110c223b66 chore(schema): update schema 2026-02-20 15:49:09 +01:00
8faf5139cb fix(mlag): correct local_interface_ip for secondary leaves in MLAG pairs 2026-02-20 14:21:59 +00:00
Damien
c902d7df58 chore(schema): update schema 2026-02-20 15:13:46 +01:00
Damien
9e1fe9be7f chore(fix schema): remove unused border leaf 2026-02-20 15:02:49 +01:00
Damien
bccac190ff Update object 2026-02-16 17:49:44 +01:00
Damien
cb06068347 feat(schema,objects): add local_identifier to BGP types for unique HFID — refs #52
Infrahub does not support relation traversal in HFID computation.
Add a local_identifier attribute to PeerGroup, Session, and
AddressFamily that combines device name with the type-specific key.

Schema changes (bgp.yml):
- InfraBGPPeerGroup: HFID=local_identifier__value (e.g. spine1__evpn)
- InfraBGPSession: HFID=local_identifier__value (e.g. spine1__10.0.250.11)
- InfraBGPAddressFamily: HFID=local_identifier__value (e.g. spine1__ipv4_unicast)

Object file changes:
- 09-bgp.yml: added local_identifier to 26 PeerGroup entries
- 10-bgp-sessions.yml: added local_identifier to 72 Session + 20 AF entries,
  updated 56 peer_group + 26 active_peer_groups references
2026-02-15 21:31:03 +01:00
Damien
4b1ca7b2a6 fix(schema,objects): simplify BGP HFIDs to single field — refs #52
Infrahub does not support relation traversal in HFID computation.
bgp_config__router_id__value returned only the name, not the full path.

Schema changes:
- InfraBGPPeerGroup HFID: [name__value] (was [bgp_config__router_id__value, name__value])
- InfraBGPSession HFID: [peer_address__value] (was [bgp_config__router_id__value, peer_address__value])
- uniqueness_constraints unchanged (still scoped to bgp_config)

Object file changes (10-bgp-sessions.yml):
- peer_group: ["10.0.250.x", "name"] → peer_group: "name" (56 refs)
- active_peer_groups: - ["10.0.250.x", "name"] → - "name" (26 refs)
2026-02-15 21:23:58 +01:00
Damien
9f89058f58 revert(schema,objects): restore router_id in BGP HFIDs — refs #52
Restore original human_friendly_id fields:
- InfraBGPPeerGroup: bgp_config__router_id__value + name__value
- InfraBGPSession: bgp_config__router_id__value + peer_address__value

Restore router_id values in peer_group and active_peer_groups
references in 10-bgp-sessions.yml (82 replacements).
2026-02-15 20:46:26 +01:00
Damien
241655e348 fix(schema,objects): use device name instead of router_id in BGP HFIDs — refs #52
The SDK cannot resolve deep relation traversals like
bgp_config__router_id__value when loading object files.

Schema changes in bgp.yml:
- InfraBGPPeerGroup HFID: bgp_config__router_id__value → bgp_config__device__name__value
- InfraBGPSession HFID: bgp_config__router_id__value → bgp_config__device__name__value

Object file changes in 10-bgp-sessions.yml:
- peer_group refs: router_id → device_name (e.g. ["10.0.250.1", "evpn"] → ["spine1", "evpn"])
- active_peer_groups refs: same mapping (82 replacements total)
2026-02-15 20:44:39 +01:00
Damien
2a2e0dfe73 fix(objects): split BGP sessions from peer groups for load ordering — refs #52
InfraBGPSession references InfraBGPPeerGroup via peer_group HFID,
so peer groups must be committed before sessions are created.

Split 09-bgp.yml into:
- 09-bgp.yml: InfraBGPRouterConfig + InfraBGPPeerGroup
- 10-bgp-sessions.yml: InfraBGPSession + InfraBGPAddressFamily

Renamed: 10-vrfs→11, 11-mlag→12. Now 12 object files total.
2026-02-15 20:25:36 +01:00
Damien
ccd882bcfd fix(schema): change InfraIPAddress.address from IPNetwork to IPHost — refs #52
IPNetwork with strict=True rejects host addresses like 10.0.1.1/31
(host bits set). Interface IP assignments are host addresses with a
prefix length, which is exactly what IPHost accepts.
2026-02-15 20:23:07 +01:00
Damien
4409dc5e8d fix(objects): remove interface refs from IPs — InfraInterface has no HFID — refs #52
InfraIPAddress.interface peers with the generic InfraInterface which
has no human_friendly_id defined, so infrahubctl cannot resolve the
HFID lookup. Remove interface references from all IP address objects.

IP-to-interface associations can be established later via the reverse
relationship (ip_addresses on interface objects) or by adding an HFID
to the InfraInterface generic in a future schema update.
2026-02-15 20:22:01 +01:00
Damien
97720d3d6d fix(objects): split Vlan SVIs and IPs for load ordering — refs #52
InfraInterfaceVlan references InfraVLAN objects (06-vlans-vxlan.yml),
so Vlan SVIs must load after VLANs. Similarly, Vlan IP addresses
reference Vlan SVIs.

Changes:
- Extract InfraInterfaceVlan from 04 into 07-interface-vlans.yml
- Extract Vlan SVI IPs from 05 into 08-ipam-vlans.yml
- Rename 07-bgp→09, 08-vrfs→10, 09-mlag→11
- Update .infrahub.yml with new 11-file load order

Load order: foundation → fabric → devices → interfaces (Lo/Lag/Eth)
→ ipam (Lo/Eth) → vlans-vxlan → interface-vlans → ipam-vlans
→ bgp → vrfs → mlag
2026-02-15 16:04:42 +01:00
Damien
50cdb911a4 fix(objects): reorder interfaces — LAG before Ethernet — refs #52
InfraInterfaceEthernet references InfraInterfaceLag (lag field),
so LAG interfaces must be created first within the same file.

New order: Loopback → Lag → Ethernet → Vlan SVI
2026-02-15 16:01:45 +01:00
Damien
30885b5a19 fix(objects): wrap all relation references as HFID lists — refs #52
All relation values now use the HFID list-of-strings format required
by infrahubctl:
- Cardinality one: device: ["spine1"], platform: ["arista_eos"]
- Cardinality many: devices: - ["leaf1"], networks: - ["10.0.250.1/32"]
- Composite HFIDs unchanged: peer_group: ["10.0.250.1", "evpn"]

Files modified: 02-fabric, 03-devices, 04-interfaces, 06-vlans-vxlan,
07-bgp, 08-vrfs, 09-mlag (05-ipam already correct).
2026-02-15 15:37:20 +01:00
Damien
77b39027a3 fix(objects): list object files explicitly for sequential loading — refs #52 2026-02-15 15:20:45 +01:00
Damien
5f071e5716 fix(objects): split foundation/fabric and renumber files for load order — refs #52 2026-02-15 15:18:40 +01:00
Damien
3838169ff7 fix(objects): remove AS 64999, rename fabric to evpn-lab, simplify .infrahub.yml — refs #52 2026-02-15 15:03:47 +01:00
Damien
14ebedadcb feat(objects): declare object files in .infrahub.yml — refs #52 2026-02-15 12:03:10 +01:00
Damien
825c5e4451 feat(objects): add 4 MLAG domains, 8 peer configs — refs #52 2026-02-15 12:03:07 +01:00
Damien
4a13a6a33d feat(objects): add VRF gold, route targets, device assignments — refs #52 2026-02-15 12:03:04 +01:00
Damien
17ff22f497 feat(objects): add BGP config, peer groups, 74 sessions, address families — refs #52 2026-02-15 12:03:00 +01:00
Damien
89f77b764e feat(objects): add VLANs, VNIs, VTEPs, mappings, EVPN instances — refs #52 2026-02-15 12:02:57 +01:00
Damien
537df72f85 feat(objects): add IP addresses (72 assignments: loopbacks, P2P, MLAG, VRF) — refs #52 2026-02-15 12:02:53 +01:00
Damien
dc382bf6de feat(objects): add interfaces (loopback, ethernet, LAG, VLAN SVIs) — refs #52 2026-02-15 12:02:50 +01:00
Damien
d3275d1ecc feat(objects): add 10 devices (2 spines, 8 leafs) — refs #52 2026-02-15 12:02:46 +01:00
Damien
009b6ebc75 feat(objects): add foundation objects (fabric, platform, site, ASNs) — refs #52 2026-02-15 12:02:40 +01:00
5a335b37e6 docs: update CLAUDE.md for issue #52 object files task — refs #52 2026-02-13 19:47:57 +00:00
7fd83b8c3f Merge pull request '[Phase 2] Define Infrahub Schema for EVPN-VXLAN Fabric' (#51) from feature/41-infrahub-schema into main
Reviewed-on: #51
2026-02-13 19:36:26 +00:00
f484af442a docs(schema): update README with complete relationships and topology mapping — refs #41 2026-02-13 14:20:02 +00:00
Damien
e32da46bd7 fix(schema): add optional VRF relation on BGPSession — refs #50 2026-02-13 15:03:55 +01:00
Damien
4e2f5b5b0c Update CLAUDE.md 2026-02-13 15:02:55 +01:00
21 changed files with 2385 additions and 2401 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
__pycache__
.envrc
.ruff_cache

View File

@@ -1,7 +0,0 @@
# yaml-language-server: $schema=https://schema.infrahub.app/python-sdk/repository-config/latest.json
---
schemas:
- schemas/
menus:
- menus/fabric-menu.yml

View File

@@ -1,35 +0,0 @@
# Claude Code Instructions — Schema Refinement
## Project Context
This is a fabric-orchestrator project managing Arista EVPN-VXLAN fabrics.
Branch: `feature/41-infrahub-schema`
Schema files are in `schemas/` directory (Infrahub YAML format).
## Reference Topology (from overlaid.net)
- 2 spines (AS 65000), 8 leafs in 4 MLAG pairs (AS 65001-65004)
- Loopback0: BGP router-id, Loopback1: shared VTEP IP per MLAG pair
- VLANs 4090/4091: MLAG peering with trunk groups
- EVPN overlay via eBGP multihop on loopbacks
- L2VXLAN (Type-2) and L3VXLAN (Type-5) with VRFs
## Infrahub Schema Rules
1. `human_friendly_id` attributes MUST have `unique: true`
2. If human_friendly_id needs to be scoped per-device, traverse Parent
relationships: `parent_rel__parent_attr__value`
3. Parent relationships need `optional: false` explicitly
4. Self-referencing relationships need `direction: outbound`
5. Multiple relationships to same peer type need unique `identifier`
6. Validate with: `infrahubctl schema check schemas/`
## Commit Convention
- Reference issue: `fix(schema): description — refs #41`
- One logical change per commit
- Run schema validation before committing
## Files
- `schemas/base.yml` — Device, Interfaces, IPAddress
- `schemas/bgp.yml` — AS, BGPRouterConfig, PeerGroup, Session, AF
- `schemas/vlan_vxlan.yml` — VLAN, VNI, VTEP, VlanVniMapping, EVPN
- `schemas/vrf.yml` — VRF, RouteTarget, VRFDeviceAssignment
- `schemas/mlag.yml` — MlagDomain, MlagPeerConfig, MlagInterface
- `schemas/extensions.yml` — UnderlayLink, HostConnection, FabricSettings

173
README.md
View File

@@ -29,7 +29,6 @@ We chose [InfraHub](https://github.com/opsmill/infrahub) over NetBox as Source o
| **Schema** | Fixed DCIM/IPAM model | Fully customizable YAML schema |
| **Git Integration** | External sync needed | Native - branches = data branches |
| **Versioning** | Changelog only | True Git-like versioning with merges |
| **Test/Redeploy** | Dump/restore | `git clone` = complete environment |
| **Transforms** | Limited | Built-in Jinja2 + Python transforms |
| **GraphQL** | Yes | Yes (auto-generated from schema) |
@@ -40,62 +39,6 @@ We chose [InfraHub](https://github.com/opsmill/infrahub) over NetBox as Source o
3. **Transforms** - Generate device configs directly from InfraHub
4. **Branches** - Test fabric changes in isolated branches before merge
## 📦 Repository as InfraHub Backend
This repository serves as the **single source of truth** for both code and infrastructure data:
```
fabric-orchestrator/
├── .infrahub.yml # InfraHub repository config
├── schemas/ # InfraHub schema definitions
│ └── fabric.yml # Custom EVPN-VXLAN fabric schema
├── data/ # Infrastructure objects (YAML)
│ ├── topology/
│ │ ├── sites.yml
│ │ └── devices.yml # Spines, Leafs, VTEP pairs
│ ├── network/
│ │ ├── vlans.yml # VLANs + L2VNI mappings
│ │ ├── vrfs.yml # VRFs + L3VNI mappings
│ │ └── interfaces.yml # Interface configs
│ └── routing/
│ ├── bgp_sessions.yml # Underlay + EVPN overlay
│ └── evpn.yml # Route targets, RDs
├── transforms/ # Jinja2 templates for config generation
│ └── arista/
│ ├── base.j2
│ ├── interfaces.j2
│ ├── bgp.j2
│ └── evpn.j2
└── src/ # Python orchestration code
```
### Workflow
```bash
# 1. Edit data files (e.g., add a VLAN)
vim data/network/vlans.yml
# 2. Commit & push
git commit -am "Add VLAN 100 for production"
git push
# 3. InfraHub syncs automatically from Git
# → Data available via GraphQL
# 4. Prefect flow detects change → reconciles fabric
```
### Benefits
- **Reproductibility**: `git clone``docker compose up` → complete environment
- **Code Review**: Infrastructure changes go through PR review
- **History**: Full audit trail via Git
- **Testing**: Create a branch, test changes, merge when validated
## 🎛 Why Prefect?
| Feature | Benefit |
@@ -111,25 +54,25 @@ git push
## 🎯 Target Fabric
This project is designed for the Arista EVPN-VXLAN ContainerLab topology:
This project is designed for Arista EVPN-VXLAN fabrics:
- **2 Spines** (BGP Route Reflectors, AS 65000)
- **8 Leafs** (4 MLAG VTEP pairs, AS 65001-65004)
- **2 Spines** (BGP Route Reflectors)
- **8 Leafs** (4 MLAG VTEP pairs)
- **cEOS 4.35.0F** with gNMI enabled
- **EVPN Type-2** (L2 VXLAN) and **Type-5** (L3 VXLAN) support
Reference: [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab)
Reference lab topology: [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab)
## 📋 Project Phases
Progress is tracked via issues. See [all issues](https://gitea.arnodo.fr/Damien/fabric-orchestrator/issues) or filter by phase:
| Phase | Description | Status |
| ----------- | -------------------------------------------------------------------- | ------------- |
| **Phase 1** | YANG Path Discovery - Map EOS 4.35.0F YANG models, validate gNMI | ✅ Complete |
| **Phase 2** | InfraHub Setup & Core Reconciler - Schema, diff engine, YANG mappers | 🔄 In Progress |
| **Phase 3** | Full Fabric Coverage - BGP, MLAG, VRFs mappers | 📋 Planned |
| **Phase 4** | Prefect Integration - Flows, webhooks, drift detection | 📋 Planned |
| Phase | Description | Status |
| ----------- | ------------------------------------------------------------------------- | -------------- |
| **Phase 1** | YANG Path Discovery - Map EOS 4.35.0F YANG models, validate gNMI | ✅ Complete |
| **Phase 2** | InfraHub Client & Core Reconciler - SDK client, diff engine, YANG mappers | 🔄 In Progress |
| **Phase 3** | Full Fabric Coverage - BGP, MLAG, VRFs mappers | 📋 Planned |
| **Phase 4** | Prefect Integration - Flows, webhooks, drift detection | 📋 Planned |
## 📁 Project Structure
@@ -137,52 +80,27 @@ Progress is tracked via issues. See [all issues](https://gitea.arnodo.fr/Damien/
fabric-orchestrator/
├── README.md
├── pyproject.toml
├── .infrahub.yml # InfraHub config (points to schemas/)
├── schemas/ # InfraHub schema definitions
│ └── fabric.yml # Custom EVPN-VXLAN fabric schema
├── data/ # Infrastructure data (YAML)
│ ├── topology/
│ │ ├── sites.yml
│ │ └── devices.yml
│ ├── network/
│ │ ├── vlans.yml
│ │ ├── vrfs.yml
│ │ └── interfaces.yml
│ └── routing/
│ ├── bgp_sessions.yml
│ └── evpn.yml
├── transforms/ # Jinja2 config templates
│ └── arista/
│ └── *.j2
├── src/ # Python package
├── src/ # Python package
│ ├── __init__.py
│ ├── cli.py # CLI for YANG discovery
│ │
│ ├── flows/ # Prefect flows
│ │ ├── __init__.py
│ │ ├── reconcile.py # @flow fabric_reconcile
│ │ ├── drift.py # @flow handle_drift
│ │ └── remediation.py # @flow drift_remediation
│ ├── cli.py # CLI for YANG discovery
│ │
│ ├── gnmi/
│ │ ├── __init__.py
│ │ ├── client.py # gNMI client wrapper (pygnmi)
│ │ ├── client.py # gNMI client wrapper (pygnmi)
│ │ └── README.md
│ │
│ ├── infrahub/ # InfraHub integration
│ ├── infrahub/ # InfraHub integration
│ │ ├── __init__.py
│ │ ├── client.py # InfraHub SDK wrapper
│ │ ── queries.py # GraphQL queries
│ │ ├── client.py # InfraHub SDK wrapper
│ │ ── models.py # Pydantic intent models
│ │ └── exceptions.py # Client exceptions
│ │
│ └── yang/
│ ├── __init__.py
│ ├── mapper.py # InfraHub intent → YANG paths
│ ├── paths.py # YANG path definitions
│ └── mappers/ # Resource-specific mappers
│ ├── mapper.py # InfraHub intent → YANG paths
│ ├── paths.py # YANG path definitions
│ └── mappers/ # Resource-specific mappers
│ ├── vlan.py
│ ├── interface.py
│ ├── bgp.py
@@ -200,7 +118,6 @@ fabric-orchestrator/
| Component | Technology | Purpose |
| --------------- | -------------------------- | ------------------------------------ |
| Source of Truth | **InfraHub** | Intent definition via custom schema |
| Data Storage | **This Git repo** | Schema + data versioned together |
| Orchestrator | **Prefect** | Python-native workflow orchestration |
| Transport | gNMI | Configuration and telemetry |
| Data Models | YANG (OpenConfig + Arista) | Structured configuration |
@@ -211,7 +128,7 @@ fabric-orchestrator/
## 🔗 Related Projects
- [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab) - Target fabric topology
- [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab) - Lab topology, InfraHub schemas & data
- [InfraHub](https://github.com/opsmill/infrahub) - Source of Truth platform
- [InfraHub Schema Library](https://github.com/opsmill/schema-library) - Reference schemas
- [Arista YANG Models](https://github.com/aristanetworks/yang/tree/master/EOS-4.35.0F) - EOS 4.35.0F YANG definitions
@@ -219,22 +136,25 @@ fabric-orchestrator/
## 📚 References
### InfraHub
- [InfraHub Documentation](https://docs.infrahub.app)
- [InfraHub Schema Guide](https://docs.infrahub.app/guides/create-schema)
- [InfraHub Python SDK](https://github.com/opsmill/infrahub-sdk-python)
- [InfraHub .infrahub.yml Reference](https://docs.infrahub.app/reference/dotinfrahub)
### Prefect
- [Prefect Documentation](https://docs.prefect.io)
- [Prefect Flows](https://docs.prefect.io/latest/develop/write-flows/)
- [Prefect Tasks](https://docs.prefect.io/latest/develop/write-tasks/)
### YANG / gNMI
- [Arista gNMI Documentation](https://aristanetworks.github.io/openmgmt/configuration/gnmi/)
- [OpenConfig Models](https://github.com/openconfig/public)
- [pygnmi Library](https://github.com/akarneliuk/pygnmi)
### EVPN-VXLAN
- [Arista BGP EVPN Configuration Example](https://overlaid.net/2019/01/27/arista-bgp-evpn-configuration-example/)
## 🚀 Getting Started
@@ -243,31 +163,22 @@ fabric-orchestrator/
- Python 3.12+
- `uv` package manager
- Docker (for InfraHub)
- Access to ContainerLab with cEOS images
- Access to an InfraHub instance with the EVPN-VXLAN fabric schema loaded
- Access to ContainerLab with cEOS images (for lab testing)
### Quick Start
```bash
# Clone the repository (includes schema + data)
# Clone the repository
git clone https://gitea.arnodo.fr/Damien/fabric-orchestrator.git
cd fabric-orchestrator
# Install Python dependencies
uv sync
# Start InfraHub (loads schema & data from this repo)
docker compose up -d
# Configure Prefect secrets
python -c "
from prefect.blocks.system import Secret
from prefect.variables import Variable
Secret(value='your-gnmi-password').save('gnmi-password', overwrite=True)
Variable.set('infrahub_url', 'http://localhost:8000')
Variable.set('gnmi_username', 'admin')
"
# Set InfraHub connection (point to your InfraHub instance)
export INFRAHUB_ADDRESS="http://localhost:8000"
export INFRAHUB_API_TOKEN="your-token"
# Verify gNMI connectivity
uv run fabric-orch discover capabilities --target leaf1:6030
@@ -285,13 +196,15 @@ from prefect.variables import Variable
@task(retries=2, retry_delay_seconds=10)
def get_fabric_intent(device: str | None = None) -> dict:
async def get_fabric_intent(device: str | None = None) -> dict:
"""Retrieve fabric intent from InfraHub."""
from infrahub_sdk import InfrahubClient
client = InfrahubClient(address=Variable.get("infrahub_url"))
# Query fabric intent via GraphQL
return client.query(...)
from src.infrahub.client import FabricInfrahubClient
async with FabricInfrahubClient(
url=Variable.get("infrahub_url"),
api_token=Variable.get("infrahub_token"),
) as client:
return await client.get_device(device)
@task
@@ -307,17 +220,17 @@ def fabric_reconcile(device: str | None = None, dry_run: bool = True) -> dict:
intent = get_fabric_intent(device)
current = get_current_state(device)
changes = compute_diff(intent, current)
if not changes:
print("✅ Fabric is in sync")
return {"in_sync": True}
if not dry_run:
apply_changes(changes)
return {"changes": changes, "applied": not dry_run}
```
---
**Status**: 🚧 Active Development - Phase 2 (InfraHub Setup & Core Reconciler)
**Status**: 🚧 Active Development - Phase 2 (InfraHub Client & Core Reconciler)

View File

@@ -1,534 +0,0 @@
# 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
### Reference Topology
This data model represents the fabric defined in [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab):
- 2 Spine switches (spine1, spine2)
- 8 Leaf switches in 4 MLAG pairs (leaf1-2, leaf3-4, leaf5-6, leaf7-8)
- 4 Hosts dual-homed to MLAG pairs
---
## 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/` |
| **Cables** | `dcim.Cable` | `/api/dcim/cables/` |
| **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) |
---
## Cabling
NetBox's native `dcim.Cable` model represents physical connections between devices. This enables topology visualization (via plugins like `netbox-topology-views`) and validation of the fabric infrastructure.
### Cable Model
| Field | Description |
|-------|-------------|
| `a_terminations` | Source interface(s) |
| `b_terminations` | Destination interface(s) |
| `type` | Cable type (e.g., `cat6a`, `mmf-om4`, `dac-passive`) |
| `status` | `connected`, `planned`, `decommissioning` |
| `label` | Optional identifier |
| `length` | Cable length (with unit) |
### Spine-Leaf Cabling Matrix
Based on the [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab) topology:
#### Spine1 to Leafs
| Spine1 Interface | Leaf | Leaf Interface | Description |
|------------------|------|----------------|-------------|
| Ethernet1 | leaf1 | Ethernet11 | Underlay uplink |
| Ethernet2 | leaf2 | Ethernet11 | Underlay uplink |
| Ethernet3 | leaf3 | Ethernet11 | Underlay uplink |
| Ethernet4 | leaf4 | Ethernet11 | Underlay uplink |
| Ethernet5 | leaf5 | Ethernet11 | Underlay uplink |
| Ethernet6 | leaf6 | Ethernet11 | Underlay uplink |
| Ethernet7 | leaf7 | Ethernet11 | Underlay uplink |
| Ethernet8 | leaf8 | Ethernet11 | Underlay uplink |
#### Spine2 to Leafs
| Spine2 Interface | Leaf | Leaf Interface | Description |
|------------------|------|----------------|-------------|
| Ethernet1 | leaf1 | Ethernet12 | Underlay uplink |
| Ethernet2 | leaf2 | Ethernet12 | Underlay uplink |
| Ethernet3 | leaf3 | Ethernet12 | Underlay uplink |
| Ethernet4 | leaf4 | Ethernet12 | Underlay uplink |
| Ethernet5 | leaf5 | Ethernet12 | Underlay uplink |
| Ethernet6 | leaf6 | Ethernet12 | Underlay uplink |
| Ethernet7 | leaf7 | Ethernet12 | Underlay uplink |
| Ethernet8 | leaf8 | Ethernet12 | Underlay uplink |
### MLAG Peer-Link Cabling
Each MLAG pair has a dedicated peer-link connection:
| MLAG Pair | Device A | Interface A | Device B | Interface B | Description |
|-----------|----------|-------------|----------|-------------|-------------|
| VTEP1 | leaf1 | Ethernet10 | leaf2 | Ethernet10 | MLAG peer-link |
| VTEP2 | leaf3 | Ethernet10 | leaf4 | Ethernet10 | MLAG peer-link |
| VTEP3 | leaf5 | Ethernet10 | leaf6 | Ethernet10 | MLAG peer-link |
| VTEP4 | leaf7 | Ethernet10 | leaf8 | Ethernet10 | MLAG peer-link |
> Note: In production, peer-links typically use multiple interfaces in a Port-Channel for redundancy (e.g., Ethernet10 + Ethernet13 → Port-Channel10).
### Host Dual-Homing Cabling
Hosts are dual-homed to MLAG pairs using LACP bonding:
| Host | Leaf A | Leaf A Interface | Leaf B | Leaf B Interface | MLAG ID |
|------|--------|------------------|--------|------------------|---------|
| host1 | leaf1 | Ethernet1 | leaf2 | Ethernet1 | 1 |
| host2 | leaf3 | Ethernet1 | leaf4 | Ethernet1 | 1 |
| host3 | leaf5 | Ethernet1 | leaf6 | Ethernet1 | 1 |
| host4 | leaf7 | Ethernet1 | leaf8 | Ethernet1 | 1 |
### Cabling Conventions
| Interface Range | Purpose |
|-----------------|---------|
| Ethernet1-9 | Host-facing (downlinks) |
| Ethernet10 | MLAG peer-link |
| Ethernet11-12 | Spine uplinks |
### Cable Retrieval via API
```python
import pynetbox
nb = pynetbox.api('http://netbox.example.com', token='your-token')
# Get all cables for a device
cables = nb.dcim.cables.filter(device='leaf1')
for cable in cables:
a_term = cable.a_terminations[0]
b_term = cable.b_terminations[0]
print(f"{a_term.device.name}:{a_term.name} <-> {b_term.device.name}:{b_term.name}")
# Get spine-leaf cables only
spine_cables = nb.dcim.cables.filter(device='spine1')
# Get peer-link cables (filter by interface name pattern)
leaf1_interfaces = nb.dcim.interfaces.filter(device='leaf1', name='Ethernet10')
for iface in leaf1_interfaces:
if iface.cable:
cable = nb.dcim.cables.get(iface.cable.id)
print(f"Peer-link: {cable}")
# Validate expected connections
def validate_spine_leaf_cabling(nb, spine_name, expected_leafs):
"""Validate that spine has cables to all expected leafs."""
cables = nb.dcim.cables.filter(device=spine_name)
connected_devices = set()
for cable in cables:
for term in cable.b_terminations:
connected_devices.add(term.device.name)
missing = set(expected_leafs) - connected_devices
if missing:
print(f"WARNING: {spine_name} missing connections to: {missing}")
return len(missing) == 0
# Example validation
validate_spine_leaf_cabling(nb, 'spine1',
['leaf1', 'leaf2', 'leaf3', 'leaf4', 'leaf5', 'leaf6', 'leaf7', 'leaf8'])
```
### Topology Visualization
With cables properly defined, the `netbox-topology-views` plugin can automatically generate fabric diagrams. The plugin uses cable data to draw connections between devices.
Key benefits:
- **Auto-generated diagrams**: No manual drawing required
- **Real-time updates**: Topology reflects current NetBox data
- **Drill-down**: Click devices/cables for details
- **Export**: SVG/PNG export for documentation
---
## 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 │ │ Cable │ │
│ │ (65001) │ │ (connections) │ │
│ └────────────┘ └────────────────┘ │
│ │
│ ┌──────────────────┐ ┌───────────────────┐ │
│ │ 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/dcim/cables/` | `device`, `site`, `rack` | `?device=leaf1` |
| `/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. **Cabling Setup** - Create cables for spine-leaf, peer-links, and host connections
3. **Data Population** - Enter fabric topology data into NetBox
4. **NetBox Client** - Update `src/netbox/client.py` to use custom fields and cables
5. **Validation** - Verify data retrieval matches expected fabric config

View File

@@ -1,247 +0,0 @@
# yaml-language-server: $schema=https://schema.infrahub.app/infrahub/menu/latest.json
# Custom menu for EVPN-VXLAN Fabric Orchestrator
# Organizes schema nodes into logical topology-aligned categories
---
apiversion: infrahub.app/v1
kind: Menu
spec:
data:
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Fabric Topology
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Topology
name: Mainmenu
label: Fabric Topology
icon: "mdi:lan"
children:
data:
- namespace: Topology
name: Fabric
label: Fabrics
kind: InfraFabric
icon: "mdi:vector-polygon"
- namespace: Topology
name: Site
label: Sites
kind: LocationSite
icon: "mingcute:building-4-line"
- namespace: Topology
name: Device
label: Devices
kind: InfraDevice
icon: "mdi:server-network"
- namespace: Topology
name: Platform
label: Platforms
kind: InfraPlatform
icon: "mdi:chip"
- namespace: Topology
name: UnderlayLink
label: Underlay Links
kind: InfraUnderlayLink
icon: "mdi:cable-data"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Interfaces
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Interfaces
name: Mainmenu
label: Interfaces
icon: "mdi:ethernet"
children:
data:
- namespace: Interfaces
name: Ethernet
label: Ethernet
kind: InfraInterfaceEthernet
icon: "mdi:ethernet"
- namespace: Interfaces
name: Loopback
label: Loopback
kind: InfraInterfaceLoopback
icon: "mdi:reload"
- namespace: Interfaces
name: VlanSvi
label: VLAN SVI
kind: InfraInterfaceVlan
icon: "mdi:lan"
- namespace: Interfaces
name: Lag
label: LAG / Port-Channel
kind: InfraInterfaceLag
icon: "mdi:link-variant"
- namespace: Interfaces
name: Vxlan
label: VXLAN Tunnel
kind: InfraInterfaceVxlan
icon: "mdi:tunnel"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# IP Addressing
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Addressing
name: Mainmenu
label: IP Addressing
icon: "mdi:ip-network"
children:
data:
- namespace: Addressing
name: IPAddress
label: IP Addresses
kind: InfraIPAddress
icon: "mdi:ip-network"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Layer 2 / VXLAN
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Layer2
name: Mainmenu
label: Layer 2 / VXLAN
icon: "mdi:switch"
children:
data:
- namespace: Layer2
name: Vlan
label: VLANs
kind: InfraVLAN
icon: "mdi:lan-connect"
- namespace: Layer2
name: Vni
label: VNIs
kind: InfraVNI
icon: "mdi:tunnel-outline"
- namespace: Layer2
name: Vtep
label: VTEPs
kind: InfraVTEP
icon: "mdi:server-network-outline"
- namespace: Layer2
name: VlanVniMapping
label: VLAN-VNI Mappings
kind: InfraVlanVniMapping
icon: "mdi:swap-horizontal"
- namespace: Layer2
name: EvpnInstance
label: EVPN Instances
kind: InfraEVPNInstance
icon: "mdi:cloud-sync"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Routing / BGP
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Routing
name: Mainmenu
label: Routing / BGP
icon: "mdi:routes"
children:
data:
- namespace: Routing
name: AutonomousSystem
label: Autonomous Systems
kind: InfraAutonomousSystem
icon: "mdi:cloud-outline"
- namespace: Routing
name: BGPRouterConfig
label: BGP Router Config
kind: InfraBGPRouterConfig
icon: "mdi:router-wireless"
- namespace: Routing
name: BGPPeerGroup
label: BGP Peer Groups
kind: InfraBGPPeerGroup
icon: "mdi:account-group"
- namespace: Routing
name: BGPSession
label: BGP Sessions
kind: InfraBGPSession
icon: "mdi:connection"
- namespace: Routing
name: BGPAddressFamily
label: BGP Address Families
kind: InfraBGPAddressFamily
icon: "mdi:format-list-bulleted"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# VRF
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Vrf
name: Mainmenu
label: VRF
icon: "mdi:router"
children:
data:
- namespace: Vrf
name: Vrf
label: VRFs
kind: InfraVRF
icon: "mdi:router"
- namespace: Vrf
name: RouteTarget
label: Route Targets
kind: InfraRouteTarget
icon: "mdi:target"
- namespace: Vrf
name: VrfAssignment
label: VRF Assignments
kind: InfraVRFDeviceAssignment
icon: "mdi:router-network"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# MLAG
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Mlag
name: Mainmenu
label: MLAG
icon: "mdi:link-variant"
children:
data:
- namespace: Mlag
name: MlagDomain
label: MLAG Domains
kind: InfraMlagDomain
icon: "mdi:link-variant"
- namespace: Mlag
name: MlagPeerConfig
label: MLAG Peer Config
kind: InfraMlagPeerConfig
icon: "mdi:server-network"
- namespace: Mlag
name: MlagInterface
label: MLAG Interfaces
kind: InfraMlagInterface
icon: "mdi:ethernet-cable"
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Host Connectivity
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- namespace: Connectivity
name: Mainmenu
label: Host Connectivity
icon: "mdi:desktop-tower"
children:
data:
- namespace: Connectivity
name: HostConnection
label: Host Connections
kind: InfraHostConnection
icon: "mdi:desktop-tower"

View File

@@ -1,13 +1,14 @@
[project]
name = "fabric-orchestrator"
version = "0.1.0"
description = "Declarative Network Fabric Orchestrator - Terraform-like infrastructure management for Arista EVPN-VXLAN using gNMI, YANG, and NetBox as Source of Truth"
description = "Declarative Network Fabric Orchestrator - Terraform-like infrastructure management for Arista EVPN-VXLAN using gNMI, YANG, and Infrahub as Source of Truth"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"click>=8.1.0",
"pygnmi>=0.8.0",
"pynetbox>=7.5.0",
"infrahub-sdk>=0.16.0",
"pydantic>=2.0",
"rich>=13.0.0",
]
@@ -35,5 +36,7 @@ ignore = ["E501"]
[dependency-groups]
dev = [
"basedpyright>=1.37.4",
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"ruff>=0.14.10",
]

View File

@@ -1,112 +0,0 @@
# Infrahub Schema for EVPN-VXLAN Fabric
This directory contains the Infrahub schema definitions for modeling an EVPN-VXLAN fabric. The schema is designed to represent the [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab) topology.
## Schema Files
| File | Description |
|------|-------------|
| `base.yml` | Core infrastructure: Device, Interface types, IP Address, Platform, Site |
| `vlan_vxlan.yml` | VLAN, VNI, VTEP, EVPN Instance |
| `vrf.yml` | VRF, Route Target, VRF assignments |
| `bgp.yml` | Autonomous System, BGP Config, Peer Groups, Sessions |
| `mlag.yml` | MLAG Domain, Peer Config, MLAG Interfaces |
| `extensions.yml` | Fabric, Underlay Links, Host Connections |
## Entity Relationship Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ FABRIC TOPOLOGY │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────┐
│ InfraFabric │
└──────┬───────┘
┌──────────────────────┼──────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌─────────────┐ ┌────────────┐
│ LocationSite│ │ InfraDevice │ │InfraAutono-│
└────────────┘ │ (spine/ │ │mousSystem │
│ leaf) │ └─────┬──────┘
└──────┬──────┘ │
│ │
┌────────────────────────────┼─────────────────────┤
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│InfraInterface│ │InfraMlagDomain│ │InfraBGPRouter│
│ (generic) │ │ (leaf pair) │ │ Config │
└──────┬───────┘ └──────────────┘ └──────┬───────┘
│ │
│ ┌─────────────────────────┬───────────────────┤
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│InterfaceTypes│ │InfraBGPPeer- │ │ InfraBGP- │
│ - Ethernet │ │ Group │ │ Session │
│ - Loopback │ │ (underlay, │ │ (neighbors) │
│ - Vlan │ │ evpn) │ └──────────────┘
│ - Lag │ └──────────────┘
└──────┬───────┘
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│InfraIPAddress│◄────►│ InfraVLAN │◄────►│ InfraVNI │
└──────────────┘ └──────────────┘ └──────────────┘
┌──────────────┐
│ InfraVTEP │
└──────────────┘
```
## Reference Topology Mapping
The schema models this topology from `arista-evpn-vxlan-clab`:
| Physical | Infrahub Model |
|----------|----------------|
| spine1, spine2 | InfraDevice (role: spine) |
| leaf1-8 | InfraDevice (role: leaf) |
| AS 65000 | InfraAutonomousSystem (spines) |
| AS 65001-65004 | InfraAutonomousSystem (leaf pairs) |
| VLAN 40, 34, 78 | InfraVLAN + InfraVNI |
| VLAN 4090, 4091 | InfraVLAN (vlan_type: mlag_peer/mlag_ibgp) |
| leaf1+leaf2 pair | InfraMlagDomain |
| Port-Channel999 | InfraInterfaceLag (peer-link) |
| Port-Channel1 | InfraMlagInterface (host-facing) |
| Ethernet1-8 | InfraInterfaceEthernet |
| Loopback0, Loopback1 | InfraInterfaceLoopback |
| Vxlan1 | InfraVTEP |
| peer groups | InfraBGPPeerGroup |
## Usage
### Loading the Schema
```bash
# From the fabric-orchestrator root directory
infrahubctl schema load schemas/
```
### Validation
```bash
infrahubctl schema check schemas/
```
## Key Design Decisions
1. **Generic Interface**: All interface types inherit from `InfraInterface` generic for polymorphic queries
2. **MLAG as Domain**: MLAG is modeled as a domain containing exactly 2 devices, with per-device config
3. **BGP Hierarchy**: BGPRouterConfig → PeerGroups → Sessions allows template-based configuration
4. **VTEP Independence**: VTEP is separate from Device to support shared VTEP scenarios
5. **EVPN Instance per VLAN**: Allows device-specific RD/RT while referencing common VLAN/VNI
## Related Issues
- Parent issue: [#41 - Define Infrahub Schema for EVPN-VXLAN Fabric](https://gitea.arnodo.fr/Damien/fabric-orchestrator/issues/41)
- Depends on: Schema being loaded before transforms (#30, #31, #32, #33)

View File

@@ -1,347 +0,0 @@
# Base Infrastructure Schema for EVPN-VXLAN Fabric
# This schema defines core infrastructure objects required for fabric orchestration
---
version: "1.0"
generics:
- name: Interface
namespace: Infra
description: Generic interface - parent for all interface types
label: Interface
include_in_menu: false
hierarchical: false
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
description: Interface name (e.g., Ethernet1, Loopback0)
- name: description
kind: Text
optional: true
- name: enabled
kind: Boolean
default_value: true
- name: mtu
kind: Number
optional: true
description: Maximum Transmission Unit
relationships:
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false
- name: ip_addresses
peer: InfraIPAddress
identifier: interface__ip_addresses
cardinality: many
kind: Generic
nodes:
# ================================================================
# Location
# ================================================================
- name: Site
namespace: Location
description: Physical site or data center
label: Site
icon: mingcute--building-4-line
include_in_menu: false
human_friendly_id:
- name__value
order_by:
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
unique: true
- name: description
kind: Text
optional: true
- name: facility
kind: Text
optional: true
description: Facility identifier or code
# ================================================================
# Platform
# ================================================================
- name: Platform
namespace: Infra
description: Device platform/OS (e.g., Arista EOS, Cisco NX-OS)
label: Platform
icon: mdi--chip
include_in_menu: false
human_friendly_id:
- name__value
order_by:
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
unique: true
description: Platform name (e.g., arista_eos, cisco_nxos)
- name: description
kind: Text
optional: true
- name: napalm_driver
kind: Text
optional: true
description: NAPALM driver name
- name: netmiko_device_type
kind: Text
optional: true
description: Netmiko device type
# ================================================================
# Device
# ================================================================
- name: Device
namespace: Infra
description: Network device (spine, leaf, etc.)
label: Device
icon: mdi--server-network
include_in_menu: false
human_friendly_id:
- name__value
order_by:
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
unique: true
description: Device hostname
- name: description
kind: Text
optional: true
- name: role
kind: Dropdown
choices:
- name: spine
label: Spine
color: "#3b82f6"
- name: leaf
label: Leaf
color: "#22c55e"
- name: border_leaf
label: Border Leaf
color: "#f59e0b"
description: Fabric role
- name: status
kind: Dropdown
default_value: active
choices:
- name: active
label: Active
color: "#22c55e"
- name: planned
label: Planned
color: "#3b82f6"
- name: maintenance
label: Maintenance
color: "#f59e0b"
- name: decommissioned
label: Decommissioned
color: "#ef4444"
relationships:
- name: site
peer: LocationSite
cardinality: one
optional: true
- name: platform
peer: InfraPlatform
cardinality: one
optional: true
- name: asn
peer: InfraAutonomousSystem
cardinality: one
optional: true
description: BGP Autonomous System
- name: interfaces
peer: InfraInterface
cardinality: many
kind: Component
- name: mlag_domain
peer: InfraMlagDomain
cardinality: one
optional: true
# ================================================================
# Interface Types (inherit from InfraInterface generic)
# ================================================================
- name: InterfaceEthernet
namespace: Infra
description: Physical Ethernet interface
label: Ethernet Interface
icon: mdi--ethernet
include_in_menu: false
inherit_from:
- InfraInterface
uniqueness_constraints:
- ["device", "name__value"]
human_friendly_id:
- device__name__value
- name__value
display_label: "{{ name__value }}"
attributes:
- name: speed
kind: Dropdown
optional: true
choices:
- name: "1000"
label: 1 Gbps
- name: "10000"
label: 10 Gbps
- name: "25000"
label: 25 Gbps
- name: "40000"
label: 40 Gbps
- name: "100000"
label: 100 Gbps
- name: mode
kind: Dropdown
optional: true
choices:
- name: access
label: Access
- name: trunk
label: Trunk
- name: routed
label: Routed (L3)
description: Switchport mode
relationships:
- name: lag
peer: InfraInterfaceLag
cardinality: one
optional: true
description: Parent LAG interface
- name: connected_interface
peer: InfraInterfaceEthernet
identifier: ethernet_connected_to
direction: outbound
cardinality: one
optional: true
description: Connected peer interface
- name: InterfaceLoopback
namespace: Infra
description: Loopback interface
label: Loopback Interface
icon: mdi--reload
include_in_menu: false
inherit_from:
- InfraInterface
uniqueness_constraints:
- ["device", "name__value"]
human_friendly_id:
- device__name__value
- name__value
display_label: "{{ name__value }}"
- name: InterfaceVlan
namespace: Infra
description: VLAN SVI interface
label: VLAN Interface
icon: mdi--lan
include_in_menu: false
inherit_from:
- InfraInterface
uniqueness_constraints:
- ["device", "name__value"]
human_friendly_id:
- device__name__value
- name__value
display_label: "{{ name__value }}"
attributes:
- name: virtual_router_address
kind: IPHost
optional: true
description: Anycast gateway IP (ip virtual-router address)
- name: autostate
kind: Boolean
default_value: true
description: "Enable autostate (set false for MLAG peer SVIs)"
relationships:
- name: vlan
peer: InfraVLAN
cardinality: one
optional: true
- name: InterfaceLag
namespace: Infra
description: Link Aggregation (Port-Channel) interface
label: LAG Interface
icon: mdi--link-variant
include_in_menu: false
inherit_from:
- InfraInterface
uniqueness_constraints:
- ["device", "name__value"]
human_friendly_id:
- device__name__value
- name__value
display_label: "{{ name__value }}"
attributes:
- name: lacp_mode
kind: Dropdown
optional: true
choices:
- name: active
label: Active
- name: passive
label: Passive
- name: static
label: Static (No LACP)
- name: mlag_id
kind: Number
optional: true
description: MLAG interface ID
relationships:
- name: members
peer: InfraInterfaceEthernet
cardinality: many
kind: Component
# ================================================================
# IP Address
# ================================================================
- name: IPAddress
namespace: Infra
description: IP Address assignment
label: IP Address
icon: mdi--ip-network
include_in_menu: false
human_friendly_id:
- address__value
order_by:
- address__value
display_label: "{{ address__value }}"
attributes:
- name: address
kind: IPNetwork
description: IP address with prefix (e.g., 10.0.1.1/31)
- name: description
kind: Text
optional: true
- name: status
kind: Dropdown
default_value: active
choices:
- name: active
label: Active
color: "#22c55e"
- name: reserved
label: Reserved
color: "#3b82f6"
- name: deprecated
label: Deprecated
color: "#ef4444"
relationships:
- name: interface
peer: InfraInterface
identifier: interface__ip_addresses
cardinality: one
optional: true
kind: Attribute

View File

@@ -1,265 +0,0 @@
# BGP Schema for EVPN-VXLAN Fabric
# Defines Autonomous System, Peer Groups, and BGP Sessions
---
version: "1.0"
nodes:
# ================================================================
# Autonomous System
# ================================================================
- name: AutonomousSystem
namespace: Infra
description: BGP Autonomous System
label: Autonomous System
icon: mdi--cloud-outline
include_in_menu: false
human_friendly_id:
- asn__value
order_by:
- asn__value
display_label: "{{ asn__value }}"
attributes:
- name: asn
kind: Number
unique: true
description: AS Number (e.g., 65000)
- name: description
kind: Text
optional: true
- name: as_type
kind: Dropdown
default_value: private
choices:
- name: private
label: Private
- name: public
label: Public
# ================================================================
# BGP Router Configuration (per device)
# ================================================================
- name: BGPRouterConfig
namespace: Infra
description: BGP router configuration on a device
label: BGP Router Config
icon: mdi--router-wireless
include_in_menu: false
human_friendly_id:
- device__name__value
display_label: "{{ router_id__value }}"
attributes:
- name: router_id
kind: IPHost
unique: true
description: BGP Router ID
- name: default_ipv4_unicast
kind: Boolean
default_value: false
description: Enable default IPv4 unicast
- name: log_neighbor_changes
kind: Boolean
default_value: true
- name: ecmp_max_paths
kind: Number
default_value: 4
description: Maximum ECMP paths
- name: ecmp_max_ecmp
kind: Number
default_value: 64
description: Maximum ECMP routes
- name: ebgp_distance
kind: Number
default_value: 20
description: eBGP administrative distance
- name: ibgp_distance
kind: Number
default_value: 200
description: iBGP administrative distance
- name: local_distance
kind: Number
default_value: 200
description: Local route administrative distance
relationships:
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false
- name: local_asn
peer: InfraAutonomousSystem
cardinality: one
- name: peer_groups
peer: InfraBGPPeerGroup
cardinality: many
kind: Component
- name: sessions
peer: InfraBGPSession
cardinality: many
kind: Component
# ================================================================
# BGP Peer Group
# ================================================================
- name: BGPPeerGroup
namespace: Infra
description: BGP peer group template
label: BGP Peer Group
icon: mdi--account-group
include_in_menu: false
uniqueness_constraints:
- ["bgp_config", "name__value"]
human_friendly_id:
- bgp_config__router_id__value
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
description: Peer group name (e.g., underlay, evpn)
- name: description
kind: Text
optional: true
- name: update_source
kind: Text
optional: true
description: Update source interface (e.g., Loopback0)
- name: ebgp_multihop
kind: Number
optional: true
description: eBGP multihop TTL
- name: send_community
kind: Dropdown
default_value: none
choices:
- name: none
label: None
- name: standard
label: Standard
- name: extended
label: Extended
- name: both
label: Both
- name: next_hop_self
kind: Boolean
default_value: false
- name: next_hop_unchanged
kind: Boolean
default_value: false
description: Keep next-hop unchanged (for route reflector)
- name: maximum_routes
kind: Number
optional: true
- name: maximum_routes_warning_only
kind: Boolean
default_value: true
- name: peer_group_type
kind: Dropdown
default_value: underlay
choices:
- name: underlay
label: Underlay IPv4
- name: underlay_ibgp
label: Underlay iBGP
- name: evpn
label: EVPN Overlay
relationships:
- name: bgp_config
peer: InfraBGPRouterConfig
cardinality: one
kind: Parent
optional: false
- name: remote_asn
peer: InfraAutonomousSystem
cardinality: one
optional: true
# ================================================================
# BGP Session (Neighbor)
# ================================================================
- name: BGPSession
namespace: Infra
description: BGP neighbor session
label: BGP Session
icon: mdi--connection
include_in_menu: false
uniqueness_constraints:
- ["bgp_config", "peer_address__value"]
human_friendly_id:
- bgp_config__router_id__value
- peer_address__value
display_label: "{{ peer_address__value }}"
attributes:
- name: peer_address
kind: IPHost
description: Neighbor IP address
- name: description
kind: Text
optional: true
- name: enabled
kind: Boolean
default_value: true
relationships:
- name: bgp_config
peer: InfraBGPRouterConfig
cardinality: one
kind: Parent
optional: false
- name: peer_group
peer: InfraBGPPeerGroup
cardinality: one
optional: true
- name: remote_asn
peer: InfraAutonomousSystem
cardinality: one
optional: true
description: Override peer group remote-as
- name: peer_device
peer: InfraDevice
cardinality: one
optional: true
description: Remote peer device (for documentation)
# ================================================================
# BGP Address Family Configuration
# ================================================================
- name: BGPAddressFamily
namespace: Infra
description: BGP address family configuration
label: BGP Address Family
icon: mdi--format-list-bulleted
include_in_menu: false
display_label: "{{ afi__value }}"
attributes:
- name: afi
kind: Dropdown
choices:
- name: ipv4
label: IPv4
- name: ipv6
label: IPv6
- name: evpn
label: EVPN
description: Address Family Identifier
- name: safi
kind: Dropdown
default_value: unicast
choices:
- name: unicast
label: Unicast
- name: multicast
label: Multicast
description: Sub Address Family Identifier
relationships:
- name: bgp_config
peer: InfraBGPRouterConfig
cardinality: one
kind: Parent
optional: false
- name: active_peer_groups
peer: InfraBGPPeerGroup
cardinality: many
description: Peer groups activated in this AF
- name: networks
peer: InfraIPAddress
cardinality: many
optional: true
description: Networks to advertise

View File

@@ -1,164 +0,0 @@
# Extensions Schema for EVPN-VXLAN Fabric
# Custom attributes and fabric-specific configurations
---
version: "1.0"
nodes:
# ================================================================
# Fabric (Top-level container for all fabric objects)
# ================================================================
- name: Fabric
namespace: Infra
description: EVPN-VXLAN Fabric definition
label: Fabric
icon: mdi--vector-polygon
include_in_menu: false
human_friendly_id:
- name__value
order_by:
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
unique: true
description: Fabric name (e.g., arista-evpn-fabric)
- name: description
kind: Text
optional: true
- name: underlay_protocol
kind: Dropdown
default_value: ebgp
choices:
- name: ebgp
label: eBGP
- name: ospf
label: OSPF
- name: isis
label: IS-IS
- name: overlay_protocol
kind: Dropdown
default_value: evpn
choices:
- name: evpn
label: EVPN
- name: ingress_replication
label: Ingress Replication
- name: anycast_gateway_mac
kind: Text
optional: true
description: "Shared MAC for anycast gateway (format: xxxx.xxxx.xxxx)"
relationships:
- name: spine_asn
peer: InfraAutonomousSystem
cardinality: one
optional: true
description: AS used by spine layer
- name: sites
peer: LocationSite
cardinality: many
optional: true
# ================================================================
# Underlay P2P Link
# ================================================================
- name: UnderlayLink
namespace: Infra
description: Point-to-point underlay link between devices
label: Underlay Link
icon: mdi--cable-data
include_in_menu: false
display_label: "{{ description__value }}"
attributes:
- name: description
kind: Text
description: Link description (e.g., spine1:eth1 <-> leaf1:eth11)
- name: mtu
kind: Number
default_value: 9214
relationships:
- name: fabric
peer: InfraFabric
cardinality: one
kind: Parent
optional: false
- name: local_device
peer: InfraDevice
identifier: underlay_link_local_device
cardinality: one
direction: outbound
- name: local_interface
peer: InfraInterfaceEthernet
identifier: underlay_link_local_interface
cardinality: one
direction: outbound
- name: local_ip_address
peer: InfraIPAddress
identifier: underlay_link_local_ip
cardinality: one
direction: outbound
- name: remote_device
peer: InfraDevice
identifier: underlay_link_remote_device
cardinality: one
direction: outbound
- name: remote_interface
peer: InfraInterfaceEthernet
identifier: underlay_link_remote_interface
cardinality: one
direction: outbound
- name: remote_ip_address
peer: InfraIPAddress
identifier: underlay_link_remote_ip
cardinality: one
direction: outbound
# ================================================================
# Host Connection
# ================================================================
- name: HostConnection
namespace: Infra
description: Host connection to fabric (single or dual-homed)
label: Host Connection
icon: mdi--desktop-tower
include_in_menu: false
human_friendly_id:
- hostname__value
display_label: "{{ hostname__value }}"
attributes:
- name: hostname
kind: Text
description: Connected host name
- name: description
kind: Text
optional: true
- name: connection_type
kind: Dropdown
default_value: dual_homed
choices:
- name: single_homed
label: Single-Homed
- name: dual_homed
label: Dual-Homed (MLAG)
- name: lacp_mode
kind: Dropdown
default_value: active
choices:
- name: active
label: Active
- name: passive
label: Passive
relationships:
- name: vlans
peer: InfraVLAN
cardinality: many
description: VLANs allowed on this connection
- name: mlag_interface
peer: InfraMlagInterface
cardinality: one
optional: true
description: MLAG interface for dual-homed
- name: lag_interface
peer: InfraInterfaceLag
cardinality: one
optional: true
description: LAG for single-homed

View File

@@ -1,141 +0,0 @@
# MLAG Schema for EVPN-VXLAN Fabric
# Defines MLAG domain and peer configuration
---
version: "1.0"
nodes:
# ================================================================
# MLAG Domain
# ================================================================
- name: MlagDomain
namespace: Infra
description: MLAG domain configuration for leaf pair
label: MLAG Domain
icon: mdi--link-variant
include_in_menu: false
human_friendly_id:
- domain_id__value
display_label: "{{ domain_id__value }}"
attributes:
- name: domain_id
kind: Text
description: MLAG domain identifier (e.g., leafs)
- name: description
kind: Text
optional: true
- name: virtual_mac
kind: Text
description: "Shared virtual MAC (format: xxxx.xxxx.xxxx)"
- name: heartbeat_vrf
kind: Text
default_value: mgmt
description: VRF for heartbeat (typically mgmt)
- name: dual_primary_detection
kind: Boolean
default_value: true
- name: dual_primary_delay
kind: Number
default_value: 10
description: Delay in seconds before dual-primary action
- name: dual_primary_action
kind: Dropdown
default_value: errdisable
choices:
- name: errdisable
label: Error Disable Interfaces
- name: none
label: No Action
relationships:
- name: devices
peer: InfraDevice
cardinality: many
min_count: 2
max_count: 2
description: MLAG peer devices
- name: peer_vlan
peer: InfraVLAN
identifier: mlag_domain_peer_vlan
cardinality: one
direction: outbound
description: VLAN for MLAG peer-link control traffic
- name: ibgp_vlan
peer: InfraVLAN
identifier: mlag_domain_ibgp_vlan
cardinality: one
direction: outbound
optional: true
description: VLAN for iBGP peering between MLAG peers
# ================================================================
# MLAG Peer Configuration (per device)
# ================================================================
- name: MlagPeerConfig
namespace: Infra
description: MLAG configuration on a specific device
label: MLAG Peer Config
icon: mdi--server-network
include_in_menu: false
human_friendly_id:
- device__name__value
display_label: "{{ local_interface_ip__value }}"
attributes:
- name: local_interface_ip
kind: IPNetwork
description: IP on MLAG peer VLAN SVI
- name: peer_address
kind: IPHost
description: Peer's MLAG SVI IP address
- name: heartbeat_peer_ip
kind: IPHost
description: Peer's management IP for heartbeat
relationships:
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false
- name: mlag_domain
peer: InfraMlagDomain
cardinality: one
- name: local_interface
peer: InfraInterfaceVlan
cardinality: one
description: Local MLAG SVI
- name: peer_link
peer: InfraInterfaceLag
cardinality: one
description: Peer-link port-channel
# ================================================================
# MLAG Interface (MLAG-enabled LAG)
# ================================================================
- name: MlagInterface
namespace: Infra
description: MLAG interface configuration
label: MLAG Interface
icon: mdi--ethernet-cable
include_in_menu: false
display_label: "{{ mlag_id__value }}"
attributes:
- name: mlag_id
kind: Number
description: MLAG interface ID
- name: description
kind: Text
optional: true
- name: lacp_fallback_timeout
kind: Number
default_value: 5
description: LACP fallback timeout in seconds
- name: lacp_fallback_individual
kind: Boolean
default_value: true
relationships:
- name: mlag_domain
peer: InfraMlagDomain
cardinality: one
- name: lag_interfaces
peer: InfraInterfaceLag
cardinality: many
min_count: 1
max_count: 2
description: LAG interfaces on each MLAG peer

View File

@@ -1,221 +0,0 @@
# VLAN and VXLAN Schema for EVPN-VXLAN Fabric
# Defines VLAN, VNI mappings, and VTEP configuration
---
version: "1.0"
nodes:
# ================================================================
# VLAN
# ================================================================
- name: VLAN
namespace: Infra
description: Virtual LAN configuration
label: VLAN
icon: mdi--lan-connect
include_in_menu: false
human_friendly_id:
- vlan_id__value
order_by:
- vlan_id__value
display_label: "{{ vlan_id__value }} ({{ name__value }})"
attributes:
- name: vlan_id
kind: Number
unique: true
description: VLAN ID (1-4094)
- name: name
kind: Text
description: VLAN name
- name: description
kind: Text
optional: true
- name: status
kind: Dropdown
default_value: active
choices:
- name: active
label: Active
color: "#22c55e"
- name: reserved
label: Reserved
color: "#3b82f6"
- name: deprecated
label: Deprecated
color: "#ef4444"
- name: vlan_type
kind: Dropdown
default_value: standard
choices:
- name: standard
label: Standard
- name: mlag_peer
label: MLAG Peer
- name: mlag_ibgp
label: MLAG iBGP
description: VLAN purpose
- name: trunk_groups
kind: List
optional: true
description: "Trunk groups restricting VLAN propagation (e.g., mlag-peer)"
- name: stp_enabled
kind: Boolean
default_value: true
description: "Enable spanning-tree on this VLAN (set false for MLAG peer VLANs)"
relationships:
- name: vni
peer: InfraVNI
cardinality: one
optional: true
description: Associated L2VNI for VXLAN
- name: site
peer: LocationSite
cardinality: one
optional: true
# ================================================================
# VNI (VXLAN Network Identifier)
# ================================================================
- name: VNI
namespace: Infra
description: VXLAN Network Identifier
label: VNI
icon: mdi--tunnel-outline
include_in_menu: false
human_friendly_id:
- vni__value
order_by:
- vni__value
display_label: "{{ vni__value }}"
attributes:
- name: vni
kind: Number
unique: true
description: VNI value (1-16777215)
- name: description
kind: Text
optional: true
- name: vni_type
kind: Dropdown
default_value: l2vni
choices:
- name: l2vni
label: L2 VNI
color: "#3b82f6"
- name: l3vni
label: L3 VNI
color: "#22c55e"
description: VNI type for EVPN
relationships:
- name: vlan
peer: InfraVLAN
cardinality: one
optional: true
description: Associated VLAN for L2VNI
- name: vrf
peer: InfraVRF
cardinality: one
optional: true
description: Associated VRF for L3VNI
# ================================================================
# VTEP (VXLAN Tunnel Endpoint)
# ================================================================
- name: VTEP
namespace: Infra
description: VXLAN Tunnel Endpoint configuration
label: VTEP
icon: mdi--server-network-outline
include_in_menu: false
human_friendly_id:
- device__name__value
display_label: "{{ source_address__value }}"
attributes:
- name: source_address
kind: IPHost
description: VTEP source IP (typically from Loopback1)
- name: udp_port
kind: Number
default_value: 4789
- name: learn_restrict
kind: Dropdown
default_value: any
choices:
- name: any
label: Any
- name: flood
label: Flood
description: MAC learning restriction mode
relationships:
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false
- name: source_interface
peer: InfraInterfaceLoopback
cardinality: one
description: Source interface for VTEP
- name: vlan_vni_mappings
peer: InfraVlanVniMapping
cardinality: many
kind: Component
# ================================================================
# VLAN to VNI Mapping (per-device)
# ================================================================
- name: VlanVniMapping
namespace: Infra
description: VLAN to VNI mapping on a VTEP
label: VLAN-VNI Mapping
icon: mdi--swap-horizontal
include_in_menu: false
display_label: "{{ description__value }}"
attributes:
- name: description
kind: Text
optional: true
description: "Mapping description (e.g., VLAN 40 <-> VNI 10040)"
relationships:
- name: vtep
peer: InfraVTEP
cardinality: one
kind: Parent
optional: false
- name: vlan
peer: InfraVLAN
cardinality: one
- name: vni
peer: InfraVNI
cardinality: one
# ================================================================
# EVPN Instance (per VLAN)
# ================================================================
- name: EVPNInstance
namespace: Infra
description: EVPN instance configuration (route targets for L2 extension)
label: EVPN Instance
icon: mdi--cloud-sync
include_in_menu: false
display_label: "{{ route_distinguisher__value }}"
attributes:
- name: route_distinguisher
kind: Text
description: "Route Distinguisher (format: ASN:VNI or IP:VNI)"
- name: route_target_import
kind: Text
description: "Import Route Target (format: VNI:VNI)"
- name: route_target_export
kind: Text
description: "Export Route Target (format: VNI:VNI)"
- name: redistribute_learned
kind: Boolean
default_value: true
relationships:
- name: vlan
peer: InfraVLAN
cardinality: one
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false

View File

@@ -1,124 +0,0 @@
# VRF Schema for EVPN-VXLAN Fabric
# Defines VRF and Route Target configuration for L3 VPN
---
version: "1.0"
nodes:
# ================================================================
# VRF (Virtual Routing and Forwarding)
# ================================================================
- name: VRF
namespace: Infra
description: Virtual Routing and Forwarding instance
label: VRF
icon: mdi--router
include_in_menu: false
human_friendly_id:
- name__value
order_by:
- name__value
display_label: "{{ name__value }}"
attributes:
- name: name
kind: Text
unique: true
description: VRF name
- name: description
kind: Text
optional: true
- name: route_distinguisher
kind: Text
optional: true
description: "Route Distinguisher (format: ASN:NN or IP:NN)"
- name: vrf_id
kind: Number
optional: true
description: VRF table ID
relationships:
- name: l3vni
peer: InfraVNI
cardinality: one
optional: true
description: L3 VNI for symmetric IRB
- name: import_targets
peer: InfraRouteTarget
identifier: vrf_import_targets
cardinality: many
direction: outbound
optional: true
- name: export_targets
peer: InfraRouteTarget
identifier: vrf_export_targets
cardinality: many
direction: outbound
optional: true
- name: interfaces
peer: InfraInterface
cardinality: many
optional: true
description: Interfaces assigned to this VRF
# ================================================================
# Route Target
# ================================================================
- name: RouteTarget
namespace: Infra
description: BGP Route Target for VPN import/export
label: Route Target
icon: mdi--target
include_in_menu: false
human_friendly_id:
- target__value
order_by:
- target__value
display_label: "{{ target__value }}"
attributes:
- name: target
kind: Text
unique: true
description: "Route Target value (format: ASN:NN or IP:NN)"
- name: description
kind: Text
optional: true
# ================================================================
# VRF Device Assignment
# ================================================================
- name: VRFDeviceAssignment
namespace: Infra
description: VRF assignment to a specific device
label: VRF Assignment
icon: mdi--router-network
include_in_menu: false
human_friendly_id:
- device__name__value
- vrf__name__value
display_label: "{{ device__name__value }} - {{ vrf__name__value }}"
attributes:
- name: route_distinguisher
kind: Text
optional: true
description: "Device-specific RD (overrides VRF default)"
relationships:
- name: vrf
peer: InfraVRF
cardinality: one
optional: false
- name: device
peer: InfraDevice
cardinality: one
kind: Parent
optional: false
- name: import_targets
peer: InfraRouteTarget
identifier: vrf_assignment_import_targets
cardinality: many
direction: outbound
optional: true
description: Device-specific import RTs
- name: export_targets
peer: InfraRouteTarget
identifier: vrf_assignment_export_targets
cardinality: many
direction: outbound
optional: true
description: Device-specific export RTs

282
src/infrahub/README.md Normal file
View File

@@ -0,0 +1,282 @@
# Infrahub Client Module
This module provides an async client for querying fabric intent data from a remote
[Infrahub](https://docs.infrahub.app/) instance. It wraps `infrahub-sdk` with structured
Pydantic model responses, TTL-based caching, and a typed exception hierarchy.
It is used by the Reconciler and Prefect flows as the single entry point to the
Source of Truth.
## Features
- Async context manager support (`async with FabricInfrahubClient(...) as client`)
- Typed, immutable Pydantic v2 response models (frozen, validated)
- TTL-based in-memory cache (60 s) to avoid redundant SDK queries per client instance
- Structured exception hierarchy for fine-grained error handling
- Branch selection — query any Infrahub branch (default: `main`)
- Full coverage of the fabric schema: devices, VLANs, BGP, VRFs, VTEP, EVPN, MLAG
## Installation
Ensure the required dependencies are installed:
```bash
uv add "infrahub-sdk>=0.16.0" "pydantic>=2.0"
```
Or via `pyproject.toml`:
```toml
dependencies = [
"infrahub-sdk>=0.16.0",
"pydantic>=2.0",
]
```
## Usage
### Basic connection
```python
import asyncio
from src.infrahub import FabricInfrahubClient
async def main():
async with FabricInfrahubClient(
url="http://infrahub:8080",
api_token="your-api-token",
branch="main", # optional, default: "main"
) as client:
device = await client.get_device("leaf1")
print(device.name, device.role, device.asn)
asyncio.run(main())
```
### `get_device()` — fetch a device intent
```python
from src.infrahub import FabricInfrahubClient, InfrahubNotFoundError
async with FabricInfrahubClient(url="http://infrahub:8080", api_token="tok") as client:
device = await client.get_device("leaf1")
# DeviceIntent(name='leaf1', role='leaf', status='active',
# platform='EOS', site='dc1', asn=65001)
print(f"{device.name} — role={device.role}, ASN={device.asn}")
```
### `get_device_vlans()` — fetch VLANs for a device
VLANs are resolved via the device's VTEP `vlan_vni_mappings`. If the device has
no VTEP, the fallback resolves VLANs through its SVI interfaces.
```python
vlans = await client.get_device_vlans("leaf1")
for vlan in vlans:
# VlanIntent(vlan_id=10, name='PROD', status='active',
# vlan_type='standard', vni=10010, stp_enabled=True)
print(f"VLAN {vlan.vlan_id} — VNI {vlan.vni}, type={vlan.vlan_type}")
```
### `get_device_bgp_config()` — fetch BGP router configuration
```python
bgp = await client.get_device_bgp_config("leaf1")
# BgpRouterConfigIntent(router_id='10.0.0.1', local_asn=65001,
# default_ipv4_unicast=True, ecmp_max_paths=4)
print(f"Router-ID: {bgp.router_id}, ASN: {bgp.local_asn}, ECMP: {bgp.ecmp_max_paths}")
```
### `get_device_bgp_peer_groups()` — fetch BGP peer groups
```python
peer_groups = await client.get_device_bgp_peer_groups("leaf1")
for pg in peer_groups:
# BgpPeerGroupIntent(name='EVPN-PEERS', peer_group_type='evpn',
# remote_asn=65000, update_source='Loopback0',
# send_community='extended', ebgp_multihop=3,
# next_hop_unchanged=True)
print(f"{pg.name} — type={pg.peer_group_type}, remote_asn={pg.remote_asn}")
```
### `get_device_bgp_sessions()` — fetch BGP sessions
```python
sessions = await client.get_device_bgp_sessions("leaf1")
for sess in sessions:
# BgpSessionIntent(peer_address='10.0.0.2', description='to-spine1',
# enabled=True, peer_group='UNDERLAY', remote_asn=65000)
print(f"{sess.peer_address} — group={sess.peer_group}, enabled={sess.enabled}")
```
### `get_device_vrfs()` — fetch VRFs assigned to a device
VRFs are resolved via `InfraVRFDeviceAssignment` (device-specific RD + route targets).
```python
vrfs = await client.get_device_vrfs("leaf1")
for vrf in vrfs:
# VrfIntent(name='PROD', route_distinguisher='10.0.0.1:100',
# vrf_id=100, l3vni=10000,
# import_targets=['65000:100'], export_targets=['65000:100'])
print(f"VRF {vrf.name} — RD={vrf.route_distinguisher}, L3VNI={vrf.l3vni}")
print(f" import: {vrf.import_targets}")
print(f" export: {vrf.export_targets}")
```
### `get_device_vtep()` — fetch VTEP configuration
Returns `None` if the device has no VTEP.
```python
vtep = await client.get_device_vtep("leaf1")
if vtep:
# VtepIntent(source_address='10.0.0.1', udp_port=4789,
# learn_restrict=False, vlan_vni_mappings=[(10, 10010), (20, 10020)])
print(f"VTEP source: {vtep.source_address}, port: {vtep.udp_port}")
for vlan_id, vni in vtep.vlan_vni_mappings:
print(f" VLAN {vlan_id} → VNI {vni}")
```
### `get_device_evpn_instances()` — fetch EVPN instances
```python
evpn_instances = await client.get_device_evpn_instances("leaf1")
for ev in evpn_instances:
# EvpnInstanceIntent(route_distinguisher='10.0.0.1:10',
# route_target_import='65000:10',
# route_target_export='65000:10',
# redistribute_learned=True, vlan_id=10)
print(f"EVPN VLAN {ev.vlan_id} — RD={ev.route_distinguisher}")
```
### `get_mlag_domain()` — fetch MLAG domain
Returns `None` if the device is not part of an MLAG domain.
```python
domain = await client.get_mlag_domain("leaf1")
if domain:
# MlagDomainIntent(domain_id='1', virtual_mac='00:1c:73:00:00:01',
# heartbeat_vrf='MGMT', dual_primary_detection=True,
# dual_primary_delay=10, dual_primary_action='errdisable',
# peer_devices=['leaf1', 'leaf2'])
peer = [d for d in domain.peer_devices if d != "leaf1"][0]
print(f"MLAG domain {domain.domain_id} — peer: {peer}")
```
### `get_mlag_peer_config()` — fetch MLAG peer configuration
Returns `None` if no MLAG peer config exists for this device.
```python
peer_cfg = await client.get_mlag_peer_config("leaf1")
if peer_cfg:
# MlagPeerConfigIntent(local_interface_ip='10.255.255.0/31',
# peer_address='10.255.255.1',
# heartbeat_peer_ip='192.168.0.2',
# peer_link='Port-Channel1')
print(f"Local IP: {peer_cfg.local_interface_ip}")
print(f"Peer: {peer_cfg.peer_address} via {peer_cfg.peer_link}")
```
### Querying a specific branch
```python
async with FabricInfrahubClient(
url="http://infrahub:8080",
api_token="tok",
branch="proposed-change",
) as client:
device = await client.get_device("leaf1")
```
## Pydantic Models
All models are **frozen** (`model_config = ConfigDict(frozen=True)`) — instances are
immutable and hashable.
| Model | Description |
|---|---|
| `DeviceIntent` | Network device — name, role, status, platform, site, ASN |
| `VlanIntent` | VLAN — vlan_id, name, status, vlan_type, VNI (if any), STP |
| `VniIntent` | VXLAN Network Identifier — vni, vni_type, description |
| `BgpRouterConfigIntent` | BGP router config — router_id, local ASN, ECMP, default IPv4 unicast |
| `BgpPeerGroupIntent` | BGP peer group — name, type, remote ASN, update-source, community, multihop |
| `BgpSessionIntent` | BGP session — peer address, description, enabled, peer group, remote ASN |
| `VrfIntent` | VRF — name, RD, VRF ID, L3VNI, import/export route targets |
| `VtepIntent` | VTEP — source address, UDP port, learn-restrict, VLAN→VNI mappings |
| `MlagDomainIntent` | MLAG domain — domain ID, virtual MAC, heartbeat VRF, dual-primary settings |
| `MlagPeerConfigIntent` | MLAG peer config — local IP, peer address, heartbeat IP, peer-link interface |
| `EvpnInstanceIntent` | EVPN instance — RD, RT import/export, redistribute-learned, VLAN ID |
## Error Handling
```python
from src.infrahub import (
FabricInfrahubClient,
InfrahubClientError,
InfrahubConnectionError,
InfrahubNotFoundError,
InfrahubQueryError,
)
async with FabricInfrahubClient(url="http://infrahub:8080", api_token="tok") as client:
try:
device = await client.get_device("unknown-device")
except InfrahubNotFoundError as e:
# Device does not exist in Infrahub
print(f"Not found: {e}")
except InfrahubQueryError as e:
# SDK query failed (network error, schema mismatch, etc.)
print(f"Query error: {e}")
except InfrahubConnectionError as e:
# Could not reach the Infrahub instance
print(f"Connection error: {e}")
except InfrahubClientError as e:
# Catch-all for any other client error
print(f"Infrahub error: {e}")
```
### Exception hierarchy
```
InfrahubClientError ← base for all client errors
├── InfrahubConnectionError ← raised when the instance is unreachable
├── InfrahubQueryError ← raised when an SDK query fails or returns unexpected data
└── InfrahubNotFoundError ← raised when a requested node does not exist
```
## Environment Variables
The `infrahub-sdk` library reads the following environment variables as defaults.
You can set them instead of passing values explicitly to `FabricInfrahubClient`.
| Variable | Description | Default |
|---|---|---|
| `INFRAHUB_ADDRESS` | Base URL of the Infrahub instance | — |
| `INFRAHUB_API_TOKEN` | API token for authentication | — |
| `INFRAHUB_DEFAULT_BRANCH` | Branch to query when none is specified | `main` |
> **Note:** `FabricInfrahubClient.__init__` always passes `url`, `api_token`, and `branch`
> explicitly to `infrahub_sdk.Config`, so environment variables only act as fallbacks
> at the SDK level when the client is initialised without arguments.
## Cache
Each `FabricInfrahubClient` instance maintains an in-memory TTL cache.
- **Scope:** per-instance (not shared across instances or processes)
- **TTL:** 60 seconds (constant `_CACHE_TTL` in `client.py`)
- **Key format:** `<method_name>:<device_name>` (e.g. `device:leaf1`, `bgp_config:leaf1`)
- **Behaviour:** on the first call the result is fetched from Infrahub and stored;
subsequent calls within the TTL window return the cached value without hitting the SDK
```python
async with FabricInfrahubClient(url="http://infrahub:8080", api_token="tok") as client:
d1 = await client.get_device("leaf1") # → SDK query
d2 = await client.get_device("leaf1") # → cache hit, no SDK call
assert d1 == d2
```
To bypass the cache, create a new `FabricInfrahubClient` instance.

24
src/infrahub/__init__.py Normal file
View File

@@ -0,0 +1,24 @@
"""
Infrahub client package for the Fabric Orchestrator.
Exports the main async client and exception hierarchy for use by the
Reconciler and Prefect flows.
"""
from __future__ import annotations
from .client import FabricInfrahubClient
from .exceptions import (
InfrahubClientError,
InfrahubConnectionError,
InfrahubNotFoundError,
InfrahubQueryError,
)
__all__ = [
"FabricInfrahubClient",
"InfrahubClientError",
"InfrahubConnectionError",
"InfrahubNotFoundError",
"InfrahubQueryError",
]

789
src/infrahub/client.py Normal file
View File

@@ -0,0 +1,789 @@
"""
Infrahub Client for Fabric Intent.
Async client wrapping `infrahub-sdk` to fetch fabric intent data from a remote
Infrahub instance. Replaces the previous NetBox/pynetbox client and is used by
the Reconciler and Prefect flows.
"""
from __future__ import annotations
import time
from typing import Any
from infrahub_sdk import Config, InfrahubClient
from .exceptions import InfrahubNotFoundError, InfrahubQueryError
from .models import (
BgpPeerGroupIntent,
BgpRouterConfigIntent,
BgpSessionIntent,
DeviceIntent,
EvpnInstanceIntent,
MlagDomainIntent,
MlagPeerConfigIntent,
VlanIntent,
VrfIntent,
VtepIntent,
)
# TTL for cache entries in seconds
_CACHE_TTL: int = 60
class FabricInfrahubClient:
"""
Async client for querying fabric intent data from a remote Infrahub instance.
Wraps `infrahub-sdk` with structured Pydantic model responses, error handling,
and a simple TTL-based cache to avoid redundant queries.
Usage as async context manager::
async with FabricInfrahubClient(url="http://infrahub:8080", api_token="xxx") as client:
device = await client.get_device("leaf1")
vlans = await client.get_device_vlans("leaf1")
"""
def __init__(self, url: str, api_token: str, branch: str = "main") -> None:
"""
Initialize the Infrahub client.
Args:
url: Base URL of the Infrahub instance (e.g. "http://infrahub:8080")
api_token: API token for authentication
branch: Default branch to query (default: "main")
"""
self._url = url
self._branch = branch
config = Config(address=url, api_token=api_token, default_branch=branch)
self._client = InfrahubClient(config=config)
self._cache: dict[str, tuple[Any, float]] = {}
async def __aenter__(self) -> FabricInfrahubClient:
"""Enter async context manager."""
return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
"""Exit async context manager."""
pass
# =========================================================================
# Cache helpers
# =========================================================================
def _cache_get(self, key: str) -> Any | None:
"""Return cached value if still valid, else None."""
if key in self._cache:
value, ts = self._cache[key]
if time.monotonic() - ts < _CACHE_TTL:
return value
del self._cache[key]
return None
def _cache_set(self, key: str, value: Any) -> None:
"""Store a value in the cache with the current timestamp."""
self._cache[key] = (value, time.monotonic())
# =========================================================================
# Device
# =========================================================================
async def get_device(self, name: str) -> DeviceIntent:
"""
Fetch a device by name and return a structured DeviceIntent.
Args:
name: Device name as stored in Infrahub
Returns:
DeviceIntent with device attributes and resolved relationship names
Raises:
InfrahubNotFoundError: When no device with the given name exists
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"device:{name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
node = await self._client.get(kind="InfraDevice", name__value=name)
except Exception as e:
msg = str(e).lower()
if "not found" in msg or "no node" in msg:
raise InfrahubNotFoundError(f"Device '{name}' not found in Infrahub") from e
raise InfrahubQueryError(f"Failed to query device '{name}': {e}") from e
if node is None:
raise InfrahubNotFoundError(f"Device '{name}' not found in Infrahub")
# Resolve cardinality-one relationships
platform: str | None = None
site: str | None = None
asn: int | None = None
try:
if node.platform.peer is None:
await node.platform.fetch()
if node.platform.peer is not None:
platform = node.platform.peer.name.value
except Exception:
pass
try:
if node.site.peer is None:
await node.site.fetch()
if node.site.peer is not None:
site = node.site.peer.name.value
except Exception:
pass
try:
if node.asn.peer is None:
await node.asn.fetch()
if node.asn.peer is not None:
asn = node.asn.peer.asn.value
except Exception:
pass
result = DeviceIntent(
name=node.name.value,
role=node.role.value,
status=node.status.value,
platform=platform,
site=site,
asn=asn,
)
self._cache_set(cache_key, result)
return result
# =========================================================================
# VLANs
# =========================================================================
async def get_device_vlans(self, device_name: str) -> list[VlanIntent]:
"""
Fetch all VLANs associated with a device via its VTEP's vlan_vni_mappings.
Falls back to VLANs linked through SVI interfaces if no VTEP is found.
Args:
device_name: Device name as stored in Infrahub
Returns:
List of VlanIntent objects for all VLANs associated with this device
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"device_vlans:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
vtep_nodes = await self._client.filters(kind="InfraVTEP", include=["vlan_vni_mappings"])
except Exception as e:
raise InfrahubQueryError(f"Failed to query VTEPs: {e}") from e
# Find VTEP belonging to this device
device_vtep = None
for vtep in vtep_nodes:
try:
if vtep.device.peer is None:
await vtep.device.fetch()
if vtep.device.peer is not None and vtep.device.peer.name.value == device_name:
device_vtep = vtep
break
except Exception:
continue
vlans: list[VlanIntent] = []
if device_vtep is not None:
for mapping_rel in device_vtep.vlan_vni_mappings.peers:
try:
mapping = mapping_rel.peer
await mapping.vlan.fetch()
vlan_node = mapping.vlan.peer
if vlan_node is None:
continue
vni_val: int | None = None
try:
await vlan_node.vni.fetch()
if vlan_node.vni.peer is not None:
vni_val = vlan_node.vni.peer.vni.value
except Exception:
pass
vlans.append(
VlanIntent(
vlan_id=vlan_node.vlan_id.value,
name=vlan_node.name.value,
status=vlan_node.status.value,
vlan_type=vlan_node.vlan_type.value,
vni=vni_val,
stp_enabled=vlan_node.stp_enabled.value,
)
)
except Exception:
continue
else:
# Fallback: discover VLANs via InfraInterfaceVlan → VLAN
try:
svi_nodes = await self._client.filters(kind="InfraInterfaceVlan")
for svi in svi_nodes:
try:
if svi.device.peer is None:
await svi.device.fetch()
if svi.device.peer is None or svi.device.peer.name.value != device_name:
continue
if svi.vlan.peer is None:
await svi.vlan.fetch()
vlan_node = svi.vlan.peer
if vlan_node is None:
continue
vni_val = None
try:
await vlan_node.vni.fetch()
if vlan_node.vni.peer is not None:
vni_val = vlan_node.vni.peer.vni.value
except Exception:
pass
vlans.append(
VlanIntent(
vlan_id=vlan_node.vlan_id.value,
name=vlan_node.name.value,
status=vlan_node.status.value,
vlan_type=vlan_node.vlan_type.value,
vni=vni_val,
stp_enabled=vlan_node.stp_enabled.value,
)
)
except Exception:
continue
except Exception as e:
raise InfrahubQueryError(
f"Failed to query SVI interfaces for device '{device_name}': {e}"
) from e
self._cache_set(cache_key, vlans)
return vlans
# =========================================================================
# BGP
# =========================================================================
async def _get_bgp_router_config_node(self, device_name: str) -> Any:
"""
Fetch the BGPRouterConfig node for a device.
Args:
device_name: Device name as stored in Infrahub
Returns:
The raw InfraBGPRouterConfig SDK node
Raises:
InfrahubNotFoundError: When no BGP config exists for this device
InfrahubQueryError: When the query fails unexpectedly
"""
try:
nodes = await self._client.filters(
kind="InfraBGPRouterConfig",
include=["peer_groups", "sessions"],
)
except Exception as e:
raise InfrahubQueryError(
f"Failed to query BGP router config for '{device_name}': {e}"
) from e
for node in nodes:
try:
if node.device.peer is None:
await node.device.fetch()
if node.device.peer is not None and node.device.peer.name.value == device_name:
return node
except Exception:
continue
raise InfrahubNotFoundError(f"No BGP router config found for device '{device_name}'")
async def get_device_bgp_config(self, device_name: str) -> BgpRouterConfigIntent:
"""
Fetch the BGP router configuration for a device.
Args:
device_name: Device name as stored in Infrahub
Returns:
BgpRouterConfigIntent with BGP router configuration
Raises:
InfrahubNotFoundError: When no BGP config exists for this device
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"bgp_config:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
node = await self._get_bgp_router_config_node(device_name)
local_asn: int = 0
try:
if node.local_asn.peer is None:
await node.local_asn.fetch()
if node.local_asn.peer is not None:
local_asn = node.local_asn.peer.asn.value
except Exception:
pass
result = BgpRouterConfigIntent(
router_id=node.router_id.value,
local_asn=local_asn,
default_ipv4_unicast=node.default_ipv4_unicast.value,
ecmp_max_paths=node.ecmp_max_paths.value,
)
self._cache_set(cache_key, result)
return result
async def get_device_bgp_peer_groups(self, device_name: str) -> list[BgpPeerGroupIntent]:
"""
Fetch all BGP peer groups for a device via its BGPRouterConfig.
Args:
device_name: Device name as stored in Infrahub
Returns:
List of BgpPeerGroupIntent objects
Raises:
InfrahubNotFoundError: When no BGP config exists for this device
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"bgp_peer_groups:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
bgp_node = await self._get_bgp_router_config_node(device_name)
peer_groups: list[BgpPeerGroupIntent] = []
for pg_rel in bgp_node.peer_groups.peers:
try:
pg = pg_rel.peer
remote_asn: int | None = None
try:
if pg.remote_asn.peer is None:
await pg.remote_asn.fetch()
if pg.remote_asn.peer is not None:
remote_asn = pg.remote_asn.peer.asn.value
except Exception:
pass
peer_groups.append(
BgpPeerGroupIntent(
name=pg.name.value,
peer_group_type=pg.peer_group_type.value,
remote_asn=remote_asn,
update_source=pg.update_source.value if pg.update_source.value else None,
send_community=pg.send_community.value,
ebgp_multihop=pg.ebgp_multihop.value if pg.ebgp_multihop.value else None,
next_hop_unchanged=pg.next_hop_unchanged.value,
)
)
except Exception:
continue
self._cache_set(cache_key, peer_groups)
return peer_groups
async def get_device_bgp_sessions(self, device_name: str) -> list[BgpSessionIntent]:
"""
Fetch all BGP sessions for a device via its BGPRouterConfig.
Args:
device_name: Device name as stored in Infrahub
Returns:
List of BgpSessionIntent objects
Raises:
InfrahubNotFoundError: When no BGP config exists for this device
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"bgp_sessions:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
bgp_node = await self._get_bgp_router_config_node(device_name)
sessions: list[BgpSessionIntent] = []
for sess_rel in bgp_node.sessions.peers:
try:
sess = sess_rel.peer
peer_group: str | None = None
try:
if sess.peer_group.peer is None:
await sess.peer_group.fetch()
if sess.peer_group.peer is not None:
peer_group = sess.peer_group.peer.name.value
except Exception:
pass
remote_asn: int | None = None
try:
if sess.remote_asn.peer is None:
await sess.remote_asn.fetch()
if sess.remote_asn.peer is not None:
remote_asn = sess.remote_asn.peer.asn.value
except Exception:
pass
sessions.append(
BgpSessionIntent(
peer_address=sess.peer_address.value,
description=sess.description.value if sess.description.value else None,
enabled=sess.enabled.value,
peer_group=peer_group,
remote_asn=remote_asn,
)
)
except Exception:
continue
self._cache_set(cache_key, sessions)
return sessions
# =========================================================================
# VRF
# =========================================================================
async def get_device_vrfs(self, device_name: str) -> list[VrfIntent]:
"""
Fetch all VRFs assigned to a device via VRFDeviceAssignment.
Args:
device_name: Device name as stored in Infrahub
Returns:
List of VrfIntent objects with resolved route targets
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"device_vrfs:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
assignments = await self._client.filters(
kind="InfraVRFDeviceAssignment",
include=["import_targets", "export_targets"],
)
except Exception as e:
raise InfrahubQueryError(
f"Failed to query VRF assignments for device '{device_name}': {e}"
) from e
vrfs: list[VrfIntent] = []
for asgn in assignments:
try:
if asgn.device.peer is None:
await asgn.device.fetch()
if asgn.device.peer is None or asgn.device.peer.name.value != device_name:
continue
if asgn.vrf.peer is None:
await asgn.vrf.fetch()
vrf_node = asgn.vrf.peer
if vrf_node is None:
continue
l3vni: int | None = None
try:
if vrf_node.l3vni.peer is None:
await vrf_node.l3vni.fetch()
if vrf_node.l3vni.peer is not None:
l3vni = vrf_node.l3vni.peer.vni.value
except Exception:
pass
import_targets: list[str] = []
for rt_rel in asgn.import_targets.peers:
try:
import_targets.append(rt_rel.peer.target.value)
except Exception:
pass
export_targets: list[str] = []
for rt_rel in asgn.export_targets.peers:
try:
export_targets.append(rt_rel.peer.target.value)
except Exception:
pass
rd = asgn.route_distinguisher.value if asgn.route_distinguisher.value else None
vrfs.append(
VrfIntent(
name=vrf_node.name.value,
route_distinguisher=rd,
vrf_id=vrf_node.vrf_id.value if vrf_node.vrf_id.value else None,
l3vni=l3vni,
import_targets=import_targets,
export_targets=export_targets,
)
)
except Exception:
continue
self._cache_set(cache_key, vrfs)
return vrfs
# =========================================================================
# VTEP
# =========================================================================
async def get_device_vtep(self, device_name: str) -> VtepIntent | None:
"""
Fetch the VTEP configuration for a device.
Args:
device_name: Device name as stored in Infrahub
Returns:
VtepIntent if a VTEP exists for this device, None otherwise
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"device_vtep:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
vtep_nodes = await self._client.filters(kind="InfraVTEP", include=["vlan_vni_mappings"])
except Exception as e:
raise InfrahubQueryError(f"Failed to query VTEPs: {e}") from e
for vtep in vtep_nodes:
try:
if vtep.device.peer is None:
await vtep.device.fetch()
if vtep.device.peer is None or vtep.device.peer.name.value != device_name:
continue
mappings: list[tuple[int, int]] = []
for mapping_rel in vtep.vlan_vni_mappings.peers:
try:
mapping = mapping_rel.peer
await mapping.vlan.fetch()
await mapping.vni.fetch()
vlan_node = mapping.vlan.peer
vni_node = mapping.vni.peer
if vlan_node is not None and vni_node is not None:
mappings.append((vlan_node.vlan_id.value, vni_node.vni.value))
except Exception:
continue
result = VtepIntent(
source_address=vtep.source_address.value,
udp_port=vtep.udp_port.value,
learn_restrict=vtep.learn_restrict.value,
vlan_vni_mappings=mappings,
)
self._cache_set(cache_key, result)
return result
except Exception:
continue
self._cache_set(cache_key, None)
return None
# =========================================================================
# EVPN Instances
# =========================================================================
async def get_device_evpn_instances(self, device_name: str) -> list[EvpnInstanceIntent]:
"""
Fetch all EVPN instances for a device.
Args:
device_name: Device name as stored in Infrahub
Returns:
List of EvpnInstanceIntent objects
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"evpn_instances:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
nodes = await self._client.filters(kind="InfraEVPNInstance")
except Exception as e:
raise InfrahubQueryError(
f"Failed to query EVPN instances for device '{device_name}': {e}"
) from e
instances: list[EvpnInstanceIntent] = []
for node in nodes:
try:
if node.device.peer is None:
await node.device.fetch()
if node.device.peer is None or node.device.peer.name.value != device_name:
continue
if node.vlan.peer is None:
await node.vlan.fetch()
vlan_node = node.vlan.peer
vlan_id: int = vlan_node.vlan_id.value if vlan_node is not None else 0
instances.append(
EvpnInstanceIntent(
route_distinguisher=node.route_distinguisher.value,
route_target_import=node.route_target_import.value,
route_target_export=node.route_target_export.value,
redistribute_learned=node.redistribute_learned.value,
vlan_id=vlan_id,
)
)
except Exception:
continue
self._cache_set(cache_key, instances)
return instances
# =========================================================================
# MLAG
# =========================================================================
async def get_mlag_domain(self, device_name: str) -> MlagDomainIntent | None:
"""
Fetch the MLAG domain that contains the given device.
Args:
device_name: Device name as stored in Infrahub
Returns:
MlagDomainIntent if an MLAG domain exists for this device, None otherwise
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"mlag_domain:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
nodes = await self._client.filters(kind="InfraMlagDomain", include=["devices"])
except Exception as e:
raise InfrahubQueryError(
f"Failed to query MLAG domains for device '{device_name}': {e}"
) from e
for node in nodes:
try:
peer_device_names: list[str] = []
found = False
for dev_rel in node.devices.peers:
try:
dev_name = dev_rel.peer.name.value
peer_device_names.append(dev_name)
if dev_name == device_name:
found = True
except Exception:
pass
if not found:
continue
result = MlagDomainIntent(
domain_id=node.domain_id.value,
virtual_mac=node.virtual_mac.value,
heartbeat_vrf=node.heartbeat_vrf.value,
dual_primary_detection=node.dual_primary_detection.value,
dual_primary_delay=node.dual_primary_delay.value,
dual_primary_action=node.dual_primary_action.value,
peer_devices=peer_device_names,
)
self._cache_set(cache_key, result)
return result
except Exception:
continue
self._cache_set(cache_key, None)
return None
async def get_mlag_peer_config(self, device_name: str) -> MlagPeerConfigIntent | None:
"""
Fetch the MLAG peer configuration for a device.
Args:
device_name: Device name as stored in Infrahub
Returns:
MlagPeerConfigIntent if MLAG peer config exists for this device, None otherwise
Raises:
InfrahubQueryError: When the query fails unexpectedly
"""
cache_key = f"mlag_peer_config:{device_name}"
cached = self._cache_get(cache_key)
if cached is not None:
return cached
try:
nodes = await self._client.filters(kind="InfraMlagPeerConfig")
except Exception as e:
raise InfrahubQueryError(
f"Failed to query MLAG peer configs for device '{device_name}': {e}"
) from e
for node in nodes:
try:
if node.device.peer is None:
await node.device.fetch()
if node.device.peer is None or node.device.peer.name.value != device_name:
continue
peer_link: str = ""
try:
if node.peer_link.peer is None:
await node.peer_link.fetch()
if node.peer_link.peer is not None:
peer_link = node.peer_link.peer.name.value
except Exception:
pass
result = MlagPeerConfigIntent(
local_interface_ip=node.local_interface_ip.value,
peer_address=node.peer_address.value,
heartbeat_peer_ip=node.heartbeat_peer_ip.value,
peer_link=peer_link,
)
self._cache_set(cache_key, result)
return result
except Exception:
continue
self._cache_set(cache_key, None)
return None

View File

@@ -0,0 +1,31 @@
"""
Infrahub Client Exceptions.
Custom exception hierarchy for the FabricInfrahubClient.
"""
from __future__ import annotations
class InfrahubClientError(Exception):
"""Base exception for all Infrahub client errors."""
pass
class InfrahubConnectionError(InfrahubClientError):
"""Raised when connection to the Infrahub instance fails."""
pass
class InfrahubQueryError(InfrahubClientError):
"""Raised when a query to Infrahub fails or returns unexpected data."""
pass
class InfrahubNotFoundError(InfrahubClientError):
"""Raised when a requested node is not found in Infrahub."""
pass

144
src/infrahub/models.py Normal file
View File

@@ -0,0 +1,144 @@
"""
Pydantic models for Infrahub fabric intent data.
These immutable models represent the structured intent retrieved from Infrahub
and are used throughout the orchestrator for typed access to fabric state.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class DeviceIntent(BaseModel):
"""Represents a network device from Infrahub."""
model_config = ConfigDict(frozen=True)
name: str
role: str
status: str
platform: str | None
site: str | None
asn: int | None
class VlanIntent(BaseModel):
"""Represents a VLAN from Infrahub."""
model_config = ConfigDict(frozen=True)
vlan_id: int
name: str
status: str
vlan_type: str
vni: int | None
stp_enabled: bool
class VniIntent(BaseModel):
"""Represents a VNI (VXLAN Network Identifier) from Infrahub."""
model_config = ConfigDict(frozen=True)
vni: int
vni_type: str
description: str | None
class BgpRouterConfigIntent(BaseModel):
"""Represents a BGP router configuration from Infrahub."""
model_config = ConfigDict(frozen=True)
router_id: str
local_asn: int
default_ipv4_unicast: bool
ecmp_max_paths: int
class BgpPeerGroupIntent(BaseModel):
"""Represents a BGP peer group from Infrahub."""
model_config = ConfigDict(frozen=True)
name: str
peer_group_type: str
remote_asn: int | None
update_source: str | None
send_community: str
ebgp_multihop: int | None
next_hop_unchanged: bool
class BgpSessionIntent(BaseModel):
"""Represents a BGP session from Infrahub."""
model_config = ConfigDict(frozen=True)
peer_address: str
description: str | None
enabled: bool
peer_group: str | None
remote_asn: int | None
class VrfIntent(BaseModel):
"""Represents a VRF from Infrahub."""
model_config = ConfigDict(frozen=True)
name: str
route_distinguisher: str | None
vrf_id: int | None
l3vni: int | None
import_targets: list[str]
export_targets: list[str]
class VtepIntent(BaseModel):
"""Represents a VTEP (VXLAN Tunnel Endpoint) from Infrahub."""
model_config = ConfigDict(frozen=True)
source_address: str
udp_port: int
learn_restrict: bool
vlan_vni_mappings: list[tuple[int, int]] # (vlan_id, vni)
class MlagDomainIntent(BaseModel):
"""Represents an MLAG domain from Infrahub."""
model_config = ConfigDict(frozen=True)
domain_id: str
virtual_mac: str
heartbeat_vrf: str
dual_primary_detection: bool
dual_primary_delay: int
dual_primary_action: str
peer_devices: list[str]
class MlagPeerConfigIntent(BaseModel):
"""Represents an MLAG peer configuration from Infrahub."""
model_config = ConfigDict(frozen=True)
local_interface_ip: str
peer_address: str
heartbeat_peer_ip: str
peer_link: str
class EvpnInstanceIntent(BaseModel):
"""Represents an EVPN instance from Infrahub."""
model_config = ConfigDict(frozen=True)
route_distinguisher: str
route_target_import: str
route_target_export: str
redistribute_learned: bool
vlan_id: int

View File

@@ -0,0 +1,617 @@
"""
Unit tests for FabricInfrahubClient.
All SDK interactions are mocked with AsyncMock — no real Infrahub instance required.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest # noqa: I001
from src.infrahub.client import FabricInfrahubClient
from src.infrahub.exceptions import InfrahubNotFoundError, InfrahubQueryError
from src.infrahub.models import (
BgpPeerGroupIntent,
BgpRouterConfigIntent,
BgpSessionIntent,
DeviceIntent,
EvpnInstanceIntent,
MlagDomainIntent,
MlagPeerConfigIntent,
VlanIntent,
VrfIntent,
VtepIntent,
)
# =============================================================================
# Helpers — mock node factories
# =============================================================================
def _attr(value):
"""Return a simple mock whose .value is set."""
m = MagicMock()
m.value = value
return m
def _rel_one(peer_mock):
"""Return a mock relationship (cardinality one) with an already-loaded peer."""
m = MagicMock()
m.peer = peer_mock
m.fetch = AsyncMock()
return m
def _rel_many(peer_mocks):
"""Return a mock relationship (cardinality many) whose .peers yields (peer_rel, peer)."""
peers = []
for p in peer_mocks:
rel = MagicMock()
rel.peer = p
peers.append(rel)
m = MagicMock()
m.peers = peers
return m
def make_device_node(
name="leaf1",
role="leaf",
status="active",
platform_name="EOS",
site_name="dc1",
asn=65001,
):
node = MagicMock()
node.name = _attr(name)
node.role = _attr(role)
node.status = _attr(status)
platform = MagicMock()
platform.name = _attr(platform_name)
node.platform = _rel_one(platform)
site = MagicMock()
site.name = _attr(site_name)
node.site = _rel_one(site)
asn_node = MagicMock()
asn_node.asn = _attr(asn)
node.asn = _rel_one(asn_node)
return node
def make_vlan_node(vlan_id=10, name="VLAN10", status="active", vlan_type="standard", vni=10010):
node = MagicMock()
node.vlan_id = _attr(vlan_id)
node.name = _attr(name)
node.status = _attr(status)
node.vlan_type = _attr(vlan_type)
node.stp_enabled = _attr(True)
vni_node = MagicMock()
vni_node.vni = _attr(vni)
node.vni = _rel_one(vni_node)
return node
def make_bgp_config_node(router_id="10.0.0.1", asn=65001, device_name="leaf1"):
node = MagicMock()
node.router_id = _attr(router_id)
node.default_ipv4_unicast = _attr(True)
node.ecmp_max_paths = _attr(4)
asn_node = MagicMock()
asn_node.asn = _attr(asn)
node.local_asn = _rel_one(asn_node)
device = MagicMock()
device.name = _attr(device_name)
node.device = _rel_one(device)
node.peer_groups = _rel_many([])
node.sessions = _rel_many([])
return node
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def mock_sdk_client():
"""Patch InfrahubClient and Config so no real connection is made."""
with (
patch("src.infrahub.client.InfrahubClient") as mock_cls,
patch("src.infrahub.client.Config"),
):
sdk = AsyncMock()
mock_cls.return_value = sdk
yield sdk
@pytest.fixture
def client(mock_sdk_client):
"""Return a FabricInfrahubClient backed by a mocked SDK."""
return FabricInfrahubClient(url="http://infrahub:8080", api_token="test-token", branch="main")
# =============================================================================
# Context manager
# =============================================================================
@pytest.mark.asyncio
async def test_context_manager(mock_sdk_client):
async with FabricInfrahubClient(url="http://infrahub:8080", api_token="test-token") as c:
assert isinstance(c, FabricInfrahubClient)
# =============================================================================
# get_device
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_returns_device_intent(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(return_value=make_device_node())
result = await client.get_device("leaf1")
assert isinstance(result, DeviceIntent)
assert result.name == "leaf1"
assert result.role == "leaf"
assert result.status == "active"
assert result.platform == "EOS"
assert result.site == "dc1"
assert result.asn == 65001
@pytest.mark.asyncio
async def test_get_device_not_found_raises(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(side_effect=Exception("not found"))
with pytest.raises(InfrahubNotFoundError):
await client.get_device("ghost-device")
@pytest.mark.asyncio
async def test_get_device_none_raises_not_found(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(return_value=None)
with pytest.raises(InfrahubNotFoundError):
await client.get_device("missing")
@pytest.mark.asyncio
async def test_get_device_query_error(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(side_effect=Exception("connection refused"))
with pytest.raises(InfrahubQueryError):
await client.get_device("leaf1")
# =============================================================================
# Caching
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_caches_result(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(return_value=make_device_node())
result1 = await client.get_device("leaf1")
result2 = await client.get_device("leaf1")
assert result1 == result2
# SDK should only be called once despite two client calls
mock_sdk_client.get.assert_called_once()
@pytest.mark.asyncio
async def test_cache_separate_keys_per_device(client, mock_sdk_client):
mock_sdk_client.get = AsyncMock(
side_effect=[
make_device_node(name="leaf1"),
make_device_node(name="leaf2", asn=65002),
]
)
r1 = await client.get_device("leaf1")
r2 = await client.get_device("leaf2")
assert r1.name == "leaf1"
assert r2.name == "leaf2"
assert mock_sdk_client.get.call_count == 2
# =============================================================================
# Branch selection
# =============================================================================
def test_branch_passed_to_config():
with (
patch("src.infrahub.client.InfrahubClient"),
patch("src.infrahub.client.Config") as mock_cfg,
):
FabricInfrahubClient(url="http://infrahub:8080", api_token="tok", branch="proposed-change")
mock_cfg.assert_called_once_with(
address="http://infrahub:8080",
api_token="tok",
default_branch="proposed-change",
)
# =============================================================================
# get_device_bgp_config
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_bgp_config(client, mock_sdk_client):
bgp_node = make_bgp_config_node(router_id="10.0.0.1", asn=65001, device_name="leaf1")
mock_sdk_client.filters = AsyncMock(return_value=[bgp_node])
result = await client.get_device_bgp_config("leaf1")
assert isinstance(result, BgpRouterConfigIntent)
assert result.router_id == "10.0.0.1"
assert result.local_asn == 65001
assert result.default_ipv4_unicast is True
assert result.ecmp_max_paths == 4
@pytest.mark.asyncio
async def test_get_device_bgp_config_not_found(client, mock_sdk_client):
mock_sdk_client.filters = AsyncMock(return_value=[])
with pytest.raises(InfrahubNotFoundError):
await client.get_device_bgp_config("leaf1")
# =============================================================================
# get_device_bgp_peer_groups
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_bgp_peer_groups(client, mock_sdk_client):
pg_node = MagicMock()
pg_node.name = _attr("EVPN-PEERS")
pg_node.peer_group_type = _attr("evpn")
pg_node.update_source = _attr("Loopback0")
pg_node.send_community = _attr("extended")
pg_node.ebgp_multihop = _attr(3)
pg_node.next_hop_unchanged = _attr(True)
remote_asn_node = MagicMock()
remote_asn_node.asn = _attr(65000)
pg_node.remote_asn = _rel_one(remote_asn_node)
bgp_node = make_bgp_config_node(device_name="leaf1")
bgp_node.peer_groups = _rel_many([pg_node])
mock_sdk_client.filters = AsyncMock(return_value=[bgp_node])
result = await client.get_device_bgp_peer_groups("leaf1")
assert len(result) == 1
pg = result[0]
assert isinstance(pg, BgpPeerGroupIntent)
assert pg.name == "EVPN-PEERS"
assert pg.peer_group_type == "evpn"
assert pg.remote_asn == 65000
assert pg.send_community == "extended"
assert pg.next_hop_unchanged is True
# =============================================================================
# get_device_bgp_sessions
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_bgp_sessions(client, mock_sdk_client):
sess_node = MagicMock()
sess_node.peer_address = _attr("10.0.0.2")
sess_node.description = _attr("to-spine1")
sess_node.enabled = _attr(True)
pg = MagicMock()
pg.name = _attr("UNDERLAY")
sess_node.peer_group = _rel_one(pg)
remote_asn = MagicMock()
remote_asn.asn = _attr(65000)
sess_node.remote_asn = _rel_one(remote_asn)
bgp_node = make_bgp_config_node(device_name="leaf1")
bgp_node.sessions = _rel_many([sess_node])
mock_sdk_client.filters = AsyncMock(return_value=[bgp_node])
result = await client.get_device_bgp_sessions("leaf1")
assert len(result) == 1
sess = result[0]
assert isinstance(sess, BgpSessionIntent)
assert sess.peer_address == "10.0.0.2"
assert sess.description == "to-spine1"
assert sess.enabled is True
assert sess.peer_group == "UNDERLAY"
assert sess.remote_asn == 65000
# =============================================================================
# get_device_vrfs
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_vrfs(client, mock_sdk_client):
rt_import = MagicMock()
rt_import.target = _attr("65000:100")
rt_export = MagicMock()
rt_export.target = _attr("65000:100")
vni_node = MagicMock()
vni_node.vni = _attr(10000)
vrf_node = MagicMock()
vrf_node.name = _attr("PROD")
vrf_node.vrf_id = _attr(100)
vrf_node.l3vni = _rel_one(vni_node)
asgn = MagicMock()
asgn.route_distinguisher = _attr("10.0.0.1:100")
asgn.vrf = _rel_one(vrf_node)
device = MagicMock()
device.name = _attr("leaf1")
asgn.device = _rel_one(device)
asgn.import_targets = _rel_many([rt_import])
asgn.export_targets = _rel_many([rt_export])
mock_sdk_client.filters = AsyncMock(return_value=[asgn])
result = await client.get_device_vrfs("leaf1")
assert len(result) == 1
vrf = result[0]
assert isinstance(vrf, VrfIntent)
assert vrf.name == "PROD"
assert vrf.route_distinguisher == "10.0.0.1:100"
assert vrf.vrf_id == 100
assert vrf.l3vni == 10000
assert vrf.import_targets == ["65000:100"]
assert vrf.export_targets == ["65000:100"]
# =============================================================================
# get_device_vtep
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_vtep(client, mock_sdk_client):
vlan_node = MagicMock()
vlan_node.vlan_id = _attr(10)
vni_node = MagicMock()
vni_node.vni = _attr(10010)
mapping = MagicMock()
mapping.vlan = _rel_one(vlan_node)
mapping.vni = _rel_one(vni_node)
mapping.vlan.fetch = AsyncMock()
mapping.vni.fetch = AsyncMock()
device = MagicMock()
device.name = _attr("leaf1")
vtep = MagicMock()
vtep.source_address = _attr("10.0.0.1")
vtep.udp_port = _attr(4789)
vtep.learn_restrict = _attr(False)
vtep.device = _rel_one(device)
vtep.vlan_vni_mappings = _rel_many([mapping])
mock_sdk_client.filters = AsyncMock(return_value=[vtep])
result = await client.get_device_vtep("leaf1")
assert isinstance(result, VtepIntent)
assert result.source_address == "10.0.0.1"
assert result.udp_port == 4789
assert result.learn_restrict is False
assert (10, 10010) in result.vlan_vni_mappings
@pytest.mark.asyncio
async def test_get_device_vtep_none_when_missing(client, mock_sdk_client):
mock_sdk_client.filters = AsyncMock(return_value=[])
result = await client.get_device_vtep("leaf1")
assert result is None
# =============================================================================
# get_device_evpn_instances
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_evpn_instances(client, mock_sdk_client):
vlan_node = MagicMock()
vlan_node.vlan_id = _attr(10)
device = MagicMock()
device.name = _attr("leaf1")
node = MagicMock()
node.route_distinguisher = _attr("10.0.0.1:10")
node.route_target_import = _attr("65000:10")
node.route_target_export = _attr("65000:10")
node.redistribute_learned = _attr(True)
node.device = _rel_one(device)
node.vlan = _rel_one(vlan_node)
mock_sdk_client.filters = AsyncMock(return_value=[node])
result = await client.get_device_evpn_instances("leaf1")
assert len(result) == 1
ev = result[0]
assert isinstance(ev, EvpnInstanceIntent)
assert ev.route_distinguisher == "10.0.0.1:10"
assert ev.route_target_import == "65000:10"
assert ev.route_target_export == "65000:10"
assert ev.redistribute_learned is True
assert ev.vlan_id == 10
# =============================================================================
# get_mlag_domain
# =============================================================================
@pytest.mark.asyncio
async def test_get_mlag_domain(client, mock_sdk_client):
dev1 = MagicMock()
dev1.name = _attr("leaf1")
dev2 = MagicMock()
dev2.name = _attr("leaf2")
domain = MagicMock()
domain.domain_id = _attr("1")
domain.virtual_mac = _attr("00:1c:73:00:00:01")
domain.heartbeat_vrf = _attr("MGMT")
domain.dual_primary_detection = _attr(True)
domain.dual_primary_delay = _attr(10)
domain.dual_primary_action = _attr("errdisable")
domain.devices = _rel_many([dev1, dev2])
mock_sdk_client.filters = AsyncMock(return_value=[domain])
result = await client.get_mlag_domain("leaf1")
assert isinstance(result, MlagDomainIntent)
assert result.domain_id == "1"
assert result.virtual_mac == "00:1c:73:00:00:01"
assert "leaf1" in result.peer_devices
assert "leaf2" in result.peer_devices
@pytest.mark.asyncio
async def test_get_mlag_domain_returns_none_when_not_member(client, mock_sdk_client):
dev_other = MagicMock()
dev_other.name = _attr("spine1")
domain = MagicMock()
domain.domain_id = _attr("99")
domain.devices = _rel_many([dev_other])
mock_sdk_client.filters = AsyncMock(return_value=[domain])
result = await client.get_mlag_domain("leaf1")
assert result is None
# =============================================================================
# get_mlag_peer_config
# =============================================================================
@pytest.mark.asyncio
async def test_get_mlag_peer_config(client, mock_sdk_client):
lag = MagicMock()
lag.name = _attr("Port-Channel1")
device = MagicMock()
device.name = _attr("leaf1")
node = MagicMock()
node.local_interface_ip = _attr("10.255.255.0/31")
node.peer_address = _attr("10.255.255.1")
node.heartbeat_peer_ip = _attr("192.168.0.2")
node.device = _rel_one(device)
node.peer_link = _rel_one(lag)
mock_sdk_client.filters = AsyncMock(return_value=[node])
result = await client.get_mlag_peer_config("leaf1")
assert isinstance(result, MlagPeerConfigIntent)
assert result.local_interface_ip == "10.255.255.0/31"
assert result.peer_address == "10.255.255.1"
assert result.heartbeat_peer_ip == "192.168.0.2"
assert result.peer_link == "Port-Channel1"
@pytest.mark.asyncio
async def test_get_mlag_peer_config_returns_none_when_missing(client, mock_sdk_client):
mock_sdk_client.filters = AsyncMock(return_value=[])
result = await client.get_mlag_peer_config("spine1")
assert result is None
# =============================================================================
# get_device_vlans
# =============================================================================
@pytest.mark.asyncio
async def test_get_device_vlans_via_vtep(client, mock_sdk_client):
vlan_node = make_vlan_node(vlan_id=10, name="VLAN10", vni=10010)
mapping = MagicMock()
mapping.vlan = _rel_one(vlan_node)
mapping.vlan.fetch = AsyncMock()
device = MagicMock()
device.name = _attr("leaf1")
vtep = MagicMock()
vtep.device = _rel_one(device)
vtep.vlan_vni_mappings = _rel_many([mapping])
mock_sdk_client.filters = AsyncMock(return_value=[vtep])
result = await client.get_device_vlans("leaf1")
assert len(result) == 1
assert isinstance(result[0], VlanIntent)
assert result[0].vlan_id == 10
assert result[0].vni == 10010
@pytest.mark.asyncio
async def test_get_device_vlans_caches_result(client, mock_sdk_client):
device = MagicMock()
device.name = _attr("leaf1")
vtep = MagicMock()
vtep.device = _rel_one(device)
vtep.vlan_vni_mappings = _rel_many([])
mock_sdk_client.filters = AsyncMock(return_value=[vtep])
r1 = await client.get_device_vlans("leaf1")
r2 = await client.get_device_vlans("leaf1")
assert r1 == r2
mock_sdk_client.filters.assert_called_once()

521
uv.lock generated
View File

@@ -2,6 +2,28 @@ version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "basedpyright"
version = "1.37.4"
@@ -80,63 +102,6 @@ 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" },
]
[[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]]
name = "click"
version = "8.3.1"
@@ -223,37 +188,88 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/47/ef/4cb333825d10317a36a1154341ba37e6e9c087bac99c1990ef07ffdb376f/dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595", size = 16754, upload-time = "2021-07-22T13:24:26.783Z" },
]
[[package]]
name = "dulwich"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/c4/bfe2a4e5b203f87ea925339691dd6e379e1a80d805dff0502e496bcaec39/dulwich-1.1.0.tar.gz", hash = "sha256:9aa855db9fee0a7065ae9ffb38e14e353876d82f17e33e1a1fb3830eb8d0cf43", size = 1178580, upload-time = "2026-02-17T21:20:05.388Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/b9/0c72c23fa4e09f4c7cc02b6930485ddbd62b636d4a9bf8beddb405d71d63/dulwich-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f6dd0c5fc45c84790d4a48d168d07f0aa817fcb879d2632e6cee603e98a843c", size = 1334617, upload-time = "2026-02-17T21:19:24.185Z" },
{ url = "https://files.pythonhosted.org/packages/6c/99/777f11d2cb476afbc8a4f31c22746d4d51202594ced18c530f963523ff6d/dulwich-1.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f8789e14981be2d33c3c36a14ec55ae06780c0a865e9df107016c4489a4a022a", size = 1410542, upload-time = "2026-02-17T21:19:25.673Z" },
{ url = "https://files.pythonhosted.org/packages/fd/2b/e7b0fdb6acafab035baebbebfd2413f7bb153e49fa0ae0956c10d7f1fb85/dulwich-1.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9a32f92c2eb86c84a175261f8fb983b6765bb31618d79d0c0dd68fab6f6ca94a", size = 1437268, upload-time = "2026-02-17T21:19:27.713Z" },
{ url = "https://files.pythonhosted.org/packages/64/1e/32d402b3c89973fa349e8e4a1710472f2706290e87300634578cdcf39b48/dulwich-1.1.0-cp312-cp312-win32.whl", hash = "sha256:06c18293fb2c715f035052f0c74f56e5ff52925ad4d0b5a0ebf16118daa5e340", size = 1003351, upload-time = "2026-02-17T21:19:29.187Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2e/eecefa69dad228daa4e81f75785fa0c55336034afee420bcf71dac5a3386/dulwich-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:e738163865dfccf155ef5fa3a2b2c849f38dadc6f009d2be355864233899bb4b", size = 1019251, upload-time = "2026-02-17T21:19:30.537Z" },
{ url = "https://files.pythonhosted.org/packages/39/e5/d694c3a7e093c1c007ad8d43241019e442f57a612e95d28b6a615c4e5ce1/dulwich-1.1.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3ba0cb28848dd8fd80d4389d1b83968da172376cea34f9bdb39043970fa1a045", size = 1434252, upload-time = "2026-02-17T21:19:32.024Z" },
{ url = "https://files.pythonhosted.org/packages/26/e6/878d39e847c8714e1acc725451815831eede17e73a0badce7f1d74cf8251/dulwich-1.1.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:8cf55f0de4cf90155aa3ab228c8ef9e7e10f7c785339f1688fb71f6adaae302c", size = 1434245, upload-time = "2026-02-17T21:19:33.584Z" },
{ url = "https://files.pythonhosted.org/packages/b6/12/df59ee8c545fa8bb80da4965a48459bb458a60b11b50b29a2dc28eb64b4b/dulwich-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:49c39844b4abe53612d18add7762faf886ade70384a101912e0849f56f885913", size = 1333720, upload-time = "2026-02-17T21:19:34.832Z" },
{ url = "https://files.pythonhosted.org/packages/53/4d/8c501844c069ba04e7a5d08a310011ae397f22d85211cb668bf9dd2562fe/dulwich-1.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:941735c87b3657019d197bb72f0e9ec03cbdbf959dc0869e672f5c6871597442", size = 1410876, upload-time = "2026-02-17T21:19:36.431Z" },
{ url = "https://files.pythonhosted.org/packages/f9/1a/b09f5849be7c3b2b38a71b4e8965087d3a9d6efeef0556681b534552726f/dulwich-1.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:37be136c7a85a64ae0cf8030f4fb2fa4860cff653ad3bcf13c49bf59fea2020c", size = 1436954, upload-time = "2026-02-17T21:19:38.477Z" },
{ url = "https://files.pythonhosted.org/packages/2a/28/77c53cd69d53d4d6779871bd1d5f420839178a453421add3e60ee814465f/dulwich-1.1.0-cp313-cp313-win32.whl", hash = "sha256:2f5a455e67f9ddd018299ce8dd05861a2696d35c6af91e9acdb4af0767bc0b8b", size = 1003251, upload-time = "2026-02-17T21:19:40.49Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0f/2c4f56cb823d2f3de947feea23966dd3ca1572cfe2ba62751f5f4d3b27af/dulwich-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b1bbb785f29f9eb51cddb9d80f82dac03939b7444961283b09adac19a823e88", size = 1018513, upload-time = "2026-02-17T21:19:41.935Z" },
{ url = "https://files.pythonhosted.org/packages/40/7b/b5f468c8448eb40a5f9b53c675c5b048e0b05a93ef13ceba61f80df25497/dulwich-1.1.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:fc38cc6f60c5e475fa61dcd2b743113f35377602c1ba1c82264898d97a7d3c48", size = 1433148, upload-time = "2026-02-17T21:19:43.897Z" },
{ url = "https://files.pythonhosted.org/packages/ea/30/b29af5c60423f5fee6e3a0ad604bb858c33dbe1ac44146443ac54d782667/dulwich-1.1.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:c9752d25f01e92587f8db52e50daf3e970deb49555340653ea44ba5e60f0f416", size = 1433143, upload-time = "2026-02-17T21:19:45.261Z" },
{ url = "https://files.pythonhosted.org/packages/a1/b9/36f537e6b651efbab9fc643f0805f255e818bcf8b638080f5c3dac03502f/dulwich-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:693c450a5d327a6a5276f5292d3dd0bc473066d2fd2a2d69a990d7738535deb6", size = 1334582, upload-time = "2026-02-17T21:19:46.637Z" },
{ url = "https://files.pythonhosted.org/packages/a8/33/56baecde3f996745b7a6092d8da60f32476d4318523a1fc121f53c9721ed/dulwich-1.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:dff1b67e0f76fcaae8f7345c05b1c4f00c11a6c42ace20864e80e7964af31827", size = 1429415, upload-time = "2026-02-17T21:19:48.589Z" },
{ url = "https://files.pythonhosted.org/packages/68/20/cc95e6d98d9e3b8e9d286f106033335438367e35c16f2246fe408122de89/dulwich-1.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:1b1b9adaf82301fd7b360a5fa521cec1623cb9d77a0c5a09d04396637b39eb48", size = 1436114, upload-time = "2026-02-17T21:19:50.186Z" },
{ url = "https://files.pythonhosted.org/packages/38/c4/a7ba0806acfa49ee5cdb5ed079dd807317168b1193f88083d044bdf2457a/dulwich-1.1.0-cp314-cp314-win32.whl", hash = "sha256:eb5440145bb2bbab71cdfa149fd297a8b7d4db889ab90c58d7a07009a73c1d28", size = 1010851, upload-time = "2026-02-17T21:19:51.607Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4a/bf512cbe5ff8b8e0d2e8bbcf3b3dd2da77f352cfae7433b5c1decf0dbd31/dulwich-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:333b0f93b289b14f98870317fb0583fdf73d5341f21fd09c694aa88bb06ad911", size = 1027524, upload-time = "2026-02-17T21:19:53.206Z" },
{ url = "https://files.pythonhosted.org/packages/de/0c/5f967cb285339e763e7694344e14a7a419c2b267a1e08ba530b995b1e3b6/dulwich-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a0f3421802225caedd11e95ce40f6a8d3c7a5df906489b6a5f49a20f88f62928", size = 1330559, upload-time = "2026-02-17T21:19:54.633Z" },
{ url = "https://files.pythonhosted.org/packages/fb/fd/9df384b61c0cb840b756d20778f50b376971ad73cbc709876244e55b9c8b/dulwich-1.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:518307ab080746ee9c32fc13e76ad4f7df8f7665bb85922e974037dd9415541a", size = 1408946, upload-time = "2026-02-17T21:19:57.179Z" },
{ url = "https://files.pythonhosted.org/packages/cf/5a/48ca834abdd51ba596ec520e083be452394b8c9c811c767db8c7c2f5c593/dulwich-1.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0890fff677c617efbac0cd4584bec9753388e6cd6336e7131338ea034b47e899", size = 1455138, upload-time = "2026-02-17T21:19:58.613Z" },
{ url = "https://files.pythonhosted.org/packages/5b/41/1458313472c4f3b6560b4cd4eff84ad0987ead6265a38261a391ab8b8596/dulwich-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:a05a1049b3928205672913f4c490cf7b08afaa3e7ee7e55e15476e696412672f", size = 1025585, upload-time = "2026-02-17T21:20:00.357Z" },
{ url = "https://files.pythonhosted.org/packages/37/15/a240999ea53cfbb40cf54e79e672f0f46f3b2544387d8c97b43336dac641/dulwich-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ba6f3f0807868f788b7f1d53b9ac0be3e425136b16563994f5ef6ecf5b7c7863", size = 1041927, upload-time = "2026-02-17T21:20:01.888Z" },
{ url = "https://files.pythonhosted.org/packages/16/4c/ba5b5f88b8e0dd43b2629aca66f472d1c3e03644da120a124586d7dc65ca/dulwich-1.1.0-py3-none-any.whl", hash = "sha256:bcd67e7f9bdffb4b660330c4597d251cd33e74f5df6898a2c1e6a1730a62af06", size = 673697, upload-time = "2026-02-17T21:20:03.346Z" },
]
[[package]]
name = "fabric-orchestrator"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "click" },
{ name = "infrahub-sdk" },
{ name = "pydantic" },
{ name = "pygnmi" },
{ name = "pynetbox" },
{ name = "rich" },
]
[package.dev-dependencies]
dev = [
{ name = "basedpyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "click", specifier = ">=8.1.0" },
{ name = "infrahub-sdk", specifier = ">=0.16.0" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pygnmi", specifier = ">=0.8.0" },
{ name = "pynetbox", specifier = ">=7.5.0" },
{ name = "rich", specifier = ">=13.0.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.37.4" },
{ name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
{ name = "ruff", specifier = ">=0.14.10" },
]
[[package]]
name = "graphql-core"
version = "3.2.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/9b/037a640a2983b09aed4a823f9cf1729e6d780b0671f854efa4727a7affbe/graphql_core-3.2.7.tar.gz", hash = "sha256:27b6904bdd3b43f2a0556dad5d579bdfdeab1f38e8e8788e555bdcb586a6f62c", size = 513484, upload-time = "2025-11-01T22:30:40.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/14/933037032608787fb92e365883ad6a741c235e0ff992865ec5d904a38f1e/graphql_core-3.2.7-py3-none-any.whl", hash = "sha256:17fc8f3ca4a42913d8e24d9ac9f08deddf0a0b2483076575757f6c412ead2ec0", size = 207262, upload-time = "2025-11-01T22:30:38.912Z" },
]
[[package]]
name = "grpcio"
version = "1.76.0"
@@ -295,6 +311,43 @@ 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" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
@@ -304,6 +357,34 @@ 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]]
name = "infrahub-sdk"
version = "1.18.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dulwich" },
{ name = "graphql-core" },
{ name = "httpx" },
{ name = "netutils" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "ujson" },
{ name = "whenever" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/1d/475819223ea2ee809329e896c3dce91abd1e4f29b12de292816666ad8e7a/infrahub_sdk-1.18.1.tar.gz", hash = "sha256:70768bcd9220170d327f26362969a62445af22c6ba915244c9441d5195d8ee47", size = 758835, upload-time = "2026-01-09T04:23:10.947Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/f1/a8ef0cc4fa5edbc71961dda666ca946da0a2d372fedad572aaf8c09ccf8d/infrahub_sdk-1.18.1-py3-none-any.whl", hash = "sha256:ce0f2497661d4924709268a72cb2ba6235208610a6f338dcaa40da21390160fc", size = 198117, upload-time = "2026-01-09T04:23:09.396Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -325,6 +406,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" },
]
[[package]]
name = "netutils"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/7f/e9e2415f9539c07a12c8ea5e59244b2912b1ebcf04f166f1bdab95fcfe88/netutils-1.17.1.tar.gz", hash = "sha256:354faa6ed9baa144e82dd1dadd7d2146070deb645fe4d79587f44cd1a6cb37be", size = 520861, upload-time = "2026-02-04T17:23:52.707Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c1/02da7e8c147ff95c67fa981651e7b532b8e3a2ace83ee70927c89c9e428e/netutils-1.17.1-py3-none-any.whl", hash = "sha256:7aea3c1e3881e92e3d0aa8776ecec74008c944cc25ff33a42c2557a7b9a7adb2", size = 535761, upload-time = "2026-02-04T17:23:51.083Z" },
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.13.0"
@@ -350,6 +440,15 @@ 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]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "protobuf"
version = "6.33.2"
@@ -374,6 +473,106 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@@ -399,31 +598,41 @@ wheels = [
]
[[package]]
name = "pynetbox"
version = "7.5.0"
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "requests" },
{ name = "pluggy" },
{ name = "pygments" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
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" },
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
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" }
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
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" },
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
@@ -474,6 +683,91 @@ 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" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]
name = "tzlocal"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]
[[package]]
name = "ujson"
version = "5.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583, upload-time = "2025-08-20T11:57:02.452Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/ef/a9cb1fce38f699123ff012161599fb9f2ff3f8d482b4b18c43a2dc35073f/ujson-5.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7895f0d2d53bd6aea11743bd56e3cb82d729980636cd0ed9b89418bf66591702", size = 55434, upload-time = "2025-08-20T11:55:34.987Z" },
{ url = "https://files.pythonhosted.org/packages/b1/05/dba51a00eb30bd947791b173766cbed3492269c150a7771d2750000c965f/ujson-5.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12b5e7e22a1fe01058000d1b317d3b65cc3daf61bd2ea7a2b76721fe160fa74d", size = 53190, upload-time = "2025-08-20T11:55:36.384Z" },
{ url = "https://files.pythonhosted.org/packages/03/3c/fd11a224f73fbffa299fb9644e425f38b38b30231f7923a088dd513aabb4/ujson-5.11.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0180a480a7d099082501cad1fe85252e4d4bf926b40960fb3d9e87a3a6fbbc80", size = 57600, upload-time = "2025-08-20T11:55:37.692Z" },
{ url = "https://files.pythonhosted.org/packages/55/b9/405103cae24899df688a3431c776e00528bd4799e7d68820e7ebcf824f92/ujson-5.11.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:fa79fdb47701942c2132a9dd2297a1a85941d966d8c87bfd9e29b0cf423f26cc", size = 59791, upload-time = "2025-08-20T11:55:38.877Z" },
{ url = "https://files.pythonhosted.org/packages/17/7b/2dcbc2bbfdbf68f2368fb21ab0f6735e872290bb604c75f6e06b81edcb3f/ujson-5.11.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8254e858437c00f17cb72e7a644fc42dad0ebb21ea981b71df6e84b1072aaa7c", size = 57356, upload-time = "2025-08-20T11:55:40.036Z" },
{ url = "https://files.pythonhosted.org/packages/d1/71/fea2ca18986a366c750767b694430d5ded6b20b6985fddca72f74af38a4c/ujson-5.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1aa8a2ab482f09f6c10fba37112af5f957689a79ea598399c85009f2f29898b5", size = 1036313, upload-time = "2025-08-20T11:55:41.408Z" },
{ url = "https://files.pythonhosted.org/packages/a3/bb/d4220bd7532eac6288d8115db51710fa2d7d271250797b0bfba9f1e755af/ujson-5.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a638425d3c6eed0318df663df44480f4a40dc87cc7c6da44d221418312f6413b", size = 1195782, upload-time = "2025-08-20T11:55:43.357Z" },
{ url = "https://files.pythonhosted.org/packages/80/47/226e540aa38878ce1194454385701d82df538ccb5ff8db2cf1641dde849a/ujson-5.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e3cff632c1d78023b15f7e3a81c3745cd3f94c044d1e8fa8efbd6b161997bbc", size = 1088817, upload-time = "2025-08-20T11:55:45.262Z" },
{ url = "https://files.pythonhosted.org/packages/7e/81/546042f0b23c9040d61d46ea5ca76f0cc5e0d399180ddfb2ae976ebff5b5/ujson-5.11.0-cp312-cp312-win32.whl", hash = "sha256:be6b0eaf92cae8cdee4d4c9e074bde43ef1c590ed5ba037ea26c9632fb479c88", size = 39757, upload-time = "2025-08-20T11:55:46.522Z" },
{ url = "https://files.pythonhosted.org/packages/44/1b/27c05dc8c9728f44875d74b5bfa948ce91f6c33349232619279f35c6e817/ujson-5.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:b7b136cc6abc7619124fd897ef75f8e63105298b5ca9bdf43ebd0e1fa0ee105f", size = 43859, upload-time = "2025-08-20T11:55:47.987Z" },
{ url = "https://files.pythonhosted.org/packages/22/2d/37b6557c97c3409c202c838aa9c960ca3896843b4295c4b7bb2bbd260664/ujson-5.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:6cd2df62f24c506a0ba322d5e4fe4466d47a9467b57e881ee15a31f7ecf68ff6", size = 38361, upload-time = "2025-08-20T11:55:49.122Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435, upload-time = "2025-08-20T11:55:50.243Z" },
{ url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193, upload-time = "2025-08-20T11:55:51.373Z" },
{ url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603, upload-time = "2025-08-20T11:55:52.583Z" },
{ url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794, upload-time = "2025-08-20T11:55:53.69Z" },
{ url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363, upload-time = "2025-08-20T11:55:54.843Z" },
{ url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311, upload-time = "2025-08-20T11:55:56.197Z" },
{ url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783, upload-time = "2025-08-20T11:55:58.081Z" },
{ url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822, upload-time = "2025-08-20T11:55:59.469Z" },
{ url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753, upload-time = "2025-08-20T11:56:01.345Z" },
{ url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866, upload-time = "2025-08-20T11:56:02.552Z" },
{ url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363, upload-time = "2025-08-20T11:56:03.688Z" },
{ url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462, upload-time = "2025-08-20T11:56:04.873Z" },
{ url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246, upload-time = "2025-08-20T11:56:06.054Z" },
{ url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631, upload-time = "2025-08-20T11:56:07.374Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877, upload-time = "2025-08-20T11:56:08.534Z" },
{ url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363, upload-time = "2025-08-20T11:56:09.758Z" },
{ url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394, upload-time = "2025-08-20T11:56:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837, upload-time = "2025-08-20T11:56:12.6Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837, upload-time = "2025-08-20T11:56:14.15Z" },
{ url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022, upload-time = "2025-08-20T11:56:15.509Z" },
{ url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111, upload-time = "2025-08-20T11:56:16.597Z" },
{ url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682, upload-time = "2025-08-20T11:56:17.763Z" },
{ url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759, upload-time = "2025-08-20T11:56:18.882Z" },
{ url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634, upload-time = "2025-08-20T11:56:20.012Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547, upload-time = "2025-08-20T11:56:21.175Z" },
{ url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489, upload-time = "2025-08-20T11:56:22.342Z" },
{ url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035, upload-time = "2025-08-20T11:56:23.92Z" },
{ url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212, upload-time = "2025-08-20T11:56:25.274Z" },
{ url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500, upload-time = "2025-08-20T11:56:27.517Z" },
{ url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487, upload-time = "2025-08-20T11:56:29.07Z" },
{ url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859, upload-time = "2025-08-20T11:56:30.495Z" },
{ url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183, upload-time = "2025-08-20T11:56:31.574Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264, upload-time = "2025-08-20T11:56:32.773Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
@@ -482,3 +776,86 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6
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" },
]
[[package]]
name = "whenever"
version = "0.9.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "tzlocal", marker = "sys_platform != 'darwin' and sys_platform != 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/9a/6b12d5708a703010877bec0efc5172ae8a851516abc13cd4543ce4d02a20/whenever-0.9.5.tar.gz", hash = "sha256:9d8f2fbc70acdab98a99b81a2ac594ebd4cc68d5b3506b990729a5f0b04d0083", size = 259436, upload-time = "2026-01-11T19:47:51.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/7d/f05ea580a1c99575c82a1b15d6c64826cb0e53101ad06020df109a51aa29/whenever-0.9.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:59c65af262738a9f649e008d51bbba506223ca7a7f2a5c935b9b0975f58227ed", size = 467312, upload-time = "2026-01-11T19:47:20.275Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6d/15102f4703ce111b1f1066afb2d62c40575ad2be91c7896442a77bf4d937/whenever-0.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a957d5e2bb652cb802eee2e1683c62023a9ba696e82483c4629b829f6c16fb5d", size = 438830, upload-time = "2026-01-11T19:47:10.453Z" },
{ url = "https://files.pythonhosted.org/packages/1c/cb/a603e92c85b3d70b64380e737d080329fd731d3ab2ef30475d099bb1f85a/whenever-0.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5e6da8f343a7522f1b2830b0e365e5417086da7a67d3086fbfc83d332b55c56", size = 453060, upload-time = "2026-01-11T19:45:28.885Z" },
{ url = "https://files.pythonhosted.org/packages/b0/36/a9f07ec85ddc415f3500d00acdfeb5b63a6dcaba91f2802e1061f4a32240/whenever-0.9.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:767f34bd6dd209afaebebb481de3af03d9a61271f5455c7e4dffedfac63ed6e9", size = 498406, upload-time = "2026-01-11T19:45:48.771Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f8/bb0891fce7605b44d0dc4d0a09c27dd8408e75e90c1f1adff1a3ef6ab0c0/whenever-0.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42d707e3f2c07a0c688c27c16f3e9bbe56551b72ec1fb9bc8df32b8d45e54520", size = 487042, upload-time = "2026-01-11T19:46:08.306Z" },
{ url = "https://files.pythonhosted.org/packages/66/35/3ea67d9e4fbebb619dc7801d678b3b40408586619a8bf0a18c7d20e9dc91/whenever-0.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e0a004b2b143146dbfafcf023b7bf007333e73c2d0955f44762a3131cbfcb47", size = 521880, upload-time = "2026-01-11T19:46:18.987Z" },
{ url = "https://files.pythonhosted.org/packages/62/5f/24f6c09a4f1e66b9257b88711164cad6262dc8c8d51d1a758d144e26f249/whenever-0.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9b760e8968f1d2681687dfd0ac06d7cb310603710faad0b85d3d9b86ac792f3", size = 483374, upload-time = "2026-01-11T19:46:48.425Z" },
{ url = "https://files.pythonhosted.org/packages/93/af/335af2917c6fdb58fd01dcf4922dd5d251ce70387d9bea60e723d45a01ae/whenever-0.9.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e7fb3da8014c3170b53a629b278105bcb3679c0835db7d88c27798058686181a", size = 521134, upload-time = "2026-01-11T19:46:28.778Z" },
{ url = "https://files.pythonhosted.org/packages/dc/05/fa3c60413befbd2c5c52fa9c65b840bcb4d73e8ed32e2df69696640481cc/whenever-0.9.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ce9a6d8d05db2aefb13b0f49d4a581b13035f966c967cd3e7f98a2bb6016ab66", size = 636617, upload-time = "2026-01-11T19:45:38.49Z" },
{ url = "https://files.pythonhosted.org/packages/3c/56/63668cd8ae23c0219e75b254ba926425d44ddb3482f6c95aac30b00cee2f/whenever-0.9.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9c52bd3ff2246e9c03d8e72c5d28044f35070b7c23d44b93592cd5b61c45f36", size = 769639, upload-time = "2026-01-11T19:45:57.548Z" },
{ url = "https://files.pythonhosted.org/packages/25/ff/3781f3867897974b39eea74a83f337479d98c76cf74608b8b6fa9c7b59a7/whenever-0.9.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77086386feba0bee7bc030b86e118a521c18b3741ad3c69e0c8e7b601a763a2a", size = 733169, upload-time = "2026-01-11T19:46:38.25Z" },
{ url = "https://files.pythonhosted.org/packages/e6/85/09d9e4e17ef78b380e57c6b89274fa37c8b250321d5e09f9efa8a58c941e/whenever-0.9.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:45ae1ce8bbb3668f6281251a0d22ac81bdf563909306b0d69eaa630ceb1051b9", size = 696787, upload-time = "2026-01-11T19:46:58.887Z" },
{ url = "https://files.pythonhosted.org/packages/0b/96/78408214f359016c0153f103decc6e3566426f809ea0c250d638b4315de6/whenever-0.9.5-cp312-cp312-win32.whl", hash = "sha256:6f2d87720d45a891f606420fcc073796740266f1bca718607569d44b7e0e6639", size = 416143, upload-time = "2026-01-11T19:47:30.894Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/ef3898377abf417a9e30692c1299cb0623f1f42ec6f8d8ba07809ca58eab/whenever-0.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:a5bfb110948bc668728dbb87f8985e6004dedf37ed910a946ddbe31985ada006", size = 440123, upload-time = "2026-01-11T19:47:41.822Z" },
{ url = "https://files.pythonhosted.org/packages/48/c7/f5626d4cc477116e6ec3191515981a60cdd021adc97046cb422f6bed327c/whenever-0.9.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d7de2cac536fe28f012928b74e0a3ed15dd8f4ac9940cfce35104cca345937f0", size = 467317, upload-time = "2026-01-11T19:47:21.582Z" },
{ url = "https://files.pythonhosted.org/packages/ea/81/d59f0e226ef542fc4bc86567d7b9e2bf9016c353b1f83661ee3913a140a7/whenever-0.9.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e00bc8f93fa469c630aad9dfdc538587c28891d6a4dce2f0b08628d5a108a219", size = 438838, upload-time = "2026-01-11T19:47:11.553Z" },
{ url = "https://files.pythonhosted.org/packages/b5/dc/090732e6e75f15a6084700d3247db6aa1f885971b637531529c62c4ba1c6/whenever-0.9.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ac83555db44e1fcfc032114f45c09af0ed9d641380672c8deb7f1131a0fd783", size = 453069, upload-time = "2026-01-11T19:45:30.238Z" },
{ url = "https://files.pythonhosted.org/packages/ab/f9/b4744370a9491689fc46327a17e3246a9e107b19efa0e24f51d9dcbe6aeb/whenever-0.9.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c486b1db0b833a007e2b8264f265079e57593925131b83ab69514fb9563b8d9", size = 498413, upload-time = "2026-01-11T19:45:49.811Z" },
{ url = "https://files.pythonhosted.org/packages/62/78/45ef94ef51cb97c514650c2af33cdfc3de3cf967fcf12955c0e3a430a6cf/whenever-0.9.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a57e8f207b5ad9b0c6bcd3efee38d65a70434c1bdcb95cc5d2e2b7a5237c208", size = 487043, upload-time = "2026-01-11T19:46:09.528Z" },
{ url = "https://files.pythonhosted.org/packages/6b/23/f45082a60471ee79edc5bf5c09c1d4f55324a2c740fb138a3a691f19d2b6/whenever-0.9.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e1d66a0ece1dabd6952eb7b03a555843bada42bed4683ea71b113f194714144", size = 521879, upload-time = "2026-01-11T19:46:20.093Z" },
{ url = "https://files.pythonhosted.org/packages/56/5c/8d6dc529595b5387f5727cd6c2c5b8615851d95fec5c599a61ef239cc1b3/whenever-0.9.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4056aaff273a579f0294e5397a0d198f52906bbaf7171da0a12ecd8cdf5026c", size = 483388, upload-time = "2026-01-11T19:46:49.746Z" },
{ url = "https://files.pythonhosted.org/packages/29/52/366c4658286a5986acb76473e9e21457af2bd1b53dc050cee00160106eaa/whenever-0.9.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9b0e7c669be2897108895f3283e23fe761782c9c1abb0ce8834e57e09ce5cdfd", size = 521135, upload-time = "2026-01-11T19:46:30.122Z" },
{ url = "https://files.pythonhosted.org/packages/9a/cb/82bbe5ce0879dd38d201214323afad418f1d721111311807c75aaf2e2d16/whenever-0.9.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e0c7a146da12c37d68387c639f957e3624cf7a55a1d534788d5aea748a261e7", size = 636617, upload-time = "2026-01-11T19:45:39.656Z" },
{ url = "https://files.pythonhosted.org/packages/ec/32/8dcb6facf229b8e2b5c73d0c50adf800593769e34ab5abcd77828451c4ed/whenever-0.9.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70c5f3013d6a1b7c40adbc05bc886e017f2b981739aaceafce47069da5b7e617", size = 769643, upload-time = "2026-01-11T19:45:59.047Z" },
{ url = "https://files.pythonhosted.org/packages/eb/40/6ab4308550851209982b78692d14a9729bf20b6e814ddedf2658e46a6cd8/whenever-0.9.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a8ce45c7271b85292e9d25e802543942b0249e73976c8614d676f0b561b25420", size = 733170, upload-time = "2026-01-11T19:46:39.457Z" },
{ url = "https://files.pythonhosted.org/packages/47/38/7ad290229e8df99107d04e21ecef9d09f215428ab3aa06c0e46d51cba860/whenever-0.9.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f1db334becda2d7895bc31373b1e53ff73e3a0ea208e2845b4f946ad9a5e667", size = 696797, upload-time = "2026-01-11T19:47:00.001Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8e/94c4fba5ef73bb817c557e19bb75665cb5761e4badfb60950bd4d3cc5a01/whenever-0.9.5-cp313-cp313-win32.whl", hash = "sha256:4079eabfa21d1418bcc2c3d605e780cfec0ad8161317e12fa0f1ef87cb08fe7d", size = 416152, upload-time = "2026-01-11T19:47:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/81/b4/17d4bc76ca73c21eb5b7883d10d8bacb7ce7a30a8f36501db2373c63ffb3/whenever-0.9.5-cp313-cp313-win_amd64.whl", hash = "sha256:16497a2b889aeeb0ee80a0d3b9ce14cdb63d7eb7d904e003aae3cd4ac67da1e8", size = 440135, upload-time = "2026-01-11T19:47:42.963Z" },
{ url = "https://files.pythonhosted.org/packages/30/d3/13db725dfef188bec32e51128e888a869ee97d26d965cfe39edf1321ca8f/whenever-0.9.5-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f222b1d59cdeadc1669657198e572727db8640e0b534e4e1ad0491417ec98379", size = 466415, upload-time = "2026-01-11T19:47:22.7Z" },
{ url = "https://files.pythonhosted.org/packages/a6/e9/4a9deeb616fbdf8e0c75fc78f13c37270d1d7e7ec776ffdf306262b8c593/whenever-0.9.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6f4ae436a4db888cd5b48a88fb83ae3f70c468a7d0018cc9572e26e99b9f591f", size = 437247, upload-time = "2026-01-11T19:47:13.1Z" },
{ url = "https://files.pythonhosted.org/packages/15/02/21866bddf5f819f1a3a59463f4637755590cb4045d25a50fb6ab60db97a6/whenever-0.9.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68b7c201fc49123159b1518147e48791e95a19cba0b3ebb646a446eb7e95148f", size = 451556, upload-time = "2026-01-11T19:45:31.645Z" },
{ url = "https://files.pythonhosted.org/packages/51/14/2c84db8f23577ddfd6ecd90c8d192d21dfa75aea57cda44eb53048b27e69/whenever-0.9.5-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5d2a8dd9c565eacb199ff704d96fc95e74f6c324639d970cac3b4d80eefbaebb", size = 498034, upload-time = "2026-01-11T19:45:50.926Z" },
{ url = "https://files.pythonhosted.org/packages/cc/37/cb69a10573382bbfd14954b5386bf5c47290551246d497a9aa733a5b5a8a/whenever-0.9.5-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d965ee9dafe1eb37ec273f7520e26b70d8b1bb075c06b0bb9e434363ca66fd", size = 487381, upload-time = "2026-01-11T19:46:10.752Z" },
{ url = "https://files.pythonhosted.org/packages/47/c4/fcc0f886322f72c5d83ee669ed1aa253c4e58625e5bf64fede03a69aa7a0/whenever-0.9.5-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035594337920cc9b9eb4531a97037971c109cc60e9a7a53e49e65722a1db7535", size = 520975, upload-time = "2026-01-11T19:46:21.789Z" },
{ url = "https://files.pythonhosted.org/packages/29/cc/ad7e9c225a97be16b97a20c46802991908a12f9920d83071d669ed7ed79c/whenever-0.9.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26c5b04d6fcb0e4ee9f6e62e4400fd99c5f188449314f5863a865be33a653984", size = 481911, upload-time = "2026-01-11T19:46:50.87Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/4fa12c102d6aa3190ce1f34dc9f709776e9c3b1537328c46b6444b9a1638/whenever-0.9.5-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04755e5a2b802451332d2a18bf1c45d74f99cd16e61a0724fe763907f32cf7f8", size = 520068, upload-time = "2026-01-11T19:46:31.175Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e7/08d7c7e59130e8379240fda96cd68ae60435f6d6b6e356a5900c6856e48f/whenever-0.9.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f7a37894b9cfc1e68b23f25d19ac38da68454f6bf782eab4cfac58578092daa", size = 635235, upload-time = "2026-01-11T19:45:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/c1/6d/49c5566c5560606659dfed8dd49cadfa4d6a14b3f0d7a3d5a46a0c4e5624/whenever-0.9.5-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55f10416bed357e0d1d1e4bf5e625974fe0a01348117161b4a0d2a69361c0efc", size = 769640, upload-time = "2026-01-11T19:46:00.112Z" },
{ url = "https://files.pythonhosted.org/packages/b8/09/e05cdc29897b97f3e3c0ffd485bf165f779c6d89bc4c0c0a76592e7c2e8c/whenever-0.9.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:75bfb8c778db727230fa5e7e2e256615be287d00cb1132b93789525e4d1c5dc6", size = 731095, upload-time = "2026-01-11T19:46:40.693Z" },
{ url = "https://files.pythonhosted.org/packages/1c/d1/dcb635c2951ed5a112d340f73bf3cc81d69d1af7c0b1a33d0ca9d3299a26/whenever-0.9.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdc5ca9cddc9264d587469d994e046ae9c55cfdb6b9048927fe2922ae8763a1", size = 694782, upload-time = "2026-01-11T19:47:01.532Z" },
{ url = "https://files.pythonhosted.org/packages/56/06/1544751be5e96aeefb59b5fedc1f8f4b0f238197ce21496d5960da20f18c/whenever-0.9.5-cp313-cp313t-win32.whl", hash = "sha256:274b7acfdc1fdfb29ab55299ce0a90cabedd079ff9940e24d05e3de4b04e1638", size = 415984, upload-time = "2026-01-11T19:47:33.34Z" },
{ url = "https://files.pythonhosted.org/packages/ab/0e/c87cf223816731718e5fef792b1f8c6d124c9fcbec677d4b7c9fcfe8f44f/whenever-0.9.5-cp313-cp313t-win_amd64.whl", hash = "sha256:7c9429fa1249aa39385716644453d9a4de0974b537974988b175d3ba7175a43f", size = 438641, upload-time = "2026-01-11T19:47:44.295Z" },
{ url = "https://files.pythonhosted.org/packages/d7/e4/5e78fc3b8a2bc84e0f055a89a9f4eabc06bc435463ea112265e1e73592f5/whenever-0.9.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e381120682f6675cccbcba13b4c88b033f31167f820c161936fa99556e455f03", size = 468861, upload-time = "2026-01-11T19:47:24.176Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a3/bd597dff8c82b2a1568813015ff25f3df2b6817872c8061176f04e3aad0f/whenever-0.9.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:caa7be36a609d765b9ea1c49159c446521b6ac33ff60b67214aa019713e05dda", size = 441322, upload-time = "2026-01-11T19:47:14.184Z" },
{ url = "https://files.pythonhosted.org/packages/82/3c/fcb3a85cfdc12783c22d44640be538f78bcc3946cc3fb454139cdaebd119/whenever-0.9.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:037fd64e27df6116c3ffe8e96cdb6562051b0eda3f0bc7c35068fe642a913da9", size = 455230, upload-time = "2026-01-11T19:45:32.978Z" },
{ url = "https://files.pythonhosted.org/packages/e9/17/83c83dddb55f7576f3748b077774d2bd8521bc2220bd2fa6f711ad8f046e/whenever-0.9.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c864363a2ff97c0b661915112140b9229132586d035ee4989cbe914aa3e3df4", size = 499473, upload-time = "2026-01-11T19:45:52.257Z" },
{ url = "https://files.pythonhosted.org/packages/07/11/406d26cf641ef9e3cdd0f11237a2a49b06f225c5f57257850a2e101c8760/whenever-0.9.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3d2c3f7bc9ceaef7d58fb40f0a4602a1947691eac1bff2623d2f923d4374543", size = 488680, upload-time = "2026-01-11T19:46:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/b5/15/7d85e7f5710fc4dfaec830da0b069791ca6b7108939ab06a0440cfd11f7a/whenever-0.9.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce45c364734e63a459b3b816f1ce159bb4d4f570faa2b0fa504d6d329dd654a3", size = 522715, upload-time = "2026-01-11T19:46:22.835Z" },
{ url = "https://files.pythonhosted.org/packages/3f/53/ec6eb7d71624437571ed22c9fbfa7c8532b6868c94138e0775f61c69101c/whenever-0.9.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8fc8e6e500f60299be739e19ffb830b2d9f50da017e14cd832b31f31ee93166", size = 484518, upload-time = "2026-01-11T19:46:51.971Z" },
{ url = "https://files.pythonhosted.org/packages/17/48/220d4a7335d91face9e5c16ec5228ad454ff58cfb3713c291c90d54b3e84/whenever-0.9.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b422bb8651f094f4420749f4c0d72287121480a495c6d247035bd52a72c4586c", size = 522611, upload-time = "2026-01-11T19:46:32.224Z" },
{ url = "https://files.pythonhosted.org/packages/be/fd/d1b270585f459f469af7c08e03ba2bcfe058449162354bf809e671ccfb1e/whenever-0.9.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:87635742acdd0adbbed62c6ed2ec4a49e25b1e3c7374f13f39d2c5f9229ae24f", size = 638421, upload-time = "2026-01-11T19:45:42.843Z" },
{ url = "https://files.pythonhosted.org/packages/2f/0c/7fc33cdb8da7d2b14cd7129f37b48c1aa91644eabc07780333979bdf15f6/whenever-0.9.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1ae39fff83ad73ec563db65707e5bde1e74b350bf4309cfb265d527a6cd222c6", size = 770967, upload-time = "2026-01-11T19:46:01.614Z" },
{ url = "https://files.pythonhosted.org/packages/fc/39/a379dcf478136619050cee1ed789a9bdb0a6537fe8c43b23ea832e38c8ce/whenever-0.9.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae5b01eb93c3ad703d7870d9e6566c389b889e97d7efec30c73abf64aa95b47f", size = 735292, upload-time = "2026-01-11T19:46:41.75Z" },
{ url = "https://files.pythonhosted.org/packages/09/a7/139ac3b81239f1d6275e8eb792c52a48adc6ba303b7e0c9d8ca405a86c76/whenever-0.9.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5e278daa1e650958f9ba7084b1c3c245444acf3e30db9cca5141658347a7c67d", size = 697998, upload-time = "2026-01-11T19:47:02.681Z" },
{ url = "https://files.pythonhosted.org/packages/2e/37/141041e40f76dd530183c715faae1d8b21e47867fe159b8b88c4960eae2d/whenever-0.9.5-cp314-cp314-win32.whl", hash = "sha256:1b62c62a00bd93e51f71d231aef3178f1c22566b2818c190e755c96fd939b91a", size = 418248, upload-time = "2026-01-11T19:47:34.855Z" },
{ url = "https://files.pythonhosted.org/packages/4b/7b/c86657bec1a679042c239a247bc3e6a92f3acc53ec858d13c1e770777f41/whenever-0.9.5-cp314-cp314-win_amd64.whl", hash = "sha256:483c2736ce367dbda01133700b064dfa8b6ed24833fc984e1d767e81aa02a189", size = 442418, upload-time = "2026-01-11T19:47:45.727Z" },
{ url = "https://files.pythonhosted.org/packages/55/3b/2d37c3438214a1bf6caddddf825d5ff86e6cfcdf91a41fa1175334a2bab0/whenever-0.9.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7644b6c35f9c1fa7b7f6cb03bf5764207374bf3e2049cb49e7578648b0d27dd9", size = 468132, upload-time = "2026-01-11T19:47:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/ba/0d/284336cf592107ee65c61030f1ca9717214365661d642aadf9fbce38bf8d/whenever-0.9.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be31d5799b49e593da413cddc73728a51d0e80461262c7a4ed66b95819a69016", size = 438449, upload-time = "2026-01-11T19:47:15.283Z" },
{ url = "https://files.pythonhosted.org/packages/5a/50/a13b1869b06a8838fab79e31707bf882f836764dcf1260ce170c1084011b/whenever-0.9.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6691e1157026773354c77771cd72db727bbd0ee36121cd139cf16f03cddc1699", size = 452750, upload-time = "2026-01-11T19:45:34.047Z" },
{ url = "https://files.pythonhosted.org/packages/79/9d/3b9006df1289c78a2e3b8bdd6498430ffee57cb851c938a9da9a7f2ca11d/whenever-0.9.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7e31f4bb592cdd433dde40acef83f968b89178b42169724e0b200a318536c4f", size = 497651, upload-time = "2026-01-11T19:45:53.477Z" },
{ url = "https://files.pythonhosted.org/packages/c3/94/6e66e55649ed8c2e742ca3284e3dd158b0cb3d1272fc7bacc5b0938eba12/whenever-0.9.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f8100609213040dda06e1d078bc43768d5d16dbaabfa75b13c5adb5189056be", size = 488006, upload-time = "2026-01-11T19:46:13.624Z" },
{ url = "https://files.pythonhosted.org/packages/c2/62/5c0bea83a518f3a76377400bdc29215d2f964be4d1bc8d3886f91824a4cc/whenever-0.9.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38c415a27661f830320c96f3cf6af99ea96b712c9c3a14975ff8c34263d125c6", size = 521517, upload-time = "2026-01-11T19:46:23.874Z" },
{ url = "https://files.pythonhosted.org/packages/80/0a/e93c2ccfb993d5ac1434913d9a2cea2e79d93affbbec06942c748d04681f/whenever-0.9.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee6fce59f948dd30c01107d55d40d2c035c6dfd3005c49d535e10175832601c", size = 483401, upload-time = "2026-01-11T19:46:53.531Z" },
{ url = "https://files.pythonhosted.org/packages/4d/05/d142ca3b56a1012082d0d2b71f9e9e1b9b42ba266d92294f841cb7acc07e/whenever-0.9.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef181122ffcef41551d5eef483bf12ea48d1115554911a674e404e640ef0fd26", size = 520096, upload-time = "2026-01-11T19:46:33.746Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b9/53a3ebb2c44e7215fa8c6720361a5c78891538f9c934935ffbf508801aae/whenever-0.9.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e4970bc5c37d4fff22e299314bebebb2d1a88133a67a51f87013dad0ac6535fb", size = 637251, upload-time = "2026-01-11T19:45:43.849Z" },
{ url = "https://files.pythonhosted.org/packages/ea/17/4f15ae9977972a7b8cd2819c4f03b63884416e4064d6d7366164cd6093f4/whenever-0.9.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:db8043800bd6eba7e681a316cba3734c8d10dad044b4a0dcf5fb0129665902ce", size = 769681, upload-time = "2026-01-11T19:46:03.041Z" },
{ url = "https://files.pythonhosted.org/packages/a0/a5/280dd222b31f478cef551f0d6554ab224e436b7b4ba1456d3cad864de756/whenever-0.9.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:067235cd1be14f709219eae4e6ae639ec19418e7860d1be1e59c5e4bd9cb3542", size = 732487, upload-time = "2026-01-11T19:46:43.395Z" },
{ url = "https://files.pythonhosted.org/packages/8d/33/d2bed59cfe7cf358e78bfa26bad4414a0a3d99c6aa5e5b76aea448661bae/whenever-0.9.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:81556be508452f5fe7d7e7837718ff91ddb16089a705febe178fa8baabc99236", size = 696906, upload-time = "2026-01-11T19:47:03.814Z" },
{ url = "https://files.pythonhosted.org/packages/36/4a/b27b182fdf24c9760a91481c6b97d9f536a15f6bc7c33d750eccaa245670/whenever-0.9.5-cp314-cp314t-win32.whl", hash = "sha256:681c9f4d322182df28037a33f0d4eadf7577fcd590130f7a5d23763a86ab5687", size = 417599, upload-time = "2026-01-11T19:47:36.201Z" },
{ url = "https://files.pythonhosted.org/packages/6d/93/712f65c4fc3c188b0c163cd650cb04a46f993a22d1ad3f66a3e4a42a39f8/whenever-0.9.5-cp314-cp314t-win_amd64.whl", hash = "sha256:37242c895078bb1bfc26bea460f6d92dc5bdfe4a59e95a4f9e01f99b90f0157e", size = 440654, upload-time = "2026-01-11T19:47:46.924Z" },
{ url = "https://files.pythonhosted.org/packages/91/fb/1b112af72e5a2b5ac66b5a6a478cc220117224c1481a2b1a35e87222a791/whenever-0.9.5-py3-none-any.whl", hash = "sha256:cc600303e703d57cc42c5db1c81bbc3becb8ef6fde5c46aee5d68c292239fc4b", size = 64873, upload-time = "2026-01-11T19:47:49.585Z" },
]