Context for large language models assisting with this project.
Snipepy is built on the Service Object / Facade pattern with supporting patterns and strict SOLID compliance. See ARCHITECTURE.md for the full design rationale.
| Pattern | Where | Purpose |
|---|---|---|
| Facade | Snipepy class |
Single entry point; hides HTTP session, auth, and service wiring |
| Service Object | *Service classes in src/snipepy/services/ |
One stateless class per SnipeIT resource group |
| Factory Method | Snipepy.from_env(), Config.from_env() |
Centralise object creation from environment |
| Protocol (Structural Interface) | HttpClientProtocol |
Decouple services from concrete HTTP implementation |
| Lazy Initialisation | @cached_property on each service in Snipepy |
Services created only on first access |
| Principle | How it appears in Snipepy |
|---|---|
| S: Single Responsibility | Config reads env vars; HttpClient makes HTTP calls; each *Service owns one resource group; models hold data only |
| O: Open/Closed | New HTTP status codes are handled by adding one dict entry in _STATUS_EXCEPTIONS: no branching logic changes |
| L: Liskov Substitution | Any HttpClientProtocol-satisfying object (e.g. a test mock) substitutes HttpClient without breaking service behaviour |
| I: Interface Segregation | HttpClientProtocol exposes only the eight methods services actually call |
| D: Dependency Inversion | Service classes import HttpClientProtocol (abstraction); Snipepy injects the concrete HttpClient at construction time |
Snipepyis the facade: one entry point, hides auth and session setup.- Resource operations live on stateless service objects:
client.assets.list(),client.users.get(id). - Service objects live in
src/snipepy/services/: one file per resource group. - Models (Pydantic v2) are pure data containers: they have no methods that call the API.
HttpClientis the concrete HTTP layer;HttpClientProtocolis thetyping.Protocolinterface used by service classes (enables mock injection in tests).- Errors are raised as typed exceptions (
SnipepyAuthError,SnipepyNotFoundError, …), never returned asNone.
** NEVER push commits, branches, or tags to the remote repository without explicit human approval.**
This is a hard rule. You must never run git push, gh push, or any command that transfers local changes to the remote.
Your role is to:
- Write and edit code.
- Run local checks (
tox). - Stage changes with
git addand write commit messages withgit commit. - Report what is ready for the human to push.
The human decides when and what reaches the remote. If a task requires pushing to remote, ask for permission first and wait for an explicit "yes".
Snipepy wraps the SnipeIT REST API in Python. SnipeIT is an open-source IT asset management system. Snipepy lets scripts create, update, and query assets, users, licenses, and related resources without hand-crafting HTTP calls.
src/snipepy/
__init__.py # public __all__: all exports live here
snipepy.py # Snipepy facade (cached_property services)
_config.py # Config frozen dataclass
_http_client.py # HttpClient, HttpClientProtocol, exception hierarchy
_constants.py # HTTP status code constants
_whitelist.py # Vulture allowlist
py.typed # PEP 561 marker
models/ # Pydantic v2 response models (one file per resource)
services/ # Service classes (one file per resource)
tests/
unit/
config/ # Config env parsing tests
http_client/ # HttpClient unit tests (mocked session)
models/ # Pydantic model validation tests
services/ # Service class unit tests (mocked HttpClient)
test_exceptions.py
test_public_api.py
test_snipepy.py
smoke/ # Live Docker SnipeIT instance tests
SnipeIT returns two distinct envelope shapes depending on the operation:
List endpoints (GET /hardware, GET /users, …):
{
"total": 244,
"rows": [ { "id": 1, ... }, ... ]
}Single-resource operations (create, update, checkout, …):
{
"status": "success",
"messages": "Asset created successfully",
"payload": { "id": 300, "asset_tag": "T00000300", ... }
}Validation errors (SnipeIT may return HTTP 200 with status: error):
{
"status": "error",
"messages": { "asset_tag": ["The asset tag has already been taken."] }
}HttpClient._parse_payload() handles all three shapes: list responses return the full dict, success payloads return payload, and status: error raises SnipepyValidationError.
| Method | HTTP verb | Returns |
|---|---|---|
get(path, params) |
GET | dict or list |
post(path, json) |
POST | dict |
put(path, json) |
PUT | dict |
patch(path, json) |
PATCH | dict |
delete(path) |
DELETE | dict |
upload(path, files) |
POST multipart | dict |
post_binary(path, json, accept) |
POST | bytes |
get_binary(path, params, accept) |
GET | bytes |
None-valued params are stripped before URL construction: service methods can pass optional keyword arguments directly without filtering.
SnipepyError
├── SnipepyAuthError # 401
├── SnipepyForbiddenError # 403
├── SnipepyNotFoundError # 404
├── SnipepyConflictError # 409
├── SnipepyValidationError # HTTP 200 with status: error
└── SnipepyServerError # 5xx
Every service follows a consistent pattern:
service.list(limit, offset, search, **filters) # GET, returns *List model
service.get(id) # GET /<id>, returns model
service.create(**fields) # POST, returns model
service.update(id, **fields) # PUT /<id>, returns model
service.update_partial(id, **fields) # PATCH /<id>, returns model
service.delete(id) # DELETE /<id>
service.restore(id) # POST /<id>/restore (soft-delete recovery)Resource-specific extras (checkout, checkin, file upload, audit, etc.) are documented in ARCHITECTURE.md.
- API definitions:
docs/api-web-docs/: OpenAPI 3.1.0 specs for every endpoint. - Usage scenarios:
docs/scenarios/: real workflows with curl examples and expected responses (written in Turkish). - Environment config:
.env.example: all supported environment variables. - Public API:
src/snipepy/__init__.py: the__all__list is the authoritative public surface. - Why decisions were made:
docs/adr/: Architecture Decision Records. - What was broken and how it was fixed:
docs/fixes/: Fix Notes.
Every fix that modifies source code under src/snipepy/ requires a Fix Note
in docs/fixes/ before the task is considered complete. This is not
optional. Do not report a task as finished until the Fix Note is written and
added to the index in docs/fixes/README.md.
LLMs and agents tend to fix problems silently. The code changes but no record explains what was broken, why it was broken, or how the fix was verified. Future agents reading the codebase cannot distinguish "this is intentional" from "this was a workaround", and cannot learn from previous fixes. Fix Notes make every fix legible to any future reader.
A Fix Note has four required sections. Each one has a specific job:
| Section | Job | Common failure |
|---|---|---|
| Problem | State the exact symptom: error message text, failing test names | Writing "tests failed" instead of copying the error |
| Root Cause | Explain the technical reason the code was wrong | Restating the symptom ("the field was incorrect") |
| Fix | Describe what changed and why it resolves the root cause | Listing files without explaining the connection to the root cause |
| Verification | Show named tests that now pass, before/after counts | Writing "tests pass now" without specifics |
The connection between Root Cause and Fix must be explicit. A reader who only reads the Fix section must be able to understand why that specific change was the correct one.
docs/fixes/NNNN-broken-behaviour-description.md
The title describes the broken behaviour, not the solution.
- Correct:
asset-assigned-to-accepts-integer - Wrong:
fix-asset-model,update-validation,improve-error-handling
- P1: caller receives wrong data silently, or data loss risk
- P2: test failures, incorrect API responses, wrong field values
- P3: edge case, minor, or internal tooling only
See docs/fixes/README.md for the template.
See AGENTS.md for annotated examples showing acceptable vs unacceptable content.
All commit messages follow Conventional Commits.
Full rules are in CONTRIBUTING.md Section 8 and AGENTS.md.
Before writing any commit message, run:
git diff --staged # staged changes
git diff # unstaged changes
git status # untracked/deleted filesThe commit message must reflect what the diff contains, not what the user described. If the description contradicts the diff, follow the diff.
<type>[optional scope][optional !]: <description>
[optional body]
[optional footer(s)]
| Type | When | SemVer |
|---|---|---|
feat |
New behaviour | MINOR |
fix |
Bug fix | PATCH |
! or BREAKING CHANGE footer |
Breaking API change | MAJOR |
docs, refactor, test, ci, chore, perf, style |
Other | None |
- Breaks existing public interface? Add
!and/orBREAKING CHANGEfooter. - New behaviour for callers?
feat - Fixes a bug?
fix - Documentation only?
docs - CI/CD only?
ci - Dependencies or tooling?
chore - Performance only?
perf - Restructure without behaviour change?
refactor - Tests only?
test - Formatting only?
style
If a change fits multiple types, split it into multiple commits.
feat(api)!: remove deprecated list_all() method
BREAKING CHANGE: list_all() is removed. Use list(limit=0) instead.
BREAKING CHANGE in footers must be uppercase.
- Imperative mood: "add", "fix", "remove", not "added" or "fixes".
- Lowercase first letter.
- No period at the end.
- Max 72 characters for the entire first line.
| Incorrect | Problem | Correct |
|---|---|---|
Added login feature |
No type prefix | feat: add login feature |
fix:token error |
Missing space after colon | fix: resolve token expiry error |
fix: bug |
Too vague | fix: prevent null pointer in array parser |
feat: add things and fix stuff |
Two concerns in one | Split into two commits |
- Never generate, suggest, or reproduce API tokens in code, comments, or output.
- Always load credentials from environment variables or
.envfiles. Never hardcode them. - The
.envfile must never be committed to version control (it is already in.gitignore).
Snipepy values readability over cleverness. When generating code:
- Use self-documenting names: Function/method names should explain what and why. Avoid abbreviations (
usr→user), single letters (x,y), generic names (data,info). - Comments in third-person: When comments are necessary, write them in English using third-person impersonal voice (e.g., "Checks if the response contains a valid token." or "The trailing slash is removed to prevent double slashes in URLs."). Never describe what the code does: the names should do that.
- Type hints required: Every function must have parameter and return type hints.
- Short, focused functions: If a function is hard to name, it is probably doing too much.
See AGENTS.md for the full guidelines.
- Trailing slash in
SNIPEPY_URL:Config.from_env()strips a trailing slash from the URL. The client always appends/api/v1/<path>, so callers must not include the API prefix in the base URL. status: erroron HTTP 200: SnipeIT returns validation errors as{"status": "error", "messages": {...}}with HTTP 200.HttpClient._parse_payload()detects this and raisesSnipepyValidationError. Any code that catches HTTP errors must also handle this case.- Paginated lists: List endpoints accept
limitandoffset. There is no cursor-based pagination: callers must iterate manually. extra="allow"on all models: SnipeIT's API evolves; models accept unknown fields without raising errors. Do not rely onmodel_dump()returning only known fields.- Binary responses: Label generation and backup downloads return binary content (
bytes), not JSON. Usepost_binary()orget_binary()for these endpoints. - Checkout types:
assets.checkout()acceptscheckout_to_typeof"user","asset", or"location"with the correspondingassigned_user,assigned_asset, orassigned_locationfield. - Soft deletes: SnipeIT soft-deletes most resources.
restore()is available on assets, users, companies, manufacturers, and categories. - Fieldset
get_fields():Fieldset.fieldscan be either alistor a paginated{"total": N, "rows": [...]}dict depending on the SnipeIT version. UseFieldset.get_fields()to always get a flat list.