Compare commits

...

105 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
Damien
cf05a5d477 fix(schema): add trunk_groups and stp_enabled on InfraVLAN — refs #49 2026-02-13 11:25:29 +01:00
Damien
f623d12b8c fix(schema): remove redundant Device.router_id, add BGP distance attributes — refs #48 2026-02-13 11:24:49 +01:00
Damien
e527534820 fix(schema): refactor UnderlayLink IPs from attributes to InfraIPAddress relations — refs #47 2026-02-13 11:24:17 +01:00
Damien
ed820f3380 fix(schema): add virtual_router_address and autostate on InterfaceVlan — refs #46 2026-02-13 11:23:29 +01:00
Damien
82af2daafc fix(schema): add unique constraint on InfraVLAN.vlan_id for human_friendly_id — refs #45 2026-02-13 11:23:02 +01:00
Damien
719ecfafaa fix(schema): remove duplicate InterfaceVxlan, keep VTEP only — refs #44 2026-02-13 11:22:43 +01:00
Damien
3654c2de9d fix(schema): scope BGPPeerGroup and BGPSession human_friendly_id per device — refs #43 2026-02-13 11:21:51 +01:00
Damien
f69bba2bf1 Create CLAUDE.md 2026-02-12 11:19:23 +01:00
aa9d0093fe fix(menu): rename VRF/MLAG namespaces to match Infrahub regex
Infrahub namespaces must match ^[A-Z][a-z0-9]+$
- VRF → Vrf
- MLAG → Mlag
2026-02-09 13:29:11 +00:00
d05c96915f feat(schema): add include_in_menu: false to all extension nodes #41 2026-02-09 12:05:11 +00:00
e31846b057 feat(schema): add include_in_menu: false to all MLAG nodes #41 2026-02-09 12:04:46 +00:00
741132a176 feat(schema): add include_in_menu: false to all VRF nodes #41 2026-02-09 12:04:21 +00:00
a05e997742 feat(schema): add include_in_menu: false to all VLAN/VXLAN nodes #41 2026-02-09 12:04:04 +00:00
ffa1eff19a feat(schema): add include_in_menu: false to all BGP nodes #41 2026-02-09 12:03:38 +00:00
69f003b798 feat(schema): add include_in_menu: false to all base nodes #41
Menu is now controlled by custom menus/fabric-menu.yml
2026-02-09 12:03:09 +00:00
40fdf504c9 feat(config): add menu reference to .infrahub.yml #41 2026-02-09 12:02:29 +00:00
78263e8bf5 feat(menu): add custom Infrahub menu for EVPN-VXLAN fabric #41
Organize 22+ schema nodes into logical categories:
- Fabric Topology (Fabric, Site, Device, Platform, Underlay Links)
- Interfaces (Ethernet, Loopback, VLAN SVI, LAG, VXLAN)
- IP Addressing
- Layer 2 / VXLAN (VLAN, VNI, VTEP, Mappings, EVPN)
- Routing / BGP (AS, Router Config, Peer Groups, Sessions, AFI)
- VRF (VRF, Route Targets, Assignments)
- MLAG (Domain, Peer Config, Interface)
- Host Connectivity
2026-02-09 12:02:18 +00:00
25550bb3c4 fix: migrate display_labels to display_label Jinja2 format in VRF schema
Also enriched VRFDeviceAssignment display_label to show device + VRF.

Refs #41
2026-02-07 09:18:38 +00:00
b9b15f3beb fix: migrate display_labels to display_label Jinja2 format in VLAN/VXLAN schema
Refs #41
2026-02-07 09:18:11 +00:00
d9b47461d3 fix: migrate display_labels to display_label Jinja2 format in MLAG schema
Refs #41
2026-02-07 09:17:46 +00:00
498fa8505e fix: migrate display_labels to display_label Jinja2 format in extensions schema
Refs #41
2026-02-07 09:17:26 +00:00
cda3804c2c fix: migrate display_labels to display_label Jinja2 format in BGP schema
Refs #41
2026-02-07 09:17:07 +00:00
97080473f7 fix: migrate display_labels to display_label Jinja2 format in base schema
Replaces deprecated display_labels list format and bare display_label
strings with the new Jinja2 template format as required by Infrahub.

Refs #41
2026-02-07 09:16:40 +00:00
bb76b93499 refactor(schema): replace deprecated display_labels with display_label 2026-02-07 09:03:36 +00:00
a917715a47 refactor(schema): replace deprecated display_labels with display_label across all nodes 2026-02-07 09:03:11 +00:00
99f34b9639 fix(schema): add unique: true on VRF.name for HFID peer traversal 2026-02-06 19:39:28 +00:00
0e96e8aca8 fix(schema): add explicit optional: false on VRFDeviceAssignment.vrf for human_friendly_id 2026-02-06 19:20:56 +00:00
d7e2d9f0e0 fix(schema): add optional: false on UnderlayLink.fabric parent relationship 2026-02-06 19:19:55 +00:00
0c0ab1b214 fix(schema): add explicit optional: false to Parent relationships in vrf.yml 2026-02-06 15:10:16 +00:00
a12e24d00c fix(schema): add explicit optional: false to all Parent relationships in vlan_vxlan.yml 2026-02-06 15:10:00 +00:00
67a54e349d fix(schema): add explicit optional: false to Parent relationships in mlag.yml 2026-02-06 15:09:25 +00:00
adb02fc25c fix(schema): add explicit optional: false to all Parent relationships in bgp.yml 2026-02-06 15:08:57 +00:00
ef6877551f fix(schema): IPAddress.interface - replace Parent with Attribute kind
Parent relationships cannot be optional in Infrahub. Since an IP address
can exist without being assigned to an interface (reserved, planned),
we use kind: Attribute instead of kind: Parent.

Also change InfraInterface.ip_addresses from Component to Generic
since Component requires a matching Parent on the other side.

Both sides now use explicit identifier: interface__ip_addresses
for proper bidirectional linking.
2026-02-06 15:07:01 +00:00
4aadf763a3 fix(schema): add optional: false on device relationship for uniqueness_constraints 2026-02-06 13:11:52 +00:00
52c335aeb0 fix(schema): display_labels must only reference local attributes in vrf.yml 2026-02-06 13:08:59 +00:00
2ca08a4f41 fix(schema): display_labels must only reference local attributes in vlan_vxlan.yml 2026-02-06 13:08:30 +00:00
95bd2ee6c8 fix(schema): display_labels must only reference local attributes in mlag.yml 2026-02-06 13:08:04 +00:00
42d03699d9 fix(schema): display_labels must only reference local attributes in extensions.yml 2026-02-06 13:07:44 +00:00
c22a5b6ab4 fix(schema): display_labels must only reference attributes, not relationships 2026-02-06 13:07:24 +00:00
4e9c2d3009 fix(schema): replace deprecated default_filter with human_friendly_id in base.yml 2026-02-06 13:06:58 +00:00
eb809812af fix(schema): add direction outbound for self-referencing relationship
InterfaceEthernet.connected_interface is a self-reference that requires
direction: outbound to avoid bidirectional conflicts per Infrahub docs.

Ref: #41
2026-02-06 09:12:15 +00:00
8114b6d973 fix(schema): add unique identifiers for VRF RouteTarget relationships
Add identifier and direction: outbound for import/export targets
in VRF and VRFDeviceAssignment to avoid bidirectional conflicts.

Ref: #41
2026-02-06 09:09:31 +00:00
6d08b19b5f fix(schema): add unique identifiers for MlagDomain VLAN relationships
Add identifier and direction: outbound for peer_vlan and ibgp_vlan
to avoid bidirectional relationship conflicts.

Ref: #41
2026-02-06 09:04:39 +00:00
4f5fef7203 fix(schema): use direction outbound for UnderlayLink relationships
Use outbound direction to avoid bidirectional relationship conflicts
when multiple relationships point to the same peer type.

Ref: #41
2026-02-06 08:55:08 +00:00
afa268ad2d fix(schema): add unique identifiers for UnderlayLink relationships
Infrahub requires unique identifiers when multiple relationships
point to the same peer type with the same direction.

Ref: #41
2026-02-06 08:51:47 +00:00
c2a92c340d fix(schema): rename rd to route_distinguisher in EVPNInstance
Infrahub requires attribute names to have at least 3 characters.

Ref: #41
2026-02-06 08:29:12 +00:00
443642c2a1 fix(schema): rename rd to route_distinguisher (min 3 chars)
Infrahub requires attribute names to have at least 3 characters.

