Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@

## Unreleased

## 0.5.0 (2026-05-17)

### New features

- **Retry jitter**: `RetryTransport` now applies full-jitter
(`uniform(0, base)`) to exponential-backoff delays, desynchronising
retries across concurrent clients (thundering-herd mitigation).
`Retry-After` headers are still honoured verbatim. Pass `jitter=` to
override the strategy.
- **`refresh=False` on asset actions**: `checkout()`, `checkin()`,
`audit()`, and `restore()` accept `refresh=False` to skip the follow-up
GET, halving round trips in bulk workflows.

### Performance

- **`_fast_json_copy`**: Snapshot-based dirty tracking now uses a
JSON-specialised recursive copy instead of `copy.deepcopy`, significantly
reducing `ApiObject` construction time on large `list_all` results.
- **`list_all` page-size cap**: When the caller's remaining `limit` is
smaller than `page_size`, only the needed rows are requested from the
server. Default `page_size` raised from 50 to 100.

### Internal / testing

- **No-op retry sleep in test fixtures**: The shared `snipeit_client`
fixture and `test_logging`'s `client_with_token` now stub out the retry
transport's sleep, eliminating ~2 s of real backoff per retry-exhausting
test.
- **Property tests**: Added property-based tests for core resource manager
and `ApiObject` logic.
- **Lint hardening**: Strengthened ruff and pyright configuration; applied
isort, pyupgrade, and bugbear auto-fixes.

## 0.4.0 (2026-05-16)

### Custom-field staging refactor
Expand Down
8 changes: 3 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,17 @@ cov:

# Mutation testing (can be slow)
mut:
$(PY) -m mutmut run --paths-to-mutate snipeit --tests-dir tests || true
$(PY) -m mutmut run || 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
$(PY) -m mutmut run || true
Comment on lines 29 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update mut-quick description (or restore scoped behavior).

Line 29 says “scoped to highest-value source files”, but Line 31 runs the same full command as mut. Please align comment and behavior to avoid misleading usage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 29 - 31, The recipe comment for target mut-quick is
misleading because the mut-quick Makefile target currently runs the full mutmut
command just like mut; either update the comment or change the recipe for the
mut-quick target to run a scoped mutmut invocation (e.g., use mutmut run with
path/arg to limit to highest-value files or a predefined file list/glob), and
keep the mut target unchanged; locate the Makefile target named mut-quick and
either adjust its description text to match the current full run or modify the
command to a scoped mutmut run (using the mutmut --paths-to-mutate /
specific-paths approach or similar) so behavior and comment are aligned.


mut-report:
$(PY) -m mutmut results

mut-reset:
$(PY) -m mutmut reset || true
rm -rf .mutmut-cache

clean:
rm -rf .pytest_cache htmlcov .coverage .mutmut-cache .hypothesis .ruff_cache
Expand Down
19 changes: 14 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "snipeit-api"
version = "0.4.0"
version = "0.5.0"
description = "A Python client for the Snipe-IT API"
readme = "README.md"
requires-python = ">=3.11"
Expand Down Expand Up @@ -47,12 +47,21 @@ dev = [
"pytest-httpx>=0.30",
"coverage",
"hypothesis",
"mutmut<3",
"mutmut>=3,<4",
"ruff",
"pyright",
]

[tool.mutmut]
paths_to_mutate = "snipeit"
tests_dir = "tests"
runner = "python -m pytest -q"
paths_to_mutate = ["snipeit"]
tests_dir = ["tests/unit", "tests/contract"]
mutate_only_covered_lines = true

[tool.ruff]
line-length = 120

[tool.ruff.lint]
select = ["E", "F", "B", "I", "UP", "RUF"]

[tool.ruff.lint.per-file-ignores]
"tests/**" = ["E501"]
6 changes: 5 additions & 1 deletion pyrightconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.11",
"typeCheckingMode": "basic",
"typeCheckingMode": "standard",
"reportMissingImports": "warning",
"reportMissingModuleSource": "warning",
"reportIncompatibleMethodOverride": false,
"reportIncompatibleVariableOverride": false,
Comment on lines +8 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Compare current pyright output vs. output with override checks enabled.
python - <<'PY'
import json, tempfile, subprocess, pathlib

cfg_path = pathlib.Path("pyrightconfig.json")
cfg = json.loads(cfg_path.read_text())

