Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ STAGED_SRC=$(echo "$STAGED_PY" | grep "^src/" || true)

if [ -n "$STAGED_SRC" ]; then
echo "Running complexipy..."
$COMPLEXIPY $STAGED_SRC --max-complexity-allowed 15
PYTHONIOENCODING=utf-8 $COMPLEXIPY $STAGED_SRC --max-complexity-allowed 15
COMPLEXITY_EXIT=$?

if [ $COMPLEXITY_EXIT -ne 0 ]; then
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,19 @@ pass values directly or set environment variables:
| `LIBRENMS_VERIFY_SSL` | Verify TLS certificates | `true` |
| `LIBRENMS_API_VERSION` | API version path segment | `v0` |

A `.env` file in your working directory is also supported. Copy the included sample to get started:
A `.env` file is auto-discovered by searching from your current working directory upward. If none is found, it falls
back to `~/.env`. Copy the included sample to get started:

```bash
cp sample.env .env
# Edit .env with your LibreNMS URL and API token
```

The search order is:

1. `.env` in the current working directory or any parent directory
2. `~/.env` (home directory fallback)

## Available Route Namespaces

All route namespaces are accessible as properties on the client:
Expand Down
200 changes: 200 additions & 0 deletions docs/examples/add_device.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Adding a Device

The `add_device` method provides a fully typed interface for adding devices to LibreNMS.
It supports SNMP v1/v2c/v3 discovery, ICMP-only (ping) devices, and all optional
configuration the LibreNMS API accepts.

## Quick Start

```python
from libreclient import LibreClient

client = LibreClient()
```

---

## Default (Auto-detect SNMP)

When `snmpver` is not specified, LibreNMS uses its global configuration defaults
and auto-detects the SNMP version during discovery.

```python
# Let LibreNMS use its configured defaults
response = client.devices.add_device("10.0.0.1")

# With a display name
response = client.devices.add_device(
"switch-core-01.example.com",
display="{{ $sysName }}",
)
```

---

## SNMP v1 / v2c

When using SNMP v1 or v2c, the `community` string is **required**.

=== "v2c"

```python
response = client.devices.add_device(
"10.0.0.1",
snmpver="v2c",
community="public",
)
```

=== "v1"

```python
response = client.devices.add_device(
"10.0.0.1",
snmpver="v1",
community="public",
)
```

---

## SNMP v3

When using SNMP v3, you must provide an `SnmpV3Credentials` instance via the
`snmp_v3` parameter.

```python
from libreclient.models import SnmpV3Credentials

creds = SnmpV3Credentials(
authlevel="authPriv",
authname="snmpuser",
authpass="authSecret123",
authalgo="SHA",
cryptopass="cryptoSecret456",
cryptoalgo="AES",
)

response = client.devices.add_device(
"10.0.0.1",
snmpver="v3",
snmp_v3=creds,
)
```

### Auth Levels

| Level | Description | Required Fields |
|----------------|----------------------------------|----------------------------------------------------------------|
| `noAuthNoPriv` | No authentication, no encryption | — |
| `authNoPriv` | Authentication only | `authname`, `authpass`, `authalgo` |
| `authPriv` | Authentication + encryption | `authname`, `authpass`, `authalgo`, `cryptopass`, `cryptoalgo` |

### Supported Algorithms

- **Auth**: `MD5`, `SHA`, `SHA-224`, `SHA-256`, `SHA-384`, `SHA-512`
- **Crypto**: `AES`, `AES-192`, `AES-256`, `AES-256-C`, `DES`

---

## ICMP Only (Disable SNMP)

For devices that don't support SNMP, set `snmp_disable=True`. You can optionally
provide device metadata via the `icmp_device` parameter.

```python
from libreclient.models import IcmpOnlyDevice

response = client.devices.add_device(
"10.0.0.50",
snmp_disable=True,
)

# With device metadata
icmp = IcmpOnlyDevice(
os="linux",
sys_name="web-server-01",
hardware="x86_64",
)

response = client.devices.add_device(
"10.0.0.50",
snmp_disable=True,
icmp_device=icmp,
)
```

!!! note
When `snmp_disable=True` and no `icmp_device` is provided, the device OS
defaults to `"ping"`.

---

## Common Options

All examples above support these additional parameters:

```python
response = client.devices.add_device(
"10.0.0.1",
snmpver="v2c",
community="public",
# Display name (supports templates)
display="{{ $hostname }}",
# SNMP connection settings
port=161,
transport="udp", # udp, tcp, udp6, tcp6
# Port identification method
port_association_mode="ifIndex", # ifIndex, ifName, ifDescr, ifAlias
# Distributed polling
poller_group=1,
# Location (mutually exclusive — use one or the other)
location="DC1 Row A Rack 5",
# location_id=42,
# Skip discovery checks
force_add=True,
# Fall back to ping if SNMP fails
ping_fallback=True,
)
```

| Parameter | Type | Default | Description |
|-------------------------|--------|----------------|------------------------------------------------------------------------------------------------------|
| `display` | `str` | hostname | Display name. Templates: `{{ $hostname }}`, `{{ $sysName }}`, `{{ $sysName_fallback }}`, `{{ $ip }}` |
| `port` | `int` | config default | SNMP port |
| `transport` | `str` | config default | `udp`, `tcp`, `udp6`, `tcp6` |
| `port_association_mode` | `str` | `ifIndex` | `ifIndex`, `ifName`, `ifDescr`, `ifAlias` |
| `poller_group` | `int` | `0` | Poller group ID for distributed polling |
| `force_add` | `bool` | `False` | Skip all checks, add directly (credentials required) |
| `ping_fallback` | `bool` | `False` | Add as ping-only if SNMP fails |
| `location` | `str` | — | Set location by text |
| `location_id` | `int` | — | Set location by ID |

