From 49ae197b5e64ebba7ddcb6ce591521902fc5d64c Mon Sep 17 00:00:00 2001 From: Justin Jeffery <34625666+jjeff07@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:41:22 -0400 Subject: [PATCH 1/3] Refactor stubs (#19) * refactor: Move sync routes class to condense stub files. Move scripts to its own directory and update docs. * style: Add more commit hooks. * ci(lint): auto-fix lint and format [skip ci] * style: Fix complexipy exclude. * style: Fix format git hook. --------- Co-authored-by: github-actions[bot] --- .githooks/commit-msg | 2 +- .githooks/pre-commit | 55 ++++ .github/workflows/ci.yml | 8 +- .github/workflows/release.yml | 2 +- .gitignore | 2 +- COMMIT_TYPES.md | 83 ++++++ CONTRIBUTING.md | 13 +- README.md | 67 +++-- generate_stubs.py | 263 ------------------ pyproject.toml | 9 +- .../check_upstream.py | 0 scripts/generate_stubs.py | 261 +++++++++++++++++ .../upstream_tracking.toml | 0 src/libreclient/__init__.pyi | 4 - src/libreclient/routes/__init__.py | 38 +-- src/libreclient/routes/alerts.py | 16 +- src/libreclient/routes/alerts_sync.py | 8 + src/libreclient/routes/arp.py | 4 - src/libreclient/routes/arp_sync.py | 6 + src/libreclient/routes/bills.py | 10 +- src/libreclient/routes/bills_sync.py | 6 + src/libreclient/routes/device_groups.py | 12 +- src/libreclient/routes/device_groups_sync.py | 8 + src/libreclient/routes/devices.py | 10 +- src/libreclient/routes/devices_sync.py | 8 + src/libreclient/routes/index.py | 4 - src/libreclient/routes/index_sync.py | 6 + src/libreclient/routes/inventory.py | 6 - src/libreclient/routes/inventory_sync.py | 8 + src/libreclient/routes/locations.py | 6 - src/libreclient/routes/locations_sync.py | 8 + src/libreclient/routes/logs.py | 4 - src/libreclient/routes/logs_sync.py | 6 + src/libreclient/routes/poller_groups.py | 6 - src/libreclient/routes/poller_groups_sync.py | 8 + src/libreclient/routes/pollers.py | 6 - src/libreclient/routes/pollers_sync.py | 8 + src/libreclient/routes/port_groups.py | 6 - src/libreclient/routes/port_groups_sync.py | 8 + src/libreclient/routes/port_security.py | 6 - src/libreclient/routes/port_security_sync.py | 8 + src/libreclient/routes/portgroups.py | 6 - src/libreclient/routes/portgroups_sync.py | 8 + src/libreclient/routes/ports.py | 9 +- src/libreclient/routes/ports_sync.py | 6 + src/libreclient/routes/routing.py | 12 +- src/libreclient/routes/routing_sync.py | 8 + src/libreclient/routes/services.py | 14 +- src/libreclient/routes/services_sync.py | 8 + src/libreclient/routes/switching.py | 6 - src/libreclient/routes/switching_sync.py | 8 + src/libreclient/routes/system.py | 6 - src/libreclient/routes/system_sync.py | 8 + uv.lock | 4 + 54 files changed, 650 insertions(+), 452 deletions(-) create mode 100644 .githooks/pre-commit create mode 100644 COMMIT_TYPES.md delete mode 100644 generate_stubs.py rename check_upstream.py => scripts/check_upstream.py (100%) create mode 100644 scripts/generate_stubs.py rename upstream_tracking.toml => scripts/upstream_tracking.toml (100%) delete mode 100644 src/libreclient/__init__.pyi create mode 100644 src/libreclient/routes/alerts_sync.py create mode 100644 src/libreclient/routes/arp_sync.py create mode 100644 src/libreclient/routes/bills_sync.py create mode 100644 src/libreclient/routes/device_groups_sync.py create mode 100644 src/libreclient/routes/devices_sync.py create mode 100644 src/libreclient/routes/index_sync.py create mode 100644 src/libreclient/routes/inventory_sync.py create mode 100644 src/libreclient/routes/locations_sync.py create mode 100644 src/libreclient/routes/logs_sync.py create mode 100644 src/libreclient/routes/poller_groups_sync.py create mode 100644 src/libreclient/routes/pollers_sync.py create mode 100644 src/libreclient/routes/port_groups_sync.py create mode 100644 src/libreclient/routes/port_security_sync.py create mode 100644 src/libreclient/routes/portgroups_sync.py create mode 100644 src/libreclient/routes/ports_sync.py create mode 100644 src/libreclient/routes/routing_sync.py create mode 100644 src/libreclient/routes/services_sync.py create mode 100644 src/libreclient/routes/switching_sync.py create mode 100644 src/libreclient/routes/system_sync.py diff --git a/.githooks/commit-msg b/.githooks/commit-msg index a9c311f..34d6b8f 100644 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -3,4 +3,4 @@ # Install: git config core.hooksPath .githooks # Use python -m to avoid Windows .exe access issues -exec python -m commitizen check --commit-msg-file "$1" +exec uv run python -m commitizen check --commit-msg-file "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..bf5a6f2 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,55 @@ +#!/bin/sh +# Run ruff lint and format on staged Python files. +# Install: git config core.hooksPath .githooks + +# Get staged .py files (excluding deleted files) +STAGED_PY=$(git diff --cached --name-only --diff-filter=d -- '**/*.py') + +if [ -z "$STAGED_PY" ]; then + exit 0 +fi + +# Check if running in an environment where ruff is available directly +if command -v ruff >/dev/null 2>&1; then + RUFF="ruff" +else + RUFF="uv run ruff" +fi + +# Lint with auto-fix +echo "Running ruff check..." +$RUFF check --fix $STAGED_PY +LINT_EXIT=$? + +# Format +echo "Running ruff format..." +$RUFF format $STAGED_PY +FORMAT_EXIT=$? + +# Re-stage any auto-fixed files +git add $STAGED_PY + +# Fail if lint had unfixable errors +if [ $LINT_EXIT -ne 0 ]; then + echo "ruff check found issues that could not be auto-fixed." + exit 1 +fi + +# Run complexipy to check cognitive complexity +if command -v complexipy >/dev/null 2>&1; then + COMPLEXIPY="complexipy" +else + COMPLEXIPY="uv run complexipy" +fi + +echo "Running complexipy..." +$COMPLEXIPY $STAGED_PY --max-complexity-allowed 15 +COMPLEXITY_EXIT=$? + +if [ $COMPLEXITY_EXIT -ne 0 ]; then + echo "complexipy found functions exceeding max cognitive complexity (15)." + exit 1 +fi + +exit 0 + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56e27ac..87a17e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,13 @@ jobs: run: uv sync - name: Run complexipy - run: uv run complexipy . + run: uv run complexipy . --output-format sarif --output complexipy-results.sarif --failed + + - name: Upload SARIF results + if: always() && hashFiles('complexipy-results.sarif') != '' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: complexipy-results.sarif test: name: Tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b7de7e..7d7dd8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -243,7 +243,7 @@ jobs: # --- Generate Type Stubs --- - name: Generate .pyi stubs if: steps.decide.outputs.skip != 'true' - run: uv run python generate_stubs.py + run: uv run python scripts/generate_stubs.py # --- Build --- - name: Build package diff --git a/.gitignore b/.gitignore index 46a044b..c86888d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ wheels/ # Virtual environments .venv -# Auto-generated type stubs (regenerate with: python generate_stubs.py) +# Auto-generated type stubs (regenerate with: python scripts/generate_stubs.py) src/py_librenms/routes/*.pyi # Secrets diff --git a/COMMIT_TYPES.md b/COMMIT_TYPES.md new file mode 100644 index 0000000..faec22d --- /dev/null +++ b/COMMIT_TYPES.md @@ -0,0 +1,83 @@ +# Commit Types Reference + +This project uses [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). Every commit message must +follow the format: + +``` +type(scope)?: description + +[optional body] +[optional footer] +``` + +## Allowed Types + +| Type | Description | +|------------|-----------------------------------------------------------------------------| +| `feat` | A new feature or user-facing enhancement | +| `fix` | A bug fix | +| `docs` | Documentation-only changes (README, docstrings, comments) | +| `style` | Code style changes that do not affect logic (formatting, whitespace) | +| `refactor` | Code restructuring that neither fixes a bug nor adds a feature | +| `perf` | Performance improvements | +| `test` | Adding or updating tests (no production code changes) | +| `build` | Changes to the build system or dependencies (pyproject.toml, uv.lock, etc.) | +| `ci` | Changes to CI/CD configuration (GitHub Actions, workflows) | +| `chore` | Routine maintenance tasks that don't modify src or test files | +| `revert` | Reverts a previous commit | +| `bump` | Version bump | + +## Examples + +``` +feat(routing): add OSPFv3 port listing +fix: handle empty response from list_devices +docs: add upstream tracking section to README +test: add functional tests for switching routes +refactor(models): simplify base response inheritance +perf(devices): reduce redundant API calls in list_devices +build: bump pydantic to 2.14.0 +ci: add Python 3.13 to test matrix +chore: remove unused test fixtures +style: fix trailing whitespace in routes module +revert: revert "feat(ports): add port graph endpoint" +bump: 0.1.3 +``` + +## Scope (Optional) + +The scope provides additional context about what area of the codebase is affected. Common scopes in this project: + +| Scope | Usage | +|-----------------|-------------------------------| +| `alerts` | Alert routes or models | +| `arp` | ARP routes or models | +| `bills` | Billing routes or models | +| `devices` | Device routes or models | +| `device_groups` | Device group routes or models | +| `inventory` | Inventory routes or models | +| `locations` | Location routes or models | +| `logs` | Log routes or models | +| `ports` | Port routes or models | +| `port_groups` | Port group routes or models | +| `routing` | Routing routes or models | +| `services` | Service routes or models | +| `switching` | Switching routes or models | +| `system` | System routes or models | +| `models` | Cross-cutting model changes | +| `routes` | Cross-cutting route changes | +| `config` | Configuration changes | +| `client` | Client class changes | + +## Validation + +Commit messages are validated automatically by a `commit-msg` git hook using +[commitizen](https://commitizen-tools.github.io/commitizen/). If your message doesn't match the format, the commit will +be rejected with an error explaining what went wrong. + +**Setup the hook (once per clone):** + +```bash +git config core.hooksPath .githooks +``` + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f40db9..f883b8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,16 +31,17 @@ git config core.hooksPath .githooks - All code is linted and formatted by [Ruff](https://docs.astral.sh/ruff/) (CI will auto-fix minor issues) - Maximum cognitive complexity of 15 per function (enforced by [complexipy](https://github.com/rohaquinlop/complexipy)) - Tests are required for new features and bug fixes -- Commit messages must follow Conventional Commits format +- Commit messages must follow Conventional Commits format (see [COMMIT_TYPES.md](COMMIT_TYPES.md) for quick reference) ## Adding a New Route 1. Create `src/libreclient/routes/myroute.py` with an async class -2. Create `src/libreclient/models/myroute.py` with Pydantic response models -3. Add exports to `routes/__init__.py` and `models/__init__.py` -4. Wire up in `client.py` (both sync and async) -5. Run `uv run python generate_stubs.py` -6. Add unit tests in `tests/unit/routes/` and `tests/unit/models/` +2. Create `src/libreclient/routes/myroute_sync.py` with `MyRouteSync = synchronizer.wrap(...)` +3. Create `src/libreclient/models/myroute.py` with Pydantic response models +4. Add exports to `models/__init__.py` +5. Wire up in `client.py` (both sync and async) +6. Run `uv run python scripts/generate_stubs.py` (regenerates stubs and `routes/__init__.py`) +7. Add unit tests in `tests/unit/routes/` and `tests/unit/models/` ## Running Tests diff --git a/README.md b/README.md index 045f170..8ce8579 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,21 @@ cd libreclient uv sync ``` +#### Git Hooks + +This project uses custom git hooks in the `.githooks/` directory. + +**Setup (once per clone):** + +```bash +git config core.hooksPath .githooks +``` + +| Hook | Purpose | +|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `pre-commit` | Runs `ruff check --fix` and `ruff format` on staged `.py` files, re-stages fixes, then runs `complexipy` to enforce max cognitive complexity (15). | +| `commit-msg` | Validates commit messages against Conventional Commits format via commitizen. | + ### Running Tests ```bash @@ -199,9 +214,9 @@ The [synchronicity](https://github.com/modal-com/synchronicity) library then wra synchronous counterpart at runtime. ``` -src/py_librenms/routes/alerts.py -├── class Alerts ← async implementation (the only code you write) -└── AlertsSync = synchronizer.wrap(Alerts, ...) ← sync wrapper (auto-generated at import) +src/libreclient/routes/ +├── alerts.py ← async implementation (the only code you write) +└── alerts_sync.py ← sync wrapper (imports Alerts, wraps with synchronizer) ``` This means: @@ -218,7 +233,7 @@ autocomplete and type checking, `.pyi` stub files are auto-generated. **Regenerate stubs locally:** ```bash -uv run python generate_stubs.py +uv run python scripts/generate_stubs.py ``` Stubs are generated automatically during the GitHub Actions release workflow, so you don't need to commit them — they're @@ -226,13 +241,12 @@ in `.gitignore`. ### Adding a New Route -1. Create `src/py_librenms/routes/myroute.py` with an async class and `MyRouteSync = synchronizer.wrap(...)` at the - bottom. -2. Create `src/py_librenms/models/myroute.py` with Pydantic response models. -3. Add exports to `src/py_librenms/models/__init__.py`. -4. Add exports to `src/py_librenms/routes/__init__.py`. -5. Wire up the route in `src/py_librenms/client.py` (both sync and async clients). -6. Run `uv run python generate_stubs.py`. +1. Create `src/libreclient/routes/myroute.py` with an async class. +2. Create `src/libreclient/routes/myroute_sync.py` with `MyRouteSync = synchronizer.wrap(...)`. +3. Create `src/libreclient/models/myroute.py` with Pydantic response models. +4. Add exports to `src/libreclient/models/__init__.py`. +5. Wire up the route in `src/libreclient/client.py` (both sync and async clients). +6. Run `uv run python scripts/generate_stubs.py` to regenerate stubs and `__init__` files. 7. Add tests in `tests/unit/routes/test_myroute.py` and `tests/unit/models/test_myroute.py`. ### Commit Convention @@ -240,12 +254,6 @@ in `.gitignore`. This project enforces [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) via [commitizen](https://commitizen-tools.github.io/commitizen/). A git hook validates every commit message automatically. -**Setup the hook (once per clone):** - -```bash -git config core.hooksPath .githooks -``` - **Format:** ``` @@ -257,6 +265,8 @@ type(scope)?: description **Allowed types:** `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`, `bump` +> See [COMMIT_TYPES.md](COMMIT_TYPES.md) for full definitions and scope conventions. + **Examples:** ``` @@ -273,26 +283,26 @@ This project tracks which LibreNMS release tag the route implementations are bas ```bash # Check if upstream has a newer release -python check_upstream.py +python scripts/check_upstream.py # See which API doc files changed -python check_upstream.py --diff +python scripts/check_upstream.py --diff # See full unified diffs of changed docs -python check_upstream.py --full +python scripts/check_upstream.py --full # Compare against a specific tag instead of latest -python check_upstream.py --diff --tag 26.6.0 +python scripts/check_upstream.py --diff --tag 26.6.0 # Bump the pinned tag after reviewing changes -python check_upstream.py --bump +python scripts/check_upstream.py --bump ``` ### Project Structure ``` libreclient/ -├── src/py_librenms/ +├── src/libreclient/ │ ├── __init__.py # Public API exports │ ├── client.py # LibreClientSync & LibreClientAsync │ ├── config.py # Pydantic-settings configuration @@ -301,16 +311,21 @@ libreclient/ │ └── routes/ # Route namespaces (async + sync wrappers) │ ├── _types.py # ClientProtocol & utilities │ ├── _synchronicity.py # Shared Synchronizer instance -│ ├── alerts.py # Example route implementation +│ ├── alerts.py # Async route implementation +│ ├── alerts_sync.py # Sync wrapper │ └── ... ├── tests/ │ ├── unit/ │ │ ├── models/ # Model validation tests │ │ └── routes/ # Route logic tests (MockClient) │ └── functional/ # Live API tests (requires .env) -├── check_upstream.py # Detect upstream API doc changes +├── scripts/ +│ ├── check_upstream.py # Detect upstream API doc changes +│ └── generate_stubs.py # .pyi stub generator +├── .githooks/ +│ ├── pre-commit # Ruff lint & format +│ └── commit-msg # Conventional commit validation ├── upstream_tracking.toml # Pinned LibreNMS release tag -├── generate_stubs.py # .pyi stub generator ├── pyproject.toml ├── CHANGELOG.md └── LICENSE diff --git a/generate_stubs.py b/generate_stubs.py deleted file mode 100644 index 5282a69..0000000 --- a/generate_stubs.py +++ /dev/null @@ -1,263 +0,0 @@ -""" -Generate .pyi stub files for synchronicity-wrapped route modules. - -Instead of using synchronicity's Protocol-based stub pattern (which doesn't -resolve well in PyCharm/JetBrains IDEs), this generates simple method stubs -that directly declare sync method signatures. -""" - -import ast -from pathlib import Path - -ROUTES_DIR = Path("src/libreclient/routes") - - -def generate_stub_from_async_source(async_source: str) -> str: - """Parse an async implementation module and produce a .pyi stub with sync signatures.""" - tree = ast.parse(async_source) - - lines: list[str] = [] - imports_needed: set[str] = set() - - for node in ast.iter_child_nodes(tree): - if isinstance(node, ast.ClassDef): - lines.append(generate_class_stub(node, imports_needed)) - - # Collect import statements from source (for model return types) - import_lines: list[str] = ["from __future__ import annotations", ""] - for node in ast.iter_child_nodes(tree): - if isinstance(node, (ast.Import, ast.ImportFrom)): - # Skip typing imports and __future__ - 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 - # Filter out private utilities from _types imports (not needed in stubs) - if isinstance(node, ast.ImportFrom) and node.module and "_types" in node.module: - node.names = [alias for alias in node.names if not alias.name.startswith("_")] - if not node.names: - continue - import_lines.append(ast.unparse(node)) - - import_lines.append("") - import_lines.append("") - - return "\n".join(import_lines) + "\n".join(lines) + "\n" - - -def generate_class_stub(class_node: ast.ClassDef, imports_needed: set[str]) -> str: - """Generate stub for a single class.""" - lines: list[str] = [] - - # Class docstring - docstring = ast.get_docstring(class_node) - doc_part = "" - if docstring: - doc_part = f'\n """{docstring}"""' - - lines.append(f"class {class_node.name}:{doc_part}") - - # Check if __init__ assigns self._client and add attribute annotation - for node in ast.iter_child_nodes(class_node): - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "__init__": - for arg in node.args.args: - if arg.arg == "client" and arg.annotation: - ann = ast.unparse(arg.annotation) - lines.append(f" _client: {ann}") - break - break - - for node in ast.iter_child_nodes(class_node): - if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef): - stub = generate_method_stub(node, imports_needed) - lines.append(stub) - - if len(lines) == 1: - lines.append(" ...") - - return "\n".join(lines) - - -def generate_method_stub( - func_node: ast.FunctionDef | ast.AsyncFunctionDef, imports_needed: set[str] -) -> str: - """Generate a sync method stub from an async function definition.""" - # Get the signature - args = func_node.args - params: list[str] = [] - - # Process all arguments - all_args = args.args - defaults = args.defaults - # defaults align to the END of positional args - num_no_default = len(all_args) - len(defaults) - - for i, arg in enumerate(all_args): - param_str = arg.arg - # Add annotation if present - if arg.annotation: - ann = ast.unparse(arg.annotation) - if "Optional" in ann or "|" in ann: - imports_needed.add("typing") - param_str += f": {ann}" - - # Add default if present - default_idx = i - num_no_default - if default_idx >= 0 and default_idx < len(defaults): - default = ast.unparse(defaults[default_idx]) - param_str += f" = {default}" - - params.append(param_str) - - # Handle **kwargs - if args.kwarg: - kw_str = f"**{args.kwarg.arg}" - if args.kwarg.annotation: - kw_str += f": {ast.unparse(args.kwarg.annotation)}" - params.append(kw_str) - - # Handle *args - if args.vararg: - va_str = f"*{args.vararg.arg}" - if args.vararg.annotation: - va_str += f": {ast.unparse(args.vararg.annotation)}" - # Insert before kwargs - if args.kwarg: - params.insert(-1, va_str) - else: - params.append(va_str) - - # Return annotation - return_ann = "" - if func_node.returns: - return_ann = f" -> {ast.unparse(func_node.returns)}" - - params_str = ", ".join(params) - signature = f" def {func_node.name}({params_str}){return_ann}: ..." - - # 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 ...' - else: - doc_body = "\n ".join(doc_lines) - signature = f' def {func_node.name}({params_str}){return_ann}:\n """{doc_body}"""\n ...' - - return signature - - -def main(): - # Find all public route files (the non-underscore ones) - route_files = sorted( - f for f in ROUTES_DIR.glob("*.py") if not f.name.startswith("_") - ) - - # Remove old _*.pyi stubs - for old_stub in ROUTES_DIR.glob("_*.pyi"): - if old_stub.name != "__init__.pyi": - old_stub.unlink() - - # Collect route metadata for __init__ generation - route_entries: list[tuple[str, str, str]] = [] # (module, async_class, sync_class) - - for route_file in route_files: - # alerts.py -> alerts.pyi (same name, includes both async class + sync class stub) - stub_file = ROUTES_DIR / f"{route_file.stem}.pyi" - - print(f"Generating {stub_file.name} from {route_file.name}...") - - source = route_file.read_text(encoding="utf-8") - stub_content = generate_stub_from_async_source(source) - - # Find the sync class name from the source (e.g. AlertsSync) - import re - - match = re.search( - r"^(\w+Sync)\s*=\s*synchronizer\.wrap\(", source, re.MULTILINE - ) - if match: - sync_name = match.group(1) - # Add a sync class stub that mirrors the async class but with plain def - stub_content += f"\nclass {sync_name}:\n" - # Re-parse to get the class body for sync stub - tree = ast.parse(source) - for node in ast.iter_child_nodes(tree): - if isinstance(node, ast.ClassDef): - stub_content += f' """{node.name} (synchronous)."""\n' - # Add _client attribute annotation - for method in ast.iter_child_nodes(node): - if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)) and method.name == "__init__": - for arg in method.args.args: - if arg.arg == "client" and arg.annotation: - ann = ast.unparse(arg.annotation) - stub_content += f" _client: {ann}\n" - break - break - for method in ast.iter_child_nodes(node): - if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)): - stub_content += generate_method_stub(method, set()) + "\n" - # Record for __init__ generation - route_entries.append((route_file.stem, node.name, sync_name)) - break - - stub_file.write_text(stub_content, encoding="utf-8") - - # Generate __init__.py and __init__.pyi - _generate_routes_init(route_entries) - - print(f"\nDone! Generated {len(route_files)} stub files + __init__.py + __init__.pyi.") - - -def _generate_routes_init(entries: list[tuple[str, str, str]]) -> None: - """Generate routes/__init__.py and __init__.pyi from route metadata. - - Each entry is (module_name, async_class_name, sync_class_name). - """ - import_lines: list[str] = [] - all_names: list[str] = [] - - for module, async_class, sync_class in entries: - async_alias = f"{async_class}Async" if not async_class.endswith("Async") else async_class - # Determine the public alias: class name + "Async" - # e.g. Alerts -> AlertsAsync, Arp -> ArpAsync - async_alias = f"{async_class}Async" - import_lines.append(f"from .{module} import {async_class} as {async_alias}") - import_lines.append(f"from .{module} import {sync_class}") - all_names.append(f' "{async_alias}",') - all_names.append(f' "{sync_class}",') - - content = "\n".join(import_lines) - content += "\n\n__all__ = [\n" - content += "\n".join(all_names) - content += "\n]\n" - - # Write __init__.py - init_py = ROUTES_DIR / "__init__.py" - init_py.write_text(content, encoding="utf-8") - print(f"Generated __init__.py") - - # Write __init__.pyi (same content but with `as` aliases on sync imports for clarity) - pyi_import_lines: list[str] = [] - for module, async_class, sync_class in entries: - async_alias = f"{async_class}Async" - pyi_import_lines.append(f"from .{module} import {async_class} as {async_alias}") - pyi_import_lines.append(f"from .{module} import {sync_class} as {sync_class}") - - pyi_content = "\n".join(pyi_import_lines) - pyi_content += "\n\n__all__ = [\n" - pyi_content += "\n".join(all_names) - pyi_content += "\n]\n" - - init_pyi = ROUTES_DIR / "__init__.pyi" - init_pyi.write_text(pyi_content, encoding="utf-8") - print(f"Generated __init__.pyi") - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index 8061b98..070350a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,8 +45,9 @@ markers = [ [tool.ruff] target-version = "py312" line-length = 79 -src = ["src", "tests"] -extend-exclude = ["src/libreclient/routes/*.pyi", "generate_stubs.py", "check_upstream.py"] +src = ["src", "tests", "scripts"] +include = ["src/**", "tests/**", "scripts/**"] +extend-exclude = ["src/libreclient/routes/*.pyi"] [tool.ruff.lint] select = [ @@ -79,9 +80,7 @@ indent-style = "space" [tool.complexipy] paths = ["src"] max-complexity-allowed = 15 -exclude = ["generate_stubs.py", "check_upstream.py"] -output-format = ["json"] -output = "complexipy-results.json" +exclude = ["scripts/**"] [tool.commitizen] name = "cz_conventional_commits" diff --git a/check_upstream.py b/scripts/check_upstream.py similarity index 100% rename from check_upstream.py rename to scripts/check_upstream.py diff --git a/scripts/generate_stubs.py b/scripts/generate_stubs.py new file mode 100644 index 0000000..b0edcba --- /dev/null +++ b/scripts/generate_stubs.py @@ -0,0 +1,261 @@ +""" +Generate .pyi stub files for synchronicity-wrapped route modules. + +Instead of using synchronicity's Protocol-based stub pattern (which doesn't +resolve well in PyCharm/JetBrains IDEs), this generates simple method stubs +that directly declare sync method signatures. +""" + +import ast +import re +from pathlib import Path + +ROUTES_DIR = ( + Path(__file__).resolve().parent.parent / "src" / "libreclient" / "routes" +) + + +def collect_imports_from_async(source: str) -> list[str]: + """Collect import lines needed for the stub from the async source module.""" + tree = ast.parse(source) + 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 + # Filter out private utilities from _types imports (not needed in stubs) + if ( + isinstance(node, ast.ImportFrom) + and node.module + and "_types" in node.module + ): + node.names = [ + alias + for alias in node.names + if not alias.name.startswith("_") + ] + if not node.names: + continue + import_lines.append(ast.unparse(node)) + import_lines.append("") + import_lines.append("") + return import_lines + + +def generate_method_stub( + func_node: ast.FunctionDef | ast.AsyncFunctionDef, imports_needed: set[str] +) -> str: + """Generate a sync method stub from an async function definition.""" + # Get the signature + args = func_node.args + params: list[str] = [] + + # Process all arguments + all_args = args.args + defaults = args.defaults + # defaults align to the END of positional args + num_no_default = len(all_args) - len(defaults) + + for i, arg in enumerate(all_args): + param_str = arg.arg + # Add annotation if present + if arg.annotation: + ann = ast.unparse(arg.annotation) + if "Optional" in ann or "|" in ann: + imports_needed.add("typing") + param_str += f": {ann}" + + # Add default if present + default_idx = i - num_no_default + if default_idx >= 0 and default_idx < len(defaults): + default = ast.unparse(defaults[default_idx]) + param_str += f" = {default}" + + params.append(param_str) + + # Handle **kwargs + if args.kwarg: + kw_str = f"**{args.kwarg.arg}" + if args.kwarg.annotation: + kw_str += f": {ast.unparse(args.kwarg.annotation)}" + params.append(kw_str) + + # Handle *args + if args.vararg: + va_str = f"*{args.vararg.arg}" + if args.vararg.annotation: + va_str += f": {ast.unparse(args.vararg.annotation)}" + # Insert before kwargs + if args.kwarg: + params.insert(-1, va_str) + else: + params.append(va_str) + + # Return annotation + return_ann = "" + if func_node.returns: + return_ann = f" -> {ast.unparse(func_node.returns)}" + + params_str = ", ".join(params) + signature = f" def {func_node.name}({params_str}){return_ann}: ..." + + # 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 ...' + else: + doc_body = "\n ".join(doc_lines) + signature = f' def {func_node.name}({params_str}){return_ann}:\n """{doc_body}"""\n ...' + + return signature + + +def main(): + # Find all *_sync.py files (these contain the synchronizer.wrap() calls) + sync_files = sorted(ROUTES_DIR.glob("*_sync.py")) + + # Collect route metadata for __init__ generation + route_entries: list[ + tuple[str, str, str] + ] = [] # (async_module, async_class, sync_class) + + for sync_file in sync_files: + sync_source = sync_file.read_text(encoding="utf-8") + + # Find the sync class name (e.g. AlertsSync) + match = re.search( + r"^(\w+Sync)\s*=\s*synchronizer\.wrap\(", sync_source, re.MULTILINE + ) + if not match: + continue + + sync_name = match.group(1) + + # Determine the async module name from the import in the sync file + # e.g. "from .alerts import Alerts" -> module="alerts", class="Alerts" + import_match = re.search( + r"from \.([a-zA-Z]\w*) import (\w+)", sync_source + ) + if not import_match: + continue + + async_module = import_match.group(1) + async_class = import_match.group(2) + + # Read the async source to get method signatures and imports + async_file = ROUTES_DIR / f"{async_module}.py" + if not async_file.exists(): + print(f" WARNING: {async_file.name} not found, skipping.") + continue + + async_source = async_file.read_text(encoding="utf-8") + + # Generate stub file: alerts_sync.pyi + stub_file = ROUTES_DIR / f"{sync_file.stem}.pyi" + print(f"Generating {stub_file.name} from {async_file.name}...") + + # Collect imports from the async module + import_lines = collect_imports_from_async(async_source) + stub_content = "\n".join(import_lines) + + # Generate sync class stub that mirrors the async class but with plain def + stub_content += f"class {sync_name}:\n" + tree = ast.parse(async_source) + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.ClassDef) and node.name == async_class: + stub_content += f' """{async_class} (synchronous)."""\n' + # Add _client attribute annotation + for method in ast.iter_child_nodes(node): + if ( + isinstance( + method, (ast.FunctionDef, ast.AsyncFunctionDef) + ) + and method.name == "__init__" + ): + for arg in method.args.args: + if arg.arg == "client" and arg.annotation: + ann = ast.unparse(arg.annotation) + stub_content += f" _client: {ann}\n" + break + break + for method in ast.iter_child_nodes(node): + if isinstance( + method, (ast.FunctionDef, ast.AsyncFunctionDef) + ): + stub_content += ( + generate_method_stub(method, set()) + "\n" + ) + # Record for __init__ generation + route_entries.append((async_module, async_class, sync_name)) + break + + stub_file.write_text(stub_content, encoding="utf-8") + + # Generate __init__.py and __init__.pyi + _generate_routes_init(route_entries) + + print( + f"\nDone! Generated {len(sync_files)} stub files + __init__.py + __init__.pyi." + ) + + +def _generate_routes_init(entries: list[tuple[str, str, str]]) -> None: + """Generate routes/__init__.py and __init__.pyi from route metadata. + + Each entry is (async_module, async_class_name, sync_class_name). + """ + import_lines: list[str] = [] + all_names: list[str] = [] + + for async_module, async_class, sync_class in entries: + sync_module = f"{async_module}_sync" + async_alias = f"{async_class}Async" + import_lines.append( + f"from .{async_module} import {async_class} as {async_alias}" + ) + import_lines.append(f"from .{sync_module} import {sync_class}") + all_names.append(f' "{async_alias}",') + all_names.append(f' "{sync_class}",') + + content = "\n".join(import_lines) + content += "\n\n__all__ = [\n" + content += "\n".join(all_names) + content += "\n]\n" + + # Write __init__.py + init_py = ROUTES_DIR / "__init__.py" + init_py.write_text(content, encoding="utf-8") + print("Generated __init__.py") + + # Write __init__.pyi (same content but with explicit `as` aliases for type checkers) + pyi_import_lines: list[str] = [] + for async_module, async_class, sync_class in entries: + sync_module = f"{async_module}_sync" + async_alias = f"{async_class}Async" + pyi_import_lines.append( + f"from .{async_module} import {async_class} as {async_alias}" + ) + pyi_import_lines.append( + f"from .{sync_module} import {sync_class} as {sync_class}" + ) + + pyi_content = "\n".join(pyi_import_lines) + pyi_content += "\n\n__all__ = [\n" + pyi_content += "\n".join(all_names) + pyi_content += "\n]\n" + + init_pyi = ROUTES_DIR / "__init__.pyi" + init_pyi.write_text(pyi_content, encoding="utf-8") + print("Generated __init__.pyi") + + +if __name__ == "__main__": + main() diff --git a/upstream_tracking.toml b/scripts/upstream_tracking.toml similarity index 100% rename from upstream_tracking.toml rename to scripts/upstream_tracking.toml diff --git a/src/libreclient/__init__.pyi b/src/libreclient/__init__.pyi deleted file mode 100644 index bc4c76d..0000000 --- a/src/libreclient/__init__.pyi +++ /dev/null @@ -1,4 +0,0 @@ -from .client import LibreClientAsync, LibreClientSync -from .config import LibreConfig - -__all__ = ["LibreClientAsync", "LibreClientSync", "LibreConfig"] diff --git a/src/libreclient/routes/__init__.py b/src/libreclient/routes/__init__.py index f941d0f..f81ca98 100644 --- a/src/libreclient/routes/__init__.py +++ b/src/libreclient/routes/__init__.py @@ -1,41 +1,41 @@ from .alerts import Alerts as AlertsAsync -from .alerts import AlertsSync +from .alerts_sync import AlertsSync from .arp import Arp as ArpAsync -from .arp import ArpSync +from .arp_sync import ArpSync from .bills import Bills as BillsAsync -from .bills import BillsSync +from .bills_sync import BillsSync from .device_groups import DeviceGroups as DeviceGroupsAsync -from .device_groups import DeviceGroupsSync +from .device_groups_sync import DeviceGroupsSync from .devices import Devices as DevicesAsync -from .devices import DevicesSync +from .devices_sync import DevicesSync from .index import Index as IndexAsync -from .index import IndexSync +from .index_sync import IndexSync from .inventory import Inventory as InventoryAsync -from .inventory import InventorySync +from .inventory_sync import InventorySync from .locations import Locations as LocationsAsync -from .locations import LocationsSync +from .locations_sync import LocationsSync from .logs import Logs as LogsAsync -from .logs import LogsSync +from .logs_sync import LogsSync from .poller_groups import PollerGroups as PollerGroupsAsync -from .poller_groups import PollerGroupsSync +from .poller_groups_sync import PollerGroupsSync from .pollers import Pollers as PollersAsync -from .pollers import PollersSync +from .pollers_sync import PollersSync from .port_groups import PortGroups as PortGroupsAsync -from .port_groups import PortGroupsSync +from .port_groups_sync import PortGroupsSync from .port_security import PortSecurity as PortSecurityAsync -from .port_security import PortSecuritySync +from .port_security_sync import PortSecuritySync from .portgroups import Portgroups as PortgroupsAsync -from .portgroups import PortgroupsSync +from .portgroups_sync import PortgroupsSync from .ports import Ports as PortsAsync -from .ports import PortsSync +from .ports_sync import PortsSync from .routing import Routing as RoutingAsync -from .routing import RoutingSync +from .routing_sync import RoutingSync from .services import Services as ServicesAsync -from .services import ServicesSync +from .services_sync import ServicesSync from .switching import Switching as SwitchingAsync -from .switching import SwitchingSync +from .switching_sync import SwitchingSync from .system import System as SystemAsync -from .system import SystemSync +from .system_sync import SystemSync __all__ = [ "AlertsAsync", diff --git a/src/libreclient/routes/alerts.py b/src/libreclient/routes/alerts.py index 66ecc13..808be9e 100644 --- a/src/libreclient/routes/alerts.py +++ b/src/libreclient/routes/alerts.py @@ -1,6 +1,6 @@ """Alerts routes — async implementation.""" -from typing import Any, Literal +import typing from ..models import ApiResponse from ..models.alerts import ( @@ -9,7 +9,6 @@ AlertTemplatesResponse, RulesResponse, ) -from ._synchronicity import synchronizer from ._types import ClientProtocol, _compact _RULES = "/rules" @@ -61,8 +60,8 @@ async def unmute_alert(self, alert_id: int) -> ApiResponse: async def list_alerts( self, - state: Literal[0, 1, 2] | None = None, - severity: Literal["ok", "warning", "critical"] | None = None, + state: typing.Literal[0, 1, 2] | None = None, + severity: typing.Literal["ok", "warning", "critical"] | None = None, alert_rule: int | None = None, order: str | None = None, ) -> AlertsResponse: @@ -168,7 +167,7 @@ async def add_alert_template( :param title_rec: Title used when an alert has recovered. :param alert_rules: List of rule_ids this template applies to. """ - payload: dict[str, Any] = { + payload: dict[str, typing.Any] = { "name": name, "template": template, **_compact( @@ -198,7 +197,7 @@ async def edit_alert_template( :param title_rec: Title used when an alert has recovered. :param alert_rules: List of rule_ids this template applies to. """ - payload: dict[str, Any] = { + payload: dict[str, typing.Any] = { "template_id": template_id, "name": name, "template": template, @@ -208,8 +207,3 @@ async def edit_alert_template( } data = await self._client._post(_ALERT_TEMPLATES, json=payload) return ApiResponse.model_validate(data) - - -AlertsSync = synchronizer.wrap( - Alerts, name="AlertsSync", target_module=__name__ -) diff --git a/src/libreclient/routes/alerts_sync.py b/src/libreclient/routes/alerts_sync.py new file mode 100644 index 0000000..22669d1 --- /dev/null +++ b/src/libreclient/routes/alerts_sync.py @@ -0,0 +1,8 @@ +"""Alerts routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .alerts import Alerts + +AlertsSync = synchronizer.wrap( + Alerts, name="AlertsSync", target_module=__name__ +) diff --git a/src/libreclient/routes/arp.py b/src/libreclient/routes/arp.py index baf6006..3f80c7c 100644 --- a/src/libreclient/routes/arp.py +++ b/src/libreclient/routes/arp.py @@ -3,7 +3,6 @@ from __future__ import annotations from ..models.arp import ArpResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol @@ -30,6 +29,3 @@ async def list_arp( f"/resources/ip/arp/{query}", params=params ) return ArpResponse.model_validate(data) - - -ArpSync = synchronizer.wrap(Arp, name="ArpSync", target_module=__name__) diff --git a/src/libreclient/routes/arp_sync.py b/src/libreclient/routes/arp_sync.py new file mode 100644 index 0000000..7c375f9 --- /dev/null +++ b/src/libreclient/routes/arp_sync.py @@ -0,0 +1,6 @@ +"""ARP routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .arp import Arp + +ArpSync = synchronizer.wrap(Arp, name="ArpSync", target_module=__name__) diff --git a/src/libreclient/routes/bills.py b/src/libreclient/routes/bills.py index 29d1ba9..9496ee2 100644 --- a/src/libreclient/routes/bills.py +++ b/src/libreclient/routes/bills.py @@ -2,11 +2,10 @@ from __future__ import annotations -from typing import Literal +import typing from ..models import ApiResponse from ..models.bills import BillHistoryResponse, BillsResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol, _compact, _graph_params _BILLS = "/bills" @@ -19,7 +18,7 @@ def __init__(self, client: ClientProtocol) -> None: self._client = client async def list_bills( - self, period: Literal["previous"] | None = None + self, period: typing.Literal["previous"] | None = None ) -> BillsResponse: """Retrieve the list of bills currently in the system. @@ -35,7 +34,7 @@ async def get_bill( bill_id: int | None = None, ref: str | None = None, custid: str | None = None, - period: Literal["previous"] | None = None, + period: typing.Literal["previous"] | None = None, ) -> BillsResponse: """Retrieve a specific bill. @@ -162,6 +161,3 @@ async def create_edit_bill(self, **kwargs) -> ApiResponse: """ data = await self._client._post(_BILLS, json=kwargs) return ApiResponse.model_validate(data) - - -BillsSync = synchronizer.wrap(Bills, name="BillsSync", target_module=__name__) diff --git a/src/libreclient/routes/bills_sync.py b/src/libreclient/routes/bills_sync.py new file mode 100644 index 0000000..5b1db54 --- /dev/null +++ b/src/libreclient/routes/bills_sync.py @@ -0,0 +1,6 @@ +"""Private async implementation for Bills routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .bills import Bills + +BillsSync = synchronizer.wrap(Bills, name="BillsSync", target_module=__name__) diff --git a/src/libreclient/routes/device_groups.py b/src/libreclient/routes/device_groups.py index 29b5ef9..0578849 100644 --- a/src/libreclient/routes/device_groups.py +++ b/src/libreclient/routes/device_groups.py @@ -2,14 +2,13 @@ from __future__ import annotations -from typing import Literal +import typing from ..models import ApiResponse from ..models.device_groups import ( DeviceGroupDevicesResponse, DeviceGroupsResponse, ) -from ._synchronicity import synchronizer from ._types import ClientProtocol, _compact, _validate_maintenance_params @@ -53,7 +52,7 @@ async def get_devicegroups(self) -> DeviceGroupsResponse: async def add_devicegroup( self, name: str, - type: Literal["static", "dynamic"], + type: typing.Literal["static", "dynamic"], desc: str | None = None, rules: str | None = None, devices: list | None = None, @@ -82,7 +81,7 @@ async def update_devicegroup( self, name: str, new_name: str | None = None, - type: Literal["static", "dynamic"] | None = None, + type: typing.Literal["static", "dynamic"] | None = None, desc: str | None = None, rules: str | None = None, devices: list | None = None, @@ -197,8 +196,3 @@ async def remove_devices_from_group( f"/devicegroups/{name}/devices", json={"devices": devices} ) return ApiResponse.model_validate(data) - - -DeviceGroupsSync = synchronizer.wrap( - DeviceGroups, name="DeviceGroupsSync", target_module=__name__ -) diff --git a/src/libreclient/routes/device_groups_sync.py b/src/libreclient/routes/device_groups_sync.py new file mode 100644 index 0000000..24997be --- /dev/null +++ b/src/libreclient/routes/device_groups_sync.py @@ -0,0 +1,8 @@ +"""Device Groups routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .device_groups import DeviceGroups + +DeviceGroupsSync = synchronizer.wrap( + DeviceGroups, name="DeviceGroupsSync", target_module=__name__ +) diff --git a/src/libreclient/routes/devices.py b/src/libreclient/routes/devices.py index 3b42ed9..dce4f09 100644 --- a/src/libreclient/routes/devices.py +++ b/src/libreclient/routes/devices.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Literal +import typing from ..models import ApiResponse from ..models.devices import ( @@ -12,7 +12,6 @@ DeviceResponse, DevicesResponse, ) -from ._synchronicity import synchronizer from ._types import ( ClientProtocol, _compact, @@ -20,7 +19,7 @@ _validate_maintenance_params, ) -DeviceListType = Literal[ +DeviceListType = typing.Literal[ "all", "active", "ignored", @@ -713,8 +712,3 @@ async def delete_parents_from_host( f"/devices/{device}/parents", json=payload ) return ApiResponse.model_validate(data) - - -DevicesSync = synchronizer.wrap( - Devices, name="DevicesSync", target_module=__name__ -) diff --git a/src/libreclient/routes/devices_sync.py b/src/libreclient/routes/devices_sync.py new file mode 100644 index 0000000..c922e71 --- /dev/null +++ b/src/libreclient/routes/devices_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for Devices routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .devices import Devices + +DevicesSync = synchronizer.wrap( + Devices, name="DevicesSync", target_module=__name__ +) diff --git a/src/libreclient/routes/index.py b/src/libreclient/routes/index.py index 7518f53..2e0f6d7 100644 --- a/src/libreclient/routes/index.py +++ b/src/libreclient/routes/index.py @@ -3,7 +3,6 @@ from __future__ import annotations from ..models.index import IndexResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol @@ -20,6 +19,3 @@ async def list_api_endpoints(self) -> IndexResponse: """ data = await self._client._get("") return IndexResponse(endpoints=data) - - -IndexSync = synchronizer.wrap(Index, name="IndexSync", target_module=__name__) diff --git a/src/libreclient/routes/index_sync.py b/src/libreclient/routes/index_sync.py new file mode 100644 index 0000000..203930e --- /dev/null +++ b/src/libreclient/routes/index_sync.py @@ -0,0 +1,6 @@ +"""Private async implementation for Index routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .index import Index + +IndexSync = synchronizer.wrap(Index, name="IndexSync", target_module=__name__) diff --git a/src/libreclient/routes/inventory.py b/src/libreclient/routes/inventory.py index baf5e61..035c559 100644 --- a/src/libreclient/routes/inventory.py +++ b/src/libreclient/routes/inventory.py @@ -3,7 +3,6 @@ from __future__ import annotations from ..models.inventory import InventoryResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol @@ -51,8 +50,3 @@ async def get_inventory_for_device( f"/inventory/{hostname}/all", params=params ) return InventoryResponse.model_validate(data) - - -InventorySync = synchronizer.wrap( - Inventory, name="InventorySync", target_module=__name__ -) diff --git a/src/libreclient/routes/inventory_sync.py b/src/libreclient/routes/inventory_sync.py new file mode 100644 index 0000000..f04c7bd --- /dev/null +++ b/src/libreclient/routes/inventory_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for Inventory routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .inventory import Inventory + +InventorySync = synchronizer.wrap( + Inventory, name="InventorySync", target_module=__name__ +) diff --git a/src/libreclient/routes/locations.py b/src/libreclient/routes/locations.py index bce8029..94abeba 100644 --- a/src/libreclient/routes/locations.py +++ b/src/libreclient/routes/locations.py @@ -4,7 +4,6 @@ from ..models import ApiResponse from ..models.locations import LocationsResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol, _compact, _validate_maintenance_params @@ -112,8 +111,3 @@ async def maintenance_location( f"/locations/{location}/maintenance", json=payload ) return ApiResponse.model_validate(data) - - -LocationsSync = synchronizer.wrap( - Locations, name="LocationsSync", target_module=__name__ -) diff --git a/src/libreclient/routes/locations_sync.py b/src/libreclient/routes/locations_sync.py new file mode 100644 index 0000000..e685002 --- /dev/null +++ b/src/libreclient/routes/locations_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for Locations routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .locations import Locations + +LocationsSync = synchronizer.wrap( + Locations, name="LocationsSync", target_module=__name__ +) diff --git a/src/libreclient/routes/logs.py b/src/libreclient/routes/logs.py index 4bbeaa7..1ac7570 100644 --- a/src/libreclient/routes/logs.py +++ b/src/libreclient/routes/logs.py @@ -4,7 +4,6 @@ from ..models import ApiResponse from ..models.logs import LogsResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol @@ -160,6 +159,3 @@ def _build_log_params(start, limit, from_time, to_time, sortorder): if sortorder is not None: params["sortorder"] = sortorder return params - - -LogsSync = synchronizer.wrap(Logs, name="LogsSync", target_module=__name__) diff --git a/src/libreclient/routes/logs_sync.py b/src/libreclient/routes/logs_sync.py new file mode 100644 index 0000000..cd29908 --- /dev/null +++ b/src/libreclient/routes/logs_sync.py @@ -0,0 +1,6 @@ +"""Private async implementation for Logs routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .logs import Logs + +LogsSync = synchronizer.wrap(Logs, name="LogsSync", target_module=__name__) diff --git a/src/libreclient/routes/poller_groups.py b/src/libreclient/routes/poller_groups.py index 730412e..99ae4a1 100644 --- a/src/libreclient/routes/poller_groups.py +++ b/src/libreclient/routes/poller_groups.py @@ -3,7 +3,6 @@ from __future__ import annotations from ..models.poller_groups import PollerGroupsResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol @@ -27,8 +26,3 @@ async def get_poller_group( url += f"/{poller_group}" data = await self._client._get(url) return PollerGroupsResponse.model_validate(data) - - -PollerGroupsSync = synchronizer.wrap( - PollerGroups, name="PollerGroupsSync", target_module=__name__ -) diff --git a/src/libreclient/routes/poller_groups_sync.py b/src/libreclient/routes/poller_groups_sync.py new file mode 100644 index 0000000..bab3754 --- /dev/null +++ b/src/libreclient/routes/poller_groups_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for PollerGroups routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .poller_groups import PollerGroups + +PollerGroupsSync = synchronizer.wrap( + PollerGroups, name="PollerGroupsSync", target_module=__name__ +) diff --git a/src/libreclient/routes/pollers.py b/src/libreclient/routes/pollers.py index 3a329eb..f6d740c 100644 --- a/src/libreclient/routes/pollers.py +++ b/src/libreclient/routes/pollers.py @@ -3,7 +3,6 @@ from __future__ import annotations from ..models.pollers import PollersResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol @@ -33,8 +32,3 @@ async def list_poller_log(self, unpolled: bool = False) -> PollersResponse: params["unpolled"] = 1 data = await self._client._get("/pollers/log", params=params) return PollersResponse.model_validate(data) - - -PollersSync = synchronizer.wrap( - Pollers, name="PollersSync", target_module=__name__ -) diff --git a/src/libreclient/routes/pollers_sync.py b/src/libreclient/routes/pollers_sync.py new file mode 100644 index 0000000..bf1f3c7 --- /dev/null +++ b/src/libreclient/routes/pollers_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for Pollers routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .pollers import Pollers + +PollersSync = synchronizer.wrap( + Pollers, name="PollersSync", target_module=__name__ +) diff --git a/src/libreclient/routes/port_groups.py b/src/libreclient/routes/port_groups.py index b0a5463..a97e146 100644 --- a/src/libreclient/routes/port_groups.py +++ b/src/libreclient/routes/port_groups.py @@ -4,7 +4,6 @@ from ..models import ApiResponse from ..models.port_groups import PortGroupsResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol, _compact @@ -80,8 +79,3 @@ async def remove_port_group( f"/port_groups/{port_group_id}/remove", json={"port_ids": port_ids} ) return ApiResponse.model_validate(data) - - -PortGroupsSync = synchronizer.wrap( - PortGroups, name="PortGroupsSync", target_module=__name__ -) diff --git a/src/libreclient/routes/port_groups_sync.py b/src/libreclient/routes/port_groups_sync.py new file mode 100644 index 0000000..078a484 --- /dev/null +++ b/src/libreclient/routes/port_groups_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for PortGroups routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .port_groups import PortGroups + +PortGroupsSync = synchronizer.wrap( + PortGroups, name="PortGroupsSync", target_module=__name__ +) diff --git a/src/libreclient/routes/port_security.py b/src/libreclient/routes/port_security.py index 3fe101f..c15c0a0 100644 --- a/src/libreclient/routes/port_security.py +++ b/src/libreclient/routes/port_security.py @@ -3,7 +3,6 @@ from __future__ import annotations from ..models.port_security import PortSecurityResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol @@ -44,8 +43,3 @@ async def get_port_security_by_hostname( """ data = await self._client._get(f"/port_security/device/{hostname}") return PortSecurityResponse.model_validate(data) - - -PortSecuritySync = synchronizer.wrap( - PortSecurity, name="PortSecuritySync", target_module=__name__ -) diff --git a/src/libreclient/routes/port_security_sync.py b/src/libreclient/routes/port_security_sync.py new file mode 100644 index 0000000..2fe7472 --- /dev/null +++ b/src/libreclient/routes/port_security_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for PortSecurity routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .port_security import PortSecurity + +PortSecuritySync = synchronizer.wrap( + PortSecurity, name="PortSecuritySync", target_module=__name__ +) diff --git a/src/libreclient/routes/portgroups.py b/src/libreclient/routes/portgroups.py index b53f7dd..923e2fb 100644 --- a/src/libreclient/routes/portgroups.py +++ b/src/libreclient/routes/portgroups.py @@ -2,7 +2,6 @@ from __future__ import annotations -from ._synchronicity import synchronizer from ._types import ClientProtocol, _graph_params @@ -57,8 +56,3 @@ async def get_graph_by_portgroup_multiport_bits( return await self._client._get_bytes( f"/portgroups/multiport/bits/{port_ids}", params=params ) - - -PortgroupsSync = synchronizer.wrap( - Portgroups, name="PortgroupsSync", target_module=__name__ -) diff --git a/src/libreclient/routes/portgroups_sync.py b/src/libreclient/routes/portgroups_sync.py new file mode 100644 index 0000000..913c38c --- /dev/null +++ b/src/libreclient/routes/portgroups_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for Portgroups routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .portgroups import Portgroups + +PortgroupsSync = synchronizer.wrap( + Portgroups, name="PortgroupsSync", target_module=__name__ +) diff --git a/src/libreclient/routes/ports.py b/src/libreclient/routes/ports.py index c34e717..2e0a382 100644 --- a/src/libreclient/routes/ports.py +++ b/src/libreclient/routes/ports.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Literal +import typing from ..models import ApiResponse from ..models.ports import ( @@ -12,7 +12,6 @@ PortsResponse, PortTransceiverResponse, ) -from ._synchronicity import synchronizer from ._types import ClientProtocol @@ -73,7 +72,8 @@ async def ports_with_associated_mac( async def get_port_info( self, port_id: int, - with_relations: Literal["vlans", "device", "statistics"] | None = None, + with_relations: typing.Literal["vlans", "device", "statistics"] + | None = None, ) -> PortResponse: """Get all info for a particular port. @@ -133,6 +133,3 @@ async def update_port_description( f"/ports/{port_id}/description", json={"description": description} ) return ApiResponse.model_validate(data) - - -PortsSync = synchronizer.wrap(Ports, name="PortsSync", target_module=__name__) diff --git a/src/libreclient/routes/ports_sync.py b/src/libreclient/routes/ports_sync.py new file mode 100644 index 0000000..07758f1 --- /dev/null +++ b/src/libreclient/routes/ports_sync.py @@ -0,0 +1,6 @@ +"""Private async implementation for Ports routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .ports import Ports + +PortsSync = synchronizer.wrap(Ports, name="PortsSync", target_module=__name__) diff --git a/src/libreclient/routes/routing.py b/src/libreclient/routes/routing.py index 8952989..4e6425d 100644 --- a/src/libreclient/routes/routing.py +++ b/src/libreclient/routes/routing.py @@ -2,11 +2,10 @@ from __future__ import annotations -from typing import Literal +import typing from ..models import ApiResponse from ..models.routing import RoutingResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol, _compact @@ -90,7 +89,7 @@ async def list_cbgp(self, hostname: str | None = None) -> RoutingResponse: return RoutingResponse.model_validate(data) async def list_ip_addresses( - self, address_family: Literal["ipv4", "ipv6"] | None = None + self, address_family: typing.Literal["ipv4", "ipv6"] | None = None ) -> RoutingResponse: """List all IPv4 and IPv6 (or version-specific) addresses. @@ -119,7 +118,7 @@ async def get_network_ip_addresses( return RoutingResponse.model_validate(data) async def list_ip_networks( - self, address_family: Literal["ipv4", "ipv6"] | None = None + self, address_family: typing.Literal["ipv4", "ipv6"] | None = None ) -> RoutingResponse: """List all IPv4 and IPv6 (or version-specific) networks. @@ -240,8 +239,3 @@ async def list_mpls_saps( "/routing/mpls/saps", params=_compact(hostname=hostname) ) return RoutingResponse.model_validate(data) - - -RoutingSync = synchronizer.wrap( - Routing, name="RoutingSync", target_module=__name__ -) diff --git a/src/libreclient/routes/routing_sync.py b/src/libreclient/routes/routing_sync.py new file mode 100644 index 0000000..f1b0164 --- /dev/null +++ b/src/libreclient/routes/routing_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for Routing routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .routing import Routing + +RoutingSync = synchronizer.wrap( + Routing, name="RoutingSync", target_module=__name__ +) diff --git a/src/libreclient/routes/services.py b/src/libreclient/routes/services.py index 97a20b8..3677cd6 100644 --- a/src/libreclient/routes/services.py +++ b/src/libreclient/routes/services.py @@ -2,11 +2,10 @@ from __future__ import annotations -from typing import Literal +import typing from ..models import ApiResponse from ..models.services import ServicesResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol, _compact @@ -17,7 +16,9 @@ def __init__(self, client: ClientProtocol) -> None: self._client = client async def list_services( - self, state: Literal[0, 1, 2] | None = None, type: str | None = None + self, + state: typing.Literal[0, 1, 2] | None = None, + type: str | None = None, ) -> ServicesResponse: """Retrieve all services. @@ -34,7 +35,7 @@ async def list_services( async def get_service_for_host( self, hostname: str, - state: Literal[0, 1, 2] | None = None, + state: typing.Literal[0, 1, 2] | None = None, type: str | None = None, ) -> ServicesResponse: """Retrieve services for a specific device. @@ -101,8 +102,3 @@ async def delete_service_from_host(self, service_id: int) -> ApiResponse: """ data = await self._client._delete(f"/services/{service_id}") return ApiResponse.model_validate(data) - - -ServicesSync = synchronizer.wrap( - Services, name="ServicesSync", target_module=__name__ -) diff --git a/src/libreclient/routes/services_sync.py b/src/libreclient/routes/services_sync.py new file mode 100644 index 0000000..071807c --- /dev/null +++ b/src/libreclient/routes/services_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for Services routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .services import Services + +ServicesSync = synchronizer.wrap( + Services, name="ServicesSync", target_module=__name__ +) diff --git a/src/libreclient/routes/switching.py b/src/libreclient/routes/switching.py index 3e72de7..f704bc9 100644 --- a/src/libreclient/routes/switching.py +++ b/src/libreclient/routes/switching.py @@ -3,7 +3,6 @@ from __future__ import annotations from ..models.switching import SwitchingResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol @@ -91,8 +90,3 @@ async def list_nac(self, mac: str | None = None) -> SwitchingResponse: url += f"/{mac}" data = await self._client._get(url) return SwitchingResponse.model_validate(data) - - -SwitchingSync = synchronizer.wrap( - Switching, name="SwitchingSync", target_module=__name__ -) diff --git a/src/libreclient/routes/switching_sync.py b/src/libreclient/routes/switching_sync.py new file mode 100644 index 0000000..4c29adc --- /dev/null +++ b/src/libreclient/routes/switching_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for Switching routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .switching import Switching + +SwitchingSync = synchronizer.wrap( + Switching, name="SwitchingSync", target_module=__name__ +) diff --git a/src/libreclient/routes/system.py b/src/libreclient/routes/system.py index 1351229..50cf81b 100644 --- a/src/libreclient/routes/system.py +++ b/src/libreclient/routes/system.py @@ -4,7 +4,6 @@ from ..models import ApiResponse from ..models.system import SystemResponse -from ._synchronicity import synchronizer from ._types import ClientProtocol @@ -29,8 +28,3 @@ async def system(self) -> SystemResponse: """ data = await self._client._get("/system") return SystemResponse.model_validate(data) - - -SystemSync = synchronizer.wrap( - System, name="SystemSync", target_module=__name__ -) diff --git a/src/libreclient/routes/system_sync.py b/src/libreclient/routes/system_sync.py new file mode 100644 index 0000000..b1e47bd --- /dev/null +++ b/src/libreclient/routes/system_sync.py @@ -0,0 +1,8 @@ +"""Private async implementation for System routes — sync implementation.""" + +from ._synchronicity import synchronizer +from .system import System + +SystemSync = synchronizer.wrap( + System, name="SystemSync", target_module=__name__ +) diff --git a/uv.lock b/uv.lock index ae29092..9b95915 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] [[package]] name = "annotated-doc" From 6b2563db6e32e56c4b0f5962dbe47f508128d8ef Mon Sep 17 00:00:00 2001 From: Justin Jeffery Date: Wed, 10 Jun 2026 16:35:48 -0400 Subject: [PATCH 2/3] docs: Add documentation. (#21) --- .githooks/pre-commit | 19 +- .github/ISSUE_TEMPLATE/bug_report.md | 22 +- .github/ISSUE_TEMPLATE/config.yml | 9 + .github/ISSUE_TEMPLATE/documentation.md | 24 ++ .github/ISSUE_TEMPLATE/feature_request.md | 14 +- .github/ISSUE_TEMPLATE/new_route.md | 25 ++ .github/ISSUE_TEMPLATE/question.md | 20 ++ .github/workflows/docs.yml | 28 ++ README.md | 12 + docs/COMMIT_TYPES.md | 9 + docs/CONTRIBUTING.md | 9 + docs/development.md | 11 + docs/documentation.md | 160 +++++++++ docs/index.md | 9 + docs/support.md | 40 +++ pyproject.toml | 3 +- scripts/zensical_macros.py | 72 ++++ site/.gitignore | 0 uv.lock | 75 +++++ zensical.toml | 384 ++++++++++++++++++++++ 20 files changed, 917 insertions(+), 28 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/documentation.md create mode 100644 .github/ISSUE_TEMPLATE/new_route.md create mode 100644 .github/ISSUE_TEMPLATE/question.md create mode 100644 .github/workflows/docs.yml create mode 100644 docs/COMMIT_TYPES.md create mode 100644 docs/CONTRIBUTING.md create mode 100644 docs/development.md create mode 100644 docs/documentation.md create mode 100644 docs/index.md create mode 100644 docs/support.md create mode 100644 scripts/zensical_macros.py create mode 100644 site/.gitignore create mode 100644 zensical.toml diff --git a/.githooks/pre-commit b/.githooks/pre-commit index bf5a6f2..ccb8bad 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -42,13 +42,18 @@ else COMPLEXIPY="uv run complexipy" fi -echo "Running complexipy..." -$COMPLEXIPY $STAGED_PY --max-complexity-allowed 15 -COMPLEXITY_EXIT=$? - -if [ $COMPLEXITY_EXIT -ne 0 ]; then - echo "complexipy found functions exceeding max cognitive complexity (15)." - exit 1 +# Run complexipy only on staged src/ files (matches pyproject.toml [tool.complexipy] paths) +STAGED_SRC=$(echo "$STAGED_PY" | grep "^src/" || true) + +if [ -n "$STAGED_SRC" ]; then + echo "Running complexipy..." + $COMPLEXIPY $STAGED_SRC --max-complexity-allowed 15 + COMPLEXITY_EXIT=$? + + if [ $COMPLEXITY_EXIT -ne 0 ]; then + echo "complexipy found functions exceeding max cognitive complexity (15)." + exit 1 + fi fi exit 0 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index ce62415..1192b94 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,37 +1,33 @@ --- name: Bug report about: Report an issue with libreclient -title: '' +title: 'BUG: [Short description]' labels: 'bug' assignees: '' --- **Describe the bug** -A clear and concise description of what the bug is. + **To Reproduce** ```python # Minimal code to reproduce the issue -from libreclient import LibreClientSync - -client = LibreClientSync(url="...", token="...") -# ... ``` **Expected behavior** -A clear and concise description of what you expected to happen. + **Error output** ``` -Paste full traceback or error message here + ``` **Environment:** -- Python version: [e.g. 3.13] -- libreclient version: [e.g. 0.2.0] -- LibreNMS version: [e.g. 24.12.0] -- OS: [e.g. Ubuntu 24.04] +- Python version: +- libreclient version: +- LibreNMS version: +- OS: **Additional context** -Add any other context about the problem here. + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0fcb13b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +blank_issues_enabled: true +contact_links: + - name: LibreNMS API Documentation + url: https://docs.librenms.org/API/ + about: Official LibreNMS API reference + - name: LibreNMS Community + url: https://community.librenms.org/ + about: LibreNMS community forums for general LibreNMS questions + diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..3da9936 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,24 @@ +--- +name: Documentation +about: Report missing, incorrect, or unclear documentation +title: 'DOCS: [Short description]' +labels: 'documentation' +assignees: '' + +--- + +**What needs documentation?** + + +**Where?** + + +**Suggested improvement** + + +**Additional context** + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 1eed0e7..21a1cfd 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,24 +1,24 @@ --- name: Feature request about: Suggest an idea for libreclient -title: '' +title: 'FEAT: [Short description]' labels: 'enhancement' assignees: '' --- **Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + **Describe the solution you'd like** -A clear and concise description of what you want to happen. + **API endpoint(s) involved** -If this relates to a specific LibreNMS API endpoint, link to the relevant docs: -- e.g. https://docs.librenms.org/API/Devices/#get-device + **Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. + **Additional context** -Add any other context or screenshots about the feature request here. + diff --git a/.github/ISSUE_TEMPLATE/new_route.md b/.github/ISSUE_TEMPLATE/new_route.md new file mode 100644 index 0000000..3be98ae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new_route.md @@ -0,0 +1,25 @@ +--- +name: New route request +about: Request support for a LibreNMS API endpoint not yet implemented +title: 'ROUTE: [Endpoint name]' +labels: 'enhancement,new-route' +assignees: '' + +--- + +**API endpoint(s)** + + +**LibreNMS version** + + +**Which namespace would this belong to?** + + +**Use case** + + +**Would you like to contribute this?** + + diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..504502a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,20 @@ +--- +name: Question +about: Ask a question about usage or behavior +title: 'Q: [Short question]' +labels: 'question' +assignees: '' + +--- + +**Your question** + + +**What have you tried?** + + +**Environment (if relevant):** +- Python version: +- libreclient version: +- LibreNMS version: + diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..272c1d8 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,28 @@ +name: Documentation +on: + push: + branches: + - main +permissions: + contents: read + pages: write + id-token: write +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/configure-pages@v6 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: 3.x + - run: pip install zensical + - run: zensical build --clean + - uses: actions/upload-pages-artifact@v5 + with: + path: site + - uses: actions/deploy-pages@v5 + id: deployment diff --git a/README.md b/README.md index 8ce8579..e2e901e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,17 @@ # libreclient +[![PyPI](https://img.shields.io/pypi/v/libreclient)](https://pypi.org/project/libreclient/) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/downloads/) +[![CI](https://github.com/jjeff07/libreclient/actions/workflows/ci.yml/badge.svg)](https://github.com/jjeff07/libreclient/actions) +[![Documentation](https://img.shields.io/badge/docs-online-blue)](https://jjeff07.github.io/libreclient/) + +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) +[![Niquests](https://img.shields.io/badge/niquests-HTTP%2F3-blueviolet)](https://github.com/jawah/niquests) +[![Pydantic v2](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pydantic/pydantic/main/docs/badge/v2.json)](https://docs.pydantic.dev/latest/) +[![complexipy](https://img.shields.io/badge/complexipy-max%2015-orange)](https://github.com/rohaquinlop/complexipy) +[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) Async and sync Python client for the [LibreNMS](https://www.librenms.org/) API. @@ -140,6 +150,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines, or just open an ## Development +This section covers everything you need to set up a local development environment and contribute code to libreclient. + ### Prerequisites - Python 3.12+ diff --git a/docs/COMMIT_TYPES.md b/docs/COMMIT_TYPES.md new file mode 100644 index 0000000..0cf0fb4 --- /dev/null +++ b/docs/COMMIT_TYPES.md @@ -0,0 +1,9 @@ +--- +title: Commit Reference +description: A reference for commit types and their meanings in the context of libreclient development. +icon: lucide/git-commit-horizontal +--- + + + +{{ COMMIT_TYPES }} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..610242e --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,9 @@ +--- +title: Contributing +description: Guidelines for developing and contributing to libreclient, including setup instructions, coding standards, and best practices. +icon: lucide/git-pull-request-arrow +--- + + + +{{ CONTRIBUTING }} diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..bf34de0 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,11 @@ +--- +title: Development +description: Guidelines for developing and contributing to libreclient, including setup instructions, coding standards, and best practices. +icon: lucide/file-code +--- + + + +{{ README_DEVELOPMENT }} + +{{ README_CONTRIBUTING }} \ No newline at end of file diff --git a/docs/documentation.md b/docs/documentation.md new file mode 100644 index 0000000..4d87377 --- /dev/null +++ b/docs/documentation.md @@ -0,0 +1,160 @@ +--- +title: Documentation +description: How the documentation site is built and maintained +icon: lucide/book-text +render_macros: false +--- + +# Documentation + +This project's documentation site is built with [Zensical](https://zensical.org/) and hosted on +GitHub Pages at [jjeff07.github.io/libreclient](https://jjeff07.github.io/libreclient/). + +## How It Works + +The docs follow a **single source of truth** philosophy — content is written once in the project root +(`README.md`, `CONTRIBUTING.md`, `COMMIT_TYPES.md`) and pulled into the docs site at build time using +a macros plugin. This means the repo-level docs and the website are always in sync. + +### Architecture + +``` +Project Root Docs Site +───────────── ───────── +README.md ──┐ +CONTRIBUTING.md ──┼──▶ scripts/zensical_macros.py ──▶ docs/*.md ──▶ Built Site +COMMIT_TYPES.md ──┘ +``` + +## Key Files + +| File | Purpose | +|------------------------------|-------------------------------------------------------------------------------| +| `zensical.toml` | Site configuration (name, theme, extensions, features) | +| `scripts/zensical_macros.py` | Macros plugin that reads markdown files and exposes them as template variables | +| `docs/index.md` | Homepage — renders `{{ README_MAIN }}` | +| `docs/development.md` | Dev guide — renders `{{ README_DEVELOPMENT }}` and `{{ README_CONTRIBUTING }}`| +| `docs/CONTRIBUTING.md` | Contributing guide — renders `{{ CONTRIBUTING }}` | +| `docs/COMMIT_TYPES.md` | Commit reference — renders `{{ COMMIT_TYPES }}` | +| `docs/support.md` | Support & issues info, with `{{ SECURITY }}` appended at the bottom | +| `docs/documentation.md` | This page (written directly, not macro-generated) | + +## Template Variables + +The macros plugin (`scripts/zensical_macros.py`) exposes these variables for use in any docs page: + +| Variable | Source | +|-----------------------------|-------------------------------------------------------------| +| `{{ README_MAIN }}` | README.md without the Contributing and Development sections | +| `{{ README_CONTRIBUTING }}` | The `## Contributing` section extracted from README.md | +| `{{ README_DEVELOPMENT }}` | The `## Development` section extracted from README.md | +| `{{ CONTRIBUTING }}` | Full contents of CONTRIBUTING.md | +| `{{ COMMIT_TYPES }}` | Full contents of COMMIT_TYPES.md | +| `{{ SECURITY }}` | Full contents of SECURITY.md | + +## Live Reload & Watch + +The `zensical.toml` config includes a `watch` list so that changes to the source markdown files +trigger a live reload during local development: + +```toml +watch = [ + "scripts/zensical_macros.py", + "README.md", + "COMMIT_TYPES.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "SECURITY.md", +] +``` + +## Running Locally + +To serve the docs site locally with live reload: + +```bash +uv run zensical serve +``` + +To build a static version: + +```bash +uv run zensical build +``` + +## Adding a New Page + +1. Create a new `.md` file in the `docs/` directory. +2. Add front matter with a title and icon: + ```markdown + --- + title: My New Page + description: A brief description for SEO + icon: lucide/icon-name + --- + ``` +3. Write content directly, or use a macro variable if the content lives in a root-level file. +4. If using a new macro variable, add it to `scripts/zensical_macros.py` and update the `watch` list in `zensical.toml` + if needed. +5. **Update the navigation** in `zensical.toml` under the `nav` array to include the new page. + +### Navigation Configuration + +The site uses explicit navigation defined in `zensical.toml`: + +```toml +nav = [ + {"Getting Started" = "index.md"}, + {"Support" = "support.md"}, + { + "Development" = [ + "development.md", + "documentation.md", + "CONTRIBUTING.md", + "COMMIT_TYPES.md" + ] + }, + {"GitHub Repo" = "https://github.com/jjeff07/libreclient"} +] +``` + +Pages not listed in `nav` won't appear in the sidebar navigation. + +### Link Compatibility + +Root-level markdown files (like `README.md` and `CONTRIBUTING.md`) contain relative links such as +`[COMMIT_TYPES.md](COMMIT_TYPES.md)` or `[CONTRIBUTING.md](CONTRIBUTING.md)`. For these links to +work both on GitHub **and** in the docs site, the docs pages must use matching filenames: + +| Root file link target | Docs file | +|-----------------------|------------------------| +| `CONTRIBUTING.md` | `docs/CONTRIBUTING.md` | +| `COMMIT_TYPES.md` | `docs/COMMIT_TYPES.md` | + +This way, when the macros inject content from root files into the docs, relative links resolve +correctly in both contexts without any rewriting. + +## Adding Content via Macros + +If you want to reuse content from a root-level markdown file: + +1. Open `scripts/zensical_macros.py`. +2. Read the file and assign it to a variable: + ```python + with open(md_dir / "MY_FILE.md", "r", encoding="utf8") as f: + env.variables["MY_FILE"] = f.read() + ``` +3. Use `{{ MY_FILE }}` in your docs page. +4. Add the source file to the `watch` list in `zensical.toml`. + +## Theme & Features + +The site uses Zensical's default theme with light/dark mode toggle and includes features like: + +- Instant navigation with prefetching +- Code block copy buttons and annotations +- Content tabs and footnote tooltips +- Navigation sections, footer navigation, and breadcrumb paths +- Search with highlighting + +These are configured in the `[project.theme]` section of `zensical.toml`. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..bf803b7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,9 @@ +--- +title: Getting Started +description: A Python client for LibreNMS, providing an easy-to-use interface for interacting with the LibreNMS API, enabling developers to integrate and automate tasks with LibreNMS efficiently. +icon: lucide/rocket +--- + + + +{{ README_MAIN }} diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 0000000..47301e1 --- /dev/null +++ b/docs/support.md @@ -0,0 +1,40 @@ +--- +title: Support +description: How to get help and report issues +icon: lucide/message-circle-question-mark +--- + +## Support + +### Getting Help + +If you have a question about using libreclient, there are a few ways to get support: + +- **[GitHub Issues](https://github.com/jjeff07/libreclient/issues)** — Open an issue for bugs, feature requests, new + route requests, or general questions. +- **[Documentation](https://jjeff07.github.io/libreclient/)** — Check the docs for usage guides and API reference. + +### Reporting Bugs + +If you've found a bug, +please [open a bug report](https://github.com/jjeff07/libreclient/issues/new?template=bug_report.md) with: + +- A minimal code snippet to reproduce the issue +- The full error traceback +- Your environment details (Python version, libreclient version, LibreNMS version) + +### Requesting Features + +Have an idea for an +improvement? [Open a feature request](https://github.com/jjeff07/libreclient/issues/new?template=feature_request.md). + +For new LibreNMS API endpoint support, use +the [new route request](https://github.com/jjeff07/libreclient/issues/new?template=new_route.md) template instead. + +### Contributing a Fix + +Found the problem yourself? PRs are welcome! See the [Contributing](CONTRIBUTING.md) guide for how to get started. + +--- + +{{ SECURITY }} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 070350a..44fe836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.2" description = "Async and sync Python client for the LibreNMS API" readme = "README.md" authors = [ - { name = "Justin Jeffery", email = "34625666+jjeff07@users.noreply.github.com" } + { name = "Justin Jeffery", email = "jjeff@j-jeff.com" } ] requires-python = ">=3.12" license = "MIT" @@ -33,6 +33,7 @@ dev = [ "pytest>=9.0.3", "python-dotenv>=1.2.2", "ruff>=0.15.16", + "zensical>=0.0.45", ] [tool.pytest.ini_options] diff --git a/scripts/zensical_macros.py b/scripts/zensical_macros.py new file mode 100644 index 0000000..9ca9bde --- /dev/null +++ b/scripts/zensical_macros.py @@ -0,0 +1,72 @@ +"""Zensical macros plugin — exposes project markdown files as template variables. + +Variables available in docs templates: + {{ README_MAIN }} - README without Contributing/Development sections + {{ README_CONTRIBUTING }} - The Contributing section from README + {{ README_DEVELOPMENT }} - The Development section from README + {{ CONTRIBUTING }} - Full CONTRIBUTING.md + {{ COMMIT_TYPES }} - Full COMMIT_TYPES.md +""" + +import re +from pathlib import Path + + +def _extract_section(content: str, heading: str) -> str: + """Extract a ## section from markdown, including its content up to the next ## heading or EOF.""" + pattern = ( + rf"(^---\s*\n\s*\n)?^## {re.escape(heading)}\s*\n(.*?)(?=^## |\Z)" + ) + match = re.search(pattern, content, re.MULTILINE | re.DOTALL) + if not match: + return "" + # Return content without the leading --- separator if present + section = match.group(0).lstrip("-").lstrip("\n") + return ( + f"## {heading}\n" + section.split("\n", 1)[-1] + if section.startswith(f"## {heading}") + else f"## {heading}\n{section}" + ) + + +def _remove_sections(content: str, headings: list[str]) -> str: + """Remove ## sections (and any preceding --- separator) from markdown.""" + for heading in headings: + pattern = ( + rf"(^---\s*\n\s*\n)?^## {re.escape(heading)}\s*\n(.*?)(?=^## |\Z)" + ) + content = re.sub(pattern, "", content, flags=re.MULTILINE | re.DOTALL) + return content.rstrip("\n") + "\n" + + +def define_env(env): + md_dir = Path(__file__).parent.parent + readme_path = md_dir / "README.md" + contributing_path = md_dir / "CONTRIBUTING.md" + commit_path = md_dir / "COMMIT_TYPES.md" + security_path = md_dir / "SECURITY.md" + + with open(readme_path, encoding="utf8") as f: + readme_content = f.read() + + with open(contributing_path, encoding="utf8") as f: + env.variables["CONTRIBUTING"] = f.read() + + with open(commit_path, encoding="utf8") as f: + env.variables["COMMIT_TYPES"] = f.read() + + with open(security_path, encoding="utf8") as f: + env.variables["SECURITY"] = f.read() + + # Parsed sections from README + env.variables["README_CONTRIBUTING"] = _extract_section( + readme_content, "Contributing" + ) + env.variables["README_DEVELOPMENT"] = _extract_section( + readme_content, "Development" + ) + + # README without Contributing and Development sections + env.variables["README_MAIN"] = _remove_sections( + readme_content, ["Contributing", "Development"] + ) diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock index 9b95915..84aa6c7 100644 --- a/uv.lock +++ b/uv.lock @@ -106,6 +106,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -189,6 +201,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" }, ] +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -303,6 +324,7 @@ dev = [ { name = "pytest" }, { name = "python-dotenv" }, { name = "ruff" }, + { name = "zensical" }, ] [package.metadata] @@ -320,6 +342,16 @@ dev = [ { name = "pytest", specifier = ">=9.0.3" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "ruff", specifier = ">=0.15.16" }, + { name = "zensical", specifier = ">=0.0.45" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] [[package]] @@ -563,6 +595,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.21.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -958,3 +1003,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, ] + +[[package]] +name = "zensical" +version = "0.0.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/d1/ecb1889fd2208b2d577e6ff952d9bee201302eec7966b5b61cc64adfd8f5/zensical-0.0.45.tar.gz", hash = "sha256:315bce4ab0470338dd3588add38fb325f840856c375722e6802bd58a06446266", size = 3935947, upload-time = "2026-06-09T11:23:32.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/fd/6b84115e3bbe6b76ebb1265e8ff2161c0bc88dcd6499eaf29c61a66421e9/zensical-0.0.45-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c4cb2e11132f02ae824e246e016e073458e12e9de1eaf86fd39f01890d41204c", size = 12698844, upload-time = "2026-06-09T11:22:56.537Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/4ddf05d77c1455c32cb26da71f2a19d355927a45a3db5b26fb258a07ce8f/zensical-0.0.45-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:799a01de2102b5f731744ad31bdbc464d0c07d484e67ba148f6923679afa6ce6", size = 12571590, upload-time = "2026-06-09T11:23:00.192Z" }, + { url = "https://files.pythonhosted.org/packages/4c/53/60c6cc7b2ce8b1a83eb87bff3f7289447995552fd9a30ca76ffba22ca9d5/zensical-0.0.45-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6201e79ea8a64bd3ced3f05ef4b1529da0e675d67b1395987c0ba942e4e10dc4", size = 12939590, upload-time = "2026-06-09T11:23:02.721Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1e/e9217ed75dba323a6f9a4eee28eb40416eff99932cd0ee6c394bf07b9ead/zensical-0.0.45-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:854aaf500e4a3ce64adea1faa7a1820c7cf9a4f66be1043e4e9ba727fe9cf2b5", size = 12911669, upload-time = "2026-06-09T11:23:05.407Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/6fc9fe2334bb4460a8a8d732e23a30d2ddc2ecf63c2eb3487d9e7405e70d/zensical-0.0.45-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a80c57fd50fc60415914388286ac10a7d8b6f70b8ca7235597d09fb12c3171b0", size = 13267643, upload-time = "2026-06-09T11:23:07.915Z" }, + { url = "https://files.pythonhosted.org/packages/be/f9/5696114af4ede5f1bd01e641a4ff24ee8ca49810bfaa28e5be12d930c0ef/zensical-0.0.45-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58c3510f69e08b6ed8bb9596fc9393e4687f90394aa0ef2d6118b1375ad97be5", size = 12972147, upload-time = "2026-06-09T11:23:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9e/5c6acde480c43f8c993b13260925df8db31d51ab8a9977618e9efdd98d45/zensical-0.0.45-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:01c484bb2ee85e98e21e24b397ff52ffc31101f7485935eee5d3afa6cca6cc08", size = 13117360, upload-time = "2026-06-09T11:23:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/3d/31/ea21f102049b35a8fe5218c5331857a15eeb60deb1bb21823a4c0701e274/zensical-0.0.45-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3654b708830303759e866a58a60c483cd2a1c56a44acdaae5bbb341a3f40ebce", size = 13185593, upload-time = "2026-06-09T11:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/b4/97/6ded39fe27fa8a292d17d9af713b018e4919315233b60fa4b4b0aca737a6/zensical-0.0.45-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c4da1c37eca1474b487def0ef40d7ac2aff31a9d7a029cb7479ef7c354437361", size = 13326882, upload-time = "2026-06-09T11:23:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/79/80/075975032a9e20f319c0134f8ca659d295ee4908f15ab212702a2728247f/zensical-0.0.45-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f8a1966c186feebd3b795f9d420000bfd582e16eefdd9bc7a286d878faabae52", size = 13253961, upload-time = "2026-06-09T11:23:23.99Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6a/0eab0eb311af6a07cde15ca58d5d720cbfa02cd509e4c7fb5fa20cda0b46/zensical-0.0.45-cp310-abi3-win32.whl", hash = "sha256:a1dd63a5efb8d0e5f2fadf862f02771a279dc5cbe9a982700194650065758f01", size = 12257083, upload-time = "2026-06-09T11:23:26.769Z" }, + { url = "https://files.pythonhosted.org/packages/1f/cd/b117e749c60b1d1e16b8450db1355f69f38376f783b8c6c8815202988933/zensical-0.0.45-cp310-abi3-win_amd64.whl", hash = "sha256:1f2c0e69839ce4274bde34d18139d3b0d96bbf02b245ada46243590c9eedebc1", size = 12498335, upload-time = "2026-06-09T11:23:29.702Z" }, +] diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000..375150e --- /dev/null +++ b/zensical.toml @@ -0,0 +1,384 @@ +# ============================================================================ +# +# The configuration produced by default is meant to highlight the features +# that Zensical provides and to serve as a starting point for your own +# projects. +# +# ============================================================================ + +[project] + +repo_url = "https://github.com/jjeff07/libreclient" +edit_uri = "edit/main/docs/" + +watch = [ + "scripts/zensical_macros.py", + "README.md", + "COMMIT_TYPES.md", + "CHANGELOG.md", + "CONTRIBUTING.md", + "SECURITY.md", +] + +# The site_name is shown in the page header and the browser window title +# +# Read more: https://zensical.org/docs/setup/basics/#site_name +site_name = "Libreclient Documentation" + +# The site_description is included in the HTML head and should contain a +# meaningful description of the site content for use by search engines. +# +# Read more: https://zensical.org/docs/setup/basics/#site_description +site_description = "Documentation for libreclient — an async and sync Python client for the LibreNMS API with typed responses, dual interface support, and environment-driven configuration." + +# The site_author attribute. This is used in the HTML head element. +# +# Read more: https://zensical.org/docs/setup/basics/#site_author +site_author = "Justin Jeffery" + +# The site_url is the canonical URL for your site. When building online +# documentation you should set this. +# Read more: https://zensical.org/docs/setup/basics/#site_url +site_url = "https://jjeff07.github.io/libreclient/" + +# The copyright notice appears in the page footer and can contain an HTML +# fragment. +# +# Read more: https://zensical.org/docs/setup/basics/#copyright +copyright = """ +Copyright © 2026 Justin Jeffery +""" + +# Zensical supports both implicit navigation and explicitly defined navigation. +# If you decide not to define a navigation here then Zensical will simply +# derive the navigation structure from the directory structure of your +# "docs_dir". The definition below demonstrates how a navigation structure +# can be defined using TOML syntax. +# +# Read more: https://zensical.org/docs/setup/navigation/ +nav = [ + {"Getting Started" = "index.md"}, + {"Support" = "support.md"}, + { + "Development" = [ + "development.md", + "documentation.md", + "CONTRIBUTING.md", + "COMMIT_TYPES.md" + ] + }, +] + +# With the "extra_css" option you can add your own CSS styling to customize +# your Zensical project according to your needs. You can add any number of +# CSS files. +# +# The path provided should be relative to the "docs_dir". +# +# Read more: https://zensical.org/docs/customization/#additional-css +# +#extra_css = ["stylesheets/extra.css"] + +# With the `extra_javascript` option you can add your own JavaScript to your +# project to customize the behavior according to your needs. +# +# The path provided should be relative to the "docs_dir". +# +# Read more: https://zensical.org/docs/customization/#additional-javascript +#extra_javascript = ["javascripts/extra.js"] + +# ---------------------------------------------------------------------------- +# Section for configuring theme options +# ---------------------------------------------------------------------------- +[project.theme] + +# change this to "classic" to use the traditional Material for MkDocs look. +#variant = "classic" + +# Zensical allows you to override specific blocks, partials, or whole +# templates as well as to define your own templates. To do this, uncomment +# the custom_dir setting below and set it to a directory in which you +# keep your template overrides. +# +# Read more: +# - https://zensical.org/docs/customization/#extending-the-theme +# +#custom_dir = "overrides" + +# With the "favicon" option you can set your own image to use as the icon +# browsers will use in the browser title bar or tab bar. The path provided +# must be relative to the "docs_dir". +# +# Read more: +# - https://zensical.org/docs/setup/logo-and-icons/#favicon +# - https://developer.mozilla.org/en-US/docs/Glossary/Favicon +# +#favicon = "images/favicon.png" + +# Zensical supports more than 60 different languages. This means that the +# labels and tooltips that Zensical's templates produce are translated. +# The "language" option allows you to set the language used. This language +# is also indicated in the HTML head element to help with accessibility +# and guide search engines and translation tools. +# +# The default language is "en" (English). It is possible to create +# sites with multiple languages and configure a language selector. See +# the documentation for details. +# +# Read more: +# - https://zensical.org/docs/setup/language/ +# +language = "en" + +# Zensical provides a number of feature toggles that change the behavior +# of the documentation site. +features = [ + # Zensical includes an announcement bar. This feature allows users to + # dismiss it when they have read the announcement. + # https://zensical.org/docs/setup/header/#announcement-bar + "announce.dismiss", + + # If you have a repository configured and turn on this feature, Zensical + # will generate an edit button for the page. This works for common + # repository hosting services. + # https://zensical.org/docs/setup/repository/#content-actions + "content.action.edit", + + # If you have a repository configured and turn on this feature, Zensical + # will generate a button that allows the user to view the Markdown + # code for the current page. + # https://zensical.org/docs/setup/repository/#content-actions + "content.action.view", + + # Code annotations allow you to add an icon with a tooltip to your + # code blocks to provide explanations at crucial points. + # https://zensical.org/docs/authoring/code-blocks/#code-annotations + "content.code.annotate", + + # This feature turns on a button in code blocks that allow users to + # copy the content to their clipboard without first selecting it. + # https://zensical.org/docs/authoring/code-blocks/#code-copy-button + "content.code.copy", + + # Code blocks can include a button to allow for the selection of line + # ranges by the user. + # https://zensical.org/docs/authoring/code-blocks/#code-selection-button + "content.code.select", + + # Zensical can render footnotes as inline tooltips, so the user can read + # the footnote without leaving the context of the document. + # https://zensical.org/docs/authoring/footnotes/#footnote-tooltips + "content.footnote.tooltips", + + # If you have many content tabs that have the same titles (e.g., "Python", + # "JavaScript", "Cobol"), this feature causes all of them to switch to + # at the same time when the user chooses their language in one. + # https://zensical.org/docs/authoring/content-tabs/#linked-content-tabs + "content.tabs.link", + + # With this feature enabled users can add tooltips to links that will be + # displayed when the mouse pointer hovers the link. + # https://zensical.org/docs/authoring/tooltips/#improved-tooltips + "content.tooltips", + + # With this feature enabled, Zensical will automatically hide parts + # of the header when the user scrolls past a certain point. + # https://zensical.org/docs/setup/header/#automatic-hiding + # "header.autohide", + + # Turn on this feature to expand all collapsible sections in the + # navigation sidebar by default. + # https://zensical.org/docs/setup/navigation/#navigation-expansion + # "navigation.expand", + + # This feature turns on navigation elements in the footer that allow the + # user to navigate to a next or previous page. + # https://zensical.org/docs/setup/footer/#navigation + "navigation.footer", + + # When section index pages are enabled, documents can be directly attached + # to sections, which is particularly useful for providing overview pages. + # https://zensical.org/docs/setup/navigation/#section-index-pages + "navigation.indexes", + + # When instant navigation is enabled, clicks on all internal links will be + # intercepted and dispatched via XHR without fully reloading the page. + # https://zensical.org/docs/setup/navigation/#instant-navigation + "navigation.instant", + + # With instant prefetching, your site will start to fetch a page once the + # user hovers over a link. This will reduce the perceived loading time + # for the user. + # https://zensical.org/docs/setup/navigation/#instant-prefetching + "navigation.instant.prefetch", + + # In order to provide a better user experience on slow connections when + # using instant navigation, a progress indicator can be enabled. + # https://zensical.org/docs/setup/navigation/#progress-indicator + #"navigation.instant.progress", + + # When navigation paths are activated, a breadcrumb navigation is rendered + # above the title of each page + # https://zensical.org/docs/setup/navigation/#navigation-path + "navigation.path", + + # When pruning is enabled, only the visible navigation items are included + # in the rendered HTML, reducing the size of the built site by 33% or more. + # https://zensical.org/docs/setup/navigation/#navigation-pruning + #"navigation.prune", + + # When sections are enabled, top-level sections are rendered as groups in + # the sidebar for viewports above 1220px, but remain as-is on mobile. + # https://zensical.org/docs/setup/navigation/#navigation-sections + "navigation.sections", + + # When tabs are enabled, top-level sections are rendered in a menu layer + # below the header for viewports above 1220px, but remain as-is on mobile. + # https://zensical.org/docs/setup/navigation/#navigation-tabs + #"navigation.tabs", + + # When sticky tabs are enabled, navigation tabs will lock below the header + # and always remain visible when scrolling down. + # https://zensical.org/docs/setup/navigation/#sticky-navigation-tabs + #"navigation.tabs.sticky", + + # A back-to-top button can be shown when the user, after scrolling down, + # starts to scroll up again. + # https://zensical.org/docs/setup/navigation/#back-to-top-button + "navigation.top", + + # When anchor tracking is enabled, the URL in the address bar is + # automatically updated with the active anchor as highlighted in the table + # of contents. + # https://zensical.org/docs/setup/navigation/#anchor-tracking + "navigation.tracking", + + # When search highlighting is enabled and a user clicks on a search result, + # Zensical will highlight all occurrences after following the link. + # https://zensical.org/docs/setup/search/#search-highlighting + "search.highlight", + + # When anchor following for the table of contents is enabled, the sidebar + # is automatically scrolled so that the active anchor is always visible. + # https://zensical.org/docs/setup/navigation/#anchor-following + # "toc.follow", + + # When navigation integration for the table of contents is enabled, it is + # always rendered as part of the navigation sidebar on the left. + # https://zensical.org/docs/setup/navigation/#navigation-integration + #"toc.integrate", +] + +# ---------------------------------------------------------------------------- +# You can configure your own logo to be shown in the header using the "logo" +# option in the "theme" subsection. The logo must be a relative path to a file +# in your "docs_dir", e.g., to use `docs/assets/logo.png` you would set: +# ---------------------------------------------------------------------------- +#logo = "assets/logo.png" + +# ---------------------------------------------------------------------------- +# If you don't have a dedicated project logo, you can use a built-in icon from +# the icon sets shipped in Zensical. Please note that the setting lives in a +# different subsection, and that the above take precedence over the icon. +# +# Read more: +# - https://zensical.org/docs/setup/logo-and-icons +# - https://github.com/zensical/ui/tree/master/dist/.icons +# ---------------------------------------------------------------------------- +[project.theme.icon] +logo = "fontawesome/brands/github" + +# ---------------------------------------------------------------------------- +# In the "font" subsection you can configure the fonts used. By default, fonts +# are loaded from Google Fonts, giving you a wide range of choices from a set +# of suitably licensed fonts. There are options for a normal text font and for +# a monospaced font used in code blocks. +# ---------------------------------------------------------------------------- +#[project.theme.font] +#text = "Inter" +#code = "Jetbrains Mono" + +# ---------------------------------------------------------------------------- +# In the "palette" subsection you can configure options for the color scheme. +# You can configure different color schemes, e.g., to turn on dark mode, +# that the user can switch between. Each color scheme can be further +# customized. +# +# Read more: +# - https://zensical.org/docs/setup/colors/ +# ---------------------------------------------------------------------------- +# Palette toggle for automatic mode +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to light mode" + +# Palette toggle for dark mode +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +toggle.icon = "lucide/moon" +toggle.name = "Switch to system preference" + +# Palette toggle for light mode +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + + + +# ---------------------------------------------------------------------------- +# The "extra" section contains miscellaneous settings. +# ---------------------------------------------------------------------------- +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/jjeff07/libreclient" + +# ---------------------------------------------------------------------------- +# In this section you can configure the Markdown extensions that are used when +# rendering your documentation. We enable the most useful extensions by default, +# but you can customize this list to your needs. +# +# Read more: +# - https://zensical.org/docs/setup/extensions/ +# ---------------------------------------------------------------------------- +[project.markdown_extensions.abbr] +[project.markdown_extensions.admonition] +[project.markdown_extensions.attr_list] +[project.markdown_extensions.def_list] +[project.markdown_extensions.footnotes] +[project.markdown_extensions.md_in_html] +[project.markdown_extensions.toc] +permalink = true +[project.markdown_extensions.pymdownx.arithmatex] +generic = true +[project.markdown_extensions.pymdownx.betterem] +[project.markdown_extensions.pymdownx.caret] +[project.markdown_extensions.pymdownx.details] +[project.markdown_extensions.pymdownx.emoji] +emoji_generator = "zensical.extensions.emoji.to_svg" +emoji_index = "zensical.extensions.emoji.twemoji" +[project.markdown_extensions.pymdownx.highlight] +anchor_linenums = true +line_spans = "__span" +pygments_lang_class = true +[project.markdown_extensions.pymdownx.inlinehilite] +[project.markdown_extensions.pymdownx.keys] +[project.markdown_extensions.pymdownx.magiclink] +[project.markdown_extensions.pymdownx.mark] +[project.markdown_extensions.pymdownx.smartsymbols] +[project.markdown_extensions.pymdownx.superfences] +custom_fences = [ + { name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" } +] +[project.markdown_extensions.pymdownx.tabbed] +alternate_style = true +combine_header_slug = true +[project.markdown_extensions.pymdownx.tasklist] +custom_checkbox = true +[project.markdown_extensions.pymdownx.tilde] + +[project.markdown_extensions.zensical.extensions.macros] +module_name = "scripts.zensical_macros" \ No newline at end of file From dd85197579a15cd0816420a9df57306d19892387 Mon Sep 17 00:00:00 2001 From: Justin Jeffery Date: Wed, 10 Jun 2026 16:44:18 -0400 Subject: [PATCH 3/3] ci: Fix sarif upload. (#23) --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87a17e8..699f84b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ concurrency: permissions: contents: read + security-events: write jobs: lint: @@ -72,7 +73,10 @@ jobs: run: uv run complexipy . --output-format sarif --output complexipy-results.sarif --failed - name: Upload SARIF results - if: always() && hashFiles('complexipy-results.sarif') != '' + if: >- + always() + && hashFiles('complexipy-results.sarif') != '' + && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) uses: github/codeql-action/upload-sarif@v4 with: sarif_file: complexipy-results.sarif