From 2cef8b9563d19825d295ef7aea451874549bff18 Mon Sep 17 00:00:00 2001 From: Justin Jeffery Date: Thu, 11 Jun 2026 13:40:59 -0400 Subject: [PATCH 1/2] feat: Update add_device function with correct args. (#28) --- .githooks/pre-commit | 2 +- docs/examples/add_device.md | 200 +++++++++++++++++++++++++++++ pyproject.toml | 1 + scripts/generate_stubs.py | 83 ++++++++++-- src/libreclient/models/__init__.py | 4 + src/libreclient/models/devices.py | 74 ++++++++++- src/libreclient/routes/devices.py | 124 ++++++++++++++++-- tests/unit/routes/test_devices.py | 1 + uv.lock | 190 +++++++++++++++++++++++++++ zensical.toml | 14 ++ 10 files changed, 669 insertions(+), 24 deletions(-) create mode 100644 docs/examples/add_device.md diff --git a/.githooks/pre-commit b/.githooks/pre-commit index ccb8bad..cf37acf 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -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 diff --git a/docs/examples/add_device.md b/docs/examples/add_device.md new file mode 100644 index 0000000..b141a44 --- /dev/null +++ b/docs/examples/add_device.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index b16a1c5..8c27eb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/scripts/generate_stubs.py b/scripts/generate_stubs.py index b0edcba..d0aa1b9 100644 --- a/scripts/generate_stubs.py +++ b/scripts/generate_stubs.py @@ -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) @@ -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 @@ -102,7 +133,13 @@ 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) @@ -110,10 +147,10 @@ def generate_method_stub( # 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 @@ -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" @@ -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)) diff --git a/src/libreclient/models/__init__.py b/src/libreclient/models/__init__.py index eac59fb..1b7da7a 100644 --- a/src/libreclient/models/__init__.py +++ b/src/libreclient/models/__init__.py @@ -22,6 +22,8 @@ DevicePortsResponse, DeviceResponse, DevicesResponse, + IcmpOnlyDevice, + SnmpV3Credentials, ) from .index import IndexResponse from .inventory import InventoryResponse @@ -60,6 +62,7 @@ "DevicePortsResponse", "DeviceResponse", "DevicesResponse", + "IcmpOnlyDevice", "IndexResponse", "InventoryResponse", "ListResponse", @@ -78,6 +81,7 @@ "RoutingResponse", "RulesResponse", "ServicesResponse", + "SnmpV3Credentials", "SwitchingResponse", "SystemResponse", ] diff --git a/src/libreclient/models/devices.py b/src/libreclient/models/devices.py index 96e616a..562eb85 100644 --- a/src/libreclient/models/devices.py +++ b/src/libreclient/models/devices.py @@ -1,11 +1,81 @@ -"""Response models for Devices routes.""" +"""Response and request models for Devices routes.""" from __future__ import annotations -from pydantic import Field +import typing + +from pydantic import BaseModel, Field from ._base import ListResponse +SnmpV3AuthLevel = typing.Literal["noAuthNoPriv", "authNoPriv", "authPriv"] +SnmpV3AuthAlgo = typing.Literal[ + "MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512" +] +SnmpV3CryptoAlgo = typing.Literal[ + "AES", "AES-192", "AES-256", "AES-256-C", "DES" +] + + +class SnmpV3Credentials(BaseModel): + """SNMPv3 authentication and encryption credentials. + + Use this when adding a device with ``snmpver='v3'``. + + Example:: + + creds = SnmpV3Credentials( + authlevel="authPriv", + authname="myuser", + authpass="secret", + authalgo="SHA", + cryptopass="encrypt_secret", + cryptoalgo="AES", + ) + client.devices.add_device("10.0.0.1", snmpver="v3", snmp_v3=creds) + """ + + authlevel: SnmpV3AuthLevel = "noAuthNoPriv" + """SNMP auth level (noAuthNoPriv, authNoPriv, authPriv).""" + + authname: str | None = None + """SNMP auth username. Required for authNoPriv and authPriv.""" + + authpass: str | None = None + """SNMP auth password. Required for authNoPriv and authPriv.""" + + authalgo: SnmpV3AuthAlgo = "SHA" + """SNMP auth algorithm (MD5, SHA, SHA-224, SHA-256, SHA-384, SHA-512).""" + + cryptopass: str | None = None + """SNMP crypto password. Required for authPriv.""" + + cryptoalgo: SnmpV3CryptoAlgo = "AES" + """SNMP crypto algorithm (AES, AES-192, AES-256, AES-256-C, DES).""" + + +class IcmpOnlyDevice(BaseModel): + """Additional fields for ICMP-only (snmp_disable) devices. + + Use this when adding a device with ``snmp_disable=True``. + + Example:: + + icmp = IcmpOnlyDevice(os="linux", sys_name="myhost", hardware="x86_64") + client.devices.add_device("10.0.0.1", snmp_disable=True, icmp_device=icmp) + """ + + model_config = {"populate_by_name": True} + + os: str = "ping" + """OS short name for the device. Defaults to 'ping'.""" + + sys_name: str | None = Field(default=None, alias="sysName") + """sysName for the device.""" + + hardware: str | None = None + """Device hardware.""" + class DevicesResponse(ListResponse): """Response from list_devices.""" diff --git a/src/libreclient/routes/devices.py b/src/libreclient/routes/devices.py index dce4f09..d8dc434 100644 --- a/src/libreclient/routes/devices.py +++ b/src/libreclient/routes/devices.py @@ -11,6 +11,8 @@ DevicePortsResponse, DeviceResponse, DevicesResponse, + IcmpOnlyDevice, + SnmpV3Credentials, ) from ._types import ( ClientProtocol, @@ -43,6 +45,12 @@ "features", ] +SnmpVersion = typing.Literal["v1", "v2c", "v3"] + +SnmpTransport = typing.Literal["udp", "tcp", "udp6", "tcp6"] + +PortAssociationMode = typing.Literal["ifIndex", "ifName", "ifDescr", "ifAlias"] + class Devices: """Async route namespace bound to a client transport.""" @@ -571,15 +579,100 @@ async def maintenance_device( ) return ApiResponse.model_validate(data) - async def add_device(self, hostname: str, **kwargs) -> ApiResponse: + async def add_device( + self, # NOSONAR + hostname: str, + display: str | None = None, + snmpver: SnmpVersion | None = None, + community: str | None = None, + snmp_v3: SnmpV3Credentials | None = None, + port: int | None = None, + transport: SnmpTransport | None = None, + port_association_mode: PortAssociationMode | None = None, + poller_group: int = 0, + force_add: bool = False, + ping_fallback: bool = False, + snmp_disable: bool = False, + icmp_device: IcmpOnlyDevice | None = None, + location: str | None = None, + location_id: int | None = None, + **kwargs, + ) -> ApiResponse: """Add a new device. Route: POST /api/v0/devices - :param hostname: Hostname or IP of the device to add. - :param kwargs: Additional device fields (snmpver, community, port, transport, etc.). - """ - payload = {"hostname": hostname, **kwargs} + :param hostname: Device hostname or IP address (required). + :param display: Display name for the device. Supports templates: + ``{{ $hostname }}``, ``{{ $sysName }}``, ``{{ $sysName_fallback }}``, ``{{ $ip }}``. + Defaults to hostname (or device_display_default setting). + :param snmpver: SNMP version — ``'v1'``, ``'v2c'``, or ``'v3'``. + Defaults to None to use the LibreNMS global config default. + :param community: SNMP community string. Required when snmpver is ``'v1'`` or ``'v2c'``. + :param snmp_v3: SNMPv3 credentials. Required when snmpver is ``'v3'``. Use a + :class:`~libreclient.models.devices.SnmpV3Credentials` instance to supply + authlevel, authname, authpass, authalgo, cryptopass, and cryptoalgo. + :param port: SNMP port. Defaults to the port defined in LibreNMS config. + :param transport: SNMP transport protocol — ``'udp'``, ``'tcp'``, ``'udp6'``, or ``'tcp6'``. + Defaults to the transport defined in LibreNMS config. + :param port_association_mode: Method to identify ports — + ``'ifIndex'``, ``'ifName'``, ``'ifDescr'``, or ``'ifAlias'``. + :param poller_group: Poller group ID for distributed polling. Defaults to 0. + :param force_add: Skip all checks and add the device directly. SNMP credentials + are required when using this option. + :param ping_fallback: If SNMP checks fail, add the device as ping-only + instead of failing. + :param snmp_disable: If True, disable SNMP and use ICMP only. + :param icmp_device: Additional device info for ICMP-only mode. Use a + :class:`~libreclient.models.devices.IcmpOnlyDevice` instance to supply + os, sysName, and hardware. Only used when snmp_disable is True. + :param location: Set device location by text (mutually exclusive with location_id). + :param location_id: Set device location by ID (mutually exclusive with location). + :raises ValueError: If both location and location_id are provided, or if + snmpver is v1/v2c without community, or v3 without snmp_v3. + """ + if location is not None and location_id is not None: + raise ValueError( + "Only one of 'location' or 'location_id' may be specified, not both." + ) + if snmpver in ("v1", "v2c") and not community: + raise ValueError( + f"'community' is required when snmpver is '{snmpver}'." + ) + if snmpver == "v3" and snmp_v3 is None: + raise ValueError( + "'snmp_v3' credentials are required when snmpver is 'v3'." + ) + + payload: dict = { + "hostname": hostname, + "poller_group": poller_group, + **_compact( + display=display, + snmpver=snmpver, + community=community, + port=port, + transport=transport, + port_association_mode=port_association_mode, + force_add=1 if force_add else None, + ping_fallback=1 if ping_fallback else None, + snmp_disable=1 if snmp_disable else None, + location=location, + location_id=location_id, + override_sysLocation=1 + if (location is not None or location_id is not None) + else None, + ), + } + + if snmp_v3 is not None: + payload.update(snmp_v3.model_dump(mode="json", exclude_none=True)) + + if snmp_disable and icmp_device is not None: + payload.update( + icmp_device.model_dump(by_alias=True, exclude_none=True) + ) + data = await self._client._post("/devices", json=payload) return ApiResponse.model_validate(data) @@ -599,10 +692,10 @@ async def list_oxidized(self, hostname: str | None = None) -> list[dict]: # API returns a raw list (not wrapped in a dict) if isinstance(data, list): return data - return ApiResponse.model_validate(data) + return [] async def update_device_field( - self, hostname: str, field: str | list, data: str | list + self, hostname: str, field: str | list[str], data: str | list[str] ) -> ApiResponse: """Update a device field in the database. @@ -611,7 +704,22 @@ async def update_device_field( :param hostname: Device hostname or ID. :param field: Field name or list of field names to update. :param data: New value or list of values corresponding to fields. - """ + :raises ValueError: If field and data are both lists but have different lengths, + or if one is a list and the other is not. + """ + if isinstance(field, list) != isinstance(data, list): + raise ValueError( + "'field' and 'data' must both be strings or both be lists." + ) + if ( + isinstance(field, list) + and isinstance(data, list) + and len(field) != len(data) + ): + raise ValueError( + f"'field' and 'data' lists must have the same length " + f"(got {len(field)} fields and {len(data)} values)." + ) resp = await self._client._patch( f"/devices/{hostname}", json={"field": field, "data": data} ) diff --git a/tests/unit/routes/test_devices.py b/tests/unit/routes/test_devices.py index b1e88ee..4763b71 100644 --- a/tests/unit/routes/test_devices.py +++ b/tests/unit/routes/test_devices.py @@ -76,6 +76,7 @@ def test_posts_payload(self, mock_client) -> None: "/devices", json={ "hostname": "newhost", + "poller_group": 0, "snmpver": "v2c", "community": "public", }, diff --git a/uv.lock b/uv.lock index 1d4e3a9..0d2d89e 100644 --- a/uv.lock +++ b/uv.lock @@ -222,6 +222,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -321,6 +342,7 @@ dependencies = [ dev = [ { name = "commitizen" }, { name = "complexipy" }, + { name = "mkdocstrings-python" }, { name = "pytest" }, { name = "python-dotenv" }, { name = "ruff" }, @@ -339,6 +361,7 @@ requires-dist = [ dev = [ { name = "commitizen", specifier = ">=4.16.3" }, { name = "complexipy", specifier = ">=5.5.0" }, + { name = "mkdocstrings-python", specifier = ">=2.0.4" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "ruff", specifier = ">=0.15.16" }, @@ -438,6 +461,98 @@ 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 = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/b4/5fed370d8ebd96e4e399460a7146ae989263f16588b05a6facd6dbd51e60/mkdocstrings_python-2.0.4.tar.gz", hash = "sha256:58c73c5d358e64e9b1673447663f4a2f8a8941e392e225fc0a0c893758cc452f", size = 199219, upload-time = "2026-06-05T08:13:01.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/e3/00ec594aef5f55522e6d373bc2ac53e53a8f5e9ae32f2d6854b0de4270f3/mkdocstrings_python-2.0.4-py3-none-any.whl", hash = "sha256:fd87c173e1e719a85997b6d4f852cdc55f36710e0ed08da3a7bd9abe79c9db00", size = 104790, upload-time = "2026-06-05T08:13:00.393Z" }, +] + [[package]] name = "niquests" version = "3.19.1" @@ -461,6 +576,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -624,6 +757,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -679,6 +824,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "qh3" version = "1.9.2" @@ -797,6 +954,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "synchronicity" version = "0.12.3" @@ -931,6 +1097,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/f9/077b0aacd337c31d3f97eef7acfa89bcd522360b56614f961af84db0a3ca/wassima-2.1.0-py3-none-any.whl", hash = "sha256:4e8bc4b439f91f4da24bd2260be662fcb42d1cc439d7678420237d314e55a854", size = 128092, upload-time = "2026-05-10T04:07:19.9Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "wcwidth" version = "0.8.1" diff --git a/zensical.toml b/zensical.toml index 1714a7e..2349041 100644 --- a/zensical.toml +++ b/zensical.toml @@ -58,6 +58,11 @@ Copyright © 2026 Justin Jeffery # Read more: https://zensical.org/docs/setup/navigation/ nav = [ {"Getting Started" = "index.md"}, + { + "Examples" = [ + "examples/add_device.md", + ] + }, {"Support" = "support.md"}, { "Development" = [ @@ -382,3 +387,12 @@ custom_checkbox = true [project.markdown_extensions.zensical.extensions.macros] module_name = "scripts/zensical_macros" + +[project.plugins.mkdocstrings.handlers.python] +inventories = ["https://docs.python.org/3/objects.inv"] +paths = ["src"] + +[project.plugins.mkdocstrings.handlers.python.options] +docstring_style = "sphinx" +inherited_members = true +show_source = false From 451fe20efcf8c07a017839309d20b7c39c34a2cf Mon Sep 17 00:00:00 2001 From: Justin Jeffery Date: Thu, 11 Jun 2026 14:03:08 -0400 Subject: [PATCH 2/2] feat: Find .env in home directory - useful for srcipts. (#29) --- README.md | 8 +++++++- src/libreclient/config.py | 12 +++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e2e901e..abbdf96 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/libreclient/config.py b/src/libreclient/config.py index f926dd6..902e985 100644 --- a/src/libreclient/config.py +++ b/src/libreclient/config.py @@ -3,21 +3,31 @@ """ import re +from pathlib import Path +from dotenv import find_dotenv from pydantic import AnyHttpUrl, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +def _find_env_file() -> str: + """Locate .env file: search from cwd upward, fallback to ~/.env.""" + return find_dotenv(usecwd=True) or str(Path("~").expanduser() / ".env") + + class LibreConfig(BaseSettings): """Configuration for the LibreNMS API client. Values can be supplied directly or via environment variables prefixed with ``LIBRENMS_`` (e.g. ``LIBRENMS_URL``, ``LIBRENMS_TOKEN``). + + The ``.env`` file is auto-discovered by searching from the current working + directory upward, falling back to ``~/.env`` if not found. """ model_config = SettingsConfigDict( env_prefix="LIBRENMS_", - env_file=".env", + env_file=_find_env_file(), env_file_encoding="utf-8", extra="ignore", )