!!! warning
You cannot specify both `location` and `location_id` — a `ValueError` will be raised.
When either is set, `override_sysLocation` is automatically enabled.

---

## API Reference

::: libreclient.routes.devices.Devices.add_device
handler: python
options:
show_root_heading: false
show_source: false
heading_level: 3

::: libreclient.models.devices.SnmpV3Credentials
handler: python
options:
show_root_heading: true
show_source: false
heading_level: 3

::: libreclient.models.devices.IcmpOnlyDevice
handler: python
options:
show_root_heading: true
show_source: false
heading_level: 3
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ build-backend = "uv_build"
dev = [
"commitizen>=4.16.3",
"complexipy>=5.5.0",
"mkdocstrings-python>=2.0.4",
"pytest>=9.0.3",
"python-dotenv>=1.2.2",
"ruff>=0.15.16",
Expand Down
83 changes: 70 additions & 13 deletions scripts/generate_stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ def collect_imports_from_async(source: str) -> list[str]:
import_lines: list[str] = ["from __future__ import annotations", ""]
for node in ast.iter_child_nodes(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
if isinstance(node, ast.ImportFrom):
if node.module and node.module.startswith("__future__"):
continue
if node.module and ("_synchronicity" in node.module):
continue
if node.module and node.module.endswith("._base"):
continue
if (
isinstance(node, ast.ImportFrom)
and node.module
and (
node.module.startswith("__future__")
or "_synchronicity" in node.module
or node.module.endswith("._base")
)
):
continue
# Filter out private utilities from _types imports (not needed in stubs)
if (
isinstance(node, ast.ImportFrom)
Expand All @@ -47,8 +50,36 @@ def collect_imports_from_async(source: str) -> list[str]:
return import_lines


def collect_type_aliases(source: str) -> list[str]:
"""Collect module-level type alias assignments from the async source module.

These are assignments like ``DeviceListType = typing.Literal[...]`` that need
to be reproduced in the stub so that type references resolve correctly.
"""
tree = ast.parse(source)
alias_lines: list[str] = []
for node in ast.iter_child_nodes(tree):
if (
isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id[0].isupper()
) or (
isinstance(node, ast.AnnAssign)
and isinstance(node.target, ast.Name)
and node.value
):
alias_lines.append(ast.unparse(node))
if alias_lines:
alias_lines.append("")
alias_lines.append("")
return alias_lines


def generate_method_stub(
func_node: ast.FunctionDef | ast.AsyncFunctionDef, imports_needed: set[str]
func_node: ast.FunctionDef | ast.AsyncFunctionDef,
imports_needed: set[str],
source_filename: str = "",
) -> str:
"""Generate a sync method stub from an async function definition."""
# Get the signature
Expand Down Expand Up @@ -102,18 +133,24 @@ def generate_method_stub(
return_ann = f" -> {ast.unparse(func_node.returns)}"

params_str = ", ".join(params)
signature = f" def {func_node.name}({params_str}){return_ann}: ..."

# Build the Better Highlights link comment for PyCharm navigation
link_comment = ""
if source_filename and func_node.name != "__init__":
link_comment = f" # [[{source_filename}#{func_node.name}]]"

signature = f" def {func_node.name}({params_str}){return_ann}: ...{link_comment}"

# Add docstring as a comment for IDE hover
docstring = ast.get_docstring(func_node)
if docstring:
# Use a proper docstring in the stub
doc_lines = docstring.strip().split("\n")
if len(doc_lines) == 1:
signature = f' def {func_node.name}({params_str}){return_ann}:\n """{doc_lines[0]}"""\n ...'
signature = f' def {func_node.name}({params_str}){return_ann}:\n """{doc_lines[0]}"""\n ...{link_comment}'
else:
doc_body = "\n ".join(doc_lines)
signature = f' def {func_node.name}({params_str}){return_ann}:\n """{doc_body}"""\n ...'
signature = f' def {func_node.name}({params_str}){return_ann}:\n """{doc_body}"""\n ...{link_comment}'

return signature

Expand Down Expand Up @@ -162,9 +199,26 @@ def main():
stub_file = ROUTES_DIR / f"{sync_file.stem}.pyi"
print(f"Generating {stub_file.name} from {async_file.name}...")

# Build module docstring for the stub
async_rel_path = f"src/libreclient/routes/{async_file.name}"
module_doc = (
f'"""\n'
f"Auto-generated stub for {sync_file.stem} (synchronous API).\n"
f"\n"
f"This file provides type information for IDE support and static analysis.\n"
f"The actual implementation is generated at runtime via synchronicity from:\n"
f" {async_rel_path}\n"
f'"""\n\n'
)

# Collect imports from the async module
import_lines = collect_imports_from_async(async_source)
stub_content = "\n".join(import_lines)
stub_content = module_doc + "\n".join(import_lines)

# Collect module-level type aliases (e.g. Literal types)
type_aliases = collect_type_aliases(async_source)
if type_aliases:
stub_content += "\n".join(type_aliases)

# Generate sync class stub that mirrors the async class but with plain def
stub_content += f"class {sync_name}:\n"
Expand All @@ -191,7 +245,10 @@ def main():
method, (ast.FunctionDef, ast.AsyncFunctionDef)
):
stub_content += (
generate_method_stub(method, set()) + "\n"
generate_method_stub(
method, set(), async_file.name
)
+ "\n"
)
# Record for __init__ generation
route_entries.append((async_module, async_class, sync_name))
Expand Down
Loading