`) 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"