strict = dict(cfg)
strict["reportIncompatibleMethodOverride"] = True
strict["reportIncompatibleVariableOverride"] = True

tmp = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False)
json.dump(strict, tmp, indent=2)
tmp.close()

print("=== Current config ===")
subprocess.run([".venv/bin/pyright", "-p", str(cfg_path)], check=False)
print("\n=== With override diagnostics enabled ===")
subprocess.run([".venv/bin/pyright", "-p", tmp.name], check=False)
PY

Repository: lfctech/snipeit-python-api

Length of output: 678


🏁 Script executed:

cat -n pyrightconfig.json

Repository: lfctech/snipeit-python-api

Length of output: 700


🏁 Script executed:

# Find all Python files in the snipeit directory
find snipeit -name "*.py" -type f | head -20

Repository: lfctech/snipeit-python-api

Length of output: 649


🏁 Script executed:

wc -l snipeit/resources/base.py && cat -n snipeit/resources/base.py

Repository: lfctech/snipeit-python-api

Length of output: 19642


🏁 Script executed:

# Search for classes that inherit from ApiObject or Manager
rg "class\s+\w+\((ApiObject|Manager|BaseResourceManager)" snipeit/

Repository: lfctech/snipeit-python-api

Length of output: 2378


🏁 Script executed:

# Search for __init__ method overrides in subclasses
rg "__init__" snipeit/resources/ | grep -v "base.py" | grep "def __init__"

Repository: lfctech/snipeit-python-api

Length of output: 52


🏁 Script executed:

# Check if any resource subclasses override common base class methods
rg "def (save|refresh|delete|_dirty_set|mark_dirty|_apply_server_data|_get|_create|_patch)" snipeit/resources/ | grep -v "base.py"

Repository: lfctech/snipeit-python-api

Length of output: 329


🏁 Script executed:

cat -n snipeit/resources/assets/model.py | head -100

Repository: lfctech/snipeit-python-api

Length of output: 4587


🏁 Script executed:

wc -l snipeit/resources/assets/model.py && tail -250 snipeit/resources/assets/model.py

Repository: lfctech/snipeit-python-api

Length of output: 11388


🏁 Script executed:

# Check Asset.save() signature more carefully and compare with base
sed -n '335,340p' snipeit/resources/assets/model.py
sed -n '244,246p' snipeit/resources/base.py

Repository: lfctech/snipeit-python-api

Length of output: 407


🏁 Script executed:

# Get exact line numbers for Asset.save() signature
sed -n '335,340p' snipeit/resources/assets/model.py

Repository: lfctech/snipeit-python-api

Length of output: 321


🏁 Script executed:

# Find the exact line where save() is defined in Asset
grep -n "def save" snipeit/resources/assets/model.py

Repository: lfctech/snipeit-python-api

Length of output: 103


🏁 Script executed:

# Compare Asset.save() signature with ApiObject.save() signature
sed -n '139,145p' snipeit/resources/assets/model.py
echo "---BASE CLASS---"
sed -n '244,250p' snipeit/resources/base.py

Repository: lfctech/snipeit-python-api

Length of output: 783


🏁 Script executed:

# Search for other method overrides that might have signature differences
find snipeit/resources -name "*.py" -exec grep -l "def save\|def _apply_server_data\|def _dirty_set\|def refresh" {} \;

Repository: lfctech/snipeit-python-api

Length of output: 130


🏁 Script executed:

# Check if there are any subclasses of Asset that might have their own overrides
grep -r "class.*Asset" snipeit/ | grep -v "test" | grep -v ".pyc"

Repository: lfctech/snipeit-python-api

Length of output: 364


🏁 Script executed:

# Check AssetsManager for any method overrides
cat -n snipeit/resources/assets/manager.py

Repository: lfctech/snipeit-python-api

Length of output: 5014


Re-enable override compatibility diagnostics.

