diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a504b8f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: test (py${{ matrix.python-version }}, pydantic${{ matrix.pydantic-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + pydantic-version: ["~=2.0.0", "~=2.10.0"] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install deps + run: uv sync --all-extras --python ${{ matrix.python-version }} + + - name: Pin pydantic version + run: uv pip install "pydantic${{ matrix.pydantic-version }}" + + - name: Lint + run: uv run ruff check . + + - name: Type-check + run: uv run pyright + + - name: Unit tests + run: uv run pytest tests/unit tests/contract -q -m unit + + - name: Coverage + if: matrix.python-version == '3.13' && matrix.pydantic-version == '~=2.10.0' + run: | + uv run coverage run -m pytest tests/unit tests/contract -q -m unit + uv run coverage report -m --fail-under=95 + + integration: + name: integration tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + + - name: Install deps + run: uv sync --all-extras --python 3.13 + + - name: Start Snipe-IT stack + run: | + rm -rf docker/api_key.txt + touch docker/api_key.txt + cd docker && docker compose up -d + + - name: Wait for API key + run: | + echo "Waiting for docker/api_key.txt (up to ~120s)..." + for i in $(seq 1 120); do + if [ -s docker/api_key.txt ]; then break; fi + sleep 1 + done + if [ ! -s docker/api_key.txt ]; then + echo "Timed out waiting for docker/api_key.txt" + cd docker && docker compose logs seeder + exit 1 + fi + + - name: Wait for API readiness + run: | + TOKEN=$(cat docker/api_key.txt) + echo "Waiting for Snipe-IT API (up to ~120s)..." + for i in $(seq 1 120); do + code=$(curl -s -o /dev/null -w "%{http_code}" -m 5 \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json" \ + http://localhost:8000/api/v1/users/me 2>/dev/null || echo "000") + if [ "$code" = "200" ]; then + echo "API is ready" + break + fi + sleep 1 + done + if [ "$code" != "200" ]; then + echo "Timed out. Last status: $code" + cd docker && docker compose logs app + exit 1 + fi + + - name: Integration tests + run: uv run pytest tests/integration -q -m integration diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 0000000..827a26c --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,44 @@ +name: Mutation (advisory) + +on: + pull_request: + branches: [main, dev] + workflow_dispatch: + +# Advisory job — never blocks merges. +# Results are uploaded as an artifact for review. + +jobs: + mutation: + name: mutmut (advisory) + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.13 + + - name: Install deps + run: uv sync --all-extras --python 3.13 + + - name: Run mut-quick + run: uv run make mut-quick + continue-on-error: true + + - name: Print mutation summary + run: uv run mutmut results || true + + - name: Upload mutation cache + uses: actions/upload-artifact@v4 + if: always() + with: + name: mutmut-results + path: .mutmut-cache + retention-days: 7 diff --git a/.gitignore b/.gitignore index 29d4c3a..6bc0359 100644 --- a/.gitignore +++ b/.gitignore @@ -61,10 +61,16 @@ env/ # Local GEMINI.md +.kiro/ .mutmut-cache .mutmut-cache/ mutants/ *.bak -*.lock *.html -*.tmp \ No newline at end of file +*.tmp + +# Tool caches +.ruff_cache/ + +# Docker dev secrets (generated at runtime) +docker/api_key.txt \ No newline at end of file diff --git a/.kiro/settings/lsp.json b/.kiro/settings/lsp.json new file mode 100644 index 0000000..3181693 --- /dev/null +++ b/.kiro/settings/lsp.json @@ -0,0 +1,205 @@ +{ + "languages": { + "typescript": { + "name": "typescript-language-server", + "command": "typescript-language-server", + "args": [ + "--stdio" + ], + "file_extensions": [ + "ts", + "js", + "tsx", + "jsx" + ], + "file_patterns": [], + "project_patterns": [ + "package.json", + "tsconfig.json" + ], + "exclude_patterns": [ + "**/node_modules/**", + "**/dist/**" + ], + "multi_workspace": false, + "initialization_options": { + "preferences": { + "disableSuggestions": false + } + }, + "request_timeout_secs": 60 + }, + "rust": { + "name": "rust-analyzer", + "command": "rust-analyzer", + "args": [], + "file_extensions": [ + "rs" + ], + "file_patterns": [], + "project_patterns": [ + "Cargo.toml" + ], + "exclude_patterns": [ + "**/target/**" + ], + "multi_workspace": false, + "initialization_options": { + "cargo": { + "buildScripts": { + "enable": true + } + }, + "diagnostics": { + "enable": true, + "enableExperimental": true + }, + "workspace": { + "symbol": { + "search": { + "scope": "workspace" + } + } + } + }, + "request_timeout_secs": 60 + }, + "python": { + "name": "pyright", + "command": "pyright-langserver", + "args": [ + "--stdio" + ], + "file_extensions": [ + "py" + ], + "file_patterns": [], + "project_patterns": [ + "pyproject.toml", + "setup.py", + "requirements.txt", + "pyrightconfig.json" + ], + "exclude_patterns": [ + "**/__pycache__/**", + "**/venv/**", + "**/.venv/**", + "**/.pytest_cache/**" + ], + "multi_workspace": false, + "initialization_options": {}, + "request_timeout_secs": 60 + }, + "java": { + "name": "jdtls", + "command": "jdtls", + "args": [], + "file_extensions": [ + "java" + ], + "file_patterns": [], + "project_patterns": [ + "pom.xml", + "build.gradle", + "build.gradle.kts", + ".project" + ], + "exclude_patterns": [ + "**/target/**", + "**/build/**", + "**/.gradle/**" + ], + "multi_workspace": false, + "initialization_options": { + "settings": { + "java": { + "compile": { + "nullAnalysis": { + "mode": "automatic" + } + }, + "configuration": { + "annotationProcessing": { + "enabled": true + } + } + } + } + }, + "request_timeout_secs": 60 + }, + "go": { + "name": "gopls", + "command": "gopls", + "args": [], + "file_extensions": [ + "go" + ], + "file_patterns": [], + "project_patterns": [ + "go.mod", + "go.sum" + ], + "exclude_patterns": [ + "**/vendor/**" + ], + "multi_workspace": false, + "initialization_options": { + "usePlaceholders": true, + "completeUnimported": true + }, + "request_timeout_secs": 60 + }, + "ruby": { + "name": "solargraph", + "command": "solargraph", + "args": [ + "stdio" + ], + "file_extensions": [ + "rb" + ], + "file_patterns": [], + "project_patterns": [ + "Gemfile", + "Rakefile" + ], + "exclude_patterns": [ + "**/vendor/**", + "**/tmp/**" + ], + "multi_workspace": false, + "initialization_options": {}, + "request_timeout_secs": 60 + }, + "cpp": { + "name": "clangd", + "command": "clangd", + "args": [ + "--background-index" + ], + "file_extensions": [ + "cpp", + "cc", + "cxx", + "c", + "h", + "hpp", + "hxx" + ], + "file_patterns": [], + "project_patterns": [ + "CMakeLists.txt", + "compile_commands.json", + "Makefile" + ], + "exclude_patterns": [ + "**/build/**", + "**/cmake-build-**/**" + ], + "multi_workspace": false, + "initialization_options": {}, + "request_timeout_secs": 60 + } + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..699085c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,159 @@ +# Changelog + +## Unreleased + +## 0.4.0 (2026-05-16) + +### Custom-field staging refactor + +- **Added** `Asset.pending_custom_fields()` — returns a dict of custom field + values staged for the next `save()` (label → value). Defensive copy. +- **Bug fix**: `Asset.set_custom_field()` followed by `save()` no longer + requires a manual `refresh()` before subsequent `set_custom_field()` calls + on the same in-memory instance. Snipe-IT's PATCH response sets + `custom_fields: null` and echoes column-name keys (`_snipeit_`) at + the top level instead. `Asset._apply_server_data` now folds those values + back into the local nested shape and strips stray column-name keys, so + the read shape survives across saves. +- **Behavior change**: `Asset.get_custom_field(label)` now returns the + server's last-known value, not the staged-but-unsaved value. Use + `pending_custom_fields()` to inspect staging state. Previously, the + staged value was mirrored into `custom_fields[label]["value"]` for + read-after-stage convenience; the mirror was a side effect of the + pydantic-internals coupling and is no longer needed. +- **Behavior change**: Setting a custom field back to its current server + value now cancels any pending stage for that label (no PATCH issued). + Previously, a redundant stage was preserved. +- **Internal**: `Asset.set_custom_field` no longer reads or writes + `__pydantic_extra__`, no longer manipulates the dirty-tracker snapshot, + and no longer mirrors staged values into the response shape. Staging + lives entirely in a dedicated `_pending_custom_fields: dict[str, Any]` + PrivateAttr; `Asset.save()` and `Asset._apply_server_data` are overridden + to manage its lifecycle. The only remaining pydantic-internals coupling + is in `ApiObject._apply_server_data`, which is documented and tested. + +### Test suite overhaul + +- **Removed 13 duplicated per-resource test files** (~500 LoC) and replaced them + with a single parametrised smoke test covering all 15 managers × 6 operations. +- **Added Companies and Suppliers** to repr tests and integration CRUD suite. +- **Fixture URL** switched to RFC 6761 reserved domain `snipe.example.test`; + `real_snipeit_client` now yields and closes the HTTP client on teardown. +- **Integration env-var setup** converted from direct `os.environ` mutation to + `pytest.MonkeyPatch.context()` to prevent session leakage. +- **Assertion accuracy**: 3xx test pins `status_code` and `Location`; session + headers test inspects actual request headers and pins `User-Agent`; localized-404 + tests assert the lookup key is preserved in the exception message. +- **Coverage gaps closed**: URL/token validation, `_require_body` 204 on + POST/PUT/PATCH, error-message list/dict/null extraction, `_stream_request` + timeout/connect errors, `SnipeITValidationError` parse-failure warning, + `upload_files` validation + file-handle cleanup, `labels()` validation paths, + streaming download without `Content-Length`, `list_all` no-total termination, + `checkout` kwargs pass-through, PATCH/DELETE non-retry, `respect_retry_after=False`. +- **Retry tests**: future HTTP-date `Retry-After`, `respect_retry_after=False`, + PATCH/DELETE non-retry assertions added. +- **`filterwarnings = error`** added to `pytest.ini` — unintentional warnings now + fail the build. +- **Coverage gate** raised from 85% to 95% (current: 97% source, 98% overall). +- **Advisory mutation CI job** added (`.github/workflows/mutation.yml`); runs + `make mut-quick` on PRs, uploads `.mutmut-cache` as an artifact, never blocks. + +## 0.3.0 (2026-05-15) + +### Breaking changes + +- **`client.session` removed**: The `session` back-compat alias pointing at the + internal `httpx.Client` is gone. If you were accessing `client.session` + directly, switch to `client._http` (private) or use the public verb helpers + (`client.get`, `client.post`, etc.). +- **In-place mutation now PATCHes**: `asset.custom_fields["x"] = 1; asset.save()` + previously silently no-oped. It now correctly detects the mutation and includes + the field in the PATCH payload. Code that relied on the no-op behavior will + now send unexpected PATCH requests. +- **Stale extra fields cleared on refresh**: `_apply_server_data` now clears all + extra fields before applying new data. Previously, extra fields not present in + the server response would persist on the object indefinitely. + +### New features + +- **Snapshot-and-diff dirty tracking**: In-place mutations of nested dicts and + lists are detected automatically. `mark_dirty()` is still available as an + explicit escape hatch. +- **`_raw_request()` and `_stream_request()`**: New private helpers on the client + for non-JSON payloads (file uploads, binary downloads, PDF generation). All + timeout and connection-error handling is centralized here. + +### Bug fixes + +- **Stale extra fields**: `_apply_server_data` now calls `extra.clear()` before + applying new data, so fields removed by the server are no longer retained on + the local object. + +### Internal changes + +- `assets.py` split into a package: `snipeit/resources/assets/{model,manager,files,labels}.py`. + Public imports (`from snipeit.resources.assets import Asset, AssetsManager`) are unchanged. +- `upload_files`, `download_file`, and `labels` now use `_raw_request()` / + `_stream_request()` — no more duplicated `try/except httpx.*` blocks. +- `requests_mock` compatibility shim (`tests/_requests_mock_shim.py`) deleted. + All tests now use `pytest-httpx` (`httpx_mock`) directly. +- CI matrix expanded: pydantic 2.0.x and 2.10.x tested across Python 3.11/3.12/3.13. +- `delete_file` URL (`/hardware/:id/files/:file_id/delete`) verified against + snipe-it/develop `routes/api.php` (2026-05-15). The `/delete` suffix is correct. + +### Documentation + +- `LICENSE` (Apache 2.0) and `NOTICE` added. Copyright 2026 Wil Collier. +- `pyproject.toml` now includes author, license, project URLs, keywords, + classifiers, and a `py.typed` marker (PEP 561). +- README: "Common Pitfalls" section added (typo footgun, in-place mutation). +- README: "Not yet supported" section clarifies scope (Groups, Reports, Settings, + Audit log, Maintenances are not wrapped). +- `docker/.env` annotated as dev-only; `docker/README.md` added. +- Unused `docs/*.json` schemas (groups, audit, maintenances, reports, settings) + and `docs/split_api.py` deleted. + +### Async + +Still sync-only. Async support is tracked for 0.4. + +## 0.2.0 (2026-05-12) + +### Breaking changes + +- **HTTP library**: The underlying HTTP client is now `httpx` instead of `requests`. The `client.session` attribute still exists as a back-compat alias pointing at the `httpx.Client`, but `requests`-specific attributes (e.g. `session.adapters`) are gone. +- **`self.token` removed**: The API token is no longer stored as a plain attribute on the client. It lives only in the `Authorization` header. `repr(client)` now shows `token='***'`. +- **`_dirty_fields` removed**: `ApiObject` no longer exposes `_dirty_fields`. Use `obj._dirty_set()` to inspect dirty state, or `obj.mark_dirty(*fields)` to force fields into the next PATCH. +- **`delete()` return type**: `BaseResourceManager.delete()` and `SnipeIT.delete()` now return `dict | None` instead of `None`. Callers that ignored the return value are unaffected. +- **URL validation tightened**: `http://localhostevil.com`, URLs with embedded credentials (`https://user:pass@host`), and URLs with a non-root path (e.g. `https://host/snipeit`) now raise `ValueError`. The client assumes Snipe-IT is served at the root of the host; path-based reverse-proxy deployments are not supported in this release. + +### New features + +- **`httpx` transport**: Sync-only client on `httpx`. Structured so adding async later is mechanical. +- **Custom retry transport** (`snipeit._retry.RetryTransport`): Retries on status codes `{429, 500, 502, 503, 504}` for idempotent methods, with exponential backoff and `Retry-After` header support. +- **Pydantic v2 models**: `ApiObject` is now a `pydantic.BaseModel` with `extra="allow"`. Unknown fields from the API pass through as attributes. Dirty tracking uses `model_fields_set` for declared fields and a private `_extra_dirty` set for extras. +- **`mark_dirty(*fields)`**: Escape hatch for in-place mutation of nested objects (e.g. `asset.custom_fields["x"] = 1; asset.mark_dirty("custom_fields")`). +- **Streaming downloads**: `AssetsManager.download_file()` now streams in 64 KB chunks. Optional `progress` callback receives `(bytes_written, total_or_None)`. +- **Structured logging**: `snipeit.http` logger emits `DEBUG` per request (method, path, status, elapsed ms). `snipeit` logger emits `WARNING` on retries, timeouts, and request errors. The API token never appears in any log record. +- **3xx as error**: `follow_redirects=False` on the httpx client. A 302 redirect (common symptom of a reverse proxy routing API traffic to the web login page) raises `SnipeITApiError` with a clear message instead of silently returning an HTML page. +- **Localization-safe lookups**: `get_by_tag` and `get_by_serial` no longer match on English error strings. They rely on the HTTP 404 status code, so they work correctly on any Snipe-IT locale. +- **Exceptions exported at top level**: `from snipeit import SnipeITNotFoundError` now works without the subpath. +- **`SnipeIT.__repr__`**: `` — safe to paste into issue trackers. +- **Pagination safety**: `list_all(offset=N)` raises `ValueError` to prevent accidentally breaking page iteration. +- **CI**: GitHub Actions workflow running lint, type-check, and unit tests on Python 3.11, 3.12, and 3.13. +- **Companies and Suppliers managers**: `api.companies` and `api.suppliers` (added in a prior commit, now fully integrated). + +### Internal changes + +- Replaced `requests`/`urllib3` with `httpx` + custom `RetryTransport`. +- Replaced `typing.Dict`/`Set`/`List`/`Tuple`/`Type` with built-in generics (Python ≥ 3.11). +- Dropped `client.pyi` stub in favour of eager manager imports (pyright infers types directly). +- Dropped `__getattr__`/`_manager_registry`/`__dir__` lazy-loading machinery. +- `_extract_payload()` helper unifies response-shape handling across `save()`, `create()`, and `patch()`. +- `SnipeITValidationError` logs a `WARNING` when the error body cannot be parsed as JSON. +- Removed `ty` from dev dependencies. +- Removed stale `build/` and `snipeit_api.egg-info/` directories. + +## 0.1.0 + +Initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6db2f1e --- /dev/null +++ b/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship made available under + the License, as indicated by a copyright notice that is included in + or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean, as submitted to the Licensor for inclusion + in the Work by the copyright owner or by an individual or Legal Entity + authorized to submit on behalf of the copyright owner. For the purposes + of this definition, "submitted" means any form of electronic, verbal, + or written communication sent to the Licensor or its representatives, + including but not limited to communication on electronic mailing lists, + source code control systems, and issue tracking systems that are managed + by, or on behalf of, the Licensor for the purpose of discussing and + improving the Work, but excluding communication that is conspicuously + marked or designated in writing by the copyright owner as "Not a + Contribution." + + "Contributor" shall mean Licensor and any Legal Entity on behalf of + whom a Contribution has been received by the Licensor and subsequently + incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by the combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a cross-claim + or counterclaim in a lawsuit) alleging that the Work or any + Contribution embodied within the Work constitutes direct or contributory + patent infringement, then any patent licenses granted to You under + this License for that Work shall terminate as of the date such + litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, You must include a readable copy of the + attribution notices contained within such NOTICE file, in + at least one of the following places: within a NOTICE text + file distributed as part of the Derivative Works; within + the Source form or documentation, if provided along with the + Derivative Works; or, within a display generated by the + Derivative Works, if and wherever such third-party notices + normally appear. The contents of the NOTICE file are for + informational purposes only and do not modify the License. + You may add Your own attribution notices within Derivative + Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional + attribution notices cannot be construed as modifying the License. + + You may add Your own license statement for Your modifications and + may provide additional grant of rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the + Contribution, either on an unrestricted basis or subject to + different terms and conditions than those stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or reproducing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or exemplary damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or all other + commercial damages or losses), even if such Contributor has been + advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format in question. + + Copyright 2026 Wil Collier + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile index c901983..1f2569a 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,16 @@ # Simple local entrypoints -PY ?= python3 +PY ?= .venv/bin/python -.PHONY: test test-unit check cov cov-html property mut mut-report mut-reset clean docker-up docker-down test-integration test-all +.PHONY: test test-unit check cov cov-html property mut mut-quick mut-report mut-reset clean docker-up docker-down test-integration test-all # Run unit tests only test: - $(PY) -m pytest tests/unit -q -m unit + $(PY) -m pytest tests/unit tests/contract -q -m unit # Run unit tests only (alias) test-unit: - $(PY) -m pytest tests/unit -q -m unit + $(PY) -m pytest tests/unit tests/contract -q -m unit # Lint and type check check: @@ -19,13 +19,19 @@ check: # Run tests with coverage (branch coverage) and enforce 95% cov: - $(PY) -m coverage run -m pytest -q && \ + $(PY) -m coverage run -m pytest tests/unit tests/contract -q -m unit && \ $(PY) -m coverage report -m --fail-under=95 # Mutation testing (can be slow) mut: $(PY) -m mutmut run --paths-to-mutate snipeit --tests-dir tests || true +# Quick mutation run scoped to the highest-value source files (used in CI) +mut-quick: + $(PY) -m mutmut run \ + --paths-to-mutate snipeit/client.py,snipeit/_retry.py,snipeit/resources/base.py \ + --tests-dir tests/unit tests/contract || true + mut-report: $(PY) -m mutmut results @@ -37,15 +43,31 @@ clean: $(MAKE) docker-down # Start Snipe-IT stack +# Ensure docker/api_key.txt exists as a regular empty file BEFORE docker compose +# bind-mounts it. If the path doesn't exist (or is a directory), Docker will +# auto-create it as a directory, breaking the seeder's `> /api_key.txt` redirect. docker-up: + @if [ ! -f docker/api_key.txt ] || [ -d docker/api_key.txt ]; then \ + rm -rf docker/api_key.txt; \ + touch docker/api_key.txt; \ + fi cd docker && docker compose up -d -# Stop stack and delete volumes +# Stop stack and delete volumes. Restore api_key.txt as an empty regular file +# so the next `make docker-up` has a valid bind-mount target. docker-down: cd docker && docker compose down -v - > docker/api_key.txt + rm -rf docker/api_key.txt + touch docker/api_key.txt -# Run integration tests: bring up docker, wait for api_key.txt, then test +# Run integration tests: bring up docker, wait for api_key.txt AND for the API +# to actually respond to authenticated requests, then test. +# +# Two-stage wait is required because the seeder writes the token to +# api_key.txt as soon as it generates one, but the Snipe-IT app container is +# usually still booting Apache/PHP at that point. Hitting the API before it's +# ready causes ECONNRESET on the first few requests, which manifests as +# "flaky" test failures that disappear on a re-run once the app is warm. test-integration: $(MAKE) docker-up @echo "Waiting for docker/api_key.txt (up to ~120s)..." @@ -53,11 +75,33 @@ test-integration: while [ ! -s docker/api_key.txt ] && [ $$i -lt 120 ]; do \ sleep 1; i=$$((i+1)); \ done; \ + if [ -d docker/api_key.txt ]; then \ + echo "ERROR: docker/api_key.txt is a directory, not a file. Run 'make docker-down' to reset."; \ + exit 1; \ + fi; \ if [ ! -s docker/api_key.txt ]; then \ echo "Timed out waiting for docker/api_key.txt. Check 'docker compose logs --follow seeder'."; \ exit 1; \ fi - .venv/bin/python -m pytest -q -m integration + @echo "Waiting for Snipe-IT API to accept authenticated requests (up to ~120s)..." + @TOKEN=$$(cat docker/api_key.txt); \ + i=0; \ + while [ $$i -lt 120 ]; do \ + code=$$(curl -s -o /dev/null -w "%{http_code}" -m 5 \ + -H "Authorization: Bearer $$TOKEN" \ + -H "Accept: application/json" \ + http://localhost:8000/api/v1/users/me 2>/dev/null || echo "000"); \ + if [ "$$code" = "200" ]; then \ + echo "API is ready (HTTP 200 on /users/me)"; \ + break; \ + fi; \ + sleep 1; i=$$((i+1)); \ + done; \ + if [ "$$code" != "200" ]; then \ + echo "Timed out waiting for Snipe-IT API. Last status: $$code. Check 'docker compose logs --follow app'."; \ + exit 1; \ + fi + .venv/bin/python -m pytest tests/integration -q -m integration # Run both unit and integration tests diff --git a/README.md b/README.md index b1e9fd8..17ae8f2 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,205 @@ # Snipe-IT Python API Client -A Python client for the [Snipe-IT](https://snipeitapp.com/) API. This library provides a convenient way to interact with a Snipe-IT instance, manage assets, users, and other resources. +A Python client for the [Snipe-IT](https://snipeitapp.com/) API. ## Features -* Object-oriented interface for Snipe-IT resources. -* Handles API authentication, request signing, and response parsing. -* Support for CRUD operations (Create, Read, Update, Delete) on various resources. -* Automatic retry mechanism for transient server errors. -* Integration with a local Dockerized Snipe-IT for development and testing. +- Object-oriented interface for Snipe-IT resources. +- Handles authentication, retries, timeouts, and response parsing. +- CRUD operations on all major resources. +- Automatic retry with exponential backoff and `Retry-After` support. +- Streaming file downloads with optional progress callback. +- Structured logging (`snipeit` / `snipeit.http` loggers). +- Pydantic v2 models with `extra="allow"` — resilient to Snipe-IT version drift. ### Supported Resources -* Accessories -* Assets -* Categories -* Components -* Consumables -* Departments -* Fields -* Fieldsets -* Licenses -* Locations -* Manufacturers -* Models -* Status Labels -* Users +Accessories, Assets, Categories, Companies, Components, Consumables, +Departments, Fields, Fieldsets, Licenses, Locations, Manufacturers, +Models, Status Labels, Suppliers, Users. -## Getting Started +### Not yet supported -### Installation +The following Snipe-IT API endpoints are **not** wrapped by this client: +Groups, Reports, Settings, Audit log, Maintenances (asset-level +`create_maintenance` is the only related method). Use the raw +`client.get`/`client.post` verbs against those paths if needed. -To install the library: +## Common Pitfalls -```bash -# Using uv -uv add git+https://github.com/lfctech/snipeit-python-api@main +### Typos on model attributes are silently accepted -# Or using pip -pip install git+https://github.com/lfctech/snipeit-python-api@main +Pydantic models use `extra="allow"` so the client stays resilient to new +fields added by future Snipe-IT versions. The downside is that a typo in an +attribute name creates a new extra field instead of raising an error: + +```python +asset.serail = "SN-001" # typo — creates an extra field named "serail" +asset.save() # PATCHes {"serail": "SN-001"} — server ignores it ``` -### Development Setup +The real `serial` field is never updated. To catch this class of bug, enable +strict type-checking in your editor (pyright or mypy) and rely on the declared +fields (`asset_tag`, `name`, `serial`, `model`) which are type-checked. For +fields not declared on the model, there is no static protection. -A Docker environment is provided for local development and testing. This will spin up a Snipe-IT instance with all necessary dependencies. +### Setting custom field values -1. **Start the Docker containers (recommended via Make):** +Use the dedicated helpers on `Asset` to read and write custom fields by their +display label: - ```bash - make docker-up - ``` +```python +asset = api.assets.get(1) -2. **API Key:** +owner = asset.get_custom_field("Owner") # read server value +asset.set_custom_field("Owner", "alice") # stage +asset.pending_custom_fields() # {"Owner": "alice"} +asset.save() # persist +asset.get_custom_field("Owner") # "alice" +``` - The first time you start the containers, an API key is generated by the seeder and saved to `docker/api_key.txt`. Integration tests will automatically read this key. +`set_custom_field()` raises `KeyError` if the label is not defined on the +asset (most often because the asset hasn't been fetched yet, or the field is +not associated with the asset's model fieldset). The methods chain, so +`asset.set_custom_field("Owner", "alice").save()` works too. You can also +combine custom and regular field updates in one save: -## Usage +```python +asset.name = "Renamed" +asset.set_custom_field("Owner", "alice") +asset.save() # PATCH {"name": "Renamed", "_snipeit_owner_3": "alice"} +``` -Here is a basic example of how to use the client to fetch assets: +You can stage and save repeatedly on the same in-memory instance — no +`refresh()` needed in between: ```python -from snipeit import SnipeIT +asset = api.assets.get(1) +asset.set_custom_field("Owner", "alice").save() +asset.set_custom_field("Owner", "bob").save() # works without refresh() +``` -# Initialize the client with your Snipe-IT URL and API token -with SnipeIT(url="http://localhost:8000", token="your-api-token") as client: - # List all assets - try: - assets = client.assets.list() - for asset in assets: - print(f"Asset Name: {asset.name}, Tag: {asset.asset_tag}") - except Exception as e: - print(f"An error occurred: {e}") - # ... +Setting a custom field back to its current server value cancels any pending +stage for that label (no PATCH is issued): + +```python +asset.set_custom_field("Owner", "alice") # stages "alice" +asset.set_custom_field("Owner", asset.get_custom_field("Owner")) # cancels +asset.pending_custom_fields() # {} ``` -## Testing +**Read semantics:** `get_custom_field(label)` always returns the server's +last-known value. Staged-but-unsaved changes are visible only via +`pending_custom_fields()`. Call `save()` to persist them. -The project uses `pytest` for testing and provides `make` commands for convenience. Tests are separated into `unit` and `integration` tests. +#### Why the helpers exist -### Running Unit Tests +Snipe-IT's REST API uses two different shapes for custom fields: -Unit tests are self-contained and do not require a running Snipe-IT instance. +* **Read shape** — GET responses return them under `custom_fields` keyed by + display label: `custom_fields["Owner"] == {"field": "_snipeit_owner_3", "value": "alice", ...}`. +* **Write shape** — PATCH expects the underlying column name as a **top-level + key**: `{"_snipeit_owner_3": "alice"}`. The nested `custom_fields` shape is + silently ignored on the versions tested in CI. +* **PATCH response quirk** — Snipe-IT returns `custom_fields: null` on PATCH + responses and echoes column-name keys at the top level instead. The helpers + fold those back into the local nested shape, so subsequent + `set_custom_field()` calls on the same in-memory asset work correctly. -```bash -# Unit tests only (default) -make test +`set_custom_field()` handles the label → column-name translation for you. +If you only have the column name and not the label, you can still use the +manual pattern, but mind the leading underscore — pydantic v2's attribute +heuristic treats `_*` names as private: -# Alias -make test-unit +```python +column_name = asset.custom_fields["Owner"]["field"] # "_snipeit_owner_3" +setattr(asset, column_name, "alice") +asset.mark_dirty(column_name) +asset.save() ``` -### Running Integration Tests +Mutating the nested response shape directly +(`asset.custom_fields["Owner"]["value"] = "alice"`) is silently ignored by +Snipe-IT's PATCH endpoint. Stick with `set_custom_field()`. -Integration tests run against a real Snipe-IT instance against a local docker instance. +### In-place mutation of nested objects -```bash -make test-integration +Mutating a nested dict or list in-place is detected automatically via +snapshot-and-diff tracking: + +```python +asset.tags.append("retired") +asset.save() # correctly PATCHes tags ``` -### Running All Tests +If you need to force a field into the PATCH payload regardless of whether it +changed (e.g. to trigger server-side recomputation), use `mark_dirty()`: -To run all tests (both unit and integration), use: +```python +asset.mark_dirty("custom_fields") +asset.save() +``` ```bash -make test-all +# Using uv +uv add git+https://github.com/lfctech/snipeit-python-api@main + +# Or using pip +pip install git+https://github.com/lfctech/snipeit-python-api@main +``` + +## Quick Start + +```python +import logging +from snipeit import SnipeIT, SnipeITNotFoundError + +# Optional: enable HTTP-level debug logging +logging.basicConfig(level=logging.DEBUG) + +with SnipeIT(url="https://snipe.example.com", token="your-api-token") as api: + # List assets + for asset in api.assets.list_all(page_size=100): + print(asset) + + # Get by tag + try: + asset = api.assets.get_by_tag("LAPTOP-001") + except SnipeITNotFoundError: + print("Not found") + + # Modify and save + asset.name = "Updated Name" + asset.save() + + # Checkout + asset.checkout(checkout_to_type="user", assigned_to_id=42) + + # Download a file with progress + api.assets.download_file( + asset_id=1, + file_id=2, + save_path="/tmp/attachment.pdf", + progress=lambda n, t: print(f"{n}/{t or '?'} bytes"), + ) ``` -### Linting and Type Checking +## Development Setup -Use the combined check target to run Ruff and Pyright: +```bash +make docker-up # Start local Snipe-IT in Docker +make test # Unit tests +make check # Lint + type-check +make test-all # Unit + integration tests +``` + +## Testing ```bash -make check +make test # Unit tests only (default) +make test-unit # Alias +make test-integration # Requires Docker +make test-all # Both +make check # ruff + pyright +make cov # Coverage (≥85% enforced) ``` diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..bc78559 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,31 @@ +# Releasing + +This project uses [Semantic Versioning](https://semver.org/). + +While pre-1.0: +- **MINOR** bumps for breaking changes or significant new features +- **PATCH** bumps for bug fixes and non-breaking additions + +## Release checklist + +1. Update `version` in `pyproject.toml` +2. Move `## Unreleased` entries in `CHANGELOG.md` under a new header: + ``` + ## X.Y.Z (YYYY-MM-DD) + ``` +3. Commit: `git commit -am "release: vX.Y.Z"` +4. Tag: `git tag vX.Y.Z` +5. Push: `git push origin main --tags` + +## Commit message convention + +Use [Conventional Commits](https://www.conventionalcommits.org/) prefixes: + +- `feat:` — new feature (bumps MINOR) +- `fix:` — bug fix (bumps PATCH) +- `docs:` — documentation only +- `test:` — test additions/changes +- `ci:` — CI/workflow changes +- `chore:` — maintenance (deps, config, cleanup) +- `refactor:` — code change that neither fixes a bug nor adds a feature +- `release:` — version bump commit diff --git a/docker/.env b/docker/.env index ff40901..9e866c1 100644 --- a/docker/.env +++ b/docker/.env @@ -1,3 +1,12 @@ +# ---------------------------------------------------------------------- +# LOCAL DEVELOPMENT ONLY. This file bootstraps a throwaway Snipe-IT +# instance for integration tests. Values here are not secrets: +# - APP_KEY is a local Laravel key, regenerated per dev environment. +# - DB_PASSWORD is "snipeitlocal", hard-coded for the test container. +# - All AWS / S3 / Redis values are null (no external services). +# Do not copy this file to a production deployment. +# ---------------------------------------------------------------------- + # -------------------------------------------- # REQUIRED: DOCKER SPECIFIC SETTINGS # -------------------------------------------- @@ -91,7 +100,7 @@ BACKUP_ENV=true #PHP_UPLOAD_LIMIT=10 #PHP_POST_MAX_SIZE=10 #PHP_UPLOAD_MAX_FILESIZE=10 -#PHP_MEMORY_LIMIT=10 +PHP_MEMORY_LIMIT=512M # -------------------------------------------- @@ -182,4 +191,4 @@ APP_FORCE_TLS=false GOOGLE_MAPS_API= LDAP_MEM_LIM=500M LDAP_TIME_LIM=600 -API_THROTTLE_PER_MINUTE=240 +API_THROTTLE_PER_MINUTE=1200 diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..2d416ae --- /dev/null +++ b/docker/README.md @@ -0,0 +1,21 @@ +# Docker dev stack + +This directory contains a throwaway Snipe-IT instance used for integration tests. + +## Quick start + +```bash +make docker-up # Start Snipe-IT + MySQL + seeder +make test-all # Run unit + integration tests +make docker-down # Stop and delete volumes +``` + +## How it works + +1. `docker-compose.yml` starts three services: `db` (MySQL), `app` (Snipe-IT), and `seeder` (a one-shot container that creates an admin user and writes the API key to `api_key.txt`). +2. `make test-integration` waits up to 120 s for `api_key.txt` to be non-empty, then runs `pytest -m integration` with `SNIPEIT_TEST_URL` and `SNIPEIT_TEST_TOKEN` set from that file. +3. `api_key.txt` is gitignored — it is generated at runtime and must not be committed. + +## `.env` + +The `.env` file is committed intentionally. It contains only local dev bootstrap values (no real secrets). See the comment block at the top of the file. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 49d0a8c..054ebec 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -25,7 +25,7 @@ services: condition: service_healthy env_file: - .env - command: ["sh", "-c", "if [ ! -f /var/lib/snipeit/seeded.txt ]; then php artisan migrate --force && php artisan db:seed --force && touch /var/lib/snipeit/seeded.txt && php artisan passport:install -n && php artisan snipeit:make-api-key --user_id 1 --name testing -n --key-only > /api_key.txt; fi"] + command: ["sh", "-c", "if [ ! -f /var/lib/snipeit/seeded.txt ]; then php -d memory_limit=512M artisan migrate --force && php -d memory_limit=512M artisan db:seed --force && touch /var/lib/snipeit/seeded.txt && php -d memory_limit=512M artisan passport:install -n && php -d memory_limit=512M artisan snipeit:make-api-key --user_id 1 --name testing -n --key-only > /api_key.txt && php -d memory_limit=512M artisan tinker --execute='\\App\\Models\\Setting::where(\"id\", 1)->update([\"label2_enable\" => 1]);'; fi"] restart: "no" volumes: - storage:/var/lib/snipeit diff --git a/docs/audit.json b/docs/audit.json deleted file mode 100644 index eaab5e2..0000000 --- a/docs/audit.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "snipe-it-rest-api", - "version": "8.2.0" - }, - "servers": [ - { - "url": "https://develop.snipeitapp.com/api/v1" - } - ], - "security": [ - {} - ], - "components": { - "securitySchemes": {} - }, - "paths": { - "/audit/{id}": { - "post": { - "description": "", - "operationId": "post_audit{id}", - "responses": { - "200": { - "description": "", - "content": {} - } - }, - "parameters": [ - { - "in": "query", - "name": "location_id", - "schema": { - "type": "integer", - "default": "" - } - }, - { - "in": "query", - "name": "note", - "schema": { - "type": "string", - "default": "" - } - }, - { - "in": "query", - "name": "update_location", - "schema": { - "type": "boolean" - }, - "description": "Optionally update the assets location through the audit." - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "integer" - }, - "required": true - } - ] - } - } - } -} \ No newline at end of file diff --git a/docs/groups.json b/docs/groups.json deleted file mode 100644 index 247882a..0000000 --- a/docs/groups.json +++ /dev/null @@ -1,384 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "snipe-it-rest-api", - "version": "8.2.0" - }, - "servers": [ - { - "url": "https://develop.snipeitapp.com/api/v1" - } - ], - "security": [ - {} - ], - "components": { - "securitySchemes": {} - }, - "paths": { - "/groups": { - "get": { - "summary": "/groups", - "description": "", - "operationId": "groups-1", - "parameters": [ - { - "name": "name", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - }, - "post": { - "summary": "/groups", - "description": "Create a group", - "operationId": "groupsid-1", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "permissions": { - "type": "string", - "description": "The string value should be a JSON document of permissions, but expressed as a string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - } - }, - "/groups/{id}": { - "get": { - "summary": "/groups/:id", - "description": "Return a group", - "operationId": "groupsid", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Group ID", - "schema": { - "type": "integer", - "format": "int32" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - }, - "put": { - "summary": "/groups/:id", - "description": "Edit a group", - "operationId": "groupsid-2", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Group ID", - "schema": { - "type": "integer", - "format": "int32" - }, - "required": true - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "permissions": { - "type": "string", - "format": "json" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - }, - "patch": { - "summary": "/groups/:id", - "description": "Partially edit a group", - "operationId": "groupsid-4", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Group ID", - "schema": { - "type": "integer", - "format": "int32" - }, - "required": true - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "permissions": { - "type": "string", - "format": "json" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - }, - "delete": { - "summary": "/groups/:id", - "description": "Delete a group", - "operationId": "groupsid-3", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Group ID", - "schema": { - "type": "integer", - "format": "int32" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - } - } - } -} \ No newline at end of file diff --git a/docs/maintenances.json b/docs/maintenances.json deleted file mode 100644 index 0fd44f4..0000000 --- a/docs/maintenances.json +++ /dev/null @@ -1,801 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "snipe-it-rest-api", - "version": "8.2.0" - }, - "servers": [ - { - "url": "https://develop.snipeitapp.com/api/v1" - } - ], - "security": [ - {} - ], - "components": { - "securitySchemes": {} - }, - "paths": { - "/maintenances": { - "get": { - "summary": "/maintenances", - "description": "List asset maintenances", - "operationId": "maintenances", - "parameters": [ - { - "name": "limit", - "in": "query", - "description": "Number of results to return", - "schema": { - "type": "integer", - "format": "int32", - "default": 50 - } - }, - { - "name": "offset", - "in": "query", - "description": "Offset to use when retrieving results (useful in pagination)", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "search", - "in": "query", - "description": "Search string", - "schema": { - "type": "string" - } - }, - { - "name": "sort", - "in": "query", - "description": "Field to order by", - "schema": { - "type": "string", - "default": "created_at" - } - }, - { - "name": "order", - "in": "query", - "description": "Sort order (asc or desc)", - "schema": { - "type": "string" - } - }, - { - "name": "asset_id", - "in": "query", - "description": "Asset ID of the asset you'd like to return maintenances for", - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{\n \"total\": 2,\n \"rows\": [\n {\n \"id\": 2,\n \"asset\": {\n \"id\": 1,\n \"name\": \"Test Name\",\n \"asset_tag\": \"02948\"\n },\n \"title\": \"Test with all fields\",\n \"location\": {\n \"id\": 3,\n \"name\": \"East Pollyville\"\n },\n \"notes\": \"This is a test\",\n \"supplier\": {\n \"id\": 3,\n \"name\": \"Effertz, Langworth and Prohaska\"\n },\n \"cost\": \"100.00\",\n \"asset_maintenance_type\": \"Repair\",\n \"start_date\": {\n \"datetime\": \"2018-03-06 00:00:00\",\n \"formatted\": \"Tue Mar 06, 2018 12:00AM\"\n },\n \"asset_maintenance_time\": 20,\n \"completion_date\": {\n \"datetime\": \"2018-03-26 00:00:00\",\n \"formatted\": \"Mon Mar 26, 2018 12:00AM\"\n },\n \"user_id\": {\n \"id\": 2,\n \"name\": \"Snipe E. Head\"\n },\n \"created_at\": {\n \"datetime\": \"2018-03-26 17:43:35\",\n \"formatted\": \"Mon Mar 26, 2018 5:43PM\"\n },\n \"updated_at\": {\n \"datetime\": \"2018-03-26 17:43:35\",\n \"formatted\": \"Mon Mar 26, 2018 5:43PM\"\n },\n \"available_actions\": {\n \"update\": true,\n \"delete\": true\n }\n },\n {\n \"id\": 1,\n \"asset\": {\n \"id\": 1,\n \"name\": \"Test Name\",\n \"asset_tag\": \"02948\"\n },\n \"title\": \"adfasasd\",\n \"location\": {\n \"id\": 3,\n \"name\": \"East Pollyville\"\n },\n \"notes\": null,\n \"supplier\": {\n \"id\": 3,\n \"name\": \"Effertz, Langworth and Prohaska\"\n },\n \"cost\": null,\n \"asset_maintenance_type\": \"Maintenance\",\n \"start_date\": {\n \"datetime\": \"2018-03-01 00:00:00\",\n \"formatted\": \"Thu Mar 01, 2018 12:00AM\"\n },\n \"asset_maintenance_time\": null,\n \"completion_date\": null,\n \"user_id\": {\n \"id\": 2,\n \"name\": \"Snipe E. Head\"\n },\n \"created_at\": {\n \"datetime\": \"2018-03-26 15:28:19\",\n \"formatted\": \"Mon Mar 26, 2018 3:28PM\"\n },\n \"updated_at\": {\n \"datetime\": \"2018-03-26 15:28:19\",\n \"formatted\": \"Mon Mar 26, 2018 3:28PM\"\n },\n \"available_actions\": {\n \"update\": true,\n \"delete\": true\n }\n }\n ]\n}" - } - }, - "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer", - "example": 2, - "default": 0 - }, - "rows": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 2, - "default": 0 - }, - "asset": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1, - "default": 0 - }, - "name": { - "type": "string", - "example": "Test Name" - }, - "asset_tag": { - "type": "string", - "example": "02948" - } - } - }, - "title": { - "type": "string", - "example": "Test with all fields" - }, - "location": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 3, - "default": 0 - }, - "name": { - "type": "string", - "example": "East Pollyville" - } - } - }, - "notes": { - "type": "string", - "example": "This is a test" - }, - "supplier": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 3, - "default": 0 - }, - "name": { - "type": "string", - "example": "Effertz, Langworth and Prohaska" - } - } - }, - "cost": { - "type": "string", - "example": "100.00" - }, - "asset_maintenance_type": { - "type": "string", - "example": "Repair" - }, - "start_date": { - "type": "object", - "properties": { - "datetime": { - "type": "string", - "example": "2018-03-06 00:00:00" - }, - "formatted": { - "type": "string", - "example": "Tue Mar 06, 2018 12:00AM" - } - } - }, - "asset_maintenance_time": { - "type": "integer", - "example": 20, - "default": 0 - }, - "completion_date": { - "type": "object", - "properties": { - "datetime": { - "type": "string", - "example": "2018-03-26 00:00:00" - }, - "formatted": { - "type": "string", - "example": "Mon Mar 26, 2018 12:00AM" - } - } - }, - "user_id": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 2, - "default": 0 - }, - "name": { - "type": "string", - "example": "Snipe E. Head" - } - } - }, - "created_at": { - "type": "object", - "properties": { - "datetime": { - "type": "string", - "example": "2018-03-26 17:43:35" - }, - "formatted": { - "type": "string", - "example": "Mon Mar 26, 2018 5:43PM" - } - } - }, - "updated_at": { - "type": "object", - "properties": { - "datetime": { - "type": "string", - "example": "2018-03-26 17:43:35" - }, - "formatted": { - "type": "string", - "example": "Mon Mar 26, 2018 5:43PM" - } - } - }, - "available_actions": { - "type": "object", - "properties": { - "update": { - "type": "boolean", - "example": true, - "default": true - }, - "delete": { - "type": "boolean", - "example": true, - "default": true - } - } - } - } - } - } - } - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - }, - "post": { - "summary": "/maintenances", - "description": "Create a new maintenance", - "operationId": "maintenances-1", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name", - "asset_id", - "supplier_id", - "asset_maintenance_type", - "start_date" - ], - "properties": { - "name": { - "type": "string" - }, - "asset_id": { - "type": "integer", - "format": "int32" - }, - "supplier_id": { - "type": "integer", - "format": "int32" - }, - "is_warranty": { - "type": "boolean" - }, - "cost": { - "type": "number", - "format": "float" - }, - "notes": { - "type": "string" - }, - "asset_maintenance_type": { - "type": "string", - "enum": [ - "Maintenance", - "Repair", - "PAT Test", - "Upgrade", - "Hardware Support", - "Software Support" - ] - }, - "start_date": { - "type": "string", - "format": "date" - }, - "completion_date": { - "type": "string", - "format": "date" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - } - }, - "/maintenances/:id": { - "put": { - "summary": "/maintenances/:id", - "description": "Update selected fields in an existing maintenance", - "operationId": "maintenances-copy", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name", - "asset_id", - "supplier_id", - "asset_maintenance_type", - "start_date" - ], - "properties": { - "name": { - "type": "string" - }, - "asset_id": { - "type": "integer", - "format": "int32" - }, - "supplier_id": { - "type": "integer", - "format": "int32" - }, - "is_warranty": { - "type": "boolean" - }, - "cost": { - "type": "number", - "format": "float" - }, - "notes": { - "type": "string" - }, - "asset_maintenance_type": { - "type": "string", - "enum": [ - "Maintenance", - "Repair", - "PAT Test", - "Upgrade", - "Hardware Support", - "Software Support" - ] - }, - "start_date": { - "type": "string", - "format": "date" - }, - "completion_date": { - "type": "string", - "format": "date" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - }, - "patch": { - "summary": "/maintenances/:id", - "description": "Update selected fields in an existing maintenance", - "operationId": "maintenances-copy-1", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "asset_maintenance_type" - ], - "properties": { - "name": { - "type": "string" - }, - "asset_id": { - "type": "integer", - "format": "int32" - }, - "supplier_id": { - "type": "integer", - "format": "int32" - }, - "is_warranty": { - "type": "boolean" - }, - "cost": { - "type": "number", - "format": "float" - }, - "notes": { - "type": "string" - }, - "asset_maintenance_type": { - "type": "string", - "enum": [ - "Maintenance", - "Repair", - "PAT Test", - "Upgrade", - "Hardware Support", - "Software Support" - ] - }, - "start_date": { - "type": "string", - "format": "date" - }, - "completion_date": { - "type": "string", - "format": "date" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - } - }, - "/maintenances/{id}": { - "delete": { - "summary": "/maintenances/:id", - "description": "Delete a maintenance", - "operationId": "maintenancesid", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Maintenance ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{\n \"status\": \"success\",\n \"messages\": \"The asset maintenance was deleted successfully.\",\n \"payload\": {\n \"id\": 1,\n \"asset_id\": 1,\n \"supplier_id\": 1,\n \"asset_maintenance_type\": \"Maintenance\",\n \"title\": \"deleteo\",\n \"is_warranty\": 0,\n \"start_date\": \"2022-11-16T08:00:00.000000Z\",\n \"completion_date\": null,\n \"asset_maintenance_time\": null,\n \"notes\": null,\n \"cost\": null,\n \"deleted_at\": \"2022-11-15T17:25:48.000000Z\",\n \"created_at\": \"2022-11-15T17:25:17.000000Z\",\n \"updated_at\": \"2022-11-15T17:25:48.000000Z\",\n \"user_id\": 1,\n \"asset\": {\n \"id\": 1,\n \"name\": null,\n \"asset_tag\": \"893278223\",\n \"model_id\": 1,\n \"serial\": \"c65ac2b6-bad7-34ef-86d0-b2f9b4abb83f\",\n \"purchase_date\": \"2022-01-19T08:00:00.000000Z\",\n \"purchase_cost\": \"551.40\",\n \"order_number\": \"19494754\",\n \"assigned_to\": null,\n \"notes\": \"Created by DB seeder\",\n \"image\": null,\n \"user_id\": 1,\n \"created_at\": \"2022-11-15T16:42:06.000000Z\",\n \"updated_at\": \"2022-11-15T16:42:37.000000Z\",\n \"physical\": 1,\n \"deleted_at\": null,\n \"status_id\": 1,\n \"archived\": 0,\n \"warranty_months\": null,\n \"depreciate\": null,\n \"supplier_id\": 3,\n \"requestable\": 1,\n \"rtd_location_id\": 4,\n \"accepted\": null,\n \"last_checkout\": null,\n \"expected_checkin\": null,\n \"company_id\": null,\n \"assigned_type\": null,\n \"last_audit_date\": null,\n \"next_audit_date\": null,\n \"location_id\": 4,\n \"checkin_counter\": 0,\n \"checkout_counter\": 0,\n \"requests_counter\": 0,\n \"_snipeit_imei_1\": null,\n \"_snipeit_phone_number_2\": null,\n \"_snipeit_ram_3\": null,\n \"_snipeit_cpu_4\": null,\n \"_snipeit_mac_address_5\": null\n }\n }\n}" - } - }, - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "success" - }, - "messages": { - "type": "string", - "example": "The asset maintenance was deleted successfully." - }, - "payload": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1, - "default": 0 - }, - "asset_id": { - "type": "integer", - "example": 1, - "default": 0 - }, - "supplier_id": { - "type": "integer", - "example": 1, - "default": 0 - }, - "asset_maintenance_type": { - "type": "string", - "example": "Maintenance" - }, - "title": { - "type": "string", - "example": "deleteo" - }, - "is_warranty": { - "type": "integer", - "example": 0, - "default": 0 - }, - "start_date": { - "type": "string", - "example": "2022-11-16T08:00:00.000000Z" - }, - "completion_date": {}, - "asset_maintenance_time": {}, - "notes": {}, - "cost": {}, - "deleted_at": { - "type": "string", - "example": "2022-11-15T17:25:48.000000Z" - }, - "created_at": { - "type": "string", - "example": "2022-11-15T17:25:17.000000Z" - }, - "updated_at": { - "type": "string", - "example": "2022-11-15T17:25:48.000000Z" - }, - "user_id": { - "type": "integer", - "example": 1, - "default": 0 - }, - "asset": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1, - "default": 0 - }, - "name": {}, - "asset_tag": { - "type": "string", - "example": "893278223" - }, - "model_id": { - "type": "integer", - "example": 1, - "default": 0 - }, - "serial": { - "type": "string", - "example": "c65ac2b6-bad7-34ef-86d0-b2f9b4abb83f" - }, - "purchase_date": { - "type": "string", - "example": "2022-01-19T08:00:00.000000Z" - }, - "purchase_cost": { - "type": "string", - "example": "551.40" - }, - "order_number": { - "type": "string", - "example": "19494754" - }, - "assigned_to": {}, - "notes": { - "type": "string", - "example": "Created by DB seeder" - }, - "image": {}, - "user_id": { - "type": "integer", - "example": 1, - "default": 0 - }, - "created_at": { - "type": "string", - "example": "2022-11-15T16:42:06.000000Z" - }, - "updated_at": { - "type": "string", - "example": "2022-11-15T16:42:37.000000Z" - }, - "physical": { - "type": "integer", - "example": 1, - "default": 0 - }, - "deleted_at": {}, - "status_id": { - "type": "integer", - "example": 1, - "default": 0 - }, - "archived": { - "type": "integer", - "example": 0, - "default": 0 - }, - "warranty_months": {}, - "depreciate": {}, - "supplier_id": { - "type": "integer", - "example": 3, - "default": 0 - }, - "requestable": { - "type": "integer", - "example": 1, - "default": 0 - }, - "rtd_location_id": { - "type": "integer", - "example": 4, - "default": 0 - }, - "accepted": {}, - "last_checkout": {}, - "expected_checkin": {}, - "company_id": {}, - "assigned_type": {}, - "last_audit_date": {}, - "next_audit_date": {}, - "location_id": { - "type": "integer", - "example": 4, - "default": 0 - }, - "checkin_counter": { - "type": "integer", - "example": 0, - "default": 0 - }, - "checkout_counter": { - "type": "integer", - "example": 0, - "default": 0 - }, - "requests_counter": { - "type": "integer", - "example": 0, - "default": 0 - }, - "_snipeit_imei_1": {}, - "_snipeit_phone_number_2": {}, - "_snipeit_ram_3": {}, - "_snipeit_cpu_4": {}, - "_snipeit_mac_address_5": {} - } - } - } - } - } - } - } - } - } - }, - "deprecated": false - } - } - } -} \ No newline at end of file diff --git a/docs/reports.json b/docs/reports.json deleted file mode 100644 index 3c3f73c..0000000 --- a/docs/reports.json +++ /dev/null @@ -1,185 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "snipe-it-rest-api", - "version": "8.2.0" - }, - "servers": [ - { - "url": "https://develop.snipeitapp.com/api/v1" - } - ], - "security": [ - {} - ], - "components": { - "securitySchemes": {} - }, - "paths": { - "/reports/activity": { - "get": { - "summary": "/reports/activity", - "description": "", - "operationId": "reportsactivity", - "parameters": [ - { - "name": "limit", - "in": "query", - "description": "Specify the number of results you wish to return. Defaults to 50, but we have it set to 2 by default so the API explorer doesn't scroll forever.", - "schema": { - "type": "integer", - "format": "int32", - "default": 2 - } - }, - { - "name": "offset", - "in": "query", - "description": "The offset from the start of results to use in order to page through the result set", - "schema": { - "type": "integer", - "format": "int32", - "default": 0 - } - }, - { - "name": "search", - "in": "query", - "description": "String to search on", - "schema": { - "type": "string" - } - }, - { - "name": "target_type", - "in": "query", - "description": "The type of target (entity something is checked out to) you're searching against. `App\\Models\\User`, etc. Required when passing target_id.", - "schema": { - "type": "string" - } - }, - { - "name": "target_id", - "in": "query", - "description": "The ID of the target you're querying against. Required if passing target_type", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "item_type", - "in": "query", - "description": "The type of item you're searching against. `App\\Models\\Asset`, etc. Required when passing item_id.", - "schema": { - "type": "string", - "enum": [ - "asset", - "accessory", - "consumable", - "component", - "license", - "user" - ] - } - }, - { - "name": "item_id", - "in": "query", - "description": "The ID of the item you're querying against. Required if passing item_type", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "action_type", - "in": "query", - "description": "The action type you'e querying against. Example values here are: \"add seats\", \"checkin from\", \"checkout\", \"update\"", - "schema": { - "type": "string", - "enum": [ - "checkout", - "checkin from", - "update", - "create", - "delete", - "audit", - "uploaded", - "accepted", - "declined", - "requested" - ] - } - }, - { - "name": "order", - "in": "query", - "description": "Ascending or descending order (defaults to desc if no value is given)", - "schema": { - "type": "string", - "enum": [ - "asc", - "desc" - ], - "default": "desc" - } - }, - { - "name": "sort", - "in": "query", - "description": "What column the results should be sorted by (defaults to created_at date if no value is given)", - "schema": { - "type": "string", - "enum": [ - "id", - "created_at", - "target_id", - "user_id", - "accept_signature", - "action_type", - "note (defaults to desc if not value is given)" - ], - "default": "created_at" - } - } - ], - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - } - } - } -} \ No newline at end of file diff --git a/docs/settings.json b/docs/settings.json deleted file mode 100644 index fa00cc3..0000000 --- a/docs/settings.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "snipe-it-rest-api", - "version": "8.2.0" - }, - "servers": [ - { - "url": "https://develop.snipeitapp.com/api/v1" - } - ], - "security": [ - {} - ], - "components": { - "securitySchemes": {} - }, - "paths": { - "/settings/backups": { - "get": { - "summary": "/settings/backups", - "description": "", - "operationId": "backups-1", - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - } - }, - "/settings/backups/download/{file}": { - "get": { - "summary": "/settings/backups/download/:file", - "description": "", - "operationId": "backupsdownloadfile", - "parameters": [ - { - "name": "file", - "in": "path", - "description": "The short name of the file to download", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "200", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "400": { - "description": "400", - "content": { - "application/json": { - "examples": { - "Result": { - "value": "{}" - } - }, - "schema": { - "type": "object", - "properties": {} - } - } - } - } - }, - "deprecated": false - } - } - } -} \ No newline at end of file diff --git a/docs/split_api.py b/docs/split_api.py deleted file mode 100644 index 79702e0..0000000 --- a/docs/split_api.py +++ /dev/null @@ -1,39 +0,0 @@ -import json - -# Load the original API spec -with open("snipe-it-rest-api.json", "r") as f: - data = json.load(f) - -# Extract paths -paths = data.get("paths", {}) - -# Group paths by category (first part after /) -groups = {} -for path, methods in paths.items(): - parts = path.strip("/").split("/") - if parts: - category = parts[0] - if category not in groups: - groups[category] = {} - groups[category][path] = methods - -# For each group, create a new spec file -for category, category_paths in groups.items(): - # Create a new spec dict - new_spec = { - "openapi": data.get("openapi"), - "info": data.get("info"), - "servers": data.get("servers"), - "security": data.get("security"), - "components": data.get("components"), - "paths": category_paths, - } - - # Write to file - filename = f"{category}.json" - with open(filename, "w") as f: - json.dump(new_spec, f, indent=2) - - print(f"Created {filename}") - -print("Splitting complete.") diff --git a/pyproject.toml b/pyproject.toml index ba4e3ac..1bcb2cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,42 +4,55 @@ build-backend = "setuptools.build_meta" [project] name = "snipeit-api" -version = "0.1.0" +version = "0.4.0" description = "A Python client for the Snipe-IT API" readme = "README.md" requires-python = ">=3.11" +authors = [{name = "Wil Collier"}] +license = "Apache-2.0" +license-files = ["LICENSE"] +keywords = ["snipe-it", "snipeit", "asset-management", "api-client", "itam"] classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Topic :: System :: Systems Administration", + "Typing :: Typed", ] dependencies = [ - "requests", + "httpx>=0.27", + "pydantic>=2.0,<3", ] -[project.optional-dependencies] -dev = [ - "requests-mock", - "pytest", - "pytest-cov", - "coverage", - "hypothesis", - "mutmut<3", - "ruff", - "pyright", -] +[project.urls] +Homepage = "https://github.com/lfctech/snipeit-python-api" +Repository = "https://github.com/lfctech/snipeit-python-api" +Issues = "https://github.com/lfctech/snipeit-python-api/issues" +Changelog = "https://github.com/lfctech/snipeit-python-api/blob/main/CHANGELOG.md" [tool.setuptools] -packages = ["snipeit", "snipeit.resources"] +packages = ["snipeit", "snipeit.resources", "snipeit.resources.assets"] + +[tool.setuptools.package-data] +snipeit = ["py.typed"] [dependency-groups] dev = [ - "requests-mock", "pytest", "pytest-cov", + "pytest-httpx>=0.30", "coverage", "hypothesis", "mutmut<3", "ruff", "pyright", - "ty>=0.0.1a21", ] + +[tool.mutmut] +paths_to_mutate = "snipeit" +tests_dir = "tests" +runner = "python -m pytest -q" \ No newline at end of file diff --git a/pytest.ini b/pytest.ini index 033467b..345d941 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,3 +4,7 @@ testpaths = tests markers = unit: Mark a test as a unit test (mocked). integration: Mark a test as an integration test (real API calls). +filterwarnings = + error + # Allow known third-party deprecation noise — add entries here as needed + # when upgrading pydantic, httpx, or hypothesis. diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index c7aa572..0000000 --- a/setup.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[mutmut] -paths_to_mutate = snipeit -tests_dir = tests -runner = python -m pytest -q - diff --git a/snipeit/__init__.py b/snipeit/__init__.py index 6a9c649..db267ef 100644 --- a/snipeit/__init__.py +++ b/snipeit/__init__.py @@ -1,14 +1,41 @@ """snipeit package. -Provides the primary SnipeIT client for interacting with the Snipe-IT API. +Provides the primary :class:`SnipeIT` client for interacting with the Snipe-IT +API, and the typed exception hierarchy raised by the client. Examples: - Basic usage: - from snipeit import SnipeIT + Basic usage:: + + from snipeit import SnipeIT, SnipeITNotFoundError + with SnipeIT(url="https://example.test", token="{{SNIPEIT_API_TOKEN}}") as api: - asset = api.assets.get(1) + try: + asset = api.assets.get(1) + except SnipeITNotFoundError: + asset = None print(asset) """ from .client import SnipeIT -__all__ = ["SnipeIT"] +from .exceptions import ( + SnipeITApiError, + SnipeITAuthenticationError, + SnipeITClientError, + SnipeITException, + SnipeITNotFoundError, + SnipeITServerError, + SnipeITTimeoutError, + SnipeITValidationError, +) + +__all__ = [ + "SnipeIT", + "SnipeITApiError", + "SnipeITAuthenticationError", + "SnipeITClientError", + "SnipeITException", + "SnipeITNotFoundError", + "SnipeITServerError", + "SnipeITTimeoutError", + "SnipeITValidationError", +] diff --git a/snipeit/_log.py b/snipeit/_log.py new file mode 100644 index 0000000..206224f --- /dev/null +++ b/snipeit/_log.py @@ -0,0 +1,47 @@ +"""Internal logging helpers. + +The library exposes two loggers: + +* ``snipeit`` — top-level events and warnings. +* ``snipeit.http`` — per-request traces at DEBUG level (method, path, + status, elapsed ms). Bodies and headers are never logged. + +Enable HTTP tracing from caller code:: + + import logging + logging.getLogger("snipeit.http").setLevel(logging.DEBUG) + +Nothing in this module ever logs the API token or the ``Authorization`` +header. +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger: logging.Logger = logging.getLogger("snipeit") +http_logger: logging.Logger = logging.getLogger("snipeit.http") + + +# NullHandler prevents "no handlers could be found" warnings when the +# library is imported by applications that do not configure logging. +logger.addHandler(logging.NullHandler()) + + +def redact_headers(headers: Any) -> dict[str, str]: + """Return a copy of ``headers`` with sensitive values masked. + + Used only in tests and in ``repr`` paths. Production request/response + logging never emits header values at all. + """ + if not headers: + return {} + redacted: dict[str, str] = {} + for key, value in dict(headers).items(): + lowered = str(key).lower() + if lowered in {"authorization", "cookie", "set-cookie", "x-api-key"}: + redacted[str(key)] = "***" + else: + redacted[str(key)] = str(value) + return redacted diff --git a/snipeit/_retry.py b/snipeit/_retry.py new file mode 100644 index 0000000..4139e41 --- /dev/null +++ b/snipeit/_retry.py @@ -0,0 +1,155 @@ +"""HTTPX transport that retries on configured status codes and transient errors. + +``httpx``'s built-in ``HTTPTransport(retries=N)`` only retries on connection +errors. Snipe-IT (like most REST APIs) also returns transient server-side +errors (429, 500, 502, 503, 504) that we want to retry with exponential +backoff, honoring ``Retry-After`` when present. + +This transport wraps a base ``httpx.HTTPTransport`` and applies those +semantics to outgoing requests whose HTTP method is in ``allowed_methods``. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable, Iterable +from email.utils import parsedate_to_datetime +from datetime import datetime, timezone + +import httpx + +from ._log import logger + + +DEFAULT_STATUS_FORCELIST: frozenset[int] = frozenset({429, 500, 502, 503, 504}) +DEFAULT_ALLOWED_METHODS: frozenset[str] = frozenset({"HEAD", "GET", "OPTIONS"}) + + +class RetryTransport(httpx.BaseTransport): + """Retry status-forcelist responses with exponential backoff. + + Also retries on :class:`httpx.ConnectError` and :class:`httpx.ReadError` + (the ``httpx.HTTPTransport(retries=...)`` default behavior). + + Args: + wrapped: The transport to forward requests to. Defaults to a plain + ``httpx.HTTPTransport()``. + max_retries: Maximum retry attempts after the initial request. + ``max_retries=0`` disables retries. + backoff_factor: Exponential backoff multiplier. Sleep between + attempts is ``backoff_factor * (2 ** attempt)``. + status_forcelist: HTTP status codes that trigger a retry. + allowed_methods: HTTP methods (upper-case) that are considered safe + to retry. POST/PATCH/PUT are excluded by default. + respect_retry_after: When ``True`` (default), honor the + ``Retry-After`` response header on 429/503 by sleeping for the + indicated duration. Supports integer seconds and HTTP-date. + sleep: Override for :func:`time.sleep`, used by tests. + """ + + def __init__( + self, + wrapped: httpx.BaseTransport | None = None, + *, + max_retries: int = 3, + backoff_factor: float = 0.3, + status_forcelist: Iterable[int] = DEFAULT_STATUS_FORCELIST, + allowed_methods: Iterable[str] = DEFAULT_ALLOWED_METHODS, + respect_retry_after: bool = True, + sleep: Callable[[float], None] | None = None, + ) -> None: + self._wrapped = wrapped if wrapped is not None else httpx.HTTPTransport() + self.max_retries = int(max_retries) + self.backoff_factor = float(backoff_factor) + self.status_forcelist = frozenset(int(s) for s in status_forcelist) + self.allowed_methods = frozenset(m.upper() for m in allowed_methods) + self.respect_retry_after = bool(respect_retry_after) + self._sleep = sleep if sleep is not None else time.sleep + + # httpx.BaseTransport API + def handle_request(self, request: httpx.Request) -> httpx.Response: # noqa: D401 + method = request.method.upper() + retryable = method in self.allowed_methods + last_error: Exception | None = None + + for attempt in range(self.max_retries + 1): + try: + response = self._wrapped.handle_request(request) + except (httpx.ConnectError, httpx.ReadError) as exc: + last_error = exc + if not retryable or attempt >= self.max_retries: + raise + # Honor allowed_methods for transport errors too. A ReadError + # can happen after the server received a mutating request. + # Log *before* sleeping so long backoffs don't look like a hang. + logger.warning( + "Retrying %s %s after transport error (attempt %d/%d): %s", + method, + request.url, + attempt + 1, + self.max_retries, + exc, + ) + self._backoff(attempt, retry_after=None) + continue + + if ( + retryable + and attempt < self.max_retries + and response.status_code in self.status_forcelist + ): + retry_after = self._parse_retry_after( + response.headers.get("Retry-After") + ) if self.respect_retry_after else None + logger.warning( + "Retrying %s %s after HTTP %d (attempt %d/%d)", + method, + request.url, + response.status_code, + attempt + 1, + self.max_retries, + ) + # Release the prior response to free its connection. + response.close() + self._backoff(attempt, retry_after=retry_after) + continue + + return response + + # Unreachable: the loop always either returns or raises. + raise last_error if last_error is not None else RuntimeError( + "RetryTransport exited loop without a response" + ) + + def close(self) -> None: + self._wrapped.close() + + # Helpers --------------------------------------------------------------- + def _backoff(self, attempt: int, *, retry_after: float | None) -> None: + delay = retry_after if retry_after is not None else self.backoff_factor * ( + 2**attempt + ) + if delay > 0: + self._sleep(delay) + + @staticmethod + def _parse_retry_after(value: str | None) -> float | None: + if not value: + return None + value = value.strip() + # Integer seconds form. + try: + return max(0.0, float(value)) + except ValueError: + pass + # HTTP-date form. + try: + dt = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = (dt - datetime.now(timezone.utc)).total_seconds() + return max(0.0, delta) diff --git a/snipeit/client.py b/snipeit/client.py index fbacc2d..3ad29ee 100644 --- a/snipeit/client.py +++ b/snipeit/client.py @@ -1,77 +1,57 @@ -"""Snipe-IT API client. - -This module provides the SnipeIT class, a high-level HTTP client that wraps the -Snipe-IT REST API and exposes resource managers via dynamic attributes -(e.g., api.assets, api.users). - -Examples: - Create a client and list the first 10 assets: - - from snipeit import SnipeIT - - with SnipeIT( - url="https://snipe.example.test", - token="{{SNIPEIT_API_TOKEN}}", - ) as api: - assets = api.assets.list(limit=10) - for a in assets: - print(a) -""" - -from typing import Any, Dict, Set -import importlib -import requests -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry +"""Snipe-IT API client.""" + +from __future__ import annotations + +import contextlib +import time +from collections.abc import Generator +from typing import Any +from urllib.parse import urlsplit + +import httpx + +from ._log import http_logger, logger +from ._retry import DEFAULT_ALLOWED_METHODS, RetryTransport from .exceptions import ( SnipeITApiError, SnipeITAuthenticationError, SnipeITClientError, + SnipeITException, SnipeITNotFoundError, SnipeITServerError, SnipeITTimeoutError, SnipeITValidationError, - SnipeITException, ) +from .resources.accessories import AccessoriesManager +from .resources.assets import AssetsManager +from .resources.categories import CategoriesManager +from .resources.companies import CompaniesManager +from .resources.components import ComponentsManager +from .resources.consumables import ConsumablesManager +from .resources.departments import DepartmentsManager +from .resources.fields import FieldsManager +from .resources.fieldsets import FieldsetsManager +from .resources.licenses import LicensesManager +from .resources.locations import LocationsManager +from .resources.manufacturers import ManufacturersManager +from .resources.models import ModelsManager +from .resources.status_labels import StatusLabelsManager +from .resources.suppliers import SuppliersManager +from .resources.users import UsersManager class SnipeIT: """Client for interacting with the Snipe-IT API. - This client manages authentication, retries, timeouts, and provides - resource managers such as assets, users, and licenses via attributes that - are created on first access. - Examples: - Basic usage with a context manager: + Basic usage:: from snipeit import SnipeIT - with SnipeIT(url="https://snipe.example.test", token="{{SNIPEIT_API_TOKEN}}") as api: + with SnipeIT(url="https://snipe.example.test", token="{{TOKEN}}") as api: user = api.users.get(1) - print(user) """ - # Registry of manager attributes -> (module_path, class_name) - _manager_registry: Dict[str, tuple[str, str]] = { - "assets": (".resources.assets", "AssetsManager"), - "accessories": (".resources.accessories", "AccessoriesManager"), - "components": (".resources.components", "ComponentsManager"), - "consumables": (".resources.consumables", "ConsumablesManager"), - "licenses": (".resources.licenses", "LicensesManager"), - "users": (".resources.users", "UsersManager"), - "locations": (".resources.locations", "LocationsManager"), - "departments": (".resources.departments", "DepartmentsManager"), - "manufacturers": (".resources.manufacturers", "ManufacturersManager"), - "models": (".resources.models", "ModelsManager"), - "categories": (".resources.categories", "CategoriesManager"), - "status_labels": (".resources.status_labels", "StatusLabelsManager"), - "fields": (".resources.fields", "FieldsManager"), - "fieldsets": (".resources.fieldsets", "FieldsetsManager"), - "companies": (".resources.companies", "CompaniesManager"), - "suppliers": (".resources.suppliers", "SuppliersManager"), - } - def __init__( self, url: str, @@ -79,309 +59,291 @@ def __init__( timeout: int = 10, max_retries: int = 3, backoff_factor: float = 0.3, - retry_allowed_methods: Set[str] | None = None, + retry_allowed_methods: set[str] | None = None, ) -> None: """Initialize the Snipe-IT API client. Args: - url (str): Base URL of the Snipe-IT instance. Must start with - "https://" or "http://localhost". - token (str): API token for authentication. - timeout (int): Request timeout in seconds. Defaults to 10. - max_retries (int): Maximum number of retry attempts for transient - errors. Defaults to 3. - backoff_factor (float): Exponential backoff factor for retries. - Defaults to 0.3. - retry_allowed_methods (set[str] | None): HTTP methods that are safe - to retry. If None, a safe default of HEAD/GET/OPTIONS is used. + url: Base URL. Must be ``https://`` or ``http://localhost``. + token: API token for authentication. + timeout: Request timeout in seconds. Defaults to 10. + max_retries: Maximum retry attempts for transient errors. + backoff_factor: Exponential backoff factor for retries. + retry_allowed_methods: HTTP methods safe to retry. Defaults to + ``{"HEAD", "GET", "OPTIONS"}``. Raises: ValueError: If the URL or token values are invalid. - - Examples: - Create a client with custom retry settings: - - api = SnipeIT( - url="https://snipe.example.test", - token="{{SNIPEIT_API_TOKEN}}", - timeout=20, - max_retries=5, - backoff_factor=0.5, - retry_allowed_methods={"GET", "HEAD"}, - ) """ - # Normalize the base URL to avoid double slashes and support trailing slashes self.url = url.rstrip("/") - if not self.url.startswith("https://") and not self.url.startswith( - "http://localhost" - ): - raise ValueError("URL must start with https:// or http://localhost") + _parsed = urlsplit(self.url) + _scheme = _parsed.scheme + _host = _parsed.hostname or "" + _localhost = _host in {"localhost", "127.0.0.1", "::1"} + _valid = ( + not (_parsed.username or _parsed.password) + and _parsed.path in {"", "/"} + and (_scheme == "https" or (_scheme == "http" and _localhost)) + ) + if not _valid: + raise ValueError( + "URL must be https:// or http://localhost (no credentials, " + "no path). Got: " + url + ) if not token or not token.strip(): raise ValueError("token must be non-empty") - self.token = token - self.session = requests.Session() - # Best-effort to include package version in UA - try: - from importlib.metadata import version + self.timeout = timeout - _ver = version("snipeit-api") + try: + from importlib.metadata import version as _pkg_version + _ver = _pkg_version("snipeit-api") except Exception: _ver = "" - ua = f"snipeit-api/{_ver}".rstrip("/") if _ver else "snipeit-api" - self.session.headers.update( - { - "Authorization": f"Bearer {self.token}", + ua = f"snipeit-api/{_ver}" if _ver else "snipeit-api" + + allowed = ( + frozenset(retry_allowed_methods) + if retry_allowed_methods is not None + else DEFAULT_ALLOWED_METHODS + ) + self._retry_transport = RetryTransport( + max_retries=max_retries, + backoff_factor=backoff_factor, + allowed_methods=allowed, + ) + self._http = httpx.Client( + base_url=f"{self.url}/api/v1/", + headers={ + "Authorization": f"Bearer {token}", "Accept": "application/json", - "Content-Type": "application/json", "User-Agent": ua, - } - ) - self.timeout = timeout - - # Configure retries; be compatible with older urllib3 that might not support respect_retry_after_header - try: - retry_strategy = Retry( - total=max_retries, - status_forcelist=[429, 500, 502, 503, 504], - backoff_factor=backoff_factor, - allowed_methods=( - frozenset(retry_allowed_methods) - if retry_allowed_methods is not None - else frozenset(["HEAD", "GET", "OPTIONS"]) - ), - respect_retry_after_header=True, - ) - except TypeError: - retry_strategy = Retry( - total=max_retries, - status_forcelist=[429, 500, 502, 503, 504], - backoff_factor=backoff_factor, - allowed_methods=( - frozenset(retry_allowed_methods) - if retry_allowed_methods is not None - else frozenset(["HEAD", "GET", "OPTIONS"]) - ), - ) - adapter = HTTPAdapter(max_retries=retry_strategy) - self.session.mount("https://", adapter) - self.session.mount("http://", adapter) - - def __getattr__(self, name: str): - """Dynamically create and cache resource managers. - - Args: - name (str): The attribute name being accessed (e.g., "assets"). - - Returns: - Any: An initialized manager instance corresponding to the attribute. - - Raises: - AttributeError: If no manager is registered for the given name. - """ - # Dynamic manager factory with caching - registry = type(self)._manager_registry - if name in registry: - module_path, class_name = registry[name] - module = importlib.import_module(module_path, package=__package__) - manager_cls = getattr(module, class_name) - instance = manager_cls(self) - setattr(self, name, instance) # cache on instance - return instance - raise AttributeError( - f"{type(self).__name__!s} object has no attribute {name!r}" + }, + timeout=httpx.Timeout(timeout), + follow_redirects=False, + transport=self._retry_transport, ) - def __dir__(self) -> list[str]: - """Return attribute names, including dynamic manager attributes. - - Returns: - list[str]: A sorted list of attribute names. - """ - # Improve IDE/repl discovery - base = set(super().__dir__()) - return sorted(base | set(type(self)._manager_registry.keys())) + # Eagerly instantiate all resource managers. + self.accessories = AccessoriesManager(self) + self.assets = AssetsManager(self) + self.categories = CategoriesManager(self) + self.companies = CompaniesManager(self) + self.components = ComponentsManager(self) + self.consumables = ConsumablesManager(self) + self.departments = DepartmentsManager(self) + self.fields = FieldsManager(self) + self.fieldsets = FieldsetsManager(self) + self.licenses = LicensesManager(self) + self.locations = LocationsManager(self) + self.manufacturers = ManufacturersManager(self) + self.models = ModelsManager(self) + self.status_labels = StatusLabelsManager(self) + self.suppliers = SuppliersManager(self) + self.users = UsersManager(self) + + def __repr__(self) -> str: + return f"" def close(self) -> None: - """Close the underlying HTTP session. - - Returns: - None - """ - self.session.close() + """Close the underlying HTTP session.""" + self._http.close() def __enter__(self) -> "SnipeIT": - """Enter the context manager. - - Returns: - SnipeIT: The client instance. - """ return self def __exit__(self, exc_type, exc, tb) -> bool | None: - """Exit the context manager and close the session. - - Args: - exc_type: Exception type if an exception occurred. - exc: Exception instance if an exception occurred. - tb: Traceback if an exception occurred. - - Returns: - bool | None: False to indicate exceptions are not suppressed. - """ self.close() - # Do not suppress exceptions return False - def _raise_for_status(self, response: requests.Response) -> None: - """Raise typed exceptions for error status codes.""" - if response.status_code >= 400: - - def _stringify_messages(msg: Any) -> str: - if msg is None: - return "" - if isinstance(msg, str): - return msg - if isinstance(msg, (list, tuple)): - return "; ".join(map(str, msg)) - if isinstance(msg, dict): - return "; ".join(f"{k}: {v}" for k, v in msg.items()) - return str(msg) - - try: - body = response.json() - messages = _stringify_messages( - body.get("messages", response.reason) - ) - except ValueError: - body = None - messages = _stringify_messages(response.text or response.reason) - - if response.status_code == 401: - raise SnipeITAuthenticationError(messages, response) - if response.status_code == 404: - raise SnipeITNotFoundError(messages, response) - if response.status_code == 422: - raise SnipeITValidationError(messages, response) - if 400 <= response.status_code < 500: - raise SnipeITClientError(messages, response) - else: - # Must be 5xx here since we are in the >=400 block and not <500 - raise SnipeITServerError(messages, response) - - def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any] | None: - """Construct and send an API request. - - Args: - method (str): HTTP method (e.g., "GET", "POST"). - path (str): API path under /api/v1/ (e.g., "hardware"). - **kwargs: Extra arguments forwarded to requests.Session.request - (e.g., params, json, headers). - - Returns: - dict[str, Any] | None: Parsed JSON response for 2xx responses, or - None for 204 No Content. + # ------------------------------------------------------------------ + # Error mapping + # ------------------------------------------------------------------ + def _raise_for_status(self, response: httpx.Response) -> None: + """Raise typed exceptions for 3xx/4xx/5xx status codes.""" + status = response.status_code + + if 300 <= status < 400: + location = response.headers.get("Location", "") + raise SnipeITApiError( + f"Unexpected redirect ({status}) to {location}. This is usually " + "a reverse-proxy or authentication-middleware misconfiguration.", + response=response, + ) - Raises: - SnipeITAuthenticationError: On 401 Unauthorized. - SnipeITNotFoundError: On 404 Not Found. - SnipeITValidationError: On 422 Unprocessable Entity. - SnipeITClientError: On other 4xx client errors. - SnipeITServerError: On 5xx server errors. - SnipeITTimeoutError: On request timeouts. - SnipeITException: On unexpected non-JSON responses or request errors. - """ - url = f"{self.url}/api/v1/{path}" + if status < 400: + return + + messages = _stringify_messages(_extract_messages(response)) + + if status == 401: + raise SnipeITAuthenticationError(messages, response) + if status == 404: + raise SnipeITNotFoundError(messages, response) + if status == 422: + raise SnipeITValidationError(messages, response) + if 400 <= status < 500: + raise SnipeITClientError(messages, response) + raise SnipeITServerError(messages, response) + + # ------------------------------------------------------------------ + # Core request method + # ------------------------------------------------------------------ + def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any] | None: + start = time.monotonic() try: - response = self.session.request(method, url, timeout=self.timeout, **kwargs) - - self._raise_for_status(response) - - if response.status_code == 204: - return None - - # Ensure we always return JSON for 2xx responses; otherwise raise a clear error - try: - json_response = response.json() - if ( - isinstance(json_response, dict) - and json_response.get("status") == "error" - ): - raise SnipeITApiError( - json_response.get("messages", "Unknown API error"), - response=response, - ) - return json_response - except ValueError as e: - raise SnipeITException( - "Expected JSON response but received invalid or non-JSON content." - ) from e - - except requests.exceptions.Timeout as e: + response = self._http.request(method, path, **kwargs) + except httpx.TimeoutException as e: + effective_timeout = kwargs.get("timeout", self.timeout) + logger.warning( + "Snipe-IT request timed out after %ss: %s /api/v1/%s", + effective_timeout, method, path, + ) raise SnipeITTimeoutError( - f"Request timed out after {self.timeout} seconds." + f"Request timed out after {effective_timeout} seconds." ) from e - except requests.exceptions.RequestException as e: + except httpx.RequestError as e: + logger.warning( + "Snipe-IT request error on %s /api/v1/%s: %s", method, path, e + ) raise SnipeITException(f"An unexpected error occurred: {e}") from e - def get(self, path: str, **kwargs: Any) -> Dict[str, Any]: - """Perform a GET request. + elapsed_ms = (time.monotonic() - start) * 1000.0 + http_logger.debug( + "%s /api/v1/%s -> %d (%.1f ms)", + method, path, response.status_code, elapsed_ms, + ) - Args: - path (str): API path under /api/v1/. - **kwargs: Query parameters appended to the request as params. + self._raise_for_status(response) - Returns: - dict[str, Any]: Parsed JSON response. - """ - return self._request("GET", path, params=kwargs) # type: ignore[return-value] + if response.status_code == 204: + return None - def post(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]: - """Perform a POST request. + try: + json_response = response.json() + except ValueError as e: + raise SnipeITException( + "Expected JSON response but received invalid or non-JSON content." + ) from e - Args: - path (str): API path under /api/v1/. - data (dict[str, Any]): JSON body to send. + if isinstance(json_response, dict) and json_response.get("status") == "error": + raise SnipeITApiError( + json_response.get("messages", "Unknown API error"), + response=response, + ) + return json_response - Returns: - dict[str, Any]: Parsed JSON response. - """ - return self._request("POST", path, json=data) # type: ignore[return-value] + # ------------------------------------------------------------------ + # Convenience verb helpers + # ------------------------------------------------------------------ + def get(self, path: str, **kwargs: Any) -> dict[str, Any]: + """Perform a GET request. Raises if the server returns 204 No Content.""" + return self._require_body("GET", self._request("GET", path, params=kwargs)) - def put(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]: - """Perform a PUT request. + def post(self, path: str, data: dict[str, Any]) -> dict[str, Any]: + """Perform a POST request. Raises if the server returns 204 No Content.""" + return self._require_body("POST", self._request("POST", path, json=data)) - Args: - path (str): API path under /api/v1/. - data (dict[str, Any]): JSON body to send. + def put(self, path: str, data: dict[str, Any]) -> dict[str, Any]: + """Perform a PUT request. Raises if the server returns 204 No Content.""" + return self._require_body("PUT", self._request("PUT", path, json=data)) - Returns: - dict[str, Any]: Parsed JSON response. + def patch(self, path: str, data: dict[str, Any]) -> dict[str, Any]: + """Perform a PATCH request. Raises if the server returns 204 No Content.""" + return self._require_body("PATCH", self._request("PATCH", path, json=data)) + + def delete(self, path: str) -> dict[str, Any] | None: + """Perform a DELETE request. + + Returns the parsed JSON body, or ``None`` for 204 No Content. """ - return self._request("PUT", path, json=data) # type: ignore[return-value] + return self._request("DELETE", path) - def patch(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]: - """Perform a PATCH request. + # ------------------------------------------------------------------ + # Raw / streaming helpers (for non-JSON payloads) + # ------------------------------------------------------------------ + def _raw_request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + """Execute a request and apply error mapping, returning the raw Response. - Args: - path (str): API path under /api/v1/. - data (dict[str, Any]): JSON body to send. + Use this for non-JSON payloads (file uploads, binary downloads, PDF). + Callers MUST call ``self._raise_for_status(response)`` before reading + the body — this method does NOT call it automatically so that callers + can inspect headers (e.g. Content-Type) before deciding how to handle + the response. - Returns: - dict[str, Any]: Parsed JSON response. + For standard JSON endpoints, prefer ``_request``. """ - return self._request("PATCH", path, json=data) # type: ignore[return-value] + try: + return self._http.request(method, path, **kwargs) + except httpx.TimeoutException as e: + effective_timeout = kwargs.get("timeout", self.timeout) + raise SnipeITTimeoutError( + f"Request timed out after {effective_timeout} seconds." + ) from e + except httpx.RequestError as e: + raise SnipeITException(f"An unexpected error occurred: {e}") from e - def delete(self, path: str) -> Dict[str, Any] | None: - """Perform a DELETE request. + @contextlib.contextmanager + def _stream_request( + self, method: str, path: str, **kwargs: Any + ) -> Generator[httpx.Response, None, None]: + """Context manager for streaming requests. - Args: - path (str): API path under /api/v1/. + Wraps ``httpx.Client.stream`` with the same timeout/error mapping as + ``_raw_request``. Callers MUST call ``self._raise_for_status(response)`` + before iterating the body. + + Usage:: - Returns: - dict[str, Any] | None: Parsed JSON response or None if the server - responds with 204 No Content. + with self.api._stream_request("GET", url) as resp: + self.api._raise_for_status(resp) + for chunk in resp.iter_bytes(): + ... """ - return self._request("DELETE", path) + try: + with self._http.stream(method, path, **kwargs) as response: + yield response + except httpx.TimeoutException as e: + effective_timeout = kwargs.get("timeout", self.timeout) + raise SnipeITTimeoutError( + f"Request timed out after {effective_timeout} seconds." + ) from e + except httpx.RequestError as e: + raise SnipeITException(f"An unexpected error occurred: {e}") from e + + @staticmethod + def _require_body(method: str, body: dict[str, Any] | None) -> dict[str, Any]: + """Raise if a body-returning verb got a 204 No Content response.""" + if body is None: + raise SnipeITException( + f"Expected a JSON body from {method}, but server returned " + "204 No Content." + ) + return body + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _extract_messages(response: httpx.Response) -> Any: + try: + body = response.json() + except ValueError: + return response.text or response.reason_phrase + if isinstance(body, dict): + return body.get("messages", response.reason_phrase) + return response.reason_phrase + + +def _stringify_messages(msg: Any) -> str: + if msg is None: + return "" + if isinstance(msg, str): + return msg + if isinstance(msg, (list, tuple)): + return "; ".join(map(str, msg)) + if isinstance(msg, dict): + return "; ".join(f"{k}: {v}" for k, v in msg.items()) + return str(msg) diff --git a/snipeit/client.pyi b/snipeit/client.pyi deleted file mode 100644 index 62c3f0d..0000000 --- a/snipeit/client.pyi +++ /dev/null @@ -1,82 +0,0 @@ -from typing import Any, Dict, Set - -import requests - -from .resources.assets import AssetsManager -from .resources.accessories import AccessoriesManager -from .resources.categories import CategoriesManager -from .resources.components import ComponentsManager -from .resources.consumables import ConsumablesManager -from .resources.departments import DepartmentsManager -from .resources.fields import FieldsManager -from .resources.fieldsets import FieldsetsManager -from .resources.licenses import LicensesManager -from .resources.locations import LocationsManager -from .resources.manufacturers import ManufacturersManager -from .resources.models import ModelsManager -from .resources.status_labels import StatusLabelsManager -from .resources.users import UsersManager -from .resources.companies import CompaniesManager -from .resources.suppliers import SuppliersManager - -class SnipeIT: - """A client for interacting with the Snipe-IT API.""" - - # Registry of manager attributes -> (module_path, class_name) - _manager_registry: Dict[str, tuple[str, str]] - - url: str - session: requests.Session - timeout: int - - def __init__( - self, - url: str, - token: str, - timeout: int = 10, - max_retries: int = 3, - backoff_factor: float = 0.3, - retry_allowed_methods: Set[str] | None = None, - ) -> None: ... - """Initializes the Snipe-IT API client.""" - - # Dynamic manager attributes (statically typed) - assets: AssetsManager - accessories: AccessoriesManager - categories: CategoriesManager - components: ComponentsManager - consumables: ConsumablesManager - departments: DepartmentsManager - fields: FieldsManager - fieldsets: FieldsetsManager - licenses: LicensesManager - locations: LocationsManager - manufacturers: ManufacturersManager - models: ModelsManager - status_labels: StatusLabelsManager - users: UsersManager - companies: CompaniesManager - suppliers: SuppliersManager - - def close(self) -> None: ... - """Closes the underlying HTTP session.""" - - def __enter__(self) -> "SnipeIT": ... - def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool | None: ... - def get(self, path: str, **kwargs: Any) -> Dict[str, Any]: ... - """Performs a GET request.""" - - def post(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]: ... - """Performs a POST request.""" - - def put(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]: ... - """Performs a PUT request.""" - - def patch(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]: ... - """Performs a PATCH request.""" - - def delete(self, path: str) -> Dict[str, Any] | None: ... - """Performs a DELETE request. - - Returns None when the server responds with 204 No Content; otherwise returns the JSON body. - """ diff --git a/snipeit/exceptions.py b/snipeit/exceptions.py index 72d5158..d2c2aff 100644 --- a/snipeit/exceptions.py +++ b/snipeit/exceptions.py @@ -43,15 +43,16 @@ class SnipeITApiError(SnipeITException): Args: message (str): Human-readable error message. - response (requests.Response | None): Original HTTP response, if any. + response: The HTTP response associated with the error, if any + (a ``httpx.Response``). Attached as ``self.response`` so + callers can inspect status code, headers, and body. Attributes: - response (requests.Response | None): The HTTP response associated with - the error. - status_code (int | None): The HTTP status code if available. + response: The HTTP response associated with the error. + status_code (int | None): The HTTP status code, if available. Examples: - Inspect response details when available: + Inspect response details when available:: try: api.assets.get(0) @@ -89,14 +90,14 @@ class SnipeITValidationError(SnipeITApiError): Args: message (str): Human-readable error message. - response (requests.Response | None): Original HTTP response, if any. + response: The HTTP response, if any. Attributes: errors (dict | None): Parsed validation errors from the API response, if available. Examples: - Access validation details: + Access validation details:: try: api.assets.create(status_id=1, model_id=1, asset_tag="") @@ -106,13 +107,15 @@ class SnipeITValidationError(SnipeITApiError): def __init__(self, message: str, response=None): super().__init__(message, response=response) self.errors = None - # Attempt to parse detailed errors from JSON body try: if response is not None: body = response.json() self.errors = body.get("errors") - except Exception: - self.errors = None + except Exception as exc: + import logging + logging.getLogger("snipeit").warning( + "SnipeITValidationError: failed to parse error body: %s", exc + ) class SnipeITClientError(SnipeITApiError): diff --git a/docker/api_key.txt b/snipeit/py.typed similarity index 100% rename from docker/api_key.txt rename to snipeit/py.typed diff --git a/snipeit/resources/accessories.py b/snipeit/resources/accessories.py index ec0be4f..c8caa7a 100644 --- a/snipeit/resources/accessories.py +++ b/snipeit/resources/accessories.py @@ -4,7 +4,7 @@ Snipe-IT accessory endpoints. """ -from typing import Any, Dict +from typing import Any from .base import ApiObject, BaseResourceManager @@ -17,7 +17,7 @@ class Accessory(ApiObject): acc = api.accessories.get(1) print(acc) """ - _path = "accessories" + _resource_path = "accessories" def __repr__(self) -> str: """Return a concise string representation. @@ -25,7 +25,7 @@ def __repr__(self) -> str: Returns: str: The accessory id and name. """ - return f"" + return f"" class AccessoriesManager(BaseResourceManager[Accessory]): @@ -38,7 +38,7 @@ class AccessoriesManager(BaseResourceManager[Accessory]): """ resource_cls = Accessory - path = Accessory._path + path = Accessory._resource_path def create(self, name: str, qty: int, category_id: int, **kwargs: Any) -> 'Accessory': """Create a new accessory. @@ -65,7 +65,7 @@ def create(self, name: str, qty: int, category_id: int, **kwargs: Any) -> 'Acces data.update(kwargs) return super().create(**data) - def checkin_from_user(self, accessory_user_id: int) -> Dict[str, Any]: + def checkin_from_user(self, accessory_user_id: int) -> dict[str, Any]: """Check in an accessory currently assigned to a user. Note: diff --git a/snipeit/resources/assets.py b/snipeit/resources/assets.py deleted file mode 100644 index f28ab49..0000000 --- a/snipeit/resources/assets.py +++ /dev/null @@ -1,512 +0,0 @@ -"""Assets resources. - -Define the Asset model and AssetsManager for interacting with hardware endpoints. -""" - -from typing import Any, Dict, List, Union, cast -from ..exceptions import SnipeITApiError, SnipeITNotFoundError -from .base import ApiObject, BaseResourceManager - -import os -import warnings - -class Asset(ApiObject): - """Represents a Snipe-IT asset. - - Examples: - Fetch and check out an asset: - - asset = api.assets.get(1) - asset.checkout(checkout_to_type="user", assigned_to_id=123) - """ - - _path = "hardware" - # Commonly-present fields declared for type checking convenience - asset_tag: str | None - name: str | None - serial: str | None - model: Dict[str, Any] | None - - def __repr__(self) -> str: - """Return a concise string representation including tag, name, serial and model. - - Returns: - str: Human-friendly summary string. - """ - asset_tag = getattr(self, "asset_tag", "N/A") - name = getattr(self, "name", "N/A") - serial = getattr(self, "serial", "N/A") - model = getattr(self, "model", None) - model_name = model.get("name", "N/A") if isinstance(model, dict) else "N/A" - return f"" - - def checkout( - self, checkout_to_type: str, assigned_to_id: int, **kwargs: Any - ) -> "Asset": - """Check out this asset to a user, asset, or location. - - Args: - checkout_to_type (str): One of "user", "asset", or "location". - assigned_to_id (int): The id of the user/asset/location to assign. - **kwargs: Additional optional fields such as expected_checkin, note, etc. - - Returns: - Asset: The updated Asset object. - - Raises: - ValueError: If checkout_to_type is not one of "user", "asset", or "location". - - Examples: - Check out an asset to a user: - - asset.checkout("user", assigned_to_id=123, note="Loaner laptop") - """ - path = f"{self._path}/{self.id}/checkout" - data: Dict[str, Any] = { - "checkout_to_type": checkout_to_type, - } - if checkout_to_type == "user": - data["assigned_user"] = assigned_to_id - elif checkout_to_type == "asset": - data["assigned_asset"] = assigned_to_id - elif checkout_to_type == "location": - data["assigned_location"] = assigned_to_id - else: - raise ValueError( - "checkout_to_type must be one of 'user', 'asset', or 'location'" - ) - - data.update(kwargs) - self._manager._create(path, data) - return self.refresh() - - def checkin(self, **kwargs: Any) -> "Asset": - """Check in this asset. - - Args: - **kwargs: Additional optional fields such as note, location_id. - - Returns: - Asset: The updated Asset object. - """ - path = f"{self._path}/{self.id}/checkin" - self._manager._create(path, kwargs) - return self.refresh() - - def audit(self, **kwargs: Any) -> "Asset": - """Audit this asset by id. - - Primary path: POST /hardware/{id}/audit. - - Args: - **kwargs: Optional fields such as location_id, note, update_location, next_audit_date. - - Returns: - Asset: The updated Asset object. - """ - path = f"{self._path}/{self.id}/audit" - self._manager._create(path, kwargs) - return self.refresh() - - def restore(self) -> "Asset": - """Restore a soft-deleted asset and refresh its data. - - Returns: - Asset: The updated Asset object after restoration. - """ - path = f"{self._path}/{self.id}/restore" - self._manager._create(path, {}) - return self.refresh() - - -class AssetsManager(BaseResourceManager[Asset]): - """Manager for Asset-related API operations. - - Examples: - Create and fetch an asset: - - new_asset = api.assets.create(status_id=1, model_id=1) - fetched = api.assets.get(new_asset.id) - """ - - resource_cls = Asset - path = Asset._path - - def create( - self, status_id: int, model_id: int, asset_tag: str | None = None, **kwargs: Any - ) -> "Asset": - """Create a new asset. - - Args: - status_id (int): The id of the status label. - model_id (int): The id of the asset model. - asset_tag (str | None): The asset tag. If omitted, Snipe-IT will auto-increment. - **kwargs: Additional optional fields for the new asset. - - Returns: - Asset: The newly created Asset object. - """ - data: Dict[str, Any] = { - "status_id": status_id, - "model_id": model_id, - } - if asset_tag: - data["asset_tag"] = asset_tag - data.update(kwargs) - return super().create(**data) - - # ---- Audits ---- - def audit_by_id(self, asset_id: int, **kwargs: Any) -> Dict[str, Any]: - """Audit an asset by id via POST /hardware/audit/:id. - - Args: - asset_id (int): The asset identifier. - **kwargs: Optional fields (location_id, note, update_location, etc.). - - Returns: - dict[str, Any]: The API response dictionary. - """ - return self._create(f"{self.path}/audit/{asset_id}", kwargs) - - def list_audit_overdue(self) -> Dict[str, Any]: - """List overdue audits via GET /hardware/audit/overdue. - - Returns: - dict[str, Any]: The API response dictionary. - """ - return self._get(f"{self.path}/audit/overdue") - - def list_audit_due(self) -> Dict[str, Any]: - """List due audits via GET /hardware/audit/due. - - Returns: - dict[str, Any]: The API response dictionary. - """ - return self._get(f"{self.path}/audit/due") - - def get_by_tag(self, asset_tag: str, **kwargs: Any) -> "Asset": - """Get a single asset by its asset tag. - - Args: - asset_tag (str): The asset tag to search for. - **kwargs: Additional optional parameters. - - Returns: - Asset: The matching asset. - - Raises: - SnipeITNotFoundError: If no asset exists with the provided tag. - """ - try: - response = self._get(f"{self.path}/bytag/{asset_tag}", **kwargs) - return self._make(response) - except SnipeITApiError as e: - if "Asset does not exist" in str(e): - raise SnipeITNotFoundError( - f"Asset with tag {asset_tag} not found." - ) from e - raise e - - def get_by_serial(self, serial: str, **kwargs: Any) -> "Asset": - """Get a single asset by serial number. - - Handles responses that are either a single object or a list envelope - with rows/total. - - Args: - serial (str): The serial number to search for. - **kwargs: Additional optional parameters. - - Returns: - Asset: The matching asset. - - Raises: - SnipeITNotFoundError: If the asset cannot be found. - SnipeITApiError: If the API indicates multiple matches or an unexpected shape. - """ - try: - response = self._get(f"{self.path}/byserial/{serial}", **kwargs) - except SnipeITApiError as e: - if "Asset does not exist" in str(e): - raise SnipeITNotFoundError( - f"Asset with serial {serial} not found." - ) from e - raise - - # Envelope shape - if isinstance(response, dict) and "rows" in response: - # If API does not include 'total', treat as not found for safety (per tests) - if "total" not in response: - raise SnipeITNotFoundError(f"Asset with serial {serial} not found.") - rows = response.get("rows") or [] - if len(rows) == 1 and response.get("total") == 1: - return self._make(rows[0]) - if response.get("total", 0) > 1: - raise SnipeITApiError( - f"Expected 1 asset with serial {serial}, but found {response.get('total')}." - ) - raise SnipeITNotFoundError(f"Asset with serial {serial} not found.") - - # Single-object shape - if isinstance(response, dict) and response.get("id") is not None: - return self._make(response) - - raise SnipeITApiError("Unexpected response for byserial") - - def create_maintenance( - self, - asset_id: int, - asset_improvement: str, - supplier_id: int, - title: str, - **kwargs: Any, - ) -> Dict[str, Any]: - """Create a new asset maintenance record. - - Args: - asset_id (int): The asset identifier. - asset_improvement (str): Type of improvement/maintenance. - supplier_id (int): Supplier identifier. - title (str): Maintenance title. - **kwargs: Additional maintenance fields (cost, start_date, etc.). - - Returns: - dict[str, Any]: The API response payload. - """ - data = { - "asset_improvement": asset_improvement, - "supplier_id": supplier_id, - "title": title, - } - data.update(kwargs) - response = self._create(f"{self.path}/{asset_id}/maintenances", data) - return response.get("payload", response) - - # ---- Licenses ---- - def get_licenses(self, asset_id: int) -> Dict[str, Any]: - """Get licenses checked out to an asset via GET /hardware/:id/licenses. - - Args: - asset_id (int): The asset identifier. - - Returns: - dict[str, Any]: The API response dictionary. - """ - return self._get(f"{self.path}/{asset_id}/licenses") - - # ---- Files ---- - def list_files(self, asset_id: int) -> Dict[str, Any]: - """List uploaded files for an asset via GET /hardware/:id/files. - - Args: - asset_id (int): The asset identifier. - - Returns: - dict[str, Any]: The API response dictionary. - """ - return self._get(f"{self.path}/{asset_id}/files") - - def upload_files( - self, asset_id: int, paths: List[str], notes: str | None = None - ) -> Dict[str, Any]: - """Upload one or more files for an asset via POST /hardware/:id/files. - - Args: - asset_id (int): The asset identifier. - paths (list[str]): Paths to local files to upload. - notes (str | None): Optional notes attached to the upload. - - Returns: - dict[str, Any]: The API response dictionary. - - Raises: - ValueError: If no file paths are provided. - FileNotFoundError: If any provided path does not exist. - PermissionError: If any provided path is not readable. - SnipeITApiError: If the response indicates an error or is invalid. - """ - if not paths: - raise ValueError("At least one file path required") - - # Validate all paths before opening any files to avoid mid-upload failures - missing: List[str] = [str(p) for p in paths if not os.path.isfile(p)] - unreadable: List[str] = [str(p) for p in paths if os.path.isfile(p) and not os.access(p, os.R_OK)] - if missing: - raise FileNotFoundError(f"File(s) not found: {', '.join(missing)}") - if unreadable: - raise PermissionError(f"File(s) not readable: {', '.join(unreadable)}") - - url = f"{self.api.url}/api/v1/{self.path}/{asset_id}/files" - files: List[tuple[str, tuple[str, Any]]] = [] - opened_files: List[Any] = [] - try: - for p in paths: - if not os.path.isfile(p): - raise ValueError(f"File not found: {p}") - f = open(p, "rb") - opened_files.append(f) - files.append(("file[]", (os.path.basename(p), f))) - data: Dict[str, Any] = {} - if notes is not None: - data["notes"] = notes - # Remove the session-level JSON Content-Type for this request so - # requests can generate the multipart/form-data boundary itself. - # Temporarily popping the header is more robust across requests - # versions than relying on per-request header removal semantics. - original_content_type = self.api.session.headers.pop("Content-Type", None) - import requests - try: - try: - resp = self.api.session.post( - url, - files=files, - data=data, - timeout=self.api.timeout, - ) - finally: - if original_content_type is not None: - self.api.session.headers["Content-Type"] = original_content_type - - self.api._raise_for_status(resp) - - try: - json_resp = resp.json() - if isinstance(json_resp, dict) and json_resp.get("status") == "error": - raise SnipeITApiError( - json_resp.get("messages", "Unknown API error"), - response=resp, - ) - return json_resp - except ValueError: - raise SnipeITApiError("Expected JSON response from file upload", response=resp) - except requests.exceptions.Timeout as e: - from ..exceptions import SnipeITTimeoutError - raise SnipeITTimeoutError(f"Request timed out after {self.api.timeout} seconds.") from e - except requests.exceptions.RequestException as e: - from ..exceptions import SnipeITException - raise SnipeITException(f"An unexpected error occurred: {e}") from e - finally: - for f in opened_files: - try: - f.close() - except Exception as e: - warnings.warn(f"Failed to close file {getattr(f, 'name', '')}: {e}") - - def download_file(self, asset_id: int, file_id: int, save_path: str) -> str: - """Download a specific file via GET /hardware/:id/files/:file_id. - - Args: - asset_id (int): The asset identifier. - file_id (int): The file identifier. - save_path (str): Local filesystem path to save the downloaded file. - - Returns: - str: The save_path where the file was written. - - Raises: - SnipeITApiError: If the API response is not a 200 OK or body is invalid. - """ - import requests - url = f"{self.api.url}/api/v1/{self.path}/{asset_id}/files/{file_id}" - try: - resp = self.api.session.get(url, timeout=self.api.timeout) - self.api._raise_for_status(resp) - if resp.status_code != 200: - raise SnipeITApiError(f"Unexpected status code {resp.status_code}", response=resp) - except requests.exceptions.Timeout as e: - from ..exceptions import SnipeITTimeoutError - raise SnipeITTimeoutError(f"Request timed out after {self.api.timeout} seconds.") from e - except requests.exceptions.RequestException as e: - from ..exceptions import SnipeITException - raise SnipeITException(f"An unexpected error occurred: {e}") from e - - directory = os.path.dirname(save_path) - if directory: - os.makedirs(directory, exist_ok=True) - with open(save_path, "wb") as f: - f.write(resp.content) - return save_path - - def delete_file(self, asset_id: int, file_id: int) -> None: - """Delete a specific file via DELETE /hardware/:id/files/:file_id/delete. - - Args: - asset_id (int): The asset identifier. - file_id (int): The file identifier. - - Returns: - None - """ - self._delete(f"{self.path}/{asset_id}/files/{file_id}/delete") - - # ---- Labels ---- - def labels( - self, save_path: str, assets_or_tags: Union[List["Asset"], List[str]] - ) -> str: - """Generate and save asset labels as a PDF via POST /hardware/labels. - - This method only supports PDF responses. JSON/base64 legacy responses are not supported. - - Args: - save_path (str): The file path where the labels PDF will be saved. - assets_or_tags (list[Asset] | list[str]): A list of Asset objects or - a list of asset tag strings. - - Returns: - str: The save_path where the PDF was saved. - - Raises: - ValueError: If no valid assets or tags are provided. - SnipeITApiError: If the API request fails or a non-PDF response is returned. - - Examples: - Generate labels for specific assets: - - api.assets.labels("/tmp/labels.pdf", [asset1, asset2]) - """ - if not assets_or_tags: - raise ValueError("At least one asset or tag required") - - if isinstance(assets_or_tags[0], Asset): - assets = cast(List[Asset], assets_or_tags) - tags = [a.asset_tag for a in assets if getattr(a, "asset_tag", None)] - else: - tags = [ - tag - for tag in cast(List[str], assets_or_tags) - if isinstance(tag, str) and tag.strip() - ] - - if not tags: - raise ValueError("No valid asset tags found") - - import requests - # Perform request directly to allow binary PDF handling - url = f"{self.api.url}/api/v1/{self.path}/labels" - headers = dict(self.api.session.headers) - # Only accept PDF - headers["Accept"] = "application/pdf" - - try: - resp = self.api.session.post( - url, json={"asset_tags": tags}, headers=headers, timeout=self.api.timeout - ) - self.api._raise_for_status(resp) - except requests.exceptions.Timeout as e: - from ..exceptions import SnipeITTimeoutError - raise SnipeITTimeoutError(f"Request timed out after {self.api.timeout} seconds.") from e - except requests.exceptions.RequestException as e: - from ..exceptions import SnipeITException - raise SnipeITException(f"An unexpected error occurred: {e}") from e - - directory = os.path.dirname(save_path) - if directory: - os.makedirs(directory, exist_ok=True) - - content_type = (resp.headers.get("Content-Type") or "").lower() - if "application/pdf" not in content_type: - raise SnipeITApiError(f"Expected PDF from hardware/labels; got Content-Type: {content_type or 'unknown'}") - - with open(save_path, "wb") as f: - f.write(resp.content) - return save_path diff --git a/snipeit/resources/assets/__init__.py b/snipeit/resources/assets/__init__.py new file mode 100644 index 0000000..6343f28 --- /dev/null +++ b/snipeit/resources/assets/__init__.py @@ -0,0 +1,6 @@ +"""Assets package — re-exports Asset and AssetsManager for back-compat.""" + +from .manager import AssetsManager +from .model import Asset + +__all__ = ["Asset", "AssetsManager"] diff --git a/snipeit/resources/assets/files.py b/snipeit/resources/assets/files.py new file mode 100644 index 0000000..9b14173 --- /dev/null +++ b/snipeit/resources/assets/files.py @@ -0,0 +1,125 @@ +"""Asset file operations mixin.""" + +from __future__ import annotations + +import os +import warnings +from typing import Any, Callable + +from ...exceptions import SnipeITApiError + + +class AssetFilesMixin: + """Mixin providing file upload/download/delete operations for AssetsManager.""" + + # These attributes are provided by Manager / BaseResourceManager + api: Any + path: str + + # ---- Files ---- + def list_files(self, asset_id: int) -> dict[str, Any]: + """List uploaded files for an asset via GET /hardware/:id/files.""" + return self._get(f"{self.path}/{asset_id}/files") # type: ignore[attr-defined] + + def upload_files( + self, asset_id: int, paths: list[str], notes: str | None = None + ) -> dict[str, Any]: + """Upload one or more files for an asset via POST /hardware/:id/files. + + Args: + asset_id (int): The asset identifier. + paths (list[str]): Paths to local files to upload. + notes (str | None): Optional notes attached to the upload. + + Returns: + dict[str, Any]: The API response dictionary. + + Raises: + ValueError: If no file paths are provided. + FileNotFoundError: If any provided path does not exist. + PermissionError: If any provided path is not readable. + SnipeITApiError: If the response indicates an error or is invalid. + """ + if not paths: + raise ValueError("At least one file path required") + + missing = [str(p) for p in paths if not os.path.isfile(p)] + unreadable = [str(p) for p in paths if os.path.isfile(p) and not os.access(p, os.R_OK)] + if missing: + raise FileNotFoundError(f"File(s) not found: {', '.join(missing)}") + if unreadable: + raise PermissionError(f"File(s) not readable: {', '.join(unreadable)}") + + url = f"{self.api.url}/api/v1/{self.path}/{asset_id}/files" + files: list[tuple[str, tuple[str, Any]]] = [] + opened_files: list[Any] = [] + try: + for p in paths: + f = open(p, "rb") + opened_files.append(f) + files.append(("file[]", (os.path.basename(p), f))) + data: dict[str, Any] = {} + if notes is not None: + data["notes"] = notes + resp = self.api._raw_request("POST", url, files=files, data=data, timeout=self.api.timeout) + self.api._raise_for_status(resp) + try: + json_resp = resp.json() + if isinstance(json_resp, dict) and json_resp.get("status") == "error": + raise SnipeITApiError(json_resp.get("messages", "Unknown API error"), response=resp) + return json_resp + except ValueError: + raise SnipeITApiError("Expected JSON response from file upload", response=resp) + finally: + for f in opened_files: + try: + f.close() + except Exception as e: + warnings.warn(f"Failed to close file {getattr(f, 'name', '')}: {e}") + + def download_file( + self, + asset_id: int, + file_id: int, + save_path: str, + progress: Callable[[int, int | None], None] | None = None, + ) -> str: + """Download a specific file via GET /hardware/:id/files/:file_id. + + Streams the response in chunks so large files don't load into memory. + + Args: + asset_id: The asset identifier. + file_id: The file identifier. + save_path: Local filesystem path to save the downloaded file. + progress: Optional callback ``(bytes_written, total_bytes_or_None)``. + + Returns: + str: The save_path where the file was written. + """ + url = f"{self.api.url}/api/v1/{self.path}/{asset_id}/files/{file_id}" + directory = os.path.dirname(save_path) + if directory: + os.makedirs(directory, exist_ok=True) + with self.api._stream_request("GET", url, timeout=self.api.timeout) as resp: + self.api._raise_for_status(resp) + total = int(resp.headers["Content-Length"]) if "Content-Length" in resp.headers else None + written = 0 + with open(save_path, "wb") as fh: + for chunk in resp.iter_bytes(chunk_size=65536): + fh.write(chunk) + written += len(chunk) + if progress is not None: + progress(written, total) + return save_path + + def delete_file(self, asset_id: int, file_id: int) -> None: + """Delete a specific file via DELETE /hardware/:id/files/:file_id/delete. + + Note: The trailing ``/delete`` segment is intentional — Snipe-IT's API + uses this non-standard suffix for all file deletions. + Verified against snipe-it/develop routes/api.php line ~1380 + (Route::delete('{object_type}/{id}/files/{file_id}/delete', ...)) + retrieved 2026-05-15. + """ + self._delete(f"{self.path}/{asset_id}/files/{file_id}/delete") # type: ignore[attr-defined] diff --git a/snipeit/resources/assets/labels.py b/snipeit/resources/assets/labels.py new file mode 100644 index 0000000..5f1221c --- /dev/null +++ b/snipeit/resources/assets/labels.py @@ -0,0 +1,74 @@ +"""Asset labels mixin.""" + +from __future__ import annotations + +import os +from typing import Any, cast + +from ...exceptions import SnipeITApiError +from .model import Asset + + +class AssetLabelsMixin: + """Mixin providing PDF label generation for AssetsManager.""" + + api: Any + path: str + + def labels(self, save_path: str, assets_or_tags: list[Asset] | list[str]) -> str: + """Generate and save asset labels as a PDF via POST /hardware/labels. + + This method only supports PDF responses. JSON/base64 legacy responses are not supported. + + Args: + save_path (str): The file path where the labels PDF will be saved. + assets_or_tags (list[Asset] | list[str]): A list of Asset objects or + a list of asset tag strings. + + Returns: + str: The save_path where the PDF was saved. + + Raises: + ValueError: If no valid assets or tags are provided. + SnipeITApiError: If the API request fails or a non-PDF response is returned. + """ + if not assets_or_tags: + raise ValueError("At least one asset or tag required") + + if isinstance(assets_or_tags[0], Asset): + assets = cast(list[Asset], assets_or_tags) + tags = [a.asset_tag for a in assets if getattr(a, "asset_tag", None)] + else: + tags = [ + tag + for tag in cast(list[str], assets_or_tags) + if isinstance(tag, str) and tag.strip() + ] + + if not tags: + raise ValueError("No valid asset tags found") + + # Passing headers= per-request lets httpx override the client-level + # Accept: application/json with Accept: application/pdf for this call only. + url = f"{self.api.url}/api/v1/{self.path}/labels" + resp = self.api._raw_request( + "POST", + url, + json={"asset_tags": tags}, + headers={"Accept": "application/pdf"}, + timeout=self.api.timeout, + ) + self.api._raise_for_status(resp) + + content_type = (resp.headers.get("Content-Type") or "").lower() + if "application/pdf" not in content_type: + raise SnipeITApiError( + f"Expected PDF from hardware/labels; got Content-Type: {content_type or 'unknown'}" + ) + + directory = os.path.dirname(save_path) + if directory: + os.makedirs(directory, exist_ok=True) + with open(save_path, "wb") as f: + f.write(resp.content) + return save_path diff --git a/snipeit/resources/assets/manager.py b/snipeit/resources/assets/manager.py new file mode 100644 index 0000000..80121bf --- /dev/null +++ b/snipeit/resources/assets/manager.py @@ -0,0 +1,105 @@ +"""AssetsManager — core CRUD, audits, licenses, maintenance.""" + +from __future__ import annotations + +from typing import Any + +from ...exceptions import SnipeITApiError, SnipeITNotFoundError +from ..base import BaseResourceManager +from .files import AssetFilesMixin +from .labels import AssetLabelsMixin +from .model import Asset + + +class AssetsManager(AssetFilesMixin, AssetLabelsMixin, BaseResourceManager[Asset]): + """Manager for Asset-related API operations. + + Examples: + Create and fetch an asset: + + new_asset = api.assets.create(status_id=1, model_id=1) + fetched = api.assets.get(new_asset.id) + """ + + resource_cls = Asset + path = Asset._resource_path + + def create( + self, status_id: int, model_id: int, asset_tag: str | None = None, **kwargs: Any + ) -> Asset: + """Create a new asset. + + Args: + status_id (int): The id of the status label. + model_id (int): The id of the asset model. + asset_tag (str | None): The asset tag. If omitted, Snipe-IT will auto-increment. + **kwargs: Additional optional fields for the new asset. + + Returns: + Asset: The newly created Asset object. + """ + data: dict[str, Any] = {"status_id": status_id, "model_id": model_id} + if asset_tag: + data["asset_tag"] = asset_tag + data.update(kwargs) + return super().create(**data) + + # ---- Audits ---- + def audit_by_id(self, asset_id: int, **kwargs: Any) -> dict[str, Any]: + """Audit an asset by id via POST /hardware/audit/:id.""" + return self._create(f"{self.path}/audit/{asset_id}", kwargs) + + def list_audit_overdue(self) -> dict[str, Any]: + """List overdue audits via GET /hardware/audit/overdue.""" + return self._get(f"{self.path}/audit/overdue") + + def list_audit_due(self) -> dict[str, Any]: + """List due audits via GET /hardware/audit/due.""" + return self._get(f"{self.path}/audit/due") + + def get_by_tag(self, asset_tag: str, **kwargs: Any) -> Asset: + """Get a single asset by its asset tag.""" + try: + return self._make(self._get(f"{self.path}/bytag/{asset_tag}", **kwargs)) + except SnipeITNotFoundError: + raise SnipeITNotFoundError(f"Asset with tag {asset_tag!r} not found.") + + def get_by_serial(self, serial: str, **kwargs: Any) -> Asset: + """Get a single asset by serial number. + + Handles both single-object and list-envelope response shapes. + """ + try: + response = self._get(f"{self.path}/byserial/{serial}", **kwargs) + except SnipeITNotFoundError: + raise SnipeITNotFoundError(f"Asset with serial {serial!r} not found.") + + if isinstance(response, dict) and "rows" in response: + if "total" not in response: + raise SnipeITNotFoundError(f"Asset with serial {serial!r} not found.") + rows = response.get("rows") or [] + total = response.get("total", 0) + if len(rows) == 1 and total == 1: + return self._make(rows[0]) + if total > 1: + raise SnipeITApiError(f"Expected 1 asset with serial {serial!r}, but found {total}.") + raise SnipeITNotFoundError(f"Asset with serial {serial!r} not found.") + + if isinstance(response, dict) and response.get("id") is not None: + return self._make(response) + + raise SnipeITApiError("Unexpected response for byserial") + + def create_maintenance( + self, asset_id: int, asset_improvement: str, supplier_id: int, title: str, **kwargs: Any + ) -> dict[str, Any]: + """Create a new asset maintenance record.""" + data = {"asset_improvement": asset_improvement, "supplier_id": supplier_id, "title": title} + data.update(kwargs) + response = self._create(f"{self.path}/{asset_id}/maintenances", data) + return response.get("payload", response) + + # ---- Licenses ---- + def get_licenses(self, asset_id: int) -> dict[str, Any]: + """Get licenses checked out to an asset via GET /hardware/:id/licenses.""" + return self._get(f"{self.path}/{asset_id}/licenses") diff --git a/snipeit/resources/assets/model.py b/snipeit/resources/assets/model.py new file mode 100644 index 0000000..b7522f7 --- /dev/null +++ b/snipeit/resources/assets/model.py @@ -0,0 +1,319 @@ +"""Asset model.""" + +from __future__ import annotations + +from typing import Any, ClassVar, Self + +from pydantic import PrivateAttr + +from ..base import ApiObject, _extract_payload + +_MISSING = object() # sentinel for "no value present" + + +class Asset(ApiObject): + """Represents a Snipe-IT asset. + + Examples: + Fetch and check out an asset: + + asset = api.assets.get(1) + asset.checkout(checkout_to_type="user", assigned_to_id=123) + """ + + _resource_path: ClassVar[str] = "hardware" + # Commonly-present fields declared for type checking convenience + asset_tag: str | None = None + name: str | None = None + serial: str | None = None + model: dict[str, Any] | None = None + + # Staged custom field values awaiting the next save(). Maps display label + # (e.g. "Owner") to the new value. The label → column-name translation + # (``_snipeit__``) happens at save() time using the live + # ``custom_fields`` response shape. + # + # This is a dedicated channel — separate from the regular dirty tracker — + # because Snipe-IT's custom field PATCH semantics are different from + # regular fields: the wire format uses column names, the GET response + # uses labels, and PATCH responses return ``custom_fields: null``. + # Keeping staging out of ``_dirty_set()`` lets the base ``ApiObject`` + # remain agnostic about Snipe-IT's wire-format quirks. + _pending_custom_fields: dict[str, Any] = PrivateAttr(default_factory=dict) + + def __repr__(self) -> str: + asset_tag = self.asset_tag or "N/A" + name = self.name or "N/A" + serial = self.serial or "N/A" + model = self.model + model_name = model.get("name", "N/A") if isinstance(model, dict) else "N/A" + return f"" + + def checkout(self, checkout_to_type: str, assigned_to_id: int, **kwargs: Any) -> "Asset": + """Check out this asset to a user, asset, or location. + + Args: + checkout_to_type (str): One of "user", "asset", or "location". + assigned_to_id (int): The id of the user/asset/location to assign. + **kwargs: Additional optional fields such as expected_checkin, note, etc. + + Returns: + Asset: The updated Asset object. + + Raises: + ValueError: If checkout_to_type is not one of "user", "asset", or "location". + """ + path = f"{self._path}/{self.id}/checkout" + data: dict[str, Any] = {"checkout_to_type": checkout_to_type} + if checkout_to_type == "user": + data["assigned_user"] = assigned_to_id + elif checkout_to_type == "asset": + data["assigned_asset"] = assigned_to_id + elif checkout_to_type == "location": + data["assigned_location"] = assigned_to_id + else: + raise ValueError("checkout_to_type must be one of 'user', 'asset', or 'location'") + data.update(kwargs) + self._manager._create(path, data) + return self.refresh() + + def checkin(self, **kwargs: Any) -> "Asset": + """Check in this asset.""" + self._manager._create(f"{self._path}/{self.id}/checkin", kwargs) + return self.refresh() + + def audit(self, **kwargs: Any) -> "Asset": + """Audit this asset via POST /hardware/{id}/audit.""" + self._manager._create(f"{self._path}/{self.id}/audit", kwargs) + return self.refresh() + + def restore(self) -> "Asset": + """Restore a soft-deleted asset.""" + self._manager._create(f"{self._path}/{self.id}/restore", {}) + return self.refresh() + + # ------------------------------------------------------------------ + # Persistence + # ------------------------------------------------------------------ + def save(self) -> "Asset": + """Persist regular dirty fields **and** any staged custom fields. + + Extends :meth:`ApiObject.save` to also flush ``_pending_custom_fields``. + Each staged label is translated to its underlying column name + (``_snipeit__``) using the asset's ``custom_fields`` + response shape, then merged into the PATCH body as a top-level key — + the only format Snipe-IT's PATCH endpoint accepts for custom fields. + + If no regular fields are dirty *and* no custom fields are staged, + no request is issued (matches base ``save()`` semantics). + """ + dirty = self._dirty_set() + data: dict[str, Any] = {f: getattr(self, f) for f in dirty} + + if self._pending_custom_fields: + cfs = getattr(self, "custom_fields", None) + if not isinstance(cfs, dict): + # Defensive: a label was staged but the read shape is gone. + # In normal flow this can't happen — set_custom_field validates + # the label against custom_fields before staging — but if + # custom_fields was wiped between staging and save (e.g. by a + # manual setattr), surface a clear error rather than silently + # dropping the change. + raise RuntimeError( + "Cannot save staged custom fields: 'custom_fields' is not " + "available on this asset. Call refresh() and re-stage." + ) + for label, value in self._pending_custom_fields.items(): + entry = cfs.get(label) + if not isinstance(entry, dict) or "field" not in entry: + raise RuntimeError( + f"Cannot resolve column name for staged custom field " + f"{label!r}: 'custom_fields[{label!r}]' is missing or " + "malformed. Call refresh() and re-stage." + ) + data[entry["field"]] = value + + if not data: + return self + + path = f"{self._path}/{self.id}" + response = self._manager._patch(path, data) + payload = _extract_payload(response) + self._apply_server_data(payload) + return self + + def _apply_server_data(self, data: dict[str, Any]) -> None: + """Apply API data, accommodating Snipe-IT's PATCH response quirks. + + Snipe-IT's PATCH response payload has two oddities: + + 1. ``custom_fields`` is always ``null`` in the response (the nested + label-keyed read shape is not echoed back). + 2. The updated values are echoed at the **top level** as + ``_snipeit_`` keys instead. Stray column-name keys for + fieldsets the asset does not even use also leak in. + + If we delegated this payload directly to ``ApiObject._apply_server_data``, + the local ``custom_fields`` nested dict would be clobbered with + ``None`` after every save, breaking ``set_custom_field`` on + subsequent calls until ``refresh()``. + + This override: + + * Preserves the local ``custom_fields`` nested shape when the + incoming payload's ``custom_fields`` is ``None`` and the local + shape is a populated dict; refreshes each entry's ``["value"]`` + from the matching top-level ``_snipeit_*`` key in the payload. + * Strips all ``_snipeit_*`` keys from the payload so they don't + pollute ``__pydantic_extra__``. + * Clears ``_pending_custom_fields`` after the server has acknowledged + the changes. + + For GET responses (used by ``refresh()``), ``custom_fields`` arrives + in the nested shape; the option-A branch below is skipped and the + payload flows through unmodified. + """ + incoming = dict(data) # avoid mutating caller's dict + + existing_cfs = getattr(self, "custom_fields", None) + incoming_cfs = incoming.get("custom_fields") + if incoming_cfs is None and isinstance(existing_cfs, dict) and existing_cfs: + refreshed: dict[str, Any] = {} + for label, entry in existing_cfs.items(): + if not isinstance(entry, dict): + refreshed[label] = entry + continue + new_entry = dict(entry) + column = entry.get("field") + if isinstance(column, str) and column in incoming: + new_entry["value"] = incoming[column] + refreshed[label] = new_entry + incoming["custom_fields"] = refreshed + + # Strip stray column-name keys. They've either been folded into the + # nested shape above, or they belong to fieldsets this asset doesn't + # use (Snipe-IT echoes them all). Either way they should not litter + # __pydantic_extra__. + incoming = {k: v for k, v in incoming.items() if not k.startswith("_snipeit_")} + + super()._apply_server_data(incoming) + self._pending_custom_fields.clear() + + # ------------------------------------------------------------------ + # Custom fields + # ------------------------------------------------------------------ + def pending_custom_fields(self) -> dict[str, Any]: + """Return a copy of custom field values staged for the next ``save()``. + + Keys are display labels (e.g. ``"Owner"``); values are the new values + passed to :meth:`set_custom_field`. The returned dict is a copy — + mutating it does not affect staging state. + + Returns: + dict[str, Any]: ``{label: value, ...}`` for every label that has + been staged but not yet saved. Empty dict when nothing is staged. + + Example: + >>> asset = api.assets.get(1) + >>> asset.set_custom_field("Owner", "alice") + >>> asset.pending_custom_fields() + {'Owner': 'alice'} + >>> asset.save() + >>> asset.pending_custom_fields() + {} + """ + return dict(self._pending_custom_fields) + + def get_custom_field(self, label: str, default: Any = None) -> Any: + """Return the value of a custom field by its display label. + + Reads ``custom_fields[label]["value"]`` from the asset's response shape. + + Args: + label: The human-readable label of the custom field + (e.g. ``"Owner"``), as it appears in the Snipe-IT UI and in + the ``custom_fields`` response dict. + default: Value returned when the field is not present on this + asset. Defaults to ``None``. + + Returns: + The current value of the custom field, or ``default`` if the + field is not defined for this asset's model. + + Example: + >>> asset = api.assets.get(1) + >>> asset.get_custom_field("Owner") + 'alice' + """ + cfs = getattr(self, "custom_fields", None) + if not isinstance(cfs, dict): + return default + entry = cfs.get(label) + if not isinstance(entry, dict): + return default + return entry.get("value", default) + + def set_custom_field(self, label: str, value: Any) -> Self: + """Stage a custom field value for the next ``save()``. + + The staged value lives in :attr:`_pending_custom_fields` (inspectable + via :meth:`pending_custom_fields`) until the next ``save()``, at which + point ``Asset.save`` translates the label to its underlying column + name (``_snipeit__``) via ``custom_fields[label]["field"]`` + and sends it as a top-level key in the PATCH body — the only format + Snipe-IT's PATCH endpoint accepts for custom field updates. + + Cancellation: if ``value`` equals the field's current server value + (read from ``custom_fields[label]["value"]``), any previous stage + for this label is discarded and no PATCH is queued. This matches the + no-op behaviour of plain attribute assignment on declared fields. + + Read semantics: the staged value does **not** update + ``custom_fields[label]["value"]``; reads via :meth:`get_custom_field` + continue to return the server's last-known value until ``save()``. + Use :meth:`pending_custom_fields` to inspect what's staged. + + Args: + label: The human-readable label of the custom field, as it + appears in ``asset.custom_fields``. The asset must have been + fetched (so ``custom_fields`` is populated) before calling + this method. + value: The new value to send to the server. + + Returns: + self, to allow chaining (``asset.set_custom_field(...).save()``). + + Raises: + KeyError: If ``label`` is not present in ``custom_fields``. This + usually means either the asset has not been fetched yet, or + the custom field is not associated with this asset's model. + + Example: + >>> asset = api.assets.get(1) + >>> asset.set_custom_field("Owner", "alice") + >>> asset.save() + """ + cfs = getattr(self, "custom_fields", None) + if not isinstance(cfs, dict) or label not in cfs: + raise KeyError( + f"Custom field {label!r} is not defined on this asset. " + f"Available labels: {sorted(cfs.keys()) if isinstance(cfs, dict) else []}. " + "Make sure the asset has been fetched (api.assets.get) and that " + "the field is associated with the asset's model fieldset." + ) + entry = cfs[label] + if not isinstance(entry, dict) or "field" not in entry: + raise KeyError( + f"Custom field {label!r} has unexpected shape: {entry!r}. " + "Expected {'field': '_snipeit_...', 'value': ...}." + ) + + current = entry.get("value", _MISSING) + if current == value: + # Setting back to the server's current value cancels any pending + # stage for this label. No PATCH will be queued. + self._pending_custom_fields.pop(label, None) + return self + + self._pending_custom_fields[label] = value + return self diff --git a/snipeit/resources/base.py b/snipeit/resources/base.py index e571786..803651e 100644 --- a/snipeit/resources/base.py +++ b/snipeit/resources/base.py @@ -1,256 +1,308 @@ -"""Base primitives for resource objects and managers. +"""Base primitives for resource objects and managers.""" -This module defines: +from __future__ import annotations -- ApiObject: A base model for API-backed resources that tracks dirty fields, - supports saving, refreshing, and deletion. -- Manager: A light wrapper around the SnipeIT client with HTTP helpers. -- BaseResourceManager: A generic CRUD manager for ApiObject subclasses. +import copy +from typing import Any, ClassVar, Generic, Iterable, TypeVar -Examples: - Iterate all assets lazily: +from pydantic import BaseModel, ConfigDict, PrivateAttr - from snipeit import SnipeIT - from snipeit.resources.assets import Asset +from ..exceptions import SnipeITApiError, SnipeITException - with SnipeIT(url="https://snipe.example.test", token="{{SNIPEIT_API_TOKEN}}") as api: - for asset in api.assets.list_all(limit=100): - assert isinstance(asset, Asset) -""" +_MISSING = object() # sentinel for "attribute not yet set" -from typing import Any, ClassVar, Dict, Generic, Iterable, List, Set, Type, TypeVar -from ..exceptions import SnipeITException -from ..client import SnipeIT -# Sentinel object to distinguish missing attributes from explicit None values -_MISSING = object() +def _safe_snapshot(d: dict[str, Any]) -> dict[str, Any]: + """Return a snapshot of ``d`` for diff-based dirty tracking. + Dicts and lists are deep-copied so that in-place mutations are detected. + Scalar values (str, int, float, bool, None) are stored as-is (they're + immutable, so no deepcopy needed). Other types are stored by reference; + for those, in-place mutation detection is not guaranteed, but + assignment-based tracking still works. + """ + result: dict[str, Any] = {} + for k, v in d.items(): + if isinstance(v, (dict, list)): + try: + result[k] = copy.deepcopy(v) + except Exception: + result[k] = v + else: + result[k] = v # scalars and other types stored by reference + return result -T = TypeVar("T", bound="ApiObject") +T = TypeVar("T", bound="ApiObject") -class ApiObject: - """Base class for all Snipe-IT API objects (Assets, Users, etc.). - Attributes: - id (int | str | None): Identifier of the resource, when available. - _path (str): Collection path used to construct resource URLs. +class ApiObject(BaseModel): + """Base class for all Snipe-IT API objects. + + Uses pydantic v2 with ``extra="allow"`` so unknown fields returned by the + API are stored as attributes without raising validation errors. This makes + the model resilient to Snipe-IT version drift. + + Note: + ``extra="allow"`` is a double-edged sword. A typo in an attribute name + (e.g. ``asset.serail = "X"``) silently creates a new extra field and + will be included in the next PATCH payload. The server may accept or + ignore it, but the intended field is never updated. Enable strict + type-checking (pyright/mypy) and rely on declared fields to catch this + class of bug. See the "Common Pitfalls" section in the README. + + Dirty tracking: + * Declared fields: tracked via ``model_fields_set`` (pydantic built-in). + * Extra (undeclared) fields: tracked via ``_extra_dirty`` private attr. + * Snapshot-and-diff: a deep copy of the loaded state is taken on every + ``_apply_server_data`` call. ``_dirty_set()`` compares the current + ``model_dump()`` against the snapshot to detect in-place mutations of + nested dicts/lists automatically. + * Use ``mark_dirty(*fields)`` to force fields into the next PATCH payload + regardless of whether they appear changed (e.g. to trigger server-side + recomputation). + + Memory note: + The snapshot is a ``copy.deepcopy`` of the full model dump. For typical + Snipe-IT objects this is in the KB range and negligible. """ - # Known attributes populated at runtime but declared for type checkers - _manager: 'Manager' - _dirty_fields: Set[str] - _initialized: bool - _path: ClassVar[str] = "" - id: int | str | None # Most resources expose an integer id; declare as optional + model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) - def __init__(self, manager: 'Manager', data: Dict[str, Any]) -> None: - """Initialize an ApiObject. + # Private attributes — not serialized, not part of the model schema. + _manager: Any = PrivateAttr(default=None) + _path: str = PrivateAttr(default="") + _extra_dirty: set[str] = PrivateAttr(default_factory=set) + _loaded_state: dict[str, Any] | None = PrivateAttr(default=None) + # Subclasses set this ClassVar to declare their API path. + _resource_path: ClassVar[str] = "" - Args: - manager (Manager): The manager instance that created this object. - data (dict[str, Any]): The data for this object from the API. - """ - # Use object.__setattr__ to avoid triggering our custom __setattr__ during initialization - object.__setattr__(self, "_manager", manager) - object.__setattr__(self, "_dirty_fields", set()) - object.__setattr__(self, "_initialized", False) + id: int | str | None = None - for key, value in data.items(): - setattr(self, key, value) - - object.__setattr__(self, "_initialized", True) + def __init__(self, manager: Any, data: dict[str, Any]) -> None: + super().__init__(**data) + self._manager = manager + self._path = type(self)._resource_path + # Clear pydantic's construction-time tracking so only post-init + # attribute assignments are considered dirty. + self.model_fields_set.clear() + # Snapshot the initial loaded state for diff-based dirty detection. + self._loaded_state = _safe_snapshot(self.model_dump()) def __setattr__(self, name: str, value: Any) -> None: - """Set an attribute and track changes for public fields. - - Args: - name (str): Attribute name. - value (Any): New value. - """ - # Only track changes after the object has been fully initialized. - if getattr(self, "_initialized", False) and not name.startswith("_"): - # To prevent flagging unchanged values as dirty - current = getattr(self, name, _MISSING) - if current is _MISSING or current != value: - self._dirty_fields.add(name) + # Track mutations after init. Only mark dirty when value actually changes. + # 'id' is excluded — it's the resource identifier, not a mutable field. + if not name.startswith("_") and name != "id": + if name in type(self).model_fields: + # Declared field: skip marking dirty only when the value is + # unchanged AND the field is not already dirty from a previous + # assignment. The already-dirty guard prevents a subsequent + # no-op assignment (``asset.name = asset.name``) from clearing + # a legitimate pending change. + try: + current = getattr(self, name, _MISSING) + except Exception: + current = _MISSING + if ( + current is not _MISSING + and current == value + and name not in self.model_fields_set + ): + # Nothing to do — attribute already has this value and is + # not pending in the dirty set. + return + else: + # Extra (undeclared) field. + current = ( + self.__pydantic_extra__.get(name, _MISSING) + if self.__pydantic_extra__ + else _MISSING + ) + if ( + current is not _MISSING + and current == value + and name not in self._extra_dirty + ): + return # no-op and not already pending + self._extra_dirty.add(name) super().__setattr__(name, value) def __repr__(self) -> str: - """Return a concise debug representation. - - Returns: - str: Class name with the resource id. + _id = self.id + return f"<{self.__class__.__name__} {_id if _id is not None else '(new)'}>" + + # ------------------------------------------------------------------ + # Dirty-field helpers + # ------------------------------------------------------------------ + def _dirty_set(self) -> set[str]: + """Return the set of fields that need to be PATCHed. + + Combines three sources: + 1. ``model_fields_set`` — pydantic tracks direct attribute assignments. + 2. ``_extra_dirty`` — extra (undeclared) fields explicitly marked dirty. + 3. Snapshot diff — fields whose current value differs from the value + at last load/save, catching in-place mutations of nested dicts/lists. + """ + dirty = (self.model_fields_set | self._extra_dirty) - {"id"} + if self._loaded_state is not None: + current = self.model_dump() + for key, loaded_value in self._loaded_state.items(): + if key == "id": + continue + try: + changed = current.get(key) != loaded_value + except Exception: + changed = True # non-comparable value; assume dirty + if changed: + dirty.add(key) + return dirty + + def mark_dirty(self, *fields: str) -> None: + """Force ``fields`` into the next PATCH payload. + + Useful when you want to send a field to the server even if its value + hasn't changed (e.g. to trigger server-side recomputation):: + + asset.mark_dirty("custom_fields") + asset.save() + """ + self._extra_dirty.update(fields) + + def _apply_server_data(self, data: dict[str, Any]) -> None: + """Apply API data without marking fields dirty. + + PYDANTIC v2 INTERNALS WARNING: + We write directly to __pydantic_extra__ and __dict__ because pydantic + v2 stores undeclared fields in __pydantic_extra__ but a plain + setattr() can create a shadow entry in __dict__ that disagrees with + model_dump(). On any pydantic version bump, re-run the + ``test_apply_server_data_*`` regression suite. If pydantic ever exposes + a public "replace all extras" API, switch to it. """ - return f"<{self.__class__.__name__} {getattr(self, 'id', '(new)')}>" + extra = self.__pydantic_extra__ + if extra is None: + extra = {} + object.__setattr__(self, "__pydantic_extra__", extra) + else: + # Clear all existing extra fields so stale keys don't persist + # after a server response that omits them. + extra.clear() + instance_dict = object.__getattribute__(self, "__dict__") + for key, value in data.items(): + if key in type(self).model_fields: + object.__setattr__(self, key, value) + else: + instance_dict.pop(key, None) + extra[key] = value + + self.model_fields_set.clear() + self._extra_dirty.clear() + # Refresh the snapshot so the next _dirty_set() diff is against the + # server's current state. We use model_dump() with a deepcopy so that + # subsequent in-place mutations of nested dicts/lists are detected. + # Non-deepcopy-able values (rare in practice) fall back to a no-snapshot + # state for that field — assignment-based tracking still works. + self._loaded_state = _safe_snapshot(self.model_dump()) + + # ------------------------------------------------------------------ + # Active-record methods + # ------------------------------------------------------------------ def save(self: T) -> T: """Persist modified fields to the API via PATCH. - Only fields that have been modified are sent to the API. - - Returns: - T: The updated object from the API. + Only fields that have been modified since the last load/save are sent. + In-place mutations of nested dicts/lists are detected automatically via + snapshot-and-diff tracking. """ - if not self._dirty_fields: + dirty = self._dirty_set() + if not dirty: return self - # Construct path from the class's _path attribute and the object's id path = f"{self._path}/{self.id}" - data = {field: getattr(self, field) for field in self._dirty_fields} - + data = {f: getattr(self, f) for f in dirty} response = self._manager._patch(path, data) + payload = _extract_payload(response) - if response.get("status") == "success": - payload = response.get("payload", {}) - for key, value in payload.items(): - setattr(self, key, value) - # Clear dirty fields after successful save - self._dirty_fields.clear() - else: - msg = response.get("messages", "Save failed with unknown error") - from ..exceptions import SnipeITApiError - raise SnipeITApiError(str(msg)) - + self._apply_server_data(payload) return self def refresh(self: T) -> T: - """Refetch the latest state from the API and update this object in-place. - - Returns: - T: The refreshed object. - """ + """Refetch the latest state from the API and update this object in-place.""" path = f"{self._path}/{self.id}" data = self._manager._get(path) - for key, value in data.items(): - setattr(self, key, value) - # After a refresh, there are no local changes - self._dirty_fields.clear() + self._apply_server_data(data) return self def delete(self) -> None: - """Delete the object from the server. + """Delete the object from the server.""" + self._manager._delete(f"{self._path}/{self.id}") - Returns: - None - """ - path = f"{self._path}/{self.id}" - self._manager._delete(path) +# --------------------------------------------------------------------------- +# Response-shape normalizer (shared by save, create, patch) +# --------------------------------------------------------------------------- +def _extract_payload(resp: dict[str, Any]) -> dict[str, Any]: + """Normalize the three response shapes Snipe-IT returns. -class Manager: - """Base class for all resource managers. - - Args: - api (SnipeIT): The SnipeIT client instance. + * ``{"status": "success", "payload": {...}}`` → payload dict + * ``{"id": ..., ...}`` (raw object, no envelope) → the dict itself + * ``{"status": "error", "messages": ...}`` → raises SnipeITApiError """ + if not isinstance(resp, dict): + return {} + status = resp.get("status") + if status == "error": + raise SnipeITApiError(str(resp.get("messages", "Unknown API error"))) + if status == "success" and "payload" in resp: + payload = resp["payload"] + return payload if isinstance(payload, dict) else {} + # Raw object shape (no envelope). + return resp + + +# --------------------------------------------------------------------------- +# Manager base classes +# --------------------------------------------------------------------------- +class Manager: + """Base class for all resource managers.""" - def __init__(self, api: 'SnipeIT') -> None: - """Initialize the manager. - - Args: - api (SnipeIT): The SnipeIT client instance. - """ + def __init__(self, api: Any) -> None: self.api = api - def _get(self, path: str, **kwargs: Any) -> Dict[str, Any]: - """Perform a GET request via the client. - - Args: - path (str): API path under /api/v1/. - **kwargs: Query parameters. - - Returns: - dict[str, Any]: Parsed JSON response. - """ + def _get(self, path: str, **kwargs: Any) -> dict[str, Any]: return self.api.get(path, **kwargs) - def _create(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]: - """Perform a POST request via the client. - - Args: - path (str): API path under /api/v1/. - data (dict[str, Any]): JSON body to send. - - Returns: - dict[str, Any]: Parsed JSON response. - """ + def _create(self, path: str, data: dict[str, Any]) -> dict[str, Any]: return self.api.post(path, data) - def _patch(self, path: str, data: Dict[str, Any]) -> Dict[str, Any]: - """Perform a PATCH request via the client. - - Args: - path (str): API path under /api/v1/. - data (dict[str, Any]): JSON body to send. - - Returns: - dict[str, Any]: Parsed JSON response. - """ + def _patch(self, path: str, data: dict[str, Any]) -> dict[str, Any]: return self.api.patch(path, data) - def _delete(self, path: str) -> None: - """Perform a DELETE request via the client. - - Args: - path (str): API path under /api/v1/. - - Returns: - None - """ - self.api.delete(path) - return None + def _delete(self, path: str) -> dict[str, Any] | None: + return self.api.delete(path) class BaseResourceManager(Manager, Generic[T]): - """Generic CRUD manager for ApiObject subclasses. - - Subclasses should provide `resource_cls` and may override `path`. + """Generic CRUD manager for ApiObject subclasses.""" - Examples: - Create and fetch a resource: + resource_cls: type[T] + path: str | None = None - asset = api.assets.create(status_id=1, model_id=1) - fetched = api.assets.get(asset.id) - """ - - resource_cls: Type[T] - path: str | None = None # default to resource_cls._path if not set - - def __init__(self, api: 'SnipeIT') -> None: + def __init__(self, api: Any) -> None: super().__init__(api) - # Resolve path from resource class if not provided if self.path is None: - self.path = getattr(self.resource_cls, "_path") # type: ignore[assignment] - - # Construction helper - def _make(self, data: Dict[str, Any]) -> T: - """Construct a resource object from API data. + self.path = getattr(self.resource_cls, "_resource_path", "") # type: ignore[assignment] - Args: - data (dict[str, Any]): Raw API payload for a single item. - - Returns: - T: An initialized ApiObject subclass instance. - """ + def _make(self, data: dict[str, Any]) -> T: return self.resource_cls(self, data) - # CRUD - def list(self, **params: Any) -> List[T]: - """List resources in the collection. - - Args: - **params: Query parameters supported by the API (e.g., limit, offset). - - Returns: - list[T]: A list of resource objects. - - Raises: - SnipeITException: If the response shape is not as expected. - """ + def list(self, **params: Any) -> list[T]: data = self._get(f"{self.path}", **params) if not isinstance(data, dict): - raise SnipeITException(f"Unexpected response shape for list: expected dict with 'rows', got {type(data).__name__}") + raise SnipeITException( + f"Unexpected response shape for list: expected dict with 'rows', got {type(data).__name__}" + ) rows = data.get("rows") if rows is None: return [] @@ -259,20 +311,12 @@ def list(self, **params: Any) -> List[T]: return [self._make(item) for item in rows] def list_all(self, *, limit: int | None = None, page_size: int = 50, **params: Any) -> Iterable[T]: - """Iterate all items across pages lazily. - - Args: - limit (int | None): Maximum number of items to yield. If None, - yields all items. Defaults to None. - page_size (int): Page size to request from the API. Defaults to 50. - **params: Additional query parameters. - - Yields: - T: Resource objects one by one. - - Raises: - SnipeITException: If the response shape is not as expected. - """ + if "offset" in params: + raise ValueError( + "Do not pass 'offset' as a filter param to list_all() — it controls " + "internal pagination and would break page iteration. " + "Use 'limit' to cap total results." + ) page = 1 yielded = 0 while True: @@ -281,7 +325,9 @@ def list_all(self, *, limit: int | None = None, page_size: int = 50, **params: A **{**params, "limit": page_size, "offset": (page - 1) * page_size}, ) if not isinstance(resp, dict): - raise SnipeITException(f"Unexpected response shape for list_all: expected dict, got {type(resp).__name__}") + raise SnipeITException( + f"Unexpected response shape for list_all: expected dict, got {type(resp).__name__}" + ) rows = resp.get("rows", []) if not isinstance(rows, list): raise SnipeITException("Unexpected response shape: 'rows' must be a list") @@ -298,57 +344,20 @@ def list_all(self, *, limit: int | None = None, page_size: int = 50, **params: A page += 1 def get(self, obj_id: int, **params: Any) -> T: - """Get a single resource by identifier. - - Args: - obj_id (int): Resource identifier. - **params: Additional query parameters. - - Returns: - T: The resource object. - - Raises: - SnipeITException: If the response shape is not as expected. - """ data = self._get(f"{self.path}/{obj_id}", **params) if not isinstance(data, dict): - raise SnipeITException(f"Unexpected response shape for get: expected dict, got {type(data).__name__}") + raise SnipeITException( + f"Unexpected response shape for get: expected dict, got {type(data).__name__}" + ) return self._make(data) def create(self, **data: Any) -> T: - """Create a new resource. - - Args: - **data: Fields for the resource creation request. - - Returns: - T: The created resource object. - """ resp = self._create(f"{self.path}", data) - payload = resp.get("payload", resp) - return self._make(payload) + return self._make(_extract_payload(resp)) def patch(self, obj_id: int, **data: Any) -> T: - """Partially update an existing resource. - - Args: - obj_id (int): Resource identifier. - **data: Fields to update. - - Returns: - T: The updated resource object. - """ resp = self._patch(f"{self.path}/{obj_id}", data) - payload = resp.get("payload", resp) - return self._make(payload) - - def delete(self, obj_id: int) -> None: - """Delete a resource by identifier. + return self._make(_extract_payload(resp)) - Args: - obj_id (int): Resource identifier. - - Returns: - None - """ - self._delete(f"{self.path}/{obj_id}") + def delete(self, obj_id: int) -> dict[str, Any] | None: + return self._delete(f"{self.path}/{obj_id}") diff --git a/snipeit/resources/categories.py b/snipeit/resources/categories.py index 38a7c27..c9f8d5b 100644 --- a/snipeit/resources/categories.py +++ b/snipeit/resources/categories.py @@ -16,7 +16,7 @@ class Category(ApiObject): cat = api.categories.get(1) print(cat) """ - _path = "categories" + _resource_path = "categories" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The category id, name, and type. """ - return f"" + return f"" class CategoriesManager(BaseResourceManager[Category]): @@ -37,7 +37,7 @@ class CategoriesManager(BaseResourceManager[Category]): """ resource_cls = Category - path = Category._path + path = Category._resource_path def create(self, name: str, category_type: str, **kwargs: Any) -> 'Category': """Create a new category. diff --git a/snipeit/resources/companies.py b/snipeit/resources/companies.py index 73cb61f..36f4571 100644 --- a/snipeit/resources/companies.py +++ b/snipeit/resources/companies.py @@ -17,7 +17,7 @@ class Company(ApiObject): print(comp) """ - _path = "companies" + _resource_path = "companies" def __repr__(self) -> str: """Return a concise string representation. @@ -25,7 +25,7 @@ def __repr__(self) -> str: Returns: str: The company id and name. """ - return f"" + return f"" class CompaniesManager(BaseResourceManager[Company]): @@ -38,7 +38,7 @@ class CompaniesManager(BaseResourceManager[Company]): """ resource_cls = Company - path = Company._path + path = Company._resource_path def create(self, name: str, **kwargs: Any) -> "Company": """Create a new company. diff --git a/snipeit/resources/components.py b/snipeit/resources/components.py index 1550f29..3a2675f 100644 --- a/snipeit/resources/components.py +++ b/snipeit/resources/components.py @@ -16,7 +16,7 @@ class Component(ApiObject): comp = api.components.get(1) print(comp) """ - _path = "components" + _resource_path = "components" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The component id, name, and quantity. """ - return f"" + return f"" class ComponentsManager(BaseResourceManager[Component]): @@ -37,7 +37,7 @@ class ComponentsManager(BaseResourceManager[Component]): """ resource_cls = Component - path = Component._path + path = Component._resource_path def create(self, name: str, qty: int, category_id: int, **kwargs: Any) -> 'Component': """Create a new component. diff --git a/snipeit/resources/consumables.py b/snipeit/resources/consumables.py index 16f9ebf..2d463a7 100644 --- a/snipeit/resources/consumables.py +++ b/snipeit/resources/consumables.py @@ -16,7 +16,7 @@ class Consumable(ApiObject): item = api.consumables.get(1) print(item) """ - _path = "consumables" + _resource_path = "consumables" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The consumable id, name, and quantity. """ - return f"" + return f"" class ConsumablesManager(BaseResourceManager[Consumable]): @@ -37,7 +37,7 @@ class ConsumablesManager(BaseResourceManager[Consumable]): """ resource_cls = Consumable - path = Consumable._path + path = Consumable._resource_path def create(self, name: str, qty: int, category_id: int, **kwargs: Any) -> 'Consumable': """Create a new consumable. diff --git a/snipeit/resources/departments.py b/snipeit/resources/departments.py index 8033d71..bfb4f4a 100644 --- a/snipeit/resources/departments.py +++ b/snipeit/resources/departments.py @@ -16,7 +16,7 @@ class Department(ApiObject): dept = api.departments.get(1) print(dept) """ - _path = "departments" + _resource_path = "departments" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The department id and name. """ - return f"" + return f"" class DepartmentsManager(BaseResourceManager[Department]): @@ -37,7 +37,7 @@ class DepartmentsManager(BaseResourceManager[Department]): """ resource_cls = Department - path = Department._path + path = Department._resource_path def create(self, name: str, **kwargs: Any) -> 'Department': """Create a new department. diff --git a/snipeit/resources/fields.py b/snipeit/resources/fields.py index dc41005..15613d9 100644 --- a/snipeit/resources/fields.py +++ b/snipeit/resources/fields.py @@ -16,7 +16,7 @@ class Field(ApiObject): fld = api.fields.get(1) print(fld) """ - _path = "fields" + _resource_path = "fields" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The field id, name, and element. """ - return f"" + return f"" class FieldsManager(BaseResourceManager[Field]): @@ -37,7 +37,7 @@ class FieldsManager(BaseResourceManager[Field]): """ resource_cls = Field - path = Field._path + path = Field._resource_path def create(self, name: str, element: str, **kwargs: Any) -> 'Field': """Create a new custom field. diff --git a/snipeit/resources/fieldsets.py b/snipeit/resources/fieldsets.py index a97b9de..9524df3 100644 --- a/snipeit/resources/fieldsets.py +++ b/snipeit/resources/fieldsets.py @@ -16,7 +16,7 @@ class Fieldset(ApiObject): fs = api.fieldsets.get(1) print(fs) """ - _path = "fieldsets" + _resource_path = "fieldsets" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The fieldset id and name. """ - return f"
" + return f"
" class FieldsetsManager(BaseResourceManager[Fieldset]): @@ -37,7 +37,7 @@ class FieldsetsManager(BaseResourceManager[Fieldset]): """ resource_cls = Fieldset - path = Fieldset._path + path = Fieldset._resource_path def create(self, name: str, **kwargs: Any) -> 'Fieldset': """Create a new fieldset. diff --git a/snipeit/resources/licenses.py b/snipeit/resources/licenses.py index a84329f..4c6542d 100644 --- a/snipeit/resources/licenses.py +++ b/snipeit/resources/licenses.py @@ -16,7 +16,7 @@ class License(ApiObject): lic = api.licenses.get(1) print(lic) """ - _path = "licenses" + _resource_path = "licenses" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The license id, name, and seats. """ - return f"" + return f"" class LicensesManager(BaseResourceManager[License]): @@ -37,7 +37,7 @@ class LicensesManager(BaseResourceManager[License]): """ resource_cls = License - path = License._path + path = License._resource_path def create(self, name: str, seats: int, category_id: int, **kwargs: Any) -> 'License': """Create a new license. diff --git a/snipeit/resources/locations.py b/snipeit/resources/locations.py index 3bd85df..9c95a63 100644 --- a/snipeit/resources/locations.py +++ b/snipeit/resources/locations.py @@ -16,7 +16,7 @@ class Location(ApiObject): loc = api.locations.get(1) print(loc) """ - _path = "locations" + _resource_path = "locations" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The location id and name. """ - return f"" + return f"" class LocationsManager(BaseResourceManager[Location]): @@ -37,7 +37,7 @@ class LocationsManager(BaseResourceManager[Location]): """ resource_cls = Location - path = Location._path + path = Location._resource_path def create(self, name: str, **kwargs: Any) -> 'Location': """Create a new location. diff --git a/snipeit/resources/manufacturers.py b/snipeit/resources/manufacturers.py index e96413a..a56b0f3 100644 --- a/snipeit/resources/manufacturers.py +++ b/snipeit/resources/manufacturers.py @@ -16,7 +16,7 @@ class Manufacturer(ApiObject): m = api.manufacturers.get(1) print(m) """ - _path = "manufacturers" + _resource_path = "manufacturers" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The manufacturer id and name. """ - return f"" + return f"" class ManufacturersManager(BaseResourceManager[Manufacturer]): @@ -37,7 +37,7 @@ class ManufacturersManager(BaseResourceManager[Manufacturer]): """ resource_cls = Manufacturer - path = Manufacturer._path + path = Manufacturer._resource_path def create(self, name: str, **kwargs: Any) -> 'Manufacturer': """Create a new manufacturer. diff --git a/snipeit/resources/models.py b/snipeit/resources/models.py index 64cd768..ba3c03c 100644 --- a/snipeit/resources/models.py +++ b/snipeit/resources/models.py @@ -16,7 +16,7 @@ class Model(ApiObject): mdl = api.models.get(1) print(mdl) """ - _path = "models" + _resource_path = "models" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The model id, name, and model number. """ - return f"" + return f"" class ModelsManager(BaseResourceManager[Model]): @@ -37,7 +37,7 @@ class ModelsManager(BaseResourceManager[Model]): """ resource_cls = Model - path = Model._path + path = Model._resource_path def create(self, name: str, category_id: int, manufacturer_id: int, **kwargs: Any) -> 'Model': """Create a new asset model. diff --git a/snipeit/resources/status_labels.py b/snipeit/resources/status_labels.py index e97ac6a..d3f526f 100644 --- a/snipeit/resources/status_labels.py +++ b/snipeit/resources/status_labels.py @@ -16,7 +16,7 @@ class StatusLabel(ApiObject): sl = api.status_labels.get(1) print(sl) """ - _path = "statuslabels" + _resource_path = "statuslabels" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The status label id, name, and type. """ - return f"" + return f"" class StatusLabelsManager(BaseResourceManager[StatusLabel]): @@ -37,7 +37,7 @@ class StatusLabelsManager(BaseResourceManager[StatusLabel]): """ resource_cls = StatusLabel - path = StatusLabel._path + path = StatusLabel._resource_path def create(self, name: str, type: str, **kwargs: Any) -> 'StatusLabel': """Create a new status label. diff --git a/snipeit/resources/suppliers.py b/snipeit/resources/suppliers.py index 190beab..a2d3a63 100644 --- a/snipeit/resources/suppliers.py +++ b/snipeit/resources/suppliers.py @@ -17,7 +17,7 @@ class Supplier(ApiObject): print(sup) """ - _path = "suppliers" + _resource_path = "suppliers" def __repr__(self) -> str: """Return a concise string representation. @@ -26,7 +26,7 @@ def __repr__(self) -> str: str: The supplier id and name. """ return ( - f"" + f"" ) @@ -40,7 +40,7 @@ class SuppliersManager(BaseResourceManager[Supplier]): """ resource_cls = Supplier - path = Supplier._path + path = Supplier._resource_path def create(self, name: str, **kwargs: Any) -> "Supplier": """Create a new supplier. diff --git a/snipeit/resources/users.py b/snipeit/resources/users.py index 689dcbe..e512653 100644 --- a/snipeit/resources/users.py +++ b/snipeit/resources/users.py @@ -16,7 +16,7 @@ class User(ApiObject): me = api.users.me() print(me) """ - _path = "users" + _resource_path = "users" def __repr__(self) -> str: """Return a concise string representation. @@ -24,7 +24,7 @@ def __repr__(self) -> str: Returns: str: The user id, name, and username. """ - return f"" + return f"" class UsersManager(BaseResourceManager[User]): @@ -37,7 +37,7 @@ class UsersManager(BaseResourceManager[User]): """ resource_cls = User - path = User._path + path = User._resource_path def create(self, username: str, **kwargs: Any) -> 'User': """Create a new user. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index 433aa32..b5ba5f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,22 +5,59 @@ @pytest.fixture def snipeit_client(): - """Provides a SnipeIT client instance for unit tests (mocked).""" - return SnipeIT(url="https://test.snipeitapp.com", token="fake-token") + """Provides a SnipeIT client instance for unit tests (mocked). + + Uses snipe.example.test — an RFC 6761 reserved domain that will never + resolve in DNS, preventing accidental real network calls if a mock is missed. + """ + return SnipeIT(url="https://snipe.example.test", token="fake-token") @pytest.fixture(scope="session") def real_snipeit_client(): """Provides a real SnipeIT client for integration tests. - + Requires environment variables: - SNIPEIT_TEST_URL: The URL of the test SnipeIT instance (e.g., http://localhost:8000) - SNIPEIT_TEST_TOKEN: The API token for the test instance - + Skips integration tests if not set. + + The integration client is configured to retry mutating methods (POST/PATCH/ + PUT/DELETE) on 429 responses. This is safe in the test environment because + Snipe-IT returns 429 *before* processing the request body, so retrying does + not risk double-create or double-mutation. In production code, you should + keep the default ``retry_allowed_methods={"HEAD", "GET", "OPTIONS"}``. + """ + url = os.environ.get("SNIPEIT_TEST_URL") + token = os.environ.get("SNIPEIT_TEST_TOKEN") + if not url or not token: + pytest.skip("SNIPEIT_TEST_URL and SNIPEIT_TEST_TOKEN must be set for integration tests") + client = SnipeIT( + url=url, + token=token, + max_retries=5, + retry_allowed_methods={"HEAD", "GET", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"}, + ) + yield client + client.close() + + +@pytest.fixture(scope="session") +def real_snipeit_client_no_retry(): + """A real SnipeIT client with retries disabled. + + Use this for tests that probe endpoints which may not be available on + every Snipe-IT build (e.g. ``/hardware/labels`` requires the new label + engine). The default ``real_snipeit_client`` retries 5xx on POST up to + 5 times with exponential backoff, which can take ~70s before the + ``pytest.skip(...)`` branch fires — long enough to look like a hang. + With ``max_retries=0`` the failure surfaces immediately. """ url = os.environ.get("SNIPEIT_TEST_URL") token = os.environ.get("SNIPEIT_TEST_TOKEN") if not url or not token: pytest.skip("SNIPEIT_TEST_URL and SNIPEIT_TEST_TOKEN must be set for integration tests") - return SnipeIT(url=url, token=token) + client = SnipeIT(url=url, token=token, max_retries=0) + yield client + client.close() diff --git a/tests/contract/test_public_surface.py b/tests/contract/test_public_surface.py new file mode 100644 index 0000000..70758e2 --- /dev/null +++ b/tests/contract/test_public_surface.py @@ -0,0 +1,177 @@ +"""Contract tests pinning the public surface of the ``snipeit`` package. + +These tests do not exercise runtime behavior. They assert that names, +signatures, and relationships between classes remain stable across +refactors. If one of these tests fails, consider whether the public API +was changed intentionally (and update the test + CHANGELOG) or +unintentionally (fix the code). +""" + +from __future__ import annotations + +import inspect + +import pytest + +pytestmark = pytest.mark.unit + + +def test_top_level_imports() -> None: + from snipeit import ( + SnipeIT, + SnipeITApiError, + SnipeITAuthenticationError, + SnipeITClientError, + SnipeITException, + SnipeITNotFoundError, + SnipeITServerError, + SnipeITTimeoutError, + SnipeITValidationError, + ) + + # Reference the classes to keep linters happy. + for cls in ( + SnipeIT, + SnipeITApiError, + SnipeITAuthenticationError, + SnipeITClientError, + SnipeITException, + SnipeITNotFoundError, + SnipeITServerError, + SnipeITTimeoutError, + SnipeITValidationError, + ): + assert isinstance(cls, type) + + +def test_exception_hierarchy() -> None: + from snipeit import ( + SnipeITApiError, + SnipeITAuthenticationError, + SnipeITClientError, + SnipeITException, + SnipeITNotFoundError, + SnipeITServerError, + SnipeITTimeoutError, + SnipeITValidationError, + ) + + # Base type + assert issubclass(SnipeITException, Exception) + # Timeout is a peer of SnipeITApiError under SnipeITException + assert issubclass(SnipeITTimeoutError, SnipeITException) + assert not issubclass(SnipeITTimeoutError, SnipeITApiError) + # API-layer errors + assert issubclass(SnipeITApiError, SnipeITException) + for sub in ( + SnipeITAuthenticationError, + SnipeITClientError, + SnipeITNotFoundError, + SnipeITServerError, + SnipeITValidationError, + ): + assert issubclass(sub, SnipeITApiError) + + +def test_snipeit_init_signature() -> None: + from snipeit import SnipeIT + + sig = inspect.signature(SnipeIT.__init__) + params = sig.parameters + + # Positional/keyword parameters in the expected order. + assert list(params.keys()) == [ + "self", + "url", + "token", + "timeout", + "max_retries", + "backoff_factor", + "retry_allowed_methods", + ] + + # Defaults must remain stable (bumping these is a breaking change). + assert params["timeout"].default == 10 + assert params["max_retries"].default == 3 + assert params["backoff_factor"].default == 0.3 + assert params["retry_allowed_methods"].default is None + + +EXPECTED_MANAGERS: tuple[str, ...] = ( + "accessories", + "assets", + "categories", + "companies", + "components", + "consumables", + "departments", + "fields", + "fieldsets", + "licenses", + "locations", + "manufacturers", + "models", + "status_labels", + "suppliers", + "users", +) + + +def test_all_expected_managers_present() -> None: + from snipeit import SnipeIT + + client = SnipeIT(url="https://snipe.example.test", token="fake") + for name in EXPECTED_MANAGERS: + mgr = getattr(client, name) + # Common CRUD methods every manager exposes. + for meth in ("list", "list_all", "get", "create", "patch", "delete"): + assert callable(getattr(mgr, meth)), f"{name}.{meth} missing" + + +def test_asset_object_methods() -> None: + from snipeit.resources.assets import Asset + + for meth in ( + "save", + "refresh", + "delete", + "checkout", + "checkin", + "audit", + "restore", + "get_custom_field", + "set_custom_field", + "pending_custom_fields", + ): + assert callable(getattr(Asset, meth)), f"Asset.{meth} missing" + + +def test_assets_manager_extra_methods() -> None: + from snipeit.resources.assets import AssetsManager + + for meth in ( + "get_by_tag", + "get_by_serial", + "audit_by_id", + "list_audit_overdue", + "list_audit_due", + "create_maintenance", + "get_licenses", + "list_files", + "upload_files", + "download_file", + "delete_file", + "labels", + ): + assert callable(getattr(AssetsManager, meth)), f"AssetsManager.{meth} missing" + + +def test_client_context_manager_protocol() -> None: + from snipeit import SnipeIT + + client = SnipeIT(url="https://snipe.example.test", token="fake") + assert hasattr(client, "__enter__") and hasattr(client, "__exit__") + assert hasattr(client, "close") + # get/post/put/patch/delete helpers on the client itself + for meth in ("get", "post", "put", "patch", "delete"): + assert callable(getattr(client, meth)) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a20b0c0..60e4739 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os from pathlib import Path import pytest @@ -16,18 +15,16 @@ def _configure_integration_env(): """Configure environment for integration tests. + Uses pytest.MonkeyPatch so env-var changes are restored after the session, + preventing leakage if unit and integration tests ever run in the same process. + - Sets SNIPEIT_TEST_URL to the local Docker URL. - Reads SNIPEIT_TEST_TOKEN from docker/api_key.txt. - Skips the integration suite if api_key.txt is missing or empty. """ - # Project root = tests/integration/../../ root = Path(__file__).resolve().parents[2] api_key_file = root / "docker" / "api_key.txt" - # URL for local Snipe-IT in docker-compose - os.environ["SNIPEIT_TEST_URL"] = "http://localhost:8000" - - # Ensure API key exists and is non-empty; otherwise skip integration suite if not api_key_file.exists(): pytest.skip( "Integration tests require docker/api_key.txt. " @@ -41,7 +38,10 @@ def _configure_integration_env(): "Run 'make test-integration' to start the local Snipe-IT and generate a token." ) - os.environ["SNIPEIT_TEST_TOKEN"] = token + with pytest.MonkeyPatch.context() as mp: + mp.setenv("SNIPEIT_TEST_URL", "http://localhost:8000") + mp.setenv("SNIPEIT_TEST_TOKEN", token) + yield # --------------------------- diff --git a/tests/integration/resources/test_asset_files_e2e.py b/tests/integration/resources/test_asset_files_e2e.py new file mode 100644 index 0000000..ea8a365 --- /dev/null +++ b/tests/integration/resources/test_asset_files_e2e.py @@ -0,0 +1,138 @@ +"""End-to-end integration test for asset file attachments. + +Uploads a file with non-trivial content, lists files on the asset, streams the +download to disk, byte-compares the result against the original, and finally +deletes the attachment via Snipe-IT's non-standard /delete suffix. + +This test exercises: + +* Multipart upload form encoding (``upload_files``). +* Streaming chunked download (``download_file``) with progress callback. +* The ``/delete`` URL suffix that Snipe-IT requires for file deletion. + +Mocks cannot reproduce real multipart encoding or HTTP/1.1 chunked transfer +edge cases, so this test catches a class of bugs the unit suite cannot. + +Note on file format: Snipe-IT validates uploads against a hardcoded extension +allowlist (``.txt``, ``.pdf``, ``.zip``, ``.xml``, ``.json``, image types, +etc.). We use ``.txt`` with random hex content so the bytes are non-trivial +(64 KiB exercises multi-chunk download) while still passing extension/MIME +validation. +""" +from __future__ import annotations + +import os +import secrets +import uuid +from pathlib import Path + +import pytest + +from snipeit import SnipeIT + +pytestmark = pytest.mark.integration + + +def test_asset_file_upload_download_delete_roundtrip( + real_snipeit_client: SnipeIT, base, run_id: str, _n, id_int, tmp_path: Path +): + c = real_snipeit_client + + # 64 KiB of pseudo-random hex characters (printable ASCII). Larger than a + # single streaming chunk so we exercise the multi-chunk path on download. + # token_hex(N) returns 2N chars, so token_hex(32 * 1024) -> 64 KiB. + payload = secrets.token_hex(32 * 1024).encode("ascii") + assert len(payload) == 64 * 1024 + + src = tmp_path / f"upload-{run_id}.txt" + src.write_bytes(payload) + + asset = c.assets.create( + status_id=id_int(base["status"]["deployable"]), + model_id=id_int(base["model"]), + asset_tag=f"FILE-{run_id}-{uuid.uuid4().hex[:4]}", + name=_n("file-asset", run_id), + ) + asset_id = id_int(asset) + uploaded_file_id: int | None = None + try: + # Upload + upload_resp = c.assets.upload_files(asset_id, [str(src)], notes=f"upload-{run_id}") + assert isinstance(upload_resp, dict) + + # List and locate our newly-uploaded file by name. + files_resp = c.assets.list_files(asset_id) + assert isinstance(files_resp, dict) + # Snipe-IT shapes vary across versions; tolerate either 'rows' or 'files'. + rows = files_resp.get("rows") or files_resp.get("files") or files_resp.get("payload") or [] + if not rows: + pytest.skip( + "list_files returned no rows after a successful upload — " + "Snipe-IT may not expose this endpoint on this version." + ) + + for row in rows: + # Snipe-IT prefixes the stored filename with asset-{id}-{random}-, + # so an exact match won't work. Match by suffix instead — the + # original basename appears at the end of the stored filename. + for key in ("original_name", "name", "filename"): + stored = str(row.get(key, "")) + if stored.endswith(src.name): + uploaded_file_id = int(row["id"]) + break + if uploaded_file_id is not None: + break + + assert uploaded_file_id is not None, ( + f"could not find uploaded file {src.name!r} in list_files response: {rows!r}" + ) + + # Download with progress callback and byte-compare. + dest = tmp_path / f"download-{run_id}.txt" + progress_calls: list[tuple[int, int | None]] = [] + out_path = c.assets.download_file( + asset_id, + uploaded_file_id, + str(dest), + progress=lambda n, t: progress_calls.append((n, t)), + ) + assert out_path == str(dest) + assert dest.exists() + downloaded = dest.read_bytes() + assert downloaded == payload, ( + f"downloaded bytes do not match uploaded payload " + f"(uploaded {len(payload)} bytes, downloaded {len(downloaded)} bytes)" + ) + + # Progress callback should have been invoked at least once and final + # count should equal the payload size. + assert progress_calls, "progress callback was never invoked" + assert progress_calls[-1][0] == len(payload), ( + f"final progress bytes ({progress_calls[-1][0]}) != payload size ({len(payload)})" + ) + + # Delete via /delete suffix endpoint. + c.assets.delete_file(asset_id, uploaded_file_id) + uploaded_file_id = None # mark as cleaned up so the finally block doesn't retry + + # Verify it's gone from the file list. + post_delete = c.assets.list_files(asset_id) + post_rows = post_delete.get("rows") or post_delete.get("files") or post_delete.get("payload") or [] + ids_after = [int(r.get("id", -1)) for r in post_rows] + # The file id should no longer appear. + assert all(i != (uploaded_file_id or -1) for i in ids_after) + finally: + # Best-effort: delete the file if we created it but failed mid-test. + if uploaded_file_id is not None: + try: + c.assets.delete_file(asset_id, uploaded_file_id) + except Exception: + pass + try: + c.assets.delete(asset_id) + except Exception: + pass + try: + os.remove(src) + except OSError: + pass diff --git a/tests/integration/resources/test_asset_restore_e2e.py b/tests/integration/resources/test_asset_restore_e2e.py new file mode 100644 index 0000000..e44a8d6 --- /dev/null +++ b/tests/integration/resources/test_asset_restore_e2e.py @@ -0,0 +1,86 @@ +"""End-to-end integration test for the asset soft-delete + restore lifecycle. + +Snipe-IT uses soft-delete for assets: a DELETE marks the asset as deleted but +does not immediately purge it. ``Asset.restore()`` POSTs to /hardware/{id}/restore +to undelete. This test proves that the full lifecycle round-trips: + + create → delete (soft) → confirm deleted → restore → confirm reachable again + +The library wraps ``Asset.restore`` and the unit suite mocks it, but only an +integration test against real Snipe-IT proves the soft-delete state machine +works as expected end-to-end. +""" +from __future__ import annotations + +import uuid + +import pytest + +from snipeit import SnipeIT +from snipeit.exceptions import SnipeITApiError, SnipeITNotFoundError + +pytestmark = pytest.mark.integration + + +def test_asset_soft_delete_and_restore_lifecycle( + real_snipeit_client: SnipeIT, base, run_id: str, _n, id_int +): + c = real_snipeit_client + + asset = c.assets.create( + status_id=id_int(base["status"]["deployable"]), + model_id=id_int(base["model"]), + asset_tag=f"RST-{run_id}-{uuid.uuid4().hex[:4]}", + name=_n("restore-asset", run_id), + ) + asset_id = id_int(asset) + cleaned_up = False + try: + # Soft-delete. + c.assets.delete(asset_id) + + # Confirm deletion: Snipe-IT may either 404 the asset or return it with + # a deleted_at marker. Both indicate soft-deletion. If it returns 200 + # with no marker, that's a real bug. + is_soft_deleted = False + try: + after = c.assets.get(asset_id) + for marker in ("deleted_at", "deleted", "archived"): + if getattr(after, marker, None): + is_soft_deleted = True + break + except (SnipeITNotFoundError, SnipeITApiError): + is_soft_deleted = True + + assert is_soft_deleted, ( + f"after delete(), asset {asset_id} is neither 404 nor flagged deleted — " + "Snipe-IT did not soft-delete as expected" + ) + + # Restore. The Asset.restore() instance method POSTs /restore and + # refreshes the local object from the server response. + # We need an Asset instance; we can build one without a fresh GET via + # the manager's _make helper, or just re-create one from the original + # asset object (which still has _manager wired). + try: + asset.restore() + except SnipeITNotFoundError: + # Some Snipe-IT versions hard-delete via the API after a brief delay, + # making restore impossible. Skip with a clear reason. + pytest.skip("asset was hard-deleted; restore endpoint is not exercisable here") + except SnipeITApiError as e: + pytest.fail(f"Asset.restore() failed against real Snipe-IT: {e}") + + # Confirm the asset is reachable again with no deleted markers. + restored = c.assets.get(asset_id) + for marker in ("deleted_at", "deleted", "archived"): + assert not getattr(restored, marker, None), ( + f"after restore(), asset still has '{marker}' set — restore did not clear it" + ) + assert id_int(restored) == asset_id + finally: + if not cleaned_up: + try: + c.assets.delete(asset_id) + except Exception: + pass diff --git a/tests/integration/resources/test_assets.py b/tests/integration/resources/test_assets.py index 643c10a..60f1464 100644 --- a/tests/integration/resources/test_assets.py +++ b/tests/integration/resources/test_assets.py @@ -16,7 +16,15 @@ pytestmark = pytest.mark.integration -def test_assets_full_flow(real_snipeit_client: SnipeIT, base, run_id: str, tmp_path: Path, _n, id_int): +def test_assets_full_flow( + real_snipeit_client: SnipeIT, + real_snipeit_client_no_retry: SnipeIT, + base, + run_id: str, + tmp_path: Path, + _n, + id_int, +): c = real_snipeit_client a = c.assets.create( @@ -54,14 +62,19 @@ def test_assets_full_flow(real_snipeit_client: SnipeIT, base, run_id: str, tmp_p # audit a = a.audit(note=f"audit-{run_id}") - # labels to PDF (this endpoint may not be enabled in some Snipe-IT builds) + # labels to PDF (this endpoint may not be enabled in some Snipe-IT + # builds — the new label engine is opt-in). Use a non-retrying client + # so that an unavailable/misconfigured endpoint surfaces immediately + # instead of looping for ~70s through the default retry budget on + # POST + 5xx (max_retries=5, exponential backoff). pdf_path = tmp_path / f"labels-{a.asset_tag}.pdf" try: - saved = c.assets.labels(str(pdf_path), [a.asset_tag]) + saved = real_snipeit_client_no_retry.assets.labels( + str(pdf_path), [a.asset_tag] + ) assert Path(saved).exists() and Path(saved).stat().st_size > 0 - except SnipeITApiError as e: - # Accept error path but assert we captured an error string - assert str(e) + except SnipeITApiError: + pytest.skip("labels endpoint not available on this Snipe-IT instance") # list smoke listed = c.assets.list() diff --git a/tests/integration/resources/test_companies.py b/tests/integration/resources/test_companies.py new file mode 100644 index 0000000..7b01f27 --- /dev/null +++ b/tests/integration/resources/test_companies.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import pytest + +from snipeit import SnipeIT +from snipeit.exceptions import SnipeITNotFoundError, SnipeITApiError + +pytestmark = pytest.mark.integration + + +def test_companies_crud(real_snipeit_client: SnipeIT, run_id: str, _n, id_int): + c = real_snipeit_client + company = c.companies.create(name=_n("company", run_id)) + try: + assert id_int(company) > 0 + + got = c.companies.get(id_int(company)) + assert id_int(got) == id_int(company) + + listed = c.companies.list() + assert any(id_int(x) == id_int(company) for x in listed) + + new_name = _n("company-upd", run_id) + updated = c.companies.patch(id_int(company), name=new_name) + assert updated.name == new_name + + got2 = c.companies.get(id_int(company)) + assert got2.name == new_name + + # save() via ApiObject + got2.name = _n("company-save", run_id) + got2.save() + got3 = c.companies.get(id_int(got2)) + assert got3.name == got2.name + finally: + try: + c.companies.delete(id_int(company)) + except Exception: + pass + + with pytest.raises((SnipeITNotFoundError, SnipeITApiError)): + c.companies.get(id_int(company)) + + with pytest.raises((SnipeITNotFoundError, SnipeITApiError)): + c.companies.get(99999999) diff --git a/tests/integration/resources/test_custom_fields_e2e.py b/tests/integration/resources/test_custom_fields_e2e.py new file mode 100644 index 0000000..58e95ea --- /dev/null +++ b/tests/integration/resources/test_custom_fields_e2e.py @@ -0,0 +1,141 @@ +"""End-to-end integration test for custom fields on assets. + +Exercises the full chain: Field → Fieldset → Model → Asset, then proves that +custom field values can be set and round-trip via the dedicated +``set_custom_field`` / ``pending_custom_fields`` / ``save()`` flow. + +This is the highest-risk code path in the library because: + +* Custom fields use Snipe-IT's column-name convention (_snipeit__). +* PATCH responses return ``custom_fields: null`` and echo column-name keys + at the top level — Asset._apply_server_data must fold them back so a + second set_custom_field on the same in-memory instance still works. +* PATCH semantics for custom fields are version-sensitive. + +If this test passes, the README's "stage and save repeatedly without +refresh()" promise is proven against real Snipe-IT. +""" +from __future__ import annotations + +import uuid + +import pytest + +from snipeit import SnipeIT +from snipeit.exceptions import SnipeITApiError + +pytestmark = pytest.mark.integration + + +def test_custom_fields_end_to_end(real_snipeit_client: SnipeIT, base, run_id: str, _n, id_int): + c = real_snipeit_client + + # 1. Create a custom Field (text element). + field_label = _n("cf-owner", run_id) + fld = c.fields.create(name=field_label, element="text") + fieldset = None + model = None + asset = None + try: + # 2. Create a Fieldset. + fieldset = c.fieldsets.create(name=_n("cf-fieldset", run_id)) + + # 3. Associate the field with the fieldset. + # The library does not wrap /fields/{id}/associate, so use the raw + # client.post() helper as the README documents. + try: + c.post(f"fields/{id_int(fld)}/associate", data={"fieldset_id": id_int(fieldset)}) + except SnipeITApiError as e: + pytest.skip(f"fields/associate not available on this Snipe-IT instance: {e}") + + # 4. Create a Model bound to the fieldset. + model = c.models.create( + name=_n("cf-model", run_id), + category_id=id_int(base["categories"]["asset"]), + manufacturer_id=id_int(base["manufacturer"]), + model_number=f"CF-{run_id}", + fieldset_id=id_int(fieldset), + ) + + # 5. Create an Asset using that model. + asset = c.assets.create( + status_id=id_int(base["status"]["deployable"]), + model_id=id_int(model), + asset_tag=f"CF-{run_id}-{uuid.uuid4().hex[:4]}", + name=_n("cf-asset", run_id), + ) + asset_id = id_int(asset) + + # 6. Refetch the asset and confirm the custom field shows up under its + # display label. + asset = c.assets.get(asset_id) + cfs = getattr(asset, "custom_fields", None) + if not cfs or field_label not in cfs: + pytest.skip( + "custom_fields not present on asset response — Snipe-IT may not " + "expose them on this version, or the fieldset/model wiring did not take." + ) + column_name = cfs[field_label]["field"] + assert column_name.startswith("_snipeit_"), ( + f"expected column name to start with '_snipeit_', got {column_name!r}" + ) + + # 7. Stage and save via the canonical helper. + first_value = "alice" + result = asset.set_custom_field(field_label, first_value) + # Returns self for chaining. + assert result is asset + # Staged in the dedicated channel. + assert asset.pending_custom_fields() == {field_label: first_value} + # Reads still see the server's current (empty/None) value. + assert asset.get_custom_field(field_label) != first_value + asset.save() + # After save: pending cleared, get_custom_field reflects the new value + # via the local nested-shape preservation in Asset._apply_server_data. + assert asset.pending_custom_fields() == {} + assert asset.get_custom_field(field_label) == first_value + + # 8. Verify on the server (independent fetch). + refetched = c.assets.get(asset_id) + assert refetched.get_custom_field(field_label) == first_value + + # 9. Two-cycle regression: a SECOND set_custom_field + save() on the + # SAME in-memory asset (no refresh between) must persist correctly. + # This is the main behaviour the refactor unlocked — Snipe-IT's PATCH + # response sets custom_fields=null, and prior to the refactor that + # would clobber the local nested shape and make this second call + # raise KeyError. + second_value = "bob" + asset.set_custom_field(field_label, second_value).save() + assert asset.get_custom_field(field_label) == second_value + + # 10. Verify the second value persisted on the server. + final = c.assets.get(asset_id) + assert final.get_custom_field(field_label) == second_value + + # 11. Cancellation: setting back to the server's value cancels the stage. + asset.set_custom_field(field_label, "ghost") + assert asset.pending_custom_fields() == {field_label: "ghost"} + asset.set_custom_field(field_label, second_value) # back to server + assert asset.pending_custom_fields() == {} + finally: + # Reverse-order cleanup + if asset is not None: + try: + c.assets.delete(id_int(asset)) + except Exception: + pass + if model is not None: + try: + c.models.delete(id_int(model)) + except Exception: + pass + if fieldset is not None: + try: + c.fieldsets.delete(id_int(fieldset)) + except Exception: + pass + try: + c.fields.delete(id_int(fld)) + except Exception: + pass diff --git a/tests/integration/resources/test_suppliers.py b/tests/integration/resources/test_suppliers.py new file mode 100644 index 0000000..f1c1541 --- /dev/null +++ b/tests/integration/resources/test_suppliers.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import pytest + +from snipeit import SnipeIT +from snipeit.exceptions import SnipeITNotFoundError, SnipeITApiError + +pytestmark = pytest.mark.integration + + +def test_suppliers_crud(real_snipeit_client: SnipeIT, run_id: str, _n, id_int): + c = real_snipeit_client + supplier = c.suppliers.create(name=_n("supplier", run_id)) + try: + assert id_int(supplier) > 0 + + got = c.suppliers.get(id_int(supplier)) + assert id_int(got) == id_int(supplier) + + listed = c.suppliers.list() + assert any(id_int(x) == id_int(supplier) for x in listed) + + new_name = _n("supplier-upd", run_id) + updated = c.suppliers.patch(id_int(supplier), name=new_name) + assert updated.name == new_name + + got2 = c.suppliers.get(id_int(supplier)) + assert got2.name == new_name + + # save() via ApiObject + got2.name = _n("supplier-save", run_id) + got2.save() + got3 = c.suppliers.get(id_int(got2)) + assert got3.name == got2.name + finally: + try: + c.suppliers.delete(id_int(supplier)) + except Exception: + pass + + with pytest.raises((SnipeITNotFoundError, SnipeITApiError)): + c.suppliers.get(id_int(supplier)) + + with pytest.raises((SnipeITNotFoundError, SnipeITApiError)): + c.suppliers.get(99999999) diff --git a/tests/unit/resources/test_accessories.py b/tests/unit/resources/test_accessories.py deleted file mode 100644 index c8c67d8..0000000 --- a/tests/unit/resources/test_accessories.py +++ /dev/null @@ -1,75 +0,0 @@ -import pytest -from snipeit.resources.accessories import Accessory - - -@pytest.mark.unit -def test_list_accessories(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/accessories", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test Accessory"}] - }) - accessories = snipeit_client.accessories.list() - assert len(accessories) == 1 - assert isinstance(accessories[0], Accessory) - assert accessories[0].name == "Test Accessory" - -@pytest.mark.unit -def test_get_accessory(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/accessories/1", json={"id": 1, "name": "Test Accessory"}) - accessory = snipeit_client.accessories.get(1) - assert isinstance(accessory, Accessory) - assert accessory.name == "Test Accessory" - -@pytest.mark.unit -def test_create_accessory(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/accessories", json={"status": "success", "payload": {"id": 2, "name": "New Accessory"}}) - new_accessory = snipeit_client.accessories.create(name="New Accessory", qty=1, category_id=1) - assert isinstance(new_accessory, Accessory) - assert new_accessory.name == "New Accessory" - body = requests_mock.last_request.json() - assert body == {"name": "New Accessory", "qty": 1, "category_id": 1} - - -@pytest.mark.unit -def test_patch_accessory(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/accessories/1", json={"status": "success", "payload": {"id": 1, "name": "Patched Accessory"}}) - patched_accessory = snipeit_client.accessories.patch(1, name="Patched Accessory") - assert isinstance(patched_accessory, Accessory) - assert patched_accessory.name == "Patched Accessory" - -@pytest.mark.unit -def test_delete_accessory(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/accessories/1", json={"status": "success", "messages": "Accessory deleted"}) - snipeit_client.accessories.delete(1) - assert requests_mock.called - -@pytest.mark.unit -def test_save_accessory(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/accessories/1", json={"id": 1, "name": "Test Accessory"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/accessories/1", json={"status": "success", "payload": {"id": 1, "name": "Saved Accessory"}}) - accessory = snipeit_client.accessories.get(1) - accessory.name = "Saved Accessory" - accessory.save() - assert accessory.name == "Saved Accessory" - - -@pytest.mark.unit -def test_accessory_repr(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/accessories/1", - json={"id": 1, "name": "Test Accessory"}, - ) - accessory = snipeit_client.accessories.get(1) - rep = repr(accessory) - assert rep == "" - - -@pytest.mark.unit -def test_checkin_from_user(snipeit_client, requests_mock): - requests_mock.post( - "https://test.snipeitapp.com/api/v1/accessories/42/checkin", - json={"status": "success", "payload": {"checked_in": True}}, - ) - payload = snipeit_client.accessories.checkin_from_user(42) - assert payload == {"checked_in": True} - assert requests_mock.last_request.method == "POST" diff --git a/tests/unit/resources/test_assets.py b/tests/unit/resources/test_assets.py index ccec8b2..a1f014c 100644 --- a/tests/unit/resources/test_assets.py +++ b/tests/unit/resources/test_assets.py @@ -1,261 +1,232 @@ +import json import pytest from snipeit.resources.assets import Asset from snipeit.exceptions import SnipeITNotFoundError +pytestmark = pytest.mark.unit + @pytest.mark.unit -def test_list_assets(snipeit_client, requests_mock): - """Tests that getting a list of assets works correctly.""" - # Mock the API response +def test_list_assets(snipeit_client, httpx_mock): mock_response = { "total": 1, - "rows": [ - { - "id": 1, - "name": "Test Asset", - "asset_tag": "12345", - "serial": "SN123", - "model": {"id": 1, "name": "Test Model"} - } - ] + "rows": [{"id": 1, "name": "Test Asset", "asset_tag": "12345", "serial": "SN123", "model": {"id": 1, "name": "Test Model"}}], } - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware", json=mock_response) - - # Make the API call + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware", json=mock_response) assets = snipeit_client.assets.list() - - # Assertions assert len(assets) == 1 assert isinstance(assets[0], Asset) assert assets[0].id == 1 assert assets[0].name == "Test Asset" - assert requests_mock.call_count == 1 - assert requests_mock.last_request.method == "GET" + assert len(httpx_mock.get_requests()) == 1 + assert httpx_mock.get_requests()[0].method == "GET" @pytest.mark.unit -def test_get_single_asset(snipeit_client, requests_mock): - """Tests that getting a single asset by ID works.""" - mock_response = { - "id": 2, - "name": "Another Asset", - "asset_tag": "67890", - "serial": "SN456", - "model": {"id": 2, "name": "Another Model"} - } - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/2", json=mock_response) - +def test_get_single_asset(snipeit_client, httpx_mock): + mock_response = {"id": 2, "name": "Another Asset", "asset_tag": "67890", "serial": "SN456", "model": {"id": 2, "name": "Another Model"}} + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/2", json=mock_response) asset = snipeit_client.assets.get(2) - assert isinstance(asset, Asset) assert asset.id == 2 assert asset.name == "Another Asset" @pytest.mark.unit -def test_create_asset(snipeit_client, requests_mock): - """Tests creating a new asset.""" - requests_mock.post("https://test.snipeitapp.com/api/v1/hardware", json={"status": "success", "payload": {"id": 3, "name": "New Asset"}}) - - new_asset = snipeit_client.assets.create( - asset_tag="new-tag", - status_id=1, - model_id=1, - name="New Asset" +def test_create_asset(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware", + json={"status": "success", "payload": {"id": 3, "name": "New Asset"}}, ) - + new_asset = snipeit_client.assets.create(asset_tag="new-tag", status_id=1, model_id=1, name="New Asset") assert isinstance(new_asset, Asset) assert new_asset.name == "New Asset" - # Full JSON body should be correct - assert requests_mock.last_request.json() == { - "status_id": 1, - "model_id": 1, - "asset_tag": "new-tag", - "name": "New Asset", - } + body = json.loads(httpx_mock.get_requests()[-1].content) + assert body == {"status_id": 1, "model_id": 1, "asset_tag": "new-tag", "name": "New Asset"} @pytest.mark.unit -def test_save_asset(snipeit_client, requests_mock): - """Tests saving an asset with dirty fields.""" - # Mock the GET and PATCH responses - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/4", json={ - "id": 4, - "name": "Original Name", - "notes": "Original notes", - "asset_tag": "original-tag", - "serial": "SN-ORIGINAL", - "model": {"id": 1, "name": "Test Model"} - }) - requests_mock.patch("https://test.snipeitapp.com/api/v1/hardware/4", json={"status": "success", "payload": {"id": 4, "name": "Updated Name", "notes": "Updated notes"}}) - - # Get the asset +def test_save_asset(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/4", + json={"id": 4, "name": "Original Name", "notes": "Original notes", "asset_tag": "original-tag", "serial": "SN-ORIGINAL", "model": {"id": 1, "name": "Test Model"}}, + ) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/4", + json={"status": "success", "payload": {"id": 4, "name": "Updated Name", "notes": "Updated notes"}}, + ) asset = snipeit_client.assets.get(4) - - # Modify the asset asset.name = "Updated Name" asset.notes = "Updated notes" asset.save() - - # Assertions - assert requests_mock.call_count == 2 - assert requests_mock.last_request.method == "PATCH" - # Check that only the dirty fields were sent - assert requests_mock.last_request.json() == {"name": "Updated Name", "notes": "Updated notes"} - # Check that the object is updated + assert len(httpx_mock.get_requests()) == 2 + assert httpx_mock.get_requests()[-1].method == "PATCH" + body = json.loads(httpx_mock.get_requests()[-1].content) + assert body == {"name": "Updated Name", "notes": "Updated notes"} assert asset.name == "Updated Name" - assert asset.notes == "Updated notes" - # Check that dirty fields are cleared - assert not asset._dirty_fields + assert not asset._dirty_set() @pytest.mark.unit -def test_save_new_attribute(snipeit_client, requests_mock): - """Tests that setting a new attribute marks it as dirty and saves correctly.""" - # Mock the GET and PATCH responses - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/5", json={ - "id": 5, - "name": "Asset without notes", - "asset_tag": "no-notes-tag", - "serial": "SN-NO-NOTES", - "model": {"id": 1, "name": "Test Model"} - }) - requests_mock.patch("https://test.snipeitapp.com/api/v1/hardware/5", json={"status": "success", "payload": {}}) - - # Get the asset +def test_save_new_attribute(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/5", + json={"id": 5, "name": "Asset without notes", "asset_tag": "no-notes-tag", "serial": "SN-NO-NOTES", "model": {"id": 1, "name": "Test Model"}}, + ) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/5", + json={"status": "success", "payload": {}}, + ) asset = snipeit_client.assets.get(5) - - # Set a new attribute that did not exist on the original object asset.notes = "These are new notes" asset.save() - - # Assertions - assert requests_mock.call_count == 2 - assert requests_mock.last_request.method == "PATCH" - # Check that the new field was sent in the request - assert requests_mock.last_request.json() == {"notes": "These are new notes"} + assert len(httpx_mock.get_requests()) == 2 + body = json.loads(httpx_mock.get_requests()[-1].content) + assert body == {"notes": "These are new notes"} @pytest.mark.unit -def test_create_asset_with_auto_increment(snipeit_client, requests_mock): - """Tests creating a new asset with auto-incrementing asset tag.""" - requests_mock.post("https://test.snipeitapp.com/api/v1/hardware", json={"status": "success", "payload": {"id": 4, "name": "Auto-Increment Asset"}}) - - new_asset = snipeit_client.assets.create( - status_id=1, - model_id=1, - name="Auto-Increment Asset" +def test_create_asset_with_auto_increment(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware", + json={"status": "success", "payload": {"id": 4, "name": "Auto-Increment Asset"}}, ) - + new_asset = snipeit_client.assets.create(status_id=1, model_id=1, name="Auto-Increment Asset") assert isinstance(new_asset, Asset) - assert new_asset.name == "Auto-Increment Asset" - assert "asset_tag" not in requests_mock.last_request.json() + body = json.loads(httpx_mock.get_requests()[-1].content) + assert "asset_tag" not in body @pytest.mark.unit -def test_get_by_serial_found(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/byserial/SN123", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test Asset", "serial": "SN123"}] - }) +def test_get_by_serial_found(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/byserial/SN123", + json={"total": 1, "rows": [{"id": 1, "name": "Test Asset", "serial": "SN123"}]}, + ) asset = snipeit_client.assets.get_by_serial("SN123") assert isinstance(asset, Asset) assert asset.serial == "SN123" @pytest.mark.unit -def test_get_by_serial_not_found(snipeit_client, requests_mock): - from snipeit.exceptions import SnipeITNotFoundError - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/byserial/SN456", status_code=404, json={"messages": "Asset not found"}) +def test_get_by_serial_not_found(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/byserial/SN456", + status_code=404, + json={"messages": "Asset not found"}, + ) with pytest.raises(SnipeITNotFoundError): snipeit_client.assets.get_by_serial("SN456") @pytest.mark.unit -def test_get_by_serial_multiple_found(snipeit_client, requests_mock): +def test_get_by_serial_multiple_found(snipeit_client, httpx_mock): from snipeit.exceptions import SnipeITApiError - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/byserial/SN789", json={ - "total": 2, - "rows": [{"id": 1, "name": "Test Asset 1"}, {"id": 2, "name": "Test Asset 2"}] - }) + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/byserial/SN789", + json={"total": 2, "rows": [{"id": 1, "name": "Test Asset 1"}, {"id": 2, "name": "Test Asset 2"}]}, + ) with pytest.raises(SnipeITApiError) as excinfo: snipeit_client.assets.get_by_serial("SN789") - assert str(excinfo.value) == "Expected 1 asset with serial SN789, but found 2." + assert "SN789" in str(excinfo.value) and "2" in str(excinfo.value) @pytest.mark.unit -def test_get_by_tag_found(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/bytag/12345", json={"id": 1, "name": "Test Asset", "asset_tag": "12345"}) +def test_get_by_tag_found(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/bytag/12345", + json={"id": 1, "name": "Test Asset", "asset_tag": "12345"}, + ) asset = snipeit_client.assets.get_by_tag("12345") assert isinstance(asset, Asset) assert asset.asset_tag == "12345" @pytest.mark.unit -def test_get_by_tag_not_found(snipeit_client, requests_mock): - from snipeit.exceptions import SnipeITNotFoundError - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/bytag/67890", status_code=404, json={"messages": "Asset not found"}) +def test_get_by_tag_not_found(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/bytag/67890", + status_code=404, + json={"messages": "Asset not found"}, + ) with pytest.raises(SnipeITNotFoundError): snipeit_client.assets.get_by_tag("67890") @pytest.mark.unit -def test_asset_checkout_to_user(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) - requests_mock.post("https://test.snipeitapp.com/api/v1/hardware/1/checkout", json={"status": "success", "payload": {}}) +def test_asset_checkout_to_user(snipeit_client, httpx_mock): + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) + httpx_mock.add_response(method="POST", url="https://snipe.example.test/api/v1/hardware/1/checkout", json={"status": "success", "payload": {}}) + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) asset = snipeit_client.assets.get(1) - asset.checkout(checkout_to_type='user', assigned_to_id=123) - post_request = requests_mock.request_history[1] - assert post_request.json()["checkout_to_type"] == "user" - assert post_request.json()["assigned_user"] == 123 + asset.checkout(checkout_to_type="user", assigned_to_id=123) + post_body = json.loads(httpx_mock.get_requests()[1].content) + assert post_body["checkout_to_type"] == "user" + assert post_body["assigned_user"] == 123 @pytest.mark.unit -def test_asset_checkout_to_location(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) - requests_mock.post("https://test.snipeitapp.com/api/v1/hardware/1/checkout", json={"status": "success", "payload": {}}) +def test_asset_checkout_to_location(snipeit_client, httpx_mock): + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) + httpx_mock.add_response(method="POST", url="https://snipe.example.test/api/v1/hardware/1/checkout", json={"status": "success", "payload": {}}) + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) asset = snipeit_client.assets.get(1) - asset.checkout(checkout_to_type='location', assigned_to_id=456) - post_request = requests_mock.request_history[1] - assert post_request.json()["checkout_to_type"] == "location" - assert post_request.json()["assigned_location"] == 456 + asset.checkout(checkout_to_type="location", assigned_to_id=456) + post_body = json.loads(httpx_mock.get_requests()[1].content) + assert post_body["checkout_to_type"] == "location" + assert post_body["assigned_location"] == 456 @pytest.mark.unit -def test_asset_checkout_to_asset(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) - requests_mock.post("https://test.snipeitapp.com/api/v1/hardware/1/checkout", json={"status": "success", "payload": {}}) +def test_asset_checkout_to_asset(snipeit_client, httpx_mock): + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) + httpx_mock.add_response(method="POST", url="https://snipe.example.test/api/v1/hardware/1/checkout", json={"status": "success", "payload": {}}) + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) asset = snipeit_client.assets.get(1) - asset.checkout(checkout_to_type='asset', assigned_to_id=789) - post_request = requests_mock.request_history[1] - assert post_request.json()["checkout_to_type"] == "asset" - assert post_request.json()["assigned_asset"] == 789 + asset.checkout(checkout_to_type="asset", assigned_to_id=789) + post_body = json.loads(httpx_mock.get_requests()[1].content) + assert post_body["checkout_to_type"] == "asset" + assert post_body["assigned_asset"] == 789 @pytest.mark.unit -def test_asset_checkin(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) - requests_mock.post("https://test.snipeitapp.com/api/v1/hardware/1/checkin", json={"status": "success", "payload": {}}) +def test_asset_checkin(snipeit_client, httpx_mock): + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) + httpx_mock.add_response(method="POST", url="https://snipe.example.test/api/v1/hardware/1/checkin", json={"status": "success", "payload": {}}) + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) asset = snipeit_client.assets.get(1) asset.checkin(note="Returned") - post_request = requests_mock.request_history[1] - assert post_request.json()["note"] == "Returned" + post_body = json.loads(httpx_mock.get_requests()[1].content) + assert post_body["note"] == "Returned" @pytest.mark.unit -def test_asset_audit(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) - requests_mock.post("https://test.snipeitapp.com/api/v1/hardware/1/audit", json={"status": "success", "payload": {}}) +def test_asset_audit(snipeit_client, httpx_mock): + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) + httpx_mock.add_response(method="POST", url="https://snipe.example.test/api/v1/hardware/1/audit", json={"status": "success", "payload": {}}) + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "name": "Test Asset"}) asset = snipeit_client.assets.get(1) asset.audit(note="Audited") - post_request = requests_mock.request_history[1] - assert post_request.json()["note"] == "Audited" + post_body = json.loads(httpx_mock.get_requests()[1].content) + assert post_body["note"] == "Audited" @pytest.mark.unit -def test_assets_patch(snipeit_client, requests_mock): - requests_mock.patch( - "https://test.snipeitapp.com/api/v1/hardware/1", +def test_assets_patch(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/1", json={"status": "success", "payload": {"id": 1, "name": "Patched"}}, ) patched = snipeit_client.assets.patch(1, name="Patched") @@ -264,48 +235,33 @@ def test_assets_patch(snipeit_client, requests_mock): @pytest.mark.unit -def test_assets_delete(snipeit_client, requests_mock): - requests_mock.delete( - "https://test.snipeitapp.com/api/v1/hardware/1", - status_code=204, - ) +def test_assets_delete(snipeit_client, httpx_mock): + httpx_mock.add_response(method="DELETE", url="https://snipe.example.test/api/v1/hardware/1", status_code=204) snipeit_client.assets.delete(1) - assert requests_mock.called + assert len(httpx_mock.get_requests()) == 1 @pytest.mark.unit -def test_asset_repr_with_defaults(snipeit_client, requests_mock): - # Provide minimal fields to exercise default fallbacks in __repr__ - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/10", - json={"id": 10}, - ) +def test_asset_repr_with_defaults(snipeit_client, httpx_mock): + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/10", json={"id": 10}) asset = snipeit_client.assets.get(10) - rep = repr(asset) - assert rep == "" + assert repr(asset) == "" @pytest.mark.unit -def test_asset_repr_full_fields(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/11", - json={ - "id": 11, - "name": "Foo", - "asset_tag": "12345", - "serial": "ABC", - "model": {"name": "Model"}, - }, +def test_asset_repr_full_fields(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/11", + json={"id": 11, "name": "Foo", "asset_tag": "12345", "serial": "ABC", "model": {"name": "Model"}}, ) asset = snipeit_client.assets.get(11) assert repr(asset) == "" @pytest.mark.unit -def test_asset_checkout_invalid_type_raises_valueerror(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/1", json={"id": 1} - ) +def test_asset_checkout_invalid_type_raises_valueerror(snipeit_client, httpx_mock): + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1}) asset = snipeit_client.assets.get(1) with pytest.raises(ValueError) as excinfo: asset.checkout(checkout_to_type="invalid", assigned_to_id=123) @@ -313,9 +269,10 @@ def test_asset_checkout_invalid_type_raises_valueerror(snipeit_client, requests_ @pytest.mark.unit -def test_get_by_serial_zero_total_raises_not_found(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/byserial/SN000", +def test_get_by_serial_zero_total_raises_not_found(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/byserial/SN000", json={"total": 0, "rows": []}, ) with pytest.raises(SnipeITNotFoundError): @@ -323,11 +280,10 @@ def test_get_by_serial_zero_total_raises_not_found(snipeit_client, requests_mock @pytest.mark.unit -def test_get_by_serial_missing_total_treated_as_not_found(snipeit_client, requests_mock): - from snipeit.exceptions import SnipeITNotFoundError - # API returns rows but omits 'total' key - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/byserial/SN111", +def test_get_by_serial_missing_total_treated_as_not_found(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/byserial/SN111", json={"rows": [{"id": 1, "serial": "SN111"}]}, ) with pytest.raises(SnipeITNotFoundError): @@ -335,12 +291,735 @@ def test_get_by_serial_missing_total_treated_as_not_found(snipeit_client, reques @pytest.mark.unit -def test_create_maintenance_returns_payload(snipeit_client, requests_mock): - requests_mock.post( - "https://test.snipeitapp.com/api/v1/hardware/1/maintenances", +def test_create_maintenance_returns_payload(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware/1/maintenances", json={"status": "success", "payload": {"id": 99, "title": "Tune-up"}}, ) - payload = snipeit_client.assets.create_maintenance( - asset_id=1, asset_improvement="repair", supplier_id=2, title="Tune-up" - ) + payload = snipeit_client.assets.create_maintenance(asset_id=1, asset_improvement="repair", supplier_id=2, title="Tune-up") assert payload == {"id": 99, "title": "Tune-up"} + + +@pytest.mark.unit +def test_asset_checkout_passes_extra_kwargs_to_request(snipeit_client, httpx_mock): + """Extra kwargs like note and expected_checkin must reach the POST body.""" + import json as _json + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1}) + httpx_mock.add_response(method="POST", url="https://snipe.example.test/api/v1/hardware/1/checkout", json={"status": "success", "payload": {}}) + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1}) + asset = snipeit_client.assets.get(1) + asset.checkout( + checkout_to_type="user", + assigned_to_id=5, + note="deploying to alice", + expected_checkin="2026-12-31", + ) + body = _json.loads(httpx_mock.get_requests()[1].content) + assert body["note"] == "deploying to alice" + assert body["expected_checkin"] == "2026-12-31" + assert body["assigned_user"] == 5 + + +# --------------------------------------------------------------------------- +# Custom field helpers: get_custom_field / set_custom_field +# --------------------------------------------------------------------------- + + +def _asset_with_custom_field(snipeit_client, httpx_mock, *, asset_id=20, label="Owner", + column="_snipeit_owner_3", value="bob"): + """Helper: GET an asset that has a single custom field defined.""" + httpx_mock.add_response( + method="GET", + url=f"https://snipe.example.test/api/v1/hardware/{asset_id}", + json={ + "id": asset_id, + "name": "Test Asset", + "asset_tag": f"TAG-{asset_id}", + "custom_fields": { + label: {"field": column, "value": value, "field_format": "ANY"}, + }, + }, + ) + return snipeit_client.assets.get(asset_id) + + +@pytest.mark.unit +def test_get_custom_field_returns_value(snipeit_client, httpx_mock): + asset = _asset_with_custom_field(snipeit_client, httpx_mock) + assert asset.get_custom_field("Owner") == "bob" + + +# ---- pending_custom_fields() accessor (Task 1) ---- + + +@pytest.mark.unit +def test_pending_custom_fields_empty_on_fresh_asset(snipeit_client, httpx_mock): + asset = _asset_with_custom_field(snipeit_client, httpx_mock) + assert asset.pending_custom_fields() == {} + + +@pytest.mark.unit +def test_pending_custom_fields_reflects_internal_state(snipeit_client, httpx_mock): + """Whitebox: directly poking the internal dict should be visible via the accessor.""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock) + asset._pending_custom_fields["Owner"] = "alice" + assert asset.pending_custom_fields() == {"Owner": "alice"} + + +@pytest.mark.unit +def test_pending_custom_fields_returns_defensive_copy(snipeit_client, httpx_mock): + """Mutating the returned dict must not affect internal staging state.""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock) + asset._pending_custom_fields["Owner"] = "alice" + snapshot = asset.pending_custom_fields() + snapshot["Owner"] = "MUTATED" + snapshot["NewLabel"] = "extra" + assert asset._pending_custom_fields == {"Owner": "alice"} + assert asset.pending_custom_fields() == {"Owner": "alice"} + + +# ---- get_custom_field vs pending separation (Task 5) ---- + + +@pytest.mark.unit +def test_get_custom_field_returns_server_value_after_stage(snipeit_client, httpx_mock): + """After set_custom_field, get_custom_field returns the SERVER's value + (not the staged value). pending_custom_fields() is the way to inspect + staged-but-unsaved changes.""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, value="bob") + asset.set_custom_field("Owner", "alice") + assert asset.get_custom_field("Owner") == "bob" # server value, not staged + assert asset.pending_custom_fields() == {"Owner": "alice"} # staged + + +@pytest.mark.unit +def test_get_custom_field_returns_new_server_value_after_save( + snipeit_client, httpx_mock +): + """After save(), the staged value has been persisted; get_custom_field + now reflects it (because Asset._apply_server_data folded the top-level + `_snipeit_*` echo back into the nested shape).""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=300, value="bob") + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/300", + json={ + "status": "success", + "payload": {"id": 300, "custom_fields": None, "_snipeit_owner_3": "alice"}, + }, + ) + asset.set_custom_field("Owner", "alice").save() + assert asset.get_custom_field("Owner") == "alice" + assert asset.pending_custom_fields() == {} + + +# ---- save() override + _pending_custom_fields integration (Task 2) ---- + + +@pytest.mark.unit +def test_save_flushes_pending_custom_field_as_top_level_column_key(snipeit_client, httpx_mock): + """Whitebox-stage a label, then save() — PATCH body must contain the + column name (not the label, not the nested shape).""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=100) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/100", + json={"status": "success", "payload": {"id": 100}}, + ) + asset._pending_custom_fields["Owner"] = "alice" + asset.save() + body = json.loads(httpx_mock.get_requests()[-1].content) + assert body == {"_snipeit_owner_3": "alice"} + + +@pytest.mark.unit +def test_save_combines_regular_field_and_pending_custom_field(snipeit_client, httpx_mock): + """Regular dirty fields and staged custom fields must merge into one PATCH.""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=101) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/101", + json={"status": "success", "payload": {"id": 101}}, + ) + asset.name = "Renamed" + asset._pending_custom_fields["Owner"] = "carol" + asset.save() + body = json.loads(httpx_mock.get_requests()[-1].content) + assert body == {"name": "Renamed", "_snipeit_owner_3": "carol"} + + +@pytest.mark.unit +def test_save_with_only_pending_custom_field_issues_patch(snipeit_client, httpx_mock): + """No regular dirty fields, only a staged custom field — still PATCHes.""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=102) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/102", + json={"status": "success", "payload": {"id": 102}}, + ) + asset._pending_custom_fields["Owner"] = "dave" + asset.save() + patches = [r for r in httpx_mock.get_requests() if r.method == "PATCH"] + assert len(patches) == 1 + assert json.loads(patches[0].content) == {"_snipeit_owner_3": "dave"} + + +@pytest.mark.unit +def test_save_no_op_when_neither_dirty_nor_pending(snipeit_client, httpx_mock): + """save() with nothing dirty and nothing staged issues no request.""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=103) + asset.save() + patches = [r for r in httpx_mock.get_requests() if r.method == "PATCH"] + assert patches == [] + + +@pytest.mark.unit +def test_save_multiple_pending_custom_fields_all_sent(snipeit_client, httpx_mock): + """Two staged labels both translated and sent in the same PATCH.""" + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/104", + json={ + "id": 104, + "custom_fields": { + "Owner": {"field": "_snipeit_owner_3", "value": "", "field_format": "ANY"}, + "Site": {"field": "_snipeit_site_4", "value": "", "field_format": "ANY"}, + }, + }, + ) + asset = snipeit_client.assets.get(104) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/104", + json={"status": "success", "payload": {"id": 104}}, + ) + asset._pending_custom_fields["Owner"] = "alice" + asset._pending_custom_fields["Site"] = "HQ" + asset.save() + body = json.loads(httpx_mock.get_requests()[-1].content) + assert body == {"_snipeit_owner_3": "alice", "_snipeit_site_4": "HQ"} + + +@pytest.mark.unit +def test_save_raises_when_pending_label_is_not_in_custom_fields(snipeit_client, httpx_mock): + """If custom_fields is missing/wiped between staging and save, surface a + clear error rather than silently dropping the change.""" + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/105", + json={"id": 105, "name": "Plain Asset"}, # no custom_fields key + ) + asset = snipeit_client.assets.get(105) + asset._pending_custom_fields["Owner"] = "alice" # whitebox: simulate stale stage + with pytest.raises(RuntimeError, match="custom_fields"): + asset.save() + + +@pytest.mark.unit +def test_save_raises_when_pending_label_entry_malformed(snipeit_client, httpx_mock): + """Malformed entry (missing 'field' key) — error mentions the label.""" + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/106", + json={ + "id": 106, + "custom_fields": {"Owner": {"value": "bob"}}, # no 'field' + }, + ) + asset = snipeit_client.assets.get(106) + asset._pending_custom_fields["Owner"] = "alice" + with pytest.raises(RuntimeError, match="Owner"): + asset.save() + + +# ---- _apply_server_data override + option A (Task 3) ---- + + +@pytest.mark.unit +def test_save_preserves_local_custom_fields_when_payload_returns_null( + snipeit_client, httpx_mock +): + """Snipe-IT's PATCH response has custom_fields=null and echoes column-name + keys at the top level. Asset._apply_server_data must preserve the local + nested shape and refresh values from those top-level keys.""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=200, value="bob") + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/200", + json={ + "status": "success", + "payload": { + "id": 200, + "name": None, + "custom_fields": None, # the quirk + "_snipeit_owner_3": "alice", # top-level echo + }, + }, + ) + asset._pending_custom_fields["Owner"] = "alice" + asset.save() + # Nested shape preserved, value updated from top-level key. + assert isinstance(asset.custom_fields, dict) + assert asset.custom_fields["Owner"]["field"] == "_snipeit_owner_3" + assert asset.custom_fields["Owner"]["value"] == "alice" + # Field format / element fields preserved from the original entry. + assert asset.custom_fields["Owner"].get("field_format") == "ANY" + + +@pytest.mark.unit +def test_save_strips_stray_snipeit_keys_from_payload(snipeit_client, httpx_mock): + """The PATCH response leaks _snipeit_* keys for fieldsets this asset + doesn't use. They should be folded into custom_fields[label]["value"] + where applicable, and otherwise dropped — never written to extras.""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=201) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/201", + json={ + "status": "success", + "payload": { + "id": 201, + "custom_fields": None, + "_snipeit_owner_3": "alice", # this asset's column + "_snipeit_other_99": "STRAY", # not in this asset's fieldset + "_snipeit_yet_another_42": None, # also stray + }, + }, + ) + asset._pending_custom_fields["Owner"] = "alice" + asset.save() + extras = asset.__pydantic_extra__ or {} + # Stray column keys are NOT in extras. + assert "_snipeit_other_99" not in extras + assert "_snipeit_yet_another_42" not in extras + assert "_snipeit_owner_3" not in extras # folded into nested shape + + +@pytest.mark.unit +def test_save_clears_pending_custom_fields(snipeit_client, httpx_mock): + """After a successful save(), _pending_custom_fields must be empty.""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=202) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/202", + json={ + "status": "success", + "payload": {"id": 202, "custom_fields": None, "_snipeit_owner_3": "alice"}, + }, + ) + asset._pending_custom_fields["Owner"] = "alice" + asset.save() + assert asset.pending_custom_fields() == {} + + +@pytest.mark.unit +def test_two_consecutive_saves_without_refresh_succeed(snipeit_client, httpx_mock): + """Regression test for the latent bug: a second set_custom_field + save() + on the same in-memory instance must not require an explicit refresh().""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=203, value="bob") + # First save + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/203", + json={ + "status": "success", + "payload": {"id": 203, "custom_fields": None, "_snipeit_owner_3": "alice"}, + }, + ) + asset._pending_custom_fields["Owner"] = "alice" + asset.save() + assert asset.custom_fields["Owner"]["value"] == "alice" + + # Second save — without refresh() + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/203", + json={ + "status": "success", + "payload": {"id": 203, "custom_fields": None, "_snipeit_owner_3": "carol"}, + }, + ) + asset._pending_custom_fields["Owner"] = "carol" + asset.save() + assert asset.custom_fields["Owner"]["value"] == "carol" + # Two PATCHes were sent, both with the column-name top-level key. + patches = [r for r in httpx_mock.get_requests() if r.method == "PATCH"] + assert len(patches) == 2 + assert json.loads(patches[0].content) == {"_snipeit_owner_3": "alice"} + assert json.loads(patches[1].content) == {"_snipeit_owner_3": "carol"} + + +@pytest.mark.unit +def test_refresh_with_nested_custom_fields_payload_flows_through( + snipeit_client, httpx_mock +): + """A GET response (e.g. via refresh()) that contains the proper nested + custom_fields shape should be applied unchanged — the option-A branch + is for PATCH responses only.""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=204, value="bob") + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/204", + json={ + "id": 204, + "custom_fields": { + "Owner": {"field": "_snipeit_owner_3", "value": "alice", "field_format": "ANY"}, + }, + }, + ) + asset.refresh() + assert asset.custom_fields["Owner"]["value"] == "alice" + assert asset.pending_custom_fields() == {} + + +@pytest.mark.unit +def test_refresh_clears_pending_custom_fields(snipeit_client, httpx_mock): + """Even if a stage was queued, refresh() should clear it (server is + authoritative — the user explicitly asked to refetch).""" + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=205, value="bob") + asset._pending_custom_fields["Owner"] = "alice" + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/205", + json={ + "id": 205, + "custom_fields": { + "Owner": {"field": "_snipeit_owner_3", "value": "bob", "field_format": "ANY"}, + }, + }, + ) + asset.refresh() + assert asset.pending_custom_fields() == {} + + +@pytest.mark.unit +def test_apply_server_data_when_no_existing_custom_fields(snipeit_client, httpx_mock): + """If the asset never had custom_fields and a payload arrives with + custom_fields=null, the option-A branch must skip cleanly (don't try to + "preserve" a non-existent dict).""" + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/206", + json={"id": 206, "name": "Plain"}, + ) + asset = snipeit_client.assets.get(206) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/206", + json={ + "status": "success", + "payload": {"id": 206, "name": "Renamed", "custom_fields": None}, + }, + ) + asset.name = "Renamed" + asset.save() + assert asset.name == "Renamed" + + +@pytest.mark.unit +def test_get_custom_field_returns_default_when_label_missing(snipeit_client, httpx_mock): + asset = _asset_with_custom_field(snipeit_client, httpx_mock) + assert asset.get_custom_field("Nope") is None + assert asset.get_custom_field("Nope", default="fallback") == "fallback" + + +@pytest.mark.unit +def test_get_custom_field_returns_default_when_no_custom_fields(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/21", + json={"id": 21, "name": "Plain Asset"}, + ) + asset = snipeit_client.assets.get(21) + assert asset.get_custom_field("Owner") is None + assert asset.get_custom_field("Owner", default="x") == "x" + + +@pytest.mark.unit +def test_set_custom_field_patches_column_name_only(snipeit_client, httpx_mock): + """set_custom_field + save() must send {column_name: value} as a top-level + PATCH key — not the nested custom_fields shape, not the label. + """ + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=22) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/22", + json={ + "status": "success", + "payload": { + "id": 22, + # Real Snipe-IT shape: custom_fields=null + top-level column key. + "custom_fields": None, + "_snipeit_owner_3": "alice", + }, + }, + ) + + result = asset.set_custom_field("Owner", "alice") + # Returns self for chaining. + assert result is asset + # The label is queued in the dedicated staging channel. + assert asset.pending_custom_fields() == {"Owner": "alice"} + # The regular dirty tracker is NOT involved — neither the column name + # nor the label nor the nested blob appears in _dirty_set(). + assert "_snipeit_owner_3" not in asset._dirty_set() + assert "Owner" not in asset._dirty_set() + assert "custom_fields" not in asset._dirty_set() + # Read returns the server's current value (no read-after-stage mirror). + assert asset.get_custom_field("Owner") == "bob" + + asset.save() + + body = json.loads(httpx_mock.get_requests()[-1].content) + assert body == {"_snipeit_owner_3": "alice"} + # After save: pending cleared, dirty set empty, server value reflected. + assert asset.pending_custom_fields() == {} + assert not asset._dirty_set() + assert asset.get_custom_field("Owner") == "alice" + + +@pytest.mark.unit +def test_set_custom_field_chains_with_save(snipeit_client, httpx_mock): + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=23) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/23", + json={"status": "success", "payload": {"id": 23}}, + ) + asset.set_custom_field("Owner", "carol").save() + body = json.loads(httpx_mock.get_requests()[-1].content) + assert body == {"_snipeit_owner_3": "carol"} + + +@pytest.mark.unit +def test_set_custom_field_combined_with_regular_field(snipeit_client, httpx_mock): + """A custom field and a regular field set in the same save() should both + appear in the PATCH body. + """ + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=24) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/24", + json={"status": "success", "payload": {"id": 24}}, + ) + asset.name = "Renamed" + asset.set_custom_field("Owner", "dave") + asset.save() + body = json.loads(httpx_mock.get_requests()[-1].content) + assert body == {"name": "Renamed", "_snipeit_owner_3": "dave"} + + +@pytest.mark.unit +def test_set_custom_field_unknown_label_raises_keyerror(snipeit_client, httpx_mock): + asset = _asset_with_custom_field(snipeit_client, httpx_mock, asset_id=25) + with pytest.raises(KeyError) as excinfo: + asset.set_custom_field("Unknown", "x") + # Error message lists the label and is informative. + assert "Unknown" in str(excinfo.value) + assert "Owner" in str(excinfo.value) + + +@pytest.mark.unit +def test_set_custom_field_no_custom_fields_raises_keyerror(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/26", + json={"id": 26, "name": "Plain Asset"}, + ) + asset = snipeit_client.assets.get(26) + with pytest.raises(KeyError): + asset.set_custom_field("Owner", "x") + + +@pytest.mark.unit +def test_set_custom_field_malformed_entry_raises_keyerror(snipeit_client, httpx_mock): + """Entry is present but missing the 'field' (column-name) key.""" + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/35", + json={ + "id": 35, + "custom_fields": {"Owner": {"value": "bob"}}, # missing 'field' + }, + ) + asset = snipeit_client.assets.get(35) + with pytest.raises(KeyError) as excinfo: + asset.set_custom_field("Owner", "alice") + assert "Owner" in str(excinfo.value) + assert "unexpected shape" in str(excinfo.value) + + +@pytest.mark.unit +def test_set_custom_field_no_op_when_value_unchanged(snipeit_client, httpx_mock): + """Staging the field to its current value should not queue a PATCH — + matches the no-op behaviour of plain attribute assignment on declared + fields (e.g. ``asset.name = asset.name`` does not mark dirty). + """ + asset = _asset_with_custom_field( + snipeit_client, httpx_mock, asset_id=27, value="bob" + ) + # set to the value that's already there. + asset.set_custom_field("Owner", "bob") + # Nothing queued in either channel. + assert asset.pending_custom_fields() == {} + assert not asset._dirty_set() + # save() is a no-op (no PATCH issued). + asset.save() + patch_requests = [r for r in httpx_mock.get_requests() if r.method == "PATCH"] + assert patch_requests == [] + + +@pytest.mark.unit +def test_set_custom_field_back_to_server_value_cancels_pending( + snipeit_client, httpx_mock +): + """Staging a value, then re-staging the server's current value, cancels + the pending change for that label. No PATCH should be issued on save(). + """ + asset = _asset_with_custom_field( + snipeit_client, httpx_mock, asset_id=28, value="bob" + ) + asset.set_custom_field("Owner", "alice") # queues + assert asset.pending_custom_fields() == {"Owner": "alice"} + asset.set_custom_field("Owner", "bob") # cancels (matches server value) + assert asset.pending_custom_fields() == {} + asset.save() + patches = [r for r in httpx_mock.get_requests() if r.method == "PATCH"] + assert patches == [] + + +@pytest.mark.unit +def test_set_custom_field_twice_before_save_uses_latest_value( + snipeit_client, httpx_mock +): + """Two consecutive set_custom_field calls on the same label before a + single save() should send only the latest value. + """ + asset = _asset_with_custom_field( + snipeit_client, httpx_mock, asset_id=29, value="bob" + ) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/29", + json={"status": "success", "payload": {"id": 29}}, + ) + asset.set_custom_field("Owner", "alice") + asset.set_custom_field("Owner", "carol") + # Pending reflects last-write-wins; reads still see the server value. + assert asset.pending_custom_fields() == {"Owner": "carol"} + assert asset.get_custom_field("Owner") == "bob" + asset.save() + body = json.loads(httpx_mock.get_requests()[-1].content) + assert body == {"_snipeit_owner_3": "carol"} + + +@pytest.mark.unit +def test_set_custom_field_refresh_discards_staged_change(snipeit_client, httpx_mock): + """refresh() should drop staged custom-field changes cleanly, leaving the + object in a clean, non-dirty state with no leftover column-name PATCH. + """ + asset = _asset_with_custom_field( + snipeit_client, httpx_mock, asset_id=30, value="bob" + ) + asset.set_custom_field("Owner", "alice") + assert asset.pending_custom_fields() == {"Owner": "alice"} + + # refresh: server still says "bob". + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/30", + json={ + "id": 30, + "name": "Test Asset", + "asset_tag": "TAG-30", + "custom_fields": { + "Owner": {"field": "_snipeit_owner_3", "value": "bob", "field_format": "ANY"}, + }, + }, + ) + asset.refresh() + + # Staged change is gone. + assert asset.pending_custom_fields() == {} + assert not asset._dirty_set() + assert asset.get_custom_field("Owner") == "bob" + # A subsequent save() must not issue a PATCH. + asset.save() + patch_requests = [r for r in httpx_mock.get_requests() if r.method == "PATCH"] + assert patch_requests == [] + + +@pytest.mark.unit +def test_set_custom_field_does_not_leak_into_pydantic_internals( + snipeit_client, httpx_mock +): + """Regression guard: the new `_pending_custom_fields` channel must not + leak the column name into ``__pydantic_extra__`` or ``__dict__``. + + The whole point of the refactor is that staging is decoupled from + pydantic v2's storage internals — if a staged column name reappears in + either bucket, the dedicated channel has been bypassed. + """ + asset = _asset_with_custom_field( + snipeit_client, httpx_mock, asset_id=31, value="bob" + ) + asset.set_custom_field("Owner", "alice") + # Staging lives ONLY in `_pending_custom_fields`. + assert asset.pending_custom_fields() == {"Owner": "alice"} + extras = asset.__pydantic_extra__ or {} + assert "_snipeit_owner_3" not in extras + assert "_snipeit_owner_3" not in asset.__dict__ + # `Owner` is the staging key, not a pydantic field — must not appear there. + assert "Owner" not in extras + assert "Owner" not in asset.__dict__ + + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/31", + json={ + "status": "success", + "payload": { + "id": 31, + "name": "Test Asset", + "asset_tag": "TAG-31", + # Real Snipe-IT shape: custom_fields=null + top-level column key. + "custom_fields": None, + "_snipeit_owner_3": "alice", + }, + }, + ) + asset.save() + # After save: pending cleared, no leakage, value reflected via the + # nested custom_fields shape (preserved by Asset._apply_server_data). + assert asset.pending_custom_fields() == {} + extras_after = asset.__pydantic_extra__ or {} + assert "_snipeit_owner_3" not in extras_after + assert "_snipeit_owner_3" not in asset.__dict__ + assert asset.custom_fields["Owner"]["value"] == "alice" + + +@pytest.mark.unit +def test_set_custom_field_does_not_touch_extra_dirty_or_snapshot( + snipeit_client, httpx_mock +): + """Regression guard: set_custom_field must not poke `_extra_dirty` or + the loaded-state snapshot. The dedicated `_pending_custom_fields` + channel is the only place staging state lives. + """ + asset = _asset_with_custom_field( + snipeit_client, httpx_mock, asset_id=32, value="bob" + ) + snapshot_before = dict(asset._loaded_state) if asset._loaded_state else {} + extra_dirty_before = set(asset._extra_dirty) + + asset.set_custom_field("Owner", "alice") + + # The dedicated channel got the value. + assert asset.pending_custom_fields() == {"Owner": "alice"} + # `_extra_dirty` is untouched. + assert asset._extra_dirty == extra_dirty_before + # The loaded-state snapshot is untouched (no mirror, no snapshot poke). + assert asset._loaded_state == snapshot_before + # `custom_fields[Owner].value` still reflects the server's "bob". + assert asset.custom_fields["Owner"]["value"] == "bob" diff --git a/tests/unit/resources/test_assets_extra.py b/tests/unit/resources/test_assets_extra.py index f2e50cd..2073246 100644 --- a/tests/unit/resources/test_assets_extra.py +++ b/tests/unit/resources/test_assets_extra.py @@ -1,17 +1,14 @@ import pytest +pytestmark = pytest.mark.unit + @pytest.mark.unit -def test_asset_repr_model_none(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/12", - json={ - "id": 12, - "name": "Foo", - "asset_tag": "T12", - "serial": "S12", - "model": None, - }, +def test_asset_repr_model_none(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/12", + json={"id": 12, "name": "Foo", "asset_tag": "T12", "serial": "S12", "model": None}, ) asset = snipeit_client.assets.get(12) - assert repr(asset) == "" \ No newline at end of file + assert repr(asset) == "" diff --git a/tests/unit/resources/test_assets_labels.py b/tests/unit/resources/test_assets_labels.py index ab71264..3ebf90d 100644 --- a/tests/unit/resources/test_assets_labels.py +++ b/tests/unit/resources/test_assets_labels.py @@ -1,13 +1,17 @@ -import base64 import os import pytest +from snipeit.exceptions import SnipeITApiError + +pytestmark = pytest.mark.unit + @pytest.mark.unit -def test_labels_pdf_content(snipeit_client, requests_mock, tmp_path): +def test_labels_pdf_content(snipeit_client, httpx_mock, tmp_path): pdf_bytes = b"%PDF-1.4\n...binary..." - requests_mock.post( - "https://test.snipeitapp.com/api/v1/hardware/labels", + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware/labels", content=pdf_bytes, headers={"Content-Type": "application/pdf"}, status_code=200, @@ -20,17 +24,91 @@ def test_labels_pdf_content(snipeit_client, requests_mock, tmp_path): @pytest.mark.unit -def test_labels_base64_fallback(snipeit_client, requests_mock, tmp_path): - pdf_bytes = b"%PDF-1.4\nFAKEPDF" - b64 = base64.b64encode(pdf_bytes).decode("ascii") - requests_mock.post( - "https://test.snipeitapp.com/api/v1/hardware/labels", - json={"pdf_base64": b64}, +def test_labels_rejects_non_pdf_content_type(snipeit_client, httpx_mock, tmp_path): + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware/labels", + json={"pdf_base64": "not-supported-anymore"}, headers={"Content-Type": "application/json"}, status_code=200, ) save_path = tmp_path / "labels_from_json.pdf" - result = snipeit_client.assets.labels(str(save_path), ["TAGX"]) - assert result == str(save_path) - with open(save_path, "rb") as f: - assert f.read() == pdf_bytes \ No newline at end of file + with pytest.raises(SnipeITApiError) as excinfo: + snipeit_client.assets.labels(str(save_path), ["TAGX"]) + assert "application/json" in str(excinfo.value) + + +@pytest.mark.unit +def test_labels_sends_exactly_one_accept_header(tmp_path): + """Regression: labels() previously sent duplicate Accept headers.""" + import httpx + from snipeit import SnipeIT + + captured: dict[str, list[str]] = {"accept": []} + + class CaptureTransport(httpx.BaseTransport): + def handle_request(self, request): + captured["accept"] = [ + v.decode() for (k, v) in request.headers.raw if k.lower() == b"accept" + ] + return httpx.Response( + 200, + content=b"%PDF-1.4", + headers={"Content-Type": "application/pdf"}, + ) + + client = SnipeIT(url="https://snipe.example.test", token="t") + client._http = httpx.Client( + base_url="https://snipe.example.test/api/v1/", + headers={"Authorization": "Bearer t", "Accept": "application/json", "User-Agent": "x"}, + transport=CaptureTransport(), + ) + + out = client.assets.labels(str(tmp_path / "x.pdf"), ["TAG1"]) + assert out == str(tmp_path / "x.pdf") + assert captured["accept"] == ["application/pdf"], ( + f"expected a single Accept: application/pdf header, got {captured['accept']!r}" + ) + + +# --------------------------------------------------------------------------- +# Task 14: labels() validation paths +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_labels_empty_list_raises_value_error(snipeit_client, tmp_path): + """labels() with an empty list must raise ValueError before any HTTP call.""" + with pytest.raises(ValueError, match="At least one"): + snipeit_client.assets.labels(str(tmp_path / "out.pdf"), []) + + +@pytest.mark.unit +def test_labels_all_blank_strings_raises_value_error(snipeit_client, tmp_path): + """labels() with only blank/whitespace strings must raise ValueError.""" + with pytest.raises(ValueError, match="No valid asset tags"): + snipeit_client.assets.labels(str(tmp_path / "out.pdf"), ["", " "]) + + +@pytest.mark.unit +def test_labels_with_asset_objects_sends_only_valid_tags(snipeit_client, httpx_mock, tmp_path): + """labels() accepts Asset objects; only assets with a non-None asset_tag are sent.""" + import json as _json + from snipeit.resources.assets import Asset + + class _Mgr: + api = snipeit_client + + # Asset with tag, Asset without tag + a1 = Asset(_Mgr(), {"id": 1, "asset_tag": "TAG-A"}) + a2 = Asset(_Mgr(), {"id": 2}) # no asset_tag + + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware/labels", + content=b"%PDF-1.4", + headers={"Content-Type": "application/pdf"}, + status_code=200, + ) + snipeit_client.assets.labels(str(tmp_path / "out.pdf"), [a1, a2]) + body = _json.loads(httpx_mock.get_requests()[-1].content) + assert body["asset_tags"] == ["TAG-A"], "only the asset with a tag should be sent" diff --git a/tests/unit/resources/test_base.py b/tests/unit/resources/test_base.py index e9e2679..59a60dc 100644 --- a/tests/unit/resources/test_base.py +++ b/tests/unit/resources/test_base.py @@ -1,5 +1,8 @@ import pytest from snipeit.resources.base import ApiObject +from snipeit.exceptions import SnipeITApiError + +pytestmark = pytest.mark.unit class MockManager: @@ -38,13 +41,12 @@ def test_save_object(api_object, mock_manager): api_object.save() assert mock_manager._patched_path == "test_objects/1" assert mock_manager._patched_data == {"name": "Updated Name", "new_field": "New Value"} - # After save, the dirty fields should be cleared - assert not api_object._dirty_fields + # After save, dirty set should be empty + assert not api_object._dirty_set() @pytest.mark.unit def test_repr_uses_id(api_object): - # __repr__ should include the class name and id rep = repr(api_object) assert "ApiObject" in rep assert "1" in rep @@ -52,7 +54,6 @@ def test_repr_uses_id(api_object): @pytest.mark.unit def test_save_no_changes_returns_self_and_no_patch(api_object, mock_manager): - # Saving without modifying fields should be a no-op and return self saved = api_object.save() assert saved is api_object assert mock_manager._patched_path is None @@ -60,7 +61,7 @@ def test_save_no_changes_returns_self_and_no_patch(api_object, mock_manager): @pytest.mark.unit -def test_save_unsuccessful_does_not_clear_dirty_fields(): +def test_save_unsuccessful_raises_and_keeps_dirty_fields(): class FailingManager: def __init__(self): self._patched_path = None @@ -68,14 +69,200 @@ def __init__(self): def _patch(self, path, data): self._patched_path = path self._patched_data = data - return {"status": "error", "payload": {}} + return {"status": "error", "messages": "nope", "payload": {}} mgr = FailingManager() obj = ApiObject(mgr, {"id": 2, "name": "A"}) obj._path = "test_objects" obj.name = "B" # mark dirty - obj.save() - # Path and data used as expected + with pytest.raises(SnipeITApiError): + obj.save() assert mgr._patched_path == "test_objects/2" assert mgr._patched_data == {"name": "B"} # Dirty fields should remain since save was not successful - assert "name" in obj._dirty_fields + assert "name" in obj._dirty_set() + + +@pytest.mark.unit +def test_declared_field_identical_reassignment_preserves_dirty_flag(): + """Regression: a no-op re-assignment must NOT clear a prior genuine change. + + Before the fix, ``asset.name = "B"; asset.name = "B"`` cleared the dirty + bit for declared fields, causing ``save()`` to silently drop the change. + """ + from snipeit.resources.assets import Asset + + class Mgr: + def __init__(self): + self.calls = [] + def _patch(self, path, data): + self.calls.append((path, data)) + return {"status": "success", "payload": data} + + mgr = Mgr() + # 'name' is a DECLARED field on Asset (the bug only affected declared fields) + asset = Asset(mgr, {"id": 1, "name": "OriginalName", "asset_tag": "T1"}) + + # Genuine change marks it dirty. + asset.name = "NewName" + assert "name" in asset._dirty_set() + + # Identical re-assignment must not clear the dirty flag. + asset.name = "NewName" + assert "name" in asset._dirty_set(), ( + "no-op re-assignment cleared dirty bit — save() would drop the change" + ) + + asset.save() + assert mgr.calls == [("hardware/1", {"name": "NewName"})] + + +@pytest.mark.unit +def test_declared_field_identical_to_loaded_value_stays_clean(): + """Complementary regression: if the user sets a field to its loaded value + (no prior change), the field should remain clean.""" + from snipeit.resources.assets import Asset + + class Mgr: + def __init__(self): + self.calls = [] + def _patch(self, path, data): + self.calls.append((path, data)) + return {"status": "success", "payload": data} + + mgr = Mgr() + asset = Asset(mgr, {"id": 1, "name": "loaded", "asset_tag": "T1"}) + asset.name = "loaded" # no actual change + assert "name" not in asset._dirty_set() + asset.save() + assert mgr.calls == [] # nothing to PATCH + + +@pytest.mark.unit +def test_extra_fields_refresh_and_save_use_pydantic_extra_storage(): + from snipeit.resources.assets import Asset + + class Mgr: + def __init__(self): + self.calls = [] + + def _get(self, path): + assert path == "hardware/1" + return {"id": 1, "custom_extra": "fresh"} + + def _patch(self, path, data): + self.calls.append((path, data)) + return {"status": "success", "payload": {"custom_extra": "server"}} + + mgr = Mgr() + asset = Asset(mgr, {"id": 1, "custom_extra": "loaded"}) + + asset.refresh() + assert asset.custom_extra == "fresh" + assert asset.model_dump()["custom_extra"] == "fresh" + + asset.custom_extra = "local" + asset.save() + + assert mgr.calls == [("hardware/1", {"custom_extra": "local"})] + assert asset.custom_extra == "server" + assert asset.model_dump()["custom_extra"] == "server" + + +# --------------------------------------------------------------------------- +# Regression tests for _apply_server_data (Task 17) +# These lock in the pydantic-internals behavior so upgrades fail loudly. +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_apply_server_data_replaces_extra_fields_not_appends(): + """After _apply_server_data, old extra fields are gone and new ones present.""" + mgr = MockManager() + obj = ApiObject(mgr, {"id": 1, "a": 1}) + obj._path = "test_objects" + obj._apply_server_data({"id": 1, "b": 2}) + dump = obj.model_dump() + assert "a" not in dump, "old extra field 'a' should be gone after _apply_server_data" + assert dump.get("b") == 2 + + +@pytest.mark.unit +def test_apply_server_data_clears_dirty_state(): + """After _apply_server_data, the dirty set must be empty.""" + mgr = MockManager() + obj = ApiObject(mgr, {"id": 1, "name": "A"}) + obj._path = "test_objects" + obj.name = "B" # mark dirty + assert "name" in obj._dirty_set() + obj._apply_server_data({"id": 1, "name": "B"}) + assert not obj._dirty_set() + + +@pytest.mark.unit +def test_apply_server_data_handles_declared_and_extra_fields_simultaneously(): + """Mix of declared (id) and extra fields should both be applied correctly.""" + mgr = MockManager() + obj = ApiObject(mgr, {"id": 1}) + obj._path = "test_objects" + obj._apply_server_data({"id": 2, "extra_field": "hello"}) + assert obj.id == 2 + assert obj.model_dump().get("extra_field") == "hello" + + +@pytest.mark.unit +def test_apply_server_data_starts_with_no_extra_dict(): + """Should not crash when __pydantic_extra__ is None (no extras on init).""" + mgr = MockManager() + # ApiObject with only declared fields — __pydantic_extra__ may be None or {} + obj = ApiObject(mgr, {"id": 1}) + obj._path = "test_objects" + # Force __pydantic_extra__ to None to test the None-guard path + object.__setattr__(obj, "__pydantic_extra__", None) + obj._apply_server_data({"id": 1, "new_extra": "value"}) + assert obj.model_dump().get("new_extra") == "value" + + +@pytest.mark.unit +def test_in_place_mutation_of_dict_field_is_detected(): + """Snapshot-and-diff: mutating a nested dict in-place is detected on save.""" + mgr = MockManager() + obj = ApiObject(mgr, {"id": 1, "custom_fields": {"owner": "alice"}}) + obj._path = "test_objects" + obj.custom_fields["owner"] = "bob" # in-place mutation, no setattr + dirty = obj._dirty_set() + assert "custom_fields" in dirty, "in-place dict mutation should be detected via snapshot diff" + + +@pytest.mark.unit +def test_in_place_mutation_of_list_field_is_detected(): + """Snapshot-and-diff: mutating a list in-place is detected on save.""" + mgr = MockManager() + obj = ApiObject(mgr, {"id": 1, "tags": ["a", "b"]}) + obj._path = "test_objects" + obj.tags.append("c") # in-place mutation + dirty = obj._dirty_set() + assert "tags" in dirty, "in-place list mutation should be detected via snapshot diff" + + +@pytest.mark.unit +def test_unchanged_object_after_load_does_not_save(): + """An object loaded from the server with no changes should not PATCH.""" + mgr = MockManager() + obj = ApiObject(mgr, {"id": 1, "name": "unchanged"}) + obj._path = "test_objects" + result = obj.save() + assert result is obj + assert mgr._patched_path is None + + +@pytest.mark.unit +def test_save_refreshes_loaded_state(): + """After save, the snapshot is updated so a second mutation is still detected.""" + mgr = MockManager() + obj = ApiObject(mgr, {"id": 1, "custom_fields": {"x": 1}}) + obj._path = "test_objects" + obj.custom_fields["x"] = 2 + obj.save() # snapshot updated to {"x": 2} + # Now mutate again + obj.custom_fields["x"] = 3 + dirty = obj._dirty_set() + assert "custom_fields" in dirty diff --git a/tests/unit/resources/test_categories.py b/tests/unit/resources/test_categories.py deleted file mode 100644 index 04c8728..0000000 --- a/tests/unit/resources/test_categories.py +++ /dev/null @@ -1,52 +0,0 @@ -import pytest -from snipeit.resources.categories import Category - - -@pytest.mark.unit -def test_list_categories(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/categories", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test Category"}] - }) - categories = snipeit_client.categories.list() - assert len(categories) == 1 - assert isinstance(categories[0], Category) - assert categories[0].name == "Test Category" - -@pytest.mark.unit -def test_get_category(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/categories/1", json={"id": 1, "name": "Test Category"}) - category = snipeit_client.categories.get(1) - assert isinstance(category, Category) - assert category.name == "Test Category" - -@pytest.mark.unit -def test_create_category(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/categories", json={"status": "success", "payload": {"id": 2, "name": "New Category"}}) - new_category = snipeit_client.categories.create(name="New Category", category_type="asset") - assert isinstance(new_category, Category) - assert new_category.name == "New Category" - assert requests_mock.last_request.json() == {"name": "New Category", "category_type": "asset"} - - -@pytest.mark.unit -def test_patch_category(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/categories/1", json={"status": "success", "payload": {"id": 1, "name": "Patched Category"}}) - patched_category = snipeit_client.categories.patch(1, name="Patched Category") - assert isinstance(patched_category, Category) - assert patched_category.name == "Patched Category" - -@pytest.mark.unit -def test_delete_category(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/categories/1", json={"status": "success", "messages": "Category deleted"}) - snipeit_client.categories.delete(1) - assert requests_mock.called - -@pytest.mark.unit -def test_save_category(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/categories/1", json={"id": 1, "name": "Test Category"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/categories/1", json={"status": "success", "payload": {"id": 1, "name": "Saved Category"}}) - category = snipeit_client.categories.get(1) - category.name = "Saved Category" - category.save() - assert category.name == "Saved Category" diff --git a/tests/unit/resources/test_components.py b/tests/unit/resources/test_components.py deleted file mode 100644 index e431416..0000000 --- a/tests/unit/resources/test_components.py +++ /dev/null @@ -1,45 +0,0 @@ -from snipeit.resources.components import Component - - -def test_list_components(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/components", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test Component"}] - }) - components = snipeit_client.components.list() - assert len(components) == 1 - assert isinstance(components[0], Component) - assert components[0].name == "Test Component" - -def test_get_component(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/components/1", json={"id": 1, "name": "Test Component"}) - component = snipeit_client.components.get(1) - assert isinstance(component, Component) - assert component.name == "Test Component" - -def test_create_component(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/components", json={"status": "success", "payload": {"id": 2, "name": "New Component"}}) - new_component = snipeit_client.components.create(name="New Component", qty=1, category_id=1) - assert isinstance(new_component, Component) - assert new_component.name == "New Component" - assert requests_mock.last_request.json() == {"name": "New Component", "qty": 1, "category_id": 1} - - -def test_patch_component(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/components/1", json={"status": "success", "payload": {"id": 1, "name": "Patched Component"}}) - patched_component = snipeit_client.components.patch(1, name="Patched Component") - assert isinstance(patched_component, Component) - assert patched_component.name == "Patched Component" - -def test_delete_component(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/components/1", json={"status": "success", "messages": "Component deleted"}) - snipeit_client.components.delete(1) - assert requests_mock.called - -def test_save_component(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/components/1", json={"id": 1, "name": "Test Component"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/components/1", json={"status": "success", "payload": {"id": 1, "name": "Saved Component"}}) - component = snipeit_client.components.get(1) - component.name = "Saved Component" - component.save() - assert component.name == "Saved Component" \ No newline at end of file diff --git a/tests/unit/resources/test_consumables.py b/tests/unit/resources/test_consumables.py deleted file mode 100644 index 5254bc0..0000000 --- a/tests/unit/resources/test_consumables.py +++ /dev/null @@ -1,52 +0,0 @@ -import pytest -from snipeit.resources.consumables import Consumable - - -@pytest.mark.unit -def test_list_consumables(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/consumables", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test Consumable"}] - }) - consumables = snipeit_client.consumables.list() - assert len(consumables) == 1 - assert isinstance(consumables[0], Consumable) - assert consumables[0].name == "Test Consumable" - -@pytest.mark.unit -def test_get_consumable(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/consumables/1", json={"id": 1, "name": "Test Consumable"}) - consumable = snipeit_client.consumables.get(1) - assert isinstance(consumable, Consumable) - assert consumable.name == "Test Consumable" - -@pytest.mark.unit -def test_create_consumable(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/consumables", json={"status": "success", "payload": {"id": 2, "name": "New Consumable"}}) - new_consumable = snipeit_client.consumables.create(name="New Consumable", qty=1, category_id=1) - assert isinstance(new_consumable, Consumable) - assert new_consumable.name == "New Consumable" - assert requests_mock.last_request.json() == {"name": "New Consumable", "qty": 1, "category_id": 1} - - -@pytest.mark.unit -def test_patch_consumable(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/consumables/1", json={"status": "success", "payload": {"id": 1, "name": "Patched Consumable"}}) - patched_consumable = snipeit_client.consumables.patch(1, name="Patched Consumable") - assert isinstance(patched_consumable, Consumable) - assert patched_consumable.name == "Patched Consumable" - -@pytest.mark.unit -def test_delete_consumable(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/consumables/1", json={"status": "success", "messages": "Consumable deleted"}) - snipeit_client.consumables.delete(1) - assert requests_mock.called - -@pytest.mark.unit -def test_save_consumable(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/consumables/1", json={"id": 1, "name": "Test Consumable"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/consumables/1", json={"status": "success", "payload": {"id": 1, "name": "Saved Consumable"}}) - consumable = snipeit_client.consumables.get(1) - consumable.name = "Saved Consumable" - consumable.save() - assert consumable.name == "Saved Consumable" diff --git a/tests/unit/resources/test_departments.py b/tests/unit/resources/test_departments.py deleted file mode 100644 index f2c4591..0000000 --- a/tests/unit/resources/test_departments.py +++ /dev/null @@ -1,54 +0,0 @@ -import pytest -from snipeit.resources.departments import Department - - -@pytest.mark.unit -def test_list_departments(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/departments", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test Department"}] - }) - departments = snipeit_client.departments.list() - assert len(departments) == 1 - assert isinstance(departments[0], Department) - assert departments[0].name == "Test Department" - -@pytest.mark.unit -def test_get_department(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/departments/1", json={"id": 1, "name": "Test Department"}) - department = snipeit_client.departments.get(1) - assert isinstance(department, Department) - assert department.name == "Test Department" - -@pytest.mark.unit -def test_create_department(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/departments", json={"status": "success", "payload": {"id": 2, "name": "New Department"}}) - new_department = snipeit_client.departments.create(name="New Department") - assert isinstance(new_department, Department) - assert new_department.name == "New Department" - assert requests_mock.last_request.json() == {"name": "New Department"} - - - -@pytest.mark.unit -def test_patch_department(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/departments/1", json={"status": "success", "payload": {"id": 1, "name": "Patched Department"}}) - patched_department = snipeit_client.departments.patch(1, name="Patched Department") - assert isinstance(patched_department, Department) - assert patched_department.name == "Patched Department" - - -@pytest.mark.unit -def test_delete_department(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/departments/1", json={"status": "success", "messages": "Department deleted"}) - snipeit_client.departments.delete(1) - assert requests_mock.called - -@pytest.mark.unit -def test_save_department(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/departments/1", json={"id": 1, "name": "Test Department"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/departments/1", json={"status": "success", "payload": {"id": 1, "name": "Saved Department"}}) - department = snipeit_client.departments.get(1) - department.name = "Saved Department" - department.save() - assert department.name == "Saved Department" diff --git a/tests/unit/resources/test_fields.py b/tests/unit/resources/test_fields.py deleted file mode 100644 index c58d180..0000000 --- a/tests/unit/resources/test_fields.py +++ /dev/null @@ -1,52 +0,0 @@ -import pytest -from snipeit.resources.fields import Field - - -@pytest.mark.unit -def test_list_fields(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/fields", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test Field"}] - }) - fields = snipeit_client.fields.list() - assert len(fields) == 1 - assert isinstance(fields[0], Field) - assert fields[0].name == "Test Field" - -@pytest.mark.unit -def test_get_field(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/fields/1", json={"id": 1, "name": "Test Field"}) - field = snipeit_client.fields.get(1) - assert isinstance(field, Field) - assert field.name == "Test Field" - -@pytest.mark.unit -def test_create_field(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/fields", json={"status": "success", "payload": {"id": 2, "name": "New Field"}}) - new_field = snipeit_client.fields.create(name="New Field", element="text") - assert isinstance(new_field, Field) - assert new_field.name == "New Field" - assert requests_mock.last_request.json() == {"name": "New Field", "element": "text"} - - -@pytest.mark.unit -def test_patch_field(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/fields/1", json={"status": "success", "payload": {"id": 1, "name": "Patched Field"}}) - patched_field = snipeit_client.fields.patch(1, name="Patched Field") - assert isinstance(patched_field, Field) - assert patched_field.name == "Patched Field" - -@pytest.mark.unit -def test_delete_field(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/fields/1", json={"status": "success", "messages": "Field deleted"}) - snipeit_client.fields.delete(1) - assert requests_mock.called - -@pytest.mark.unit -def test_save_field(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/fields/1", json={"id": 1, "name": "Test Field"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/fields/1", json={"status": "success", "payload": {"id": 1, "name": "Saved Field"}}) - field = snipeit_client.fields.get(1) - field.name = "Saved Field" - field.save() - assert field.name == "Saved Field" diff --git a/tests/unit/resources/test_fieldsets.py b/tests/unit/resources/test_fieldsets.py deleted file mode 100644 index 4715d01..0000000 --- a/tests/unit/resources/test_fieldsets.py +++ /dev/null @@ -1,45 +0,0 @@ -from snipeit.resources.fieldsets import Fieldset - - -def test_list_fieldsets(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/fieldsets", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test Fieldset"}] - }) - fieldsets = snipeit_client.fieldsets.list() - assert len(fieldsets) == 1 - assert isinstance(fieldsets[0], Fieldset) - assert fieldsets[0].name == "Test Fieldset" - -def test_get_fieldset(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/fieldsets/1", json={"id": 1, "name": "Test Fieldset"}) - fieldset = snipeit_client.fieldsets.get(1) - assert isinstance(fieldset, Fieldset) - assert fieldset.name == "Test Fieldset" - -def test_create_fieldset(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/fieldsets", json={"status": "success", "payload": {"id": 2, "name": "New Fieldset"}}) - new_fieldset = snipeit_client.fieldsets.create(name="New Fieldset") - assert isinstance(new_fieldset, Fieldset) - assert new_fieldset.name == "New Fieldset" - assert requests_mock.last_request.json() == {"name": "New Fieldset"} - - -def test_patch_fieldset(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/fieldsets/1", json={"status": "success", "payload": {"id": 1, "name": "Patched Fieldset"}}) - patched_fieldset = snipeit_client.fieldsets.patch(1, name="Patched Fieldset") - assert isinstance(patched_fieldset, Fieldset) - assert patched_fieldset.name == "Patched Fieldset" - -def test_delete_fieldset(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/fieldsets/1", json={"status": "success", "messages": "Fieldset deleted"}) - snipeit_client.fieldsets.delete(1) - assert requests_mock.called - -def test_save_fieldset(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/fieldsets/1", json={"id": 1, "name": "Test Fieldset"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/fieldsets/1", json={"status": "success", "payload": {"id": 1, "name": "Saved Fieldset"}}) - fieldset = snipeit_client.fieldsets.get(1) - fieldset.name = "Saved Fieldset" - fieldset.save() - assert fieldset.name == "Saved Fieldset" \ No newline at end of file diff --git a/tests/unit/resources/test_licenses.py b/tests/unit/resources/test_licenses.py deleted file mode 100644 index c634822..0000000 --- a/tests/unit/resources/test_licenses.py +++ /dev/null @@ -1,45 +0,0 @@ -from snipeit.resources.licenses import License - - -def test_list_licenses(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/licenses", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test License"}] - }) - licenses = snipeit_client.licenses.list() - assert len(licenses) == 1 - assert isinstance(licenses[0], License) - assert licenses[0].name == "Test License" - -def test_get_license(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/licenses/1", json={"id": 1, "name": "Test License"}) - license = snipeit_client.licenses.get(1) - assert isinstance(license, License) - assert license.name == "Test License" - -def test_create_license(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/licenses", json={"status": "success", "payload": {"id": 2, "name": "New License"}}) - new_license = snipeit_client.licenses.create(name="New License", seats=10, category_id=1) - assert isinstance(new_license, License) - assert new_license.name == "New License" - assert requests_mock.last_request.json() == {"name": "New License", "seats": 10, "category_id": 1} - - -def test_patch_license(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/licenses/1", json={"status": "success", "payload": {"id": 1, "name": "Patched License"}}) - patched_license = snipeit_client.licenses.patch(1, name="Patched License") - assert isinstance(patched_license, License) - assert patched_license.name == "Patched License" - -def test_delete_license(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/licenses/1", json={"status": "success", "messages": "License deleted"}) - snipeit_client.licenses.delete(1) - assert requests_mock.called - -def test_save_license(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/licenses/1", json={"id": 1, "name": "Test License"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/licenses/1", json={"status": "success", "payload": {"id": 1, "name": "Saved License"}}) - license = snipeit_client.licenses.get(1) - license.name = "Saved License" - license.save() - assert license.name == "Saved License" \ No newline at end of file diff --git a/tests/unit/resources/test_locations.py b/tests/unit/resources/test_locations.py deleted file mode 100644 index 6b919d5..0000000 --- a/tests/unit/resources/test_locations.py +++ /dev/null @@ -1,45 +0,0 @@ -from snipeit.resources.locations import Location - - -def test_list_locations(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/locations", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test Location"}] - }) - locations = snipeit_client.locations.list() - assert len(locations) == 1 - assert isinstance(locations[0], Location) - assert locations[0].name == "Test Location" - -def test_get_location(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/locations/1", json={"id": 1, "name": "Test Location"}) - location = snipeit_client.locations.get(1) - assert isinstance(location, Location) - assert location.name == "Test Location" - -def test_create_location(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/locations", json={"status": "success", "payload": {"id": 2, "name": "New Location"}}) - new_location = snipeit_client.locations.create(name="New Location") - assert isinstance(new_location, Location) - assert new_location.name == "New Location" - assert requests_mock.last_request.json() == {"name": "New Location"} - - -def test_patch_location(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/locations/1", json={"status": "success", "payload": {"id": 1, "name": "Patched Location"}}) - patched_location = snipeit_client.locations.patch(1, name="Patched Location") - assert isinstance(patched_location, Location) - assert patched_location.name == "Patched Location" - -def test_delete_location(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/locations/1", json={"status": "success", "messages": "Location deleted"}) - snipeit_client.locations.delete(1) - assert requests_mock.called - -def test_save_location(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/locations/1", json={"id": 1, "name": "Test Location"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/locations/1", json={"status": "success", "payload": {"id": 1, "name": "Saved Location"}}) - location = snipeit_client.locations.get(1) - location.name = "Saved Location" - location.save() - assert location.name == "Saved Location" \ No newline at end of file diff --git a/tests/unit/resources/test_manufacturers.py b/tests/unit/resources/test_manufacturers.py deleted file mode 100644 index 35a7752..0000000 --- a/tests/unit/resources/test_manufacturers.py +++ /dev/null @@ -1,45 +0,0 @@ -from snipeit.resources.manufacturers import Manufacturer - - -def test_list_manufacturers(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/manufacturers", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test Manufacturer"}] - }) - manufacturers = snipeit_client.manufacturers.list() - assert len(manufacturers) == 1 - assert isinstance(manufacturers[0], Manufacturer) - assert manufacturers[0].name == "Test Manufacturer" - -def test_get_manufacturer(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/manufacturers/1", json={"id": 1, "name": "Test Manufacturer"}) - manufacturer = snipeit_client.manufacturers.get(1) - assert isinstance(manufacturer, Manufacturer) - assert manufacturer.name == "Test Manufacturer" - -def test_create_manufacturer(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/manufacturers", json={"status": "success", "payload": {"id": 2, "name": "New Manufacturer"}}) - new_manufacturer = snipeit_client.manufacturers.create(name="New Manufacturer") - assert isinstance(new_manufacturer, Manufacturer) - assert new_manufacturer.name == "New Manufacturer" - assert requests_mock.last_request.json() == {"name": "New Manufacturer"} - - -def test_patch_manufacturer(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/manufacturers/1", json={"status": "success", "payload": {"id": 1, "name": "Patched Manufacturer"}}) - patched_manufacturer = snipeit_client.manufacturers.patch(1, name="Patched Manufacturer") - assert isinstance(patched_manufacturer, Manufacturer) - assert patched_manufacturer.name == "Patched Manufacturer" - -def test_delete_manufacturer(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/manufacturers/1", json={"status": "success", "messages": "Manufacturer deleted"}) - snipeit_client.manufacturers.delete(1) - assert requests_mock.called - -def test_save_manufacturer(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/manufacturers/1", json={"id": 1, "name": "Test Manufacturer"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/manufacturers/1", json={"status": "success", "payload": {"id": 1, "name": "Saved Manufacturer"}}) - manufacturer = snipeit_client.manufacturers.get(1) - manufacturer.name = "Saved Manufacturer" - manufacturer.save() - assert manufacturer.name == "Saved Manufacturer" \ No newline at end of file diff --git a/tests/unit/resources/test_models.py b/tests/unit/resources/test_models.py deleted file mode 100644 index 4b18e02..0000000 --- a/tests/unit/resources/test_models.py +++ /dev/null @@ -1,45 +0,0 @@ -from snipeit.resources.models import Model - - -def test_list_models(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/models", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test Model"}] - }) - models = snipeit_client.models.list() - assert len(models) == 1 - assert isinstance(models[0], Model) - assert models[0].name == "Test Model" - -def test_get_model(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/models/1", json={"id": 1, "name": "Test Model"}) - model = snipeit_client.models.get(1) - assert isinstance(model, Model) - assert model.name == "Test Model" - -def test_create_model(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/models", json={"status": "success", "payload": {"id": 2, "name": "New Model"}}) - new_model = snipeit_client.models.create(name="New Model", category_id=1, manufacturer_id=1) - assert isinstance(new_model, Model) - assert new_model.name == "New Model" - assert requests_mock.last_request.json() == {"name": "New Model", "category_id": 1, "manufacturer_id": 1} - - -def test_patch_model(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/models/1", json={"status": "success", "payload": {"id": 1, "name": "Patched Model"}}) - patched_model = snipeit_client.models.patch(1, name="Patched Model") - assert isinstance(patched_model, Model) - assert patched_model.name == "Patched Model" - -def test_delete_model(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/models/1", json={"status": "success", "messages": "Model deleted"}) - snipeit_client.models.delete(1) - assert requests_mock.called - -def test_save_model(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/models/1", json={"id": 1, "name": "Test Model"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/models/1", json={"status": "success", "payload": {"id": 1, "name": "Saved Model"}}) - model = snipeit_client.models.get(1) - model.name = "Saved Model" - model.save() - assert model.name == "Saved Model" \ No newline at end of file diff --git a/tests/unit/resources/test_pagination.py b/tests/unit/resources/test_pagination.py index 26b818a..012a41b 100644 --- a/tests/unit/resources/test_pagination.py +++ b/tests/unit/resources/test_pagination.py @@ -1,59 +1,59 @@ import pytest +pytestmark = pytest.mark.unit + @pytest.mark.unit -def test_list_all_paginates_and_yields_all(snipeit_client, requests_mock): - # Page 1 - requests_mock.get( - "https://test.snipeitapp.com/api/v1/users?limit=2&offset=0", - json={ - "total": 3, - "rows": [ - {"id": 1, "name": "A"}, - {"id": 2, "name": "B"}, - ], - }, - complete_qs=True, +def test_list_all_paginates_and_yields_all(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/users?limit=2&offset=0", + json={"total": 3, "rows": [{"id": 1, "name": "A"}, {"id": 2, "name": "B"}]}, ) - # Page 2 - requests_mock.get( - "https://test.snipeitapp.com/api/v1/users?limit=2&offset=2", - json={ - "total": 3, - "rows": [ - {"id": 3, "name": "C"}, - ], - }, - complete_qs=True, + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/users?limit=2&offset=2", + json={"total": 3, "rows": [{"id": 3, "name": "C"}]}, ) - items = list(snipeit_client.users.list_all(page_size=2)) assert [i.id for i in items] == [1, 2, 3] @pytest.mark.unit -def test_list_all_respects_limit(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/users?limit=2&offset=0", - json={ - "total": 3, - "rows": [ - {"id": 1, "name": "A"}, - {"id": 2, "name": "B"}, - ], - }, - complete_qs=True, +def test_list_all_respects_limit(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/users?limit=2&offset=0", + json={"total": 3, "rows": [{"id": 1, "name": "A"}, {"id": 2, "name": "B"}]}, ) - requests_mock.get( - "https://test.snipeitapp.com/api/v1/users?limit=2&offset=2", - json={ - "total": 3, - "rows": [ - {"id": 3, "name": "C"}, - ], - }, - complete_qs=True, + # Page 2 is registered but never fetched because limit=2 stops iteration. + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/users?limit=2&offset=2", + json={"total": 3, "rows": [{"id": 3, "name": "C"}]}, + is_optional=True, ) - items = list(snipeit_client.users.list_all(page_size=2, limit=2)) assert [i.id for i in items] == [1, 2] + + +@pytest.mark.unit +def test_list_all_rejects_offset_in_params(snipeit_client): + with pytest.raises(ValueError, match="offset"): + list(snipeit_client.users.list_all(**{"offset": 5})) + + +@pytest.mark.unit +def test_list_all_terminates_when_rows_empty_and_no_total(snipeit_client, httpx_mock): + """list_all must stop when rows is empty, even if 'total' is absent from the response. + + Some Snipe-IT versions omit 'total' on the last page. The iterator must not + loop forever — it must stop when rows is empty. + """ + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/users?limit=50&offset=0", + json={"rows": []}, # no 'total' key + ) + items = list(snipeit_client.users.list_all()) + assert items == [] diff --git a/tests/unit/resources/test_resources_smoke.py b/tests/unit/resources/test_resources_smoke.py new file mode 100644 index 0000000..5514b00 --- /dev/null +++ b/tests/unit/resources/test_resources_smoke.py @@ -0,0 +1,146 @@ +"""Parametrised CRUD smoke tests for all simple resource managers. + +These tests verify that each manager correctly wires list/get/create/patch/delete/save +to the right HTTP method and URL path, and that the returned objects are the correct type. +They replace 13 near-identical per-resource test files. +""" +import json + +import pytest + +from snipeit.resources.accessories import Accessory +from snipeit.resources.categories import Category +from snipeit.resources.companies import Company +from snipeit.resources.components import Component +from snipeit.resources.consumables import Consumable +from snipeit.resources.departments import Department +from snipeit.resources.fields import Field +from snipeit.resources.fieldsets import Fieldset +from snipeit.resources.licenses import License +from snipeit.resources.locations import Location +from snipeit.resources.manufacturers import Manufacturer +from snipeit.resources.models import Model +from snipeit.resources.status_labels import StatusLabel +from snipeit.resources.suppliers import Supplier +from snipeit.resources.users import User + +pytestmark = pytest.mark.unit + +BASE = "https://snipe.example.test/api/v1" + +# (manager_attr, api_path, resource_cls, create_kwargs) +# api_path is the URL segment used by Snipe-IT (may differ from attr name, e.g. statuslabels). +RESOURCES = [ + ("accessories", "accessories", Accessory, {"name": "x", "qty": 1, "category_id": 1}), + ("categories", "categories", Category, {"name": "x", "category_type": "asset"}), + ("companies", "companies", Company, {"name": "x"}), + ("components", "components", Component, {"name": "x", "qty": 1, "category_id": 1}), + ("consumables", "consumables", Consumable, {"name": "x", "qty": 1, "category_id": 1}), + ("departments", "departments", Department, {"name": "x"}), + ("fields", "fields", Field, {"name": "x", "element": "text"}), + ("fieldsets", "fieldsets", Fieldset, {"name": "x"}), + ("licenses", "licenses", License, {"name": "x", "seats": 1, "category_id": 1}), + ("locations", "locations", Location, {"name": "x"}), + ("manufacturers","manufacturers",Manufacturer, {"name": "x"}), + ("models", "models", Model, {"name": "x", "category_id": 1, "manufacturer_id": 1}), + ("status_labels","statuslabels", StatusLabel, {"name": "x", "type": "deployable"}), + ("suppliers", "suppliers", Supplier, {"name": "x"}), + ("users", "users", User, {"username": "x"}), +] + +IDS = [r[0] for r in RESOURCES] + + +@pytest.mark.parametrize("attr,path,cls,_create_kwargs", RESOURCES, ids=IDS) +def test_list_returns_typed_objects(snipeit_client, httpx_mock, attr, path, cls, _create_kwargs): + httpx_mock.add_response( + method="GET", + url=f"{BASE}/{path}", + json={"total": 1, "rows": [{"id": 1, "name": "item"}]}, + ) + items = getattr(snipeit_client, attr).list() + assert len(items) == 1 + assert isinstance(items[0], cls) + assert items[0].id == 1 + + +@pytest.mark.parametrize("attr,path,cls,_create_kwargs", RESOURCES, ids=IDS) +def test_get_returns_typed_object(snipeit_client, httpx_mock, attr, path, cls, _create_kwargs): + httpx_mock.add_response( + method="GET", + url=f"{BASE}/{path}/1", + json={"id": 1, "name": "item"}, + ) + obj = getattr(snipeit_client, attr).get(1) + assert isinstance(obj, cls) + assert obj.id == 1 + + +@pytest.mark.parametrize("attr,path,cls,create_kwargs", RESOURCES, ids=IDS) +def test_create_sends_correct_body_and_returns_typed_object( + snipeit_client, httpx_mock, attr, path, cls, create_kwargs +): + httpx_mock.add_response( + method="POST", + url=f"{BASE}/{path}", + json={"status": "success", "payload": {"id": 2, **create_kwargs}}, + ) + obj = getattr(snipeit_client, attr).create(**create_kwargs) + assert isinstance(obj, cls) + assert obj.id == 2 + sent = json.loads(httpx_mock.get_requests()[-1].content) + for key, value in create_kwargs.items(): + assert sent[key] == value + + +@pytest.mark.parametrize("attr,path,cls,_create_kwargs", RESOURCES, ids=IDS) +def test_patch_sends_correct_body_and_returns_typed_object( + snipeit_client, httpx_mock, attr, path, cls, _create_kwargs +): + httpx_mock.add_response( + method="PATCH", + url=f"{BASE}/{path}/1", + json={"status": "success", "payload": {"id": 1, "name": "updated"}}, + ) + obj = getattr(snipeit_client, attr).patch(1, name="updated") + assert isinstance(obj, cls) + assert obj.name == "updated" + sent = json.loads(httpx_mock.get_requests()[-1].content) + assert sent["name"] == "updated" + + +@pytest.mark.parametrize("attr,path,cls,_create_kwargs", RESOURCES, ids=IDS) +def test_delete_sends_delete_request(snipeit_client, httpx_mock, attr, path, cls, _create_kwargs): + httpx_mock.add_response( + method="DELETE", + url=f"{BASE}/{path}/1", + json={"status": "success", "messages": "deleted"}, + ) + getattr(snipeit_client, attr).delete(1) + assert httpx_mock.get_requests()[-1].method == "DELETE" + + +@pytest.mark.parametrize("attr,path,cls,_create_kwargs", RESOURCES, ids=IDS) +def test_save_patches_only_changed_fields(snipeit_client, httpx_mock, attr, path, cls, _create_kwargs): + """Mutating a field on a fetched object and calling save() sends only that field via PATCH.""" + httpx_mock.add_response( + method="GET", + url=f"{BASE}/{path}/1", + json={"id": 1, "name": "original"}, + ) + httpx_mock.add_response( + method="PATCH", + url=f"{BASE}/{path}/1", + json={"status": "success", "payload": {"id": 1, "name": "changed"}}, + ) + obj = getattr(snipeit_client, attr).get(1) + obj.name = "changed" + obj.save() + + patch_req = httpx_mock.get_requests()[-1] + assert patch_req.method == "PATCH" + body = json.loads(patch_req.content) + assert body == {"name": "changed"} + assert obj.name == "changed" + # After save the object is clean — no pending dirty fields. + assert not obj._dirty_set() diff --git a/tests/unit/resources/test_resources_specific.py b/tests/unit/resources/test_resources_specific.py new file mode 100644 index 0000000..7bfd15d --- /dev/null +++ b/tests/unit/resources/test_resources_specific.py @@ -0,0 +1,85 @@ +"""Resource-specific behavioural tests. + +These cover behaviour that is unique to a particular resource and cannot be +expressed in the generic CRUD smoke tests (test_resources_smoke.py). +""" +import json + +import pytest + +pytestmark = pytest.mark.unit + +BASE = "https://snipe.example.test/api/v1" + + +# --------------------------------------------------------------------------- +# UsersManager.me() — unique endpoint not shared by any other manager +# --------------------------------------------------------------------------- + +def test_users_me_hits_users_me_endpoint(snipeit_client, httpx_mock): + """me() must GET /users/me and return a User object for the token owner.""" + from snipeit.resources.users import User + + httpx_mock.add_response( + method="GET", + url=f"{BASE}/users/me", + json={"id": 7, "username": "admin", "name": "Admin User"}, + ) + me = snipeit_client.users.me() + assert isinstance(me, User) + assert me.id == 7 + assert me.username == "admin" + + +# --------------------------------------------------------------------------- +# AccessoriesManager.checkin_from_user() — unique endpoint +# --------------------------------------------------------------------------- + +def test_accessories_checkin_from_user_posts_to_correct_url(snipeit_client, httpx_mock): + """checkin_from_user(id) must POST to /accessories/{id}/checkin and return the payload.""" + httpx_mock.add_response( + method="POST", + url=f"{BASE}/accessories/42/checkin", + json={"status": "success", "payload": {"checked_in": True}}, + ) + result = snipeit_client.accessories.checkin_from_user(42) + assert result == {"checked_in": True} + req = httpx_mock.get_requests()[-1] + assert req.method == "POST" + assert "/accessories/42/checkin" in str(req.url) + + +# --------------------------------------------------------------------------- +# CategoriesManager.create() — requires category_type +# --------------------------------------------------------------------------- + +def test_categories_create_sends_category_type(snipeit_client, httpx_mock): + """create() must include category_type in the request body — it is required by the API.""" + httpx_mock.add_response( + method="POST", + url=f"{BASE}/categories", + json={"status": "success", "payload": {"id": 3, "name": "Printers", "category_type": "asset"}}, + ) + cat = snipeit_client.categories.create(name="Printers", category_type="asset") + body = json.loads(httpx_mock.get_requests()[-1].content) + assert body["category_type"] == "asset" + assert body["name"] == "Printers" + assert cat.id == 3 + + +# --------------------------------------------------------------------------- +# StatusLabelsManager — path is 'statuslabels', not 'status_labels' +# --------------------------------------------------------------------------- + +def test_status_labels_uses_statuslabels_api_path(snipeit_client, httpx_mock): + """The Snipe-IT API path for status labels is 'statuslabels' (no underscore). + A wrong path would cause 404s in production.""" + httpx_mock.add_response( + method="GET", + url=f"{BASE}/statuslabels/1", + json={"id": 1, "name": "Deployable", "type": "deployable"}, + ) + label = snipeit_client.status_labels.get(1) + assert label.id == 1 + req = httpx_mock.get_requests()[-1] + assert "/statuslabels/1" in str(req.url) diff --git a/tests/unit/resources/test_shape_validation.py b/tests/unit/resources/test_shape_validation.py index b989a7f..b150ce4 100644 --- a/tests/unit/resources/test_shape_validation.py +++ b/tests/unit/resources/test_shape_validation.py @@ -1,12 +1,14 @@ import pytest from snipeit.exceptions import SnipeITException +pytestmark = pytest.mark.unit + @pytest.mark.unit -def test_list_non_dict_response_raises(snipeit_client, requests_mock): - # JSON-valid string; client will parse JSON successfully into a str - requests_mock.get( - "https://test.snipeitapp.com/api/v1/users", +def test_list_non_dict_response_raises(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/users", json="not-a-dict", status_code=200, ) @@ -16,9 +18,10 @@ def test_list_non_dict_response_raises(snipeit_client, requests_mock): @pytest.mark.unit -def test_list_rows_not_list_raises(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/users", +def test_list_rows_not_list_raises(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/users", json={"rows": {}}, status_code=200, ) @@ -28,12 +31,13 @@ def test_list_rows_not_list_raises(snipeit_client, requests_mock): @pytest.mark.unit -def test_get_non_dict_response_raises(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/users/1", +def test_get_non_dict_response_raises(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/users/1", json=[{"id": 1}], status_code=200, ) with pytest.raises(SnipeITException) as excinfo: snipeit_client.users.get(1) - assert "Unexpected response shape for get" in str(excinfo.value) \ No newline at end of file + assert "Unexpected response shape for get" in str(excinfo.value) diff --git a/tests/unit/resources/test_status_labels.py b/tests/unit/resources/test_status_labels.py deleted file mode 100644 index ea772f6..0000000 --- a/tests/unit/resources/test_status_labels.py +++ /dev/null @@ -1,45 +0,0 @@ -from snipeit.resources.status_labels import StatusLabel - - -def test_list_status_labels(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/statuslabels", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test StatusLabel"}] - }) - status_labels = snipeit_client.status_labels.list() - assert len(status_labels) == 1 - assert isinstance(status_labels[0], StatusLabel) - assert status_labels[0].name == "Test StatusLabel" - -def test_get_status_label(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/statuslabels/1", json={"id": 1, "name": "Test StatusLabel"}) - status_label = snipeit_client.status_labels.get(1) - assert isinstance(status_label, StatusLabel) - assert status_label.name == "Test StatusLabel" - -def test_create_status_label(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/statuslabels", json={"status": "success", "payload": {"id": 2, "name": "New StatusLabel"}}) - new_status_label = snipeit_client.status_labels.create(name="New StatusLabel", type="deployable") - assert isinstance(new_status_label, StatusLabel) - assert new_status_label.name == "New StatusLabel" - assert requests_mock.last_request.json()["name"] == "New StatusLabel" - - -def test_patch_status_label(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/statuslabels/1", json={"status": "success", "payload": {"id": 1, "name": "Patched StatusLabel"}}) - patched_status_label = snipeit_client.status_labels.patch(1, name="Patched StatusLabel") - assert isinstance(patched_status_label, StatusLabel) - assert patched_status_label.name == "Patched StatusLabel" - -def test_delete_status_label(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/statuslabels/1", json={"status": "success", "messages": "StatusLabel deleted"}) - snipeit_client.status_labels.delete(1) - assert requests_mock.called - -def test_save_status_label(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/statuslabels/1", json={"id": 1, "name": "Test StatusLabel"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/statuslabels/1", json={"status": "success", "payload": {"id": 1, "name": "Saved StatusLabel"}}) - status_label = snipeit_client.status_labels.get(1) - status_label.name = "Saved StatusLabel" - status_label.save() - assert status_label.name == "Saved StatusLabel" \ No newline at end of file diff --git a/tests/unit/resources/test_users.py b/tests/unit/resources/test_users.py deleted file mode 100644 index 94278d6..0000000 --- a/tests/unit/resources/test_users.py +++ /dev/null @@ -1,51 +0,0 @@ -from snipeit.resources.users import User - - -def test_list_users(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/users", json={ - "total": 1, - "rows": [{"id": 1, "name": "Test User"}] - }) - users = snipeit_client.users.list() - assert len(users) == 1 - assert isinstance(users[0], User) - assert users[0].name == "Test User" - -def test_get_user(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/users/1", json={"id": 1, "name": "Test User"}) - user = snipeit_client.users.get(1) - assert isinstance(user, User) - assert user.name == "Test User" - -def test_create_user(snipeit_client, requests_mock): - requests_mock.post("https://test.snipeitapp.com/api/v1/users", json={"status": "success", "payload": {"id": 2, "name": "New User"}}) - new_user = snipeit_client.users.create(username="newuser") - assert isinstance(new_user, User) - assert new_user.name == "New User" - assert requests_mock.last_request.json() == {"username": "newuser"} - - -def test_patch_user(snipeit_client, requests_mock): - requests_mock.patch("https://test.snipeitapp.com/api/v1/users/1", json={"status": "success", "payload": {"id": 1, "name": "Patched User"}}) - patched_user = snipeit_client.users.patch(1, name="Patched User") - assert isinstance(patched_user, User) - assert patched_user.name == "Patched User" - -def test_delete_user(snipeit_client, requests_mock): - requests_mock.delete("https://test.snipeitapp.com/api/v1/users/1", json={"status": "success", "messages": "User deleted"}) - snipeit_client.users.delete(1) - assert requests_mock.called - -def test_save_user(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/users/1", json={"id": 1, "name": "Test User"}) - requests_mock.patch("https://test.snipeitapp.com/api/v1/users/1", json={"status": "success", "payload": {"id": 1, "name": "Saved User"}}) - user = snipeit_client.users.get(1) - user.name = "Saved User" - user.save() - assert user.name == "Saved User" - -def test_get_current_user(snipeit_client, requests_mock): - requests_mock.get("https://test.snipeitapp.com/api/v1/users/me", json={"id": 1, "name": "Current User"}) - me = snipeit_client.users.me() - assert isinstance(me, User) - assert me.name == "Current User" \ No newline at end of file diff --git a/tests/unit/test_assets_endpoints.py b/tests/unit/test_assets_endpoints.py index 23b1477..e702731 100644 --- a/tests/unit/test_assets_endpoints.py +++ b/tests/unit/test_assets_endpoints.py @@ -1,156 +1,185 @@ -import base64 import pytest - - -@pytest.mark.unit -def test_labels_decodes_base64_and_writes_file(snipeit_client, requests_mock, tmp_path): +pytestmark = pytest.mark.unit +def test_labels_writes_pdf_bytes_directly(snipeit_client, httpx_mock, tmp_path): pdf_bytes = b"%PDF-1.4 test" - b64 = base64.b64encode(pdf_bytes).decode() - requests_mock.post( - "https://test.snipeitapp.com/api/v1/hardware/labels", - json={"status": "success", "payload": {"file_type": "application/pdf", "file_contents": b64}}, + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware/labels", + content=pdf_bytes, + headers={"Content-Type": "application/pdf"}, status_code=200, ) - save_path = tmp_path / "labels.pdf" - out = snipeit_client.assets.labels(str(save_path), ["TAG1"]) # type: ignore[arg-type] + out = snipeit_client.assets.labels(str(save_path), ["TAG1"]) assert out == str(save_path) assert save_path.read_bytes() == pdf_bytes @pytest.mark.unit -def test_audit_by_id_and_asset_audit(snipeit_client, requests_mock): - # Manager helper - requests_mock.post( - "https://test.snipeitapp.com/api/v1/hardware/audit/1", - json={"status": "success"}, - status_code=200, - ) +def test_audit_by_id_and_asset_audit(snipeit_client, httpx_mock): + httpx_mock.add_response(method="POST", url="https://snipe.example.test/api/v1/hardware/audit/1", json={"status": "success"}) resp = snipeit_client.assets.audit_by_id(1, note="checked") assert isinstance(resp, dict) - # Asset instance method with refresh - # Mock GET for refresh and POST for audit-by-id path - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/1", - json={"id": 1, "asset_tag": "A1"}, - status_code=200, - ) - requests_mock.post( - "https://test.snipeitapp.com/api/v1/hardware/1/audit", - json={"status": "success"}, - status_code=200, - ) + httpx_mock.add_response(method="POST", url="https://snipe.example.test/api/v1/hardware/1/audit", json={"status": "success"}) + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "asset_tag": "A1"}) asset = snipeit_client.assets._make({"id": 1, "asset_tag": "A1"}) asset.audit(note="checked") @pytest.mark.unit -def test_audit_overdue_and_due_lists(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/audit/overdue", - json={"status": "success", "data": []}, - status_code=200, - ) - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/audit/due", - json={"status": "success", "data": []}, - status_code=200, - ) +def test_audit_overdue_and_due_lists(snipeit_client, httpx_mock): + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/audit/overdue", json={"status": "success", "data": []}) + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/audit/due", json={"status": "success", "data": []}) assert snipeit_client.assets.list_audit_overdue()["status"] == "success" assert snipeit_client.assets.list_audit_due()["status"] == "success" @pytest.mark.unit -def test_restore(snipeit_client, requests_mock): - requests_mock.post( - "https://test.snipeitapp.com/api/v1/hardware/1/restore", - json={"status": "success"}, - status_code=200, - ) - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/1", - json={"id": 1, "asset_tag": "A1"}, - status_code=200, - ) +def test_restore(snipeit_client, httpx_mock): + httpx_mock.add_response(method="POST", url="https://snipe.example.test/api/v1/hardware/1/restore", json={"status": "success"}) + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1", json={"id": 1, "asset_tag": "A1"}) asset = snipeit_client.assets._make({"id": 1, "asset_tag": "A1"}) out = asset.restore() assert out.id == 1 @pytest.mark.unit -def test_licenses_and_files_endpoints(snipeit_client, requests_mock, tmp_path): - # Licenses - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/1/licenses", - json={"status": "success", "data": []}, - status_code=200, - ) +def test_licenses_and_files_endpoints(snipeit_client, httpx_mock, tmp_path): + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1/licenses", json={"status": "success", "data": []}) data = snipeit_client.assets.get_licenses(1) assert data["status"] == "success" - # Files - list - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/1/files", - json={"status": "success", "files": []}, - status_code=200, - ) + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1/files", json={"status": "success", "files": []}) files_list = snipeit_client.assets.list_files(1) assert files_list["status"] == "success" - # Files - upload (multipart) f = tmp_path / "hello.txt" f.write_text("hello") - requests_mock.post( - "https://test.snipeitapp.com/api/v1/hardware/1/files", + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware/1/files", json={"file": {"original_name": "hello.txt", "name": "hello.txt"}}, status_code=200, ) up = snipeit_client.assets.upload_files(1, [str(f)], notes="Test") assert up["file"]["original_name"] == "hello.txt" - assert requests_mock.last_request.headers["Content-Type"].startswith( - "multipart/form-data; boundary=" - ) - assert ( - snipeit_client.session.headers["Content-Type"] == "application/json" - ) + upload_req = httpx_mock.get_requests()[-1] + assert "multipart/form-data" in upload_req.headers["Content-Type"] - # Files - download dest = tmp_path / "dl.txt" - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/1/files/2", - content=b"data", - status_code=200, - ) + httpx_mock.add_response(method="GET", url="https://snipe.example.test/api/v1/hardware/1/files/2", content=b"data") out_path = snipeit_client.assets.download_file(1, 2, str(dest)) assert out_path == str(dest) assert dest.read_bytes() == b"data" - # Files - delete - requests_mock.delete( - "https://test.snipeitapp.com/api/v1/hardware/1/files/2/delete", - status_code=204, - ) + httpx_mock.add_response(method="DELETE", url="https://snipe.example.test/api/v1/hardware/1/files/2/delete", status_code=204) snipeit_client.assets.delete_file(1, 2) + + + +# --------------------------------------------------------------------------- +# Task 11: _raw_request error paths via upload_files +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_upload_files_timeout_raises_snipeit_timeout_error(snipeit_client, httpx_mock, tmp_path): + """A timeout during file upload must surface as SnipeITTimeoutError.""" + import httpx + from snipeit.exceptions import SnipeITTimeoutError + + f = tmp_path / "file.txt" + f.write_text("data") + httpx_mock.add_exception( + httpx.TimeoutException("timed out"), + method="POST", + url="https://snipe.example.test/api/v1/hardware/1/files", + ) + with pytest.raises(SnipeITTimeoutError): + snipeit_client.assets.upload_files(1, [str(f)]) + + +# --------------------------------------------------------------------------- +# Task 13: upload_files validation and error-response paths +# --------------------------------------------------------------------------- + @pytest.mark.unit -def test_get_by_serial_shapes(snipeit_client, requests_mock): - # Single-object response - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/byserial/SN1", - json={"id": 10, "asset_tag": "A10"}, +def test_upload_files_empty_paths_raises_value_error(snipeit_client): + """upload_files([]) must raise ValueError before making any HTTP request.""" + with pytest.raises(ValueError, match="At least one file path"): + snipeit_client.assets.upload_files(1, []) + + +@pytest.mark.unit +def test_upload_files_missing_file_raises_file_not_found(snipeit_client, tmp_path): + """upload_files with a non-existent path must raise FileNotFoundError.""" + with pytest.raises(FileNotFoundError, match="not found"): + snipeit_client.assets.upload_files(1, [str(tmp_path / "ghost.txt")]) + + +@pytest.mark.unit +def test_upload_files_server_error_json_raises_api_error(snipeit_client, httpx_mock, tmp_path): + """When the server returns status:error JSON, SnipeITApiError must be raised.""" + from snipeit.exceptions import SnipeITApiError + + f = tmp_path / "file.txt" + f.write_text("data") + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware/1/files", + json={"status": "error", "messages": "Upload failed"}, status_code=200, ) - a = snipeit_client.assets.get_by_serial("SN1") - assert a.id == 10 + with pytest.raises(SnipeITApiError, match="Upload failed"): + snipeit_client.assets.upload_files(1, [str(f)]) - # Envelope shape - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/byserial/SN2", - json={"rows": [{"id": 20, "asset_tag": "A20"}], "total": 1}, + +@pytest.mark.unit +def test_upload_files_non_json_response_raises_api_error(snipeit_client, httpx_mock, tmp_path): + """When the server returns a non-JSON 200, SnipeITApiError must be raised.""" + from snipeit.exceptions import SnipeITApiError + + f = tmp_path / "file.txt" + f.write_text("data") + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware/1/files", + text="not json", status_code=200, + headers={"Content-Type": "text/plain"}, ) - b = snipeit_client.assets.get_by_serial("SN2") - assert b.id == 20 + with pytest.raises(SnipeITApiError, match="Expected JSON"): + snipeit_client.assets.upload_files(1, [str(f)]) + + +@pytest.mark.unit +def test_upload_files_closes_file_handles_on_success(snipeit_client, httpx_mock, tmp_path): + """File handles opened during upload must be closed even on success.""" + f = tmp_path / "file.txt" + f.write_text("data") + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware/1/files", + json={"file": {"original_name": "file.txt"}}, + status_code=200, + ) + opened_handles: list = [] + original_open = __builtins__["open"] if isinstance(__builtins__, dict) else open + + import builtins + original_open = builtins.open + + def tracking_open(path, mode="r", **kwargs): + fh = original_open(path, mode, **kwargs) + opened_handles.append(fh) + return fh + + import unittest.mock as mock + with mock.patch("builtins.open", side_effect=tracking_open): + snipeit_client.assets.upload_files(1, [str(f)]) + + assert all(fh.closed for fh in opened_handles if hasattr(fh, "closed")), \ + "All file handles must be closed after upload" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py deleted file mode 100644 index d7eecbf..0000000 --- a/tests/unit/test_client.py +++ /dev/null @@ -1,2 +0,0 @@ - -# Duplicate test removed. See tests/unit/test_client_edge_cases.py for test_https_required. diff --git a/tests/unit/test_client_edge_cases.py b/tests/unit/test_client_edge_cases.py index 63b64e8..8a940df 100644 --- a/tests/unit/test_client_edge_cases.py +++ b/tests/unit/test_client_edge_cases.py @@ -1,34 +1,92 @@ +"""Tests for client edge cases, T9 (3xx/localization), and coverage targets.""" + +import json + +import httpx import pytest -import requests + from snipeit import SnipeIT +from snipeit._log import redact_headers from snipeit.exceptions import ( SnipeITApiError, SnipeITClientError, SnipeITException, + SnipeITNotFoundError, + SnipeITServerError, SnipeITTimeoutError, ) +pytestmark = pytest.mark.unit + +# --------------------------------------------------------------------------- +# URL validation +# --------------------------------------------------------------------------- @pytest.mark.unit def test_https_required(): - with pytest.raises(ValueError) as excinfo: - SnipeIT(url="http://test.snipeitapp.com", token="test") - assert str(excinfo.value) == "URL must start with https:// or http://localhost" + with pytest.raises(ValueError): + SnipeIT(url="http://snipe.example.com", token="test") + + +@pytest.mark.unit +def test_url_with_credentials_rejected(): + with pytest.raises(ValueError): + SnipeIT(url="https://user:pass@snipe.example.com", token="test") + + +@pytest.mark.unit +def test_url_localhost_http_allowed(): + SnipeIT(url="http://localhost:8000", token="test") + SnipeIT(url="http://127.0.0.1:8000", token="test") + + +@pytest.mark.unit +def test_url_localhost_evil_rejected(): + with pytest.raises(ValueError): + SnipeIT(url="http://localhostevil.com", token="test") + + +@pytest.mark.unit +def test_repr_redacts_token(): + client = SnipeIT(url="https://snipe.example.test", token="super-secret") + r = repr(client) + assert "super-secret" not in r + assert "***" in r + assert "https://snipe.example.test" in r +# --------------------------------------------------------------------------- +# HTTP response handling +# --------------------------------------------------------------------------- @pytest.mark.unit -def test_delete_returns_none_on_204(snipeit_client, requests_mock): - requests_mock.delete( - "https://test.snipeitapp.com/api/v1/hardware/1", status_code=204 +def test_delete_returns_none_on_204(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="DELETE", + url="https://snipe.example.test/api/v1/hardware/1", + status_code=204, ) result = snipeit_client.delete("hardware/1") assert result is None @pytest.mark.unit -def test_status_error_in_json_raises_api_error(snipeit_client, requests_mock): - requests_mock.post( - "https://test.snipeitapp.com/api/v1/hardware", +def test_delete_returns_body_on_200(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="DELETE", + url="https://snipe.example.test/api/v1/hardware/1", + json={"status": "success", "messages": "Asset deleted"}, + status_code=200, + ) + result = snipeit_client.delete("hardware/1") + assert isinstance(result, dict) + assert result["status"] == "success" + + +@pytest.mark.unit +def test_status_error_in_json_raises_api_error(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware", json={"status": "error", "messages": "Something went wrong"}, status_code=200, ) @@ -38,9 +96,10 @@ def test_status_error_in_json_raises_api_error(snipeit_client, requests_mock): @pytest.mark.unit -def test_non_json_2xx_raises_snipeit_exception(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/1", +def test_non_json_2xx_raises_snipeit_exception(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", text="this is not json", status_code=200, ) @@ -50,9 +109,10 @@ def test_non_json_2xx_raises_snipeit_exception(snipeit_client, requests_mock): @pytest.mark.unit -def test_400_client_error_raises_SnipeITClientError(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/1", +def test_400_client_error_raises_SnipeITClientError(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", status_code=400, json={"messages": "Bad Request"}, ) @@ -61,10 +121,11 @@ def test_400_client_error_raises_SnipeITClientError(snipeit_client, requests_moc @pytest.mark.unit -def test_timeout_raises_SnipeITTimeoutError(snipeit_client, requests_mock): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/1", - exc=requests.exceptions.Timeout(), +def test_timeout_raises_SnipeITTimeoutError(snipeit_client, httpx_mock): + httpx_mock.add_exception( + httpx.TimeoutException("timed out"), + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", ) with pytest.raises(SnipeITTimeoutError) as excinfo: snipeit_client.get("hardware/1") @@ -72,22 +133,24 @@ def test_timeout_raises_SnipeITTimeoutError(snipeit_client, requests_mock): @pytest.mark.unit -def test_generic_request_exception_raises_SnipeITException( - snipeit_client, requests_mock -): - requests_mock.get( - "https://test.snipeitapp.com/api/v1/hardware/1", - exc=requests.exceptions.RequestException("boom"), - ) +def test_generic_request_exception_raises_SnipeITException(snipeit_client, httpx_mock): + # ConnectError is retried on GET; register enough for all attempts. + for _ in range(4): # 1 initial + 3 retries + httpx_mock.add_exception( + httpx.ConnectError("boom"), + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + ) with pytest.raises(SnipeITException) as excinfo: snipeit_client.get("hardware/1") assert str(excinfo.value) == "An unexpected error occurred: boom" @pytest.mark.unit -def test_status_error_default_message(snipeit_client, requests_mock): - requests_mock.post( - "https://test.snipeitapp.com/api/v1/hardware", +def test_status_error_default_message(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware", json={"status": "error"}, status_code=200, ) @@ -99,10 +162,10 @@ def test_status_error_default_message(snipeit_client, requests_mock): @pytest.mark.unit def test_context_manager_calls_close_on_exit(): close_called = {"count": 0} - with SnipeIT(url="https://test.snipeitapp.com", token="fake") as client: + with SnipeIT(url="https://snipe.example.test", token="fake") as client: def close_stub(): close_called["count"] += 1 - client.session.close = close_stub + client._http.close = close_stub assert close_called["count"] == 1 @@ -110,10 +173,297 @@ def close_stub(): def test_context_manager_does_not_suppress_exceptions_and_closes(): close_called = {"count": 0} with pytest.raises(RuntimeError): - with SnipeIT(url="https://test.snipeitapp.com", token="fake") as client: + with SnipeIT(url="https://snipe.example.test", token="fake") as client: def close_stub(): close_called["count"] += 1 - client.session.close = close_stub + client._http.close = close_stub raise RuntimeError("boom") - # Even though the exception was raised, close() should have been called assert close_called["count"] == 1 + + +# --------------------------------------------------------------------------- +# T9: 3xx redirect and localization-safe lookups +# --------------------------------------------------------------------------- +@pytest.mark.unit +def test_3xx_raises_api_error_with_status_and_location(snipeit_client, httpx_mock): + """A 3xx response must raise SnipeITApiError carrying the status code and redirect target. + + Snipe-IT behind a misconfigured reverse proxy often redirects to a login page. + The error must surface both the status code and the Location so operators can diagnose it. + """ + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + status_code=302, + headers={"Location": "https://snipe.example.test/login"}, + ) + with pytest.raises(SnipeITApiError) as excinfo: + snipeit_client.get("hardware/1") + assert excinfo.value.status_code == 302 + assert "https://snipe.example.test/login" in str(excinfo.value) + + +@pytest.mark.unit +def test_get_by_tag_localized_404_raises_not_found_with_tag_in_message(snipeit_client, httpx_mock): + """A localized 404 from get_by_tag must raise SnipeITNotFoundError and include the tag.""" + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/bytag/TAG1", + status_code=404, + json={"messages": "L'actif n'existe pas"}, + ) + with pytest.raises(SnipeITNotFoundError) as excinfo: + snipeit_client.assets.get_by_tag("TAG1") + assert "TAG1" in str(excinfo.value) + + +@pytest.mark.unit +def test_get_by_serial_localized_404_raises_not_found_with_serial_in_message(snipeit_client, httpx_mock): + """A localized 404 from get_by_serial must raise SnipeITNotFoundError and include the serial.""" + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/byserial/SN999", + status_code=404, + json={"messages": "El activo no existe"}, + ) + with pytest.raises(SnipeITNotFoundError) as excinfo: + snipeit_client.assets.get_by_serial("SN999") + assert "SN999" in str(excinfo.value) + + +@pytest.mark.unit +def test_get_by_tag_non_404_api_error_propagates(snipeit_client, httpx_mock): + # 500 triggers retries on GET; register enough for all attempts. + for _ in range(4): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/bytag/TAG2", + status_code=500, + json={"messages": "Internal Server Error"}, + ) + with pytest.raises(SnipeITServerError): + snipeit_client.assets.get_by_tag("TAG2") + + +# --------------------------------------------------------------------------- +# Coverage targets +# --------------------------------------------------------------------------- +@pytest.mark.unit +def test_redact_headers_masks_authorization(): + h = {"Authorization": "Bearer secret", "Accept": "application/json"} + r = redact_headers(h) + assert r["Authorization"] == "***" + assert r["Accept"] == "application/json" + + +@pytest.mark.unit +def test_redact_headers_empty(): + assert redact_headers({}) == {} + assert redact_headers(None) == {} + + +@pytest.mark.unit +def test_companies_create(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/companies", + json={"status": "success", "payload": {"id": 1, "name": "Acme"}}, + ) + c = snipeit_client.companies.create(name="Acme") + assert c.name == "Acme" + + +@pytest.mark.unit +def test_suppliers_create(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/suppliers", + json={"status": "success", "payload": {"id": 1, "name": "Widgets Co"}}, + ) + s = snipeit_client.suppliers.create(name="Widgets Co") + assert s.name == "Widgets Co" + + +@pytest.mark.unit +def test_users_create(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/users", + json={"status": "success", "payload": {"id": 5, "username": "jdoe"}}, + ) + u = snipeit_client.users.create(username="jdoe") + assert u.username == "jdoe" + + +@pytest.mark.unit +def test_retry_after_http_date_parsing(): + from snipeit._retry import RetryTransport + result = RetryTransport._parse_retry_after("Thu, 01 Jan 2020 00:00:00 GMT") + assert result == 0.0 + + +@pytest.mark.unit +def test_retry_after_invalid_returns_none(): + from snipeit._retry import RetryTransport + assert RetryTransport._parse_retry_after("not-a-date") is None + assert RetryTransport._parse_retry_after(None) is None + assert RetryTransport._parse_retry_after("") is None + + +@pytest.mark.unit +def test_mark_dirty_forces_field_into_patch(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + json={"id": 1, "custom_fields": {"owner": "alice"}}, + ) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/1", + json={"status": "success", "payload": {"id": 1}}, + ) + asset = snipeit_client.assets.get(1) + asset.custom_fields["owner"] = "bob" + asset.mark_dirty("custom_fields") + asset.save() + body = json.loads(httpx_mock.get_requests()[-1].content) + assert "custom_fields" in body + + +# --------------------------------------------------------------------------- +# Task 9: URL/token validation gaps and _require_body 204 paths +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_empty_token_raises(): + with pytest.raises(ValueError, match="token"): + SnipeIT(url="https://snipe.example.test", token="") + + +@pytest.mark.unit +def test_whitespace_only_token_raises(): + with pytest.raises(ValueError, match="token"): + SnipeIT(url="https://snipe.example.test", token=" ") + + +@pytest.mark.unit +def test_url_with_path_rejected(): + with pytest.raises(ValueError): + SnipeIT(url="https://snipe.example.test/api", token="t") + + +@pytest.mark.unit +def test_post_204_raises_snipeit_exception(snipeit_client, httpx_mock): + """POST returning 204 must raise — callers always expect a JSON body.""" + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware", + status_code=204, + ) + with pytest.raises(SnipeITException) as excinfo: + snipeit_client.post("hardware", data={}) + assert "POST" in str(excinfo.value) + assert "204" in str(excinfo.value) + + +@pytest.mark.unit +def test_put_204_raises_snipeit_exception(snipeit_client, httpx_mock): + """PUT returning 204 must raise — callers always expect a JSON body.""" + httpx_mock.add_response( + method="PUT", + url="https://snipe.example.test/api/v1/hardware/1", + status_code=204, + ) + with pytest.raises(SnipeITException) as excinfo: + snipeit_client.put("hardware/1", data={}) + assert "PUT" in str(excinfo.value) + + +@pytest.mark.unit +def test_patch_204_raises_snipeit_exception(snipeit_client, httpx_mock): + """PATCH returning 204 must raise — callers always expect a JSON body.""" + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/1", + status_code=204, + ) + with pytest.raises(SnipeITException) as excinfo: + snipeit_client.patch("hardware/1", data={}) + assert "PATCH" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# Task 10: Error-message extraction paths +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_4xx_with_non_json_body_uses_reason_phrase(snipeit_client, httpx_mock): + """When the error body is not JSON, the HTTP reason phrase is used as the message.""" + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + status_code=503, + text="Service Unavailable", + headers={"Content-Type": "text/plain"}, + ) + # 503 retries on GET; register enough + for _ in range(3): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + status_code=503, + text="Service Unavailable", + headers={"Content-Type": "text/plain"}, + ) + from snipeit.exceptions import SnipeITServerError + with pytest.raises(SnipeITServerError) as excinfo: + snipeit_client.get("hardware/1") + # Message should be non-empty (reason phrase or text) + assert str(excinfo.value) + + +@pytest.mark.unit +def test_4xx_with_messages_list_joins_with_semicolon(snipeit_client, httpx_mock): + """When messages is a list, items are joined with '; '.""" + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware", + status_code=422, + json={"messages": ["name is required", "model_id is required"]}, + ) + from snipeit.exceptions import SnipeITValidationError + with pytest.raises(SnipeITValidationError) as excinfo: + snipeit_client.post("hardware", data={}) + assert "name is required" in str(excinfo.value) + assert "model_id is required" in str(excinfo.value) + assert ";" in str(excinfo.value) + + +@pytest.mark.unit +def test_4xx_with_messages_dict_formats_as_key_value(snipeit_client, httpx_mock): + """When messages is a dict, it is formatted as 'key: value' pairs.""" + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware", + status_code=422, + json={"messages": {"name": "The name field is required."}}, + ) + from snipeit.exceptions import SnipeITValidationError + with pytest.raises(SnipeITValidationError) as excinfo: + snipeit_client.post("hardware", data={}) + assert "name" in str(excinfo.value) + assert "required" in str(excinfo.value) + + +@pytest.mark.unit +def test_4xx_with_null_messages_produces_empty_string(snipeit_client, httpx_mock): + """When messages is null, the exception message is empty (not a crash).""" + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware", + status_code=400, + json={"messages": None}, + ) + with pytest.raises(SnipeITClientError) as excinfo: + snipeit_client.post("hardware", data={}) + assert str(excinfo.value) == "" diff --git a/tests/unit/test_client_properties.py b/tests/unit/test_client_properties.py index 7fbfbe3..375e996 100644 --- a/tests/unit/test_client_properties.py +++ b/tests/unit/test_client_properties.py @@ -1,42 +1,44 @@ import pytest from snipeit import SnipeIT +pytestmark = pytest.mark.unit + @pytest.mark.unit def test_manager_properties_are_cached(): - client = SnipeIT(url="https://test.snipeitapp.com/", token="fake") + client = SnipeIT(url="https://snipe.example.test/", token="fake") # url normalization trims trailing slash - assert client.url == "https://test.snipeitapp.com" + assert client.url == "https://snipe.example.test" # Each property should return the same object on subsequent access - assert client.assets is client.assets - assert client.accessories is client.accessories - assert client.components is client.components - assert client.consumables is client.consumables - assert client.licenses is client.licenses - assert client.users is client.users - assert client.locations is client.locations - assert client.departments is client.departments - assert client.manufacturers is client.manufacturers - assert client.models is client.models - assert client.categories is client.categories - assert client.status_labels is client.status_labels - assert client.fields is client.fields - assert client.fieldsets is client.fieldsets + for name in ( + "assets", "accessories", "components", "consumables", "licenses", + "users", "locations", "departments", "manufacturers", "models", + "categories", "status_labels", "fields", "fieldsets", + "companies", "suppliers", + ): + mgr = getattr(client, name) + assert mgr is getattr(client, name), f"{name} not cached" @pytest.mark.unit -def test_session_headers_are_correct(): - client = SnipeIT(url="https://test.snipeitapp.com", token="fake-token") - headers = client.session.headers - assert headers["Authorization"] == "Bearer fake-token" - assert headers["Accept"] == "application/json" - assert headers["Content-Type"] == "application/json" +def test_request_headers_are_correct(httpx_mock): + """The client must send Authorization, Accept, and a snipeit-api User-Agent on every request.""" + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + json={"id": 1}, + ) + client = SnipeIT(url="https://snipe.example.test", token="my-secret-token") + client.get("hardware/1") + + req = httpx_mock.get_requests()[0] + assert req.headers["Authorization"] == "Bearer my-secret-token" + assert req.headers["Accept"] == "application/json" + assert req.headers["User-Agent"].startswith("snipeit-api") + # Content-Type is NOT set at the session level; httpx sets it per-request. + assert "content-type" not in {k.lower() for k in dict(req.headers)} + -@pytest.mark.unit -def test_url_normalization_does_not_strip_non_slash_trailing_chars(): - # Ensure trailing characters other than '/' are preserved - client = SnipeIT(url="https://test.snipeitapp.comX", token="fake") - assert client.url == "https://test.snipeitapp.comX" diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 907205a..91581cb 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -7,52 +7,91 @@ SnipeITApiError, ) +pytestmark = pytest.mark.unit + @pytest.mark.unit -def test_401_raises_auth_error(snipeit_client, requests_mock): - """Tests that a 401 response raises SnipeITAuthenticationError.""" - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/1", status_code=401, json={"messages": "Unauthenticated."}) +def test_401_raises_auth_error(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + status_code=401, + json={"messages": "Unauthenticated."}, + ) with pytest.raises(SnipeITAuthenticationError) as excinfo: snipeit_client.assets.get(1) assert "Unauthenticated." in str(excinfo.value) @pytest.mark.unit -def test_404_raises_not_found_error(snipeit_client, requests_mock): - """Tests that a 404 response raises SnipeITNotFoundError.""" - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/9999", status_code=404, json={"messages": "Asset not found"}) +def test_404_raises_not_found_error(snipeit_client, httpx_mock): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/9999", + status_code=404, + json={"messages": "Asset not found"}, + ) with pytest.raises(SnipeITNotFoundError) as excinfo: snipeit_client.assets.get(9999) assert "Asset not found" in str(excinfo.value) @pytest.mark.unit -def test_422_raises_validation_error(snipeit_client, requests_mock): - """Tests that a 422 response raises SnipeITValidationError.""" +def test_422_raises_validation_error(snipeit_client, httpx_mock): error_payload = { "messages": "The given data was invalid.", - "errors": {"model_id": ["The selected model id is invalid."]} + "errors": {"model_id": ["The selected model id is invalid."]}, } - requests_mock.post("https://test.snipeitapp.com/api/v1/hardware", status_code=422, json=error_payload) + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware", + status_code=422, + json=error_payload, + ) with pytest.raises(SnipeITValidationError) as excinfo: snipeit_client.assets.create(asset_tag="123", status_id=1, model_id=999) assert "The given data was invalid." in str(excinfo.value) @pytest.mark.unit -def test_500_raises_server_error(snipeit_client, requests_mock): - """Tests that a 500 response raises SnipeITServerError.""" - requests_mock.get("https://test.snipeitapp.com/api/v1/hardware/1", status_code=500, reason="Internal Server Error") - with pytest.raises(SnipeITServerError) as excinfo: +def test_500_raises_server_error(snipeit_client, httpx_mock): + # 500 triggers retries on GET; register enough responses for all attempts. + for _ in range(4): # 1 initial + 3 retries + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + status_code=500, + ) + with pytest.raises(SnipeITServerError): snipeit_client.assets.get(1) - assert "Internal Server Error" in str(excinfo.value) @pytest.mark.unit def test_api_error_preserves_response_and_status_code(): - import requests - r = requests.models.Response() - r.status_code = 418 + import httpx + r = httpx.Response(418, text="") exc = SnipeITApiError("I am a teapot", response=r) assert exc.response is r assert exc.status_code == 418 + + +# --------------------------------------------------------------------------- +# Task 12: SnipeITValidationError body-parse failure +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_validation_error_with_unparseable_body_sets_errors_none(caplog): + """When the 422 response body is not valid JSON, errors must be None and a warning logged.""" + import logging + import httpx + from snipeit.exceptions import SnipeITValidationError + + # Build a response whose .json() will raise ValueError + resp = httpx.Response(422, text="not json at all", headers={"Content-Type": "text/plain"}) + + with caplog.at_level(logging.WARNING, logger="snipeit"): + exc = SnipeITValidationError("validation failed", response=resp) + + assert exc.errors is None + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert warnings, "expected a WARNING when validation error body cannot be parsed" diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py new file mode 100644 index 0000000..2bf74e1 --- /dev/null +++ b/tests/unit/test_logging.py @@ -0,0 +1,90 @@ +"""Tests for structured logging.""" + +import logging +import re + +import httpx +import pytest + +from snipeit import SnipeIT +from snipeit.exceptions import SnipeITException, SnipeITTimeoutError + +pytestmark = pytest.mark.unit + + +SUPER_SECRET_TOKEN = "super-secret-token-abcdef1234567890" + + +@pytest.fixture +def client_with_token(): + return SnipeIT(url="https://snipe.example.test", token=SUPER_SECRET_TOKEN) + + +@pytest.mark.unit +def test_http_logger_emits_debug_on_request(client_with_token, httpx_mock, caplog): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + json={"id": 1, "name": "x"}, + status_code=200, + ) + with caplog.at_level(logging.DEBUG, logger="snipeit.http"): + client_with_token.get("hardware/1") + + matching = [r for r in caplog.records if r.name == "snipeit.http"] + assert matching, "expected at least one snipeit.http DEBUG record" + msg = matching[0].getMessage() + assert "GET" in msg + assert "/api/v1/hardware/1" in msg + assert "200" in msg + assert re.search(r"\d+\.\d+ ms", msg) + + +@pytest.mark.unit +def test_token_never_appears_in_logs(client_with_token, httpx_mock, caplog): + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + json={"id": 1}, + status_code=200, + ) + with caplog.at_level(logging.DEBUG, logger="snipeit"): + client_with_token.get("hardware/1") + + for rec in caplog.records: + assert SUPER_SECRET_TOKEN not in rec.getMessage() + for arg in (rec.args or ()): + assert SUPER_SECRET_TOKEN not in str(arg) + + +@pytest.mark.unit +def test_timeout_emits_warning(client_with_token, httpx_mock, caplog): + httpx_mock.add_exception( + httpx.TimeoutException("timed out"), + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + ) + with caplog.at_level(logging.WARNING, logger="snipeit"): + with pytest.raises(SnipeITTimeoutError): + client_with_token.get("hardware/1") + + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert warnings, "expected a WARNING on timeout" + assert any("timed out" in r.getMessage() for r in warnings) + + +@pytest.mark.unit +def test_request_error_emits_warning(client_with_token, httpx_mock, caplog): + # ConnectError is retried on GET; register enough for all attempts. + for _ in range(4): + httpx_mock.add_exception( + httpx.ConnectError("connreset"), + method="GET", + url="https://snipe.example.test/api/v1/hardware/1", + ) + with caplog.at_level(logging.WARNING, logger="snipeit"): + with pytest.raises(SnipeITException): + client_with_token.get("hardware/1") + + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert warnings, "expected a WARNING on request error" diff --git a/tests/unit/test_property_apiobject.py b/tests/unit/test_property_apiobject.py index 24784ee..779b6e5 100644 --- a/tests/unit/test_property_apiobject.py +++ b/tests/unit/test_property_apiobject.py @@ -2,6 +2,8 @@ from hypothesis import given, strategies as st from snipeit.resources.base import ApiObject +pytestmark = pytest.mark.unit + class _ManagerStub: def __init__(self): @@ -50,7 +52,7 @@ def test_apiobject_property_only_sends_changed_fields(initial, updates): if not changed: # Force at least one change if all updates were same as before k = next(iter(updates.keys())) - setattr(obj, k, object()) # make it definitely different + setattr(obj, k, ("__forced__",)) # deterministic sentinel, never equal to real values changed[k] = getattr(obj, k) obj.save() @@ -58,4 +60,4 @@ def test_apiobject_property_only_sends_changed_fields(initial, updates): assert mgr._patched_path == "props/1" assert mgr._patched_data == changed # Dirty fields cleared - assert not getattr(obj, "_dirty_fields") + assert not obj._dirty_set() diff --git a/tests/unit/test_repr.py b/tests/unit/test_repr.py index 494af7c..dfb6114 100644 --- a/tests/unit/test_repr.py +++ b/tests/unit/test_repr.py @@ -2,6 +2,7 @@ from snipeit.resources.accessories import Accessory from snipeit.resources.categories import Category +from snipeit.resources.companies import Company from snipeit.resources.components import Component from snipeit.resources.consumables import Consumable from snipeit.resources.departments import Department @@ -11,9 +12,12 @@ from snipeit.resources.locations import Location from snipeit.resources.manufacturers import Manufacturer from snipeit.resources.models import Model +from snipeit.resources.suppliers import Supplier from snipeit.resources.users import User from snipeit.resources.status_labels import StatusLabel +pytestmark = pytest.mark.unit + class _MockManager: pass @@ -25,17 +29,19 @@ class _MockManager: [ (Accessory, {"id": 1, "name": "Acc"}, ["Accessory", "1", "Acc"]), (Category, {"id": 2, "name": "Cat", "category_type": "asset"}, ["Category", "Cat", "asset"]), - (Component, {"id": 3, "name": "Comp", "qty": 5}, ["Component", "Comp", "5"]), - (Consumable, {"id": 4, "name": "Con", "qty": 10}, ["Consumable", "Con", "10"]), - (Department, {"id": 5, "name": "Dept"}, ["Department", "Dept"]), - (Field, {"id": 6, "name": "Field", "element": "text"}, ["Field", "Field", "text"]), - (Fieldset, {"id": 7, "name": "FS"}, ["Fieldset", "FS"]), - (License, {"id": 8, "name": "Lic", "seats": 100}, ["License", "Lic", "100"]), - (Location, {"id": 9, "name": "Loc"}, ["Location", "Loc"]), - (Manufacturer, {"id": 10, "name": "Manu"}, ["Manufacturer", "Manu"]), - (Model, {"id": 11, "name": "Mod", "model_number": "MN"}, ["Model", "Mod", "MN"]), - (User, {"id": 12, "name": "User", "username": "uname"}, ["User", "User", "uname"]), - (StatusLabel, {"id": 13, "name": "Active", "type": "deployable"}, ["StatusLabel", "Active", "deployable"]), + (Company, {"id": 3, "name": "Acme"}, ["Company", "3", "Acme"]), + (Component, {"id": 4, "name": "Comp", "qty": 5}, ["Component", "Comp", "5"]), + (Consumable, {"id": 5, "name": "Con", "qty": 10}, ["Consumable", "Con", "10"]), + (Department, {"id": 6, "name": "Dept"}, ["Department", "Dept"]), + (Field, {"id": 7, "name": "Field", "element": "text"}, ["Field", "Field", "text"]), + (Fieldset, {"id": 8, "name": "FS"}, ["Fieldset", "FS"]), + (License, {"id": 9, "name": "Lic", "seats": 100}, ["License", "Lic", "100"]), + (Location, {"id": 10, "name": "Loc"}, ["Location", "Loc"]), + (Manufacturer, {"id": 11, "name": "Manu"}, ["Manufacturer", "Manu"]), + (Model, {"id": 12, "name": "Mod", "model_number": "MN"}, ["Model", "Mod", "MN"]), + (Supplier, {"id": 13, "name": "Widgets Co"}, ["Supplier", "13", "Widgets Co"]), + (User, {"id": 14, "name": "User", "username": "uname"}, ["User", "User", "uname"]), + (StatusLabel, {"id": 15, "name": "Active", "type": "deployable"}, ["StatusLabel", "Active", "deployable"]), ], ) def test_repr_for_resources(cls, data, expected_parts): @@ -49,6 +55,7 @@ def test_repr_fallbacks_exact_strings(): # Objects with no data should fall back to 'N/A' placeholders in __repr__ assert repr(Accessory(_MockManager(), {})) == "" assert repr(Category(_MockManager(), {})) == "" + assert repr(Company(_MockManager(), {})) == "" assert repr(Component(_MockManager(), {})) == "" assert repr(Consumable(_MockManager(), {})) == "" assert repr(Department(_MockManager(), {})) == "" @@ -58,5 +65,6 @@ def test_repr_fallbacks_exact_strings(): assert repr(Location(_MockManager(), {})) == "" assert repr(Manufacturer(_MockManager(), {})) == "" assert repr(Model(_MockManager(), {})) == "" + assert repr(Supplier(_MockManager(), {})) == "" assert repr(User(_MockManager(), {})) == "" assert repr(StatusLabel(_MockManager(), {})) == "" diff --git a/tests/unit/test_repr_exact.py b/tests/unit/test_repr_exact.py deleted file mode 100644 index 9912224..0000000 --- a/tests/unit/test_repr_exact.py +++ /dev/null @@ -1,6 +0,0 @@ - - - -class _Mgr: - pass - diff --git a/tests/unit/test_retries.py b/tests/unit/test_retries.py index eef71c7..4d43fc3 100644 --- a/tests/unit/test_retries.py +++ b/tests/unit/test_retries.py @@ -1,57 +1,217 @@ +"""Tests for the RetryTransport and SnipeIT retry configuration.""" + import pytest from snipeit import SnipeIT +from snipeit._retry import RetryTransport from snipeit.exceptions import SnipeITServerError +pytestmark = pytest.mark.unit + @pytest.mark.unit def test_retry_defaults_configured(): - # Do not override defaults so we can detect mutations of default values - client = SnipeIT( - url="https://test.snipeitapp.com", - token="fake", - ) - # Defaults + client = SnipeIT(url="https://snipe.example.test", token="fake") assert client.timeout == 10 - retries = client.session.adapters["https://"].max_retries - assert getattr(retries, "total", None) == 3 - assert getattr(retries, "backoff_factor", None) == 0.3 - # Only idempotent methods by default - assert retries.allowed_methods == frozenset({"HEAD", "GET", "OPTIONS"}) - # Status forcelist should be exact - assert set(retries.status_forcelist) == {429, 500, 502, 503, 504} + rt: RetryTransport = client._retry_transport + assert rt.max_retries == 3 + assert rt.backoff_factor == 0.3 + assert rt.allowed_methods == frozenset({"HEAD", "GET", "OPTIONS"}) + assert rt.status_forcelist == frozenset({429, 500, 502, 503, 504}) @pytest.mark.unit -def test_post_503_does_not_retry_by_default(requests_mock): +def test_post_503_does_not_retry_by_default(httpx_mock): client = SnipeIT( - url="https://test.snipeitapp.com", + url="https://snipe.example.test", token="fake", max_retries=2, backoff_factor=0, ) - requests_mock.post( - "https://test.snipeitapp.com/api/v1/hardware", - [{"status_code": 503, "json": {"messages": "Service Unavailable"}}], + httpx_mock.add_response( + method="POST", + url="https://snipe.example.test/api/v1/hardware", + json={"messages": "Service Unavailable"}, + status_code=503, ) with pytest.raises(SnipeITServerError): client.post("hardware", data={"x": 1}) - assert requests_mock.call_count == 1 + # POST is not in allowed_methods, so no retries — exactly 1 call. + assert len(httpx_mock.get_requests()) == 1 @pytest.mark.unit def test_retry_allows_post_when_configured(): client = SnipeIT( - url="https://test.snipeitapp.com", + url="https://snipe.example.test", token="fake", retry_allowed_methods={"HEAD", "GET", "OPTIONS", "POST"}, ) - retries = client.session.adapters["https://"].max_retries - assert "POST" in retries.allowed_methods + rt: RetryTransport = client._retry_transport + assert "POST" in rt.allowed_methods @pytest.mark.unit -def test_http_and_https_adapters_mounted(): - client = SnipeIT(url="https://test.snipeitapp.com", token="fake") - adapters = client.session.adapters - assert "https://" in adapters - assert "http://" in adapters +def test_retry_transport_retries_get_on_503(httpx_mock): + """GET on 503 should be retried up to max_retries times.""" + import httpx + from snipeit._retry import RetryTransport + + sleep_calls: list[float] = [] + rt = RetryTransport(max_retries=2, backoff_factor=0, sleep=lambda s: sleep_calls.append(s)) + + httpx_mock.add_response(status_code=503, json={"messages": "down"}) + httpx_mock.add_response(status_code=503, json={"messages": "down"}) + httpx_mock.add_response(status_code=200, json={"id": 1}) + + client = httpx.Client(transport=rt) + resp = client.get("https://example.com/api/v1/hardware/1") + assert resp.status_code == 200 + assert len(httpx_mock.get_requests()) == 3 + + +@pytest.mark.unit +def test_retry_transport_respects_retry_after(httpx_mock): + """Retry-After header should override backoff sleep.""" + from snipeit._retry import RetryTransport + import httpx + + sleep_calls: list[float] = [] + rt = RetryTransport( + max_retries=1, + backoff_factor=99, + sleep=lambda s: sleep_calls.append(s), + ) + httpx_mock.add_response( + status_code=429, + headers={"Retry-After": "2"}, + json={"messages": "rate limited"}, + ) + httpx_mock.add_response(status_code=200, json={"id": 1}) + + client = httpx.Client(transport=rt) + resp = client.get("https://example.com/api/v1/hardware/1") + assert resp.status_code == 200 + assert sleep_calls == [2.0] + + +@pytest.mark.unit +def test_retry_transport_does_not_retry_post_read_error_by_default(): + import httpx + + class ReadErrorTransport(httpx.BaseTransport): + def __init__(self): + self.calls = 0 + + def handle_request(self, request): + self.calls += 1 + raise httpx.ReadError("socket closed", request=request) + + wrapped = ReadErrorTransport() + rt = RetryTransport(wrapped=wrapped, max_retries=2, backoff_factor=0) + client = httpx.Client(transport=rt) + + with pytest.raises(httpx.ReadError): + client.post("https://example.com/api/v1/hardware", json={"x": 1}) + + assert wrapped.calls == 1 + + +@pytest.mark.unit +def test_retry_after_future_http_date_sleeps_for_correct_duration(httpx_mock): + """A Retry-After HTTP-date 30 seconds in the future must produce a sleep of ~30s.""" + import time + import httpx + from email.utils import formatdate + from snipeit._retry import RetryTransport + + future_date = formatdate(time.time() + 30, usegmt=True) + sleep_calls: list[float] = [] + rt = RetryTransport( + max_retries=1, + backoff_factor=0, + sleep=lambda s: sleep_calls.append(s), + ) + httpx_mock.add_response( + status_code=429, + headers={"Retry-After": future_date}, + json={"messages": "rate limited"}, + ) + httpx_mock.add_response(status_code=200, json={"id": 1}) + + client = httpx.Client(transport=rt) + resp = client.get("https://example.com/api/v1/hardware/1") + assert resp.status_code == 200 + assert len(sleep_calls) == 1 + # Allow ±2s tolerance for test execution time + assert 28.0 <= sleep_calls[0] <= 32.0 + + +# --------------------------------------------------------------------------- +# Task 17: respect_retry_after=False and PATCH/DELETE non-retry +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_retry_after_false_uses_backoff_not_header(httpx_mock): + """When respect_retry_after=False, the Retry-After header must be ignored and backoff used.""" + import httpx + from snipeit._retry import RetryTransport + + sleep_calls: list[float] = [] + rt = RetryTransport( + max_retries=1, + backoff_factor=0, # backoff = 0 * 2^0 = 0 + respect_retry_after=False, + sleep=lambda s: sleep_calls.append(s), + ) + httpx_mock.add_response( + status_code=429, + headers={"Retry-After": "60"}, # would be 60s if respected + json={"messages": "rate limited"}, + ) + httpx_mock.add_response(status_code=200, json={"id": 1}) + + client = httpx.Client(transport=rt) + resp = client.get("https://example.com/api/v1/hardware/1") + assert resp.status_code == 200 + # backoff_factor=0 → delay=0 → sleep not called (delay > 0 guard in _backoff) + assert sleep_calls == [] + + +@pytest.mark.unit +def test_patch_503_does_not_retry_by_default(httpx_mock): + """PATCH is not in DEFAULT_ALLOWED_METHODS, so a 503 must not be retried.""" + client = SnipeIT( + url="https://snipe.example.test", + token="fake", + max_retries=3, + backoff_factor=0, + ) + httpx_mock.add_response( + method="PATCH", + url="https://snipe.example.test/api/v1/hardware/1", + json={"messages": "Service Unavailable"}, + status_code=503, + ) + with pytest.raises(SnipeITServerError): + client.patch("hardware/1", data={"name": "x"}) + assert len(httpx_mock.get_requests()) == 1 + + +@pytest.mark.unit +def test_delete_503_does_not_retry_by_default(httpx_mock): + """DELETE is not in DEFAULT_ALLOWED_METHODS, so a 503 must not be retried.""" + client = SnipeIT( + url="https://snipe.example.test", + token="fake", + max_retries=3, + backoff_factor=0, + ) + httpx_mock.add_response( + method="DELETE", + url="https://snipe.example.test/api/v1/hardware/1", + json={"messages": "Service Unavailable"}, + status_code=503, + ) + with pytest.raises(SnipeITServerError): + client.delete("hardware/1") + assert len(httpx_mock.get_requests()) == 1 diff --git a/tests/unit/test_streaming_download.py b/tests/unit/test_streaming_download.py new file mode 100644 index 0000000..51e9ace --- /dev/null +++ b/tests/unit/test_streaming_download.py @@ -0,0 +1,103 @@ +"""Tests for T11: streaming file downloads.""" + +import pytest +from pytest_httpx import IteratorStream + +pytestmark = pytest.mark.unit + + +@pytest.mark.unit +def test_download_file_streams_and_writes(snipeit_client, httpx_mock, tmp_path): + """download_file writes streamed chunks to disk.""" + data = b"chunk1" + b"chunk2" + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1/files/2", + stream=IteratorStream([b"chunk1", b"chunk2"]), + headers={"Content-Length": str(len(data))}, + status_code=200, + ) + dest = tmp_path / "dl.bin" + out = snipeit_client.assets.download_file(1, 2, str(dest)) + assert out == str(dest) + assert dest.read_bytes() == data + + +@pytest.mark.unit +def test_download_file_progress_callback(snipeit_client, httpx_mock, tmp_path): + """progress callback receives (bytes_written, total) on each chunk.""" + chunks = [b"a" * 100, b"b" * 200] + total_bytes = sum(len(c) for c in chunks) + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1/files/3", + stream=IteratorStream(chunks), + headers={"Content-Length": str(total_bytes)}, + status_code=200, + ) + calls: list[tuple[int, int | None]] = [] + dest = tmp_path / "progress.bin" + snipeit_client.assets.download_file(1, 3, str(dest), progress=lambda n, t: calls.append((n, t))) + assert calls[-1][0] == total_bytes + assert all(t == total_bytes for _, t in calls) + + +# --------------------------------------------------------------------------- +# Task 11: _stream_request error paths via download_file +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_download_file_timeout_raises_snipeit_timeout_error(snipeit_client, httpx_mock, tmp_path): + """A timeout during streaming must surface as SnipeITTimeoutError, not a raw httpx error.""" + import httpx + from snipeit.exceptions import SnipeITTimeoutError + + httpx_mock.add_exception( + httpx.TimeoutException("timed out"), + method="GET", + url="https://snipe.example.test/api/v1/hardware/1/files/9", + ) + with pytest.raises(SnipeITTimeoutError): + snipeit_client.assets.download_file(1, 9, str(tmp_path / "out.bin")) + + +@pytest.mark.unit +def test_download_file_connect_error_raises_snipeit_exception(snipeit_client, httpx_mock, tmp_path): + """A connection error during streaming must surface as SnipeITException.""" + import httpx + from snipeit.exceptions import SnipeITException + + # ConnectError on GET is retried (default max_retries=3); register 4 exceptions. + for _ in range(4): + httpx_mock.add_exception( + httpx.ConnectError("refused"), + method="GET", + url="https://snipe.example.test/api/v1/hardware/1/files/10", + ) + with pytest.raises(SnipeITException): + snipeit_client.assets.download_file(1, 10, str(tmp_path / "out.bin")) + + +# --------------------------------------------------------------------------- +# Task 15: Streaming download without Content-Length +# --------------------------------------------------------------------------- + +@pytest.mark.unit +def test_download_file_progress_without_content_length(snipeit_client, httpx_mock, tmp_path): + """When Content-Length is absent, progress callback receives total=None.""" + from pytest_httpx import IteratorStream + + chunks = [b"x" * 50, b"y" * 50] + httpx_mock.add_response( + method="GET", + url="https://snipe.example.test/api/v1/hardware/1/files/11", + stream=IteratorStream(chunks), + # No Content-Length header + status_code=200, + ) + calls: list[tuple[int, int | None]] = [] + dest = tmp_path / "no_len.bin" + snipeit_client.assets.download_file(1, 11, str(dest), progress=lambda n, t: calls.append((n, t))) + assert dest.read_bytes() == b"x" * 50 + b"y" * 50 + assert all(t is None for _, t in calls), "total must be None when Content-Length is absent" + assert calls[-1][0] == 100 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..529b5e3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,651 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "glob2" +version = "0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/a5/bbbc3b74a94fbdbd7915e7ad030f16539bfdc1362f7e9003b594f0537950/glob2-0.7.tar.gz", hash = "sha256:85c3dbd07c8aa26d63d7aacee34fa86e9a91a3873bc30bf62ec46e531f92ab8c", size = 10697, upload-time = "2019-06-10T23:33:48.308Z" } + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.151.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "junit-xml" +version = "1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/af/bc988c914dd1ea2bc7540ecc6a0265c2b6faccc6d9cdb82f20e2094a8229/junit-xml-1.9.tar.gz", hash = "sha256:de16a051990d4e25a3982b2dd9e89d671067548718866416faec14d9de56db9f", size = 7349, upload-time = "2023-01-24T18:42:00.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/93/2d896b5fd3d79b4cadd8882c06650e66d003f465c9d12c488d92853dff78/junit_xml-1.9-py2.py3-none-any.whl", hash = "sha256:ec5ca1a55aefdd76d28fcc0b135251d156c7106fa979686a4b48d62b761b4732", size = 7130, upload-time = "2020-02-22T20:41:37.661Z" }, +] + +[[package]] +name = "mutmut" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "glob2" }, + { name = "junit-xml" }, + { name = "parso" }, + { name = "pony" }, + { name = "toml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/b3/bbeef9223c0f25b7336e673723f667e3a851dbe7ad235c9180937739dd44/mutmut-2.5.1.tar.gz", hash = "sha256:d8fea2538805277f6290922e88881ad045002fc284d5a53c2b3915298b77f79d", size = 50502, upload-time = "2024-08-15T13:55:23.418Z" } + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "parso" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pony" +version = "0.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/59/ab6542afa95de0d5a16545ce6ce683960352cfd9a2318722593b81a98123/pony-0.7.19.tar.gz", hash = "sha256:f7f83b2981893e49f7f18e8def52ad8fa8f8e6c5f9583b9aaed62d4d85036a0f", size = 258589, upload-time = "2024-08-27T12:29:29.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/cb/0ef8429024309fe6f5edf1debc7cf63adeaeb34b2242490cc18710658abd/pony-0.7.19-py3-none-any.whl", hash = "sha256:5112b4cf40d3f24e93ae66dc5ab7dc6813388efa870e750928d60dc699873cf5", size = 317259, upload-time = "2024-08-27T12:29:28.247Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-httpx" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/42/f53c58570e80d503ade9dd42ce57f2915d14bcbe25f6308138143950d1d6/pytest_httpx-0.36.2.tar.gz", hash = "sha256:05a56527484f7f4e8c856419ea379b8dc359c36801c4992fdb330f294c690356", size = 57683, upload-time = "2026-04-09T13:57:19.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/55/1fa65f8e4fceb19dd6daa867c162ad845d547f6058cd92b4b02384a44777/pytest_httpx-0.36.2-py3-none-any.whl", hash = "sha256:d42ebd5679442dc7bfb0c48e0767b6562e9bc4534d805127b0084171886a5e22", size = 20315, upload-time = "2026-04-09T13:57:18.587Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "snipeit-api" +version = "0.4.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "coverage" }, + { name = "hypothesis" }, + { name = "mutmut" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-httpx" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pydantic", specifier = ">=2.0,<3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "coverage" }, + { name = "hypothesis" }, + { name = "mutmut", specifier = "<3" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-httpx", specifier = ">=0.30" }, + { name = "ruff" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +]