Skip to content

Add asset restore/audit/licenses/files/labels APIs and tests#8

Merged
wilcollier merged 4 commits into
mainfrom
Dev
Sep 16, 2025
Merged

Add asset restore/audit/licenses/files/labels APIs and tests#8
wilcollier merged 4 commits into
mainfrom
Dev

Conversation

@wilcollier

@wilcollier wilcollier commented Sep 16, 2025

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Restore assets.
    • Audit support: run by asset, list due/overdue.
    • Manage asset files: list, upload, download, delete.
    • Retrieve asset licenses.
    • Generate and save asset labels with improved PDF handling.
    • More robust asset lookup by serial number.
  • Tests

    • Extensive new integration tests across major resources.
    • Added unit tests covering asset endpoints and workflows.
  • Chores

    • Updated ignore patterns.
    • Reorganized project configuration and added dev dependency groups.

Wil Collier added 4 commits September 15, 2025 16:58
Enhance integration testing setup by adding pytest fixtures in conftest.py
for shared test data (e.g., manufacturers, categories) and utilities like
unique naming and ID extraction to prevent collisions. Introduce new test
files for custom fields and fieldsets, verifying create, read, update,
delete operations via the SnipeIT client. Update pyproject.toml to include
dev dependency group for testing tools like pytest, coverage, and ruff.
Add *.html and *.tmp patterns to prevent committing generated HTML outputs
and temporary files, keeping the repository clean.
- Updated `Asset.audit()` docstring to clarify ID-based auditing via POST /hardware/{id}/audit, with optional fields like location_id and next_audit_date.
- Added `Asset.restore()` method to handle soft-deleted assets via POST /hardware/{id}/restore.
- In `AssetsManager`:
  - Added `audit_by_id()` for targeted audits (POST /hardware/audit/{id}).
  - Added `list_audit_overdue()` and `list_audit_due()` for retrieving due/overdue audits (GET endpoints).
  - Refactored `get_by_serial()` to robustly handle single-object or envelope responses (with rows/total), improving error handling for not-found or multi-match cases.
- Retained `create_maintenance()` as-is per request, for future handling.

These changes enhance asset management capabilities and API response flexibility in the Snipe-IT integration.
@coderabbitai

coderabbitai Bot commented Sep 16, 2025

Copy link
Copy Markdown

Walkthrough

Adds ignore patterns in .gitignore and restructures pyproject.toml with dependency groups. Expands assets API: restore, audits, licenses, files, labels handling, and robust serial lookup. Introduces extensive integration scaffolding and CRUD tests across resources; replaces two legacy asset integration tests. Adds unit tests for new asset endpoints and flows.

Changes

Cohort / File(s) Summary
Repository config
\.gitignore, pyproject.toml
.gitignore adds *.html and *.tmp; lock rule unchanged. pyproject.toml moves packages to top-level and adds [dependency-groups].dev with testing/linting/type-checking tools.
Assets API enhancements
snipeit/resources/assets.py
Adds restore, audit endpoints (by id, due/overdue), licenses and file operations (list/upload/download/delete), improved labels generation (PDF/JSON with base64), and robust get_by_serial response handling.
Integration test scaffolding
tests/integration/conftest.py
Introduces shared fixtures/utilities: unique-name generator, ID coercer, run ID, and a base setup creating common resources with teardown logic.
Integration tests added
tests/integration/resources/*
New CRUD and lifecycle tests for accessories, assets, categories, components, consumables, departments, fields, fieldsets, licenses, locations, manufacturers, models, status labels, and users; include positive/negative paths and cleanup.
Integration tests removed
tests/integration/resources/test_assets_checkout_integration.py, tests/integration/resources/test_assets_integration.py
Deletes older asset checkout and CRUD integration tests superseded by new coverage.
Unit tests for assets
tests/unit/test_assets_endpoints.py
Adds unit tests for labels (base64/PDF), audits, restore, licenses, files, and get_by_serial envelope handling using requests-mock and tmp paths.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Dev as Caller
  participant AM as AssetsManager
  participant API as Snipe-IT API
  participant FS as Filesystem

  rect rgba(200,220,255,0.3)
  note over Dev,AM: Labels generation (PDF or JSON/base64)
  Dev->>AM: labels(save_path, assets_or_tags)
  AM->>API: POST /hardware/labels (accept pdf,json)
  alt PDF response
    API-->>AM: application/pdf (binary)
    AM->>FS: write PDF bytes
    AM-->>Dev: return save_path
  else JSON response with file contents
    API-->>AM: application/json { pdf_base64 | payload.file_contents }
    AM->>AM: decode base64
    AM->>FS: write PDF bytes
    AM-->>Dev: return save_path
  else Error JSON
    API-->>AM: error payload
    AM-->>Dev: raise error
  end
  end

  rect rgba(200,255,200,0.3)
  note over Dev,AM: File operations
  Dev->>AM: upload_files(asset_id, paths, notes?)
  AM->>API: POST /hardware/{id}/files (multipart)
  API-->>AM: upload result
  AM-->>Dev: result

  Dev->>AM: list_files(asset_id)
  AM->>API: GET /hardware/{id}/files
  API-->>AM: files[]
  AM-->>Dev: files[]

  Dev->>AM: download_file(asset_id, file_id, save_path)
  AM->>API: GET /hardware/{id}/files/{fid}/download
  API-->>AM: file bytes
  AM->>FS: write bytes
  AM-->>Dev: return save_path

  Dev->>AM: delete_file(asset_id, file_id)
  AM->>API: DELETE /hardware/{id}/files/{fid}
  API-->>AM: 204/ok
  AM-->>Dev: None
  end

  rect rgba(255,240,200,0.35)
  note over Dev,AM: Restore and audits
  Dev->>AM: restore(asset_id) or asset.restore()
  AM->>API: POST /hardware/{id}/restore
  API-->>AM: ok
  AM->>API: GET /hardware/{id}
  API-->>AM: asset
  AM-->>Dev: asset

  Dev->>AM: audit_by_id(asset_id, payload)
  AM->>API: POST /hardware/audit/{id}
  API-->>AM: audit result
  AM-->>Dev: result

  Dev->>AM: list_audit_due()/overdue()
  AM->>API: GET /hardware/audit/(due|overdue)
  API-->>AM: {}
  AM-->>Dev: {}
  end

  rect rgba(255,220,230,0.35)
  note over Dev,AM: Robust serial lookup
  Dev->>AM: get_by_serial(serial)
  AM->>API: GET /hardware?search=serial:...
  alt Single object
    API-->>AM: {id, ...}
  else Envelope
    API-->>AM: {rows:[{id,...}], total:n}
    AM->>AM: validate 1 match
  end
  AM-->>Dev: Asset or not found/multi-match error
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

In burrows of tests I thump with glee,
New assets restored, labels set free.
I stash my files, base64 treats,
Audit the trails of hoppy feats.
With fixtures snug and CRUD so bright—
I nose-twitch, merge, and bound to light. 🐇✨

✨ Finishing touches
  • 📝 Docstrings were successfully generated. (🔄 Check again to generate docstrings again)
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch Dev

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Pre-merge checks

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title concisely and accurately summarizes the primary changes in the patch—addition of asset-related APIs (restore, audit, licenses, files, labels) and corresponding tests—so it directly reflects the changeset and is specific enough for reviewers to understand the main intent.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/integration/conftest.py (1)

27-45: Don’t override env; prefer env if set, fallback to docker/api_key.txt.

Current logic always sets URL and requires the file token, which breaks CI where env is already provided.

-    # URL for local Snipe-IT in docker-compose
-    os.environ["SNIPEIT_TEST_URL"] = "http://localhost:8000"
-
-    # Ensure API key exists and is non-empty; otherwise skip integration suite
-    if not api_key_file.exists():
-        pytest.skip(
-            "Integration tests require docker/api_key.txt. "
-            "Run 'make test-integration' to start the local Snipe-IT and generate a token."
-        )
-
-    token = api_key_file.read_text().strip()
-    if not token:
-        pytest.skip(
-            "Integration tests require a non-empty docker/api_key.txt. "
-            "Run 'make test-integration' to start the local Snipe-IT and generate a token."
-        )
-
-    os.environ["SNIPEIT_TEST_TOKEN"] = token
+    # Use URL from env if present; otherwise default to local docker-compose
+    os.environ.setdefault("SNIPEIT_TEST_URL", "http://localhost:8000")
+
+    # Prefer SNIPEIT_TEST_TOKEN from env; otherwise read docker/api_key.txt; skip only if neither.
+    token = os.environ.get("SNIPEIT_TEST_TOKEN")
+    if not token:
+        if not api_key_file.exists():
+            pytest.skip(
+                "Integration tests require SNIPEIT_TEST_TOKEN env var or docker/api_key.txt. "
+                "Run 'make test-integration' to start the local Snipe-IT and generate a token."
+            )
+        token = api_key_file.read_text().strip()
+        if not token:
+            pytest.skip(
+                "Integration tests require non-empty SNIPEIT_TEST_TOKEN env var or docker/api_key.txt."
+            )
+        os.environ["SNIPEIT_TEST_TOKEN"] = token
🧹 Nitpick comments (36)
.gitignore (1)

68-70: Don't blanket-ignore lockfiles.

Ignoring *.lock can hinder reproducible builds (Poetry, Pipenv, uv). Prefer committing lockfiles and scoping ignores narrowly if needed.

Apply this diff to keep lockfiles versioned while still ignoring temporary artifacts:

-*.lock
 *.html
 *.tmp
+# If you truly need to ignore specific editor/system locks, scope narrowly:
+# .*.swp handled above; add others explicitly rather than *.lock
pyproject.toml (2)

31-33: Use setuptools package discovery instead of a hard-coded list.

Hard-coding packages risks missing new subpackages. Let setuptools find snipeit*.

-[tool.setuptools]
-packages = ["snipeit", "snipeit.resources"]
+[tool.setuptools.packages.find]
+include = ["snipeit*"]

19-29: Avoid duplicating dev dependencies in two mechanisms.

You already declare dev under [project.optional-dependencies]. Having a second dev under [dependency-groups] risks drift. Pick one (extras for pip, or groups for uv) and document how to install.

Minimal cleanup to rely on standard extras:

-[dependency-groups]
-dev = ["requests-mock",
-    "pytest",
-    "pytest-cov",
-    "coverage",
-    "hypothesis",
-    "mutmut<3",
-    "ruff",
-    "pyright",
-]

Optionally add a note to README: pip install -e .[dev].

Also applies to: 34-43

tests/integration/resources/test_licenses.py (1)

34-37: Narrow cleanup exceptions; don't swallow real errors.

Catching Exception and pass can hide API/transport issues. Ignore only “not found”, surface others.

-        try:
-            c.licenses.delete(id_int(lic))
-        except Exception:
-            pass
+        try:
+            c.licenses.delete(id_int(lic))
+        except SnipeITApiError as e:  # type: ignore[name-defined]
+            if getattr(e, "status_code", None) not in (404,):
+                raise

Add at top if not already imported:

-from snipeit import SnipeIT
+from snipeit import SnipeIT
+from snipeit.exceptions import SnipeITApiError
tests/integration/resources/test_fieldsets.py (2)

29-33: Make the delete assertion resilient.

Asserting on error text "in use" is brittle. Prefer checking status code (e.g., 422/400) when deletion is blocked.

-        except SnipeITApiError as e:
-            assert "in use" in str(e).lower()
+        except SnipeITApiError as e:
+            assert getattr(e, "status_code", None) in (400, 409, 422)

35-38: Don't blanket-suppress cleanup failures.

Limit to not-found; re-raise unexpected errors.

-        try:
-            c.fieldsets.delete(id_int(fs))
-        except Exception:
-            pass
+        try:
+            c.fieldsets.delete(id_int(fs))
+        except SnipeITApiError as e:
+            if getattr(e, "status_code", None) not in (404,):
+                raise
tests/integration/resources/test_manufacturers.py (2)

34-35: Verify that save() persisted the change.

After updated.save(), fetch and assert the note to validate instance persistence.

         updated.notes = f"note-{run_id}"
         updated.save()
+        got_after = c.manufacturers.get(id_int(updated))
+        assert getattr(got_after, "notes", "") == f"note-{run_id}"

37-40: Tighten cleanup exception handling.

Only ignore 404; re-raise others so test failures aren’t masked.

-        try:
-            c.manufacturers.delete(id_int(created))
-        except Exception:
-            pass
+        try:
+            c.manufacturers.delete(id_int(created))
+        except SnipeITApiError as e:
+            if getattr(e, "status_code", None) not in (404,):
+                raise

Add import if missing:

 from snipeit.exceptions import (
     SnipeITNotFoundError,
     SnipeITApiError,
 )
tests/integration/resources/test_fields.py (1)

35-38: Narrow exception in cleanup.

Mirror other tests: ignore only 404 to avoid hiding real API issues.

-        try:
-            c.fields.delete(id_int(fld))
-        except Exception:
-            pass
+        try:
+            c.fields.delete(id_int(fld))
+        except SnipeITApiError as e:
+            if getattr(e, "status_code", None) not in (404,):
+                raise

Ensure SnipeITApiError is imported at top (it is).

tests/integration/resources/test_locations.py (2)

21-25: Avoid getattr with a constant; access the attribute directly.

Small readability nit; also satisfies Ruff B009.

-        else:
-            assert int(getattr(got_child, "parent_id")) == id_int(root)
+        else:
+            assert int(got_child.parent_id) == id_int(root)

36-43: Tighten cleanup exception handling.

Ignore only 404; re-raise others.

-        try:
-            c.locations.delete(id_int(child))
-        except Exception:
-            pass
+        try:
+            c.locations.delete(id_int(child))
+        except SnipeITApiError as e:
+            if getattr(e, "status_code", None) not in (404,):
+                raise
-        try:
-            c.locations.delete(id_int(root))
-        except Exception:
-            pass
+        try:
+            c.locations.delete(id_int(root))
+        except SnipeITApiError as e:
+            if getattr(e, "status_code", None) not in (404,):
+                raise

Add import if missing:

 from snipeit.exceptions import (
     SnipeITNotFoundError,
     SnipeITApiError,
 )
tests/integration/resources/test_consumables.py (1)

34-37: Narrow cleanup exceptions.

Consistent with other tests: ignore 404 only.

-        try:
-            c.consumables.delete(id_int(cons))
-        except Exception:
-            pass
+        try:
+            c.consumables.delete(id_int(cons))
+        except SnipeITApiError as e:  # type: ignore[name-defined]
+            if getattr(e, "status_code", None) not in (404,):
+                raise

Add at top if not already imported:

-from snipeit import SnipeIT
+from snipeit import SnipeIT
+from snipeit.exceptions import SnipeITApiError
tests/integration/resources/test_status_labels.py (1)

29-32: Narrow cleanup exceptions instead of except Exception.

Catching all exceptions can hide real failures during teardown. Limit to expected API errors.

-    finally:
-        try:
-            c.status_labels.delete(id_int(lab))
-        except Exception:
-            pass
+    finally:
+        try:
+            c.status_labels.delete(id_int(lab))
+        except (SnipeITNotFoundError, SnipeITApiError):
+            pass
tests/integration/resources/test_accessories.py (1)

45-48: Tighten teardown exception handling.

Avoid a blind except. Restrict to known API exceptions.

-    finally:
-        try:
-            c.accessories.delete(id_int(acc))
-        except Exception:
-            pass
+    finally:
+        try:
+            c.accessories.delete(id_int(acc))
+        except (SnipeITNotFoundError, SnipeITApiError):
+            pass
tests/integration/resources/test_categories.py (1)

34-37: Avoid catching Exception in cleanup.

Scope the except to expected API failures.

-    finally:
-        try:
-            c.categories.delete(id_int(created))
-        except Exception:
-            pass
+    finally:
+        try:
+            c.categories.delete(id_int(created))
+        except (SnipeITNotFoundError, SnipeITApiError):
+            pass
tests/integration/resources/test_components.py (1)

35-38: Refine teardown exception handling.

Don’t swallow unexpected errors.

-    finally:
-        try:
-            c.components.delete(id_int(comp))
-        except Exception:
-            pass
+    finally:
+        try:
+            c.components.delete(id_int(comp))
+        except (SnipeITNotFoundError, SnipeITApiError):
+            pass
tests/integration/resources/test_models.py (1)

43-46: Limit broad exception in cleanup.

Prefer explicit API exceptions.

-    finally:
-        try:
-            c.models.delete(id_int(m))
-        except Exception:
-            pass
+    finally:
+        try:
+            c.models.delete(id_int(m))
+        except (SnipeITNotFoundError, SnipeITApiError):
+            pass
tests/integration/resources/test_departments.py (2)

34-38: Tighten teardown exception handling.

Avoid a blanket except.

-    finally:
-        try:
-            c.departments.delete(id_int(dep))
-        except Exception:
-            pass
+    finally:
+        try:
+            c.departments.delete(id_int(dep))
+        except (SnipeITNotFoundError, SnipeITApiError):
+            pass

39-41: Add post‑delete NotFound assertion for symmetry.

Other resource tests assert the created ID is gone after deletion; mirror that here.

-    with pytest.raises((SnipeITNotFoundError, SnipeITApiError)):
-        c.departments.get(99999999)
+    # Ensure the deleted department is not retrievable
+    with pytest.raises((SnipeITNotFoundError, SnipeITApiError)):
+        c.departments.get(id_int(dep))
+
+    # Non-existent sentinel
+    with pytest.raises((SnipeITNotFoundError, SnipeITApiError)):
+        c.departments.get(99999999)
tests/integration/resources/test_users.py (2)

19-25: Silence “hardcoded password” lint in tests.

These are test creds; annotate to appease Ruff S106 without changing behavior.

-        password="Pass1234!",
-        password_confirmation="Pass1234!",
+        password="Pass1234!",  # noqa: S106 - test credential
+        password_confirmation="Pass1234!",  # noqa: S106 - test credential
@@
-                password="Pass1234!",
-                password_confirmation="Pass1234!",
+                password="Pass1234!",  # noqa: S106 - test credential
+                password_confirmation="Pass1234!",  # noqa: S106 - test credential

Also applies to: 36-43


55-58: Restrict teardown exceptions.

Avoid swallowing unexpected issues during cleanup.

-    finally:
-        try:
-            c.users.delete(id_int(u))
-        except Exception:
-            pass
+    finally:
+        try:
+            c.users.delete(id_int(u))
+        except (SnipeITNotFoundError, SnipeITApiError):
+            pass
tests/integration/resources/test_assets.py (3)

70-73: Narrow delete cleanup exceptions.

Limit to expected API errors.

-    finally:
-        try:
-            c.assets.delete(id_int(a))
-        except Exception:
-            pass
+    finally:
+        try:
+            c.assets.delete(id_int(a))
+        except (SnipeITNotFoundError, SnipeITApiError):
+            pass

93-107: Narrow exceptions in nested cleanup.

Same concern for the nested asset b deletion.

-        finally:
-            try:
-                c.assets.delete(id_int(b))
-            except Exception:
-                pass
+        finally:
+            try:
+                c.assets.delete(id_int(b))
+            except (SnipeITNotFoundError, SnipeITApiError):
+                pass

75-87: Optional: exercise restore flow if supported.

Given recent additions, consider restoring the asset after delete (soft‑delete path) to validate the new API.

     try:
         after = c.assets.get(id_int(a))
         deleted_markers = [
             getattr(after, "deleted_at", None),
             getattr(after, "deleted", None),
             getattr(after, "archived", None),
         ]
         assert any(bool(m) for m in deleted_markers)
     except (SnipeITNotFoundError, SnipeITApiError):
         pass
+
+# If restore is available, try it and assert the asset returns normally again.
+if hasattr(c.assets, "restore"):
+    try:
+        c.assets.restore(id_int(a))
+        restored = c.assets.get(id_int(a))
+        assert id_int(restored) == id_int(a)
+    except (SnipeITApiError, SnipeITNotFoundError):
+        # Some servers may hard-delete; accept failure.
+        pass
tests/unit/test_assets_endpoints.py (4)

2-3: Remove unused imports.

os, io, and SnipeIT are unused in this test file.

-import os
-import io
-from snipeit import SnipeIT

Also applies to: 6-6


20-20: Drop unnecessary type ignore.

labels() accepts List[str]; the ignore mask isn’t needed.

-    out = snipeit_client.assets.labels(str(save_path), ["TAG1"])  # type: ignore[arg-type]
+    out = snipeit_client.assets.labels(str(save_path), ["TAG1"])

48-50: Assert the refreshed asset to verify workflow.

Make the test validate the return from audit().

-    asset = snipeit_client.assets._make({"id": 1, "asset_tag": "A1"})
-    asset.audit(note="checked")
+    asset = snipeit_client.assets._make({"id": 1, "asset_tag": "A1"})
+    updated = asset.audit(note="checked")
+    assert updated.id == 1

9-23: Add a unit test for direct PDF responses.

You already cover the JSON base64 path; also cover Content-Type: application/pdf.

@pytest.mark.unit
def test_labels_writes_pdf_response(snipeit_client, requests_mock, tmp_path):
    pdf_bytes = b"%PDF-1.4 direct"
    requests_mock.post(
        "https://test.snipeitapp.com/api/v1/hardware/labels",
        content=pdf_bytes,
        status_code=200,
        headers={"Content-Type": "application/pdf"},
    )
    save_path = tmp_path / "labels.pdf"
    out = snipeit_client.assets.labels(str(save_path), ["TAG2"])
    assert out == str(save_path)
    assert save_path.read_bytes() == pdf_bytes
tests/integration/conftest.py (2)

8-13: Replace try/except/pass cleanup with targeted suppression.

Use contextlib.suppress(SnipeITException) to avoid catching everything and silence Ruff S110/BLE001.

@@
 import time
 import uuid
 from typing import Dict, Any
+import contextlib
+from snipeit.exceptions import SnipeITException
@@
-    try:
-        c.users.delete(_id_int(user))
-    except Exception:
-        pass
-    try:
-        c.models.delete(_id_int(model))
-    except Exception:
-        pass
-    try:
-        c.status_labels.delete(_id_int(status_deploy))
-    except Exception:
-        pass
-    try:
-        c.status_labels.delete(_id_int(status_undep))
-    except Exception:
-        pass
+    with contextlib.suppress(SnipeITException):
+        c.users.delete(_id_int(user))
+    with contextlib.suppress(SnipeITException):
+        c.models.delete(_id_int(model))
+    with contextlib.suppress(SnipeITException):
+        c.status_labels.delete(_id_int(status_deploy))
+    with contextlib.suppress(SnipeITException):
+        c.status_labels.delete(_id_int(status_undep))
@@
-    try:
-        c.locations.delete(_id_int(loc_child))
-    except Exception:
-        pass
-    try:
-        c.locations.delete(_id_int(loc_root))
-    except Exception:
-        pass
+    with contextlib.suppress(SnipeITException):
+        c.locations.delete(_id_int(loc_child))
+    with contextlib.suppress(SnipeITException):
+        c.locations.delete(_id_int(loc_root))
@@
-    for cat in (cat_asset, cat_acc, cat_comp, cat_cons, cat_lic):
-        try:
-            c.categories.delete(_id_int(cat))
-        except Exception:
-            pass
-    try:
-        c.manufacturers.delete(_id_int(mfg))
-    except Exception:
-        pass
+    for cat in (cat_asset, cat_acc, cat_comp, cat_cons, cat_lic):
+        with contextlib.suppress(SnipeITException):
+            c.categories.delete(_id_int(cat))
+    with contextlib.suppress(SnipeITException):
+        c.manufacturers.delete(_id_int(mfg))

Also applies to: 147-181


118-126: Hardcoded password in tests is acceptable; consider marking as test-only.

Optional: add an inline comment to quiet linters or add # noqa: S106 on both password lines.

snipeit/resources/assets.py (6)

231-241: Preserve response context and exception chaining on upload error.

Pass resp into SnipeITApiError and chain JSON decode failures.

-            if resp.status_code >= 400:
+            if resp.status_code >= 400:
                 try:
-                    body = resp.json()
+                    body = resp.json()
                     msg = body.get("messages") or body.get("message") or resp.reason
                 except ValueError:
                     msg = resp.text or resp.reason
-                raise SnipeITApiError(str(msg))
+                raise SnipeITApiError(str(msg), resp)
             try:
-                return resp.json()
-            except ValueError:
-                raise SnipeITApiError("Expected JSON response from file upload")
+                return resp.json()
+            except ValueError as e:
+                raise SnipeITApiError("Expected JSON response from file upload") from e

253-260: Include response on download errors.

Propagate resp to keep status_code/body for callers.

-    if resp.status_code != 200:
+    if resp.status_code != 200:
         try:
             body = resp.json()
             msg = body.get("messages") or body.get("message") or resp.reason
         except ValueError:
             msg = resp.text or resp.reason
-        raise SnipeITApiError(str(msg))
+        raise SnipeITApiError(str(msg), resp)

251-265: Stream downloads to avoid large-memory reads.

Use stream=True and chunked writes.

-    resp = self.api.session.get(url, timeout=self.api.timeout)
+    resp = self.api.session.get(url, timeout=self.api.timeout, stream=True)
@@
-    with open(save_path, "wb") as f:
-        f.write(resp.content)
+    with open(save_path, "wb") as f:
+        for chunk in resp.iter_content(chunk_size=8192):
+            if chunk:
+                f.write(chunk)

308-315: Preserve response on labels errors.

Pass resp into the error for better diagnostics.

-    if resp.status_code >= 400:
+    if resp.status_code >= 400:
         try:
             body = resp.json()
             msg = body.get("messages") or body.get("message") or resp.reason
         except ValueError:
             msg = resp.text or resp.reason
-        raise SnipeITApiError(str(msg))
+        raise SnipeITApiError(str(msg), resp)

343-346: Validate base64 input and chain the original error.

Use validate=True and from e.

-    try:
-        pdf_bytes = base64.b64decode(b64)
-    except Exception as e:
-        raise SnipeITApiError(f"Failed to decode label file: {e}")
+    try:
+        pdf_bytes = base64.b64decode(b64, validate=True)
+    except Exception as e:
+        raise SnipeITApiError(f"Failed to decode label file: {e}") from e

188-189: Clarify unexpected byserial response.

Include type info to aid debugging.

-        raise SnipeITApiError("Unexpected response for byserial")
+        raise SnipeITApiError(f"Unexpected response for byserial: {type(response).__name__}")
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ead8f40 and 35849c0.

📒 Files selected for processing (21)
  • .gitignore (1 hunks)
  • pyproject.toml (1 hunks)
  • snipeit/resources/assets.py (7 hunks)
  • tests/integration/conftest.py (2 hunks)
  • tests/integration/resources/test_accessories.py (1 hunks)
  • tests/integration/resources/test_assets.py (1 hunks)
  • tests/integration/resources/test_assets_checkout_integration.py (0 hunks)
  • tests/integration/resources/test_assets_integration.py (0 hunks)
  • tests/integration/resources/test_categories.py (1 hunks)
  • tests/integration/resources/test_components.py (1 hunks)
  • tests/integration/resources/test_consumables.py (1 hunks)
  • tests/integration/resources/test_departments.py (1 hunks)
  • tests/integration/resources/test_fields.py (1 hunks)
  • tests/integration/resources/test_fieldsets.py (1 hunks)
  • tests/integration/resources/test_licenses.py (1 hunks)
  • tests/integration/resources/test_locations.py (1 hunks)
  • tests/integration/resources/test_manufacturers.py (1 hunks)
  • tests/integration/resources/test_models.py (1 hunks)
  • tests/integration/resources/test_status_labels.py (1 hunks)
  • tests/integration/resources/test_users.py (1 hunks)
  • tests/unit/test_assets_endpoints.py (1 hunks)
💤 Files with no reviewable changes (2)
  • tests/integration/resources/test_assets_checkout_integration.py
  • tests/integration/resources/test_assets_integration.py
🧰 Additional context used
🧬 Code graph analysis (17)
tests/integration/resources/test_fieldsets.py (5)
snipeit/client.py (1)
  • SnipeIT (18-200)
snipeit/exceptions.py (1)
  • SnipeITApiError (19-30)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (3)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
snipeit/resources/base.py (1)
  • save (55-80)
tests/integration/resources/test_departments.py (5)
snipeit/client.py (1)
  • SnipeIT (18-200)
snipeit/exceptions.py (2)
  • SnipeITNotFoundError (38-40)
  • SnipeITApiError (19-30)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (3)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
snipeit/resources/base.py (1)
  • save (55-80)
tests/integration/resources/test_licenses.py (4)
snipeit/client.py (1)
  • SnipeIT (18-200)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (4)
  • base (82-181)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
snipeit/resources/base.py (1)
  • save (55-80)
tests/integration/resources/test_locations.py (4)
snipeit/client.py (1)
  • SnipeIT (18-200)
snipeit/exceptions.py (2)
  • SnipeITNotFoundError (38-40)
  • SnipeITApiError (19-30)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (3)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
tests/integration/resources/test_categories.py (4)
snipeit/client.py (1)
  • SnipeIT (18-200)
snipeit/exceptions.py (2)
  • SnipeITNotFoundError (38-40)
  • SnipeITApiError (19-30)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (3)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
tests/integration/resources/test_accessories.py (6)
snipeit/client.py (1)
  • SnipeIT (18-200)
snipeit/exceptions.py (4)
  • SnipeITNotFoundError (38-40)
  • SnipeITValidationError (43-58)
  • SnipeITClientError (61-63)
  • SnipeITApiError (19-30)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (4)
  • base (82-181)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
snipeit/resources/base.py (1)
  • save (55-80)
snipeit/resources/accessories.py (1)
  • checkin_from_user (40-55)
tests/integration/resources/test_fields.py (5)
snipeit/client.py (1)
  • SnipeIT (18-200)
snipeit/exceptions.py (2)
  • SnipeITNotFoundError (38-40)
  • SnipeITApiError (19-30)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (3)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
snipeit/resources/base.py (1)
  • save (55-80)
tests/integration/resources/test_models.py (5)
snipeit/client.py (1)
  • SnipeIT (18-200)
snipeit/exceptions.py (2)
  • SnipeITNotFoundError (38-40)
  • SnipeITApiError (19-30)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (4)
  • base (82-181)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
snipeit/resources/base.py (1)
  • save (55-80)
tests/integration/resources/test_consumables.py (3)
snipeit/client.py (1)
  • SnipeIT (18-200)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (4)
  • base (82-181)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
tests/unit/test_assets_endpoints.py (3)
tests/conftest.py (1)
  • snipeit_client (7-9)
snipeit/resources/assets.py (12)
  • labels (272-349)
  • audit_by_id (122-124)
  • audit (69-84)
  • list_audit_overdue (126-128)
  • list_audit_due (130-132)
  • restore (86-90)
  • get_licenses (206-208)
  • list_files (211-213)
  • upload_files (215-247)
  • download_file (249-265)
  • delete_file (267-269)
  • get_by_serial (153-188)
snipeit/resources/base.py (1)
  • _make (147-148)
tests/integration/resources/test_users.py (5)
snipeit/client.py (1)
  • SnipeIT (18-200)
snipeit/exceptions.py (3)
  • SnipeITValidationError (43-58)
  • SnipeITNotFoundError (38-40)
  • SnipeITApiError (19-30)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (3)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
snipeit/resources/users.py (1)
  • me (34-41)
tests/integration/resources/test_manufacturers.py (4)
snipeit/exceptions.py (2)
  • SnipeITNotFoundError (38-40)
  • SnipeITApiError (19-30)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (3)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
snipeit/resources/base.py (2)
  • list_all (162-183)
  • save (55-80)
tests/integration/resources/test_assets.py (6)
snipeit/client.py (1)
  • SnipeIT (18-200)
snipeit/exceptions.py (4)
  • SnipeITNotFoundError (38-40)
  • SnipeITValidationError (43-58)
  • SnipeITClientError (61-63)
  • SnipeITApiError (19-30)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (4)
  • base (82-181)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
snipeit/resources/base.py (1)
  • save (55-80)
snipeit/resources/assets.py (6)
  • get_by_tag (134-151)
  • get_by_serial (153-188)
  • checkout (26-53)
  • checkin (55-67)
  • audit (69-84)
  • labels (272-349)
tests/integration/resources/test_components.py (4)
snipeit/client.py (1)
  • SnipeIT (18-200)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (4)
  • base (82-181)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
snipeit/resources/base.py (1)
  • save (55-80)
tests/integration/resources/test_status_labels.py (4)
snipeit/client.py (1)
  • SnipeIT (18-200)
snipeit/exceptions.py (2)
  • SnipeITNotFoundError (38-40)
  • SnipeITApiError (19-30)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
tests/integration/conftest.py (3)
  • run_id (61-63)
  • _n (67-72)
  • id_int (76-78)
tests/integration/conftest.py (2)
snipeit/client.py (1)
  • SnipeIT (18-200)
tests/conftest.py (1)
  • real_snipeit_client (13-26)
snipeit/resources/assets.py (3)
snipeit/resources/base.py (6)
  • _create (116-118)
  • refresh (82-90)
  • _get (112-114)
  • get (185-189)
  • _make (147-148)
  • _delete (124-127)
snipeit/exceptions.py (2)
  • SnipeITNotFoundError (38-40)
  • SnipeITApiError (19-30)
snipeit/client.py (2)
  • get (179-181)
  • post (183-185)
🪛 Ruff (0.12.2)
tests/integration/resources/test_fieldsets.py

37-38: try-except-pass detected, consider logging the exception

(S110)


37-37: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_departments.py

37-38: try-except-pass detected, consider logging the exception

(S110)


37-37: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_licenses.py

36-37: try-except-pass detected, consider logging the exception

(S110)


36-36: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_locations.py

24-24: Do not call getattr with a constant attribute value. It is not any safer than normal property access.

Replace getattr with attribute access

(B009)


38-39: try-except-pass detected, consider logging the exception

(S110)


38-38: Do not catch blind exception: Exception

(BLE001)


42-43: try-except-pass detected, consider logging the exception

(S110)


42-42: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_categories.py

36-37: try-except-pass detected, consider logging the exception

(S110)


36-36: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_accessories.py

47-48: try-except-pass detected, consider logging the exception

(S110)


47-47: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_fields.py

37-38: try-except-pass detected, consider logging the exception

(S110)


37-37: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_models.py

45-46: try-except-pass detected, consider logging the exception

(S110)


45-45: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_consumables.py

36-37: try-except-pass detected, consider logging the exception

(S110)


36-36: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_users.py

23-23: Possible hardcoded password assigned to argument: "password"

(S106)


24-24: Possible hardcoded password assigned to argument: "password_confirmation"

(S106)


41-41: Possible hardcoded password assigned to argument: "password"

(S106)


42-42: Possible hardcoded password assigned to argument: "password_confirmation"

(S106)


57-58: try-except-pass detected, consider logging the exception

(S110)


57-57: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_manufacturers.py

39-40: try-except-pass detected, consider logging the exception

(S110)


39-39: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_assets.py

72-73: try-except-pass detected, consider logging the exception

(S110)


72-72: Do not catch blind exception: Exception

(BLE001)


105-106: try-except-pass detected, consider logging the exception

(S110)


105-105: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_components.py

37-38: try-except-pass detected, consider logging the exception

(S110)


37-37: Do not catch blind exception: Exception

(BLE001)

tests/integration/resources/test_status_labels.py

31-32: try-except-pass detected, consider logging the exception

(S110)


31-31: Do not catch blind exception: Exception

(BLE001)

tests/integration/conftest.py

124-124: Possible hardcoded password assigned to argument: "password"

(S106)


125-125: Possible hardcoded password assigned to argument: "password_confirmation"

(S106)


149-150: try-except-pass detected, consider logging the exception

(S110)


149-149: Do not catch blind exception: Exception

(BLE001)


153-154: try-except-pass detected, consider logging the exception

(S110)


153-153: Do not catch blind exception: Exception

(BLE001)


157-158: try-except-pass detected, consider logging the exception

(S110)


157-157: Do not catch blind exception: Exception

(BLE001)


161-162: try-except-pass detected, consider logging the exception

(S110)


161-161: Do not catch blind exception: Exception

(BLE001)


166-167: try-except-pass detected, consider logging the exception

(S110)


166-166: Do not catch blind exception: Exception

(BLE001)


170-171: try-except-pass detected, consider logging the exception

(S110)


170-170: Do not catch blind exception: Exception

(BLE001)


176-177: try-except-pass detected, consider logging the exception

(S110)


176-176: Do not catch blind exception: Exception

(BLE001)


180-181: try-except-pass detected, consider logging the exception

(S110)


180-180: Do not catch blind exception: Exception

(BLE001)

snipeit/resources/assets.py

176-176: Avoid specifying long messages outside the exception class

(TRY003)


181-181: Avoid specifying long messages outside the exception class

(TRY003)


182-182: Avoid specifying long messages outside the exception class

(TRY003)


188-188: Avoid specifying long messages outside the exception class

(TRY003)


218-218: Avoid specifying long messages outside the exception class

(TRY003)


241-241: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


241-241: Avoid specifying long messages outside the exception class

(TRY003)


246-247: try-except-pass detected, consider logging the exception

(S110)


246-246: Do not catch blind exception: Exception

(BLE001)


330-330: Avoid specifying long messages outside the exception class

(TRY003)


341-341: Avoid specifying long messages outside the exception class

(TRY003)


345-345: Do not catch blind exception: Exception

(BLE001)


346-346: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


346-346: Avoid specifying long messages outside the exception class

(TRY003)

🔇 Additional comments (11)
tests/integration/resources/test_licenses.py (1)

24-26: Confirmed — ApiObject tracks dirty fields and save() PATCHes only changed fields.

ApiObject.setattr adds modified public attributes to _dirty_fields and save() builds/sends a PATCH from those fields and clears the set on success (snipeit/resources/base.py).

tests/integration/resources/test_status_labels.py (1)

14-28: CRUD flow looks solid.

Happy‑path coverage, patch/get verification, and list smoke check are all good.

tests/integration/resources/test_accessories.py (1)

16-44: Good end‑to‑end coverage, including negative path.

Create/patch/save/list flows and the invalid checkin path are exercised well.

tests/integration/resources/test_categories.py (1)

14-33: LGTM on CRUD assertions.

Covers create/get/list/update and post‑delete NotFound checks cleanly.

tests/integration/resources/test_components.py (1)

10-33: Component CRUD path looks good.

Assertions around qty via patch/save and list smoke check are appropriate.

tests/integration/resources/test_models.py (1)

14-41: Solid coverage of model_number updates via patch and save.

End‑to‑end behavior is exercised well.

tests/integration/resources/test_departments.py (1)

14-33: CRUD path reads cleanly.

Patch, save, and list checks are fine.

tests/integration/resources/test_users.py (1)

15-33: Users CRUD + me() coverage is on point.

Good validation of duplicate username behavior and token owner via me().

tests/integration/resources/test_assets.py (2)

19-49: Great full‑flow asset coverage.

Covers tag/serial lookups, checkout/checkin, audit, label generation, and list presence. Nice handling for differing server behavior on labels.


66-69: Potential flake: paginated list().

If list() is paginated, the new asset may not appear on the first page. Consider passing a search filter (e.g., search=a.asset_tag) or a higher limit if supported by the manager to reduce flakiness.

Would you confirm the AssetsManager.list signature supports search/limit and apply the same pattern across resource list “smoke” checks if available?

snipeit/resources/assets.py (1)

86-91: Overall: Assets API expansions look solid.

Good coverage of audits, restore, by-serial envelope handling, labels, and file ops; API paths align with tests; refresh usage is consistent.

If you want, I can add negative-path unit tests (4xx/5xx for uploads/downloads/labels) to ensure error propagation behaves as intended after the above tweaks.

Also applies to: 121-133, 205-270, 271-349

coderabbitai Bot added a commit that referenced this pull request Sep 16, 2025
Docstrings generation was requested by @wilcollier.

* #8 (comment)

The following files were modified:

* `snipeit/resources/assets.py`
* `tests/integration/conftest.py`
* `tests/integration/resources/test_components.py`
* `tests/integration/resources/test_consumables.py`
* `tests/integration/resources/test_fieldsets.py`
* `tests/integration/resources/test_locations.py`
* `tests/integration/resources/test_models.py`
@coderabbitai coderabbitai Bot mentioned this pull request Sep 16, 2025
@coderabbitai

coderabbitai Bot commented Sep 16, 2025

Copy link
Copy Markdown

Note

Generated docstrings for this pull request at #9

@wilcollier wilcollier changed the title Dev Add asset restore/audit/licenses/files/labels APIs and tests Sep 16, 2025
@wilcollier wilcollier merged commit abe3810 into main Sep 16, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant