diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6b71074d..f36463b4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -22,6 +22,9 @@ Dev venv: `/home/vscode/.local/dev-venv/bin/activate` **After any code change, always run the full validation sequence before considering the task complete:** +**Exception:** for CLI-only changes limited to `pypaperless/cli.py` and/or CLI-focused tests/docs, the live smoketest is not required on every change. +In this case, run unit tests + coverage and report that the smoketest was intentionally skipped due to CLI-only scope. + 1. **Unit tests + coverage** ``` @@ -38,18 +41,14 @@ Dev venv: `/home/vscode/.local/dev-venv/bin/activate` Required: 0 failures. -3. **API field coverage audit** - ``` - python script/pngx_audit_coverage.py - ``` - Required: no new gaps compared to before the change. - -Report all three results explicitly before closing the task. +Report both results explicitly before closing the task. --- ## Code Conventions +- **Ruff compliance is mandatory for all newly generated code**: always follow the currently active Ruff rules/configuration in this repository when writing or modifying code. +- **Mypy compliance is mandatory for all newly generated code**: all new and modified code must type-check cleanly under the repository's active mypy configuration. - Models use **Pydantic v2** (`BaseModel`, `model_validator`, `Field`). - Services use **httpx** for async HTTP. - Context managers (`@asynccontextmanager`) always use `try/finally` to guarantee cleanup. diff --git a/.github/skills/add-resource/SKILL.md b/.github/skills/add-resource/SKILL.md index ded13a41..9d0084bb 100644 --- a/.github/skills/add-resource/SKILL.md +++ b/.github/skills/add-resource/SKILL.md @@ -106,7 +106,7 @@ class DocumentHistoryService(ServiceBase): async def __call__(self, pk=None): doc_pk = self._get_document_pk(pk) res = await self._client.request_json("get", self._api_path.format(pk=doc_pk)) - return [self._resource_cls.create_with_data(self._client, {**item, "document": doc_pk}) + return [self._resource_cls.from_data(self._client, {**item, "document": doc_pk}) for item in res] def _get_document_pk(self, pk=None): diff --git a/.github/skills/add-resource/references/patterns.md b/.github/skills/add-resource/references/patterns.md index d5a88980..30f6cd1a 100644 --- a/.github/skills/add-resource/references/patterns.md +++ b/.github/skills/add-resource/references/patterns.md @@ -130,7 +130,7 @@ class DocumentHistoryService(ServiceBase): doc_pk = self._get_document_pk(pk) res = await self._client.request_json("get", self._api_path.format(pk=doc_pk)) return [ - self._resource_cls.create_with_data(self._client, {**item, "document": doc_pk}) + self._resource_cls.from_data(self._client, {**item, "document": doc_pk}) for item in res ] @@ -255,7 +255,7 @@ KNOWN_SCHEMA_EXTRAS["WidgetMeta"] = { | ------------------- | --------------------------------------------------------------------- | | `CallableMixin[T]` | `await service(pk)` — fetch single item | | `IterableMixin[T]` | `async for item in service` + `.pages()` + `.as_list()` + `.filter()` | -| `DraftableMixin[D]` | `service.draft(...)` + `await service.save(draft)` | +| `DraftableMixin[D]` | `service.create(...)` + `await service.save(draft)` | | `UpdatableMixin[T]` | `await service.update(item)` | | `DeletableMixin[T]` | `await service.delete(item)` | | `SecurableMixin` | `permissions` field + `?full_perms=true` on requests | diff --git a/docs/concepts/custom_fields.md b/docs/concepts/custom_fields.md index 4020ee39..f982c436 100644 --- a/docs/concepts/custom_fields.md +++ b/docs/concepts/custom_fields.md @@ -200,12 +200,12 @@ await paperless.documents.update(document) ## Creating a custom field -Use `draft()` and `save()` on `paperless.custom_fields`: +Use `create()` and `save()` on `paperless.custom_fields`: ```python from pypaperless.models.custom_fields import CustomFieldType -draft = paperless.custom_fields.draft( +draft = paperless.custom_fields.create( name="Invoice amount", data_type=CustomFieldType.MONETARY, ) @@ -221,7 +221,7 @@ from pypaperless.models.custom_fields import ( CustomFieldType, ) -draft = paperless.custom_fields.draft( +draft = paperless.custom_fields.create( name="Status", data_type=CustomFieldType.SELECT, extra_data=CustomFieldExtraData( diff --git a/docs/concepts/documents.md b/docs/concepts/documents.md index 4f124d87..b264ac30 100644 --- a/docs/concepts/documents.md +++ b/docs/concepts/documents.md @@ -38,9 +38,9 @@ Or using an already-fetched document: ```python doc = await paperless.documents(42) -download = await doc.get_download() -preview = await doc.get_preview() -thumbnail = await doc.get_thumbnail() +download = await doc.download() +preview = await doc.preview() +thumbnail = await doc.thumbnail() ``` `DownloadedDocument` gives you the raw bytes plus everything from the response headers you'd need to save or serve the file: @@ -110,6 +110,11 @@ Find documents similar to a given document: ```python async for document in paperless.documents.more_like(42): print(document.title) + +# or via a fetched document +doc = await paperless.documents(42) +async for document in doc.more_like(): + print(document.title) ``` --- @@ -121,7 +126,7 @@ meta = await paperless.documents.metadata(42) # or via a fetched document doc = await paperless.documents(42) -meta = await doc.get_metadata() +meta = await doc.metadata() ``` The returned `DocumentMeta` object includes embedded metadata from the file (e.g. EXIF or PDF metadata): @@ -142,7 +147,7 @@ suggestions = await paperless.documents.suggestions(42) # or via a fetched document doc = await paperless.documents(42) -suggestions = await doc.get_suggestions() +suggestions = await doc.suggestions() print(suggestions.correspondents) print(suggestions.document_types) @@ -173,7 +178,7 @@ for note in notes: ```python # Pass the document pk as the first positional argument -draft = paperless.documents.notes.draft(42, note="This needs review") +draft = paperless.documents.notes.create(42, note="This needs review") note_id, doc_id = await paperless.documents.notes.save(draft) ``` @@ -181,8 +186,11 @@ Or via a fetched document (the document pk is bound automatically): ```python doc = await paperless.documents(42) -draft = doc.notes.draft(note="This needs review") +draft = doc.notes.create(note="This needs review") note_id, doc_id = await doc.notes.save(draft) + +# or use the shortcut directly on the draft +note_id, doc_id = await draft.save() ``` ### Deleting a note @@ -190,6 +198,9 @@ note_id, doc_id = await doc.notes.save(draft) ```python note = notes[0] await paperless.documents.notes.delete(note) + +# or via the note instance directly +await note.delete() ``` --- @@ -207,13 +218,13 @@ print(f"Next ASN: {next_asn}") ## Uploading a document -Use `draft()` to construct a document upload and `save()` to submit it. The document content must be provided as `bytes`. All fields except `document` are optional. +Use `create()` to construct a document upload and `save()` to submit it. The document content must be provided as `bytes`. All fields except `document` are optional. ```python with open("invoice.pdf", "rb") as f: content = f.read() -draft = paperless.documents.draft( +draft = paperless.documents.create( document=content, # required — raw file bytes filename="invoice.pdf", # original filename title="Invoice 2024-01", @@ -245,7 +256,7 @@ cf_list = DocumentCustomFieldList(paperless, data=[]) cf_list += CustomFieldValue(field=3, value="ACME Corp") cf_list += CustomFieldValue(field=8, value=42) -draft = paperless.documents.draft(document=content, custom_fields=cf_list) +draft = paperless.documents.create(document=content, custom_fields=cf_list) ``` See [Custom fields](custom_fields.md) for the full custom field API. @@ -299,7 +310,7 @@ await paperless.documents.email( ) ``` -A single document can also be passed as an integer: +A single document can also be passed as an integer, or use the shortcut on a fetched `Document` instance: ```python await paperless.documents.email( @@ -309,6 +320,15 @@ await paperless.documents.email( message="See attachment.", use_archive_version=False, # send original instead of archived version ) + +# shortcut — document pk is bound automatically +doc = await paperless.documents(42) +await doc.email( + addresses="alice@example.com", + subject="Invoice", + message="See attachment.", + use_archive_version=False, +) ``` | Parameter | Default | Description | diff --git a/docs/concepts/permissions.md b/docs/concepts/permissions.md index b908dcd0..c542740f 100644 --- a/docs/concepts/permissions.md +++ b/docs/concepts/permissions.md @@ -94,7 +94,7 @@ perms = Permissions( change_groups=[1], ) -draft = paperless.tags.draft( +draft = paperless.tags.create( name="confidential", owner=1, set_permissions=perms, diff --git a/docs/exceptions.md b/docs/exceptions.md index cec24f17..e4395001 100644 --- a/docs/exceptions.md +++ b/docs/exceptions.md @@ -108,7 +108,7 @@ Base class for exceptions raised during draft lifecycle operations. Catch this t Raised by `draft.validate_draft()` (called automatically inside `save()`) when one or more required fields are missing. ```python -draft = paperless.documents.draft() # missing `document` field +draft = paperless.documents.create() # missing `document` field try: await paperless.documents.save(draft) diff --git a/docs/index.md b/docs/index.md index 068cdc1d..3faf6f8b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,7 +51,7 @@ asyncio.run(main()) ## Documentation - [Getting started](quickstart.md) — installation, first session, token generation -- [Session management](session.md) — lifecycle, context manager, custom client +- [Session management](session.md) — lifecycle, configuration modes, custom client - [Resources](resources.md) — all available resources and their capabilities - [Documents](concepts/documents.md) — uploads, search, download, notes, metadata - [Custom fields](concepts/custom_fields.md) — typed field values, cache, read & write diff --git a/docs/migrating-v5-to-v6.md b/docs/migrating-v5-to-v6.md index af33d3b9..0802178f 100644 --- a/docs/migrating-v5-to-v6.md +++ b/docs/migrating-v5-to-v6.md @@ -18,18 +18,18 @@ v6 is also almost a full rewrite of pypaperless. Three things drove it: ## Quick checklist -| # | What to change | Section | -| --- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| 1 | Replace `aiohttp` / `yarl` with `httpx` | [Dependencies](#dependencies) | -| 2 | Update `Paperless(...)` constructor arguments | [Initializing the client](#initializing-the-client) | -| 3 | Replace `reduce()` with `filter()` — different call pattern | [Iteration and filtering](#iteration-and-filtering) | -| 4 | Move `update()`, `delete()`, `save()` from model instances to services | [CRUD](#crud-update-delete-save) | -| 5 | Replace `request_permissions = True` with `with_permissions()` | [Permissions](#permissions) | -| 6 | Replace `doc.get_download()`, `get_metadata()`, etc. | [Document convenience methods removed](#document-convenience-methods-removed) | -| 7 | Note deletion: `note.delete()` → service call | [Document notes](#document-notes) | -| 8 | `generate_api_token()` custom-client argument renamed | [Token generation](#token-generation) | -| 9 | New: `paperless.profile`, `paperless.trash`, `paperless.documents.history` | [New resources](#new-resources) | -| 10 | Rename four `Paperless`-prefixed exception classes | [Error handling](#error-handling) | +| # | What to change | Section | +| --- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| 1 | Replace `aiohttp` / `yarl` with `httpx` | [Dependencies](#dependencies) | +| 2 | Update `Paperless(...)` constructor arguments; new: `PaperlessConfig` and env vars | [Initializing the client](#initializing-the-client) | +| 3 | Replace `reduce()` with `filter()` — different call pattern | [Iteration and filtering](#iteration-and-filtering) | +| 4 | `draft()` renamed to `create()`; `update()`, `delete()`, `save()` moved to services — shortcuts still on models | [CRUD](#crud) | +| 5 | Replace `request_permissions = True` with `with_permissions()` | [Permissions](#permissions) | +| 6 | Rename `doc.get_download()`, `doc.get_metadata()`, etc. — shortcuts are back | [Document convenience methods renamed](#document-convenience-methods-renamed) | +| 7 | Note deletion: `note.delete()` → service call | [Document notes](#document-notes) | +| 8 | `generate_api_token()` custom-client argument renamed | [Token generation](#token-generation) | +| 9 | New: `paperless.profile`, `paperless.trash`, `paperless.documents.history` | [New resources](#new-resources) | +| 10 | Rename four `Paperless`-prefixed exception classes | [Error handling](#error-handling) | --- @@ -66,14 +66,42 @@ The constructor signature changed. `session` was renamed to `client`, and `reque import httpx paperless = Paperless("http://localhost:8000", "mytoken") paperless = Paperless("http://localhost:8000", "mytoken", client=my_httpx_client) + ``` - # SSL / TLS customization — pass a pre-configured httpx.AsyncClient +!!! tip "SSL / TLS customization" + Pass a pre-configured `httpx.AsyncClient` to control TLS behaviour: + ```python client = httpx.AsyncClient(verify=False) # or verify="/path/to/cert.pem" paperless = Paperless("http://localhost:8000", "mytoken", client=client) ``` The `url` parameter no longer accepts `yarl.URL` objects — pass a plain string. +### New: `PaperlessConfig` and environment variables + +v6 adds two additional initialization modes via the new `PaperlessConfig` class (backed by `pydantic-settings`): + +**Config object** — useful when you want to construct or validate settings in one place: + +```python +from pypaperless import Paperless, PaperlessConfig + +cfg = PaperlessConfig(url="http://localhost:8000", token="mytoken") +paperless = Paperless(config=cfg) +``` + +**Environment variables** — pass no arguments at all; `PaperlessConfig` reads the values automatically: + +```python +paperless = Paperless() +``` + +| Environment variable | Maps to | +| --------------------------------- | --------------------------------- | +| `PYPAPERLESS_URL` | URL of the Paperless-ngx instance | +| `PYPAPERLESS_TOKEN` | API token | +| `PYPAPERLESS_REQUEST_API_VERSION` | API version header (optional) | + --- ## Token generation @@ -98,7 +126,6 @@ The `url` parameter no longer accepts `yarl.URL` objects — pass a plain string === "v5" ```python - # reduce() mutated the helper for the duration of the block async with paperless.documents.reduce(title__icontains="invoice"): async for doc in paperless.documents: print(doc.title) @@ -130,9 +157,11 @@ The `pages()` method was removed. Use `as_list()` or iterate directly. --- -## CRUD: update, delete, save +## CRUD -In v5, CRUD operations lived on model instances. In v6 they live on the service. +In v5, CRUD operations lived on model instances. v6 moves the canonical API to +the **service** level, but also re-adds **shortcuts** on the model +instances themselves for convenience (see below). ### update() @@ -144,6 +173,13 @@ In v5, CRUD operations lived on model instances. In v6 they live on the service. ``` === "v6" + ```python + doc = await paperless.documents(42) + doc.title = "New Title" + await doc.update() + ``` + +=== "v6 (service)" ```python doc = await paperless.documents(42) doc.title = "New Title" @@ -159,6 +195,12 @@ In v5, CRUD operations lived on model instances. In v6 they live on the service. ``` === "v6" + ```python + doc = await paperless.documents(42) + await doc.delete() + ``` + +=== "v6 (service)" ```python doc = await paperless.documents(42) await paperless.documents.delete(doc) @@ -166,17 +208,25 @@ In v5, CRUD operations lived on model instances. In v6 they live on the service. This applies to every resource — correspondents, tags, custom fields, etc. -### save() — creating new resources +### save() + +`draft()` was renamed to `create()`. The call signature is otherwise identical. === "v5" ```python draft = paperless.documents.draft(document=raw_bytes, title="Invoice") - pk = await draft.save() + task_id = await draft.save() ``` === "v6" ```python - draft = paperless.documents.draft(document=raw_bytes, title="Invoice") + draft = paperless.documents.create(document=raw_bytes, title="Invoice") + task_id = await draft.save() + ``` + +=== "v6 (service)" + ```python + draft = paperless.documents.create(document=raw_bytes, title="Invoice") task_id = await paperless.documents.save(draft) ``` @@ -190,7 +240,13 @@ For all other resources (correspondents, tags, …): === "v6" ```python - draft = paperless.tags.draft(name="urgent") + draft = paperless.tags.create(name="urgent") + pk = await draft.save() + ``` + +=== "v6 (service)" + ```python + draft = paperless.tags.create(name="urgent") pk = await paperless.tags.save(draft) ``` @@ -205,7 +261,7 @@ The mutable `request_permissions` setter was replaced by a `with_permissions()` paperless.documents.request_permissions = True doc = await paperless.documents(42) print(doc.owner, doc.permissions) - paperless.documents.request_permissions = False # had to reset manually + paperless.documents.request_permissions = False ``` === "v6" @@ -215,23 +271,29 @@ The mutable `request_permissions` setter was replaced by a `with_permissions()` print(doc.owner, doc.permissions) async for doc in ctx: print(doc.owner, doc.permissions) - # flag is reset automatically on exit, even on exceptions ``` +!!! note + Unlike v5, `with_permissions()` resets it automatically on exit, even if an exception occurs. + --- -## Document convenience methods removed +## Document convenience methods renamed -The shortcut methods on `Document` instances that delegated back to the helper were removed. Call the service directly. +The `get_*` shortcut methods on `Document` instances were renamed. The canonical API lives on the service, but the model shortcuts are still available: -| v5 (on model instance) | v6 (on service) | -| --------------------------------------- | ----------------------------------------------------------- | -| `await doc.get_download()` | `await paperless.documents.download(doc.id)` | -| `await doc.get_download(original=True)` | `await paperless.documents.download(doc.id, original=True)` | -| `await doc.get_metadata()` | `await paperless.documents.metadata(doc.id)` | -| `await doc.get_preview()` | `await paperless.documents.preview(doc.id)` | -| `await doc.get_thumbnail()` | `await paperless.documents.thumbnail(doc.id)` | -| `await doc.get_suggestions()` | `await paperless.documents.suggestions(doc.id)` | +| v5 (on model instance) | v6 shortcut | v6 | +| --------------------------------------- | ----------------------------------- | ----------------------------------------------------------- | +| `await doc.get_download()` | `await doc.download()` | `await paperless.documents.download(doc.id)` | +| `await doc.get_download(original=True)` | `await doc.download(original=True)` | `await paperless.documents.download(doc.id, original=True)` | +| `await doc.get_preview()` | `await doc.preview()` | `await paperless.documents.preview(doc.id)` | +| `await doc.get_thumbnail()` | `await doc.thumbnail()` | `await paperless.documents.thumbnail(doc.id)` | +| `await doc.get_metadata()` | `await doc.metadata()` | `await paperless.documents.metadata(doc.id)` | +| `await doc.get_suggestions()` | `await doc.suggestions()` | `await paperless.documents.suggestions(doc.id)` | +| *(not available)* | `async for d in doc.more_like()` | `async for d in paperless.documents.more_like(doc.id)` | +| *(not available)* | `await doc.email(...)` | `await paperless.documents.email(doc.id, ...)` | +| *(not available)* | `notes = await doc.notes()` | `notes = await paperless.documents.notes(doc.id)` | +| *(not available)* | `entries = await doc.history()` | `entries = await paperless.documents.history(doc.id)` | --- @@ -247,6 +309,14 @@ Note deletion moved from the model to the service, consistent with the general C ``` === "v6" + ```python + doc = await paperless.documents(42) + notes = await doc.notes() + for note in notes: + await note.delete() + ``` + +=== "v6 (service)" ```python notes = await paperless.documents.notes(42) for note in notes: @@ -263,7 +333,17 @@ Creating a new note: === "v6" ```python - draft = paperless.documents.notes.draft(note="Checked.", document=42) + doc = await paperless.documents(42) + draft = doc.notes.create(note="Checked.") + note_id, doc_id = await draft.save() + ``` + +!!! note + When using `doc.notes`, the document pk is bound automatically — no need to pass `document=` to `create()`. + +=== "v6 (service)" + ```python + draft = paperless.documents.notes.create(42, note="Checked.") note_id, doc_id = await paperless.documents.notes.save(draft) ``` @@ -293,7 +373,7 @@ async for doc in paperless.trash: print(doc.id, doc.title, doc.deleted_at) await paperless.trash.restore([42, 43]) -await paperless.trash.empty() # empties entire trash +await paperless.trash.empty() ``` The `Document.is_deleted` property returns `True` for documents retrieved from the trash. @@ -326,11 +406,13 @@ for entry in entries: ```python except httpx.ConnectError: ... - # or catch the pypaperless wrapper (works in both v5 and v6) except PaperlessConnectionError: ... ``` +!!! tip + `PaperlessConnectionError` wraps `httpx.ConnectError` and works in both v5 and v6 — catching it is the most forward-compatible option. + ### Exception renames Four exception classes lost their `Paperless` prefix to follow standard Python naming conventions. `PaperlessConnectionError` is the only exception kept as-is, because it would otherwise shadow Python's built-in `ConnectionError`. diff --git a/docs/quickstart.md b/docs/quickstart.md index bf18bf6f..4d040490 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -53,6 +53,23 @@ async def main(): await paperless.close() ``` +### Configuration via environment variables + +Instead of hard-coding credentials you can set environment variables and call `Paperless()` without any arguments: + +```bash +export PYPAPERLESS_URL=https://paperless.example.com +export PYPAPERLESS_TOKEN=your-api-token +export PYPAPERLESS_REQUEST_API_VERSION=9 +``` + +```python +async with Paperless() as paperless: + ... +``` + +See [Session management](session.md#configuration-modes) for all three configuration modes. + --- ## URL formats diff --git a/docs/resources.md b/docs/resources.md index 515345d2..be2ff95e 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -133,14 +133,14 @@ Use `draft()` to create a new draft model and `save()` to persist it: ```python # Create a new tag -draft = paperless.tags.draft(name="important", color="#ff0000") +draft = paperless.tags.create(name="important", color="#ff0000") new_id = await paperless.tags.save(draft) print(f"Created tag with id {new_id}") ``` ```python # Create a new correspondent -draft = paperless.correspondents.draft(name="ACME Corp") +draft = paperless.correspondents.create(name="ACME Corp") new_id = await paperless.correspondents.save(draft) ``` diff --git a/docs/resources/correspondents.md b/docs/resources/correspondents.md index 30456649..d7f9f9d9 100644 --- a/docs/resources/correspondents.md +++ b/docs/resources/correspondents.md @@ -33,7 +33,7 @@ filtered = [ ## Create ```python -draft = paperless.correspondents.draft() +draft = paperless.correspondents.create() draft.name = "ACME Corp" pk = await paperless.correspondents.save(draft) @@ -64,6 +64,21 @@ c = await paperless.correspondents(7) deleted = await paperless.correspondents.delete(c) # True on success ``` +## Shortcuts + +Model instances expose `update()` and `delete()` directly; draft instances expose `save()`: + +```python +c = await paperless.correspondents(7) +c.name = "ACME Corp (renamed)" +changed = await c.update() + +await c.delete() + +draft = paperless.correspondents.create(name="New Corp") +pk = await draft.save() +``` + ## Permissions ```python diff --git a/docs/resources/custom_fields.md b/docs/resources/custom_fields.md index 63937d35..3d7f1adf 100644 --- a/docs/resources/custom_fields.md +++ b/docs/resources/custom_fields.md @@ -29,7 +29,7 @@ fields = await paperless.custom_fields.as_dict() ```python from pypaperless.models.custom_fields import CustomFieldType -draft = paperless.custom_fields.draft() +draft = paperless.custom_fields.create() draft.name = "Invoice amount" draft.data_type = CustomFieldType.MONETARY @@ -46,7 +46,7 @@ from pypaperless.models.custom_fields import ( CustomFieldType, ) -draft = paperless.custom_fields.draft() +draft = paperless.custom_fields.create() draft.name = "Priority" draft.data_type = CustomFieldType.SELECT draft.extra_data = CustomFieldExtraData( @@ -74,3 +74,18 @@ changed = await paperless.custom_fields.update(field) field = await paperless.custom_fields(3) deleted = await paperless.custom_fields.delete(field) ``` + +## Shortcuts + +Model instances expose `update()` and `delete()` directly; draft instances expose `save()`: + +```python +field = await paperless.custom_fields(3) +field.name = "Invoice total" +changed = await field.update() + +await field.delete() + +draft = paperless.custom_fields.create(name="Notes", data_type=CustomFieldType.STRING) +pk = await draft.save() +``` diff --git a/docs/resources/document_types.md b/docs/resources/document_types.md index 0133f610..25f474e1 100644 --- a/docs/resources/document_types.md +++ b/docs/resources/document_types.md @@ -27,7 +27,7 @@ types = await paperless.document_types.as_dict() ## Create ```python -draft = paperless.document_types.draft() +draft = paperless.document_types.create() draft.name = "Invoice" pk = await paperless.document_types.save(draft) @@ -49,6 +49,21 @@ dt = await paperless.document_types(4) deleted = await paperless.document_types.delete(dt) ``` +## Shortcuts + +Model instances expose `update()` and `delete()` directly; draft instances expose `save()`: + +```python +dt = await paperless.document_types(4) +dt.name = "Invoice (updated)" +changed = await dt.update() + +await dt.delete() + +draft = paperless.document_types.create(name="Contract") +pk = await draft.save() +``` + ## Permissions ```python diff --git a/docs/resources/documents.md b/docs/resources/documents.md index 23615ae9..b0efdbae 100644 --- a/docs/resources/documents.md +++ b/docs/resources/documents.md @@ -32,7 +32,7 @@ To upload a new document, use `draft()` + `save()`. The first positional argumen with open("invoice.pdf", "rb") as fh: raw = fh.read() -draft = paperless.documents.draft() +draft = paperless.documents.create() draft.title = "Invoice 2024" draft.correspondent = 7 draft.tags = [1, 3] @@ -58,6 +58,58 @@ doc = await paperless.documents(42) deleted = await paperless.documents.delete(doc) ``` +## Shortcuts + +Model instances expose `update()` and `delete()` directly; draft instances expose `save()`: + +```python +doc = await paperless.documents(42) +doc.title = "Updated title" +changed = await doc.update() + +await doc.delete() + +# Draft.save() returns a task UUID, same as paperless.documents.save(draft) +draft = paperless.documents.create() +draft.title = "Invoice 2024" +draft.document = raw_bytes +task_id = await draft.save() +``` + +Document instances also expose shortcuts for the sub-resource operations: + +```python +doc = await paperless.documents(42) + +# file access +downloaded = await doc.download() +preview = await doc.preview() +thumb = await doc.thumbnail() + +# metadata and suggestions +meta = await doc.metadata() +suggestions = await doc.suggestions() + +# similar documents (async generator) +async for similar in doc.more_like(): + print(similar.title) + +# send by e-mail +await doc.email( + addresses="alice@example.com", + subject="Invoice", + message="See attachment.", +) + +# notes and history (bound sub-services) +notes = await doc.notes() # list[DocumentNote] +entries = await doc.history() # list[DocumentHistory] +note_draft = doc.notes.create(note="Checked.") +await doc.notes.save(note_draft) +await note_draft.save() # same, as a draft shortcut +await notes[0].delete() # note instance shortcut +``` + ## Permissions ```python diff --git a/docs/resources/share_links.md b/docs/resources/share_links.md index f36ba0f5..3100d171 100644 --- a/docs/resources/share_links.md +++ b/docs/resources/share_links.md @@ -34,7 +34,7 @@ doc_links = [ import datetime from pypaperless.models.share_links import ShareLinkFileVersion -draft = paperless.share_links.draft() +draft = paperless.share_links.create() draft.document = 42 draft.file_version = ShareLinkFileVersion.ARCHIVE draft.expiration = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc) @@ -62,3 +62,18 @@ changed = await paperless.share_links.update(link) link = await paperless.share_links(8) deleted = await paperless.share_links.delete(link) ``` + +## Shortcuts + +Model instances expose `update()` and `delete()` directly; draft instances expose `save()`: + +```python +link = await paperless.share_links(8) +link.expiration = datetime.datetime(2027, 1, 1, tzinfo=datetime.timezone.utc) +changed = await link.update() + +await link.delete() + +draft = paperless.share_links.create(document=42, file_version=ShareLinkFileVersion.ARCHIVE) +slug = await draft.save() +``` diff --git a/docs/resources/storage_paths.md b/docs/resources/storage_paths.md index c21983f8..0af7f5b9 100644 --- a/docs/resources/storage_paths.md +++ b/docs/resources/storage_paths.md @@ -27,7 +27,7 @@ paths = await paperless.storage_paths.as_dict() ## Create ```python -draft = paperless.storage_paths.draft() +draft = paperless.storage_paths.create() draft.name = "By year and type" draft.path = "{created_year}/{document_type}/{title}" @@ -50,6 +50,21 @@ sp = await paperless.storage_paths(2) deleted = await paperless.storage_paths.delete(sp) ``` +## Shortcuts + +Model instances expose `update()` and `delete()` directly; draft instances expose `save()`: + +```python +sp = await paperless.storage_paths(2) +sp.path = "{created_year}/{correspondent}/{title}" +changed = await sp.update() + +await sp.delete() + +draft = paperless.storage_paths.create(name="Archive", path="{created_year}/{title}") +pk = await draft.save() +``` + ## Permissions ```python diff --git a/docs/resources/tags.md b/docs/resources/tags.md index f6185416..af3b0ac7 100644 --- a/docs/resources/tags.md +++ b/docs/resources/tags.md @@ -31,7 +31,7 @@ inbox = next( ## Create ```python -draft = paperless.tags.draft() +draft = paperless.tags.create() draft.name = "Invoice" draft.color = "#a6cee3" draft.is_inbox_tag = False @@ -55,6 +55,21 @@ tag = await paperless.tags(5) deleted = await paperless.tags.delete(tag) ``` +## Shortcuts + +Model instances expose `update()` and `delete()` directly; draft instances expose `save()`: + +```python +tag = await paperless.tags(5) +tag.color = "#ff0000" +changed = await tag.update() + +await tag.delete() + +draft = paperless.tags.create(name="urgent") +pk = await draft.save() +``` + ## Permissions ```python diff --git a/docs/session.md b/docs/session.md index 651a44c1..60c8daa7 100644 --- a/docs/session.md +++ b/docs/session.md @@ -1,16 +1,72 @@ # Session management -This page covers advanced session configuration: custom HTTP clients, API version pinning and connection lifecycle details. +This page covers all configuration modes, custom HTTP clients, API version pinning and connection lifecycle details. --- -## The `Paperless` constructor +## Configuration modes + +**pypaperless** can be configured in three ways: + +### 1. Explicit parameters + +Pass `url` and `token` directly to the constructor: + +```python +paperless = Paperless("localhost:8000", "your-api-token") +``` + +### 2. `PaperlessConfig` object + +Build a `PaperlessConfig` instance and pass it via the `config` keyword. Useful when you want to construct or validate settings separately: + +```python +from pypaperless import Paperless, PaperlessConfig + +cfg = PaperlessConfig( + url="https://paperless.example.com", + token="your-api-token", + request_api_version=9, # optional — defaults to the built-in value +) + +async with Paperless(config=cfg) as paperless: + ... +``` + +### 3. Environment variables + +Set the `PYPAPERLESS_*` environment variables and call `Paperless()` with no arguments. Ideal for containers, CI pipelines and twelve-factor apps: + +| Environment variable | Field | Required | +| --------------------------------- | -------------------- | :------: | +| `PYPAPERLESS_URL` | Base URL | ✓ | +| `PYPAPERLESS_TOKEN` | API token | | +| `PYPAPERLESS_REQUEST_API_VERSION` | API version override | | + +```bash +export PYPAPERLESS_URL=https://paperless.example.com +export PYPAPERLESS_TOKEN=your-api-token +export PYPAPERLESS_REQUEST_API_VERSION=9 +``` + +```python +async with Paperless() as paperless: + ... +``` + +!!! note + `PYPAPERLESS_URL` is required. If it is not set and no `url` argument is provided, a `ValidationError` is raised immediately. + +--- + +## The `Paperless` constructor reference ```python Paperless( - url: str, + url: str | None = None, token: str | None = None, *, + config: PaperlessConfig | None = None, client: httpx.AsyncClient | None = None, request_api_version: int | None = None, ) @@ -20,6 +76,7 @@ Paperless( | --------------------- | ----------------------------------------------------------------------- | | `url` | Hostname, IP address or full URL of your Paperless-ngx instance | | `token` | API token obtained from Paperless-ngx settings | +| `config` | A `PaperlessConfig` instance (alternative to `url` / `token`) | | `client` | Optional custom HTTP client (see below) | | `request_api_version` | Pin a specific Paperless API version (defaults to the latest supported) | diff --git a/pypaperless/__init__.py b/pypaperless/__init__.py index fbad4ffb..9ff3bb0a 100644 --- a/pypaperless/__init__.py +++ b/pypaperless/__init__.py @@ -1,5 +1,6 @@ """PyPaperless.""" from .client import Paperless +from .settings import PaperlessConfig -__all__ = ("Paperless",) +__all__ = ("Paperless", "PaperlessConfig") diff --git a/pypaperless/__main__.py b/pypaperless/__main__.py new file mode 100644 index 00000000..e17a7d8a --- /dev/null +++ b/pypaperless/__main__.py @@ -0,0 +1,5 @@ +"""PyPaperless CLI.""" + +from .cli import cli + +cli() diff --git a/pypaperless/cli.py b/pypaperless/cli.py new file mode 100644 index 00000000..48c6c4c8 --- /dev/null +++ b/pypaperless/cli.py @@ -0,0 +1,260 @@ +"""PyPaperless CLI.""" + +import asyncio +import json +import sys +from collections.abc import Awaitable, Callable +from typing import Any + +import click + +try: + from pygments import highlight + from pygments.formatters import TerminalFormatter + from pygments.lexers import JsonLexer + + _PYGMENTS = True +except ImportError: # pragma: no cover + _PYGMENTS = False + +from . import Paperless +from .const import ENV_TOKEN, ENV_URL +from .exceptions import ( + ForbiddenError, + InitializationError, + InvalidTokenError, + PaperlessConnectionError, +) + + +def _out(obj: Any) -> None: + """Dump *obj* as indented JSON to stdout, with syntax highlighting when connected to a TTY.""" + text = json.dumps(obj, indent=2, default=str) + if sys.stdout.isatty() and _PYGMENTS: + text = highlight(text, JsonLexer(), TerminalFormatter()) + click.echo(text) + + +def _render_compact_list(items: list[Any]) -> None: + """Render a compact ID/Name listing from model instances.""" + rows: list[tuple[str, str]] = [] + for item in items: + item_id = str(getattr(item, "id", "") or "") + row_name = "" + for key in ("name", "title", "username", "task_id", "slug"): + value = getattr(item, key, None) + if value not in (None, ""): + row_name = str(value) + break + rows.append((item_id, row_name)) + + rows.sort(key=lambda row: (row[1].casefold(), row[0])) + id_width = max((len(row[0]) for row in rows), default=2) + click.echo(f"{'ID':<{id_width}} Name") + click.echo(f"{'-' * id_width} {'-' * 40}") + for row_id, row_name in rows: + click.echo(f"{row_id:<{id_width}} {row_name}") + + +async def _with_client( + url: str | None, + token: str | None, + coro: Callable[[Paperless], Awaitable[None]], +) -> None: + """Build a ``Paperless`` client, initialize it, run ``coro(paperless)``, then close.""" + if url is not None: + paperless = Paperless(url=url, token=token) + else: + try: + paperless = Paperless() + except Exception as exc: + msg = f"Configuration error: {exc}" + raise click.ClickException(msg) from exc + + try: + async with paperless: + await coro(paperless) + except PaperlessConnectionError as exc: + msg = f"Connection error: {exc}" + raise click.ClickException(msg) from exc + except InvalidTokenError as exc: + msg = "Authentication failed — invalid or missing token." + raise click.ClickException(msg) from exc + except ForbiddenError as exc: + msg = "Access denied (HTTP 403)." + raise click.ClickException(msg) from exc + except InitializationError as exc: + msg = f"Initialization failed: {exc}" + raise click.ClickException(msg) from exc + + +@click.group() +@click.option( + "--url", + envvar=ENV_URL, + default=None, + metavar="URL", + help=f"Paperless-ngx base URL [env: {ENV_URL}]", +) +@click.option( + "--token", + envvar=ENV_TOKEN, + default=None, + metavar="TOKEN", + help=f"API token [env: {ENV_TOKEN}]", +) +@click.pass_context +def cli(ctx: click.Context, url: str | None, token: str | None) -> None: + r"""PyPaperless — command-line interface for Paperless-ngx. + + \b + Credentials can be supplied as options or via environment variables: + PYPAPERLESS_URL base URL of your Paperless-ngx instance + PYPAPERLESS_TOKEN API token + """ + ctx.ensure_object(dict) + ctx.obj["url"] = url + ctx.obj["token"] = token + + +@cli.command("status") +@click.pass_context +def cmd_status(ctx: click.Context) -> None: + """Show host version, API version and system status.""" + + async def _fetch(p: Paperless) -> None: + stat = await p.status() + rv = await p.remote_version() + _out( + { + "host_version": p.host_version, + "api_version": p.host_api_version, + "status": stat.model_dump(mode="json"), + "remote_version": rv.model_dump(mode="json"), + } + ) + + asyncio.run(_with_client(ctx.obj["url"], ctx.obj["token"], _fetch)) + + +@cli.command("profile") +@click.pass_context +def cmd_profile(ctx: click.Context) -> None: + """Show the currently authenticated user's profile.""" + + async def _fetch(p: Paperless) -> None: + item = await p.profile() + _out(item.model_dump(mode="json")) + + asyncio.run(_with_client(ctx.obj["url"], ctx.obj["token"], _fetch)) + + +def _resource_group( + name: str, + service_attr: str, + *, + supports_get: bool = True, + supports_list: bool = True, + id_type: click.ParamType = click.INT, +) -> click.Group: + """Return a ``click.Group`` with ``list`` and/or ``get`` subcommands. + + `name`: CLI group name (e.g. ``"tags"``). + `service_attr`: attribute on the ``Paperless`` instance (e.g. ``"tags"``). + `supports_get`: add a ``get `` subcommand. + `supports_list`: add a ``list`` subcommand. + `id_type`: click parameter type for the ``get`` argument (default: ``INT``). + """ + + @click.group(name) + def grp() -> None: + pass + + if supports_list: + + @grp.command("list") + @click.option( + "--limit", + default=None, + type=int, + metavar="N", + help="Stop after N items (default: all).", + ) + @click.pass_context + def list_cmd(ctx: click.Context, limit: int | None) -> None: + """List item IDs and names in a compact console format.""" + _attr = service_attr + + async def _fetch(p: Paperless) -> None: + service = getattr(p, _attr) + results: list[Any] = [] + count = 0 + async for item in service: + results.append(item) + count += 1 + if limit is not None and count >= limit: + break + _render_compact_list(results) + + asyncio.run(_with_client(ctx.obj["url"], ctx.obj["token"], _fetch)) + + @grp.command("json") + @click.option( + "--limit", + default=None, + type=int, + metavar="N", + help="Stop after N items (default: all).", + ) + @click.pass_context + def json_cmd(ctx: click.Context, limit: int | None) -> None: + """List full items as JSON objects.""" + _attr = service_attr + + async def _fetch(p: Paperless) -> None: + service = getattr(p, _attr) + results: list[dict[str, Any]] = [] + count = 0 + async for item in service: + results.append(item.model_dump(mode="json")) + count += 1 + if limit is not None and count >= limit: + break + _out(results) + + asyncio.run(_with_client(ctx.obj["url"], ctx.obj["token"], _fetch)) + + if supports_get: + + @grp.command("get") + @click.argument("item_id", type=id_type, metavar="ID") + @click.pass_context + def get_cmd(ctx: click.Context, item_id: Any) -> None: + """Fetch a single item by ID.""" + _attr = service_attr + + async def _fetch(p: Paperless) -> None: + service = getattr(p, _attr) + item = await service(item_id) + _out(item.model_dump(mode="json")) + + asyncio.run(_with_client(ctx.obj["url"], ctx.obj["token"], _fetch)) + + return grp + + +cli.add_command(_resource_group("correspondents", "correspondents")) +cli.add_command(_resource_group("custom-fields", "custom_fields")) +cli.add_command(_resource_group("document-types", "document_types")) +cli.add_command(_resource_group("documents", "documents")) +cli.add_command(_resource_group("groups", "groups")) +cli.add_command(_resource_group("mail-accounts", "mail_accounts")) +cli.add_command(_resource_group("mail-rules", "mail_rules")) +cli.add_command(_resource_group("saved-views", "saved_views")) +cli.add_command(_resource_group("share-links", "share_links")) +cli.add_command(_resource_group("storage-paths", "storage_paths")) +cli.add_command(_resource_group("tags", "tags")) +cli.add_command(_resource_group("tasks", "tasks", id_type=click.STRING)) +cli.add_command(_resource_group("trash", "trash", supports_get=False)) +cli.add_command(_resource_group("users", "users")) +cli.add_command(_resource_group("workflows", "workflows")) diff --git a/pypaperless/client.py b/pypaperless/client.py index cfb63f88..18d50614 100644 --- a/pypaperless/client.py +++ b/pypaperless/client.py @@ -2,7 +2,7 @@ import logging from json import JSONDecodeError -from typing import Any +from typing import Any, overload import httpx @@ -17,6 +17,7 @@ JsonResponseWithError, PaperlessConnectionError, ) +from .settings import PaperlessConfig from .utils import normalize_base_url, process_form_data @@ -32,27 +33,88 @@ async def __aexit__(self, *_: object) -> None: """Exit context manager.""" await self.close() + @overload def __init__( self, url: str, + token: str | None = ..., + *, + client: httpx.AsyncClient | None = ..., + request_api_version: int | None = ..., + ) -> None: ... + + @overload + def __init__( + self, + *, + config: PaperlessConfig, + client: httpx.AsyncClient | None = ..., + ) -> None: ... + + @overload + def __init__( + self, + *, + client: httpx.AsyncClient | None = ..., + ) -> None: ... + + def __init__( + self, + url: str | None = None, token: str | None = None, *, + config: PaperlessConfig | None = None, client: httpx.AsyncClient | None = None, request_api_version: int | None = None, ) -> None: """Initialize a `Paperless` instance. - You have to permit either a client, or an url / token pair. + Three configuration modes are supported: - `url`: A hostname or IP-address as string. - `token`: An api token created in Paperless Django settings, or via the helper function. - `client`: A custom `httpx.AsyncClient` object, if existing. + **1. Explicit parameters:** + ```python + paperless = Paperless("localhost:8000", "your-token") + ``` + + **2. Config object:** + ```python + cfg = PaperlessConfig(url="localhost:8000", token="your-token") + paperless = Paperless(config=cfg) + ``` + + **3. Environment variables (no arguments):** + Set ``PYPAPERLESS_URL`` and optionally ``PYPAPERLESS_TOKEN`` / + ``PYPAPERLESS_REQUEST_API_VERSION`` before starting your application. + ```python + paperless = Paperless() + ``` + + `url`: A hostname, IP-address, or full URL string. + `token`: An API token generated in Paperless Django settings, or via `generate_api_token`. + `config`: A `PaperlessConfig` instance with all connection parameters. + `client`: A custom `httpx.AsyncClient` to use for requests. + `request_api_version`: Override the API version header sent with each request. """ - self._base_url = normalize_base_url(url) + if config is not None: + _url = config.url + _token = config.token + _request_api_version = config.request_api_version + elif url is not None: + _url = url + _token = token + _request_api_version = request_api_version or API_VERSION + else: + # No args supplied — read everything from environment variables. + _cfg = PaperlessConfig() + _url = _cfg.url + _token = _cfg.token + _request_api_version = _cfg.request_api_version + + self._base_url = normalize_base_url(_url) self._client = client self._initialized = False - self._request_api_version = request_api_version or API_VERSION - self._token = token + self._request_api_version = _request_api_version + self._token = _token self._api_version = API_VERSION self._version: str | None = None diff --git a/pypaperless/const.py b/pypaperless/const.py index 99902009..2fb89e27 100644 --- a/pypaperless/const.py +++ b/pypaperless/const.py @@ -4,6 +4,11 @@ API_VERSION = 9 +ENV_PREFIX = "PYPAPERLESS_" +ENV_URL = f"{ENV_PREFIX}URL" +ENV_TOKEN = f"{ENV_PREFIX}TOKEN" +ENV_REQUEST_API_VERSION = f"{ENV_PREFIX}REQUEST_API_VERSION" + CONFIG = "config" CORRESPONDENTS = "correspondents" CUSTOM_FIELDS = "custom_fields" diff --git a/pypaperless/exceptions.py b/pypaperless/exceptions.py index a5443b9c..d9a7a1e0 100644 --- a/pypaperless/exceptions.py +++ b/pypaperless/exceptions.py @@ -102,7 +102,7 @@ class ResourceError(PaperlessError): class ItemNotFoundError(ResourceError): - """Raise when trying to access non-existing items in PaperlessModelData classes.""" + """Raise when trying to access non-existing items in PaperlessCustomDataModel classes.""" class PrimaryKeyRequiredError(ResourceError): diff --git a/pypaperless/models/base.py b/pypaperless/models/base.py index e5715e08..75354da1 100644 --- a/pypaperless/models/base.py +++ b/pypaperless/models/base.py @@ -1,11 +1,11 @@ """Provide base classes.""" -from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeVar, final -from pydantic import BaseModel, ConfigDict, PrivateAttr, TypeAdapter +from pydantic import BaseModel, ConfigDict, PrivateAttr, TypeAdapter, model_serializer from pypaperless.const import API_PATH +from pypaperless.utils import object_to_dict_value if TYPE_CHECKING: from pypaperless import Paperless @@ -15,11 +15,7 @@ class PaperlessModel(BaseModel): - """Base class for all models in PyPaperless. - - Models are pure data containers. All API operations (load, save, update, - delete) are handled by services. - """ + """Base class for all models in PyPaperless.""" model_config = ConfigDict( arbitrary_types_allowed=True, @@ -33,37 +29,13 @@ class PaperlessModel(BaseModel): _client: "Paperless" = PrivateAttr() _data: dict[str, Any] = PrivateAttr(default_factory=dict) - @property - def api_path(self) -> str: - """Return the API path for this model instance.""" - return self._api_path - - @property - def data(self) -> dict[str, Any]: - """Return the internal model data dictionary.""" - return self._data - - @data.setter - def data(self, value: dict[str, Any]) -> None: - """Set the internal model data dictionary.""" - self._data = value - - def __init__(self, client: "Paperless", data: dict[str, Any], **kwargs: Any) -> None: - """Initialize a `PaperlessModel` instance.""" - super().__init__(**kwargs) - self._client = client - self._data = dict(data) - self._set_api_path(self._data) - - def _set_api_path(self, data: dict[str, Any], **format_kwargs: Any) -> None: - """Set the instance's `_api_path` by resolving its `{pk}` placeholder. - - Uses `_pk_field` to determine which data key provides the primary key value. - Override `_pk_field` on a subclass to use a different source field. - """ - format_kwargs.setdefault("pk", data.get(self._pk_field)) - if format_kwargs["pk"] is not None: - object.__setattr__(self, "_api_path", self._api_path.format(**format_kwargs)) + def model_post_init(self, __context: Any, /) -> None: + """Bind `_client` from validation context and resolve the instance API path.""" + if isinstance(__context, dict) and "client" in __context: + self._client = __context["client"] + pk = getattr(self, self._pk_field, None) + if pk is not None: + object.__setattr__(self, "_api_path", self._api_path.format(pk=pk)) @classmethod def format_api_path(cls, **kwargs: Any) -> str: @@ -72,16 +44,33 @@ def format_api_path(cls, **kwargs: Any) -> str: @final @classmethod - def create_with_data( + def from_data( cls, client: "Paperless", data: dict[str, Any], ) -> Self: """Return a new instance of `cls` from `data`. - Primarily used by class factories to create new model instances. + Primarily used by service-level factory methods. """ - return cls(client=client, data=data, **data) + instance = cls.model_validate(data, context={"client": client}) + instance._data = data # noqa: SLF001 + return instance + + @property + def api_path(self) -> str: + """Return the API path for this model instance.""" + return self._api_path + + @property + def data(self) -> dict[str, Any]: + """Return the internal model data dictionary.""" + return self._data + + @data.setter + def data(self, value: dict[str, Any]) -> None: + """Set the internal model data dictionary.""" + self._data = value def apply_data(self) -> None: """Apply data from `self.data` to model fields. @@ -110,14 +99,45 @@ def _get_type_adapter(cls, field_name: str, annotation: type) -> TypeAdapter: return cache[field_name] -class PaperlessModelData(ABC): +class PaperlessCustomDataModel(BaseModel): """Base class for all custom data types in PyPaperless.""" + model_config = ConfigDict(arbitrary_types_allowed=True) + + _client: "Paperless" = PrivateAttr() + _data: Any = PrivateAttr(default=None) + + def model_post_init(self, __context: Any, /) -> None: + """Bind `_client` and `_data` from validation context.""" + if isinstance(__context, dict): + if "client" in __context: + self._client = __context["client"] + if "data" in __context: + self._data = __context["data"] + @classmethod - @abstractmethod - def unserialize(cls, client: "Paperless", data: Any) -> Self: - """Return a new instance of `cls` from `data`.""" + def from_data(cls, client: "Paperless", data: Any) -> Self: + """Return a new instance of ``cls`` from API data.""" + return cls.model_validate({}, context={"client": client, "data": data}) + + @property + def data(self) -> Any: + """Return the internal custom-model data payload.""" + return self._data + + @data.setter + def data(self, value: Any) -> None: + """Set the internal custom-model data payload.""" + self._data = value - @abstractmethod def serialize(self) -> Any: - """Serialize the class data.""" + """Return the JSON-compatible payload for this model.""" + payload = { + field_name: getattr(self, field_name) for field_name in self.__class__.model_fields + } + return object_to_dict_value(payload) + + @model_serializer(mode="plain") + def _model_serializer(self) -> Any: + """Delegate Pydantic serialization to the custom ``serialize`` method.""" + return self.serialize() diff --git a/pypaperless/models/correspondents.py b/pypaperless/models/correspondents.py index a64135b2..ad06f60e 100644 --- a/pypaperless/models/correspondents.py +++ b/pypaperless/models/correspondents.py @@ -3,7 +3,7 @@ import datetime from typing import ClassVar -from pypaperless.const import API_PATH +from pypaperless.const import API_PATH, PaperlessResource from . import mixins from .base import PaperlessModel @@ -13,10 +13,13 @@ class Correspondent( PaperlessModel, mixins.MatchingFieldsMixin, mixins.SecurableMixin, + mixins.UpdatableMixin, + mixins.DeletableMixin, ): """Represent a Paperless `Correspondent`.""" _api_path: ClassVar[str] = API_PATH["correspondents_single"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.CORRESPONDENTS id: int | None = None slug: str | None = None @@ -30,10 +33,12 @@ class CorrespondentDraft( mixins.MatchingFieldsMixin, mixins.SecurableDraftMixin, mixins.CreatableMixin, + mixins.SaveableMixin, ): """Represent a new `Correspondent`, which is not yet stored in Paperless.""" _api_path: ClassVar[str] = API_PATH["correspondents"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.CORRESPONDENTS _create_required_fields: ClassVar[set[str]] = { "name", diff --git a/pypaperless/models/custom_fields.py b/pypaperless/models/custom_fields.py index a591c0a1..016ca4c6 100644 --- a/pypaperless/models/custom_fields.py +++ b/pypaperless/models/custom_fields.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field, field_validator -from pypaperless.const import API_PATH +from pypaperless.const import API_PATH, PaperlessResource from . import mixins from .base import PaperlessModel @@ -197,10 +197,13 @@ class CustomFieldURLValue(CustomFieldValue): class CustomField( PaperlessModel, + mixins.UpdatableMixin, + mixins.DeletableMixin, ): """Represent a Paperless `CustomField`.""" _api_path: ClassVar[str] = API_PATH["custom_fields_single"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.CUSTOM_FIELDS id: int name: str | None = None @@ -240,10 +243,11 @@ def draft_value( return CustomFieldValue(field=self.id, value=value) -class CustomFieldDraft(PaperlessModel, mixins.CreatableMixin): +class CustomFieldDraft(PaperlessModel, mixins.CreatableMixin, mixins.SaveableMixin): """Represent a new Paperless `CustomField`, which is not stored in Paperless.""" _api_path: ClassVar[str] = API_PATH["custom_fields"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.CUSTOM_FIELDS _create_required_fields: ClassVar[set[str]] = {"name", "data_type"} diff --git a/pypaperless/models/document_types.py b/pypaperless/models/document_types.py index db1773a7..3e31163f 100644 --- a/pypaperless/models/document_types.py +++ b/pypaperless/models/document_types.py @@ -2,7 +2,7 @@ from typing import ClassVar -from pypaperless.const import API_PATH +from pypaperless.const import API_PATH, PaperlessResource from . import mixins from .base import PaperlessModel @@ -12,10 +12,13 @@ class DocumentType( PaperlessModel, mixins.MatchingFieldsMixin, mixins.SecurableMixin, + mixins.UpdatableMixin, + mixins.DeletableMixin, ): """Represent a Paperless `DocumentType`.""" _api_path: ClassVar[str] = API_PATH["document_types_single"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.DOCUMENT_TYPES id: int | None = None slug: str | None = None @@ -28,10 +31,12 @@ class DocumentTypeDraft( mixins.MatchingFieldsMixin, mixins.SecurableDraftMixin, mixins.CreatableMixin, + mixins.SaveableMixin, ): """Represent a new `DocumentType`, which is not yet stored in Paperless.""" _api_path: ClassVar[str] = API_PATH["document_types"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.DOCUMENT_TYPES _create_required_fields: ClassVar[set[str]] = { "name", diff --git a/pypaperless/models/documents.py b/pypaperless/models/documents.py index d65040ba..3671df98 100644 --- a/pypaperless/models/documents.py +++ b/pypaperless/models/documents.py @@ -2,18 +2,18 @@ import datetime import json -from collections.abc import Iterator +from collections.abc import AsyncGenerator, Iterator from enum import StrEnum from typing import TYPE_CHECKING, Any, ClassVar, Self, cast, overload -from pydantic import BaseModel, Field, PrivateAttr +from pydantic import BaseModel, Field, PrivateAttr, ValidationInfo, field_validator -from pypaperless.const import API_PATH +from pypaperless.const import API_PATH, PaperlessResource from pypaperless.exceptions import ItemNotFoundError from pypaperless.utils import object_to_dict_value from . import mixins -from .base import PaperlessModel, PaperlessModelData +from .base import PaperlessCustomDataModel, PaperlessModel from .custom_fields import ( CUSTOM_FIELD_TYPE_VALUE_MAP, CustomField, @@ -23,7 +23,6 @@ ) if TYPE_CHECKING: - from pypaperless import Paperless from pypaperless.services.documents import DocumentHistoryService, DocumentNoteService @@ -80,18 +79,19 @@ class DocumentHistory(PaperlessModel): actor: DocumentHistoryActor | None = None -class DocumentCustomFieldList(PaperlessModelData): +class DocumentCustomFieldList(PaperlessCustomDataModel): """Represent a list of Paperless custom field instances typically on documents.""" - def __init__(self, client: "Paperless", data: list[dict[str, Any]]) -> None: - """Initialize a `DocumentCustomFieldList` instance.""" - self._client = client - self._data = data - self._fields: list[CustomFieldValue] = [] + _fields: list[CustomFieldValue] = PrivateAttr(default_factory=list) - cache = client.cache.custom_fields + def model_post_init(self, __context: Any, /) -> None: + """Populate ``_fields`` from the raw API payload stored in ``_data``.""" + super().model_post_init(__context) + self._fields = [] - for item in data: + cache = self._client.cache.custom_fields + + for item in self._data: if cache and (field := cache.get(item["field"], None)): klass = CUSTOM_FIELD_TYPE_VALUE_MAP.get( field.data_type or CustomFieldType.UNKNOWN, CustomFieldValue @@ -111,9 +111,12 @@ def __contains__(self, field: int | CustomField) -> bool: item_id = field.id if isinstance(field, CustomField) else field return any(item.field == item_id for item in self._fields) - def __iter__(self) -> Iterator[CustomFieldValue]: + def __iter__(self) -> Iterator[CustomFieldValue]: # type: ignore[override] """Iterate over custom fields. + This intentionally behaves like a container iterator (yielding + ``CustomFieldValue`` items) instead of Pydantic's field-pair iterator. + Example: ------- ```python @@ -147,7 +150,6 @@ def remove(self, field: CustomFieldValue | CustomField | int) -> Self: else field ) self._fields = [field for field in self._fields if field.field != item_id] - return self @overload @@ -186,24 +188,14 @@ def get( ) -> CustomFieldValue | CustomFieldValueT: """Access and return a (typed) `CustomFieldValue` from the list.""" item_id = field.id if isinstance(field, CustomField) else field - for item in self._fields: if item.field == item_id: if expected_type is not None and not isinstance(item, expected_type): msg = f"Expected {expected_type.__name__}, got {type(item).__name__}" raise TypeError(msg) return item - raise ItemNotFoundError - @classmethod - def unserialize(cls, client: "Paperless", data: list[dict[str, Any]]) -> Self: - """Return a new instance of `cls` from `data`. - - Primarily used by `dict_value_to_object` when instantiating model classes. - """ - return cls(client, data=data) - def serialize(self) -> list[dict[str, Any]]: """Serialize the class data.""" return [{"field": field.field, "value": field.value} for field in self._fields] @@ -212,10 +204,13 @@ def serialize(self) -> list[dict[str, Any]]: class Document( PaperlessModel, mixins.SecurableMixin, + mixins.UpdatableMixin, + mixins.DeletableMixin, ): """Represent a Paperless `Document`.""" _api_path: ClassVar[str] = API_PATH["documents_single"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.DOCUMENTS _history: "DocumentHistoryService | None" = PrivateAttr(default=None) _notes: "DocumentNoteService | None" = PrivateAttr(default=None) @@ -240,18 +235,13 @@ class Document( mime_type: str | None = None search_hit_: DocumentSearchHit | None = Field(default=None, alias="__search_hit__") - def __init__(self, client: "Paperless", data: dict[str, Any], **kwargs: Any) -> None: - """Initialize a `Document` instance.""" - # Convert custom_fields list to DocumentCustomFieldList before pydantic validation - if "custom_fields" in kwargs and isinstance(kwargs["custom_fields"], list): - kwargs["custom_fields"] = DocumentCustomFieldList(client, kwargs["custom_fields"]) - super().__init__(client, data, **kwargs) - - def apply_data(self) -> None: - """Apply data from `self._data` to model fields, converting custom_fields.""" - super().apply_data() - if "custom_fields" in self._data and isinstance(self._data["custom_fields"], list): - self.custom_fields = DocumentCustomFieldList(self._client, self._data["custom_fields"]) + @field_validator("custom_fields", mode="before") + @classmethod + def _coerce_custom_fields(cls, v: Any, info: ValidationInfo) -> Any: + """Convert a raw list of custom field dicts into a ``DocumentCustomFieldList``.""" + if isinstance(v, list) and isinstance(info.context, dict) and "client" in info.context: + return DocumentCustomFieldList.from_data(info.context["client"], v) + return v @property def history(self) -> "DocumentHistoryService": @@ -271,6 +261,48 @@ def notes(self) -> "DocumentNoteService": self._notes = DocumentNoteService(self._client, cast("int", self.id)) return self._notes + async def download(self, *, original: bool = False) -> "DownloadedDocument": + """Shortcut for ``paperless.documents.download(self.id)``.""" + return await self._client.documents.download(cast("int", self.id), original=original) + + async def preview(self, *, original: bool = False) -> "DownloadedDocument": + """Shortcut for ``paperless.documents.preview(self.id)``.""" + return await self._client.documents.preview(cast("int", self.id), original=original) + + async def thumbnail(self, *, original: bool = False) -> "DownloadedDocument": + """Shortcut for ``paperless.documents.thumbnail(self.id)``.""" + return await self._client.documents.thumbnail(cast("int", self.id), original=original) + + async def metadata(self) -> "DocumentMeta": + """Shortcut for ``paperless.documents.metadata(self.id)``.""" + return await self._client.documents.metadata(cast("int", self.id)) + + async def suggestions(self) -> "DocumentSuggestions": + """Shortcut for ``paperless.documents.suggestions(self.id)``.""" + return await self._client.documents.suggestions(cast("int", self.id)) + + async def more_like(self) -> AsyncGenerator["Document"]: + """Shortcut for ``paperless.documents.more_like(self.id)``.""" + async for doc in self._client.documents.more_like(cast("int", self.id)): + yield doc + + async def email( + self, + *, + addresses: str, + subject: str, + message: str, + use_archive_version: bool = True, + ) -> None: + """Shortcut for ``paperless.documents.email(self.id, ...)``.""" + await self._client.documents.email( + cast("int", self.id), + addresses=addresses, + subject=subject, + message=message, + use_archive_version=use_archive_version, + ) + @property def created_date(self) -> datetime.date | None: """Backward compatibility for the removed `created_date` field.""" @@ -291,31 +323,20 @@ def search_hit(self) -> DocumentSearchHit | None: """Return the document search hit.""" return self.search_hit_ - async def get_download(self, *, original: bool = False) -> "DownloadedDocument": - """Request and return the `DownloadedDocument` class.""" - return await self._client.documents.download(cast("int", self.id), original=original) - - async def get_metadata(self) -> "DocumentMeta": - """Request and return the documents `DocumentMeta` class.""" - return await self._client.documents.metadata(cast("int", self.id)) - - async def get_preview(self, *, original: bool = False) -> "DownloadedDocument": - """Request and return the `DownloadedDocument` class.""" - return await self._client.documents.preview(cast("int", self.id), original=original) - - async def get_suggestions(self) -> "DocumentSuggestions": - """Request and return the `DocumentSuggestions` class.""" - return await self._client.documents.suggestions(cast("int", self.id)) - - async def get_thumbnail(self, *, original: bool = False) -> "DownloadedDocument": - """Request and return the `DownloadedDocument` class.""" - return await self._client.documents.thumbnail(cast("int", self.id), original=original) + def apply_data(self) -> None: + """Apply data from `self._data` to model fields, converting custom_fields.""" + super().apply_data() + if "custom_fields" in self._data and isinstance(self._data["custom_fields"], list): + self.custom_fields = DocumentCustomFieldList.from_data( + self._client, self._data["custom_fields"] + ) -class DocumentDraft(PaperlessModel, mixins.CreatableMixin): +class DocumentDraft(PaperlessModel, mixins.CreatableMixin, mixins.SaveableMixin): """Represent a new Paperless `Document`, which is not stored in Paperless.""" _api_path: ClassVar[str] = API_PATH["documents_post"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.DOCUMENTS _create_required_fields: ClassVar[set[str]] = {"document"} @@ -371,6 +392,10 @@ class DocumentNote(PaperlessModel): document: int | None = None user: int | None = None + async def delete(self) -> bool: + """Shortcut for ``paperless.documents.notes.delete(self)``.""" + return await self._client.documents.notes.delete(self) + class DocumentNoteDraft(PaperlessModel, mixins.CreatableMixin): """Represent a new Paperless `DocumentNote`, which is not stored in Paperless.""" @@ -383,6 +408,10 @@ class DocumentNoteDraft(PaperlessModel, mixins.CreatableMixin): note: str | None = None document: int | None = None + async def save(self) -> tuple[int, int]: + """Shortcut for ``paperless.documents.notes.save(self)``.""" + return await self._client.documents.notes.save(self) + class DocumentMeta(PaperlessModel): """Represent a Paperless `Document`s metadata.""" diff --git a/pypaperless/models/mixins/__init__.py b/pypaperless/models/mixins/__init__.py index 1f902456..a055a7fb 100644 --- a/pypaperless/models/mixins/__init__.py +++ b/pypaperless/models/mixins/__init__.py @@ -2,11 +2,17 @@ from .creatable import CreatableMixin from .data_fields import MatchingFieldsMixin +from .deletable import DeletableMixin +from .saveable import SaveableMixin from .securable import SecurableDraftMixin, SecurableMixin +from .updatable import UpdatableMixin __all__ = ( "CreatableMixin", + "DeletableMixin", "MatchingFieldsMixin", + "SaveableMixin", "SecurableDraftMixin", "SecurableMixin", + "UpdatableMixin", ) diff --git a/pypaperless/models/mixins/deletable.py b/pypaperless/models/mixins/deletable.py new file mode 100644 index 00000000..33e5722f --- /dev/null +++ b/pypaperless/models/mixins/deletable.py @@ -0,0 +1,34 @@ +"""DeletableMixin for PyPaperless models.""" + +from typing import TYPE_CHECKING, ClassVar, cast + +if TYPE_CHECKING: + from pypaperless import Paperless + from pypaperless.const import PaperlessResource + + +class DeletableMixin: + """Model shortcut: delegate delete() to the bound service. + + Requires ``_resource`` to be set as a ``ClassVar[PaperlessResource]`` on the + model. Its string value is the attribute name of the matching service on the + ``Paperless`` client (e.g. ``_resource = PaperlessResource.DOCUMENTS``). + """ + + _resource: ClassVar["PaperlessResource"] + _client: "Paperless" + + async def delete(self) -> bool: + """Delete this model from Paperless. There is no point of return. + + Delegates to ``service.delete()``. + + Example: + ------- + ```python + doc = await paperless.documents(42) + await doc.delete() + ``` + + """ + return cast("bool", await getattr(self._client, self._resource).delete(self)) diff --git a/pypaperless/models/mixins/saveable.py b/pypaperless/models/mixins/saveable.py new file mode 100644 index 00000000..749cfe19 --- /dev/null +++ b/pypaperless/models/mixins/saveable.py @@ -0,0 +1,33 @@ +"""SaveableMixin for PyPaperless models.""" + +from typing import TYPE_CHECKING, ClassVar, cast + +if TYPE_CHECKING: + from pypaperless import Paperless + from pypaperless.const import PaperlessResource + + +class SaveableMixin: + """Model shortcut: delegate save() to the bound service. + + Requires ``_resource`` to be set as a ``ClassVar[PaperlessResource]`` on the + model. Intended for Draft models that implement ``CreatableMixin``. + """ + + _resource: ClassVar["PaperlessResource"] + _client: "Paperless" + + async def save(self) -> int | str: + """Persist this draft to Paperless and return the new resource id. + + Delegates to ``service.save()``. + + Example: + ------- + ```python + draft = paperless.tags.create(name="urgent") + new_id = await draft.save() + ``` + + """ + return cast("int | str", await getattr(self._client, self._resource).save(self)) diff --git a/pypaperless/models/mixins/updatable.py b/pypaperless/models/mixins/updatable.py new file mode 100644 index 00000000..4bc9a1e6 --- /dev/null +++ b/pypaperless/models/mixins/updatable.py @@ -0,0 +1,38 @@ +"""UpdatableMixin for PyPaperless models.""" + +from typing import TYPE_CHECKING, ClassVar, cast + +if TYPE_CHECKING: + from pypaperless import Paperless + from pypaperless.const import PaperlessResource + + +class UpdatableMixin: + """Model shortcut: delegate update() to the bound service. + + Requires ``_resource`` to be set as a ``ClassVar[PaperlessResource]`` on the + model. Its string value is the attribute name of the matching service on the + ``Paperless`` client (e.g. ``_resource = PaperlessResource.DOCUMENTS``). + """ + + _resource: ClassVar["PaperlessResource"] + _client: "Paperless" + + async def update(self, *, only_changed: bool = True) -> bool: + """Persist changes on this model to Paperless. + + Delegates to ``service.update()``. + + Example: + ------- + ```python + doc = await paperless.documents(42) + doc.title = "New Title" + await doc.update() + ``` + + """ + return cast( + "bool", + await getattr(self._client, self._resource).update(self, only_changed=only_changed), + ) diff --git a/pypaperless/models/pages.py b/pypaperless/models/pages.py index cd1c1af4..eb9e25d4 100644 --- a/pypaperless/models/pages.py +++ b/pypaperless/models/pages.py @@ -28,14 +28,6 @@ class Page(PaperlessModel, Generic[ResourceT]): # noqa: UP046 all: list[int] = Field(default_factory=list) results: list[dict[str, Any]] = Field(default_factory=list) - def __iter__(self) -> Iterator[ResourceT]: # type: ignore[override] - """Return iter of `.items`.""" - return iter(self.items) - - def set_resource_cls(self, resource_cls: type[ResourceT]) -> None: - """Set the resource class for items mapping.""" - self._resource_cls = resource_cls - @property def current_count(self) -> int: """Return the item count of the current page.""" @@ -66,7 +58,7 @@ def items(self) -> list[ResourceT]: """ def mapper(data: dict[str, Any]) -> ResourceT: - return self._resource_cls.create_with_data(self._client, data) + return self._resource_cls.from_data(self._client, data) return list(map(mapper, self.results)) @@ -93,3 +85,11 @@ def previous_page(self) -> int | None: if self.previous is None: return None return self.current_page - 1 + + def __iter__(self) -> Iterator[ResourceT]: # type: ignore[override] + """Return iter of `.items`.""" + return iter(self.items) + + def set_resource_cls(self, resource_cls: type[ResourceT]) -> None: + """Set the resource class for items mapping.""" + self._resource_cls = resource_cls diff --git a/pypaperless/models/share_links.py b/pypaperless/models/share_links.py index c60a04e5..5104c9d1 100644 --- a/pypaperless/models/share_links.py +++ b/pypaperless/models/share_links.py @@ -4,7 +4,7 @@ from enum import Enum from typing import ClassVar -from pypaperless.const import API_PATH +from pypaperless.const import API_PATH, PaperlessResource from . import mixins from .base import PaperlessModel @@ -25,10 +25,13 @@ def _missing_(cls: type, *_: object) -> "ShareLinkFileVersion": class ShareLink( PaperlessModel, + mixins.UpdatableMixin, + mixins.DeletableMixin, ): """Represent a Paperless `ShareLink`.""" _api_path: ClassVar[str] = API_PATH["share_links_single"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.SHARE_LINKS id: int created: datetime.datetime | None = None @@ -38,10 +41,11 @@ class ShareLink( file_version: ShareLinkFileVersion | None = None -class ShareLinkDraft(PaperlessModel, mixins.CreatableMixin): +class ShareLinkDraft(PaperlessModel, mixins.CreatableMixin, mixins.SaveableMixin): """Represent a new Paperless `ShareLink`, which is not stored in Paperless.""" _api_path: ClassVar[str] = API_PATH["share_links"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.SHARE_LINKS _create_required_fields: ClassVar[set[str]] = {"document", "file_version"} diff --git a/pypaperless/models/storage_paths.py b/pypaperless/models/storage_paths.py index 9751adaa..d406502d 100644 --- a/pypaperless/models/storage_paths.py +++ b/pypaperless/models/storage_paths.py @@ -2,7 +2,7 @@ from typing import ClassVar -from pypaperless.const import API_PATH +from pypaperless.const import API_PATH, PaperlessResource from . import mixins from .base import PaperlessModel @@ -12,10 +12,13 @@ class StoragePath( PaperlessModel, mixins.MatchingFieldsMixin, mixins.SecurableMixin, + mixins.UpdatableMixin, + mixins.DeletableMixin, ): """Represent a Paperless `StoragePath`.""" _api_path: ClassVar[str] = API_PATH["storage_paths_single"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.STORAGE_PATHS id: int | None = None slug: str | None = None @@ -29,10 +32,12 @@ class StoragePathDraft( mixins.MatchingFieldsMixin, mixins.SecurableDraftMixin, mixins.CreatableMixin, + mixins.SaveableMixin, ): """Represent a new `StoragePath`, which is not yet stored in Paperless.""" _api_path: ClassVar[str] = API_PATH["storage_paths"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.STORAGE_PATHS _create_required_fields: ClassVar[set[str]] = { "name", diff --git a/pypaperless/models/tags.py b/pypaperless/models/tags.py index 36f6e425..53e2da2a 100644 --- a/pypaperless/models/tags.py +++ b/pypaperless/models/tags.py @@ -1,10 +1,8 @@ """Provide `Tag` related models.""" -from typing import Any, ClassVar +from typing import ClassVar -from pydantic import field_validator - -from pypaperless.const import API_PATH +from pypaperless.const import API_PATH, PaperlessResource from . import mixins from .base import PaperlessModel @@ -14,10 +12,13 @@ class Tag( PaperlessModel, mixins.MatchingFieldsMixin, mixins.SecurableMixin, + mixins.UpdatableMixin, + mixins.DeletableMixin, ): """Represent a Paperless `Tag`.""" _api_path: ClassVar[str] = API_PATH["tags_single"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.TAGS id: int | None = None slug: str | None = None @@ -29,31 +30,18 @@ class Tag( parent: int | None = None children: list["Tag"] | None = None - @classmethod - def _build_child_tag(cls, item: dict[str, Any]) -> "Tag": - nested = [ - cls._build_child_tag(c) if isinstance(c, dict) else c - for c in (item.get("children") or []) - ] - return cls.model_construct(**{**item, "children": nested or None}) - - @field_validator("children", mode="before") - @classmethod - def _validate_children(cls, v: Any) -> Any: - if not v: - return v - return [cls._build_child_tag(item) if isinstance(item, dict) else item for item in v] - class TagDraft( PaperlessModel, mixins.MatchingFieldsMixin, mixins.SecurableDraftMixin, mixins.CreatableMixin, + mixins.SaveableMixin, ): """Represent a new `Tag`, which is not yet stored in Paperless.""" _api_path: ClassVar[str] = API_PATH["tags"] + _resource: ClassVar[PaperlessResource] = PaperlessResource.TAGS _create_required_fields: ClassVar[set[str]] = { "name", diff --git a/pypaperless/services/base.py b/pypaperless/services/base.py index ea672697..436a8a48 100644 --- a/pypaperless/services/base.py +++ b/pypaperless/services/base.py @@ -13,15 +13,15 @@ class PaperlessBase: _api_path = API_PATH["index"] + def __init__(self, client: "Paperless") -> None: + """Initialize a `PaperlessBase` instance.""" + self._client = client + @property def api_path(self) -> str: """Return the API path for the object.""" return self._api_path - def __init__(self, client: "Paperless") -> None: - """Initialize a `PaperlessBase` instance.""" - self._client = client - class ServiceProtocol[ResourceT](Protocol): """Protocol for any `ServiceBase` instances and its ancestors.""" diff --git a/pypaperless/services/documents.py b/pypaperless/services/documents.py index ade01f58..cfb17125 100644 --- a/pypaperless/services/documents.py +++ b/pypaperless/services/documents.py @@ -40,7 +40,7 @@ async def __call__(self, pk: int) -> DocumentSuggestions: data = await self._client.request_json("get", api_path) data["id"] = pk - return self._resource_cls.create_with_data(self._client, data) + return self._resource_cls.from_data(self._client, data) class DocumentSubServiceBase(ServiceBase): @@ -83,7 +83,7 @@ async def __call__( if stripped.startswith("filename="): data["disposition_filename"] = stripped.split("=", 1)[1].strip('"') - return self._resource_cls.create_with_data(self._client, data) + return self._resource_cls.from_data(self._client, data) class DocumentFileDownloadService(DocumentSubServiceBase): @@ -156,8 +156,7 @@ async def __call__(self, pk: int | None = None) -> list[DocumentHistory]: doc_pk = self._get_document_pk(pk) res = await self._client.request_json("get", self._api_path.format(pk=doc_pk)) return [ - self._resource_cls.create_with_data(self._client, {**item, "document": doc_pk}) - for item in res + self._resource_cls.from_data(self._client, {**item, "document": doc_pk}) for item in res ] def _get_document_pk(self, pk: int | None = None) -> int: @@ -208,7 +207,7 @@ async def __call__( # .document -> does not exist (so we add it here) # .user -> dict(id=int, username=str, first_name=str, last_name=str) return [ - self._resource_cls.create_with_data( + self._resource_cls.from_data( self._client, { **item, @@ -232,19 +231,19 @@ def _get_api_path(self, pk: int) -> str: """Return the formatted api path.""" return self._api_path.format(pk=pk) - def draft(self, pk: int | None = None, **kwargs: Any) -> DocumentNoteDraft: + def create(self, pk: int | None = None, **kwargs: Any) -> DocumentNoteDraft: """Return a fresh and empty `DocumentNoteDraft` instance. Example: ------- ```python - draft = paperless.documents.notes.draft(...) + draft = paperless.documents.notes.create(...) # do something ``` """ kwargs.update({"document": self._get_document_pk(pk)}) - return DocumentNoteDraft.create_with_data( + return DocumentNoteDraft.from_data( self._client, data=kwargs, ) @@ -319,13 +318,7 @@ def download(self) -> DocumentFileDownloadService: Example: ------- ```python - # request document contents directly... download = await paperless.documents.download(42) - - # ... or by using an already fetched document - doc = await paperless.documents(42) - - download = await doc.get_download() ``` """ @@ -356,12 +349,7 @@ def metadata(self) -> DocumentMetaService: Example: ------- ```python - # request metadata of a document directly... metadata = await paperless.documents.metadata(42) - - # ... or by using an already fetched document - doc = await paperless.documents(42) - metadata = await doc.get_metadata() ``` """ @@ -392,13 +380,7 @@ def preview(self) -> DocumentFilePreviewService: Example: ------- ```python - # request document contents directly... download = await paperless.documents.preview(42) - - # ... or by using an already fetched document - doc = await paperless.documents(42) - - download = await doc.get_preview() ``` """ @@ -411,13 +393,7 @@ def suggestions(self) -> DocumentSuggestionsService: Example: ------- ```python - # request document suggestions directly... suggestions = await paperless.documents.suggestions(42) - - # ... or by using an already fetched document - doc = await paperless.suggestions(42) - - suggestions = await doc.get_suggestions() ``` """ @@ -430,13 +406,7 @@ def thumbnail(self) -> DocumentFileThumbnailService: Example: ------- ```python - # request document contents directly... download = await paperless.documents.thumbnail(42) - - # ... or by using an already fetched document - doc = await paperless.documents(42) - - download = await doc.get_thumbnail() ``` """ diff --git a/pypaperless/services/generators/page.py b/pypaperless/services/generators/page.py index b4c77b1b..69673090 100644 --- a/pypaperless/services/generators/page.py +++ b/pypaperless/services/generators/page.py @@ -38,7 +38,7 @@ async def __anext__(self) -> Page: "current_page": self.params["page"], "page_size": self.params["page_size"], } - self._page = Page.create_with_data(self._client, data) + self._page = Page.from_data(self._client, data) # Attach the resource class to the page for items mapping self._page.set_resource_cls(self._resource_cls) diff --git a/pypaperless/services/mixins/callable.py b/pypaperless/services/mixins/callable.py index d8bf5feb..22d9e610 100644 --- a/pypaperless/services/mixins/callable.py +++ b/pypaperless/services/mixins/callable.py @@ -29,7 +29,7 @@ async def __call__( """ if lazy: - return self._resource_cls.create_with_data(self._client, {"id": pk}) + return self._resource_cls.from_data(self._client, {"id": pk}) params: dict[str, Any] = {} if getattr(self, "_request_full_perms", False): @@ -38,4 +38,4 @@ async def __call__( api_path = self._resource_cls.format_api_path(pk=pk) data = await self._client.request_json("get", api_path, params=params or None) - return self._resource_cls.create_with_data(self._client, data) + return self._resource_cls.from_data(self._client, data) diff --git a/pypaperless/services/mixins/draftable.py b/pypaperless/services/mixins/draftable.py index 493930e4..1c6a7717 100644 --- a/pypaperless/services/mixins/draftable.py +++ b/pypaperless/services/mixins/draftable.py @@ -12,13 +12,13 @@ class DraftableMixin(ServiceProtocol[ResourceT]): _draft_cls: type[ResourceT] - def draft(self, **kwargs: Any) -> ResourceT: + def create(self, **kwargs: Any) -> ResourceT: """Return a fresh and empty `PaperlessModel` instance. Example: ------- ```python - draft = paperless.documents.draft(document=bytes(...), title="New Document") + draft = paperless.documents.create(document=bytes(...), title="New Document") # do something ``` @@ -28,7 +28,7 @@ def draft(self, **kwargs: Any) -> ResourceT: raise DraftNotSupportedError(message) kwargs.update({"id": -1}) - return self._draft_cls.create_with_data(self._client, data=kwargs) + return self._draft_cls.from_data(self._client, data=kwargs) async def save(self, draft: ResourceT) -> int | str: """Create a new `resource item` in Paperless. @@ -38,7 +38,7 @@ async def save(self, draft: ResourceT) -> int | str: Example: ------- ```python - draft = paperless.documents.draft(document=bytes(...)) + draft = paperless.documents.create(document=bytes(...)) draft.title = "Add a title" # request Paperless to store the new item diff --git a/pypaperless/services/profile.py b/pypaperless/services/profile.py index e9c4885b..6aa0cf39 100644 --- a/pypaperless/services/profile.py +++ b/pypaperless/services/profile.py @@ -17,7 +17,7 @@ class ProfileService(ServiceBase): async def __call__(self) -> Profile: """Request the `Profile` model data.""" res = await self._client.request_json("get", self._api_path) - return self._resource_cls.create_with_data(self._client, res) + return self._resource_cls.from_data(self._client, res) async def update( self, @@ -46,4 +46,4 @@ async def update( if last_name is not None: payload["last_name"] = last_name res = await self._client.request_json("patch", self._api_path, json=payload) - return self._resource_cls.create_with_data(self._client, res) + return self._resource_cls.from_data(self._client, res) diff --git a/pypaperless/services/remote_version.py b/pypaperless/services/remote_version.py index c9625835..679d42cd 100644 --- a/pypaperless/services/remote_version.py +++ b/pypaperless/services/remote_version.py @@ -17,4 +17,4 @@ class RemoteVersionService(ServiceBase): async def __call__(self) -> RemoteVersion: """Request the `Remote Version` model data.""" res = await self._client.request_json("get", self._api_path) - return self._resource_cls.create_with_data(self._client, res) + return self._resource_cls.from_data(self._client, res) diff --git a/pypaperless/services/statistics.py b/pypaperless/services/statistics.py index cab2916f..ddb224ef 100644 --- a/pypaperless/services/statistics.py +++ b/pypaperless/services/statistics.py @@ -17,4 +17,4 @@ class StatisticService(ServiceBase): async def __call__(self) -> Statistic: """Request the `Statistic` model data.""" res = await self._client.request_json("get", self._api_path) - return self._resource_cls.create_with_data(self._client, res) + return self._resource_cls.from_data(self._client, res) diff --git a/pypaperless/services/status.py b/pypaperless/services/status.py index b4e1d20f..5b4dc970 100644 --- a/pypaperless/services/status.py +++ b/pypaperless/services/status.py @@ -17,4 +17,4 @@ class StatusService(ServiceBase): async def __call__(self) -> Status: """Request the `Status` model data.""" res = await self._client.request_json("get", self._api_path) - return self._resource_cls.create_with_data(self._client, res) + return self._resource_cls.from_data(self._client, res) diff --git a/pypaperless/services/tasks.py b/pypaperless/services/tasks.py index 42627e47..e0f3dae6 100644 --- a/pypaperless/services/tasks.py +++ b/pypaperless/services/tasks.py @@ -49,7 +49,7 @@ async def filter(self, **kwargs: Unpack[TaskFilters]) -> AsyncIterator[Task]: """ res = await self._client.request_json("get", self._api_path, params=dict(kwargs)) for data in res: - yield self._resource_cls.create_with_data(self._client, data) + yield self._resource_cls.from_data(self._client, data) async def __call__(self, task_id: int | str) -> Task: """Request exactly one task by id. @@ -71,10 +71,10 @@ async def __call__(self, task_id: int | str) -> Task: } res = await self._client.request_json("get", self._api_path, params=params) try: - return self._resource_cls.create_with_data(self._client, res.pop()) + return self._resource_cls.from_data(self._client, res.pop()) except IndexError as exc: raise TaskNotFoundError(task_id) from exc else: api_path = self._resource_cls.format_api_path(pk=task_id) data = await self._client.request_json("get", api_path) - return self._resource_cls.create_with_data(self._client, data) + return self._resource_cls.from_data(self._client, data) diff --git a/pypaperless/settings.py b/pypaperless/settings.py new file mode 100644 index 00000000..7f2ed417 --- /dev/null +++ b/pypaperless/settings.py @@ -0,0 +1,48 @@ +"""PyPaperless client configuration.""" + +from pydantic import model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from .const import API_VERSION, ENV_PREFIX, ENV_URL + + +class PaperlessConfig(BaseSettings): + """Configuration for the `Paperless` client. + + All fields can be supplied via environment variables with the ``PYPAPERLESS_`` prefix: + + - ``PYPAPERLESS_URL`` — Paperless-ngx base URL + - ``PYPAPERLESS_TOKEN`` — API token + - ``PYPAPERLESS_REQUEST_API_VERSION`` — API version to request (defaults to the built-in value) + + Example — from environment: + ```python + # export PYPAPERLESS_URL=https://paperless.example.com + # export PYPAPERLESS_TOKEN=mytoken + async with Paperless() as paperless: + ... + ``` + + Example — explicit config object: + ```python + cfg = PaperlessConfig(url="https://paperless.example.com", token="mytoken") + async with Paperless(config=cfg) as paperless: + ... + ``` + """ + + model_config = SettingsConfigDict(env_prefix=ENV_PREFIX) + + url: str = "" + token: str | None = None + request_api_version: int = API_VERSION + + @model_validator(mode="after") + def _require_url(self) -> "PaperlessConfig": + if not self.url: + msg = ( + "PaperlessConfig requires a URL. " + f"Pass url= explicitly or set the {ENV_URL} environment variable." + ) + raise ValueError(msg) + return self diff --git a/pypaperless/utils.py b/pypaperless/utils.py index 7b85a3a8..654933af 100644 --- a/pypaperless/utils.py +++ b/pypaperless/utils.py @@ -7,8 +7,6 @@ from pydantic import BaseModel -from .models import base as paperless_base - def normalize_base_url(url: str) -> str: """Normalize a URL string for use as a Paperless API base URL.""" @@ -83,12 +81,7 @@ def object_to_dict_value(value: Any) -> Any: return value.value if isinstance(value, (date, datetime)): return _dateobj_to_str(value) - if isinstance(value, (paperless_base.PaperlessModelData, BaseModel)): - serialized = ( - value.serialize() - if isinstance(value, paperless_base.PaperlessModelData) - else value.model_dump(mode="json") - ) - return object_to_dict_value(serialized) + if isinstance(value, BaseModel): + return object_to_dict_value(value.model_dump(mode="json")) return value diff --git a/pyproject.toml b/pyproject.toml index 44f5b92f..45f49425 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,10 +23,15 @@ classifiers = [ ] requires-python = ">=3.13, <3.15" dependencies = [ + "click>=8.0", "httpx>=0.28.0", "pydantic>=2.10.0", + "pydantic-settings>=2.7.0", ] +[project.scripts] +pypaperless = "pypaperless.cli:cli" + [dependency-groups] dev = [ "codespell>=2.4.1", diff --git a/script/pngx_smoketest.py b/script/pngx_smoketest.py index 7923571b..bc3700ae 100644 --- a/script/pngx_smoketest.py +++ b/script/pngx_smoketest.py @@ -316,12 +316,11 @@ async def test_documents(p: Paperless) -> None: ) # suggestions - if doc is not None: - await check( - "doc.get_suggestions()", - doc.get_suggestions(), - detail_fn=lambda r: f"tags={r.tags}, correspondents={r.correspondents}", - ) + await check( + f"documents.suggestions({TEST_DOCUMENT_ID})", + p.documents.suggestions(TEST_DOCUMENT_ID), + detail_fn=lambda r: f"tags={r.tags}, correspondents={r.correspondents}", + ) # helper properties on document if doc is not None: @@ -332,6 +331,39 @@ async def test_documents(p: Paperless) -> None: except Exception as exc: fail("doc properties", exc) + # shortcuts on Document instance + await check( + "doc.metadata() (shortcut)", + doc.metadata(), + detail_fn=lambda r: f"mime={r.original_mime_type}", + ) + await check( + "doc.thumbnail() (shortcut)", + doc.thumbnail(), + detail_fn=lambda r: f"bytes={len(r.content or b'')}", + ) + await check( + "doc.suggestions() (shortcut)", + doc.suggestions(), + detail_fn=lambda r: f"tags={r.tags}", + ) + try: + similar = [d async for d in doc.more_like()] + ok("doc.more_like() (shortcut)", f"similar={len(similar)}") + except Exception as exc: + fail("doc.more_like() shortcut", exc) + + # doc.email() shortcut — we expect success or SendEmailError depending on server config + try: + await doc.email( + addresses="smoketest@example.org", + subject="pypaperless smoketest", + message="Automated shortcut test.", + ) + ok("doc.email() (shortcut)", "sent") + except Exception as exc: + ok("doc.email() (shortcut)", f"skipped – {type(exc).__name__}: {exc}") + # ────────────────────────────────────────────────────────────────────────────── async def test_document_history(p: Paperless) -> None: @@ -379,7 +411,7 @@ async def test_document_notes(p: Paperless) -> None: # create a note note_id = None try: - draft: DocumentNoteDraft = p.documents.notes.draft( + draft: DocumentNoteDraft = p.documents.notes.create( TEST_DOCUMENT_ID, note="pypaperless smoke-test note" ) note_id, doc_id = await p.documents.notes.save(draft) @@ -726,7 +758,7 @@ async def test_correspondents(p: Paperless) -> None: ) # create - draft = CorrespondentDraft.create_with_data( + draft = CorrespondentDraft.from_data( p, { "name": "pypaperless Smoke Test Corp", @@ -759,6 +791,25 @@ async def test_correspondents(p: Paperless) -> None: detail_fn=lambda r: f"changed={r}", ) + # model shortcuts – update() and draft.save() + try: + ar_draft = p.correspondents.create( + name="pypaperless AR Shortcut Corp", + match="", + matching_algorithm=0, + is_insensitive=True, + ) + ar_id = int(await ar_draft.save()) + ok("correspondent_draft.save()", f"id={ar_id} (shortcut)") + ar_corr = await p.correspondents(ar_id) + ar_corr.name = "pypaperless AR Shortcut Corp (updated)" + ar_changed = await ar_corr.update() + ok("correspondent.update()", f"changed={ar_changed} (shortcut)") + ar_deleted = await ar_corr.delete() + ok("correspondent.delete()", f"deleted={ar_deleted} (shortcut)") + except Exception as exc: + fail("model shortcut (correspondent)", exc) + # permissions async with p.correspondents.with_permissions(): await check( @@ -782,7 +833,7 @@ async def test_tags(p: Paperless) -> None: await check("tags.as_list()", p.tags.as_list(), detail_fn=lambda r: f"count={len(r)}") - draft = TagDraft.create_with_data( + draft = TagDraft.from_data( p, { "name": "pypaperless-smoke-test", @@ -845,7 +896,7 @@ async def test_tags(p: Paperless) -> None: parent_id: int | None = None child_id: int | None = None - _parent_draft = TagDraft.create_with_data( + _parent_draft = TagDraft.from_data( p, { "name": "pypaperless-smoke-parent", @@ -863,7 +914,7 @@ async def test_tags(p: Paperless) -> None: fail("tags.save(parent_draft)", exc) if parent_id is not None: - _child_draft = TagDraft.create_with_data( + _child_draft = TagDraft.from_data( p, { "name": "pypaperless-smoke-child", @@ -920,7 +971,7 @@ async def test_document_types(p: Paperless) -> None: detail_fn=lambda r: f"count={len(r)}", ) - draft = DocumentTypeDraft.create_with_data( + draft = DocumentTypeDraft.from_data( p, { "name": "pypaperless Smoke Test Type", @@ -964,7 +1015,7 @@ async def test_storage_paths(p: Paperless) -> None: detail_fn=lambda r: f"count={len(r)}", ) - draft = StoragePathDraft.create_with_data( + draft = StoragePathDraft.from_data( p, { "name": "pypaperless Smoke Test Path", @@ -1009,7 +1060,7 @@ async def test_share_links(p: Paperless) -> None: detail_fn=lambda r: f"count={len(r)}", ) - draft = ShareLinkDraft.create_with_data( + draft = ShareLinkDraft.from_data( p, { "document": TEST_DOCUMENT_ID, @@ -1159,7 +1210,7 @@ async def test_document_post(p: Paperless) -> None: _hdr("Document POST – upload a minimal PDF") token = str(uuid.uuid4()) - draft = DocumentDraft.create_with_data( + draft = DocumentDraft.from_data( p, { "document": _make_unique_pdf(token), @@ -1192,7 +1243,7 @@ async def test_document_post_with_cf_mapping(p: Paperless) -> None: # Variant A: list[int] – assign field IDs only (empty values) token_a = str(uuid.uuid4()) - draft_a = DocumentDraft.create_with_data( + draft_a = DocumentDraft.from_data( p, { "document": _make_unique_pdf(token_a), @@ -1210,11 +1261,11 @@ async def test_document_post_with_cf_mapping(p: Paperless) -> None: # Variant B: DocumentCustomFieldList – object mapping with typed values token_b = str(uuid.uuid4()) - cf = DocumentCustomFieldList(p, []) + cf = DocumentCustomFieldList.from_data(p, []) cf += CustomFieldStringValue(field=8, value="pypaperless-smoke") cf += CustomFieldIntegerValue(field=3, value=1) - draft_b = DocumentDraft.create_with_data( + draft_b = DocumentDraft.from_data( p, { "document": _make_unique_pdf(token_b), diff --git a/tests/mappings.py b/tests/mappings.py index aa23dc76..9385b203 100644 --- a/tests/mappings.py +++ b/tests/mappings.py @@ -43,6 +43,8 @@ class ResourceTestMapping: # field and value used by test_update update_field: str = "name" update_value: Any = "Name Updated" + # explicit mock JSON for test_draft_save; None → {"id": N} default + draft_response_json: Any = None CONFIG_MAP = ResourceTestMapping( @@ -95,6 +97,9 @@ class ResourceTestMapping: "created": None, "archive_serial_number": 1, }, + update_field="title", + update_value="Updated Title", + draft_response_json="11112222-3333-4444-5555-666677778888", ) DOCUMENT_TYPE_MAP = ResourceTestMapping( diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..8d4dc0fc --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,409 @@ +"""Tests for the CLI commands.""" + +import json +import re +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + +import httpx as _httpx +import pytest +from click.testing import CliRunner +from pytest_httpx import HTTPXMock + +import pypaperless.cli as cli_module +from pypaperless.cli import _out, _render_compact_list, _resource_group, cli +from pypaperless.const import API_PATH + +from .const import PAPERLESS_TEST_TOKEN, PAPERLESS_TEST_URL +from .data import ( + DATA_PROFILE, + DATA_REMOTE_VERSION, + DATA_SCHEMA, + DATA_STATUS, + DATA_TAGS, + DATA_TASKS, +) + +# Common CLI args for explicit credentials +_ARGS = ["--url", PAPERLESS_TEST_URL, "--token", PAPERLESS_TEST_TOKEN] + + +def _mock_init(httpx_mock: HTTPXMock) -> None: + """Add a successful initialization response.""" + httpx_mock.add_response( + url=f"{PAPERLESS_TEST_URL}{API_PATH['index']}", + method="GET", + status_code=200, + json=DATA_SCHEMA, + ) + + +# ── help / smoke ────────────────────────────────────────────────────────────── + + +def test_cli_help() -> None: + """--help returns exit code 0 and shows usage.""" + runner = CliRunner() + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "pypaperless" in result.output.lower() + + +def test_cli_subcommand_help() -> None: + """Tags --help shows list, json and get subcommands.""" + runner = CliRunner() + result = runner.invoke(cli, ["tags", "--help"]) + assert result.exit_code == 0 + assert "list" in result.output + assert "json" in result.output + assert "get" in result.output + + +def test_cli_list_subcommand_help() -> None: + """Tags list --help shows --limit option.""" + runner = CliRunner() + result = runner.invoke(cli, ["tags", "list", "--help"]) + assert result.exit_code == 0 + assert "--limit" in result.output + + +# ── status ──────────────────────────────────────────────────────────────────── + + +def test_cli_status(httpx_mock: HTTPXMock) -> None: + """Status outputs host_version, api_version, status and remote_version keys.""" + _mock_init(httpx_mock) + httpx_mock.add_response( + url=f"{PAPERLESS_TEST_URL}{API_PATH['status']}", + method="GET", + status_code=200, + json=DATA_STATUS, + ) + httpx_mock.add_response( + url=f"{PAPERLESS_TEST_URL}{API_PATH['remote_version']}", + method="GET", + status_code=200, + json=DATA_REMOTE_VERSION, + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "status"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert "host_version" in data + assert "api_version" in data + assert "status" in data + assert "remote_version" in data + + +# ── profile ─────────────────────────────────────────────────────────────────── + + +def test_cli_profile(httpx_mock: HTTPXMock) -> None: + """Profile outputs user profile as JSON.""" + _mock_init(httpx_mock) + httpx_mock.add_response( + url=f"{PAPERLESS_TEST_URL}{API_PATH['profile']}", + method="GET", + status_code=200, + json=DATA_PROFILE, + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "profile"]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["email"] == DATA_PROFILE["email"] + + +# ── resource list ───────────────────────────────────────────────────────────── + + +def test_cli_tags_list(httpx_mock: HTTPXMock) -> None: + """Tags list returns a structured ID/name console table.""" + _mock_init(httpx_mock) + httpx_mock.add_response( + url=re.compile(r"^" + re.escape(f"{PAPERLESS_TEST_URL}{API_PATH['tags']}") + r"(\?.*)?$"), + method="GET", + status_code=200, + json=DATA_TAGS, + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "tags", "list"]) + assert result.exit_code == 0, result.output + assert "ID" in result.output + assert "Name" in result.output + assert DATA_TAGS["results"][0]["name"] in result.output + + +def test_cli_tags_list_limit(httpx_mock: HTTPXMock) -> None: + """Tags list --limit 1 prints exactly one data row.""" + _mock_init(httpx_mock) + httpx_mock.add_response( + url=re.compile(r"^" + re.escape(f"{PAPERLESS_TEST_URL}{API_PATH['tags']}") + r"(\?.*)?$"), + method="GET", + status_code=200, + json=DATA_TAGS, + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "tags", "list", "--limit", "1"]) + assert result.exit_code == 0, result.output + lines = [line for line in result.output.splitlines() if line.strip()] + data_lines = lines[2:] + assert len(data_lines) == 1 + + +def test_cli_tags_list_sorted_by_name(httpx_mock: HTTPXMock) -> None: + """Tags list sorts rows by name.""" + _mock_init(httpx_mock) + httpx_mock.add_response( + url=re.compile(r"^" + re.escape(f"{PAPERLESS_TEST_URL}{API_PATH['tags']}") + r"(\?.*)?$"), + method="GET", + status_code=200, + json={ + "count": 2, + "next": None, + "previous": None, + "results": [ + {"id": 2, "name": "Zulu"}, + {"id": 1, "name": "alpha"}, + ], + }, + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "tags", "list"]) + assert result.exit_code == 0, result.output + lines = [line for line in result.output.splitlines() if line.strip()] + data_lines = lines[2:] + assert data_lines[0].endswith("alpha") + assert data_lines[1].endswith("Zulu") + + +def test_cli_tags_json(httpx_mock: HTTPXMock) -> None: + """Tags json returns a JSON array of all tag items.""" + _mock_init(httpx_mock) + httpx_mock.add_response( + url=re.compile(r"^" + re.escape(f"{PAPERLESS_TEST_URL}{API_PATH['tags']}") + r"(\?.*)?$"), + method="GET", + status_code=200, + json=DATA_TAGS, + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "tags", "json"]) + assert result.exit_code == 0, result.output + items = json.loads(result.output) + assert isinstance(items, list) + assert len(items) == len(DATA_TAGS["results"]) + + +# ── resource get ────────────────────────────────────────────────────────────── + + +def test_cli_tags_get(httpx_mock: HTTPXMock) -> None: + """Tags get returns a single tag as JSON.""" + _mock_init(httpx_mock) + tag_data = DATA_TAGS["results"][0] + tag_id = tag_data["id"] + httpx_mock.add_response( + url=f"{PAPERLESS_TEST_URL}{API_PATH['tags_single']}".format(pk=tag_id), + method="GET", + status_code=200, + json=tag_data, + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "tags", "get", str(tag_id)]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["id"] == tag_id + + +def test_cli_tasks_get_by_string_id(httpx_mock: HTTPXMock) -> None: + """Tasks get accepts a string (UUID) as the task id argument.""" + _mock_init(httpx_mock) + # tasks.filter() is called for string IDs + task_data = DATA_TASKS[0] + task_uuid = task_data["task_id"] + httpx_mock.add_response( + url=f"{PAPERLESS_TEST_URL}{API_PATH['tasks']}", + method="GET", + match_params={"task_id": task_uuid}, + status_code=200, + json=[task_data], + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "tasks", "get", task_uuid]) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["task_id"] == task_uuid + + +# ── env var configuration ───────────────────────────────────────────────────── + + +def test_cli_env_credentials(httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch) -> None: + """CLI reads PYPAPERLESS_URL + PYPAPERLESS_TOKEN from the environment.""" + monkeypatch.setenv("PYPAPERLESS_URL", PAPERLESS_TEST_URL) + monkeypatch.setenv("PYPAPERLESS_TOKEN", PAPERLESS_TEST_TOKEN) + _mock_init(httpx_mock) + httpx_mock.add_response( + url=re.compile(r"^" + re.escape(f"{PAPERLESS_TEST_URL}{API_PATH['tags']}") + r"(\?.*)?$"), + method="GET", + status_code=200, + json=DATA_TAGS, + ) + + runner = CliRunner() + # No --url / --token options — should pick up from env + result = runner.invoke(cli, ["tags", "json"]) + assert result.exit_code == 0, result.output + assert isinstance(json.loads(result.output), list) + + +# ── error handling ──────────────────────────────────────────────────────────── + + +def test_cli_connection_error(httpx_mock: HTTPXMock) -> None: + """CLI reports a clean error on connection failure.""" + httpx_mock.add_exception( + _httpx.ConnectError("refused"), + url=f"{PAPERLESS_TEST_URL}{API_PATH['index']}", + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "tags", "list"]) + assert result.exit_code != 0 + assert "Connection error" in result.output + + +def test_cli_invalid_token(httpx_mock: HTTPXMock) -> None: + """CLI reports a clean error on 401 Unauthorized.""" + httpx_mock.add_response( + url=f"{PAPERLESS_TEST_URL}{API_PATH['index']}", + method="GET", + status_code=401, + text="Unauthorized", + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "tags", "list"]) + assert result.exit_code != 0 + assert "token" in result.output.lower() + + +def test_cli_missing_url(monkeypatch: pytest.MonkeyPatch) -> None: + """CLI raises a config error when no URL is configured at all.""" + monkeypatch.delenv("PYPAPERLESS_URL", raising=False) + monkeypatch.delenv("PYPAPERLESS_TOKEN", raising=False) + + runner = CliRunner() + result = runner.invoke(cli, ["tags", "list"]) + assert result.exit_code != 0 + assert "Configuration error" in result.output + + +def test_cli_forbidden_error(httpx_mock: HTTPXMock) -> None: + """CLI reports 'Access denied' when the server returns 403.""" + httpx_mock.add_response( + url=f"{PAPERLESS_TEST_URL}{API_PATH['index']}", + method="GET", + status_code=403, + text="Forbidden", + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "tags", "list"]) + assert result.exit_code != 0 + assert "Access denied" in result.output + + +def test_cli_initialization_error(httpx_mock: HTTPXMock) -> None: + """CLI reports 'Initialization failed' when the server returns 500 during init.""" + httpx_mock.add_response( + url=f"{PAPERLESS_TEST_URL}{API_PATH['index']}", + method="GET", + status_code=500, + text="Internal Server Error", + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "tags", "list"]) + assert result.exit_code != 0 + assert "Initialization failed" in result.output + + +# ── json --limit ────────────────────────────────────────────────────────────── + + +def test_cli_tags_json_limit(httpx_mock: HTTPXMock) -> None: + """Tags json --limit 1 returns exactly one item.""" + _mock_init(httpx_mock) + httpx_mock.add_response( + url=re.compile(r"^" + re.escape(f"{PAPERLESS_TEST_URL}{API_PATH['tags']}") + r"(\?.*)?$"), + method="GET", + status_code=200, + json=DATA_TAGS, + ) + + runner = CliRunner() + result = runner.invoke(cli, [*_ARGS, "tags", "json", "--limit", "1"]) + assert result.exit_code == 0, result.output + items = json.loads(result.output) + assert len(items) == 1 + + +# ── _render_compact_list unit tests ────────────────────────────────────────── + + +def test_render_compact_list_no_name_key(capsys: pytest.CaptureFixture[str]) -> None: + """_render_compact_list renders item with id only when no name-like key exists.""" + # Item has no name/title/username/task_id/slug — row_name stays "" + _render_compact_list([SimpleNamespace(id=42)]) + out = capsys.readouterr().out + assert "42" in out + + +def test_render_compact_list_fallback_key(capsys: pytest.CaptureFixture[str]) -> None: + """_render_compact_list falls back to the first key that is not None or empty.""" + # name=None forces the loop to continue; title provides the value + _render_compact_list([SimpleNamespace(id=1, name=None, title="My Title")]) + out = capsys.readouterr().out + assert "My Title" in out + + +# ── _out unit tests ─────────────────────────────────────────────────────────── + + +def test_out_pygments_highlight(monkeypatch: pytest.MonkeyPatch) -> None: + """_out uses pygments highlight when stdout is a TTY and pygments is available.""" + if not cli_module._PYGMENTS: + pytest.skip("pygments not installed") + + mock_stdout = MagicMock() + mock_stdout.isatty.return_value = True + monkeypatch.setattr(sys, "stdout", mock_stdout) + + captured: list[str] = [] + monkeypatch.setattr(cli_module.click, "echo", captured.append) + + _out({"key": "value"}) + assert len(captured) == 1 + assert "key" in captured[0] + + +# ── _resource_group factory ─────────────────────────────────────────────────── + + +def test_cli_resource_group_no_list() -> None: + """_resource_group with supports_list=False omits list and json subcommands.""" + grp = _resource_group("test", "tags", supports_list=False) + commands = set(grp.commands or {}) + assert "list" not in commands + assert "json" not in commands + assert "get" in commands diff --git a/tests/test_client.py b/tests/test_client.py index e72f7b2d..92e73b8e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,11 +4,11 @@ import httpx import pytest -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from pytest_httpx import HTTPXMock -from pypaperless import Paperless -from pypaperless.const import API_PATH +from pypaperless import Paperless, PaperlessConfig +from pypaperless.const import API_PATH, API_VERSION from pypaperless.exceptions import ( BadJsonResponseError, DraftNotSupportedError, @@ -278,7 +278,7 @@ class TestResource(PaperlessModel): data["all"].append(i) data["results"].append({"id": i}) - page = Page.create_with_data(api, data=data) + page = Page.from_data(api, data=data) page.set_resource_cls(TestResource) assert isinstance(page, Page) @@ -309,7 +309,7 @@ class TestResource(PaperlessModel): async def test_draft_not_supported(api: Paperless) -> None: - """Test that DraftableMixin.draft() raises when no draft_cls is configured.""" + """Test that DraftableMixin.create() raises when no draft_cls is configured.""" class TestResource(PaperlessModel): pass @@ -321,7 +321,7 @@ class TestService(ServiceBase, service_mixins.DraftableMixin): service = TestService(api) with pytest.raises(DraftNotSupportedError): - service.draft() + service.create() async def test_object_to_dict_value() -> None: @@ -403,3 +403,61 @@ def test_process_form_data_duplicate_key_scalar_to_list() -> None: data2, _ = process_form_data({"ids": [10, 20]}) # Both values land in data2["ids"] as a list assert data2["ids"] == ["10", "20"] + + +# --------------------------------------------------------------------------- +# PaperlessConfig / multi-mode init tests +# --------------------------------------------------------------------------- + + +def test_config_explicit_params() -> None: + """Paperless(url, token) — classic mode still works.""" + api = Paperless(PAPERLESS_TEST_URL, PAPERLESS_TEST_TOKEN) + assert api.base_url == PAPERLESS_TEST_URL + assert api._token == PAPERLESS_TEST_TOKEN + + +def test_config_object() -> None: + """Paperless(config=PaperlessConfig(...)) wires url and token correctly.""" + cfg = PaperlessConfig(url=PAPERLESS_TEST_URL, token=PAPERLESS_TEST_TOKEN) + api = Paperless(config=cfg) + assert api.base_url == PAPERLESS_TEST_URL + assert api._token == PAPERLESS_TEST_TOKEN + + +def test_config_object_custom_api_version() -> None: + """PaperlessConfig.request_api_version is forwarded to the client.""" + cfg = PaperlessConfig(url=PAPERLESS_TEST_URL, token=PAPERLESS_TEST_TOKEN, request_api_version=7) + api = Paperless(config=cfg) + assert api._request_api_version == 7 + + +def test_config_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Paperless() with no args reads PYPAPERLESS_URL / PYPAPERLESS_TOKEN from the environment.""" + monkeypatch.setenv("PYPAPERLESS_URL", PAPERLESS_TEST_URL) + monkeypatch.setenv("PYPAPERLESS_TOKEN", PAPERLESS_TEST_TOKEN) + api = Paperless() + assert api.base_url == PAPERLESS_TEST_URL + assert api._token == PAPERLESS_TEST_TOKEN + + +def test_config_from_env_missing_url(monkeypatch: pytest.MonkeyPatch) -> None: + """Paperless() raises ValidationError when PYPAPERLESS_URL is not set.""" + monkeypatch.delenv("PYPAPERLESS_URL", raising=False) + monkeypatch.delenv("PYPAPERLESS_TOKEN", raising=False) + with pytest.raises(ValidationError): + Paperless() + + +def test_config_from_env_no_token(monkeypatch: pytest.MonkeyPatch) -> None: + """Paperless() sets token to None when PYPAPERLESS_TOKEN is not set.""" + monkeypatch.setenv("PYPAPERLESS_URL", PAPERLESS_TEST_URL) + monkeypatch.delenv("PYPAPERLESS_TOKEN", raising=False) + api = Paperless() + assert api._token is None + + +def test_config_default_api_version_from_const() -> None: + """PaperlessConfig uses API_VERSION as default for request_api_version.""" + cfg = PaperlessConfig(url=PAPERLESS_TEST_URL) + assert cfg.request_api_version == API_VERSION diff --git a/tests/test_custom_fields.py b/tests/test_custom_fields.py index 76717211..514f887f 100644 --- a/tests/test_custom_fields.py +++ b/tests/test_custom_fields.py @@ -18,6 +18,7 @@ CustomFieldQueryNot, CustomFieldQueryOr, ) +from pypaperless.models.mixins.data_fields import MatchingAlgorithm from pypaperless.models.types import ( CUSTOM_FIELD_TYPE_VALUE_MAP, CustomFieldDateValue, @@ -42,7 +43,7 @@ async def test_draft_value_without_cache(paperless: Paperless) -> None: """draft_value() returns a plain object when the custom field cache is empty.""" - custom_field = CustomField.create_with_data( + custom_field = CustomField.from_data( paperless, data={"id": 1337, "name": "Test", "data_type": CustomFieldType.INTEGER}, ) @@ -63,7 +64,7 @@ async def test_draft_value_with_cache(httpx_mock: HTTPXMock, paperless: Paperles ) paperless.cache.custom_fields = await paperless.custom_fields.as_dict() - custom_field = CustomField.create_with_data( + custom_field = CustomField.from_data( client=paperless, data=DATA_CUSTOM_FIELDS["results"][5], ) @@ -252,6 +253,7 @@ def test_tag_with_nested_children(api: Paperless) -> None: "name": "Parent Tag", "color": "#000000", "text_color": "#ffffff", + "matching_algorithm": 2, "children": [ { "id": 2, @@ -259,6 +261,7 @@ def test_tag_with_nested_children(api: Paperless) -> None: "name": "Child Tag", "color": "#000000", "text_color": "#ffffff", + "matching_algorithm": 1, "children": [ { "id": 3, @@ -266,32 +269,35 @@ def test_tag_with_nested_children(api: Paperless) -> None: "name": "Grandchild Tag", "color": "#000000", "text_color": "#ffffff", + "matching_algorithm": 6, } ], } ], } - tag = Tag.create_with_data(api, data=tag_data) + tag = Tag.from_data(api, data=tag_data) assert tag.name == "Parent Tag" assert isinstance(tag.children, list) child = tag.children[0] assert isinstance(child, Tag) assert child.name == "Child Tag" + assert child.matching_algorithm == MatchingAlgorithm.ANY assert isinstance(child.children, list) assert isinstance(child.children[0], Tag) assert child.children[0].name == "Grandchild Tag" + assert child.children[0].matching_algorithm == MatchingAlgorithm.AUTO def test_tag_with_empty_children(api: Paperless) -> None: """Tag._validate_children returns the falsy value unchanged (empty list / None).""" - tag_empty = Tag.create_with_data( + tag_empty = Tag.from_data( api, data={"id": 5, "slug": "leaf", "name": "Leaf Tag", "children": []}, ) # empty list — _validate_children early-returns the empty list assert tag_empty.children == [] - tag_none = Tag.create_with_data( + tag_none = Tag.from_data( api, data={"id": 6, "slug": "leaf2", "name": "Leaf Tag 2"}, ) diff --git a/tests/test_documents.py b/tests/test_documents.py index 5806978c..33d8acd3 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -68,7 +68,7 @@ async def test_lazy(self, paperless: Paperless) -> None: async def test_create(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: """Draft upload validates required fields and POSTs via the correct endpoint.""" defaults = DOCUMENT_MAP.draft_defaults or {} - draft = paperless.documents.draft(**defaults) + draft = paperless.documents.create(**defaults) assert isinstance(draft, DocumentDraft) backup = draft.document draft.document = None @@ -85,7 +85,7 @@ async def test_create(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None async def test_create_date_property(self, paperless: Paperless) -> None: """created_date is an alias for the created field.""" - document = Document.create_with_data(paperless, data={**DATA_DOCUMENTS["results"][0]}) + document = Document.from_data(paperless, data={**DATA_DOCUMENTS["results"][0]}) assert document.created_date == document.created async def test_update(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: @@ -130,22 +130,143 @@ async def test_delete(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None ) assert not await paperless.documents.delete(to_delete) - async def test_meta(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: - """get_metadata() returns a DocumentMeta with original and archive metadata lists.""" + async def test_shortcut_files(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: + """Document.download/preview/thumbnail() shortcuts delegate to the service.""" + httpx_mock.add_response( + method="GET", + url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), + status_code=200, + json=DATA_DOCUMENTS["results"][0], + ) + doc = await paperless.documents(1) + + httpx_mock.add_response( + method="GET", + url=re.compile( + r"^" + + f"{PAPERLESS_TEST_URL}{API_PATH['documents_download']}".format(pk=1) + + r"\?.*$" + ), + status_code=200, + headers={ + "Content-Type": "application/pdf", + "Content-Disposition": "attachment;filename=any.pdf", + }, + content=b"Binary data", + ) + assert isinstance(await doc.download(), DownloadedDocument) + + httpx_mock.add_response( + method="GET", + url=re.compile( + r"^" + + f"{PAPERLESS_TEST_URL}{API_PATH['documents_preview']}".format(pk=1) + + r"\?.*$" + ), + status_code=200, + headers={"Content-Type": "application/pdf"}, + content=b"Binary data", + ) + assert isinstance(await doc.preview(), DownloadedDocument) + + httpx_mock.add_response( + method="GET", + url=re.compile( + r"^" + + f"{PAPERLESS_TEST_URL}{API_PATH['documents_thumbnail']}".format(pk=1) + + r"\?.*$" + ), + status_code=200, + content=b"Binary data", + ) + assert isinstance(await doc.thumbnail(), DownloadedDocument) + + async def test_shortcut_metadata(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: + """Document.metadata() shortcut delegates to the service.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), status_code=200, json=DATA_DOCUMENTS["results"][0], ) - document = await paperless.documents(1) + doc = await paperless.documents(1) httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_meta']}".format(pk=1), status_code=200, json=DATA_DOCUMENT_METADATA, ) - meta = await document.get_metadata() + assert isinstance(await doc.metadata(), DocumentMeta) + + async def test_shortcut_suggestions(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: + """Document.suggestions() shortcut delegates to the service.""" + httpx_mock.add_response( + method="GET", + url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), + status_code=200, + json=DATA_DOCUMENTS["results"][0], + ) + doc = await paperless.documents(1) + httpx_mock.add_response( + method="GET", + url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_suggestions']}".format(pk=1), + status_code=200, + json=DATA_DOCUMENT_SUGGESTIONS, + ) + assert isinstance(await doc.suggestions(), DocumentSuggestions) + + async def test_shortcut_more_like(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: + """Document.more_like() shortcut yields Document items.""" + httpx_mock.add_response( + method="GET", + url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), + status_code=200, + json=DATA_DOCUMENTS["results"][0], + ) + doc = await paperless.documents(1) + httpx_mock.add_response( + method="GET", + url=re.compile( + r"^" + f"{PAPERLESS_TEST_URL}{API_PATH['documents']}" + r"\?.*more_like_id.*$" + ), + status_code=200, + json=DATA_DOCUMENTS_SEARCH, + ) + async for item in doc.more_like(): + assert isinstance(item, Document) + + async def test_shortcut_email(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: + """Document.email() shortcut delegates to the service.""" + httpx_mock.add_response( + method="GET", + url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), + status_code=200, + json=DATA_DOCUMENTS["results"][0], + ) + doc = await paperless.documents(1) + httpx_mock.add_response( + method="POST", + url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_email']}", + status_code=200, + json={"message": "Email sent"}, + ) + await doc.email( + addresses="test@example.org", + subject="Test", + message="Body", + ) + + async def test_meta(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: + """get_metadata() returns a DocumentMeta with original and archive metadata lists.""" + httpx_mock.add_response( + method="GET", + url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_meta']}".format(pk=1), + status_code=200, + json=DATA_DOCUMENT_METADATA, + ) + meta = await paperless.documents.metadata(1) assert isinstance(meta, DocumentMeta) assert isinstance(meta.original_metadata, list) for item in meta.original_metadata: @@ -156,14 +277,6 @@ async def test_meta(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: async def test_files(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: """get_download/preview/thumbnail each return a DownloadedDocument.""" - httpx_mock.add_response( - method="GET", - url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), - status_code=200, - json=DATA_DOCUMENTS["results"][0], - ) - document = await paperless.documents(1) - httpx_mock.add_response( method="GET", url=re.compile( @@ -178,7 +291,7 @@ async def test_files(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: }, content=b"Binary data: download", ) - download = await document.get_download() + download = await paperless.documents.download(1) assert isinstance(download, DownloadedDocument) assert download.mode == FileRetrieveMode.DOWNLOAD @@ -193,7 +306,7 @@ async def test_files(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: headers={"Content-Type": "application/pdf"}, content=b"Binary data: preview", ) - preview = await document.get_preview() + preview = await paperless.documents.preview(1) assert isinstance(preview, DownloadedDocument) assert preview.mode == FileRetrieveMode.PREVIEW @@ -207,26 +320,19 @@ async def test_files(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: status_code=200, content=b"Binary data: thumbnail", ) - thumbnail = await document.get_thumbnail() + thumbnail = await paperless.documents.thumbnail(1) assert isinstance(thumbnail, DownloadedDocument) assert thumbnail.mode == FileRetrieveMode.THUMBNAIL async def test_suggestions(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: """get_suggestions() returns a DocumentSuggestions instance.""" - httpx_mock.add_response( - method="GET", - url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), - status_code=200, - json=DATA_DOCUMENTS["results"][0], - ) - document = await paperless.documents(1) httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_suggestions']}".format(pk=1), status_code=200, json=DATA_DOCUMENT_SUGGESTIONS, ) - assert isinstance(await document.get_suggestions(), DocumentSuggestions) + assert isinstance(await paperless.documents.suggestions(1), DocumentSuggestions) async def test_get_next_asn(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: """get_next_asn() returns an int on success and raises AsnRequestError on failure.""" @@ -322,7 +428,7 @@ async def test_note_create(self, httpx_mock: HTTPXMock, paperless: Paperless) -> json=DATA_DOCUMENTS["results"][0], ) item = await paperless.documents(1) - draft = item.notes.draft(note="Test note.") + draft = item.notes.create(note="Test note.") assert isinstance(draft, DocumentNoteDraft) backup = draft.note draft.note = None @@ -363,6 +469,52 @@ async def test_note_delete(self, httpx_mock: HTTPXMock, paperless: Paperless) -> ) assert await item.notes.delete(results.pop()) + async def test_shortcut_note_delete(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: + """DocumentNote.delete() shortcut delegates to the service.""" + httpx_mock.add_response( + method="GET", + url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), + status_code=200, + json=DATA_DOCUMENTS["results"][0], + ) + item = await paperless.documents(1) + httpx_mock.add_response( + method="GET", + url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_notes']}".format(pk=1), + status_code=200, + json=DATA_DOCUMENT_NOTES, + ) + notes = await item.notes() + httpx_mock.add_response( + method="DELETE", + url=re.compile( + r"^" + f"{PAPERLESS_TEST_URL}{API_PATH['documents_notes']}".format(pk=1) + r"\?.*$" + ), + status_code=204, + ) + assert await notes[0].delete() + + async def test_shortcut_note_draft_save( + self, httpx_mock: HTTPXMock, paperless: Paperless + ) -> None: + """DocumentNoteDraft.save() shortcut delegates to the service.""" + httpx_mock.add_response( + method="GET", + url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), + status_code=200, + json=DATA_DOCUMENTS["results"][0], + ) + item = await paperless.documents(1) + draft = item.notes.create(note="Shortcut test.") + httpx_mock.add_response( + method="POST", + url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_notes']}".format(pk=1), + status_code=200, + json=DATA_DOCUMENT_NOTES, + ) + result = await draft.save() + assert isinstance(result, tuple) + async def test_history_call(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: """History returns typed entries; direct service call and missing pk error both work.""" httpx_mock.add_response( @@ -445,7 +597,7 @@ async def test_custom_field_list_with_cache( for field in item.custom_fields: assert isinstance(field, CustomFieldValue) - test_cf = CustomField.create_with_data( + test_cf = CustomField.from_data( client=paperless, data=DATA_CUSTOM_FIELDS["results"][0], ) @@ -475,17 +627,17 @@ async def test_custom_field_list_with_cache( async def test_draft_custom_fields_as_id_list(self, paperless: Paperless) -> None: """DocumentDraft serialises list[int] custom_fields as repeated form values.""" - draft = paperless.documents.draft(document=b"pdf", custom_fields=[1, 3, 5]) + draft = paperless.documents.create(document=b"pdf", custom_fields=[1, 3, 5]) serialized = draft.serialize() assert serialized["form"]["custom_fields"] == [1, 3, 5] async def test_draft_custom_fields_as_object_mapping(self, paperless: Paperless) -> None: """DocumentDraft serialises DocumentCustomFieldList as a JSON string.""" - cf = DocumentCustomFieldList(paperless, []) + cf = DocumentCustomFieldList.from_data(paperless, []) cf += CustomFieldStringValue(field=6, value="hello") cf += CustomFieldIntegerValue(field=3, value=42) - draft = paperless.documents.draft(document=b"pdf") + draft = paperless.documents.create(document=b"pdf") draft.custom_fields = cf serialized = draft.serialize() @@ -500,10 +652,10 @@ async def test_draft_custom_fields_object_mapping_upload( self, httpx_mock: HTTPXMock, paperless: Paperless ) -> None: """A draft with a DocumentCustomFieldList can be POSTed successfully.""" - cf = DocumentCustomFieldList(paperless, []) + cf = DocumentCustomFieldList.from_data(paperless, []) cf += CustomFieldStringValue(field=6, value="smoke") - draft = paperless.documents.draft(document=b"%PDF-fake", title="CF Mapping Test") + draft = paperless.documents.create(document=b"%PDF-fake", title="CF Mapping Test") draft.custom_fields = cf httpx_mock.add_response( @@ -553,19 +705,19 @@ async def test_email(self, httpx_mock: HTTPXMock, paperless: Paperless) -> None: async def test_is_deleted(self, paperless: Paperless) -> None: """Document.is_deleted is True when deleted_at is set, False otherwise.""" - doc_alive = Document.create_with_data(paperless, data={**DATA_DOCUMENTS["results"][0]}) + doc_alive = Document.from_data(paperless, data={**DATA_DOCUMENTS["results"][0]}) assert not doc_alive.is_deleted - doc_trashed = Document.create_with_data( + doc_trashed = Document.from_data( paperless, data={**DATA_DOCUMENTS["results"][0], "deleted_at": "2024-01-01T00:00:00Z"}, ) assert doc_trashed.is_deleted - async def test_custom_field_list_unserialize(self, paperless: Paperless) -> None: - """DocumentCustomFieldList.unserialize() constructs the list from raw API data.""" + async def test_custom_field_list_from_data(self, paperless: Paperless) -> None: + """DocumentCustomFieldList.from_data() constructs the list from raw API data.""" raw = [{"field": 1, "value": "hello"}, {"field": 2, "value": 42}] - cf_list = DocumentCustomFieldList.unserialize(paperless, raw) + cf_list = DocumentCustomFieldList.from_data(paperless, raw) assert isinstance(cf_list, DocumentCustomFieldList) assert len(list(cf_list)) == 2 @@ -573,13 +725,6 @@ async def test_download_content_disposition_non_filename_part( self, httpx_mock: HTTPXMock, paperless: Paperless ) -> None: """Download with a Content-Disposition that has a non-filename= part is handled.""" - httpx_mock.add_response( - method="GET", - url=f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), - status_code=200, - json=DATA_DOCUMENTS["results"][0], - ) - document = await paperless.documents(1) # Content-Disposition has an extra part that is NOT filename= httpx_mock.add_response( method="GET", @@ -595,7 +740,7 @@ async def test_download_content_disposition_non_filename_part( }, content=b"binary", ) - download = await document.get_download() + download = await paperless.documents.download(1) assert isinstance(download, DownloadedDocument) assert download.disposition_filename == "doc.pdf" @@ -629,7 +774,7 @@ async def test_check_permissions_field_has_permissions_no_perms_key( self, paperless: Paperless ) -> None: """_check_permissions_field exits without changes when permissions not in data.""" - item = Correspondent.create_with_data( + item = Correspondent.from_data( paperless, data={ "id": 1, diff --git a/tests/test_resources.py b/tests/test_resources.py index acd986b9..3bfaebdd 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -195,16 +195,16 @@ async def test_status_has_errors(paperless: Paperless) -> None: "classifier_status": "OK", }, } - status = Status.create_with_data(paperless, data=data) + status = Status.from_data(paperless, data=data) assert status.has_errors is False data["database"]["status"] = "ERROR" - status = Status.create_with_data(paperless, data=data) + status = Status.from_data(paperless, data=data) assert status.has_errors is True # None values are treated as no errors del data["database"]["status"] - status = Status.create_with_data(paperless, data=data) + status = Status.from_data(paperless, data=data) assert status.has_errors is False diff --git a/tests/test_service_mixins.py b/tests/test_service_mixins.py index c101f108..b2d1231d 100644 --- a/tests/test_service_mixins.py +++ b/tests/test_service_mixins.py @@ -198,7 +198,7 @@ async def test_create( ) -> None: """Test create.""" service = getattr(paperless, mapping.resource) - draft = service.draft(**mapping.draft_defaults) + draft = service.create(**mapping.draft_defaults) assert isinstance(draft, mapping.draft_cls) # test that blanking out the required field raises DraftFieldRequiredError if mapping.required_field is not None: @@ -440,3 +440,97 @@ class _MinimalService(ServiceBase, svc_mixins.IterableMixin[_MinimalModel]): async with svc.filter(title__icontains="test") as ctx: assert ctx._aiter_filters == {"title__icontains": "test"} assert svc._aiter_filters is None + + +# --------------------------------------------------------------------------- +# Active Record model mixin tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "mapping", + [ + CORRESPONDENT_MAP, + CUSTOM_FIELD_MAP, + DOCUMENT_MAP, + DOCUMENT_TYPE_MAP, + SHARE_LINK_MAP, + STORAGE_PATH_MAP, + TAG_MAP, + ], + scope="class", +) +class TestActiveRecord: + """Active Record shortcuts: model.update(), model.delete(), draft.save().""" + + async def test_model_update( + self, httpx_mock: HTTPXMock, paperless: Paperless, mapping: ResourceTestMapping + ) -> None: + """model.update() delegates to the bound service and updates model state.""" + update_field = mapping.update_field + update_value = mapping.update_value + pk = mapping.data["results"][0]["id"] + httpx_mock.add_response( + method="GET", + url=f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=pk), + status_code=200, + json=mapping.data["results"][0], + ) + item = await getattr(paperless, mapping.resource)(pk) + setattr(item, update_field, update_value) + httpx_mock.add_response( + method="PATCH", + url=f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=pk), + status_code=200, + json={**item._data, update_field: update_value}, + ) + result = await item.update() + assert result is True + assert getattr(item, update_field) == update_value + + async def test_model_delete( + self, httpx_mock: HTTPXMock, paperless: Paperless, mapping: ResourceTestMapping + ) -> None: + """model.delete() delegates to the bound service.""" + pk = mapping.data["results"][0]["id"] + httpx_mock.add_response( + method="GET", + url=f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=pk), + status_code=200, + json=mapping.data["results"][0], + ) + item = await getattr(paperless, mapping.resource)(pk) + httpx_mock.add_response( + method="DELETE", + url=f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=pk), + status_code=204, + ) + assert await item.delete() is True + httpx_mock.add_response( + method="DELETE", + url=f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=pk), + status_code=404, + ) + assert await item.delete() is False + + async def test_draft_save( + self, httpx_mock: HTTPXMock, paperless: Paperless, mapping: ResourceTestMapping + ) -> None: + """draft.save() delegates to the bound service and returns the new pk.""" + if mapping.draft_defaults is None: + pytest.skip("No draft_defaults defined for this mapping.") + service = getattr(paperless, mapping.resource) + draft = service.create(**mapping.draft_defaults) + response_json = ( + mapping.draft_response_json + if mapping.draft_response_json is not None + else {"id": len(mapping.data["results"])} + ) + httpx_mock.add_response( + method="POST", + url=f"{PAPERLESS_TEST_URL}{draft.api_path}", + status_code=200, + json=response_json, + ) + new_pk = await draft.save() + assert isinstance(new_pk, str) or new_pk >= 1 diff --git a/uv.lock b/uv.lock index 60dba340..a0cb60bd 100644 --- a/uv.lock +++ b/uv.lock @@ -404,6 +404,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -431,8 +445,10 @@ name = "pypaperless" version = "0.0.0" source = { editable = "." } dependencies = [ + { name = "click" }, { name = "httpx" }, { name = "pydantic" }, + { name = "pydantic-settings" }, ] [package.dev-dependencies] @@ -455,8 +471,10 @@ docs = [ [package.metadata] requires-dist = [ + { name = "click", specifier = ">=8.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "pydantic", specifier = ">=2.10.0" }, + { name = "pydantic-settings", specifier = ">=2.7.0" }, ] [package.metadata.requires-dev] @@ -530,6 +548,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3"