Disabling reportIncompatibleMethodOverride and reportIncompatibleVariableOverride hides real type safety issues. The codebase contains at least one actual incompatible method override: Asset.save() returns Asset instead of preserving the type variable T from the base class signature, which violates Liskov Substitution Principle and would be caught by these diagnostics.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyrightconfig.json` around lines 8 - 9, Re-enable the Pyright diagnostics by
setting reportIncompatibleMethodOverride and reportIncompatibleVariableOverride
back to true in pyrightconfig.json, then fix the incompatible override in the
Asset class: change Asset.save() signature/return type so it preserves the
generic type variable T from the base class (or make the base method
covariant/adjust generics) so the override matches the base class contract;
ensure any calls using Asset.save() still type-check after adjusting the
signature to satisfy Pyright's reportIncompatibleMethodOverride check.

"reportPrivateUsage": false,
"reportUnnecessaryTypeIgnoreComment": true,
"include": [
"snipeit"
],
Expand Down
45 changes: 36 additions & 9 deletions snipeit/_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,34 @@

from __future__ import annotations

import random
import time
from collections.abc import Callable, Iterable
from datetime import UTC, datetime
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"})


def _full_jitter(base: float) -> float:
"""Default jitter strategy: pick a delay uniformly in ``[0, base]``.

"Full jitter" desynchronises retries across many concurrent clients
(`https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/`),
avoiding the thundering-herd problem when a single Snipe-IT instance
starts returning 5xx and every client retries on the same backoff
schedule. ``base`` is the un-jittered exponential-backoff delay.
"""
if base <= 0:
return 0.0
return random.uniform(0.0, base)


class RetryTransport(httpx.BaseTransport):
"""Retry status-forcelist responses with exponential backoff.

Expand All @@ -37,14 +51,22 @@ class RetryTransport(httpx.BaseTransport):
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)``.
attempts is ``backoff_factor * (2 ** attempt)``, then passed
through ``jitter`` to spread retries across concurrent clients.
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.
``Retry-After`` delays are *not* jittered: the server gave us
an explicit instruction.
sleep: Override for :func:`time.sleep`, used by tests.
jitter: Callable mapping the un-jittered backoff base (seconds) to
an actual sleep duration. Defaults to :func:`_full_jitter`,
which picks ``uniform(0, base)``. Pass ``lambda base: base`` to
disable jitter, or another strategy (decorrelated jitter, etc.).
Only applied when ``Retry-After`` is not used.
"""

def __init__(
Expand All @@ -57,6 +79,7 @@ def __init__(
allowed_methods: Iterable[str] = DEFAULT_ALLOWED_METHODS,
respect_retry_after: bool = True,
sleep: Callable[[float], None] | None = None,
jitter: Callable[[float], float] | None = None,
) -> None:
self._wrapped = wrapped if wrapped is not None else httpx.HTTPTransport()
self.max_retries = int(max_retries)
Expand All @@ -65,9 +88,10 @@ def __init__(
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
self._jitter = jitter if jitter is not None else _full_jitter

# httpx.BaseTransport API
def handle_request(self, request: httpx.Request) -> httpx.Response: # noqa: D401
def handle_request(self, request: httpx.Request) -> httpx.Response:
method = request.method.upper()
retryable = method in self.allowed_methods
last_error: Exception | None = None
Expand Down Expand Up @@ -126,9 +150,12 @@ def close(self) -> None:

# 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 retry_after is not None:
# Server told us how long to wait. Don't second-guess with jitter.
delay = retry_after
else:
base = self.backoff_factor * (2**attempt)
delay = self._jitter(base)
if delay > 0:
self._sleep(delay)

Expand All @@ -150,6 +177,6 @@ def _parse_retry_after(value: str | None) -> float | 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()
dt = dt.replace(tzinfo=UTC)
delta = (dt - datetime.now(UTC)).total_seconds()
return max(0.0, delta)
2 changes: 1 addition & 1 deletion snipeit/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ def close(self) -> None:
"""Close the underlying HTTP session."""
self._http.close()

def __enter__(self) -> "SnipeIT":
def __enter__(self) -> SnipeIT:
return self

def __exit__(self, exc_type, exc, tb) -> bool | None:
Expand Down
1 change: 1 addition & 0 deletions snipeit/resources/accessories.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

from typing import Any

from .base import ApiObject, BaseResourceManager


Expand Down
7 changes: 4 additions & 3 deletions snipeit/resources/assets/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import os
import warnings
from typing import Any, Callable
from collections.abc import Callable
from typing import Any

from ...exceptions import SnipeITApiError

Expand Down Expand Up @@ -69,13 +70,13 @@ def upload_files(
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)
raise SnipeITApiError("Expected JSON response from file upload", response=resp) from None
finally:
for f in opened_files:
try:
f.close()
except Exception as e:
warnings.warn(f"Failed to close file {getattr(f, 'name', '<unknown>')}: {e}")
warnings.warn(f"Failed to close file {getattr(f, 'name', '<unknown>')}: {e}", stacklevel=2)

def download_file(
self,
Expand Down
4 changes: 2 additions & 2 deletions snipeit/resources/assets/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def get_by_tag(self, asset_tag: str, **kwargs: Any) -> Asset:
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.")
raise SnipeITNotFoundError(f"Asset with tag {asset_tag!r} not found.") from None

def get_by_serial(self, serial: str, **kwargs: Any) -> Asset:
"""Get a single asset by serial number.
Expand All @@ -72,7 +72,7 @@ def get_by_serial(self, serial: str, **kwargs: Any) -> Asset:
try:
response = self._get(f"{self.path}/byserial/{serial}", **kwargs)
except SnipeITNotFoundError:
raise SnipeITNotFoundError(f"Asset with serial {serial!r} not found.")
raise SnipeITNotFoundError(f"Asset with serial {serial!r} not found.") from None

if isinstance(response, dict) and "rows" in response:
if "total" not in response:
Expand Down
67 changes: 54 additions & 13 deletions snipeit/resources/assets/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,30 @@ def __repr__(self) -> str:
model_name = model.get("name", "N/A") if isinstance(model, dict) else "N/A"
return f"<Asset {asset_tag} ({name} - {serial} - {model_name})>"

def checkout(self, checkout_to_type: str, assigned_to_id: int, **kwargs: Any) -> "Asset":
def checkout(
self,
checkout_to_type: str,
assigned_to_id: int,
*,
refresh: bool = True,
**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.
refresh: When ``True`` (default), issue a follow-up ``GET`` to
refresh this object's local state from the server. Pass
``False`` for bulk operations where you do not need the
updated fields locally — this halves the round trips per
checkout. The local state will be stale until you call
:meth:`refresh` yourself.
**kwargs: Additional optional fields such as expected_checkin, note, etc.

Returns:
Asset: The updated Asset object.
Asset: The updated Asset object (or this object unchanged when
``refresh=False``).

Raises:
ValueError: If checkout_to_type is not one of "user", "asset", or "location".
Expand All @@ -75,27 +89,54 @@ def checkout(self, checkout_to_type: str, assigned_to_id: int, **kwargs: Any) ->
raise ValueError("checkout_to_type must be one of 'user', 'asset', or 'location'")
data.update(kwargs)
self._manager._create(path, data)
return self.refresh()
if refresh:
return self.refresh()
return self

def checkin(self, *, refresh: bool = True, **kwargs: Any) -> Asset:
"""Check in this asset.

def checkin(self, **kwargs: Any) -> "Asset":
"""Check in this asset."""
Args:
refresh: When ``True`` (default), refetch the asset after the
check-in. Pass ``False`` to skip the GET round trip when
you don't need the updated local state.
**kwargs: Optional check-in fields (``note``, ``location_id`` etc).
"""
self._manager._create(f"{self._path}/{self.id}/checkin", kwargs)
return self.refresh()
if refresh:
return self.refresh()
return self

def audit(self, *, refresh: bool = True, **kwargs: Any) -> Asset:
"""Audit this asset via POST /hardware/{id}/audit.

def audit(self, **kwargs: Any) -> "Asset":
"""Audit this asset via POST /hardware/{id}/audit."""
Args:
refresh: When ``True`` (default), refetch the asset after the
audit. Pass ``False`` for bulk audit jobs where the local
state is not needed.
**kwargs: Optional audit fields.
"""
self._manager._create(f"{self._path}/{self.id}/audit", kwargs)
return self.refresh()
if refresh:
return self.refresh()
return self

def restore(self, *, refresh: bool = True) -> Asset:
"""Restore a soft-deleted asset.

def restore(self) -> "Asset":
"""Restore a soft-deleted asset."""
Args:
refresh: When ``True`` (default), refetch the asset after the
restore. Pass ``False`` to skip the GET.
"""
self._manager._create(f"{self._path}/{self.id}/restore", {})
return self.refresh()
if refresh:
return self.refresh()
return self

# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
def save(self) -> "Asset":
def save(self) -> Asset:
"""Persist regular dirty fields **and** any staged custom fields.

Extends :meth:`ApiObject.save` to also flush ``_pending_custom_fields``.
Expand Down
Loading
Loading