Ref: #41
2026-02-06 08:28:42 +00:00
Damien
ea6b66d639 docs: reformat README tables for better raw readability
Update Markdown tables including InfraHub comparison, Prefect benefits, and project progress phases to use consistent column padding. This improves the visual alignment and readability when viewing the raw source file.
2026-02-06 09:00:27 +01:00
f03698dec5 docs(schema): add schema documentation and ERD
Document all schema files, entity relationships, and mapping
to the reference arista-evpn-vxlan-clab topology.

Ref: #41
2026-02-05 15:54:38 +00:00
bd46d5f046 feat(schema): add extensions schema
Add Fabric, Underlay Link, and Host Connection models
for fabric-wide configuration and topology documentation.

Ref: #41
2026-02-05 15:54:07 +00:00
e3ef0b3763 feat(schema): add MLAG schema
Add models for MLAG Domain, Peer Config, and MLAG Interfaces.
Supports dual-primary detection and heartbeat configuration.

Ref: #41
2026-02-05 15:53:49 +00:00
3e96b0ff1a feat(schema): add BGP schema
Add models for Autonomous System, BGP Router Config, Peer Groups,
BGP Sessions, and Address Families. Supports underlay and EVPN overlay.

Ref: #41
2026-02-05 15:53:32 +00:00
d87ffcaca6 feat(schema): add VRF schema
Add models for VRF, Route Target, and VRF-Device assignments.
Supports L3VNI association for symmetric IRB routing.

Ref: #41
2026-02-05 15:53:11 +00:00
535f24a920 feat(schema): add VLAN and VXLAN schema
Add models for VLAN, VNI, VTEP, VLAN-VNI mappings, and EVPN instances.
Supports L2VNI and L3VNI types for EVPN-VXLAN fabric.

Ref: #41
2026-02-05 15:52:58 +00:00
077917f011 feat(schema): add base infrastructure schema
Add core infrastructure models for Device, Interface, IPAddress,
and Platform based on the arista-evpn-vxlan-clab topology.

Ref: #41
2026-02-05 15:52:36 +00:00
Damien
c6595cda51 docs: replace ASCII architecture diagram with Excalidraw SVG
Replace the text-based ASCII art representation of the system architecture with a linked Excalidraw SVG image. This improves visual clarity, readability, and maintainability of the architectural overview in the README.
2026-02-05 13:03:03 +01:00
383aa6b35f docs: Add Git-as-backend architecture for InfraHub
- Repository now serves as InfraHub backend (schema + data in Git)
- Add data/ directory structure for infrastructure objects
- Add transforms/ for Jinja2 config templates
- Update architecture diagram to show Git-centric workflow
- Add "Repository as InfraHub Backend" section explaining benefits
- Simplify project structure to reflect new approach
2026-02-05 08:48:34 +00:00
77ca22bd0a docs: Update README for InfraHub migration
- Replace NetBox with InfraHub as Source of Truth
- Update architecture diagram
- Explain InfraHub benefits (Git-native, custom schema)
- Update project structure (remove netbox references)
- Update technology stack
- Revise project phases for new approach
2026-02-05 08:42:51 +00:00
Damien
e46946606c Remove fabric provisioning script
The `scripts/provision_fabric.py` script and its associated NetBox
client
module (`src/netbox/`) have been removed. This functionality is no
longer
needed.
2026-02-05 09:38:05 +01:00
Damien
4e598ae400 fix(script): update populate script 2026-02-04 18:23:04 +01:00
Damien
0a2f658a2a fix(scripts): enhance get_or_create logic and parameter handling
- Replace `endpoint.get()` with `endpoint.filter()` in `get_or_create` to handle object retrieval more robustly and avoid potential exceptions with multiple results.
- Decouple `search_params` from `create_params` to correctly handle differences between API filtering keys (e.g., `group_id`, `vrf_id`) and creation keys (e.g., `group`, `vrf`).
- Refine IP address lookups to include `assigned_object_id` in search parameters, preventing ambiguous matches against IPs not assigned to the target interface.
2026-02-04 16:31:50 +01:00
52c9586667 fix: use 'device' instead of 'device_id' for interface creation
NetBox API requires 'device' field for creation, not 'device_id'.
Separated search and create logic for interfaces.
2026-02-04 15:17:30 +00:00
Damien
0de450211d chore: add .envrc to .gitignore
Add .envrc to the .gitignore file to prevent local environment variables
and secrets managed by direnv from being accidentally committed to the
repository.
2026-02-04 16:12:10 +01:00
f4ba38a735 fix: use object_types instead of content_types for NetBox 4.4.x custom fields
The NetBox API changed the field name from content_types to object_types.
2026-02-04 15:08:01 +00:00
16 changed files with 2489 additions and 2763 deletions

2
.gitignore vendored
View File

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

273
README.md
View File

