nbpull 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ Metadata-Version: 2.4
2
+ Name: nbpull
3
+ Version: 0.1.0
4
+ Summary: 🔍 Read-only CLI tool to pull IPAM data from NetBox
5
+ Keywords: netbox,ipam,network,cli,read-only
6
+ Author: Aditya Patel
7
+ Author-email: Aditya Patel <adityapatel0905@gmail.com>
8
+ License-Expression: GPL-3.0-only
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: System :: Networking
18
+ Classifier: Topic :: Utilities
19
+ Classifier: Typing :: Typed
20
+ Requires-Dist: httpx>=0.28
21
+ Requires-Dist: pydantic>=2.10
22
+ Requires-Dist: pydantic-settings>=2.7
23
+ Requires-Dist: typer>=0.15
24
+ Requires-Dist: rich>=13.9
25
+ Requires-Python: >=3.13
26
+ Project-URL: Homepage, https://github.com/Champion2005/nbpull
27
+ Project-URL: Documentation, https://github.com/Champion2005/nbpull/tree/main/docs
28
+ Project-URL: Repository, https://github.com/Champion2005/nbpull
29
+ Project-URL: Issues, https://github.com/Champion2005/nbpull/issues
30
+ Project-URL: Changelog, https://github.com/Champion2005/nbpull/blob/main/CHANGELOG.md
31
+ Description-Content-Type: text/markdown
32
+
33
+ # 🔍 nbpull
34
+
35
+ [![CI](https://github.com/Champion2005/nbpull/actions/workflows/ci.yml/badge.svg)](https://github.com/Champion2005/nbpull/actions/workflows/ci.yml)
36
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13%2B-blue.svg)](https://www.python.org/downloads/)
37
+ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
38
+ [![Typed](https://img.shields.io/badge/typing-strict-brightgreen.svg)](https://mypy-lang.org/)
39
+
40
+ **Read-only CLI tool to pull IPAM data from
41
+ [NetBox](https://netbox.dev).**
42
+
43
+ > **🔒 Safety guarantee:** nbpull **only reads** data from NetBox. No
44
+ > POST / PUT / PATCH / DELETE requests are ever made. The HTTP client is
45
+ > hardcoded to GET-only — this invariant is enforced by code structure
46
+ > and verified by tests.
47
+
48
+ ---
49
+
50
+ ## ✨ Features
51
+
52
+ - 📡 **Prefixes** — list and filter IPAM prefixes
53
+ - 🖥️ **IP Addresses** — query IP address allocations
54
+ - 🏷️ **VLANs** — browse VLAN assignments
55
+ - 🔀 **VRFs** — inspect VRF instances
56
+ - 📦 **Batch queries** — check many prefixes at once from a TOML file
57
+ - 🎨 Rich table output (default) or JSON (`--format json`)
58
+ - 🔎 Filter by status, VRF, tenant, site, tag, or free-text search
59
+ - ⚡ Async HTTP with automatic pagination
60
+ - 🔒 Strict typing (mypy strict mode + Pydantic v2)
61
+
62
+ ## 📦 Installation
63
+
64
+ ### With [uv](https://docs.astral.sh/uv/) (recommended)
65
+
66
+ ```bash
67
+ uv tool install nbpull
68
+ ```
69
+
70
+ ### With [pipx](https://pipx.pypa.io/)
71
+
72
+ ```bash
73
+ pipx install nbpull
74
+ ```
75
+
76
+ ### With pip
77
+
78
+ ```bash
79
+ pip install nbpull
80
+ ```
81
+
82
+ ### From source
83
+
84
+ ```bash
85
+ git clone https://github.com/Champion2005/nbpull.git
86
+ cd nbpull
87
+ make install # uses uv sync
88
+ ```
89
+
90
+ ## 🚀 Quick Start
91
+
92
+ ```bash
93
+ # 1. Configure your NetBox connection
94
+ export NETBOX_URL=https://netbox.example.com
95
+ export NETBOX_TOKEN=your_read_only_token
96
+
97
+ # Or use a .env file:
98
+ cp .env.example .env
99
+ # Edit .env with your values
100
+
101
+ # 2. Pull data
102
+ nbpull prefixes
103
+ nbpull prefixes --status active --vrf Production
104
+ nbpull ip-addresses --prefix 10.0.0.0/24
105
+ nbpull vlans --site DC1
106
+ nbpull vrfs --tenant Ops
107
+ nbpull batch-prefixes --file my_prefixes.toml --status-only
108
+ ```
109
+
110
+ ## 📋 Commands
111
+
112
+ | Command | Description |
113
+ |---|---|
114
+ | `nbpull prefixes` | List IPAM prefixes |
115
+ | `nbpull ip-addresses` | List IP addresses |
116
+ | `nbpull vlans` | List VLANs |
117
+ | `nbpull vrfs` | List VRFs |
118
+ | `nbpull batch-prefixes` | Query multiple prefixes from a TOML file |
119
+
120
+ ### Common Flags
121
+
122
+ | Flag | Description |
123
+ |---|---|
124
+ | `--status` | Filter by status (active, reserved, deprecated, container) |
125
+ | `--vrf` | Filter by VRF name |
126
+ | `--tenant` | Filter by tenant name |
127
+ | `--site` | Filter by site name |
128
+ | `--tag` | Filter by tag slug |
129
+ | `--search` / `-s` | Free-text search |
130
+ | `--limit` / `-l` | Max results (default: 50) |
131
+ | `--format` / `-f` | Output format: `table` (default) or `json` |
132
+ | `--verbose` / `-v` | Enable debug logging |
133
+
134
+ See the full [command reference](docs/commands.md) for all options.
135
+
136
+ ## ⚙️ Configuration
137
+
138
+ Set these in `.env` or as environment variables:
139
+
140
+ | Variable | Required | Default | Description |
141
+ |---|---|---|---|
142
+ | `NETBOX_URL` | ✅ | — | NetBox instance URL |
143
+ | `NETBOX_TOKEN` | ✅ | — | API token (read-only recommended) |
144
+ | `NETBOX_PAGE_SIZE` | ❌ | `100` | Results per API page |
145
+ | `NETBOX_TIMEOUT` | ❌ | `30` | Request timeout (seconds) |
146
+ | `NETBOX_VERIFY_SSL` | ❌ | `true` | Verify SSL certificates |
147
+
148
+ See [docs/configuration.md](docs/configuration.md) for details on
149
+ token setup and SSL options.
150
+
151
+ ## 📦 Batch Queries
152
+
153
+ Create a TOML file to query multiple prefixes in one run:
154
+
155
+ ```toml
156
+ prefixes = [
157
+ "10.0.0.0/8",
158
+ "172.16.0.0/12",
159
+ "192.168.0.0/16",
160
+ ]
161
+
162
+ [filters]
163
+ # status = "active"
164
+ # vrf = "Production"
165
+ ```
166
+
167
+ ```bash
168
+ nbpull batch-prefixes --file prefixes.toml --status-only
169
+ ```
170
+
171
+ ## 📐 Architecture
172
+
173
+ ```
174
+ src/netbox_data_puller/
175
+ ├── cli.py # Typer commands + filtering
176
+ ├── client.py # Async GET-only NetBox API client
177
+ ├── config.py # Pydantic Settings (.env)
178
+ ├── formatters.py # Rich table renderers
179
+ └── models/ # Pydantic models per resource
180
+ ├── prefix.py
181
+ ├── ip_address.py
182
+ ├── vlan.py
183
+ └── vrf.py
184
+ ```
185
+
186
+ See [docs/architecture.md](docs/architecture.md) for a full breakdown.
187
+
188
+ ## 🛠️ Development
189
+
190
+ ### Prerequisites
191
+
192
+ - **Python 3.13+**
193
+ - **[uv](https://docs.astral.sh/uv/)** — fast Python package manager
194
+
195
+ ### Setup
196
+
197
+ ```bash
198
+ git clone https://github.com/Champion2005/nbpull.git
199
+ cd nbpull
200
+ make install # Install dependencies
201
+ ```
202
+
203
+ ### Commands
204
+
205
+ ```bash
206
+ make all # format → lint → typecheck → test
207
+ make test # unit tests (no network)
208
+ make lint # ruff linter
209
+ make format # auto-format with ruff
210
+ make typecheck # mypy strict mode
211
+ make test-integration # hits real NetBox API
212
+ ```
213
+
214
+ ### Running Tests
215
+
216
+ Unit tests use mocked HTTP responses and require no network access:
217
+
218
+ ```bash
219
+ make test
220
+ ```
221
+
222
+ Integration tests require `NETBOX_URL` and `NETBOX_TOKEN`:
223
+
224
+ ```bash
225
+ make test-integration
226
+ ```
227
+
228
+ ## 🤝 Contributing
229
+
230
+ Contributions are welcome! Please read
231
+ [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR.
232
+
233
+ ## 📝 Changelog
234
+
235
+ See [CHANGELOG.md](CHANGELOG.md) for a history of changes.
236
+
237
+ ## 📄 License
238
+
239
+ This project is licensed under the
240
+ **GNU General Public License v3.0** — see the [LICENSE](LICENSE) file
241
+ for details.
242
+
243
+ ## 🙏 Acknowledgements
244
+
245
+ - [NetBox](https://netbox.dev) — the leading open-source IPAM/DCIM
246
+ platform
247
+ - [Typer](https://typer.tiangolo.com/) — CLI framework
248
+ - [Rich](https://rich.readthedocs.io/) — beautiful terminal formatting
249
+ - [httpx](https://www.python-httpx.org/) — async HTTP client
250
+ - [Pydantic](https://docs.pydantic.dev/) — data validation
@@ -0,0 +1,15 @@
1
+ netbox_data_puller/__init__.py,sha256=7iXthOLjrLHdTZMyYOLcnptqfZ_eJ09QI-B0IdDElc8,102
2
+ netbox_data_puller/cli.py,sha256=25ZgUYkj7nL-1qMqHT8n-1198VUhPcfHzeNgL0KWvO8,12659
3
+ netbox_data_puller/client.py,sha256=jsLGQ8D00e8H_IotmvsTaI72Mh_nWw96hYwF248GGWY,3525
4
+ netbox_data_puller/config.py,sha256=x3OstA8HLeUInL_xkknOneAehdhT5V6Sf_Qh0Z7O92w,582
5
+ netbox_data_puller/formatters.py,sha256=8RNt6Onp4hs3lQX8StNhR5c2mYppXbnTvZUQAq20-2o,9586
6
+ netbox_data_puller/models/__init__.py,sha256=Or2eAaquny9YSpq8PcqbikNyMdI92XqLe69ac1_D9Oc,54
7
+ netbox_data_puller/models/ip_address.py,sha256=_PnieCUhjtSEbjjA1D6akNVl0XLZlGc_Cm07MWqbOMM,593
8
+ netbox_data_puller/models/prefix.py,sha256=d2QEUBTONMCwAtKM5lwMG0J4mPCiM8jsFMI_N02pBFI,1060
9
+ netbox_data_puller/models/vlan.py,sha256=ttKGxAlO3T-g51f3FGBVl80_VV4IZkm9e5ycM1fU7W4,513
10
+ netbox_data_puller/models/vrf.py,sha256=eVE9xPYfDkTpQtN5OcY3Rc104tI30-cuOCQiShrkEx0,405
11
+ netbox_data_puller/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ nbpull-0.1.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
13
+ nbpull-0.1.0.dist-info/entry_points.txt,sha256=l4XI8XvxYl7G1dlhoqbeB9NdPVElzpia0TRIh5VdC50,55
14
+ nbpull-0.1.0.dist-info/METADATA,sha256=6n4C5O52m1t2S1DKR9w9qfbjJ1d4F8NDqz33-FXZ2b8,7075
15
+ nbpull-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.30
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ nbpull = netbox_data_puller.cli:app
3
+
@@ -0,0 +1,3 @@
1
+ """🔍 NetBox Data Puller — Read-only CLI for querying NetBox IPAM data."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,467 @@
1
+ """🖥️ CLI commands for querying NetBox IPAM data.
2
+
3
+ All commands are READ-ONLY — no data is ever written to NetBox.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ import sys
9
+ from enum import StrEnum
10
+ from pathlib import Path
11
+ from typing import Annotated, Any
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.progress import (
17
+ BarColumn,
18
+ MofNCompleteColumn,
19
+ Progress,
20
+ SpinnerColumn,
21
+ TextColumn,
22
+ TimeElapsedColumn,
23
+ )
24
+ from rich.text import Text
25
+
26
+ from netbox_data_puller.client import NetBoxClient
27
+ from netbox_data_puller.config import Settings
28
+ from netbox_data_puller.formatters import (
29
+ print_batch_summary,
30
+ print_ip_addresses,
31
+ print_json,
32
+ print_prefixes,
33
+ print_prefixes_status,
34
+ print_vlans,
35
+ print_vrfs,
36
+ )
37
+ from netbox_data_puller.models.ip_address import IPAddress
38
+ from netbox_data_puller.models.prefix import Prefix
39
+ from netbox_data_puller.models.vlan import VLAN
40
+ from netbox_data_puller.models.vrf import VRF
41
+
42
+ logger = logging.getLogger(__name__)
43
+ console = Console(stderr=True)
44
+
45
+ app = typer.Typer(
46
+ name="nbpull",
47
+ help="🔍 Read-only CLI to pull IPAM data from NetBox.",
48
+ no_args_is_help=True,
49
+ rich_markup_mode="rich",
50
+ )
51
+
52
+
53
+ # ------------------------------------------------------------------
54
+ # Shared types
55
+ # ------------------------------------------------------------------
56
+
57
+
58
+ class OutputFormat(StrEnum):
59
+ """Output format selector."""
60
+
61
+ table = "table"
62
+ json = "json"
63
+
64
+
65
+ # Common option type aliases
66
+ FormatOpt = Annotated[
67
+ OutputFormat,
68
+ typer.Option("--format", "-f", help="Output format."),
69
+ ]
70
+ StatusOpt = Annotated[
71
+ str | None,
72
+ typer.Option(help="Filter by status (e.g. active, reserved)."),
73
+ ]
74
+ VRFOpt = Annotated[
75
+ str | None,
76
+ typer.Option("--vrf", help="Filter by VRF name."),
77
+ ]
78
+ TenantOpt = Annotated[
79
+ str | None,
80
+ typer.Option("--tenant", help="Filter by tenant name."),
81
+ ]
82
+ SiteOpt = Annotated[
83
+ str | None,
84
+ typer.Option("--site", help="Filter by site name."),
85
+ ]
86
+ TagOpt = Annotated[
87
+ str | None,
88
+ typer.Option("--tag", help="Filter by tag slug."),
89
+ ]
90
+ SearchOpt = Annotated[
91
+ str | None,
92
+ typer.Option("--search", "-s", help="Free-text search."),
93
+ ]
94
+ LimitOpt = Annotated[
95
+ int,
96
+ typer.Option("--limit", "-l", help="Maximum results to return."),
97
+ ]
98
+ VerboseOpt = Annotated[
99
+ bool,
100
+ typer.Option("--verbose", "-v", help="Enable debug logging."),
101
+ ]
102
+ StatusOnlyOpt = Annotated[
103
+ bool,
104
+ typer.Option(
105
+ "--status-only",
106
+ help="Show only prefix and status columns.",
107
+ ),
108
+ ]
109
+
110
+
111
+ # ------------------------------------------------------------------
112
+ # Helpers
113
+ # ------------------------------------------------------------------
114
+
115
+
116
+ def _build_params(**kwargs: Any) -> dict[str, Any]:
117
+ """Build API query params, dropping None values."""
118
+ return {k: v for k, v in kwargs.items() if v is not None}
119
+
120
+
121
+ def _get_settings() -> Settings:
122
+ """Load settings, with a friendly error on misconfiguration."""
123
+ try:
124
+ return Settings()
125
+ except Exception as exc:
126
+ console.print(
127
+ f"[bold red]❌ Configuration error:[/bold red] {exc}\n\n"
128
+ "Make sure NETBOX_URL and NETBOX_TOKEN are set in your "
129
+ ".env file or environment variables.\n"
130
+ "See .env.example for reference.",
131
+ )
132
+ raise typer.Exit(code=1) from exc
133
+
134
+
135
+ def _configure_logging(verbose: bool) -> None:
136
+ """Set up logging level based on verbosity flag."""
137
+ level = logging.DEBUG if verbose else logging.WARNING
138
+ logging.basicConfig(
139
+ level=level,
140
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
141
+ stream=sys.stderr,
142
+ )
143
+
144
+
145
+ async def _fetch(
146
+ endpoint: str,
147
+ params: dict[str, Any],
148
+ ) -> list[dict[str, Any]]:
149
+ """Execute a read-only fetch against NetBox."""
150
+ settings = _get_settings()
151
+ async with NetBoxClient(settings) as client:
152
+ return await client.get(endpoint, params)
153
+
154
+
155
+ def _fetch_with_spinner(
156
+ endpoint: str,
157
+ params: dict[str, Any],
158
+ label: str = "Querying NetBox",
159
+ ) -> list[dict[str, Any]]:
160
+ """Fetch with a live spinner shown on stderr."""
161
+ with Progress(
162
+ SpinnerColumn("dots"),
163
+ TextColumn("[bold cyan]{task.description}"),
164
+ TimeElapsedColumn(),
165
+ console=console,
166
+ transient=True,
167
+ ) as progress:
168
+ progress.add_task(label, total=None)
169
+ return asyncio.run(_fetch(endpoint, params))
170
+
171
+
172
+ # ------------------------------------------------------------------
173
+ # Commands
174
+ # ------------------------------------------------------------------
175
+
176
+
177
+ @app.command()
178
+ def prefixes(
179
+ status: StatusOpt = None,
180
+ vrf: VRFOpt = None,
181
+ tenant: TenantOpt = None,
182
+ site: SiteOpt = None,
183
+ tag: TagOpt = None,
184
+ search: SearchOpt = None,
185
+ limit: LimitOpt = 50,
186
+ fmt: FormatOpt = OutputFormat.table,
187
+ status_only: StatusOnlyOpt = False,
188
+ verbose: VerboseOpt = False,
189
+ ) -> None:
190
+ """📡 List IPAM prefixes from NetBox."""
191
+ _configure_logging(verbose)
192
+ params = _build_params(
193
+ status=status,
194
+ vrf=vrf,
195
+ tenant=tenant,
196
+ site=site,
197
+ tag=tag,
198
+ q=search,
199
+ limit=limit,
200
+ )
201
+ raw = _fetch_with_spinner("ipam/prefixes/", params, "Fetching prefixes")
202
+ records = [Prefix.model_validate(r) for r in raw]
203
+
204
+ if fmt == OutputFormat.json:
205
+ print_json(records)
206
+ elif status_only:
207
+ print_prefixes_status(records)
208
+ else:
209
+ print_prefixes(records)
210
+
211
+
212
+ @app.command(name="ip-addresses")
213
+ def ip_addresses(
214
+ status: StatusOpt = None,
215
+ vrf: VRFOpt = None,
216
+ tenant: TenantOpt = None,
217
+ tag: TagOpt = None,
218
+ search: SearchOpt = None,
219
+ prefix: Annotated[
220
+ str | None,
221
+ typer.Option(
222
+ "--prefix",
223
+ help="Filter by parent prefix (e.g. 10.0.0.0/24).",
224
+ ),
225
+ ] = None,
226
+ limit: LimitOpt = 50,
227
+ fmt: FormatOpt = OutputFormat.table,
228
+ verbose: VerboseOpt = False,
229
+ ) -> None:
230
+ """🖥️ List IPAM IP addresses from NetBox."""
231
+ _configure_logging(verbose)
232
+ params = _build_params(
233
+ status=status,
234
+ vrf=vrf,
235
+ tenant=tenant,
236
+ tag=tag,
237
+ q=search,
238
+ parent=prefix,
239
+ limit=limit,
240
+ )
241
+ raw = _fetch_with_spinner(
242
+ "ipam/ip-addresses/",
243
+ params,
244
+ "Fetching IP addresses",
245
+ )
246
+ records = [IPAddress.model_validate(r) for r in raw]
247
+
248
+ if fmt == OutputFormat.json:
249
+ print_json(records)
250
+ else:
251
+ print_ip_addresses(records)
252
+
253
+
254
+ @app.command()
255
+ def vlans(
256
+ status: StatusOpt = None,
257
+ tenant: TenantOpt = None,
258
+ site: SiteOpt = None,
259
+ tag: TagOpt = None,
260
+ search: SearchOpt = None,
261
+ limit: LimitOpt = 50,
262
+ fmt: FormatOpt = OutputFormat.table,
263
+ verbose: VerboseOpt = False,
264
+ ) -> None:
265
+ """🏷️ List IPAM VLANs from NetBox."""
266
+ _configure_logging(verbose)
267
+ params = _build_params(
268
+ status=status,
269
+ tenant=tenant,
270
+ site=site,
271
+ tag=tag,
272
+ q=search,
273
+ limit=limit,
274
+ )
275
+ raw = _fetch_with_spinner("ipam/vlans/", params, "Fetching VLANs")
276
+ records = [VLAN.model_validate(r) for r in raw]
277
+
278
+ if fmt == OutputFormat.json:
279
+ print_json(records)
280
+ else:
281
+ print_vlans(records)
282
+
283
+
284
+ @app.command()
285
+ def vrfs(
286
+ tenant: TenantOpt = None,
287
+ tag: TagOpt = None,
288
+ search: SearchOpt = None,
289
+ limit: LimitOpt = 50,
290
+ fmt: FormatOpt = OutputFormat.table,
291
+ verbose: VerboseOpt = False,
292
+ ) -> None:
293
+ """🔀 List IPAM VRFs from NetBox."""
294
+ _configure_logging(verbose)
295
+ params = _build_params(
296
+ tenant=tenant,
297
+ tag=tag,
298
+ q=search,
299
+ limit=limit,
300
+ )
301
+ raw = _fetch_with_spinner("ipam/vrfs/", params, "Fetching VRFs")
302
+ records = [VRF.model_validate(r) for r in raw]
303
+
304
+ if fmt == OutputFormat.json:
305
+ print_json(records)
306
+ else:
307
+ print_vrfs(records)
308
+
309
+
310
+ # ------------------------------------------------------------------
311
+ # Batch commands
312
+ # ------------------------------------------------------------------
313
+
314
+ _DEFAULT_BATCH_FILE = "batch_prefixes.toml"
315
+
316
+
317
+ def _load_batch_toml(path: Path) -> dict[str, Any]:
318
+ """Load and validate a batch-prefixes TOML file."""
319
+ if not path.exists():
320
+ console.print(
321
+ f"[bold red]❌ File not found:[/bold red] {path}\n\n"
322
+ f"Create a [bold]{_DEFAULT_BATCH_FILE}[/bold] or pass "
323
+ "--file /path/to/file.toml",
324
+ )
325
+ raise typer.Exit(code=1)
326
+
327
+ import tomllib
328
+
329
+ with path.open("rb") as fh:
330
+ data = tomllib.load(fh)
331
+
332
+ if "prefixes" not in data or not data["prefixes"]:
333
+ console.print(
334
+ "[bold red]❌ TOML file must contain a non-empty "
335
+ "'prefixes' list.[/bold red]",
336
+ )
337
+ raise typer.Exit(code=1)
338
+
339
+ return data
340
+
341
+
342
+ @app.command(name="batch-prefixes")
343
+ def batch_prefixes(
344
+ file: Annotated[
345
+ Path,
346
+ typer.Option(
347
+ "--file",
348
+ help=(
349
+ f"Path to TOML file with prefix list (default: {_DEFAULT_BATCH_FILE})."
350
+ ),
351
+ ),
352
+ ] = Path(_DEFAULT_BATCH_FILE),
353
+ fmt: FormatOpt = OutputFormat.table,
354
+ status_only: StatusOnlyOpt = False,
355
+ verbose: VerboseOpt = False,
356
+ ) -> None:
357
+ """📦 Query NetBox for multiple prefixes defined in a TOML file.
358
+
359
+ Reads a list of CIDR prefixes and optional global filters from
360
+ a TOML file, then queries NetBox for each one.
361
+
362
+ Example TOML:
363
+
364
+ prefixes = ["10.32.16.0/20", "172.16.0.0/12"]
365
+
366
+ [filters]
367
+
368
+ status = "active"
369
+ """
370
+ _configure_logging(verbose)
371
+ data = _load_batch_toml(file)
372
+
373
+ prefix_list: list[str] = data["prefixes"]
374
+ global_filters: dict[str, Any] = data.get("filters", {})
375
+
376
+ # Header panel
377
+ header_lines = [
378
+ f"[bold]Source:[/bold] [cyan]{file}[/cyan]",
379
+ f"[bold]Prefixes:[/bold] {len(prefix_list)}",
380
+ ]
381
+ if global_filters:
382
+ header_lines.append(
383
+ f"[bold]Filters:[/bold] [dim]{global_filters}[/dim]",
384
+ )
385
+ console.print()
386
+ console.print(
387
+ Panel(
388
+ "\n".join(header_lines),
389
+ title="📦 Batch Prefix Query",
390
+ border_style="cyan",
391
+ expand=False,
392
+ ),
393
+ )
394
+ console.print()
395
+
396
+ # Fetch all prefixes with a progress bar
397
+ batch_results: list[tuple[str, list[Prefix]]] = []
398
+ not_found: list[str] = []
399
+
400
+ with Progress(
401
+ SpinnerColumn("dots"),
402
+ TextColumn("[bold cyan]{task.description}"),
403
+ BarColumn(bar_width=30),
404
+ MofNCompleteColumn(),
405
+ TimeElapsedColumn(),
406
+ console=console,
407
+ transient=True,
408
+ ) as progress:
409
+ task = progress.add_task(
410
+ "Querying NetBox",
411
+ total=len(prefix_list),
412
+ )
413
+ for cidr in prefix_list:
414
+ progress.update(task, description=f"Querying [green]{cidr}[/green]")
415
+ params = _build_params(q=cidr, **global_filters)
416
+ raw = asyncio.run(_fetch("ipam/prefixes/", params))
417
+ records = [Prefix.model_validate(r) for r in raw]
418
+ if records:
419
+ batch_results.append((cidr, records))
420
+ else:
421
+ not_found.append(cidr)
422
+ progress.advance(task)
423
+
424
+ # Render results
425
+ if fmt == OutputFormat.json:
426
+ for cidr, records in batch_results:
427
+ console.print(
428
+ f"\n[bold cyan]── {cidr} ──[/bold cyan]",
429
+ highlight=False,
430
+ )
431
+ print_json(records)
432
+ for cidr in not_found:
433
+ console.print(
434
+ f"\n[bold cyan]── {cidr} ──[/bold cyan]",
435
+ highlight=False,
436
+ )
437
+ console.print("[yellow] ⚠️ No results found.[/yellow]")
438
+ elif status_only:
439
+ print_batch_summary(batch_results, not_found)
440
+ else:
441
+ for cidr, records in batch_results:
442
+ console.print(
443
+ f"\n[bold cyan]── {cidr} ──[/bold cyan]",
444
+ highlight=False,
445
+ )
446
+ print_prefixes(records)
447
+ for cidr in not_found:
448
+ console.print(
449
+ f"\n[bold cyan]── {cidr} ──[/bold cyan]",
450
+ highlight=False,
451
+ )
452
+ console.print("[yellow] ⚠️ No results found.[/yellow]")
453
+
454
+ # Final summary line
455
+ done = Text()
456
+ done.append("\n✅ ", style="bold green")
457
+ done.append(f"{len(batch_results)}", style="bold")
458
+ done.append(" found")
459
+ if not_found:
460
+ done.append(" · ", style="dim")
461
+ done.append(f"⚠️ {len(not_found)}", style="bold yellow")
462
+ done.append(" not found")
463
+ console.print(done)
464
+
465
+
466
+ if __name__ == "__main__":
467
+ app()
@@ -0,0 +1,116 @@
1
+ """🔒 Read-only async HTTP client for the NetBox REST API.
2
+
3
+ SAFETY: This client ONLY performs GET requests. No data is ever
4
+ written, modified, or deleted in NetBox. The HTTP method is
5
+ hardcoded — there are no POST/PUT/PATCH/DELETE methods.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from netbox_data_puller.config import Settings
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class NetBoxClient:
19
+ """Async, read-only NetBox API client.
20
+
21
+ All requests use HTTP GET. No write operations are exposed
22
+ or possible through this client.
23
+ """
24
+
25
+ def __init__(self, settings: Settings) -> None:
26
+ base_url = str(settings.url).rstrip("/")
27
+ self._base_url = f"{base_url}/api"
28
+ self._page_size = settings.page_size
29
+ self._client = httpx.AsyncClient(
30
+ base_url=self._base_url,
31
+ headers={
32
+ "Authorization": f"Token {settings.token}",
33
+ "Accept": "application/json",
34
+ },
35
+ timeout=httpx.Timeout(settings.timeout),
36
+ verify=settings.verify_ssl,
37
+ )
38
+
39
+ # ------------------------------------------------------------------
40
+ # Public interface — READ ONLY
41
+ # ------------------------------------------------------------------
42
+
43
+ async def get(
44
+ self,
45
+ endpoint: str,
46
+ params: dict[str, Any] | None = None,
47
+ ) -> list[dict[str, Any]]:
48
+ """Fetch all pages of results from a NetBox API endpoint.
49
+
50
+ Uses HTTP GET exclusively. Automatically follows pagination.
51
+
52
+ Args:
53
+ endpoint: API path relative to /api/ (e.g. "ipam/prefixes/").
54
+ params: Optional query parameters for filtering.
55
+
56
+ Returns:
57
+ Flat list of result dicts across all pages.
58
+ """
59
+ results: list[dict[str, Any]] = []
60
+ query = {**(params or {}), "limit": self._page_size, "offset": 0}
61
+
62
+ while True:
63
+ logger.debug("GET %s params=%s", endpoint, query)
64
+ response = await self._client.get(endpoint, params=query)
65
+ response.raise_for_status()
66
+ data = response.json()
67
+
68
+ results.extend(data.get("results", []))
69
+
70
+ if data.get("next") is None:
71
+ break
72
+
73
+ query["offset"] = query["offset"] + self._page_size
74
+
75
+ logger.info(
76
+ "Fetched %d records from %s",
77
+ len(results),
78
+ endpoint,
79
+ )
80
+ return results
81
+
82
+ async def get_single(
83
+ self,
84
+ endpoint: str,
85
+ params: dict[str, Any] | None = None,
86
+ ) -> dict[str, Any]:
87
+ """Fetch a single object from a NetBox API endpoint.
88
+
89
+ Uses HTTP GET exclusively.
90
+
91
+ Args:
92
+ endpoint: API path (e.g. "ipam/prefixes/42/").
93
+ params: Optional query parameters.
94
+
95
+ Returns:
96
+ Single result dict.
97
+ """
98
+ logger.debug("GET %s params=%s", endpoint, params)
99
+ response = await self._client.get(endpoint, params=params)
100
+ response.raise_for_status()
101
+ result: dict[str, Any] = response.json()
102
+ return result
103
+
104
+ # ------------------------------------------------------------------
105
+ # Lifecycle
106
+ # ------------------------------------------------------------------
107
+
108
+ async def close(self) -> None:
109
+ """Close the underlying HTTP connection pool."""
110
+ await self._client.aclose()
111
+
112
+ async def __aenter__(self) -> "NetBoxClient":
113
+ return self
114
+
115
+ async def __aexit__(self, *_: Any) -> None:
116
+ await self.close()
@@ -0,0 +1,24 @@
1
+ """⚙️ Configuration via environment variables / .env file."""
2
+
3
+ from pydantic import HttpUrl
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ """NetBox connection settings.
9
+
10
+ Reads from environment variables or a .env file in the project root.
11
+ """
12
+
13
+ model_config = SettingsConfigDict(
14
+ env_file=".env",
15
+ env_file_encoding="utf-8",
16
+ env_prefix="NETBOX_",
17
+ case_sensitive=False,
18
+ )
19
+
20
+ url: HttpUrl
21
+ token: str
22
+ page_size: int = 100
23
+ timeout: int = 30
24
+ verify_ssl: bool = True
@@ -0,0 +1,321 @@
1
+ """🎨 Rich table formatters for NetBox IPAM resources."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ console = Console()
11
+
12
+ # ------------------------------------------------------------------
13
+ # Helpers
14
+ # ------------------------------------------------------------------
15
+
16
+
17
+ def _display_or_dash(obj: Any) -> str:
18
+ """Extract display name from a nested ref, or return '—'."""
19
+ if obj is None:
20
+ return "—"
21
+ if hasattr(obj, "display"):
22
+ return str(obj.display)
23
+ return str(obj)
24
+
25
+
26
+ def _tags_str(tags: list[Any]) -> str:
27
+ """Format tags as a comma-separated string."""
28
+ return ", ".join(t.display for t in tags) if tags else "—"
29
+
30
+
31
+ def _styled_status(obj: Any) -> Text:
32
+ """Return a Rich Text object with colour-coded status."""
33
+ if obj is None:
34
+ return Text("—", style="dim")
35
+ label = str(obj.display) if hasattr(obj, "display") else str(obj)
36
+ value = obj.value if hasattr(obj, "value") else ""
37
+ style_map = {
38
+ "active": "bold green",
39
+ "reserved": "bold yellow",
40
+ "deprecated": "bold red",
41
+ "container": "bold blue",
42
+ "dhcp": "bold magenta",
43
+ "slaac": "bold magenta",
44
+ }
45
+ return Text(label, style=style_map.get(value, ""))
46
+
47
+
48
+ # ------------------------------------------------------------------
49
+ # JSON output
50
+ # ------------------------------------------------------------------
51
+
52
+
53
+ def print_json(records: list[Any]) -> None:
54
+ """Print records as formatted JSON to stdout."""
55
+ data = [
56
+ r.model_dump(mode="json") if hasattr(r, "model_dump") else r for r in records
57
+ ]
58
+ console.print_json(json.dumps(data, indent=2, default=str))
59
+
60
+
61
+ # ------------------------------------------------------------------
62
+ # Prefixes
63
+ # ------------------------------------------------------------------
64
+
65
+
66
+ def print_prefixes(records: list[Any]) -> None:
67
+ """Render prefixes as a Rich table."""
68
+ table = Table(
69
+ title="📡 IPAM Prefixes",
70
+ show_lines=True,
71
+ header_style="bold cyan",
72
+ title_style="bold",
73
+ )
74
+ table.add_column("ID", style="dim", justify="right")
75
+ table.add_column("Prefix", style="bold green")
76
+ table.add_column("Status")
77
+ table.add_column("VRF")
78
+ table.add_column("Tenant")
79
+ table.add_column("Site")
80
+ table.add_column("VLAN")
81
+ table.add_column("Role")
82
+ table.add_column("Pool", justify="center")
83
+ table.add_column("Description", max_width=30)
84
+ table.add_column("Tags")
85
+
86
+ for r in records:
87
+ table.add_row(
88
+ str(r.id),
89
+ r.prefix,
90
+ _styled_status(r.status),
91
+ _display_or_dash(r.vrf),
92
+ _display_or_dash(r.tenant),
93
+ _display_or_dash(r.site),
94
+ _display_or_dash(r.vlan),
95
+ _display_or_dash(r.role),
96
+ "✅" if r.is_pool else "—",
97
+ r.description or "—",
98
+ _tags_str(r.tags),
99
+ )
100
+
101
+ console.print(table)
102
+ console.print(f"[dim] {len(records)} prefixes[/dim]\n")
103
+
104
+
105
+ def print_prefixes_status(records: list[Any]) -> None:
106
+ """Render a compact prefix + status table."""
107
+ table = Table(
108
+ title="📡 Prefix Status",
109
+ show_lines=True,
110
+ header_style="bold cyan",
111
+ title_style="bold",
112
+ )
113
+ table.add_column("Prefix", style="bold green")
114
+ table.add_column("Status")
115
+
116
+ for r in records:
117
+ table.add_row(r.prefix, _styled_status(r.status))
118
+
119
+ console.print(table)
120
+ console.print(f"[dim] {len(records)} prefixes[/dim]\n")
121
+
122
+
123
+ def print_batch_summary(
124
+ results: list[tuple[str, list[Any]]],
125
+ not_found: list[str],
126
+ ) -> None:
127
+ """Render a single consolidated table for batch --status-only.
128
+
129
+ Groups results by queried CIDR and shows the direct match status
130
+ prominently, with parent containers shown underneath in dim text.
131
+ """
132
+ table = Table(
133
+ title="📦 Batch Prefix Status",
134
+ show_lines=True,
135
+ header_style="bold cyan",
136
+ title_style="bold",
137
+ padding=(0, 1),
138
+ )
139
+ table.add_column("Queried Prefix", style="bold white")
140
+ table.add_column("Matched Prefix", style="green")
141
+ table.add_column("Status")
142
+ table.add_column("Site")
143
+ table.add_column("Tenant")
144
+ table.add_column("Description", max_width=32)
145
+
146
+ for cidr, records in results:
147
+ # Separate direct match from parent containers
148
+ direct = [r for r in records if r.prefix == cidr]
149
+ parents = [r for r in records if r.prefix != cidr]
150
+
151
+ if direct:
152
+ for r in direct:
153
+ table.add_row(
154
+ cidr,
155
+ r.prefix,
156
+ _styled_status(r.status),
157
+ _display_or_dash(r.site),
158
+ _display_or_dash(r.tenant),
159
+ r.description or "—",
160
+ )
161
+ elif parents:
162
+ # No exact match — show closest parent
163
+ closest = max(parents, key=lambda p: _prefix_len(p.prefix))
164
+ table.add_row(
165
+ cidr,
166
+ Text(f"≈ {closest.prefix}", style="yellow"),
167
+ _styled_status(closest.status),
168
+ _display_or_dash(closest.site),
169
+ _display_or_dash(closest.tenant),
170
+ closest.description or "—",
171
+ )
172
+
173
+ for cidr in not_found:
174
+ table.add_row(
175
+ cidr,
176
+ Text("—", style="dim"),
177
+ Text("Not Found", style="bold red"),
178
+ "—",
179
+ "—",
180
+ "—",
181
+ )
182
+
183
+ console.print()
184
+ console.print(table)
185
+
186
+ # Compact legend
187
+ legend = (
188
+ "[dim]Status: "
189
+ "[bold green]● Active[/bold green] "
190
+ "[bold blue]● Container[/bold blue] "
191
+ "[bold yellow]● Reserved[/bold yellow] "
192
+ "[bold red]● Deprecated[/bold red]"
193
+ "[/dim]"
194
+ )
195
+ console.print(legend)
196
+
197
+
198
+ def _prefix_len(prefix: str) -> int:
199
+ """Extract the CIDR mask length for sorting."""
200
+ try:
201
+ return int(prefix.split("/")[1])
202
+ except (IndexError, ValueError):
203
+ return 0
204
+
205
+
206
+ # ------------------------------------------------------------------
207
+ # IP Addresses
208
+ # ------------------------------------------------------------------
209
+
210
+
211
+ def print_ip_addresses(records: list[Any]) -> None:
212
+ """Render IP addresses as a Rich table."""
213
+ table = Table(
214
+ title="🖥️ IPAM IP Addresses",
215
+ show_lines=True,
216
+ header_style="bold cyan",
217
+ title_style="bold",
218
+ )
219
+ table.add_column("ID", style="dim", justify="right")
220
+ table.add_column("Address", style="bold green")
221
+ table.add_column("Status")
222
+ table.add_column("VRF")
223
+ table.add_column("Tenant")
224
+ table.add_column("DNS Name")
225
+ table.add_column("Role")
226
+ table.add_column("Description", max_width=30)
227
+ table.add_column("Tags")
228
+
229
+ for r in records:
230
+ table.add_row(
231
+ str(r.id),
232
+ r.address,
233
+ _styled_status(r.status),
234
+ _display_or_dash(r.vrf),
235
+ _display_or_dash(r.tenant),
236
+ r.dns_name or "—",
237
+ _display_or_dash(r.role),
238
+ r.description or "—",
239
+ _tags_str(r.tags),
240
+ )
241
+
242
+ console.print(table)
243
+ console.print(f"[dim] {len(records)} IP addresses[/dim]\n")
244
+
245
+
246
+ # ------------------------------------------------------------------
247
+ # VLANs
248
+ # ------------------------------------------------------------------
249
+
250
+
251
+ def print_vlans(records: list[Any]) -> None:
252
+ """Render VLANs as a Rich table."""
253
+ table = Table(
254
+ title="🏷️ IPAM VLANs",
255
+ show_lines=True,
256
+ header_style="bold cyan",
257
+ title_style="bold",
258
+ )
259
+ table.add_column("ID", style="dim", justify="right")
260
+ table.add_column("VID", justify="right", style="bold")
261
+ table.add_column("Name", style="bold green")
262
+ table.add_column("Status")
263
+ table.add_column("Tenant")
264
+ table.add_column("Site")
265
+ table.add_column("Group")
266
+ table.add_column("Role")
267
+ table.add_column("Description", max_width=30)
268
+ table.add_column("Tags")
269
+
270
+ for r in records:
271
+ table.add_row(
272
+ str(r.id),
273
+ str(r.vid),
274
+ r.name,
275
+ _styled_status(r.status),
276
+ _display_or_dash(r.tenant),
277
+ _display_or_dash(r.site),
278
+ _display_or_dash(r.group),
279
+ _display_or_dash(r.role),
280
+ r.description or "—",
281
+ _tags_str(r.tags),
282
+ )
283
+
284
+ console.print(table)
285
+ console.print(f"[dim] {len(records)} VLANs[/dim]\n")
286
+
287
+
288
+ # ------------------------------------------------------------------
289
+ # VRFs
290
+ # ------------------------------------------------------------------
291
+
292
+
293
+ def print_vrfs(records: list[Any]) -> None:
294
+ """Render VRFs as a Rich table."""
295
+ table = Table(
296
+ title="🔀 IPAM VRFs",
297
+ show_lines=True,
298
+ header_style="bold cyan",
299
+ title_style="bold",
300
+ )
301
+ table.add_column("ID", style="dim", justify="right")
302
+ table.add_column("Name", style="bold green")
303
+ table.add_column("RD")
304
+ table.add_column("Tenant")
305
+ table.add_column("Enforce Unique", justify="center")
306
+ table.add_column("Description", max_width=30)
307
+ table.add_column("Tags")
308
+
309
+ for r in records:
310
+ table.add_row(
311
+ str(r.id),
312
+ r.name,
313
+ r.rd or "—",
314
+ _display_or_dash(r.tenant),
315
+ "✅" if r.enforce_unique else "❌",
316
+ r.description or "—",
317
+ _tags_str(r.tags),
318
+ )
319
+
320
+ console.print(table)
321
+ console.print(f"[dim] {len(records)} VRFs[/dim]\n")
@@ -0,0 +1 @@
1
+ """📦 Pydantic models for NetBox IPAM resources."""
@@ -0,0 +1,22 @@
1
+ """📦 Pydantic model for NetBox IPAM IP Address."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from netbox_data_puller.models.prefix import ChoiceRef, NestedRef
6
+
7
+
8
+ class IPAddress(BaseModel, extra="allow"):
9
+ """NetBox IPAM IP Address resource."""
10
+
11
+ id: int
12
+ display: str
13
+ address: str
14
+ status: ChoiceRef | None = None
15
+ vrf: NestedRef | None = None
16
+ tenant: NestedRef | None = None
17
+ role: ChoiceRef | None = None
18
+ assigned_object_type: str | None = None
19
+ assigned_object_id: int | None = None
20
+ dns_name: str = ""
21
+ description: str = ""
22
+ tags: list[NestedRef] = []
@@ -0,0 +1,45 @@
1
+ """📦 Pydantic model for NetBox IPAM Prefix."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class NestedRef(BaseModel):
7
+ """Nested reference to a related object (id + display name)."""
8
+
9
+ id: int
10
+ display: str
11
+
12
+
13
+ class ChoiceRef(BaseModel):
14
+ """NetBox v4 choice/enum field (value + label).
15
+
16
+ Used for status, role, and other enumerated fields that
17
+ return {"value": "active", "label": "Active"} instead of
18
+ {"id": 1, "display": "Active"}.
19
+ """
20
+
21
+ value: str
22
+ label: str
23
+
24
+ @property
25
+ def display(self) -> str:
26
+ """Consistent interface with NestedRef."""
27
+ return self.label
28
+
29
+
30
+ class Prefix(BaseModel, extra="allow"):
31
+ """NetBox IPAM Prefix resource."""
32
+
33
+ id: int
34
+ display: str
35
+ prefix: str
36
+ status: ChoiceRef | None = None
37
+ vrf: NestedRef | None = None
38
+ tenant: NestedRef | None = None
39
+ site: NestedRef | None = None
40
+ vlan: NestedRef | None = None
41
+ role: NestedRef | None = None
42
+ is_pool: bool = False
43
+ mark_utilized: bool = False
44
+ description: str = ""
45
+ tags: list[NestedRef] = []
@@ -0,0 +1,21 @@
1
+ """📦 Pydantic model for NetBox IPAM VLAN."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from netbox_data_puller.models.prefix import ChoiceRef, NestedRef
6
+
7
+
8
+ class VLAN(BaseModel, extra="allow"):
9
+ """NetBox IPAM VLAN resource."""
10
+
11
+ id: int
12
+ display: str
13
+ vid: int
14
+ name: str
15
+ status: ChoiceRef | None = None
16
+ tenant: NestedRef | None = None
17
+ site: NestedRef | None = None
18
+ group: NestedRef | None = None
19
+ role: NestedRef | None = None
20
+ description: str = ""
21
+ tags: list[NestedRef] = []
@@ -0,0 +1,18 @@
1
+ """📦 Pydantic model for NetBox IPAM VRF."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from netbox_data_puller.models.prefix import NestedRef
6
+
7
+
8
+ class VRF(BaseModel, extra="allow"):
9
+ """NetBox IPAM VRF resource."""
10
+
11
+ id: int
12
+ display: str
13
+ name: str
14
+ rd: str | None = None
15
+ tenant: NestedRef | None = None
16
+ enforce_unique: bool = True
17
+ description: str = ""
18
+ tags: list[NestedRef] = []
File without changes