Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
b126e0b
feat(client): migrate transport and models to httpx
Wil-Collier May 13, 2026
c038666
docs: document 0.2 client migration
Wil-Collier May 13, 2026
35fadb4
ci: add uv test workflow
Wil-Collier May 13, 2026
64d77ad
chore: gitignore audit — add .ruff_cache, untrack docker/api_key.txt
Wil-Collier May 16, 2026
fee0c44
docs: add dev-only intent banner to docker/.env, add docker/README.md
Wil-Collier May 16, 2026
4da6f15
chore: add Apache 2.0 LICENSE and NOTICE (copyright 2026 Wil Collier)
Wil-Collier May 16, 2026
e20772e
chore: fill in pyproject.toml metadata — author, license, URLs, py.ty…
Wil-Collier May 16, 2026
b4e5dbb
feat: add _raw_request() and _stream_request() helpers to client.py
Wil-Collier May 16, 2026
f160c8e
refactor: assets.py — use _raw_request()/_stream_request(), remove in…
Wil-Collier May 16, 2026
1a8cb7f
breaking: remove self.session alias — use client._http directly
Wil-Collier May 16, 2026
dc8011f
docs: verify delete_file URL against snipe-it/develop routes/api.php …
Wil-Collier May 16, 2026
1779342
refactor: split assets.py into assets/ package (model, manager, files…
Wil-Collier May 16, 2026
f588417
chore: delete unused docs/*.json schemas (groups/audit/maintenances/r…
Wil-Collier May 16, 2026
a54f01c
docs: document extra=allow typo footgun in README Common Pitfalls + A…
Wil-Collier May 16, 2026
8364cc4
feat: snapshot-and-diff dirty tracking — in-place mutation of nested …
Wil-Collier May 16, 2026
3975fcc
refactor: rewrite all tests to use httpx_mock directly — remove reque…
Wil-Collier May 16, 2026
ea1787a
chore: delete tests/_requests_mock_shim.py — all tests now use httpx_…
Wil-Collier May 16, 2026
6606758
ci: add pydantic version matrix (2.0.x and 2.10.x) to CI
Wil-Collier May 16, 2026
2ee54cc
test: add _apply_server_data regression tests; fix stale extra fields…
Wil-Collier May 16, 2026
8da709c
docs: write 0.3.0 CHANGELOG
Wil-Collier May 16, 2026
10413c2
chore: bump version to 0.3.0; update README What's New section
Wil-Collier May 16, 2026
6dcfaaf
chore: fix ruff lint (unused json imports in test files)
Wil-Collier May 16, 2026
efaed35
fix(docker): bump PHP memory_limit to 512M — seeder fails OOM with de…
Wil-Collier May 16, 2026
afa81ca
fix(Makefile): constrain test-integration to tests/integration path —…
Wil-Collier May 16, 2026
1cca516
test: remove dead-code test files and stub tests (task 1)
Wil-Collier May 16, 2026
0c1f704
test: add parametrised CRUD smoke tests; switch fixture URL to RFC 67…
Wil-Collier May 16, 2026
5e757cd
test: replace 13 duplicated CRUD files with parametrised smoke + spec…
Wil-Collier May 16, 2026
a00a73a
test: add Company and Supplier to repr parametrize (task 4)
Wil-Collier May 16, 2026
f4fe7ba
test: add integration tests for Companies and Suppliers (task 5)
Wil-Collier May 16, 2026
9a573fc
test: strengthen weak assertions; fix hypothesis sentinel; add future…
Wil-Collier May 16, 2026
337120e
test: normalise pytestmark to file-level; fix integration labels skip…
Wil-Collier May 16, 2026
6f42ee8
test: cover URL/token validation, 204 paths, error-message extraction…
Wil-Collier May 16, 2026
ac6a34f
test: cover upload_files validation/error paths and labels validation…
Wil-Collier May 16, 2026
9ced0ea
test: cover list_all no-total, checkout kwargs, localized-404 message…
Wil-Collier May 16, 2026
41dab02
test: convert integration env-var setup to MonkeyPatch.context() (tas…
Wil-Collier May 16, 2026
ea099fd
test: add strict filterwarnings=error to pytest.ini (task 19)
Wil-Collier May 16, 2026
7fd40bd
ci: add mut-quick Makefile target and advisory mutation CI job (task 20)
Wil-Collier May 16, 2026
43fbae8
test: bump coverage gate to 95%; update CHANGELOG (task 21)
Wil-Collier May 16, 2026
a34621a
test(integration): add custom fields e2e, file upload/download round-…
Wil-Collier May 16, 2026
b677c06
fix(docker): ensure api_key.txt is a regular file before bind-mount
Wil-Collier May 16, 2026
ebab8ff
fix(integration): eliminate flakiness; fix e2e tests against real Sni…
Wil-Collier May 16, 2026
063b7df
Update README.md
Wil-Collier May 17, 2026
8f336cd
Delete NOTICE
Wil-Collier May 17, 2026
9992f99
Refactor custom field handling in Asset model
Wil-Collier May 17, 2026
531ac00
feat: refactor custom-field staging on Asset
Wil-Collier May 17, 2026
607dd2c
docs: update README and CHANGELOG for v0.4.0
Wil-Collier May 17, 2026
cf4a786
ci: add integration test job to CI workflow
Wil-Collier May 17, 2026
c906504
chore: repo cleanup before merge
Wil-Collier May 17, 2026
6b2e039
docs: add RELEASING.md with versioning process
Wil-Collier May 17, 2026
7227ed3
Update uv.lock
Wil-Collier May 17, 2026
75299d0
Delete lsp.json
Wil-Collier May 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: test (py${{ matrix.python-version }}, pydantic${{ matrix.pydantic-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
pydantic-version: ["~=2.0.0", "~=2.10.0"]

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}

- name: Install deps
run: uv sync --all-extras --python ${{ matrix.python-version }}

- name: Pin pydantic version
run: uv pip install "pydantic${{ matrix.pydantic-version }}"

- name: Lint
run: uv run ruff check .

- name: Type-check
run: uv run pyright

- name: Unit tests
run: uv run pytest tests/unit tests/contract -q -m unit

- name: Coverage
if: matrix.python-version == '3.13' && matrix.pydantic-version == '~=2.10.0'
run: |
uv run coverage run -m pytest tests/unit tests/contract -q -m unit
uv run coverage report -m --fail-under=95

integration:
name: integration tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.13

- name: Install deps
run: uv sync --all-extras --python 3.13

- name: Start Snipe-IT stack
run: |
rm -rf docker/api_key.txt
touch docker/api_key.txt
cd docker && docker compose up -d

- name: Wait for API key
run: |
echo "Waiting for docker/api_key.txt (up to ~120s)..."
for i in $(seq 1 120); do
if [ -s docker/api_key.txt ]; then break; fi
sleep 1
done
if [ ! -s docker/api_key.txt ]; then
echo "Timed out waiting for docker/api_key.txt"
cd docker && docker compose logs seeder
exit 1
fi

- name: Wait for API readiness
run: |
TOKEN=$(cat docker/api_key.txt)
echo "Waiting for Snipe-IT API (up to ~120s)..."
for i in $(seq 1 120); do
code=$(curl -s -o /dev/null -w "%{http_code}" -m 5 \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
http://localhost:8000/api/v1/users/me 2>/dev/null || echo "000")
if [ "$code" = "200" ]; then
echo "API is ready"
break
fi
sleep 1
done
if [ "$code" != "200" ]; then
echo "Timed out. Last status: $code"
cd docker && docker compose logs app
exit 1
fi

- name: Integration tests
run: uv run pytest tests/integration -q -m integration
44 changes: 44 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Mutation (advisory)

on:
pull_request:
branches: [main, dev]
workflow_dispatch:

# Advisory job — never blocks merges.
# Results are uploaded as an artifact for review.

jobs:
mutation:
name: mutmut (advisory)
runs-on: ubuntu-latest
continue-on-error: true

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.13

- name: Install deps
run: uv sync --all-extras --python 3.13

- name: Run mut-quick
run: uv run make mut-quick
continue-on-error: true

- name: Print mutation summary
run: uv run mutmut results || true

- name: Upload mutation cache
uses: actions/upload-artifact@v4
if: always()
with:
name: mutmut-results
path: .mutmut-cache
retention-days: 7
10 changes: 8 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,16 @@ env/

# Local
GEMINI.md
.kiro/
.mutmut-cache
.mutmut-cache/
mutants/
*.bak
*.lock
*.html
*.tmp
*.tmp

# Tool caches
.ruff_cache/

# Docker dev secrets (generated at runtime)
docker/api_key.txt
159 changes: 159 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Changelog

## Unreleased

## 0.4.0 (2026-05-16)

### Custom-field staging refactor

- **Added** `Asset.pending_custom_fields()` — returns a dict of custom field
values staged for the next `save()` (label → value). Defensive copy.
- **Bug fix**: `Asset.set_custom_field()` followed by `save()` no longer
requires a manual `refresh()` before subsequent `set_custom_field()` calls
on the same in-memory instance. Snipe-IT's PATCH response sets
`custom_fields: null` and echoes column-name keys (`_snipeit_<col>`) 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__`**: `<SnipeIT url='https://...' token='***'>` — 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.
Loading
Loading