@@ -2,13 +2,13 @@
**Declarative Network Infrastructure Management for Arista EVPN-VXLAN Fabrics**
A workflow-based orchestration system that uses NetBox as Source of Truth, [Prefect](https://prefect.io) for orchestration, and gNMI/YANG for atomic configuration management of Arista data center fabrics.
A workflow-based orchestration system that uses [InfraHub](https://github.com/opsmill/infrahub) as Source of Truth, [Prefect](https://prefect.io) for orchestration, and gNMI/YANG for atomic configuration management of Arista data center fabrics.
## 🎯 Project Vision
Transform network infrastructure management from imperative scripting to true declarative infrastructure-as-code, where:
- **Intent** is defined in NetBox (Custom Fields, Native Models, BGP Plugin)
- **Intent** is defined in InfraHub (custom schema, Git-versioned)
- **Orchestration** is handled by Prefect (Python-native `@flow` and `@task` decorators)
- **State** is continuously monitored via gNMI Subscribe
- **Changes** are computed as diffs and applied atomically via gNMI Set
@@ -18,56 +18,31 @@ Think `terraform plan` and `terraform apply`, but for your network fabric — po
## 🏗️ Architecture
```
┌───────────────────────────────────────────────────────────────────────────────────────┐
│ INTENT LAYER │
│ ┌─────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐ │
│ │ NetBox │ │ Custom Fields / │ │ netbox-bgp │ │
│ │ (SoT) │◄───│ Native Models │◄───│ Plugin │ │
│ └──────────┬──────────┘ └──────────────────────────┘ └──────────────────────┘
└─────────────┼─────────────────────────────────────────────────────────────────────────┘
│ Webhook / Polling
┌──────────────────────────────────────────────────────────────────────────────┐
│ ORCHESTRATION LAYER (PREFECT)
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Prefect Flows (Python) │ │
│ │ ┌───────────────────┐ ┌───────────────────┐ ┌─────────────────────┐ │ │
│ │ │ fabric_reconcile │ │ handle_drift │ │ drift_remediation │ │ │
│ │ │ (plan/apply) │ │ (subscribe) │ │ (auto-fix) │ │ │
│ │ └───────────────────┘ └───────────────────┘ └─────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ Prefect Tasks (Python) │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────────┐ │ │
│ │ │ Intent Parser │ │ Diff Engine │ │ gNMI Client │ │ │
│ │ │ (NetBox→YANG) │ │ (Want vs Have) │ │ (pygnmi wrapper) │ │ │
│ │ └─────────────────┘ └─────────────────┘ └───────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ FastAPI Webhook Receiver │ Prefect .serve() │ Prefect Server (UI) │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
│ gNMI Get/Set/Subscribe
┌──────────────────────────────────────────────────────────────────────────────┐
│ DEVICE LAYER │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ spine1 │ │ spine2 │ │ leaf1 │ │ leaf2 │ ... │
│ │ gNMI:6030 │ │ gNMI:6030 │ │ gNMI:6030 │ │ gNMI:6030 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
```
![architecture](docs/assets/architecture/fabric-orchestration-archi.excalidraw.svg)
## 🎯 Why InfraHub?
We chose [InfraHub](https://github.com/opsmill/infrahub) over NetBox as Source of Truth for several reasons:
| Feature | NetBox | InfraHub |
| ------------------- | --------------------- | ------------------------------------ |
| **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 |
| **Transforms** | Limited | Built-in Jinja2 + Python transforms |
| **GraphQL** | Yes | Yes (auto-generated from schema) |
**Key benefits for this project:**
1. **Custom Schema** - Model exactly what we need (VTEPs, MLAG pairs, fabric topology)
2. **Git-native** - Schema + data versioned together, easy test environment setup
3. **Transforms** - Generate device configs directly from InfraHub
4. **Branches** - Test fabric changes in isolated branches before merge
## 🎛 Why Prefect?
We chose [Prefect](https://prefect.io) as the orchestration engine for several reasons:
| Feature | Benefit |
|---------|---------|
| -------------------------------- | ---------------------------------------------------------------------- |
| **Python-native workflows** | Use `@flow` and `@task` decorators — no YAML, just Python |
| **Free secrets management** | Native `Secret` blocks for credentials (free in OSS) |
| **Built-in UI** | Dashboard, logs, metrics, execution history via `prefect server start` |
@@ -79,27 +54,25 @@ We chose [Prefect](https://prefect.io) as the orchestration engine for several r
## 🎯 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 | Issues |
|-------|-------------|--------|
| **Phase 1** | YANG Path Discovery - Map EOS 4.35.0F YANG models, validate gNMI | [phase-1-yang-discovery](https://gitea.arnodo.fr/Damien/fabric-orchestrator/issues?type=all&state=all&labels=1) |
| **Phase 2** | Core Components - NetBox client, diff engine, gNMI operations | [phase-2-minimal-reconciler](https://gitea.arnodo.fr/Damien/fabric-orchestrator/issues?type=all&state=all&labels=2) |
| **Phase 3** | Full Fabric - BGP, MLAG, VRFs, YANG mappers | [phase-3-full-fabric](https://gitea.arnodo.fr/Damien/fabric-orchestrator/issues?type=all&state=all&labels=3) |
| **Phase 4** | Prefect Integration - Flows, webhooks, drift detection | [phase-4-event-driven](https://gitea.arnodo.fr/Damien/fabric-orchestrator/issues?type=all&state=all&labels=4) |
📌 **Project Board**: [View Kanban](https://gitea.arnodo.fr/Damien/fabric-orchestrator/projects)
| 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
@@ -110,84 +83,79 @@ fabric-orchestrator/
├── src/ # Python package
│ ├── __init__.py
│ ├── cli.py # CLI for YANG discovery (discover commands)
│ │
│ ├── flows/ # Prefect flows
│ │ ├── __init__.py
│ │ ├── reconcile.py # @flow fabric_reconcile (plan/apply)
│ │ ├── drift.py # @flow handle_drift
│ │ └── remediation.py # @flow drift_remediation
│ │
│ ├── api/ # FastAPI webhook receiver
│ │ ├── __init__.py
│ │ └── webhooks.py # NetBox webhook endpoint
│ │
│ ├── services/ # Long-running services
│ │ ├── __init__.py
│ │ └── drift_monitor.py # gNMI Subscribe drift detection
│ ├── cli.py # CLI for YANG discovery
│ │
│ ├── gnmi/
│ │ ├── __init__.py
│ │ ├── client.py # gNMI client wrapper (pygnmi)
│ │ └── README.md
│ │
│ ├── netbox/
│ ├── infrahub/ # InfraHub integration
│ │ ├── __init__.py
│ │ ├── client.py # NetBox API client (pynetbox)
│ │ ── models.py # Pydantic models for intent validation
│ │ ├── client.py # InfraHub SDK wrapper
│ │ ── models.py # Pydantic intent models
│ │ └── exceptions.py # Client exceptions
│ │
│ └── yang/
│ ├── __init__.py
│ ├── mapper.py # NetBox intent → YANG paths
│ ├── mapper.py # InfraHub intent → YANG paths
│ ├── paths.py # YANG path definitions
│ └── dependencies.py # Dependency ordering graph
│ └── mappers/ # Resource-specific mappers
│ ├── vlan.py
│ ├── interface.py
│ ├── bgp.py
│ └── vxlan.py
├── tests/
└── docs/
├── cli-user-guide.md # CLI documentation
── yang-paths.md # Documented YANG paths
└── netbox-data-model.md # NetBox schema documentation
├── cli-user-guide.md
── yang-paths.md
```
## 🛠️ Technology Stack
| Component | Technology | Purpose |
|-----------|------------|---------|
| Source of Truth | NetBox + BGP Plugin | Intent definition via native models |
| --------------- | -------------------------- | ------------------------------------ |
| Source of Truth | **InfraHub** | Intent definition via custom schema |
| Orchestrator | **Prefect** | Python-native workflow orchestration |
| Webhooks | FastAPI | Receive NetBox webhooks |
| Transport | gNMI | Configuration and telemetry |
| Data Models | YANG (OpenConfig + Arista) | Structured configuration |
| Python Library | pygnmi + pynetbox | gNMI/NetBox interactions |
| Python Library | pygnmi + infrahub-sdk | gNMI/InfraHub interactions |
| CLI | Click + Rich | YANG discovery tools |
| Validation | Pydantic v2 | Intent data validation |
| Lab | ContainerLab + cEOS | Development environment |
## 🔗 Related Projects
- [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab) - Target fabric topology
- [projet-vxlan-automation](https://gitea.arnodo.fr/Damien/projet-vxlan-automation) - Previous NetBox RenderConfig work
- [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
- [Prefect Documentation](https://docs.prefect.io) - Orchestration platform docs
## 📚 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)
### 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/)
- [Prefect Deployments](https://docs.prefect.io/latest/deploy/run-flows-in-local-processes/)
- [Prefect Secrets](https://docs.prefect.io/latest/develop/blocks/#secret)
### 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/)
- [Arista EVPN Deployment Guide](https://www.arista.com/en/solutions/evpn-vxlan)
## 🚀 Getting Started
@@ -195,8 +163,8 @@ fabric-orchestrator/
- Python 3.12+
- `uv` package manager
- Access to ContainerLab with cEOS images
- NetBox instance with BGP plugin
- Access to an InfraHub instance with the EVPN-VXLAN fabric schema loaded
- Access to ContainerLab with cEOS images (for lab testing)
### Quick Start
@@ -208,79 +176,35 @@ cd fabric-orchestrator
# Install Python dependencies
uv sync
# Configure Prefect secrets
python -c "
from prefect.blocks.system import Secret
from prefect.variables import Variable
# Set InfraHub connection (point to your InfraHub instance)
export INFRAHUB_ADDRESS="http://localhost:8000"
export INFRAHUB_API_TOKEN="your-token"
Secret(value='your-netbox-token').save('netbox-token', overwrite=True)
Secret(value='your-gnmi-password').save('gnmi-password', overwrite=True)
Variable.set('netbox_url', 'https://netbox.example.com')
Variable.set('gnmi_username', 'admin')
"
# Start Prefect server (optional, for UI)
prefect server start
# Verify gNMI connectivity to your fabric
# Verify gNMI connectivity
uv run fabric-orch discover capabilities --target leaf1:6030
# Explore YANG paths
uv run fabric-orch discover get --target leaf1:6030 \
--path "/interfaces/interface[name=Ethernet1]/state"
```
### Running Flows
```python
from src.flows.reconcile import fabric_reconcile
# Plan only (dry-run)
result = fabric_reconcile(dry_run=True)
# Plan for a specific device
result = fabric_reconcile(device="leaf1", dry_run=True)
# Apply changes automatically
result = fabric_reconcile(auto_apply=True, dry_run=False)
```
### Deploying with Scheduling
```bash
# Start the flow with scheduling (runs every 6 hours)
python -m src.flows.reconcile
# Or deploy via Prefect CLI
prefect deployment run fabric-reconcile/fabric-reconcile-scheduled
```
### Starting the Webhook Receiver
```bash
# Start FastAPI webhook server
uvicorn src.api.webhooks:app --host 0.0.0.0 --port 8000
# Run reconciliation
uv run fabric-orch plan
uv run fabric-orch apply
```
## Prefect Flow Example
```python
from prefect import flow, task
from prefect.blocks.system import Secret
from prefect.variables import Variable
@task(retries=2, retry_delay_seconds=10)
def get_fabric_intent(device: str | None = None) -> dict:
"""Retrieve fabric intent from NetBox."""
from src.netbox import FabricNetBoxClient
async def get_fabric_intent(device: str | None = None) -> dict:
"""Retrieve fabric intent from InfraHub."""
from src.infrahub.client import FabricInfrahubClient
netbox_url = Variable.get("netbox_url")
netbox_token = Secret.load("netbox-token").get()
client = FabricNetBoxClient(url=netbox_url, token=netbox_token)
return client.get_fabric_intent() if not device else client.get_device_intent(device)
async with FabricInfrahubClient(
url=Variable.get("infrahub_url"),
api_token=Variable.get("infrahub_token"),
) as client:
return await client.get_device(device)
@task
@@ -290,46 +214,23 @@ def compute_diff(intent: dict, current: dict) -> list[dict]:
return diff_engine(want=intent, have=current)
@task(retries=1)
def apply_changes(changes: list[dict], dry_run: bool = True) -> dict:
"""Apply changes via gNMI Set."""
if dry_run:
return {"applied": False, "changes": changes}
# Apply via gNMI...
return {"applied": True, "changes": changes}
@flow(log_prints=True, name="fabric-reconcile")
def fabric_reconcile(
device: str | None = None,
auto_apply: bool = False,
dry_run: bool = True
) -> dict:
"""Reconcile fabric state with NetBox intent."""
print(f"🔄 Starting fabric reconciliation")
def fabric_reconcile(device: str | None = None, dry_run: bool = True) -> dict:
"""Reconcile fabric state with InfraHub intent."""
intent = get_fabric_intent(device)
current = get_current_state(devices)
current = get_current_state(device)
changes = compute_diff(intent, current)
if not changes:
print("No changes detected - fabric is in sync")
return {"changes": [], "in_sync": True}
print("Fabric is in sync")
return {"in_sync": True}
should_apply = auto_apply and not dry_run
result = apply_changes(changes, dry_run=not should_apply)
if not dry_run:
apply_changes(changes)
return {"changes": changes, "applied": should_apply}
if __name__ == "__main__":
fabric_reconcile.serve(
name="fabric-reconcile-scheduled",
cron="0 */6 * * *",
tags=["network", "fabric"]
)
return {"changes": changes, "applied": not dry_run}
```
---
**Status**: 🚧 Active Development - Phase 2 (Core Components) & Phase 4 (Prefect Integration)
**Status**: 🚧 Active Development - Phase 2 (InfraHub Client & Core Reconciler)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 45 KiB

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,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",
]
@@ -34,5 +35,8 @@ 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,84 +0,0 @@
# NetBox Provisioning for EVPN-VXLAN Fabric
This directory contains scripts to populate NetBox with the fabric topology defined in [arista-evpn-vxlan-clab](https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab).
## Prerequisites
- NetBox 4.4.x running and accessible
- NetBox BGP Plugin v0.17.x installed (optional, for BGP sessions)
- Python 3.9+
- API token with write permissions
## Installation
```bash
uv add pynetbox
```
## Usage
```bash
export NETBOX_URL="http://netbox.example.com"
export NETBOX_TOKEN="your-api-token"
uv run python scripts/provision_fabric.py
```
## What Gets Created
### Custom Fields
| Object Type | Field | Description |
|-------------|-------|-------------|
| Device | `asn` | BGP ASN |
| Device | `mlag_domain_id` | MLAG domain identifier |
| Device | `mlag_peer_address` | MLAG peer IP |
| Device | `mlag_local_address` | MLAG local IP |
| Device | `mlag_virtual_mac` | Shared virtual MAC |
| Interface | `mlag_peer_link` | Marks peer-link interfaces |
| Interface | `mlag_id` | MLAG ID for host LAGs |
| VRF | `l3vni` | L3 VNI for EVPN |
| VRF | `vrf_vlan` | VLAN for L3 VNI SVI |
| IP Address | `virtual_ip` | Anycast/virtual IP flag |
### Organization
- **Site**: evpn-lab
- **Manufacturer**: Arista
- **Device Types**: cEOS-lab, Linux Server
- **Device Roles**: Spine, Leaf, Server
### Devices
| Device | Role | ASN | MLAG Domain |
|--------|------|-----|-------------|
| spine1, spine2 | Spine | 65000 | - |
| leaf1, leaf2 | Leaf | 65001 | MLAG1 |
| leaf3, leaf4 | Leaf | 65002 | MLAG2 |
| leaf5, leaf6 | Leaf | 65003 | MLAG3 |
| leaf7, leaf8 | Leaf | 65004 | MLAG4 |
| host1-4 | Server | - | - |
### Cabling
- Spine1/2 Ethernet1-8 → Leaf1-8 Ethernet11/12
- MLAG peer-links: Leaf pairs via Ethernet10
- Host dual-homing: eth1/eth2 to MLAG pairs
### IP Addressing
| Purpose | Prefix |
|---------|--------|
| Spine1-Leaf P2P | 10.0.1.0/24 |
| Spine2-Leaf P2P | 10.0.2.0/24 |
| MLAG iBGP P2P | 10.0.3.0/24 |
| MLAG Peer VLAN | 10.0.199.0/24 |
| Loopback0 (Router-ID) | 10.0.250.0/24 |
| Loopback1 (VTEP) | 10.0.255.0/24 |
## Idempotency
The script is idempotent - running it multiple times will not create duplicate objects.
## Reference
- [NetBox Data Model Documentation](../docs/netbox-data-model.md)

View File

@@ -1,972 +0,0 @@
#!/usr/bin/env python3
"""
NetBox Provisioning Script for EVPN-VXLAN Fabric
This script populates NetBox with the complete fabric topology defined in
arista-evpn-vxlan-clab, including devices, interfaces, cables, IP addresses,
VLANs, and custom fields required for fabric orchestration.
Requirements:
pip install pynetbox
Usage:
export NETBOX_URL="http://netbox.example.com"
export NETBOX_TOKEN="your-api-token"
python scripts/provision_fabric.py
Reference:
https://gitea.arnodo.fr/Damien/fabric-orchestrator/src/branch/main/docs/netbox-data-model.md
https://gitea.arnodo.fr/Damien/arista-evpn-vxlan-clab
"""
import os
import sys
from typing import Any
try:
import pynetbox
except ImportError:
print("Error: pynetbox is required. Install with: pip install pynetbox")
sys.exit(1)
# =============================================================================
# Configuration
# =============================================================================
NETBOX_URL = os.environ.get("NETBOX_URL", "http://localhost:8000")
NETBOX_TOKEN = os.environ.get("NETBOX_TOKEN", "")
# Fabric configuration
SITE_NAME = "evpn-lab"
SITE_SLUG = "evpn-lab"
MANUFACTURER_NAME = "Arista"
MANUFACTURER_SLUG = "arista"
# Device type from community library
# https://github.com/netbox-community/devicetype-library/tree/master/device-types/Arista
DEVICE_TYPE_MODEL = "cEOS-lab"
DEVICE_TYPE_SLUG = "ceos-lab"
# =============================================================================
# Custom Fields Definition
# =============================================================================
CUSTOM_FIELDS = [
# Device custom fields
{
"content_types": ["dcim.device"],
"name": "asn",
"label": "ASN",
"type": "integer",
"required": False,
"description": "BGP Autonomous System Number assigned to this device",
},
{
"content_types": ["dcim.device"],
"name": "mlag_domain_id",
"label": "MLAG Domain ID",
"type": "text",
"required": False,
"description": "MLAG domain identifier",
},
{
"content_types": ["dcim.device"],
"name": "mlag_peer_address",
"label": "MLAG Peer Address",
"type": "text",
"required": False,
"description": "MLAG peer IP address",
},
{
"content_types": ["dcim.device"],
"name": "mlag_local_address",
"label": "MLAG Local Address",
"type": "text",
"required": False,
"description": "MLAG local IP address",
},
{
"content_types": ["dcim.device"],
"name": "mlag_virtual_mac",
"label": "MLAG Virtual MAC",
"type": "text",
"required": False,
"description": "Shared virtual-router MAC address",
},
# Interface custom fields
{
"content_types": ["dcim.interface"],
"name": "mlag_peer_link",
"label": "MLAG Peer Link",
"type": "boolean",
"required": False,
"description": "Marks interface as MLAG peer-link",
},
{
"content_types": ["dcim.interface"],
"name": "mlag_id",
"label": "MLAG ID",
"type": "integer",
"required": False,
"description": "MLAG port-channel ID for host-facing LAGs",
},
# VRF custom fields
{
"content_types": ["ipam.vrf"],
"name": "l3vni",
"label": "L3 VNI",
"type": "integer",
"required": False,
"description": "Layer 3 VNI for EVPN symmetric IRB",
},
{
"content_types": ["ipam.vrf"],
"name": "vrf_vlan",
"label": "VRF VLAN",
"type": "integer",
"required": False,
"description": "VLAN ID used for L3 VNI SVI",
},
# IP Address custom fields
{
"content_types": ["ipam.ipaddress"],
"name": "virtual_ip",
"label": "Virtual IP",
"type": "boolean",
"required": False,
"description": "Marks IP as anycast/virtual IP (shared across MLAG pair)",
},
]
# =============================================================================
# Device Definitions
# =============================================================================
DEVICE_ROLES = [
{"name": "Spine", "slug": "spine", "color": "ff5722"},
{"name": "Leaf", "slug": "leaf", "color": "4caf50"},
{"name": "Server", "slug": "server", "color": "2196f3"},
]
# Spine devices
SPINES = [
{"name": "spine1", "mgmt_ip": "172.16.0.1/24", "asn": 65000, "loopback0": "10.0.250.1/32"},
{"name": "spine2", "mgmt_ip": "172.16.0.2/24", "asn": 65000, "loopback0": "10.0.250.2/32"},
]
# Leaf devices with MLAG configuration
LEAFS = [
{
"name": "leaf1",
"mgmt_ip": "172.16.0.25/24",
"asn": 65001,
"loopback0": "10.0.250.11/32",
"loopback1": "10.0.255.11/32", # Shared VTEP with leaf2
"mlag": {
"domain_id": "MLAG1",
"local_address": "10.0.199.254",
"peer_address": "10.0.199.255",
"virtual_mac": "00:1c:73:00:00:01",
},
},
{
"name": "leaf2",
"mgmt_ip": "172.16.0.50/24",
"asn": 65001,
"loopback0": "10.0.250.12/32",
"loopback1": "10.0.255.11/32", # Shared VTEP with leaf1
"mlag": {
"domain_id": "MLAG1",
"local_address": "10.0.199.255",
"peer_address": "10.0.199.254",
"virtual_mac": "00:1c:73:00:00:01",
},
},
{
"name": "leaf3",
"mgmt_ip": "172.16.0.27/24",
"asn": 65002,
"loopback0": "10.0.250.13/32",
"loopback1": "10.0.255.12/32",
"mlag": {
"domain_id": "MLAG2",
"local_address": "10.0.199.252",
"peer_address": "10.0.199.253",
"virtual_mac": "00:1c:73:00:00:02",
},
},
{
"name": "leaf4",
"mgmt_ip": "172.16.0.28/24",
"asn": 65002,
"loopback0": "10.0.250.14/32",
"loopback1": "10.0.255.12/32",
"mlag": {
"domain_id": "MLAG2",
"local_address": "10.0.199.253",
"peer_address": "10.0.199.252",
"virtual_mac": "00:1c:73:00:00:02",
},
},
{
"name": "leaf5",
"mgmt_ip": "172.16.0.29/24",
"asn": 65003,
"loopback0": "10.0.250.15/32",
"loopback1": "10.0.255.13/32",
"mlag": {
"domain_id": "MLAG3",
"local_address": "10.0.199.250",
"peer_address": "10.0.199.251",
"virtual_mac": "00:1c:73:00:00:03",
},
},
{
"name": "leaf6",
"mgmt_ip": "172.16.0.30/24",
"asn": 65003,
"loopback0": "10.0.250.16/32",
"loopback1": "10.0.255.13/32",
"mlag": {
"domain_id": "MLAG3",
"local_address": "10.0.199.251",
"peer_address": "10.0.199.250",
"virtual_mac": "00:1c:73:00:00:03",
},
},
{
"name": "leaf7",
"mgmt_ip": "172.16.0.31/24",
"asn": 65004,
"loopback0": "10.0.250.17/32",
"loopback1": "10.0.255.14/32",
"mlag": {
"domain_id": "MLAG4",
"local_address": "10.0.199.248",
"peer_address": "10.0.199.249",
"virtual_mac": "00:1c:73:00:00:04",
},
},
{
"name": "leaf8",
"mgmt_ip": "172.16.0.32/24",
"asn": 65004,
"loopback0": "10.0.250.18/32",
"loopback1": "10.0.255.14/32",
"mlag": {
"domain_id": "MLAG4",
"local_address": "10.0.199.249",
"peer_address": "10.0.199.248",
"virtual_mac": "00:1c:73:00:00:04",
},
},
]
# Host devices
HOSTS = [
{"name": "host1", "mgmt_ip": "172.16.0.101/24", "vlan": 40, "ip": "10.40.40.101/24"},
{"name": "host2", "mgmt_ip": "172.16.0.102/24", "vlan": 34, "ip": "10.34.34.102/24"},
{"name": "host3", "mgmt_ip": "172.16.0.103/24", "vlan": 40, "ip": "10.40.40.103/24"},
{"name": "host4", "mgmt_ip": "172.16.0.104/24", "vlan": 78, "ip": "10.78.78.104/24"},
]
# =============================================================================
# Cabling Matrix
# =============================================================================
# Spine to Leaf connections: (spine_name, spine_intf, leaf_name, leaf_intf)
SPINE_LEAF_CABLES = [
# Spine1 connections
("spine1", "Ethernet1", "leaf1", "Ethernet11"),
("spine1", "Ethernet2", "leaf2", "Ethernet11"),
("spine1", "Ethernet3", "leaf3", "Ethernet11"),
("spine1", "Ethernet4", "leaf4", "Ethernet11"),
("spine1", "Ethernet5", "leaf5", "Ethernet11"),
("spine1", "Ethernet6", "leaf6", "Ethernet11"),
("spine1", "Ethernet7", "leaf7", "Ethernet11"),
("spine1", "Ethernet8", "leaf8", "Ethernet11"),
# Spine2 connections
("spine2", "Ethernet1", "leaf1", "Ethernet12"),
("spine2", "Ethernet2", "leaf2", "Ethernet12"),
("spine2", "Ethernet3", "leaf3", "Ethernet12"),
("spine2", "Ethernet4", "leaf4", "Ethernet12"),
("spine2", "Ethernet5", "leaf5", "Ethernet12"),
("spine2", "Ethernet6", "leaf6", "Ethernet12"),
("spine2", "Ethernet7", "leaf7", "Ethernet12"),
("spine2", "Ethernet8", "leaf8", "Ethernet12"),
]
# MLAG Peer-link cables: (leaf_a, intf_a, leaf_b, intf_b)
MLAG_PEER_CABLES = [
("leaf1", "Ethernet10", "leaf2", "Ethernet10"),
("leaf3", "Ethernet10", "leaf4", "Ethernet10"),
("leaf5", "Ethernet10", "leaf6", "Ethernet10"),
("leaf7", "Ethernet10", "leaf8", "Ethernet10"),
]
# Host dual-homing cables: (leaf_a, intf_a, leaf_b, intf_b, host, host_intf_a, host_intf_b)
HOST_CABLES = [
("leaf1", "Ethernet1", "leaf2", "Ethernet1", "host1"),
("leaf3", "Ethernet1", "leaf4", "Ethernet1", "host2"),
("leaf5", "Ethernet1", "leaf6", "Ethernet1", "host3"),
("leaf7", "Ethernet1", "leaf8", "Ethernet1", "host4"),
]
# =============================================================================
# IP Addressing for P2P Links
# =============================================================================
# Spine1 P2P addresses: leaf_name -> (spine_ip, leaf_ip)
SPINE1_P2P = {
"leaf1": ("10.0.1.0/31", "10.0.1.1/31"),
"leaf2": ("10.0.1.2/31", "10.0.1.3/31"),
"leaf3": ("10.0.1.4/31", "10.0.1.5/31"),
"leaf4": ("10.0.1.6/31", "10.0.1.7/31"),
"leaf5": ("10.0.1.8/31", "10.0.1.9/31"),
"leaf6": ("10.0.1.10/31", "10.0.1.11/31"),
"leaf7": ("10.0.1.12/31", "10.0.1.13/31"),
"leaf8": ("10.0.1.14/31", "10.0.1.15/31"),
}
SPINE2_P2P = {
"leaf1": ("10.0.2.0/31", "10.0.2.1/31"),
"leaf2": ("10.0.2.2/31", "10.0.2.3/31"),
"leaf3": ("10.0.2.4/31", "10.0.2.5/31"),
"leaf4": ("10.0.2.6/31", "10.0.2.7/31"),
"leaf5": ("10.0.2.8/31", "10.0.2.9/31"),
"leaf6": ("10.0.2.10/31", "10.0.2.11/31"),
"leaf7": ("10.0.2.12/31", "10.0.2.13/31"),
"leaf8": ("10.0.2.14/31", "10.0.2.15/31"),
}
# MLAG iBGP P2P addresses: (leaf_a, leaf_b) -> (leaf_a_ip, leaf_b_ip)
MLAG_IBGP_P2P = {
("leaf1", "leaf2"): ("10.0.3.0/31", "10.0.3.1/31"),
("leaf3", "leaf4"): ("10.0.3.2/31", "10.0.3.3/31"),
("leaf5", "leaf6"): ("10.0.3.4/31", "10.0.3.5/31"),
("leaf7", "leaf8"): ("10.0.3.6/31", "10.0.3.7/31"),
}
# =============================================================================
# VLANs and VRFs
# =============================================================================
VLAN_GROUP_NAME = "evpn-fabric"
VLANS = [
{"vid": 34, "name": "vlan34-l3", "description": "L3 VLAN for VRF gold"},
{"vid": 40, "name": "vlan40-l2", "description": "L2 VXLAN stretched VLAN"},
{"vid": 78, "name": "vlan78-l3", "description": "L3 VLAN for VRF gold"},
{"vid": 4090, "name": "mlag-peer", "description": "MLAG peer communication"},
{"vid": 4091, "name": "mlag-ibgp", "description": "iBGP between MLAG peers"},
]
VRFS = [
{
"name": "gold",
"rd": "1:100001",
"l3vni": 100001,
"vrf_vlan": 3000,
"import_targets": ["1:100001"],
"export_targets": ["1:100001"],
},
]
# =============================================================================
# Prefixes
# =============================================================================
PREFIXES = [
{"prefix": "10.0.1.0/24", "description": "Spine1-Leaf P2P"},
{"prefix": "10.0.2.0/24", "description": "Spine2-Leaf P2P"},
{"prefix": "10.0.3.0/24", "description": "MLAG iBGP P2P"},
{"prefix": "10.0.199.0/24", "description": "MLAG Peer VLAN 4090"},
{"prefix": "10.0.250.0/24", "description": "Loopback0 (Router-ID)"},
{"prefix": "10.0.255.0/24", "description": "Loopback1 (VTEP)"},
{"prefix": "10.34.34.0/24", "description": "VLAN 34 subnet", "vrf": "gold"},
{"prefix": "10.40.40.0/24", "description": "VLAN 40 subnet"},
{"prefix": "10.78.78.0/24", "description": "VLAN 78 subnet", "vrf": "gold"},
{"prefix": "172.16.0.0/24", "description": "Management network"},
]
# =============================================================================
# Helper Functions
# =============================================================================
def get_or_create(endpoint, search_params: dict, create_params: dict) -> Any:
"""Get existing object or create new one."""
obj = endpoint.get(**search_params)
if obj:
return obj, False
obj = endpoint.create({**search_params, **create_params})
return obj, True
def log_result(action: str, obj_type: str, name: str, created: bool):
"""Log creation result."""
status = "Created" if created else "Exists"
print(f" [{status}] {obj_type}: {name}")
# =============================================================================
# Provisioning Functions
# =============================================================================
def create_custom_fields(nb: pynetbox.api):
"""Create custom fields for fabric orchestration."""
print("\n=== Creating Custom Fields ===")
for cf in CUSTOM_FIELDS:
existing = nb.extras.custom_fields.get(name=cf["name"])
if existing:
print(f" [Exists] Custom Field: {cf['name']}")
continue
nb.extras.custom_fields.create(cf)
print(f" [Created] Custom Field: {cf['name']}")
def create_organization(nb: pynetbox.api) -> dict:
"""Create site, manufacturer, device types, and roles."""
print("\n=== Creating Organization ===")
result = {}
# Site
site, created = get_or_create(
nb.dcim.sites,
{"slug": SITE_SLUG},
{"name": SITE_NAME, "status": "active"},
)
log_result("Site", "Site", SITE_NAME, created)
result["site"] = site
# Manufacturer
manufacturer, created = get_or_create(
nb.dcim.manufacturers,
{"slug": MANUFACTURER_SLUG},
{"name": MANUFACTURER_NAME},
)
log_result("Manufacturer", "Manufacturer", MANUFACTURER_NAME, created)
result["manufacturer"] = manufacturer
# Device Type for switches
device_type, created = get_or_create(
nb.dcim.device_types,
{"slug": DEVICE_TYPE_SLUG},
{
"model": DEVICE_TYPE_MODEL,
"manufacturer": manufacturer.id,
"u_height": 1,
},
)
log_result("DeviceType", "DeviceType", DEVICE_TYPE_MODEL, created)
result["device_type"] = device_type
# Device Type for servers/hosts
server_type, created = get_or_create(
nb.dcim.device_types,
{"slug": "linux-server"},
{
"model": "Linux Server",
"manufacturer": manufacturer.id,
"u_height": 1,
},
)
log_result("DeviceType", "DeviceType", "Linux Server", created)
result["server_type"] = server_type
# Device Roles
result["roles"] = {}
for role in DEVICE_ROLES:
role_obj, created = get_or_create(
nb.dcim.device_roles,
{"slug": role["slug"]},
{"name": role["name"], "color": role["color"]},
)
log_result("DeviceRole", "DeviceRole", role["name"], created)
result["roles"][role["slug"]] = role_obj
return result
def create_vlans(nb: pynetbox.api, site) -> dict:
"""Create VLAN group and VLANs."""
print("\n=== Creating VLANs ===")
result = {}
# VLAN Group
vlan_group, created = get_or_create(
nb.ipam.vlan_groups,
{"slug": VLAN_GROUP_NAME},
{"name": VLAN_GROUP_NAME, "scope_type": "dcim.site", "scope_id": site.id},
)
log_result("VLANGroup", "VLANGroup", VLAN_GROUP_NAME, created)
result["group"] = vlan_group
# VLANs
result["vlans"] = {}
for vlan in VLANS:
vlan_obj, created = get_or_create(
nb.ipam.vlans,
{"vid": vlan["vid"], "group_id": vlan_group.id},
{"name": vlan["name"], "description": vlan.get("description", "")},
)
log_result("VLAN", "VLAN", f"{vlan['vid']} ({vlan['name']})", created)
result["vlans"][vlan["vid"]] = vlan_obj
return result
def create_vrfs(nb: pynetbox.api) -> dict:
"""Create VRFs with route targets."""
print("\n=== Creating VRFs ===")
result = {}
for vrf_def in VRFS:
# Create route targets first
import_rts = []
export_rts = []
for rt in vrf_def.get("import_targets", []):
rt_obj, created = get_or_create(
nb.ipam.route_targets,
{"name": rt},
{"description": f"Import RT for {vrf_def['name']}"},
)
import_rts.append(rt_obj.id)
log_result("RouteTarget", "RouteTarget", rt, created)
for rt in vrf_def.get("export_targets", []):
rt_obj = nb.ipam.route_targets.get(name=rt)
if rt_obj:
export_rts.append(rt_obj.id)
# Create VRF
vrf, created = get_or_create(
nb.ipam.vrfs,
{"name": vrf_def["name"]},
{
"rd": vrf_def.get("rd"),
"import_targets": import_rts,
"export_targets": export_rts,
"custom_fields": {
"l3vni": vrf_def.get("l3vni"),
"vrf_vlan": vrf_def.get("vrf_vlan"),
},
},
)
log_result("VRF", "VRF", vrf_def["name"], created)
result[vrf_def["name"]] = vrf
return result
def create_prefixes(nb: pynetbox.api, vrfs: dict):
"""Create IP prefixes."""
print("\n=== Creating Prefixes ===")
for prefix_def in PREFIXES:
vrf_id = None
if "vrf" in prefix_def:
vrf_id = vrfs.get(prefix_def["vrf"])
if vrf_id:
vrf_id = vrf_id.id
prefix, created = get_or_create(
nb.ipam.prefixes,
{"prefix": prefix_def["prefix"], "vrf_id": vrf_id},
{"description": prefix_def.get("description", "")},
)
log_result("Prefix", "Prefix", prefix_def["prefix"], created)
def create_devices(nb: pynetbox.api, org: dict) -> dict:
"""Create all network devices."""
print("\n=== Creating Devices ===")
result = {}
# Create Spines
for spine in SPINES:
device, created = get_or_create(
nb.dcim.devices,
{"name": spine["name"]},
{
"device_type": org["device_type"].id,
"role": org["roles"]["spine"].id,
"site": org["site"].id,
"status": "active",
"custom_fields": {"asn": spine["asn"]},
},
)
log_result("Device", "Device", spine["name"], created)
result[spine["name"]] = {"device": device, "config": spine}
# Create Leafs
for leaf in LEAFS:
mlag = leaf.get("mlag", {})
custom_fields = {
"asn": leaf["asn"],
"mlag_domain_id": mlag.get("domain_id"),
"mlag_peer_address": mlag.get("peer_address"),
"mlag_local_address": mlag.get("local_address"),
"mlag_virtual_mac": mlag.get("virtual_mac"),
}
device, created = get_or_create(
nb.dcim.devices,
{"name": leaf["name"]},
{
"device_type": org["device_type"].id,
"role": org["roles"]["leaf"].id,
"site": org["site"].id,
"status": "active",
"custom_fields": custom_fields,
},
)
log_result("Device", "Device", leaf["name"], created)
result[leaf["name"]] = {"device": device, "config": leaf}
# Create Hosts
for host in HOSTS:
device, created = get_or_create(
nb.dcim.devices,
{"name": host["name"]},
{
"device_type": org["server_type"].id,
"role": org["roles"]["server"].id,
"site": org["site"].id,
"status": "active",
},
)
log_result("Device", "Device", host["name"], created)
result[host["name"]] = {"device": device, "config": host}
return result
def create_interfaces(nb: pynetbox.api, devices: dict) -> dict:
"""Create all device interfaces."""
print("\n=== Creating Interfaces ===")
result = {}
for device_name, device_data in devices.items():
device = device_data["device"]
config = device_data["config"]
result[device_name] = {}
# Determine interface requirements based on device type
if device_name.startswith("spine"):
# Spines: 8 Ethernet ports for leaf connections + Management + Loopback0
interfaces = [
("Management1", "virtual"),
("Loopback0", "virtual"),
]
for i in range(1, 9):
interfaces.append((f"Ethernet{i}", "1000base-t"))
elif device_name.startswith("leaf"):
# Leafs: Ethernet1 (host), Ethernet10 (peer-link), Ethernet11-12 (spines)
# + Management + Loopback0 + Loopback1 + Vxlan1
interfaces = [
("Management1", "virtual"),
("Loopback0", "virtual"),
("Loopback1", "virtual"),
("Vxlan1", "virtual"),
("Ethernet1", "1000base-t"), # Host-facing
("Ethernet10", "1000base-t"), # MLAG peer-link
("Ethernet11", "1000base-t"), # Spine1
("Ethernet12", "1000base-t"), # Spine2
("Port-Channel10", "lag"), # MLAG peer-link LAG
]
elif device_name.startswith("host"):
# Hosts: eth1, eth2 for bonding + bond0
interfaces = [
("eth0", "virtual"), # Management
("eth1", "1000base-t"),
("eth2", "1000base-t"),
("bond0", "lag"),
]
else:
continue
for intf_name, intf_type in interfaces:
intf, created = get_or_create(
nb.dcim.interfaces,
{"device_id": device.id, "name": intf_name},
{"type": intf_type},
)
# Set custom fields for MLAG peer-link
if intf_name in ("Ethernet10", "Port-Channel10") and device_name.startswith("leaf"):
if created or not intf.custom_fields.get("mlag_peer_link"):
intf.custom_fields = {"mlag_peer_link": True}
intf.save()
result[device_name][intf_name] = intf
if created:
log_result("Interface", "Interfaces", f"{device_name}", True)
return result
def create_ip_addresses(nb: pynetbox.api, devices: dict, interfaces: dict, vrfs: dict):
"""Create IP addresses and assign to interfaces."""
print("\n=== Creating IP Addresses ===")
# Loopback addresses for spines
for spine in SPINES:
device = devices[spine["name"]]["device"]
intf = interfaces[spine["name"]]["Loopback0"]
ip, created = get_or_create(
nb.ipam.ip_addresses,
{"address": spine["loopback0"]},
{
"assigned_object_type": "dcim.interface",
"assigned_object_id": intf.id,
"description": f"{spine['name']} Router-ID",
},
)
log_result("IP", "IP Address", spine["loopback0"], created)
# Loopback addresses for leafs
for leaf in LEAFS:
device = devices[leaf["name"]]["device"]
# Loopback0
intf = interfaces[leaf["name"]]["Loopback0"]
ip, created = get_or_create(
nb.ipam.ip_addresses,
{"address": leaf["loopback0"]},
{
"assigned_object_type": "dcim.interface",
"assigned_object_id": intf.id,
"description": f"{leaf['name']} Router-ID",
},
)
log_result("IP", "IP Address", leaf["loopback0"], created)
# Loopback1 (VTEP)
intf = interfaces[leaf["name"]]["Loopback1"]
ip, created = get_or_create(
nb.ipam.ip_addresses,
{"address": leaf["loopback1"]},
{
"assigned_object_type": "dcim.interface",
"assigned_object_id": intf.id,
"description": f"{leaf['name']} VTEP",
},
)
log_result("IP", "IP Address", leaf["loopback1"], created)
# P2P addresses for Spine1-Leaf links
for leaf_name, (spine_ip, leaf_ip) in SPINE1_P2P.items():
# Spine side
spine_intf = interfaces["spine1"][f"Ethernet{list(SPINE1_P2P.keys()).index(leaf_name) + 1}"]
ip, created = get_or_create(
nb.ipam.ip_addresses,
{"address": spine_ip},
{
"assigned_object_type": "dcim.interface",
"assigned_object_id": spine_intf.id,
"description": f"spine1 to {leaf_name}",
},
)
log_result("IP", "IP Address", spine_ip, created)
# Leaf side
leaf_intf = interfaces[leaf_name]["Ethernet11"]
ip, created = get_or_create(
nb.ipam.ip_addresses,
{"address": leaf_ip},
{
"assigned_object_type": "dcim.interface",
"assigned_object_id": leaf_intf.id,
"description": f"{leaf_name} to spine1",
},
)
log_result("IP", "IP Address", leaf_ip, created)
# P2P addresses for Spine2-Leaf links
for leaf_name, (spine_ip, leaf_ip) in SPINE2_P2P.items():
spine_intf = interfaces["spine2"][f"Ethernet{list(SPINE2_P2P.keys()).index(leaf_name) + 1}"]
ip, created = get_or_create(
nb.ipam.ip_addresses,
{"address": spine_ip},
{
"assigned_object_type": "dcim.interface",
"assigned_object_id": spine_intf.id,
"description": f"spine2 to {leaf_name}",
},
)
log_result("IP", "IP Address", spine_ip, created)
leaf_intf = interfaces[leaf_name]["Ethernet12"]
ip, created = get_or_create(
nb.ipam.ip_addresses,
{"address": leaf_ip},
{
"assigned_object_type": "dcim.interface",
"assigned_object_id": leaf_intf.id,
"description": f"{leaf_name} to spine2",
},
)
log_result("IP", "IP Address", leaf_ip, created)
def create_cables(nb: pynetbox.api, interfaces: dict):
"""Create cables between devices."""
print("\n=== Creating Cables ===")
# Spine-Leaf cables
for spine, spine_intf, leaf, leaf_intf in SPINE_LEAF_CABLES:
a_intf = interfaces.get(spine, {}).get(spine_intf)
b_intf = interfaces.get(leaf, {}).get(leaf_intf)
if not a_intf or not b_intf:
print(f" [Skip] Cable: {spine}:{spine_intf} <-> {leaf}:{leaf_intf} (interface not found)")
continue
# Check if cable already exists
existing = nb.dcim.cables.filter(termination_a_id=a_intf.id)
if list(existing):
print(f" [Exists] Cable: {spine}:{spine_intf} <-> {leaf}:{leaf_intf}")
continue
try:
cable = nb.dcim.cables.create(
{
"a_terminations": [{"object_type": "dcim.interface", "object_id": a_intf.id}],
"b_terminations": [{"object_type": "dcim.interface", "object_id": b_intf.id}],
"status": "connected",
"type": "cat6a",
}
)
print(f" [Created] Cable: {spine}:{spine_intf} <-> {leaf}:{leaf_intf}")
except Exception as e:
print(f" [Error] Cable: {spine}:{spine_intf} <-> {leaf}:{leaf_intf}: {e}")
# MLAG peer-link cables
for leaf_a, intf_a, leaf_b, intf_b in MLAG_PEER_CABLES:
a_intf = interfaces.get(leaf_a, {}).get(intf_a)
b_intf = interfaces.get(leaf_b, {}).get(intf_b)
if not a_intf or not b_intf:
print(f" [Skip] Cable: {leaf_a}:{intf_a} <-> {leaf_b}:{intf_b} (interface not found)")
continue
existing = nb.dcim.cables.filter(termination_a_id=a_intf.id)
if list(existing):
print(f" [Exists] Cable: {leaf_a}:{intf_a} <-> {leaf_b}:{intf_b}")
continue
try:
cable = nb.dcim.cables.create(
{
"a_terminations": [{"object_type": "dcim.interface", "object_id": a_intf.id}],
"b_terminations": [{"object_type": "dcim.interface", "object_id": b_intf.id}],
"status": "connected",
"type": "cat6a",
"label": "MLAG Peer-Link",
}
)
print(f" [Created] Cable: {leaf_a}:{intf_a} <-> {leaf_b}:{intf_b} (MLAG peer-link)")
except Exception as e:
print(f" [Error] Cable: {leaf_a}:{intf_a} <-> {leaf_b}:{intf_b}: {e}")
# Host dual-homing cables
for leaf_a, intf_a, leaf_b, intf_b, host in HOST_CABLES:
# Cable from leaf_a to host eth1
a_intf = interfaces.get(leaf_a, {}).get(intf_a)
host_eth1 = interfaces.get(host, {}).get("eth1")
if a_intf and host_eth1:
existing = nb.dcim.cables.filter(termination_a_id=a_intf.id)
if not list(existing):
try:
cable = nb.dcim.cables.create(
{
"a_terminations": [{"object_type": "dcim.interface", "object_id": a_intf.id}],
"b_terminations": [{"object_type": "dcim.interface", "object_id": host_eth1.id}],
"status": "connected",
"type": "cat6a",
}
)
print(f" [Created] Cable: {leaf_a}:{intf_a} <-> {host}:eth1")
except Exception as e:
print(f" [Error] Cable: {leaf_a}:{intf_a} <-> {host}:eth1: {e}")
else:
print(f" [Exists] Cable: {leaf_a}:{intf_a} <-> {host}:eth1")
# Cable from leaf_b to host eth2
b_intf = interfaces.get(leaf_b, {}).get(intf_b)
host_eth2 = interfaces.get(host, {}).get("eth2")
if b_intf and host_eth2:
existing = nb.dcim.cables.filter(termination_a_id=b_intf.id)
if not list(existing):
try:
cable = nb.dcim.cables.create(
{
"a_terminations": [{"object_type": "dcim.interface", "object_id": b_intf.id}],
"b_terminations": [{"object_type": "dcim.interface", "object_id": host_eth2.id}],
"status": "connected",
"type": "cat6a",
}
)
print(f" [Created] Cable: {leaf_b}:{intf_b} <-> {host}:eth2")
except Exception as e:
print(f" [Error] Cable: {leaf_b}:{intf_b} <-> {host}:eth2: {e}")
else:
print(f" [Exists] Cable: {leaf_b}:{intf_b} <-> {host}:eth2")
# =============================================================================
# Main
# =============================================================================
def main():
"""Main provisioning function."""
if not NETBOX_TOKEN:
print("Error: NETBOX_TOKEN environment variable is required")
sys.exit(1)
print(f"Connecting to NetBox at {NETBOX_URL}...")
nb = pynetbox.api(NETBOX_URL, token=NETBOX_TOKEN)
try:
# Verify connection
status = nb.status()
print(f"Connected to NetBox {status.get('netbox-version', 'unknown')}")
except Exception as e:
print(f"Error connecting to NetBox: {e}")
sys.exit(1)
# Run provisioning steps
create_custom_fields(nb)
org = create_organization(nb)
vlans = create_vlans(nb, org["site"])
vrfs = create_vrfs(nb)
create_prefixes(nb, vrfs)
devices = create_devices(nb, org)
interfaces = create_interfaces(nb, devices)
create_ip_addresses(nb, devices, interfaces, vrfs)
create_cables(nb, interfaces)
print("\n" + "=" * 50)
print("Provisioning complete!")
print("=" * 50)
if __name__ == "__main__":
main()

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

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

View File

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

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()

555
uv.lock generated
View File

@@ -2,6 +2,40 @@ 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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/15/8f335ed50b5fed4d7587e293c200bb498049f4a74d9913c58c26a42d3503/basedpyright-1.37.4.tar.gz", hash = "sha256:f818d8b56c1e7f639dfbdaf875aa6b0bd53eef08204389959027d3d7fb2017ed", size = 25238593, upload-time = "2026-02-04T02:57:15.893Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/b6/f075ecdc60a3e389c32934a98171b7f5c6f12250fc0030e12efc1102a557/basedpyright-1.37.4-py3-none-any.whl", hash = "sha256:bcf61d7d8dbd4570f346008fa591585bd605ce47a0561509899c276f2e53a450", size = 12299643, upload-time = "2026-02-04T02:57:19.915Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
@@ -68,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"
@@ -211,32 +188,87 @@ 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 = "ruff", specifier = ">=0.14.10" }]
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"
@@ -279,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"
@@ -288,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"
@@ -309,6 +406,31 @@ 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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b7/f1/73182280e2c05f49a7c2c8dbd46144efe3f74f03f798fb90da67b4a93bbf/nodejs_wheel_binaries-24.13.0.tar.gz", hash = "sha256:766aed076e900061b83d3e76ad48bfec32a035ef0d41bd09c55e832eb93ef7a4", size = 8056, upload-time = "2026-01-14T11:05:33.653Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/dc/4d7548aa74a5b446d093f03aff4fb236b570959d793f21c9c42ab6ad870a/nodejs_wheel_binaries-24.13.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:356654baa37bfd894e447e7e00268db403ea1d223863963459a0fbcaaa1d9d48", size = 55133268, upload-time = "2026-01-14T11:05:05.335Z" },
{ url = "https://files.pythonhosted.org/packages/24/8a/8a4454d28339487240dd2232f42f1090e4a58544c581792d427f6239798c/nodejs_wheel_binaries-24.13.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:92fdef7376120e575f8b397789bafcb13bbd22a1b4d21b060d200b14910f22a5", size = 55314800, upload-time = "2026-01-14T11:05:09.121Z" },
{ url = "https://files.pythonhosted.org/packages/e7/fb/46c600fcc748bd13bc536a735f11532a003b14f5c4dfd6865f5911672175/nodejs_wheel_binaries-24.13.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3f619ac140e039ecd25f2f71d6e83ad1414017a24608531851b7c31dc140cdfd", size = 59666320, upload-time = "2026-01-14T11:05:12.369Z" },
{ url = "https://files.pythonhosted.org/packages/85/47/d48f11fc5d1541ace5d806c62a45738a1db9ce33e85a06fe4cd3d9ce83f6/nodejs_wheel_binaries-24.13.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:dfb31ebc2c129538192ddb5bedd3d63d6de5d271437cd39ea26bf3fe229ba430", size = 60162447, upload-time = "2026-01-14T11:05:16.003Z" },
{ url = "https://files.pythonhosted.org/packages/b1/74/d285c579ae8157c925b577dde429543963b845e69cd006549e062d1cf5b6/nodejs_wheel_binaries-24.13.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fdd720d7b378d5bb9b2710457bbc880d4c4d1270a94f13fbe257198ac707f358", size = 61659994, upload-time = "2026-01-14T11:05:19.68Z" },
{ url = "https://files.pythonhosted.org/packages/ba/97/88b4254a2ff93ed2eaed725f77b7d3d2d8d7973bf134359ce786db894faf/nodejs_wheel_binaries-24.13.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ad6383613f3485a75b054647a09f1cd56d12380d7459184eebcf4a5d403f35c", size = 62244373, upload-time = "2026-01-14T11:05:23.987Z" },
{ url = "https://files.pythonhosted.org/packages/4e/c3/0e13a3da78f08cb58650971a6957ac7bfef84164b405176e53ab1e3584e2/nodejs_wheel_binaries-24.13.0-py2.py3-none-win_amd64.whl", hash = "sha256:605be4763e3ef427a3385a55da5a1bcf0a659aa2716eebbf23f332926d7e5f23", size = 41345528, upload-time = "2026-01-14T11:05:27.67Z" },
{ url = "https://files.pythonhosted.org/packages/a3/f1/0578d65b4e3dc572967fd702221ea1f42e1e60accfb6b0dd8d8f15410139/nodejs_wheel_binaries-24.13.0-py2.py3-none-win_arm64.whl", hash = "sha256:2e3431d869d6b2dbeef1d469ad0090babbdcc8baaa72c01dd3cc2c6121c96af5", size = 39054688, upload-time = "2026-01-14T11:05:30.739Z" },
]
[[package]]
name = "packaging"
version = "25.0"
@@ -318,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"
@@ -342,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"
@@ -367,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]]
@@ -442,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"
@@ -450,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" },
]