pax_global_header00006660000000000000000000000064152407612640014521gustar00rootroot0000000000000052 comment=5d67d60514dd63cf7b4d6ba29928df4babea70f4 tb1337-pypaperless-5d67d60/000077500000000000000000000000001524076126400153525ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.agent/000077500000000000000000000000001524076126400165265ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.agent/skills/000077500000000000000000000000001524076126400200275ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.agent/skills/pp-add-resource/000077500000000000000000000000001524076126400230215ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.agent/skills/pp-add-resource/SKILL.md000066400000000000000000000255701524076126400242320ustar00rootroot00000000000000--- name: pp-add-resource description: "Add a new Paperless-ngx API resource to pypaperless. Use when implementing a new endpoint, service, model, or sub-service (e.g. /api/trash/, /api/documents/{id}/history/). Covers model definition, service class, const.py wiring, client registration, test fixtures, unit tests, script/pngx_audit_coverage.py, and script/pngx_smoketest.py." argument-hint: "Name or path of the endpoint to implement (e.g. /api/trash/)" --- # Add a New Resource to pypaperless ## When to Use - Implementing a new top-level endpoint (e.g. `/api/trash/`) - Implementing a document sub-service (e.g. `/api/documents/{id}/history/`) - Any time a `/api/...` path is listed as "unimplemented" in `script/pngx_audit_coverage.py` ## Decision Tree Before starting, answer these questions: | Question | → | | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Is it a sub-resource of `/api/documents/{id}/...`? | → **Document sub-service** (see [Sub-service pattern](./references/patterns.md#document-sub-service-flat-list-attached-pk)) | | Does GET return `{ count, next, previous, all, results }`? | → **Paginated** (`IterableService`) | | Does GET return a flat list `[...]`? | → **Flat list** (direct `transport.get` + list comprehension) | | Does GET return a single object (not a list)? | → **Direct** (`CallableService` only, no `IterableService`) | | Does the endpoint have POST/action methods (e.g. restore, empty)? | → Add explicit `async def` methods calling `self._runtime.transport.post(...)` | ## Step-by-Step Procedure ### 1. Research the Endpoint ```bash set -a && source .env && set +a curl -s "$PYPAPERLESS_URL/api//" \ -H "Authorization: Token $PYPAPERLESS_TOKEN" | python3 -m json.tool | head -60 # Check OpenAPI schema component uv run python -c " import os, httpx, json r = httpx.get(f\"{os.environ['PYPAPERLESS_URL']}/api/schema/?format=json\", headers={'Authorization': f\"Token {os.environ['PYPAPERLESS_TOKEN']}\"}) s = r.json() # Replace 'ComponentName' with the schema component name print(json.dumps(s['components']['schemas'].get('ComponentName', {}), indent=2)) " ``` ### 2. `pypaperless/const.py` Add an `EndpointPath` member (and a `_SINGLE` variant if the resource has detail routes) plus a `PaperlessResource` enum value — both in alphabetical order: ```python # EndpointPath (StrEnum) — list route + detail route TRASH = "/api/trash/" TRASH_SINGLE = "/api/trash/{pk}/" # PaperlessResource enum TRASH = "trash" ``` For document sub-services, add only an `EndpointPath` member with a `{pk}` placeholder (no new `PaperlessResource` value needed): ```python DOCUMENTS_HISTORY = "/api/documents/{pk}/history/" ``` ### 3. Model (`pypaperless/models/.py` or into `documents.py`) See [model templates](./references/patterns.md#model-templates) for full code examples. - The resource (read) model inherits `IdentifiedModel` (provides a required, non-optional `id: int` — do **not** redeclare `id`); the draft model inherits `PaperlessModel`. Add the mixins each side needs (`SecurableModel` / `MatchingFieldsModel` on the read model; `SecurableDraftModel` / `CreatableModel` on the draft) - Set `_api_path: ClassVar[str] = EndpointPath.` (and `_resource` for dispatched resources) - All other fields `Optional` (use `| None = None`); `id` is the exception — inherited as a required `int` - Use `datetime.datetime | None` for timestamps - Use `StrEnum` subclasses for typed enum fields - Export from `pypaperless/models/__init__.py` - Re-export any public enum and filter types from `pypaperless/models/types.py` **If reusing an existing model** (e.g. `Document` for `/api/trash/`): no new model needed. ### 4. Service (`pypaperless/services/.py`) See [service templates](./references/patterns.md#service-templates). **Top-level paginated service:** ```python class TrashService(ResourceService, mixins.IterableService[Document]): _api_path = EndpointPath.TRASH _resource = PaperlessResource.TRASH _resource_cls = Document ``` **Document sub-service (flat list):** inherit `DocumentScopedServiceBase`, which provides `__init__(runtime, attached_to=None)` and `_get_document_pk()` — do not re-implement them: ```python class DocumentHistoryService(DocumentScopedServiceBase): _api_path = EndpointPath.DOCUMENTS_HISTORY _resource = PaperlessResource.DOCUMENTS _resource_cls = DocumentHistory async def __call__(self, pk: int | None = None) -> list[DocumentHistory]: doc_pk = self._get_document_pk(pk) res = await self._runtime.transport.get(self._api_path.format(pk=doc_pk)) return [ self._resource_cls.from_data(self._runtime, {**item, "document": doc_pk}) for item in res ] ``` **Action methods (POST):** `transport.post` already parses JSON and raises on error: ```python async def restore(self, documents: list[int]) -> None: await self._runtime.transport.post( self._api_path, json={"action": "restore", "documents": documents} ) ``` ### 5. Wire Up **`pypaperless/services/__init__.py`** — export the new service class. **`pypaperless/client.py`** — register lazily on `PaperlessClient` as a cached property (never an eager assignment in `__init__`): ```python @cached_property def trash(self) -> services.TrashService: """Return the :class:`~pypaperless.services.TrashService`.""" return services.TrashService(self._runtime) ``` Use `@dispatchable_cached_property` instead when the resource supports `paperless.update()` / `paperless.delete()` (i.e. it uses `UpdatableService` / `DeletableService`). **For document sub-services** — construct in `DocumentService.__init__`, expose a property, and add a lazy property on the `Document` model (see [sub-service wiring](./references/patterns.md#document-sub-service-wiring)). ### 6. Test Fixture (`tests/data/.py`) Create realistic snapshot data matching the actual API response shape: - **Paginated**: wrap in `{ "count": N, "next": null, "previous": null, "all": [...], "results": [...] }` - **Flat list**: plain `[{...}, ...]` - **Direct**: single `{...}` dict Export from `tests/data/__init__.py` (import + add to `__all__`). ### 7. Unit Tests Add a `class Test:` — top-level resources go in `tests/test_resources.py`, document sub-services in `tests/test_documents.py`: - `test_iter` / `test_call` — test the main GET path via `httpx_mock` - `test_` — test each POST method - For sub-services: also test via the parent `document.property()` and the `PrimaryKeyRequiredError` case Pattern for mocking: ```python httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.TRASH}", # or re.compile(...) for query params status_code=200, json=DATA_TRASH, ) ``` ### 8. `script/pngx_audit_coverage.py` Add an `EndpointSpec` to the `ENDPOINTS` list: ```python ENDPOINTS = [ ..., EndpointSpec("Trash", "/api/trash/?page_size=1", Document, "paginated", "Document"), ] ``` `unwrap` values: - `"paginated"` — DRF paginated response - `"direct"` — single object - `"list_index0"` — flat list (use first element) If the service injects fields not in the API response (e.g. `document` PK): - Add to `KNOWN_EXTRAS["ModelName"]` - Add to `KNOWN_SCHEMA_EXTRAS["ModelName"]` ### 9. Documentation (`docs/resources/.md`) Create a Markdown page under `docs/resources/` for the new resource: - **Heading** — resource name (e.g. `# Trash`) - **Short description** — one sentence about what this resource represents - **Model table** — all fields with a brief description - **Sub-model tables** — one table per nested model class (if any) - **Usage examples** — `GET` (call / iterate), any `POST` actions (restore, update, …) Follow the style of existing pages such as `docs/resources/statistics.md` (direct call) or `docs/resources/correspondents.md` (full CRUD). In the **Model** section of the new page, always link both the model file and [`pypaperless/models/types.py`](https://github.com/tb1337/paperless-api/blob/main/pypaperless/models/types.py) whenever the resource has enum or filter types re-exported there. Then update **`docs/resources.md`**: - Add the new resource to the **capability matrix** table (alphabetical order). Columns: `call`, `iterate`, `create`/`save`, `update`, `delete`, `permissions`. Then register the new page in **`zensical.toml`** (the MkDocs nav): - Add a `{ "Name" = "resources/.md" }` entry inside the `"Resources"` nav list, in alphabetical order. ```toml { "Profile" = "resources/profile.md" }, { "Trash" = "resources/trash.md" }, ``` ### 10. `script/pngx_smoketest.py` Add a test function before the relevant section and wire it into `main()`: ```python async def test_trash(p: PaperlessClient) -> None: _hdr("Trash – list deleted documents") await check("trash.as_list()", p.trash.as_list(), detail_fn=lambda r: f"count={len(r)}") ``` ### 11. Validate ```bash # Unit tests uv run pytest -x -q # Linting uv run ruff check pypaperless # Live audit (requires running Paperless instance) uv run python script/pngx_audit_coverage.py # Live smoketest uv run python script/pngx_smoketest.py ``` Expected: all unit tests pass, audit shows ` → OK`, smoketest shows 0 failed. ## Checklist - [ ] `const.py` — `EndpointPath` member(s) + `PaperlessResource` - [ ] Model class with `_api_path`, typed fields, `StrEnum` for enums; public enum/filter types re-exported via `models/types.py` - [ ] `models/__init__.py` — export - [ ] Service class with correct mixins - [ ] `services/__init__.py` — export - [ ] `client.py` — register lazily on `PaperlessClient` (`@cached_property` / `@dispatchable_cached_property`) - [ ] Test fixture in `tests/data/` - [ ] `tests/data/__init__.py` — export - [ ] Unit tests in `test_resources.py` (or `test_documents.py` for document sub-services) - [ ] `script/pngx_audit_coverage.py` — `EndpointSpec` + any `KNOWN_EXTRAS` - [ ] `docs/resources/.md` — model table + usage examples - [ ] `docs/resources.md` — capability matrix row - [ ] `zensical.toml` — nav entry in alphabetical order - [ ] `script/pngx_smoketest.py` — test function + wired in `main()` - [ ] All tests green, lint clean ## Reference - [Code patterns & templates](./references/patterns.md) - Live Paperless dev credentials: git-ignored `.env` (`PYPAPERLESS_URL`, `PYPAPERLESS_TOKEN`, `PYPAPERLESS_TEST_DOC`); copy `.env.example` to `.env`. Scripts and `run/debug.py` read them via `script/_dev_env.py`. tb1337-pypaperless-5d67d60/.agent/skills/pp-add-resource/references/000077500000000000000000000000001524076126400251425ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.agent/skills/pp-add-resource/references/patterns.md000066400000000000000000000272041524076126400273310ustar00rootroot00000000000000# Code Patterns & Templates All snippets below reflect the **current** code surface: `EndpointPath` (StrEnum) instead of an `API_PATH` dict, `IdentifiedModel` (required `id: int`) as the base for API-identified read models, `ResourceService` as the service base, `*Service` mixins, model-side `*Model` mixins, `self._runtime.transport` for HTTP, `from_data(self._runtime, ...)`, and lazy `@cached_property` registration on the client. ## Model Templates ### Standard paginated resource model ```python # pypaperless/models/widgets.py import datetime from typing import ClassVar from pypaperless.const import EndpointPath, PaperlessResource from . import mixins from .base import IdentifiedModel, PaperlessModel class Widget( IdentifiedModel, # provides a required, non-optional `id: int` — do not redeclare it mixins.SecurableModel, # adds owner / user_can_change / permissions ): """Represent a Paperless `Widget`.""" _api_path: ClassVar[str] = EndpointPath.WIDGETS_SINGLE _resource: ClassVar[PaperlessResource] = PaperlessResource.WIDGETS name: str | None = None created: datetime.datetime | None = None class WidgetDraft( PaperlessModel, mixins.SecurableDraftModel, # adds owner / set_permissions mixins.CreatableModel, # adds serialize() + validate_draft() ): """Represent a new `Widget` not yet stored in Paperless.""" _api_path: ClassVar[str] = EndpointPath.WIDGETS _resource: ClassVar[PaperlessResource] = PaperlessResource.WIDGETS _create_required_fields: ClassVar[set[str]] = {"name"} name: str | None = None ``` Do **not** list `owner` / `user_can_change` / `permissions` manually — `mixins.SecurableModel` provides them (and `mixins.SecurableDraftModel` provides `owner` / `set_permissions` on the draft). For resources with text-matching fields (`Correspondent`, `DocumentType`, `StoragePath`, `Tag`), also mix in `mixins.MatchingFieldsModel`, which adds `match`, `matching_algorithm` (`MatchingAlgorithm` enum), and `is_insensitive`. ### Sub-resource model (document-attached, flat list) ```python import datetime from typing import Any, ClassVar from pydantic import Field from pypaperless.const import EndpointPath from .base import IdentifiedModel class DocumentHistory(IdentifiedModel): """Represent a single Paperless document history (audit-log) entry.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS_HISTORY document: int | None = None # injected by the service; not in the API payload timestamp: datetime.datetime | None = None action: DocumentHistoryAction | None = None changes: dict[str, Any] = Field(default_factory=dict) actor: DocumentHistoryActor | None = None ``` --- ## Service Templates Services reach the API through `self._runtime.transport`. `get` / `post` / `patch` / `put` parse the JSON body and raise on error; `delete` returns `None` (raises on error, no body to parse). Either way, no manual `raise_for_status()`. ### Full CRUD top-level service ```python # pypaperless/services/widgets.py from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.widgets import Widget, WidgetDraft from . import mixins from .base import ResourceService class WidgetService( ResourceService, mixins.SecurableService, mixins.CallableService[Widget], mixins.CreatableService[WidgetDraft], mixins.IterableService[Widget], mixins.UpdatableService[Widget], mixins.DeletableService[Widget], ): """Represent a factory for Paperless `Widget` models.""" _api_path = EndpointPath.WIDGETS _resource = PaperlessResource.WIDGETS _draft_cls = WidgetDraft _resource_cls = Widget ``` Add a typed `filter()` override only for resources that support server-side filtering (see the `update-filters` skill for the exact pattern). ### Read-only top-level service (paginated, plus POST actions) ```python from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.documents import Document from . import mixins from .base import ResourceService class TrashService(ResourceService, mixins.IterableService[Document]): _api_path = EndpointPath.TRASH _resource = PaperlessResource.TRASH _resource_cls = Document async def restore(self, documents: list[int]) -> None: await self._runtime.transport.post( self._api_path, json={"action": "restore", "documents": documents} ) async def empty(self, documents: list[int] | None = None) -> None: payload: dict = {"action": "empty"} if documents is not None: payload["documents"] = documents await self._runtime.transport.post(self._api_path, json=payload) ``` ### Document sub-service (flat list, attached pk) Inherit `DocumentScopedServiceBase` — it provides `__init__(runtime, attached_to=None)` and `_get_document_pk()`; do **not** re-implement them. ```python # pypaperless/services/documents/history.py from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.documents.history import DocumentHistory from .base import DocumentScopedServiceBase class DocumentHistoryService(DocumentScopedServiceBase): """Represent a factory for Paperless `DocumentHistory` models.""" _api_path = EndpointPath.DOCUMENTS_HISTORY _resource = PaperlessResource.DOCUMENTS _resource_cls = DocumentHistory async def __call__(self, pk: int | None = None) -> list[DocumentHistory]: doc_pk = self._get_document_pk(pk) res = await self._runtime.transport.get(self._api_path.format(pk=doc_pk)) return [ self._resource_cls.from_data(self._runtime, {**item, "document": doc_pk}) for item in res ] ``` --- ## Document Sub-service Wiring Sub-services attached to a `Document` instance live in their own module to avoid circular imports. Representative model/service files: - `DocumentHistory`, `DocumentHistoryAction` — `from pypaperless.models import DocumentHistory, DocumentHistoryAction` - `DocumentHistoryService` — `pypaperless/services/documents/history.py` - `DocumentNote`, `DocumentNoteDraft` — `from pypaperless.models import DocumentNote, DocumentNoteDraft` - `DocumentNoteService` — `pypaperless/services/documents/notes.py` - `DocumentShareLinkService` — `pypaperless/services/documents/share_links.py` ### On the `Document` model (lazy init from the bound runtime) ```python # In models/documents/document.py — top-level import (no circular dep because the service # module does not import from models/documents/document.py): from pypaperless.services.documents.history import DocumentHistoryService # In the Document class body: _history: DocumentHistoryService | None = PrivateAttr(default=None) @property def history(self) -> DocumentHistoryService: """Return the history service for this document.""" if self._history is None: self._history = DocumentHistoryService(self._runtime, cast("int", self.id)) return self._history ``` ### On `DocumentService` (construct in `__init__`, expose via property) ```python # In services/documents/document.py — import from the sub-service module: from .history import DocumentHistoryService # In DocumentService.__init__ (after super().__init__(runtime)): self._history = DocumentHistoryService(runtime) # Property on DocumentService: @property def history(self) -> DocumentHistoryService: return self._history ``` --- ## Client Registration (top-level services) Services are registered **lazily** on `PaperlessClient` as cached properties returning `Service(self._runtime)` — never eagerly assigned in `__init__`. ```python # pypaperless/client.py from functools import cached_property # dispatchable_cached_property is imported from .dispatch @cached_property def widgets(self) -> services.WidgetService: """Return the :class:`~pypaperless.services.WidgetService`.""" return services.WidgetService(self._runtime) ``` Use `@dispatchable_cached_property` (instead of `@cached_property`) when the resource supports `paperless.update(model)` / `paperless.delete(model)` — i.e. it uses `UpdatableService` / `DeletableService`. That decorator registers the service with the `ModelDispatcher` so model-level updates/deletes route back to it. Read-only or action-only services (e.g. `trash`) use plain `@cached_property`. --- ## Test Fixture Shapes ### Paginated (`IterableService` resources) ```python DATA_WIDGETS = { "count": 2, "next": None, "previous": None, "all": [1, 2], "results": [ {"id": 1, "name": "Red Widget", ...}, {"id": 2, "name": "Blue Widget", ...}, ], } ``` ### Flat list (document sub-services) ```python DATA_DOCUMENT_HISTORY = [ { "id": 10, "timestamp": "2024-01-01T00:00:00Z", "action": "update", "changes": {}, "actor": {"id": 1, "username": "admin"}, }, { "id": 1, "timestamp": "2023-01-01T00:00:00Z", "action": "create", "changes": {}, "actor": {"id": 1, "username": "admin"}, }, ] ``` ### Direct single object ```python DATA_WIDGET_META = { "id": 42, "size_bytes": 1024, "checksum": "abc123", } ``` --- ## Audit EndpointSpec Examples ```python ENDPOINTS = [ # Paginated list endpoint EndpointSpec("Widget", "/api/widgets/?page_size=1", Widget, "paginated", "Widget"), # Single object EndpointSpec("Config", "/api/config/1/", Config, "direct", "ApplicationConfiguration"), # Flat list (use first element for field comparison) EndpointSpec( "DocumentNote", f"/api/documents/{TEST_DOC_ID}/notes/", DocumentNote, "list_index0", "Notes" ), # Reuses an existing model (e.g. Trash returns Document objects) EndpointSpec("Trash", "/api/trash/?page_size=1", Document, "paginated", "Document"), ] ``` ### KNOWN_EXTRAS for injected fields ```python KNOWN_EXTRAS["WidgetMeta"] = { "id": "injected by service from path parameter; not in API response", } KNOWN_SCHEMA_EXTRAS["WidgetMeta"] = { "id": "injected by service from path parameter; WidgetMeta schema has no 'id'", } ``` --- ## Mixin Reference Service mixins (`pypaperless/services/mixins/`): | Mixin | What it adds | | ------------------------------ | ------------------------------------------------------------------------ | | `mixins.CallableService[T]` | `await service(pk)` — fetch a single item | | `mixins.IterableService[T]` | `async for item in service` + `.as_list()` + `.pages()` + `.filter()` | | `mixins.CreatableService[D]` | `service.create(...)` (returns a draft) + `await service.save(draft)` | | `mixins.UpdatableService[T]` | `await service.update(item)` (also reachable via `paperless.update`) | | `mixins.DeletableService[T]` | `await service.delete(item)` (also reachable via `paperless.delete`) | | `mixins.SecurableService` | adds `?full_perms=true` handling for permission-aware requests | Model mixins (`pypaperless/models/mixins/`): | Mixin | What it adds | | ------------------------------ | ------------------------------------------------------------------------ | | `mixins.SecurableModel` | `owner`, `user_can_change`, `permissions` | | `mixins.SecurableDraftModel` | `owner`, `set_permissions` (draft side) | | `mixins.CreatableModel` | `serialize()` + `validate_draft()`; needs `_create_required_fields` | | `mixins.MatchingFieldsModel` | `match`, `matching_algorithm` (`MatchingAlgorithm`), `is_insensitive` | Use only the mixins the endpoint actually supports. tb1337-pypaperless-5d67d60/.agent/skills/pp-update-filters/000077500000000000000000000000001524076126400233745ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.agent/skills/pp-update-filters/SKILL.md000066400000000000000000000211741524076126400246010ustar00rootroot00000000000000--- name: pp-update-filters description: "Update the query filter TypedDicts in pypaperless/models/filters.py by reading the paperless-ngx source FilterSets, then sync the service filter() overrides and the docs. Use whenever paperless-ngx adds, renames, or removes filter fields." argument-hint: "Optional: name of a specific resource to update (e.g. Document, Tag). Omit to update all." --- # Update Query Filters in pypaperless ## When to Use - paperless-ngx added or changed filter fields - A new resource needs a typed `filter()` override - `ruff` or `mypy` reports issues in `pypaperless/models/filters.py` ## Files Involved | File | Purpose | | --- | --- | | `pypaperless/models/filters.py` | TypedDicts — one per filterable resource | | `pypaperless/models/types.py` | Re-exports all filter TypedDicts | | `pypaperless/services/.py` | `filter()` override with `Unpack[XxxFilters]` | | `docs/resources/.md` | Per-resource filter examples | --- ## Step 1 — Read the filter fields **Primary:** `tests/data/schema.json` — fetch first to ensure it's current: ```bash uv run python script/pngx_fetch_schema.py ``` For each list endpoint collect every `"in": "query"` parameter. Exclude: `page`, `page_size`, `format`, `ordering`, `full_perms`. **Fallback:** upstream source — only for fields the schema omits (known case: `more_like_id` on `DocumentFilters`): ```bash curl -sL https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/dev/src/documents/filters.py ``` ### Schema type → Python type | Schema type | Python type | | --- | --- | | `integer` | `int` | | `boolean` | `bool` | | `string` / `string+date` / `string+date-time` | `str` | | `number` | `int` (date/time components) | | `array[integer]` | `str` — comma-separated PKs; **do not change to `list[int]`** | | `array[string+enum]` | `EnumType \| str \| list[EnumType \| str]` | Schema bug: `shared_by__id` is `boolean` in the schema — keep `int` in the TypedDict. --- ## Step 2 — Update `pypaperless/models/filters.py` ### FilterSet → TypedDict | paperless-ngx FilterSet | pypaperless TypedDict | | --- | --- | | `CorrespondentFilterSet` | `CorrespondentFilters` | | `TagFilterSet` | `TagFilters` | | `DocumentTypeFilterSet` | `DocumentTypeFilters` | | `StoragePathFilterSet` | `StoragePathFilters` | | `CustomFieldFilterSet` | `CustomFieldFilters` | | `DocumentFilterSet` | `DocumentFilters` | | `PaperlessTaskFilterSet` | `TaskFilters` | | `ShareLinkFilterSet` | `ShareLinkFilters` | Not every filterable resource maps to a dedicated upstream FilterSet. `GroupFilters`, `UserFilters`, `ProcessedMailFilters`, `ShareLinkBundleFilters` and `TaskSummaryFilters` are pypaperless-composed (they extend the private bases or another TypedDict) — take their fields from `schema.json` (Step 1), not from a row above. `PaperlessTaskFilterSet.name` and `.result` are `CharFilter(method=…)` free-text searches, not exact matches — keep the inline comments in `TaskFilters` that say so. ### Style rules - `total=False` on every class. - No `page` / `page_size` fields. - No section-header comments (ERA001). - Fields sorted **alphabetically**. Classes sorted **alphabetically**; private bases (`_CreatedFilters`, `_IdFilters`, `_ExpirationFilters`, `_NameFilters`, `_NameIdFilters`) always first. - Use a pypaperless enum type instead of plain `str` whenever the schema documents a fixed value set for a field. Pattern: `XxxEnum | str` for single-value fields; `XxxEnum | str | list[XxxEnum | str]` for array fields. Import the enum directly from its model module (not from `types.py`). - Inline comments only for non-obvious semantics: `# comma-separated PKs`, `# JSON expression`, `# True → has at least one tag`, etc. - No blank lines between fields. ### Inheritance rules **Absolute rule: no field may appear in more than one public TypedDict.** Whenever identical fields exist in two or more classes, extract them into a new private base `_XxxFilters`. Always use the narrowest fitting base. | Situation | Inherit from | | --- | --- | | Resource has `id` + `name__*` | `_NameIdFilters` | | Resource has `name__*` only | `_NameFilters` | | Resource has `id` only | `_IdFilters` | | Resource has `created__*` date range | `_CreatedFilters` | | Resource has `expiration__*` date range | `_ExpirationFilters` | | Resource has both id and created | `_IdFilters, _CreatedFilters` | | Resource has created + expiration | `_CreatedFilters, _ExpirationFilters` | Current private bases and what they provide: | Base | Fields provided | | --- | --- | | `_IdFilters` | `id`, `id__in` | | `_NameFilters` | `name__icontains/iendswith/iexact/istartswith` | | `_NameIdFilters(_IdFilters, _NameFilters)` | both of the above | | `_CreatedFilters` | `created__date__gt/gte/lt/lte`, `created__day/month/year`, `created__gt/gte/lt/lte` | | `_ExpirationFilters` | same fields with `expiration__` prefix | `groups` is the only name-filterable resource without `id`/`id__in` — it inherits `_NameFilters` directly, every other named resource uses `_NameIdFilters`. Re-verify against `schema.json` before moving a class between the two. A class body with only a docstring is correct when the base covers everything. When adding a new date-range field group that would appear in ≥ 2 classes, create a new private base first. --- ## Step 3 — Update `pypaperless/models/types.py` Import the new TypedDict and add it to `__all__` in alphabetical order. --- ## Step 4 — Update service `filter()` overrides Pattern (identical for every service): ```python @asynccontextmanager async def filter(self, **kwargs: Unpack[XxxFilters]) -> AsyncGenerator[Self]: """Iterate with server-side filters. See :class:`~pypaperless.models.filters.XxxFilters` for available keys. """ async with self._store_filters(**kwargs) as ctx: yield ctx ``` Always call `self._store_filters(**kwargs)` — never `super().filter(**kwargs)`. ### Service → Filters | Service | TypedDict | | --- | --- | | `CorrespondentService` | `CorrespondentFilters` | | `CustomFieldService` | `CustomFieldFilters` | | `DocumentService` | `DocumentFilters` | | `DocumentTypeService` | `DocumentTypeFilters` | | `GroupService` | `GroupFilters` | | `ProcessedMailService` | `ProcessedMailFilters` | | `ShareLinkBundleService` | `ShareLinkBundleFilters` | | `ShareLinkService` | `ShareLinkFilters` | | `StoragePathService` | `StoragePathFilters` | | `TagService` | `TagFilters` | | `TaskService` | `TaskFilters` | | `UserService` | `UserFilters` | No override for: `SavedViewService`, `WorkflowService`, `MailAccountService`, `MailRuleService`, `TrashService`. `TrashService` deliberately has none: `/api/trash/` declares zero query parameters and silently ignores document filters (verified live — identical `count` with and without a filter), so it keeps the pagination-only base `filter()`. `TaskService` additionally exposes two typed methods that must be kept in sync alongside `filter()`: `active(**Unpack[TaskFilters])` and `summary(**Unpack[TaskSummaryFilters])`. `TaskSummaryFilters` extends `TaskFilters` (adds `days: int`); when `TaskFilters` fields change, both methods follow automatically, but a summary-only field belongs on `TaskSummaryFilters`. --- ## Step 5 — Update docs Only update `docs/resources/.md` files that already have a `filter()` example — do not create new doc files. --- ## Step 6 — Add or Update Tests **`tests/test_filters.py`** — add a row to the `test_service_filter_accepts_typed_kwargs` parametrize table **and** the matching entry in its `ids` list: an `(api_key, service_attr, filter_kwargs, mock_data)` tuple where `filter_kwargs` uses a key specific to the new TypedDict and `mock_data` is the resource's `DATA_*` fixture (import it at the top). **`tests/test_service_mixins.py`** — add an HTTP-level test for the new service's `filter()` using a key specific to that TypedDict. **`script/pngx_smoketest.py`** — add a live call inside `test_filter_context` using a safe, read-only filter key. --- ## Step 7 — Validate ```bash uv run pytest -x -q uv run python script/pngx_smoketest.py uv run python script/pngx_audit_coverage.py ``` --- ## Common Mistakes - Repeating `id`/`id__in` or `name__*` in a class that inherits `_NameFilters`. - Repeating `created__*` in a class that inherits `_CreatedFilters`. - Adding `page` / `page_size` to a TypedDict. - Adding section-header comments inside TypedDicts (ERA001). - Sorting fields non-alphabetically. - Using `TypeVar` with `Unpack` (not supported by PEP 692). - Calling `super().filter(**kwargs)` instead of `self._store_filters(**kwargs)`. - Forgetting to add the resource's row to the `test_service_filter_accepts_typed_kwargs` parametrize table (and its `ids` list) in `tests/test_filters.py`. tb1337-pypaperless-5d67d60/.claude/000077500000000000000000000000001524076126400166655ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.claude/skills000077700000000000000000000000001524076126400227172../.agent/skillsustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.devcontainer/000077500000000000000000000000001524076126400201115ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.devcontainer/devcontainer.json000066400000000000000000000035501524076126400234700ustar00rootroot00000000000000{ "name": "pypaperless Developer", "context": "..", "dockerFile": "../Dockerfile.dev", "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && script/setup", "postStartCommand": "script/bootstrap", "containerEnv": { "DEVCONTAINER": "true" }, "features": {}, "runArgs": [ "-e", "GIT_EDITOR=code --wait", "--security-opt", "label=disable" ], "customizations": { "vscode": { "extensions": [ "charliermarsh.ruff", "ms-python.vscode-pylance", "redhat.vscode-yaml", "GitHub.vscode-pull-request-github", "ryanluker.vscode-coverage-gutters", "yzhang.markdown-all-in-one" ], "settings": { "coverage-gutters.customizable.context-menu": true, "coverage-gutters.customizable.status-bar-toggler-watchCoverageAndVisibleEditors-enabled": true, "coverage-gutters.showGutterCoverage": true, "coverage-gutters.showLineCoverage": true, "coverage-gutters.xmlname": "coverage.xml", "editor.formatOnPaste": false, "editor.formatOnSave": true, "editor.formatOnType": true, "files.trimTrailingWhitespace": true, "python.defaultInterpreterPath": "/home/vscode/.local/dev-venv/bin/python", "python.terminal.activateEnvInCurrentTerminal": true, "python.testing.pytestEnabled": true, "python.testing.unittestEnabled": false, "python.testing.pytestArgs": [ "tests", "--no-cov", "--cov-report=term", "--cov-report=xml" ], "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" }, "terminal.integrated.profiles.linux": { "zsh": { "path": "/usr/bin/zsh" } }, "terminal.integrated.defaultProfile.linux": "zsh" } } } } tb1337-pypaperless-5d67d60/.editorconfig000066400000000000000000000002741524076126400200320ustar00rootroot00000000000000root = true [*] charset = utf-8 end_of_line = lf indent_style = space insert_final_newline = true trim_trailing_whitespace = true indent_size = 2 [*.md] trim_trailing_whitespace = false tb1337-pypaperless-5d67d60/.env.example000066400000000000000000000003741524076126400176010ustar00rootroot00000000000000# Dev-instance connection for the maintenance scripts and run/debug.py. # Copy this file to .env (git-ignored) and fill in your throwaway dev values. PYPAPERLESS_URL="http://172.17.0.1:8000" PYPAPERLESS_TOKEN="your-dev-token" PYPAPERLESS_TEST_DOC=1980 tb1337-pypaperless-5d67d60/.gitattributes000066400000000000000000000000511524076126400202410ustar00rootroot00000000000000* text eol=lf *.py whitespace=error tb1337-pypaperless-5d67d60/.github/000077500000000000000000000000001524076126400167125ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.github/ISSUE_TEMPLATE/000077500000000000000000000000001524076126400210755ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.github/ISSUE_TEMPLATE/bug_report.yml000066400000000000000000000032171524076126400237730ustar00rootroot00000000000000--- name: Bug Report description: Report a bug or issue with pypaperless title: "[BUG] " body: - type: markdown attributes: value: | ## ⚠️ Note on Response Times pypaperless is a private side project. I'm a family father with a full-time job, so replies can sometimes take a while - but I will always reply. Thanks for your patience! 🙏 If you're able to investigate and submit a pull request with a fix, that would be greatly appreciated and will significantly speed up the resolution. - type: textarea id: bug-description attributes: label: Describe the Bug description: A clear and concise description of what the bug is. placeholder: Tell us what you see! validations: required: true - type: textarea id: reproduce attributes: label: To Reproduce description: Steps to reproduce the behavior placeholder: | 1. 2. 3. validations: required: true - type: textarea id: expected attributes: label: Expected Behavior description: A clear and concise description of what you expected to happen. validations: required: true - type: input id: pypaperless-version attributes: label: pypaperless Version placeholder: e.g., 1.2.3 validations: required: true - type: input id: python-version attributes: label: Python Version placeholder: e.g., 3.11.5 validations: required: true - type: input id: paperless-version attributes: label: Paperless-ngx Version placeholder: e.g., 2.4.0 validations: required: true tb1337-pypaperless-5d67d60/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000002621524076126400230650ustar00rootroot00000000000000--- blank_issues_enabled: false contact_links: - name: 📖 Documentation url: https://pypaperless.docs.tbsch.de about: Check the documentation for guides and examples tb1337-pypaperless-5d67d60/.github/ISSUE_TEMPLATE/feature_request.yml000066400000000000000000000025351524076126400250300ustar00rootroot00000000000000--- name: Feature Request description: Suggest an idea or enhancement for pypaperless title: "[FEATURE] " body: - type: markdown attributes: value: | ## ⚠️ Note on Response Times pypaperless is a private side project. I'm a family father with a full-time job, so replies can sometimes take a while - but I will always reply. Thanks for your patience! 🙏 If you're able to implement this feature and submit a pull request, that would be greatly appreciated and will help get it added much faster. - type: textarea id: feature-description attributes: label: Feature Description description: A clear and concise description of the feature you'd like to see. placeholder: Describe the feature... validations: required: true - type: textarea id: use-case attributes: label: Use Case description: Describe the problem or use case this feature would solve. placeholder: What problem does this solve? validations: required: true - type: checkboxes id: implementation-offer attributes: label: Implementation Offer description: How can you help with this feature? options: - label: I'm willing to implement this feature and submit a PR - label: I can help with testing - label: I can help with documentation tb1337-pypaperless-5d67d60/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000036441524076126400225220ustar00rootroot00000000000000 ## Proposed change ## Type of change - [ ] Bugfix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that changes existing behavior) - [ ] Code quality, refactor or performance (no functional change) - [ ] Documentation - [ ] CI, tooling or dependency update ## Breaking change ## Related issues - Fixes # - Related to # ## Checklist - [ ] I understand the code I am submitting and can explain how it works. - [ ] AI and agentic coding tools are very welcome here. If I used one, I have read and reviewed its entire output myself and take responsibility for it. - [ ] `uv run pytest -x -q` passes locally and coverage stays at 95 % or above. - [ ] `prek run --all-files` passes (ruff, mypy, codespell, yamllint). - [ ] Tests have been added or updated for the changed behavior. - [ ] (OPTIONAL) The docs in `docs/` have been updated if the public API changed. - [ ] (OPTIONAL) `uv run python script/pngx_smoketest.py` passed against a live instance, or the change cannot affect live API interaction. tb1337-pypaperless-5d67d60/.github/copilot-instructions.md000077700000000000000000000000001524076126400251602../AGENTS.mdustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.github/labels.yml000066400000000000000000000054451524076126400207070ustar00rootroot00000000000000--- - name: "auto" color: bfdadc description: "Marks a PR or issue that has been created automatically." - name: "breaking-change" color: ee0701 description: "A breaking change for existing users." - name: "bugfix" color: ee0701 description: "Inconsistencies or issues which will cause a problem for users or implementers." - name: "documentation" color: 0052cc description: "Solely about the documentation of the project." - name: "enhancement" color: 1d76db description: "Enhancement of the code, not introducing new features." - name: "refactor" color: 1d76db description: "Improvement of existing code, not introducing new features." - name: "performance" color: 1d76db description: "Improving performance, not introducing new features." - name: "new-feature" color: 0e8a16 description: "New features or options." - name: "maintenance" color: 2af79e description: "Generic maintenance tasks." - name: "ci" color: 1d76db description: "Work that improves the continuous integration." - name: "dependencies" color: 1d76db description: "Upgrade or downgrade of project dependencies." - name: "in-progress" color: fbca04 description: "Issue is currently being resolved by a developer." - name: "stale" color: fef2c0 description: "There has not been activity on this issue or PR for quite some time." - name: "no-stale" color: fef2c0 description: "This issue or PR is exempted from the stale bot." - name: "security" color: ee0701 description: "Marks a security issue that needs to be resolved asap." - name: "incomplete" color: fef2c0 description: "Marks a PR or issue that is missing information." - name: "invalid" color: fef2c0 description: "Marks a PR or issue that is missing information." - name: "beginner-friendly" color: 0e8a16 description: "Good first issue for people wanting to contribute to the project." - name: "help-wanted" color: 0e8a16 description: "We need some extra helping hands or expertise in order to resolve this." - name: "priority-critical" color: ee0701 description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." - name: "priority-high" color: b60205 description: "After critical issues are fixed, these should be dealt with before any further issues." - name: "priority-medium" color: 0e8a16 description: "This issue may be useful, and needs some attention." - name: "priority-low" color: e4ea8a description: "Nice addition, maybe... someday..." - name: "major" color: b60205 description: "This PR causes a major version bump in the version number." - name: "minor" color: 0e8a16 description: "This PR causes a minor version bump in the version number." - name: "skip-changelog" color: bfdadc description: "This PR causes no version bump and won't be displayed in the changelog." tb1337-pypaperless-5d67d60/.github/release-drafter.yml000066400000000000000000000021451524076126400225040ustar00rootroot00000000000000--- name-template: "v$RESOLVED_VERSION 🌈" tag-template: "v$RESOLVED_VERSION" change-template: "- $TITLE @$AUTHOR (#$NUMBER)" exclude-labels: - "skip-changelog" categories: - title: "🚨 Breaking changes" labels: - "breaking-change" - title: "✨ New features" labels: - "new-feature" - title: "🐛 Bug fixes" labels: - "bugfix" - title: "🚀 Enhancements" labels: - "enhancement" - "refactor" - "performance" - title: "🧰 Maintenance" labels: - "maintenance" - "ci" - title: "📚 Documentation" labels: - "documentation" - title: "⬆️ Dependency updates" labels: - "dependencies" - title: "⬆️ Dev Dependency updates" labels: - "dev-deps" version-resolver: major: labels: - "major" - "breaking-change" minor: labels: - "minor" - "new-feature" patch: labels: - "bugfix" - "ci" - "dependencies" - "dev-deps" - "enhancement" - "performance" - "refactor" default: patch template: | ## Changes $CHANGES tb1337-pypaperless-5d67d60/.github/renovate.json000066400000000000000000000035031524076126400214310ustar00rootroot00000000000000{ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "schedule": [ "before 6am" ], "rebaseWhen": "behind-base-branch", "dependencyDashboard": true, "labels": [ "auto", "no-stale" ], "lockFileMaintenance": { "enabled": true, "automerge": true }, "commitMessagePrefix": "⬆️", "packageRules": [ { "matchManagers": [ "pip_requirements" ], "enabled": false }, { "matchDepNames": [ "python" ], "enabled": false }, { "matchUpdateTypes": [ "lockFileMaintenance" ], "addLabels": [ "dependencies", "skip-changelog" ] }, { "matchManagers": [ "pep621" ], "matchDepTypes": [ "!dev" ], "addLabels": [ "dependencies", "python" ], "rangeStrategy": "bump" }, { "matchManagers": [ "pep621" ], "matchDepTypes": [ "dev" ], "addLabels": [ "dependencies", "dev-deps", "python" ], "rangeStrategy": "bump" }, { "matchManagers": [ "pep621" ], "matchUpdateTypes": [ "minor", "patch" ], "automerge": true }, { "matchManagers": [ "github-actions" ], "addLabels": [ "ci", "github_actions", "skip-changelog" ], "rangeStrategy": "pin" }, { "matchManagers": [ "github-actions" ], "matchUpdateTypes": [ "minor", "patch" ], "automerge": true }, { "matchManagers": [ "dockerfile" ], "addLabels": [ "dependencies", "docker", "skip-changelog" ], "rangeStrategy": "pin" } ] } tb1337-pypaperless-5d67d60/.github/skills000077700000000000000000000000001524076126400227442../.agent/skillsustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.github/workflows/000077500000000000000000000000001524076126400207475ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.github/workflows/labels.yml000066400000000000000000000007341524076126400227400ustar00rootroot00000000000000--- name: Sync labels # yamllint disable-line rule:truthy on: push: branches: - main paths: - .github/labels.yml workflow_dispatch: jobs: labels: name: 🏷 Sync labels runs-on: ubuntu-latest steps: - name: ⤵️ Check out code from GitHub uses: actions/checkout@v7.0.1 - name: 🚀 Run Label Syncer uses: micnncim/action-label-syncer@v1.3.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} tb1337-pypaperless-5d67d60/.github/workflows/linting.yml000066400000000000000000000024461524076126400231440ustar00rootroot00000000000000--- name: Linting # yamllint disable-line rule:truthy on: pull_request: paths: - "**.py" - "pyproject.toml" - "uv.lock" - "pypaperless/**" - "tests/**" - ".pre-commit-config.yaml" - ".github/workflows/linting.yml" workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: DEFAULT_PYTHON: "3.14" jobs: lint: name: Lint runs-on: ubuntu-latest steps: - name: ⤵️ Check out code from GitHub uses: actions/checkout@v7.0.1 - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v7.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: 🏗 Set up uv uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: "uv.lock" - name: 🏗 Install Python dependencies run: uv sync --group dev - name: 🚀 Run ruff (with GitHub annotations) run: | uv run ruff check --output-format=github . uv run ruff format --check . - name: 🚀 Run remaining hooks via prek env: SKIP: mypy,pytest,ruff-check,ruff-format run: uv run prek run --all-files --show-diff-on-failure tb1337-pypaperless-5d67d60/.github/workflows/lock.yml000066400000000000000000000006711524076126400224260ustar00rootroot00000000000000--- name: Lock # yamllint disable-line rule:truthy on: schedule: - cron: "0 5 * * 1,3,5" workflow_dispatch: jobs: lock: name: 🔒 Lock closed issues and PRs runs-on: ubuntu-latest steps: - uses: dessant/lock-threads@v6.0.2 with: github-token: ${{ github.token }} issue-inactive-days: "30" issue-lock-reason: "" pr-inactive-days: "1" pr-lock-reason: "" tb1337-pypaperless-5d67d60/.github/workflows/pr-labels.yml000066400000000000000000000050321524076126400233530ustar00rootroot00000000000000--- name: PR Labels # yamllint disable-line rule:truthy on: pull_request: types: - opened - reopened - synchronize - labeled - unlabeled workflow_call: # GitHub emits one `labeled` event per label, so a bot applying five labels # queues five runs within a second. They are serialised rather than cancelled: # Renovate reads every check run on the head commit and maps `cancelled` to # neither pass nor fail, which parks the branch in a pending state forever. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: false permissions: pull-requests: read jobs: pr_labels: name: Verify runs-on: ubuntu-latest steps: - name: 🏷 Verify PR has a valid label env: GH_TOKEN: ${{ github.token }} PR_REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} MAX_ATTEMPTS: "8" RETRY_DELAY: "5" VALID_LABELS: >- breaking-change bugfix ci dependencies documentation enhancement maintenance new-feature performance refactor run: | # A bot opens the PR first and applies its labels in a second API # call, so both the event payload and an immediate read can see an # empty set — this job has finished within 7s of a PR being opened. # A failure sticks to the commit for good, so poll for the labels to # land before calling a PR unlabelled. matched="" for attempt in $(seq 1 "$MAX_ATTEMPTS"); do if ! labels="$(gh api "repos/${PR_REPO}/pulls/${PR_NUMBER}" --jq '.labels[].name')"; then labels="" fi while IFS= read -r label; do [ -n "$label" ] || continue for valid in $VALID_LABELS; do if [ "$label" = "$valid" ]; then matched="${matched:+$matched, }$label" fi done done <<< "$labels" if [ -n "$matched" ]; then break fi if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then echo "No valid label yet (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${RETRY_DELAY}s" sleep "$RETRY_DELAY" fi done if [ -z "$matched" ]; then echo "::error title=Missing label::This PR needs at least one of these labels: ${VALID_LABELS}" exit 1 fi echo "Found valid label(s): ${matched}" tb1337-pypaperless-5d67d60/.github/workflows/release-drafter.yml000066400000000000000000000005721524076126400245430ustar00rootroot00000000000000--- name: Release Drafter # yamllint disable-line rule:truthy on: push: branches: - main workflow_dispatch: jobs: update_release_draft: name: 📝 Draft release runs-on: ubuntu-latest steps: - name: 🚀 Run Release Drafter uses: release-drafter/release-drafter@v7.7.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} tb1337-pypaperless-5d67d60/.github/workflows/release.yml000066400000000000000000000024731524076126400231200ustar00rootroot00000000000000--- name: Release # yamllint disable-line rule:truthy on: release: types: - published env: DEFAULT_PYTHON: "3.14" jobs: release: name: Releasing to PyPi runs-on: ubuntu-latest environment: name: release url: https://pypi.org/p/pypaperless permissions: id-token: write # important for trusted publishing (pypi) steps: - name: ⤵️ Check out code from GitHub uses: actions/checkout@v7.0.1 - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v7.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: 🏗 Set up uv uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: "uv.lock" - name: 🏗 Install Python dependencies run: uv sync --group dev - name: 🏗 Set package version run: | version="${{ github.event.release.tag_name }}" version="${version,,}" version="${version#v}" sed -i "s/^version = .*/version = \"${version}\"/" pyproject.toml - name: 🏗 Build package run: uv build - name: 🚀 Publish to PyPi uses: pypa/gh-action-pypi-publish@v1.14.2 with: verbose: true print-hash: true tb1337-pypaperless-5d67d60/.github/workflows/stale.yml000066400000000000000000000025571524076126400226130ustar00rootroot00000000000000--- name: Stale # yamllint disable-line rule:truthy on: schedule: - cron: "0 3 * * 1,3,5" workflow_dispatch: jobs: stale: name: 🧹 Clean up stale issues and PRs runs-on: ubuntu-latest steps: - name: 🚀 Run stale uses: actions/stale@v11.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 30 days-before-close: 7 remove-stale-when-updated: true stale-issue-label: "stale" exempt-issue-labels: "no-stale,help-wanted" stale-issue-message: > There hasn't been any activity on this issue recently, so we have to clean up some inactive issues. Please make sure to update to the latest version and check if that solves the issue. Let us know if that works for you by leaving a comment 👍 This issue has now been marked as stale and will be closed if no further activity occurs. Thank you. stale-pr-label: "stale" exempt-pr-labels: "no-stale" stale-pr-message: > There hasn't been any activity on this pull request recently. This pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days. Thank you for your contribution! tb1337-pypaperless-5d67d60/.github/workflows/tests.yml000066400000000000000000000026141524076126400226370ustar00rootroot00000000000000--- name: Testing # yamllint disable-line rule:truthy on: pull_request: paths: - "**.py" - "pyproject.toml" - "uv.lock" - "pypaperless/**" - "tests/**" - ".github/workflows/tests.yml" workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: COVERAGE_PYTHON: "3.14" jobs: pytest: name: Python ${{ matrix.python }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: python: ["3.13", "3.14"] steps: - name: ⤵️ Check out code from GitHub uses: actions/checkout@v7.0.1 - name: 🏗 Set up Python ${{ matrix.python }} id: python uses: actions/setup-python@v7.0.0 with: python-version: ${{ matrix.python }} - name: 🏗 Set up uv uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: "uv.lock" - name: 🏗 Install Python dependencies run: uv sync --group dev - name: 🚀 Run pytest run: uv run pytest -v --cov-report xml:coverage.xml --cov pypaperless tests - name: 🚀 Upload coverage report if: matrix.python == env.COVERAGE_PYTHON uses: codecov/codecov-action@v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml fail_ci_if_error: false tb1337-pypaperless-5d67d60/.github/workflows/typing.yml000066400000000000000000000017621524076126400230120ustar00rootroot00000000000000--- name: Typing # yamllint disable-line rule:truthy on: pull_request: paths: - "**.py" - "pyproject.toml" - "uv.lock" - "pypaperless/**" - "tests/**" - ".github/workflows/typing.yml" workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: DEFAULT_PYTHON: "3.14" jobs: mypy: name: mypy runs-on: ubuntu-latest steps: - name: ⤵️ Check out code from GitHub uses: actions/checkout@v7.0.1 - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v7.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: 🏗 Set up uv uses: astral-sh/setup-uv@v9.0.0 with: enable-cache: true cache-dependency-glob: "uv.lock" - name: 🏗 Install Python dependencies run: uv sync --group dev - name: 🚀 Run mypy run: uv run mypy pypaperless tb1337-pypaperless-5d67d60/.gitignore000066400000000000000000000031451524076126400173450ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # OSX useful to ignore *.DS_Store .AppleDouble .LSOverride # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # pyenv .python-version # virtualenv .venv venv/ ENV/ # mypy .mypy_cache/ # ruff .ruff_cache # Visual Studio Code .vscode # IntelliJ Idea family of suites .idea *.iml ## File-based project format: *.ipr *.iws ## mpeltonen/sbt-idea plugin .idea_modules/ # PyBuilder target/ # Cookiecutter output/ python_boilerplate/ # Node node_modules/ # Deepcode AI .dccache # run run/ # zensical docs build output site/ # wrangler files .wrangler .dev.vars* !.dev.vars.example .env* !.env.example # Claude .claude/settings.local.json tb1337-pypaperless-5d67d60/.pre-commit-config.yaml000066400000000000000000000040151524076126400216330ustar00rootroot00000000000000--- repos: - repo: https://github.com/renovatebot/pre-commit-hooks rev: 41.1.4 hooks: - id: renovate-config-validator - repo: builtin hooks: - id: check-case-conflict - id: check-executables-have-shebangs - id: check-json - id: check-merge-conflict - id: check-symlinks - id: check-toml - id: check-xml - id: check-yaml - id: detect-private-key - id: end-of-file-fixer stages: [pre-commit, pre-push, manual] - id: no-commit-to-branch args: - --branch=main - id: trailing-whitespace stages: [pre-commit, pre-push, manual] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-ast - id: check-docstring-first - repo: local hooks: - id: yamllint name: 🎗 Check YAML files with yamllint language: system types: [yaml] entry: uv run yamllint - id: codespell name: ✅ Check code for common misspellings language: system types: [text] exclude: ^uv\.lock$ entry: uv run codespell - id: ruff-check name: 🐶 Ruff Linter language: system types: [python] entry: uv run ruff check --fix require_serial: true stages: [pre-commit, pre-push, manual] - id: ruff-format name: 🐶 Ruff Formatter language: system # markdown too: ruff formats python snippets inside code fences types_or: [python, markdown] entry: uv run ruff format require_serial: true stages: [pre-commit, pre-push, manual] - id: mypy name: 🆎 Static type checking using mypy language: system types: [python] entry: uv run mypy pass_filenames: false require_serial: true - id: pytest name: 🧪 Running tests and test coverage with pytest language: system types: [python] entry: uv run pytest pass_filenames: false tb1337-pypaperless-5d67d60/.vscode/000077500000000000000000000000001524076126400167135ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/.vscode/launch.json000066400000000000000000000004621524076126400210620ustar00rootroot00000000000000{ "version": "0.2.0", "configurations": [ { "name": "Python: pypaperless debug", "type": "debugpy", "request": "launch", "program": "run/debug.py", "console": "integratedTerminal", "justMyCode": true } ] } tb1337-pypaperless-5d67d60/.vscode/tasks.json000066400000000000000000000024621524076126400207370ustar00rootroot00000000000000{ "version": "2.0.0", "tasks": [ { "label": "test: pytest", "type": "shell", "command": "/usr/local/py-utils/bin/pytest -x -q", "group": "test", "presentation": { "reveal": "always", "panel": "shared", "clear": true }, "problemMatcher": [] }, { "label": "test: smoketest", "type": "shell", "command": "source /home/vscode/.local/dev-venv/bin/activate && python script/pngx_smoketest.py", "group": "test", "presentation": { "reveal": "always", "panel": "shared", "clear": false }, "problemMatcher": [] }, { "label": "test: api-coverage", "type": "shell", "command": "python script/pngx_audit_coverage.py", "group": "test", "presentation": { "reveal": "always", "panel": "shared", "clear": false }, "problemMatcher": [] }, { "label": "validate: full suite", "dependsOn": ["test: pytest", "test: smoketest", "test: api-coverage"], "dependsOrder": "sequence", "group": { "kind": "build", "isDefault": true }, "presentation": { "reveal": "always", "panel": "shared", "clear": true }, "problemMatcher": [] } ] } tb1337-pypaperless-5d67d60/.yamllint000066400000000000000000000024001524076126400172000ustar00rootroot00000000000000--- ignore: - .venv rules: braces: level: error min-spaces-inside: 0 max-spaces-inside: 1 min-spaces-inside-empty: -1 max-spaces-inside-empty: -1 brackets: level: error min-spaces-inside: 0 max-spaces-inside: 0 min-spaces-inside-empty: -1 max-spaces-inside-empty: -1 colons: level: error max-spaces-before: 0 max-spaces-after: 1 commas: level: error max-spaces-before: 0 min-spaces-after: 1 max-spaces-after: 1 comments: level: error require-starting-space: true min-spaces-from-content: 1 comments-indentation: level: error document-end: level: error present: false document-start: level: error present: true empty-lines: level: error max: 1 max-start: 0 max-end: 1 hyphens: level: error max-spaces-after: 1 indentation: level: error spaces: 2 indent-sequences: true check-multi-line-strings: false key-duplicates: level: error line-length: level: warning max: 120 allow-non-breakable-words: true allow-non-breakable-inline-mappings: true new-line-at-end-of-file: level: error new-lines: level: error type: unix trailing-spaces: level: error truthy: level: error tb1337-pypaperless-5d67d60/AGENTS.md000066400000000000000000000107701524076126400166620ustar00rootroot00000000000000# AGENTS.md Guidance for AI coding agents working in this repo. ## Project overview Async Python client library for the Paperless-ngx REST API. Package manager: **uv**. Public entry point: `PaperlessClient` (also exported: `PaperlessSettings`, `generate_api_token`) — see `pypaperless/__init__.py`. - `pypaperless/` — library source - `models/` — Pydantic v2 resource models (subpackages `documents/`, `mails/`, `mixins/`, `permissions/`, `share_links/`, `workflows/`); public types re-exported via `models/types.py`; filter TypedDicts in `models/filters.py` - `services/` — API service classes composed from `services/mixins/` (base `ResourceService` in `services/base.py`); registered lazily via `@property` - `builders/` — fluent query builders (`search.py`, `custom_fields.py`) - top-level: `client.py`, `const.py`, `settings.py`, `transport.py`, `cache.py`, `dispatch.py`, `pagination.py`, `runtime.py`, `exceptions.py`, `utils.py` - `tests/` — pytest + `pytest-httpx`; fixture data per resource in `tests/data/` - `script/` — `pngx_smoketest.py` (live smoketest), `pngx_audit_coverage.py` (API coverage audit), `pngx_fetch_schema.py` (refresh `tests/data/schema.json`) - `docs/` — Markdown, built with `zensical` Current code surface (trust these over older docs): `const.py` uses `EndpointPath(StrEnum)` (no `API_PATH` dict) · service base `ResourceService` / `PaperlessService` · mixins are `*Service` (`Iterable`, `Callable`, `Creatable`, `Updatable`, `Deletable`, `Securable`) · runtime handle `self._runtime`. ## Dev Commands - `uv sync` — install deps (add `--group docs` for docs) - `uv run pytest -x -q` — unit tests, coverage ≥ 95 % - `uv run ruff check pypaperless` — lint (`select = ALL`) - `uv run ruff format pypaperless` — format - `uv run mypy` — static type check (strict-ish) - `uv run codespell` — spell check - `uv run yamllint .` — lint YAML (enforced by pre-commit) - `prek run --all-files` — all pre-commit hooks Local dev instance credentials live in the git-ignored `.env` (`PYPAPERLESS_URL`, `PYPAPERLESS_TOKEN`, `PYPAPERLESS_TEST_DOC`) — copy `.env.example` to `.env`. Activate the dev venv before running scripts. The `script/*.py` tools and `run/debug.py` read the `.env` via `script/_dev_env.py`. ## Testing instructions 1. `uv run pytest -x -q` — always required, all green, coverage ≥ 95 %. 2. `uv run python script/pngx_smoketest.py` — **only** when the change could affect live API interaction: new/changed service, model, mixin or endpoint, or edits to `client.py`, `const.py`, `utils.py`. Needs a live instance (below); expect 0 failures. For docs, filters, pure refactors and docstrings, run unit tests only and state that the smoketest was skipped and why. Report both results (or the skip reason) before closing the task. ## PR instructions - Branch off `main`; keep the branch scoped to one logical change. - Title format: `: ` (e.g. `fix:`, `feat:`, `docs:`, `refactor:`). - Before opening a PR, all `## Dev Commands` pass and the `## Testing instructions` are satisfied (report the smoketest result or the skip reason). - Fill in the PR template and do not uncheck/remove its checkboxes. ## Code style - Ruff (`select = ALL`) and mypy must report **0 findings** on new/modified code. - `# noqa`, `# type: ignore` and all other suppressions are **forbidden** — fix the root cause. Only `# noqa: F401` on re-export lines in `__init__.py` is allowed without asking. - Pydantic v2 (`BaseModel`, `model_validator`, `Field`) · httpx for async HTTP. - `@asynccontextmanager` always uses `try/finally`. - Public types re-exported via `models/types.py`; internal helpers prefixed with `_`. - New resource → `.claude/skills/pp-add-resource/SKILL.md` (or `/pp-add-resource`). Filter changes → `.claude/skills/pp-update-filters/SKILL.md` (or `/pp-update-filters`). These skills track the current code surface and hold the canonical templates. ## Good practices - Comments explain *why* (non-obvious constraints, surprises, workarounds), never *what*. Prefer one short line, or none. Never justify a change by referencing what the code used to be. - No section/divider comments (e.g. `# --- Triggers ---`) — they go stale. - Keep try-clauses minimal: wrap only the statement that can raise, catch only expected exceptions. - Docstrings: public APIs RST-style (one-line summary, Google `Args:`, mandatory `Example::` block, no Markdown fences — see `pypaperless/builders/search.py`); private `_` symbols one or two lines on *why* only. tb1337-pypaperless-5d67d60/CLAUDE.md000077700000000000000000000000001524076126400201272AGENTS.mdustar00rootroot00000000000000tb1337-pypaperless-5d67d60/Dockerfile.dev000066400000000000000000000027171524076126400201300ustar00rootroot00000000000000FROM mcr.microsoft.com/devcontainers/python:3-3.14 SHELL ["/bin/bash", "-o", "pipefail", "-c"] # Uninstall pre-installed formatting and linting tools # They would conflict with our pinned versions RUN \ pipx uninstall pydocstyle \ && pipx uninstall pycodestyle \ && pipx uninstall mypy RUN \ mkdir -p /etc/apt/keyrings \ && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor -o /etc/apt/keyrings/yarn-archive-keyring.gpg \ && echo "deb [signed-by=/etc/apt/keyrings/yarn-archive-keyring.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ # Additional library needed by some tests and accordingly by VScode Tests Discovery bluez \ ffmpeg \ libudev-dev \ libavformat-dev \ libavcodec-dev \ libavdevice-dev \ libavutil-dev \ libgammu-dev \ libswscale-dev \ libswresample-dev \ libavfilter-dev \ libpcap-dev \ libturbojpeg0 \ libyaml-dev \ libxml2 \ git \ cmake \ gh \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Install uv RUN pip3 install uv USER vscode ENV VIRTUAL_ENV="/home/vscode/.local/dev-venv" RUN uv venv $VIRTUAL_ENV ENV PATH="$VIRTUAL_ENV/bin:$PATH" # force use the venv ENV UV_PROJECT_ENVIRONMENT="$VIRTUAL_ENV" WORKDIR /workspaces # Set the default shell to bash instead of sh ENV SHELL=/bin/bash tb1337-pypaperless-5d67d60/LICENSE.md000066400000000000000000000020561524076126400167610ustar00rootroot00000000000000# MIT License Copyright (c) 2022-2024 tb1337 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. tb1337-pypaperless-5d67d60/README.md000066400000000000000000000132571524076126400166410ustar00rootroot00000000000000# pypaperless [![GitHub Release][release-badge]][release-url] [![Python Version][python-badge]][python-url] [![GitHub License][license-badge]][license-url] [![Tests][tests-badge]][tests-url] [![Codecov][codecov-badge]][codecov-url] [![Linting][linting-badge]][linting-url] [![Typing][typing-badge]][typing-url] **pypaperless** is a modern, fully async Python client library for the [Paperless-ngx](https://docs.paperless-ngx.com/) REST API. - [Features](#features) - [Quick example](#quick-example) - [Installation](#installation) - [Documentation](#documentation) - [Compatibility matrix](#compatibility-matrix) - [Authors \& contributors](#authors--contributors) --- ## Features | Feature | Details | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Fully async** | Built on `asyncio` + `httpx.AsyncClient`; drop-in for any async framework | | **Type-safe models** | All resources are validated [Pydantic](https://docs.pydantic.dev/) models | | **Complete resource coverage** | Documents, Correspondents, Tags, Document Types, Storage Paths, Custom Fields, Saved Views, Share Links, Workflows, Mail Accounts/Rules, Tasks, Trash, and more | | **CRUD + permissions** | Create, read, update, delete and manage object-level permissions per resource | | **Async iteration & paging** | Iterate over all items or page-by-page; server-side filtering via `filter()` | | **Document operations** | Upload, download, search (full-text & advanced), notes, suggestions, metadata | | **Custom field system** | Rich typed access to custom field values — read, write, remove with caching | | **Token generation** | `generate_api_token()` helper to exchange username + password for an API token | | **Custom HTTP client** | Bring your own `httpx.AsyncClient` for full control over timeouts, proxies, TLS, … | ## Quick example ```python import asyncio from pypaperless import PaperlessClient async def main(): async with PaperlessClient("localhost:8000", "your-api-token") as paperless: # iterate all documents - pagination is handled automatically async for document in paperless.documents: print(document.id, document.title) # fetch a single item doc = await paperless.documents(42) # filter with server-side parameters async with paperless.tags.filter(name__icontains="invoice") as filtered: async for tag in filtered: print(tag.id, tag.name) asyncio.run(main()) ``` ## Installation Requires **Python 3.13+**. ```bash pip install pypaperless ``` ## Documentation Full documentation is available **[here][docs-url]**. ## Compatibility matrix | **pypaperless** | *Paperless-ngx* | Python | Notes | | --------------- | --------------- | ------ | --------------------------------------- | | **>= v6.0** | **>= 3.0** | 3.13 | Current release | | >= v5.2 | >= 2.19 | 3.12 | | | >= v5.0 | >= 2.17 | 3.12 | | | >= v4.x | >= 2.15 | 3.12 | | | < v4.0 | < 2.15 | 3.11 | Incompatible with Paperless-ngx >= 2.15 | > [!TIP] > Keep both *Paperless-ngx* and **pypaperless** up to date to benefit from the latest API features and bug fixes. ## Authors & contributors **pypaperless** is written and maintained by: [Tobias Schulz][contributors-tbsch] and Claude. Feedback and contributions are always welcome. Check out all [contributors][contributors-url]. [codecov-badge]: https://codecov.io/gh/tb1337/pypaperless/graph/badge.svg?token=IMXRBK3HRE [codecov-url]: https://app.codecov.io/gh/tb1337/pypaperless/tree/main [contributors-tbsch]: https://tbsch.de [contributors-url]: https://github.com/tb1337/pypaperless/graphs/contributors [docs-url]: https://pypaperless.docs.tbsch.de [license-badge]: https://img.shields.io/github/license/tb1337/pypaperless [license-url]: /LICENSE.md [python-badge]: https://img.shields.io/pypi/pyversions/pypaperless [python-url]: https://pypi.org/project/pypaperless/ [tests-badge]: https://github.com/tb1337/pypaperless/actions/workflows/tests.yml/badge.svg [tests-url]: https://github.com/tb1337/pypaperless/actions [release-badge]: https://img.shields.io/github/v/release/tb1337/pypaperless [release-url]: https://github.com/tb1337/pypaperless/releases [linting-badge]: https://github.com/tb1337/pypaperless/actions/workflows/linting.yml/badge.svg [linting-url]: https://github.com/tb1337/pypaperless/actions [typing-badge]: https://github.com/tb1337/pypaperless/actions/workflows/typing.yml/badge.svg [typing-url]: https://github.com/tb1337/pypaperless/actions tb1337-pypaperless-5d67d60/docs/000077500000000000000000000000001524076126400163025ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/docs/concepts/000077500000000000000000000000001524076126400201205ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/docs/concepts/custom_field_query.md000066400000000000000000000062431524076126400243510ustar00rootroot00000000000000# Custom field query The `custom_field_query` filter lets you search documents by custom field values using a structured boolean expression. Paperless-ngx parses the expression serverside - pypaperless provides a small builder so you never have to construct the JSON by hand. --- ## Basic usage Build an expression and pass `str(q)` to the `custom_field_query` kwarg of `documents.filter()`: ```python from pypaperless.builders import CustomFieldQuery as Q q = Q("Status", "exact", "open") async with paperless.documents.filter(custom_field_query=str(q)) as docs: async for doc in docs: print(doc.title) ``` --- ## Combining expressions Use `&`, `|` and `~` to build boolean queries. Chained `&` and `|` are automatically flattened: ```python q = Q("Status", "exact", "open") & Q("Amount", "gte", 100) & ~Q("Archived", "exact", True) # Serialises to: # ["AND", [["Status","exact","open"], ["Amount","gte",100], ["NOT",["Archived","exact",true]]]] ``` OR across multiple categories: ```python q = Q("Category", "exact", "A") | Q("Category", "exact", "B") | Q("Category", "exact", "C") ``` --- ## Referencing a field `field` can be either the **integer ID** or the **name string**: ```python Q(42, "exists", True) # by ID Q("Invoice Amount", "gte", 100) # by name ``` --- ## Supported operators The valid operators depend on the field's data type: | Applies to | Operators | | ------------------------------------ | ------------------------------------- | | **All types** | `exact` `in` `isnull` `exists` | | `STRING` `LONGTEXT` `URL` `MONETARY` | `icontains` `istartswith` `iendswith` | | `INTEGER` `FLOAT` `MONETARY` `DATE` | `gt` `gte` `lt` `lte` `range` | | `DOCUMENT_LINK` | `contains` | The full operator set is enumerated in [`builders/custom_fields.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/builders/custom_fields.py) as the `_QueryOperation` literal — those are the only values the type checker accepts as the second argument to `CustomFieldQuery(...)`. ### `exists` vs `isnull` `exists` is the idiomatic way to check field presence: ```python Q("Due Date", "exists", True) # document has the field Q("Due Date", "exists", False) # document does not have the field ``` ### `in` and `range` Pass a list as the value: ```python Q("Status", "in", ["open", "pending"]) Q("Amount", "range", [10, 100]) # start, end (inclusive) ``` --- ## Using `build()` `str(q)` is shorthand for `json.dumps(q.build())`. Call `build()` directly if you need the raw Python structure: ```python q = Q("Amount", "gte", 50) & Q("Status", "exact", "open") print(q.build()) # ["AND", [["Amount", "gte", 50], ["Status", "exact", "open"]]] print(str(q)) # '["AND", [["Amount", "gte", 50], ["Status", "exact", "open"]]]' ``` --- ## Server-side limits Paperless-ngx enforces: - Maximum nesting depth: **10** - Maximum number of atoms: **20** Exceeding these limits returns a HTTP 400 validation error. The full filter specification is in the [Paperless-ngx API reference](https://docs.paperless-ngx.com/api/#filtering-by-custom-fields). tb1337-pypaperless-5d67d60/docs/concepts/custom_fields.md000066400000000000000000000157051524076126400233120ustar00rootroot00000000000000# Custom fields Custom fields let you add structured, typed metadata to documents in Paperless-ngx. pypaperless provides a rich API for reading and writing them. --- ## Custom field types Each custom field has a `data_type` which determines the type of its value: | `CustomFieldType` | Python value type | Description | | ----------------- | ----------------- | ---------------------------------- | | `STRING` | `str` | Short text | | `LONGTEXT` | `str` | Long text / multi-line | | `URL` | `str` | URL string | | `DATE` | `datetime.date` | Date value | | `BOOLEAN` | `bool` | True / False | | `INTEGER` | `int` | Integer number | | `FLOAT` | `float` | Floating point number | | `MONETARY` | `str` | Currency amount, e.g. `"EUR12.50"` | | `SELECT` | `int` or `str` | Selection from predefined options | | `DOCUMENT_LINK` | `list[int]` | Links to other documents by ID | --- ## The cache To get typed `CustomFieldValue` instances (instead of plain `CustomFieldValue`), pypaperless needs to know the type of each field. This information comes from the **custom fields cache**. ### Providing a cache The cache lives on the runtime (`paperless.runtime.cache.custom_fields`) and is ``None`` until you populate it. Fetch all custom fields once and assign them as an ``id → CustomField`` mapping before working with documents: ```python from pypaperless import PaperlessClient async with PaperlessClient("localhost:8000", "your-api-token") as paperless: paperless.runtime.cache.custom_fields = await paperless.custom_fields.as_dict() doc = await paperless.documents(42) for item in doc.custom_fields: print(type(item).__name__, item.field, item.value) ``` With the cache populated, `doc.custom_fields` yields typed instances like `CustomFieldStringValue`, `CustomFieldIntegerValue`, etc. ### Without cache Without the cache, every `CustomFieldValue` is of the base type and `data_type` will be `None`: ```python doc = await paperless.documents(42) for item in doc.custom_fields: # item is always CustomFieldValue, value is Any print(item.field, item.value) ``` --- ## Reading custom field values ### Checking if a field is present ```python # by field ID if 8 in document.custom_fields: print("Field 8 is set") # by CustomField object cf = await paperless.custom_fields(8) if cf in document.custom_fields: print("Present") ``` ### Iterating over all values ```python for item in document.custom_fields: print(item.field, item.value, item.name) ``` ### Getting a specific value Use `get()` to retrieve a value by field ID or `CustomField` object. Raises `ItemNotFoundError` if not present: ```python value = document.custom_fields.get(8) print(value.value) ``` Use `default()` to return `None` instead of raising when the field is absent: ```python if value := document.custom_fields.default(8): print(value.value) ``` ### Typed access Both `get()` and `default()` accept an optional `expected_type` parameter to assert and return a more specific subclass: ```python from pypaperless.models.custom_fields import CustomFieldIntegerValue value = document.custom_fields.get(8, CustomFieldIntegerValue) print(value.value + 1) # IDE knows it's an int ``` A `TypeError` is raised if the actual type does not match `expected_type`. --- ## Typed value subclasses When the cache is active, pypaperless instantiates the right subclass automatically: | Class | `data_type` | `value` type | | ------------------------------ | -------------------- | --------------- | | `CustomFieldStringValue` | `STRING`, `LONGTEXT` | `str` | | `CustomFieldURLValue` | `URL` | `str` | | `CustomFieldDateValue` | `DATE` | `datetime.date` | | `CustomFieldBooleanValue` | `BOOLEAN` | `bool` | | `CustomFieldIntegerValue` | `INTEGER` | `int` | | `CustomFieldFloatValue` | `FLOAT` | `float` | | `CustomFieldMonetaryValue` | `MONETARY` | `str` | | `CustomFieldSelectValue` | `SELECT` | `int` or `str` | | `CustomFieldDocumentLinkValue` | `DOCUMENT_LINK` | `list[int]` | ### `CustomFieldMonetaryValue` extras Monetary values provide convenience properties: ```python item = document.custom_fields.get(8, CustomFieldMonetaryValue) print(item.currency) # "EUR" print(item.amount) # 12.5 item.currency = "USD" item.amount = 9.99 # item.value is now "USD9.99" ``` ### `CustomFieldSelectValue` extras ```python item = document.custom_fields.get(5, CustomFieldSelectValue) print(item.label) # human-readable label of selected option print(item.labels) # all available options as list[CustomFieldSelectOptions] ``` --- ## Writing custom field values ### Drafting a value from a `CustomField` The recommended way is to call `draft_value()` on a `CustomField` instance. With the cache active, it returns the correct typed subclass: ```python paperless.runtime.cache.custom_fields = await paperless.custom_fields.as_dict() cf = await paperless.custom_fields(8) new_value = cf.draft_value(42) # new_value is CustomFieldIntegerValue(field=8, value=42) ``` ### Adding a value to a document Use `+=` or `add()` to append to a document's custom field list: ```python document = await paperless.documents(42) cf = await paperless.custom_fields(8) new_value = cf.draft_value(100) document.custom_fields += new_value # or: document.custom_fields.add(new_value) await paperless.documents.update(document) ``` ### Removing a value from a document Use `-=` or `remove()`. You can pass a `CustomFieldValue`, a `CustomField` or a plain field ID: ```python document.custom_fields -= new_value # or: document.custom_fields.remove(8) # by field id document.custom_fields.remove(cf) # by CustomField object await paperless.documents.update(document) ``` --- ## Creating a custom field Use `create()` and `save()` on `paperless.custom_fields`: ```python from pypaperless.models.custom_fields import CustomFieldType draft = paperless.custom_fields.create( name="Invoice amount", data_type=CustomFieldType.MONETARY, ) new_id = await paperless.custom_fields.save(draft) ``` For `SELECT` fields, provide options in `extra_data`: ```python from pypaperless.models.custom_fields import ( CustomFieldExtraData, CustomFieldSelectOptions, CustomFieldType, ) draft = paperless.custom_fields.create( name="Status", data_type=CustomFieldType.SELECT, extra_data=CustomFieldExtraData( select_options=[ CustomFieldSelectOptions(id="1", label="Open"), CustomFieldSelectOptions(id="2", label="Closed"), ] ), ) ``` tb1337-pypaperless-5d67d60/docs/concepts/documents.md000066400000000000000000000464331524076126400224550ustar00rootroot00000000000000# Documents `paperless.documents` provides the full feature set of the Paperless-ngx document API - in addition to the standard CRUD operations available on all resources. --- ## Fetching a document ```python document = await paperless.documents(42) print(document.id) print(document.title) print(document.correspondent) print(document.document_type) print(document.tags) print(document.created) print(document.content) print(document.page_count) print(document.mime_type) print(document.archive_serial_number) ``` --- ## Downloading file contents Every document can be fetched in three modes: **download** (archived), **preview** and **thumbnail**. All return a `DownloadedDocument` instance. ```python download = await paperless.documents.download(42) preview = await paperless.documents.preview(42) thumbnail = await paperless.documents.thumbnail(42) ``` `DownloadedDocument` gives you the raw bytes plus everything from the response headers you'd need to save or serve the file: ```python # save to disk using the filename suggested by the API with open(download.disposition_filename, "wb") as f: f.write(download.content) print(download.content_type) # e.g. "application/pdf" print(download.disposition_type) # "attachment" or "inline" ``` ### Requesting the original file By default, the archived (processed) version is returned. Pass `original=True` to get the original uploaded file: ```python download = await paperless.documents.download(42, original=True) ``` --- ## Searching documents ### Full-text search ```python async for document in paperless.documents.search("type:invoice"): print(document.title, document.search_hit.score) ``` You can also pass the query as a keyword argument: ```python async for document in paperless.documents.search(query="annual report"): ... ``` ### Search hits When a document was returned from a search, it carries a `DocumentSearchHit`. Use `has_search_hit` to branch on it, or the walrus operator to check and bind in one step: ```python if document.has_search_hit: print(f"{document.title} matched the query") if hit := document.search_hit: print(hit.score) print(hit.highlights) print(hit.note_highlights) print(hit.rank) ``` `search_hit` is `None` for documents fetched directly (e.g. `paperless.documents(42)`). ### Custom field query For building expressions in a type-safe way, see [Custom field query](custom_field_query.md). --- ## More-like search Find documents similar to a given document: ```python async for document in paperless.documents.more_like(42): print(document.title) ``` --- ## Metadata ```python meta = await paperless.documents.metadata(42) ``` The returned `DocumentMeta` object includes embedded metadata from the file (e.g. EXIF or PDF metadata): ```python for entry in meta.original_metadata: print(entry.namespace, entry.key, entry.value) ``` --- ## Suggestions Paperless-ngx can suggest classifiers (correspondent, document type, tags) for a document: ```python suggestions = await paperless.documents.suggestions(42) print(suggestions.correspondents) print(suggestions.document_types) print(suggestions.tags) print(suggestions.storage_paths) print(suggestions.dates) ``` These are the classifier's rule-based suggestions and resolve to existing object IDs. ### AI suggestions `ai_suggestions()` is a separate endpoint backed by the LLM configured on the server (`config.ai_enabled` must be on). Alongside ID lists it returns *names* for objects that do not exist yet, so you can create them before assigning: ```python ai = await paperless.documents.ai_suggestions(42) print(ai.title) # suggested document title print(ai.tags) # [1, 5] - existing tag IDs print(ai.suggested_tags) # ["Insurance"] - names not yet in Paperless print(ai.correspondents) print(ai.suggested_correspondents) print(ai.document_types, ai.suggested_document_types) print(ai.storage_paths, ai.suggested_storage_paths) print(ai.dates) # or via a fetched document (pk bound automatically) doc = await paperless.documents(42) ai = await doc.ai_suggestions() ``` --- ## Chat `documents.chat` sends a natural-language question to the LLM configured on the Paperless-ngx server. Pass a document pk as the second argument to scope the question to a single document, or omit it for an unscoped query: ```python response = await paperless.documents.chat("What is this invoice about?", 42) print(response.q) # "What is this invoice about?" - echoed by the server print(response.document_id) # 42 # unscoped response = await paperless.documents.chat("Which contracts expire this year?") ``` !!! warning "The answer is streamed, not returned" Upstream this is a streaming endpoint, and its documented JSON response contains only the echoed `q` and `document_id` — which is exactly what `DocumentChat` exposes. `chat()` therefore confirms the query was accepted; it does **not** give you the generated answer text. !!! note Requires AI to be enabled server-side, and `q` is capped at 4000 characters. Check `(await paperless.config()).ai_enabled` before relying on `chat` or `ai_suggestions`. --- ## Document versions A document can hold several file versions. `documents.versions` uploads, relabels and removes them; `documents.root` reports which document is the root of a version chain. ```python # upload a new version of document 42 with open("invoice-v2.pdf", "rb") as fh: await paperless.documents.versions.upload(fh, version_label="v2", pk=42) # relabel an existing version - returns the updated DocumentVersionInfo info = await paperless.documents.versions.update(1, version_label="final", pk=42) print(info.version_label, info.checksum, info.is_root) # delete a version await paperless.documents.versions.delete(1, pk=42) # which document is the root of this chain? root = await paperless.documents.root(42) print(root.root_id) ``` Via a fetched document the pk is bound automatically, so `pk=` can be omitted: ```python doc = await paperless.documents(42) with open("invoice-v2.pdf", "rb") as fh: await doc.versions.upload(fh, version_label="v2") await doc.versions.delete(1) root = await doc.root() ``` !!! note `doc.versions` is not callable - it has no `__call__`, only `upload()`, `update()` and `delete()`. The version list the API embeds in a document response is parsed into `doc.versions_` (a `list[DocumentVersionInfo]`), and `doc.root_document` holds the root document's pk. --- ## Notes Every document can have a list of notes attached to it. When a document is fetched from the API the notes are already embedded in the response - calling `doc.notes()` returns them immediately from an in-memory cache without a second HTTP request. ```python doc = await paperless.documents(42) notes = await doc.notes() # served from cache, no HTTP request for note in notes: print(note.id, note.note, note.created) ``` To force a fresh fetch from the API and refresh the cache, pass `force_request=True`: ```python notes = await doc.notes(force_request=True) ``` The standalone service always requests the API: ```python notes = await paperless.documents.notes(42) ``` ### Adding a note ```python # Pass the document pk as the first positional argument draft = paperless.documents.notes.create(42, note="This needs review") note_id = await paperless.documents.notes.save(draft) ``` Or via a fetched document (the document pk is bound automatically): ```python doc = await paperless.documents(42) draft = doc.notes.create(note="This needs review") note_id = await doc.notes.save(draft) ``` After `save()` the cache is updated automatically - the next `doc.notes()` call returns the latest state without an extra request. ### Deleting a note ```python notes = await doc.notes() await doc.notes.delete(notes[0]) # model instance await doc.notes.delete(notes[0].id) # integer shorthand (document pk implicit) # standalone — supply the document pk explicitly await paperless.documents.notes.delete(notes[0].id, pk=42) ``` After a successful delete the cache is updated in-place. --- ## Share links for a document Every document can have share links attached to it. These are read-only from the document sub-service - to create or delete share links use `paperless.share_links`. ```python # Fetch share links for a document links = await paperless.documents.share_links(42) # or via a fetched document doc = await paperless.documents(42) links = await doc.share_links() for link in links: print(link.slug, link.expiration) ``` --- ## Next available ASN Request the next free archive serial number from Paperless-ngx: ```python next_asn = await paperless.documents.get_next_asn() print(f"Next ASN: {next_asn}") ``` --- ## Updating & deleting a document Modify fields on a fetched document and persist them with `update()`, or remove the document with `delete()`. Both can be called on the service directly or via the client-level dispatcher: ```python doc = await paperless.documents(42) doc.title = "Invoice 2024-01" doc.correspondent = 3 # service await paperless.documents.update(doc) await paperless.documents.delete(doc) # dispatcher — no need to reference the service explicitly await paperless.update(doc) await paperless.delete(doc) ``` See [Resources — Updating items](../resources.md#updating-items) and [Resources — Deleting items](../resources.md#deleting-items) for full options (`only_changed`, `silent_fail`). --- ## Uploading a document 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.create( document=content, # required - raw file bytes filename="invoice.pdf", # original filename title="Invoice 2024-01", created=datetime.datetime(2024, 1, 15), correspondent=3, # correspondent ID document_type=2, # document type ID storage_path=1, # storage path ID tags=[1, 5], # tag IDs archive_serial_number=1042, custom_fields=[3, 8], # custom field IDs (Paperless assigns null values) ) task_id = await paperless.documents.save(draft) print(f"Upload queued as task: {task_id}") ``` !!! note Unlike other resources, `save()` for documents returns a **task ID string**, not an integer ID. The document is processed asynchronously by Paperless-ngx. Use `paperless.tasks` to monitor the task. ### Skipping duplicates before upload Paperless-ngx rejects duplicate files server-side, but the consume task only fails *after* the file has been transferred and queued. For large files, or when you upload from a bulk script, it is cheaper to check up-front. `find_duplicate()` hashes the bytes (MD5, as used by Paperless) and asks the server whether a document with that checksum already exists: ```python with open("invoice.pdf", "rb") as f: content = f.read() existing = await paperless.documents.find_duplicate(content, filename="invoice.pdf") if existing is not None: print(f"Already uploaded as #{existing.id}: {existing.title}") else: draft = paperless.documents.create(document=content, filename="invoice.pdf") await paperless.documents.save(draft) ``` `filename` is optional and, when given, additionally matches `original_filename` case-insensitively. The hash is computed in a worker thread so the event loop stays responsive on large files. ### Uploading with custom field values To set explicit values on custom fields at upload time, build a `DocumentCustomFieldList` via its `from_data()` factory (which binds the runtime) and add `CustomFieldValue` entries: ```python from pypaperless.models.documents import DocumentCustomFieldList from pypaperless.models.custom_fields import CustomFieldValue cf_list = DocumentCustomFieldList.from_data(paperless.runtime, []) cf_list += CustomFieldValue(field=3, value="ACME Corp") cf_list += CustomFieldValue(field=8, value=42) draft = paperless.documents.create(document=content, custom_fields=cf_list) ``` See [Custom fields](custom_fields.md) for the full custom field API. --- ## Monitoring upload tasks After uploading a document, use `paperless.tasks` to check the status: ```python import asyncio from pypaperless.models.tasks import TaskStatus task_id = await paperless.documents.save(draft) for _ in range(30): await asyncio.sleep(2) task = await paperless.tasks(task_id) if task.status in (TaskStatus.SUCCESS, TaskStatus.FAILURE): break print(task.status, task.result_data) ``` --- ## Checking if a document is deleted The `is_deleted` property returns `True` when the document is currently in the trash: ```python doc = await paperless.documents(42) print(doc.is_deleted) # False for active documents # Documents returned from paperless.trash also have this set async for doc in paperless.trash: print(doc.id, doc.is_deleted, doc.deleted_at) ``` --- ## Sending documents by e-mail You can send one or more documents as attachments to one or more e-mail addresses: ```python await paperless.documents.email( [23, 42], addresses="alice@example.com, bob@example.com", subject="Your requested documents", message="Please find the documents attached.", ) ``` A single document can also be passed as an integer: ```python await paperless.documents.email( 42, addresses="alice@example.com", subject="Invoice", message="See attachment.", use_archive_version=False, # send original instead of archived version ) ``` | Parameter | Default | Description | | --------------------- | ------- | ------------------------------------------- | | `documents` | - | Document ID(s) to send | | `addresses` | - | Comma-separated recipient e-mail addresses | | `subject` | - | E-mail subject | | `message` | - | E-mail body text | | `use_archive_version` | `True` | Send archived version; `False` for original | Raises `SendEmailError` if the Paperless server rejects the request. --- ## Audit history Every change to a document is recorded as an audit-log entry. Use `document.history()` or the service directly to retrieve the full history of a document. ```python # Via a fetched document (document pk is bound automatically) doc = await paperless.documents(42) entries = await doc.history() for entry in entries: print(entry.timestamp, entry.action, entry.actor.username if entry.actor else "-") print(entry.changes) # dict of changed fields # Via the service, passing the document pk explicitly entries = await paperless.documents.history(42) ``` --- ## Bulk editing `paperless.documents.bulk_edit` lets you apply operations to many documents at once in a single API call. ### Metadata ```python await paperless.documents.bulk_edit.set_correspondent([1, 2, 3], 5) await paperless.documents.bulk_edit.set_document_type([1, 2], 3) await paperless.documents.bulk_edit.set_storage_path([1, 2], 4) # clear correspondent await paperless.documents.bulk_edit.set_correspondent([1, 2, 3], None) ``` ### Tags ```python await paperless.documents.bulk_edit.add_tag([1, 2, 3], 7) await paperless.documents.bulk_edit.remove_tag([1, 2, 3], 7) # Add and remove in one call await paperless.documents.bulk_edit.modify_tags( [1, 2, 3], add_tags=[5, 6], remove_tags=[2], ) ``` ### Custom fields ```python await paperless.documents.bulk_edit.modify_custom_fields( [1, 2], add_custom_fields={3: "open"}, # {pk: value} or list of PKs remove_custom_fields=[4], ) ``` ### Permissions ```python from pypaperless.models.types import Permissions await paperless.documents.bulk_edit.set_permissions( [1, 2, 3], owner=1, permissions=Permissions(view_users=[2, 3], change_users=[1]), merge=False, # True merges with existing instead of replacing ) ``` ### Document operations ```python # Move to trash await paperless.documents.bulk_edit.delete([10, 11, 12]) # Re-run OCR await paperless.documents.bulk_edit.reprocess([1, 2, 3]) # Rotate pages await paperless.documents.bulk_edit.rotate([1, 2], 90) # Merge into a new single document await paperless.documents.bulk_edit.merge( [10, 11, 12], metadata_document_id=10, # whose metadata to use for the result delete_originals=True, # move source documents to trash after merging archive_fallback=False, # fall back to the archived file if no original exists ) ``` ### PDF operations `edit_pdf()` applies page-level operations to **one** document - the API accepts only a single document per request. Each operation needs a `page` key; `rotate` and `doc` are optional: ```python await paperless.documents.bulk_edit.edit_pdf( 42, operations=[{"page": 1, "rotate": 90}, {"page": 3}], delete_original=False, # move the source document to trash afterwards update_document=False, # True updates in place instead of creating a new document include_metadata=True, # carry metadata over to the result ) ``` `split()` and `delete_pages()` are convenience wrappers around `edit_pdf()` - both take a single document, like `edit_pdf()` itself: ```python # two new documents: pages 1-2 and page 3 await paperless.documents.bulk_edit.split( 42, [[1, 2], [3]], delete_originals=False, # move the source document to trash afterwards ) # drop pages 2 and 4, creating a new version of document 42 await paperless.documents.bulk_edit.delete_pages(42, [2, 4]) ``` Two things worth knowing: - The documents produced by `split()` inherit the source metadata **unchanged** - there is no `(split 1)`, `(split 2)`, … title suffix. Rename the results afterwards if you need it. - The API keeps the pages it is handed rather than removing them, so `delete_pages()` needs the total page count and looks it up with one extra request. Pass `page_count=` when you already have the document, when its record carries no page count (not a PDF, or not processed yet), or when `source_mode` selects a file with a different number of pages. - That count comes from an earlier request, so the pages to keep are a snapshot. If the document gains a version in between, the wrong pages survive - a shorter file is rejected by the server as out of bounds, but a longer one silently loses its extra pages. Passing `page_count=` does not close that window; it only skips the lookup, moving the staleness to your own read. The native, now-deprecated `delete_pages` bulk method avoided this by removing pages by index server-side. Both raise `BulkEditPagesError` for input that cannot produce a valid PDF - no page groups, an empty group, no pages to remove, page numbers outside the document, or removing every page. It subclasses both `DocumentError` and `ValueError`, and the checks run before any request is sent. See [Exceptions](../exceptions.md#bulkeditpageserror). `remove_password()` decrypts password-protected PDFs: ```python await paperless.documents.bulk_edit.remove_password( [5, 6], password="secret", update_document=True, ) ``` ### Choosing the source file `rotate()`, `merge()`, `edit_pdf()`, `split()`, `delete_pages()` and `remove_password()` all accept `source_mode`, which selects the file the operation reads from - `"latest_version"` (default) or `"explicit_selection"`: ```python await paperless.documents.bulk_edit.rotate([1, 2], 90, source_mode="explicit_selection") ``` All bulk edit operations raise `BulkEditError` (a `ResponseError` subclass) when the API returns a non-OK result. tb1337-pypaperless-5d67d60/docs/concepts/permissions.md000066400000000000000000000114421524076126400230170ustar00rootroot00000000000000# Permissions Paperless-ngx supports **object-level permissions** - some resources can have explicit `view` and `change` permissions assigned to individual users or groups. --- ## Resources that support permissions The following resources include permission fields (`SecurableModel`): - `documents` - `correspondents` - `document_types` - `mail_accounts` - `mail_rules` - `saved_views` - `storage_paths` - `tags` !!! note `processed_mail` exposes a plain `owner` field but no permission table - `with_permissions()` has no effect there. See [Processed Mail](../resources/processed_mail.md#owner). --- ## Permission fields on a model When permissions are loaded, a `Document` (or other securable model) exposes: | Field | Description | | ----------------- | -------------------------------------------- | | `owner` | User ID of the owner | | `user_can_change` | Whether the current user can change the item | | `permissions` | Full permission table | ```python doc = await paperless.documents(42) print(doc.owner) print(doc.user_can_change) if doc.has_permissions: print(doc.permissions.view.users) print(doc.permissions.view.groups) print(doc.permissions.change.users) print(doc.permissions.change.groups) ``` !!! note `doc.permissions` is `None` unless you explicitly request the full permissions (see below). --- ## Requesting permissions alongside data By default, Paperless-ngx does not include the full permission table in responses. ### Recommended: `with_permissions()` context manager Use the `with_permissions()` context manager - the flag is set automatically on entry and reset on exit, even if an exception occurs. It is task-local, so concurrent asyncio tasks cannot interfere with each other: ```python async with paperless.documents.with_permissions() as docs: doc = await docs(42) print(doc.permissions.view.users) async for doc in docs: print(doc.owner, doc.permissions) ``` ### Alternative: `request_permissions` property For cases where you need persistent control across multiple calls, the underlying property is available directly: ```python paperless.documents.request_permissions = True doc = await paperless.documents(42) print(doc.permissions) async for doc in paperless.documents: print(doc.owner, doc.permissions) paperless.documents.request_permissions = False ``` The flag applies to all methods on the service (`__call__`, iteration, `update()`) until reset. --- ## Creating an item with permissions When drafting a new item, use `set_permissions` to assign ownership and ACLs at creation time (`SecurableDraftModel`): ```python from pypaperless.models.types import Permissions perms = Permissions( view_users=[2, 3], change_users=[2], change_groups=[1], ) draft = paperless.tags.create( name="confidential", owner=1, set_permissions=perms, ) new_id = await paperless.tags.save(draft) ``` --- ## Updating permissions on an existing item Fetch the item, modify the `permissions` field, then call `update()`. **Replace the whole permission set:** ```python from pypaperless.models.types import Permissions doc = await paperless.documents(42) doc.permissions = Permissions( view_users=[2, 3], change_users=[2], ) doc.owner = 1 await paperless.documents.update(doc) ``` **Or mutate in place** (useful when adding/removing a single user): ```python async with paperless.documents.with_permissions() as docs: doc = await docs(42) doc.permissions.view.users.append(9) doc.permissions.change.users.remove(3) await paperless.documents.update(doc) ``` --- ## `Permissions` ```python class Permissions: view: _PermissionScope # .users: list[int], .groups: list[int] change: _PermissionScope # .users: list[int], .groups: list[int] ``` Constructed with flat keyword arguments - only specify what you need, omitted keys default to `[]`: | Keyword argument | Meaning | | ---------------- | -------------------------------- | | `view_users` | User IDs with view permission | | `view_groups` | Group IDs with view permission | | `change_users` | User IDs with change permission | | `change_groups` | Group IDs with change permission | ```python from pypaperless.models.types import Permissions # Only specify what you need Permissions(view_users=[2, 3], change_users=[2], change_groups=[1]) # Read individual scopes perms = doc.permissions perms.view.users perms.view.groups perms.change.users perms.change.groups ``` --- ## Listing users and groups To populate permission assignments, you can enumerate users and groups: ```python async for user in paperless.users: print(user.id, user.username) async for group in paperless.groups: print(group.id, group.name) ``` tb1337-pypaperless-5d67d60/docs/concepts/search_query.md000066400000000000000000000101041524076126400231300ustar00rootroot00000000000000# Search Query Builder The `SearchQuery` builder provides a fluent, composable DSL for constructing Tantivy query strings for the Paperless-ngx global search endpoint (`/api/search/`). The builder output is a plain string - it is fully compatible with the raw string API. Both raw strings and builder objects are accepted by `paperless.search()`. --- ## Quick start ```python from pypaperless.models.types import SearchQuery as Q # Plain term result = await paperless.search("invoice") # Using the builder (equivalent to above) result = await paperless.search(Q("invoice")) # Field-scoped: tag:unpaid result = await paperless.search(Q.field("tag", "unpaid")) # Combined: (invoice AND tag:unpaid) q = Q("invoice") & Q.field("tag", "unpaid") result = await paperless.search(q) ``` --- ## Atoms ### Plain term ```python Q("produ*name") # wildcard Q("contract") # exact word ``` ### Field-scoped term (`field:value`) Match against a specific metadata field: ```python Q.field("document_type", "invoice") Q.field("tag", "unpaid") Q.field("correspondent", "university") Q.field("storage_path", "archive") ``` Notes and custom fields support JSON sub-field syntax for targeted queries: ```python Q.field("notes.note", "urgent") # match within note text Q.field("notes.user", "alice") # match by note author Q.field("custom_fields.value", "12345") # match custom field value Q.field("custom_fields.name", "invoice") # match custom field name ``` The field names correspond to the Paperless-ngx Tantivy index schema. ### Date range (`field:[start to end]`) ```python Q.date_range("created", "2005", "2009") # → created:[2005 to 2009] Q.date_range("added", "yesterday", "today") # → added:[yesterday to today] Q.date_range("modified", "2024-01-01", "2024-12-31") # → modified:[2024-01-01 to 2024-12-31] ``` Paperless-ngx rewrites natural-language date keywords to ISO 8601 ranges before passing them to Tantivy. Relative expressions like `today`, `yesterday`, and partial dates like `2024` are all valid. --- ## Combining expressions ### AND (`&`) All terms must match. Chaining `&` flattens into a single AND node (no extra parentheses): ```python q = Q("invoice") & Q.field("tag", "unpaid") # str(q) → "(invoice AND tag:unpaid)" q = Q("shop") & Q.field("document_type", "invoice") & Q.date_range("created", "2020", "2024") # str(q) → "(shop AND document_type:invoice AND created:[2020 to 2024])" ``` ### OR (`|`) At least one term must match. Chaining `|` also flattens: ```python q = Q.field("tag", "inbox") | Q.field("tag", "important") # str(q) → "(tag:inbox OR tag:important)" ``` ### NOT (`~`) Negates a term or expression: ```python q = ~Q.field("document_type", "letter") # str(q) → "NOT document_type:letter" ``` --- ## Complex expressions Operators compose freely: ```python from pypaperless.models.types import SearchQuery as Q # "Find invoices or receipts tagged unpaid, from 2020 onwards, that are not letters" q = ( (Q.field("document_type", "invoice") | Q.field("document_type", "receipt")) & Q.field("tag", "unpaid") & Q.date_range("created", "2020", "today") & ~Q.field("document_type", "letter") ) # str(q) → "((document_type:invoice OR document_type:receipt) AND tag:unpaid AND created:[2020 to today] AND NOT document_type:letter)" result = await paperless.search(q) ``` --- ## `str()` passthrough Calling `str()` on any builder object yields the Tantivy query string directly: ```python q = Q("contract") & Q.date_range("created", "2015", "2019") print(str(q)) # (contract AND created:[2015 to 2019]) ``` This means you can freely mix builder objects and raw strings in your code - both work with `paperless.search()`: ```python # These are equivalent: result = await paperless.search("invoice") result = await paperless.search(Q("invoice")) ``` --- ## API reference | Class / method | Output format | | ------------------------------------ | --------------- | | `SearchQuery(term)` | `term` | | `SearchQuery.field(name, value)` | `name:value` | | `SearchQuery.date_range(name, s, e)` | `name:[s to e]` | tb1337-pypaperless-5d67d60/docs/exceptions.md000066400000000000000000000203641524076126400210120ustar00rootroot00000000000000# Exceptions pypaperless raises structured exceptions so you can handle specific error conditions precisely. All exceptions inherit from `PaperlessError`, which in turn inherits from Python's built-in `Exception`. --- ## Exception hierarchy ``` PaperlessError ├── InitializationError │ ├── PaperlessConnectionError │ │ └── PaperlessTimeoutError │ ├── AuthError │ │ ├── InvalidTokenError │ │ └── InactiveOrDeletedError │ └── ForbiddenError ├── ResponseError │ ├── BadJsonResponseError │ ├── JsonResponseWithError │ ├── NotFoundError │ ├── UnexpectedStatusError │ └── BulkEditError ├── DraftError │ ├── DraftFieldRequiredError │ └── DraftNotSupportedError ├── DispatchError ├── ResourceError │ ├── DeletionError │ ├── ItemNotFoundError │ ├── PrimaryKeyRequiredError │ └── TaskNotFoundError └── DocumentError ├── AsnRequestError ├── BulkEditPagesError └── SendEmailError ``` --- ## Reference ### `PaperlessError` Base class for all pypaperless exceptions. Catch this to handle any pypaperless error generically. --- ### `InitializationError` Raised when `PaperlessClient.initialize()` fails for any reason - connectivity, authentication or authorisation. ```python from pypaperless.exceptions import InitializationError from pypaperless import PaperlessClient try: async with PaperlessClient("localhost:8000", "bad-token") as p: pass except InitializationError as exc: print("Could not initialise:", exc) ``` **Subclasses:** #### `PaperlessConnectionError` The host could not be reached (network error, wrong URL, DNS failure, broken connection mid-response, etc.). All transport-level failures raise this exception - `httpx` internals never leak through. **Subclasses:** - **`PaperlessTimeoutError`** - the request timed out. The host is reachable but did not respond in time; retrying, or passing a custom `httpx.AsyncClient` with a higher timeout, may help. #### `AuthError` The server responded with HTTP **401**. **Subclasses:** - **`InvalidTokenError`** - 401 because the token is invalid or expired. - **`InactiveOrDeletedError`** - 401 because the user account is inactive or deleted. #### `ForbiddenError` The server responded with HTTP **403** - the user is authenticated but lacks permission to access the resource. --- ### `ResponseError` Base class for exceptions caused by an unexpected or error API response. Catch this to handle all response-level failures in one place. #### `BadJsonResponseError` The API returned a response that could not be decoded as JSON. #### `JsonResponseWithError` The API accepted the request but returned an error payload in its JSON body. The exception message includes the key path and error message extracted from the payload. ```python from pypaperless.exceptions import JsonResponseWithError try: await paperless.documents.save(draft) except JsonResponseWithError as exc: print(exc) # e.g. "Paperless [document]: No file was submitted." ``` #### `NotFoundError` The server responded with HTTP **404** - the requested resource does not exist. The original `httpx.Response` is available as the `response` attribute. ```python from pypaperless.exceptions import NotFoundError try: doc = await paperless.documents(999999) except NotFoundError: print("Document does not exist.") ``` #### `UnexpectedStatusError` The server responded with a non-2xx status code that has no dedicated exception (e.g. a 5xx server error). The original `httpx.Response` is available as the `response` attribute. #### `BulkEditError` Raised when a bulk edit operation via `paperless.documents.bulk_edit` returns a non-OK result from the API. ```python from pypaperless.exceptions import BulkEditError try: await paperless.documents.bulk_edit.delete([10, 11]) except BulkEditError as exc: print(exc) # "Bulk edit operation returned a non-OK result: ..." ``` --- ### `DraftError` Base class for exceptions raised during draft lifecycle operations. Catch this to handle all draft-related failures in one place. #### `DraftFieldRequiredError` Raised by `draft.validate_draft()` (called automatically inside `save()`) when one or more required fields are missing. ```python draft = paperless.documents.create() # missing `document` field try: await paperless.documents.save(draft) except DraftFieldRequiredError as exc: print(exc) # "Missing fields for saving a `DocumentDraft`: document." ``` #### `DraftNotSupportedError` Raised when calling `create()` on a service that does not have a `_draft_cls` defined (i.e. creation is not supported for that resource). --- ### `DispatchError` Raised when `paperless.update()`, `paperless.delete()`, or `paperless.save()` is called with a model type that has no registered service. Only models managed by a CRUD service can be dispatched. --- ### `ResourceError` Base class for exceptions raised during resource access or lookup operations. Catch this to handle all resource-level failures in one place. #### `DeletionError` Raised by `delete()` when the API returns a non-2xx response. To suppress this exception, pass `silent_fail=True` to the service `delete()` method. ```python from pypaperless.exceptions import DeletionError try: await paperless.tags.delete(tag) except DeletionError as exc: print("Deletion failed:", exc) # or suppress silently: await paperless.tags.delete(tag, silent_fail=True) ``` #### `ItemNotFoundError` Raised by `DocumentCustomFieldList.get()` when the requested field is not present on the document. ```python from pypaperless.exceptions import ItemNotFoundError try: value = document.custom_fields.get(99) except ItemNotFoundError: print("Field 99 is not set on this document") ``` Use `default()` instead of `get()` to avoid this exception and receive `None` on absence. #### `PrimaryKeyRequiredError` Raised when a document-scoped sub-service is used without a resolvable document primary key — that is, neither bound via a `Document` instance nor passed as `pk`. This covers `notes`, `history`, `share_links`, `ai_suggestions`, `root` and `versions`. ```python from pypaperless.exceptions import PrimaryKeyRequiredError try: await paperless.documents.history() # no pk, no bound document except PrimaryKeyRequiredError: print("Pass the document pk: paperless.documents.history(42)") ``` #### `TaskNotFoundError` Raised when looking up a task by UUID that does not exist in Paperless-ngx. ```python from pypaperless.exceptions import TaskNotFoundError try: task = await paperless.tasks("non-existent-uuid") except TaskNotFoundError as exc: print(exc) # "Task with UUID non-existent-uuid not found." ``` --- ### `DocumentError` Base class for exceptions raised by document-specific service operations. Catch this to handle all document-level failures in one place. #### `AsnRequestError` Raised when the request for the next available archive serial number fails unexpectedly. #### `BulkEditPagesError` Raised by `documents.bulk_edit.split()` and `.delete_pages()` when the page selection cannot produce a valid PDF: no page groups, an empty group, no pages to remove, page numbers outside the document, or removing every page. Also raised when the page count has to be looked up and the document record carries none - not a PDF, or not processed yet. This one has two bases - `DocumentError` **and** `ValueError` - so both `except PaperlessError` and `except ValueError` catch it. The checks run before any request is sent. #### `SendEmailError` Raised when the API rejects an e-mail send request. --- ## Recommended error handling pattern ```python from pypaperless.exceptions import ( PaperlessConnectionError, AuthError, ForbiddenError, PaperlessError, ) from pypaperless import PaperlessClient try: async with PaperlessClient("localhost:8000", "your-token") as paperless: doc = await paperless.documents(42) except PaperlessConnectionError: print("Cannot reach the Paperless server.") except AuthError: print("Authentication failed - check your token.") except ForbiddenError: print("Access denied.") except PaperlessError as exc: print(f"Unexpected error: {exc}") ``` tb1337-pypaperless-5d67d60/docs/index.md000066400000000000000000000072531524076126400177420ustar00rootroot00000000000000# pypaperless **pypaperless** is a modern, fully async Python client library for the [Paperless-ngx](https://docs.paperless-ngx.com/) REST API. It is built on top of [httpx](https://www.python-httpx.org/) and [Pydantic](https://docs.pydantic.dev/), providing type-safe, async-first access to all Paperless-ngx resources. --- ## Installation ```bash pip install pypaperless ``` Requires **Python 3.13+**. --- ## Quick example ```python import asyncio from pypaperless import PaperlessClient async def main(): async with PaperlessClient("localhost:8000", "your-api-token") as paperless: async for document in paperless.documents: print(document.id, document.title) asyncio.run(main()) ``` --- ## Features | Feature | Description | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Fully async** | Built on `asyncio` + `httpx.AsyncClient` | | **Type-safe models** | All resources are Pydantic models | | **All resources** | Documents, Correspondents, Tags, Document Types, Storage Paths, Custom Fields, Saved Views, Share Links, Workflows, Mail Accounts/Rules, Tasks, and more | | **CRUD operations** | Create, read, update and delete on all supported resources | | **Iteration & filtering** | Async iteration, pagination and server-side filtering via `filter()` | | **Custom field system** | Rich, typed access to custom field values on documents | | **Object permissions** | Full support for Paperless-ngx object-level permissions | | **Token generation** | Helper to generate API tokens from username + password | | **Custom HTTP client** | Bring your own `httpx.AsyncClient` | --- ## Documentation - [Getting started](quickstart.md) - installation, first session, token generation - [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 - [Permissions](concepts/permissions.md) - object-level permission management - [Custom field query](concepts/custom_field_query.md) - structured `custom_field_query` filter builder - [Search query](concepts/search_query.md) - fluent builder for the global search endpoint - [Exceptions](exceptions.md) - full exception reference --- ## Links - [GitHub repository](https://github.com/tb1337/pypaperless) - [Paperless-ngx documentation](https://docs.paperless-ngx.com/) - [Paperless-ngx API reference](https://docs.paperless-ngx.com/api/) tb1337-pypaperless-5d67d60/docs/javascripts/000077500000000000000000000000001524076126400206335ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/docs/javascripts/counter.js000066400000000000000000000007071524076126400226540ustar00rootroot00000000000000(function () { var PIXEL_URL = "https://data.tbsch.de/p/N2J4VgMWJ"; function count() { fetch(PIXEL_URL, { mode: "no-cors", cache: "no-store", keepalive: true }); } // document$ replays on subscribe and re-emits after every instant // navigation, so each rendered page counts as one view. if (window.document$ && typeof window.document$.subscribe === "function") { window.document$.subscribe(count); } else { count(); } })(); tb1337-pypaperless-5d67d60/docs/migrating-v5-to-v6.md000066400000000000000000001010041524076126400221020ustar00rootroot00000000000000# Migrating from v5 to v6 ## Python version requirement raised to 3.13 v6 drops support for **Python 3.12**. The minimum required version is now **Python 3.13**. If you are still on Python 3.12, upgrade your runtime before updating pypaperless to v6. --- v6 is also almost a full rewrite of pypaperless. Four things drove it: - **Models were too tightly coupled to the HTTP layer.** In v5, every model instance carried a reference to the client and called it directly. That made testing awkward and sharing models between contexts impossible. v6 models are plain data - all I/O goes through services. - **No runtime type safety.** v5 used dataclasses with manual dict conversion, so bad API responses would silently produce wrong values. v6 uses Pydantic v2, which validates every response at parse time. - **`aiohttp` got removed.** `httpx` is modern, has a cleaner sync/async API and a built-in mock transport that makes testing easier. - **Model-level CRUD shortcuts removed — replaced by a dispatcher.** `doc.update()`, `doc.delete()`, and `draft.save()` are gone. Instead, call the operations directly on the `PaperlessClient`; the dispatcher routes to the correct service automatically: `await paperless.update(model)`, `await paperless.delete(model)`, `await paperless.save(draft)`. See [CRUD](#crud). --- ## Quick checklist | # | What to change | Section | | --- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | 1 | Replace `aiohttp` / `yarl` with `httpx` | [Dependencies](#dependencies) | | 2 | Rename class `Paperless` → `PaperlessClient`; `PaperlessConfig` → `PaperlessSettings` | [Initializing the client](#initializing-the-client) | | 3 | Constructor changed: env-var mode → `PaperlessClient.from_env()`; config mode → `PaperlessClient.from_config(cfg)` | [Initializing the client](#initializing-the-client) | | 4 | `request_api_version` removed from constructor and `PaperlessSettings` | [Initializing the client](#initializing-the-client) | | 5 | Replace `reduce()` with `filter()` - different call pattern | [Iteration and filtering](#iteration-and-filtering) | | 6 | `draft()` renamed to `create()`; model shortcuts `update()`, `delete()`, `save()` removed - use service or dispatcher | [CRUD](#crud) | | 7 | Replace `request_permissions = True` with `with_permissions()` | [Permissions](#permissions) | | 8 | Rename `doc.get_download()`, `doc.get_metadata()`, etc. - shortcuts are back | [Document convenience methods renamed](#document-convenience-methods-renamed) | | 9 | Note deletion: `note.delete()` → service call | [Document notes](#document-notes) | | 10 | `generate_api_token()` is now a module-level function, no longer a static class method | [Token generation](#token-generation) | | 11 | `Task` fields and enum values overhauled; `run()` signature changed; new `active()` and `summary()` methods | [Changed resources](#changed-resources) | | 12 | New: `profile`, `trash`, `search`, `share_link_bundles`, `bulk_edit_objects`, six `documents.*` sub-services | [New resources](#new-resources) | | 13 | Rename four `Paperless`-prefixed exception classes; new `DeletionError`, `DispatchError` | [Error handling](#error-handling) | | 14 | HTTP error statuses now raise typed exceptions: 404 → `NotFoundError`, other non-2xx → `UnexpectedStatusError` | [Error handling](#http-error-statuses-raise-typed-exceptions) | --- ## Dependencies Replace `aiohttp` and `yarl` with `httpx`: === "v5" ```toml dependencies = ["aiohttp", "yarl", "pypaperless"] ``` === "v6" ```toml dependencies = ["httpx", "pypaperless"] ``` --- ## Initializing the client ### Class renamed The client class and settings class were renamed: | v5 | v6 | | ----------------- | ------------------- | | `Paperless` | `PaperlessClient` | | `PaperlessConfig` | `PaperlessSettings` | Update all imports and type annotations accordingly. ### Constructor signature changed In v5, the constructor accepted `url`, `token`, and `request_api_version`. In v6 the constructor only accepts `url`, `token`, and `client`. Environment-variable and config-object modes are now explicit factory class methods. === "v5" ```python import aiohttp paperless = Paperless("http://localhost:8000", "mytoken") paperless = Paperless("http://localhost:8000", "mytoken", session=my_session) paperless = Paperless("http://localhost:8000", "mytoken", request_args={"ssl": False}) ``` === "v6" ```python import httpx from pypaperless import PaperlessClient paperless = PaperlessClient("http://localhost:8000", "mytoken") paperless = PaperlessClient("http://localhost:8000", "mytoken", client=my_httpx_client) ``` !!! 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 = PaperlessClient("http://localhost:8000", "mytoken", client=client) ``` The `url` parameter no longer accepts `yarl.URL` objects - pass a plain string. ### `PaperlessSettings` and factory class methods v6 exposes `PaperlessSettings` (backed by `pydantic-settings`) and two factory class methods. **Config object** - useful when you want to construct or validate settings in one place: === "v5" ```python from pypaperless import Paperless, PaperlessConfig cfg = PaperlessConfig(url="http://localhost:8000", token="mytoken") paperless = Paperless(config=cfg) ``` === "v6" ```python from pypaperless import PaperlessClient, PaperlessSettings cfg = PaperlessSettings(url="http://localhost:8000", token="mytoken") paperless = PaperlessClient.from_config(cfg) ``` **Environment variables** - use the `from_env()` factory; `PaperlessSettings` reads the values automatically: === "v5" ```python paperless = Paperless() # zero-arg constructor read env vars ``` === "v6" ```python paperless = PaperlessClient.from_env() ``` | Environment variable | Maps to | | -------------------- | --------------------------------- | | `PYPAPERLESS_URL` | URL of the Paperless-ngx instance | | `PYPAPERLESS_TOKEN` | API token | !!! note `PYPAPERLESS_REQUEST_API_VERSION` was removed. API version is now negotiated automatically from the server's `x-api-version` response header. --- ## Token generation `generate_api_token()` is now a **module-level function** importable from `pypaperless`, no longer a static method on `PaperlessClient`. The optional custom-client argument was also renamed from `session` to `client`. === "v5" ```python token = await Paperless.generate_api_token(url, username, password, session=my_session) ``` === "v6" ```python from pypaperless import generate_api_token token = await generate_api_token(url, username, password, client=my_client) ``` --- ## Iteration and filtering `reduce()` was replaced by `filter()`. The key difference: the context manager now **yields the service object**, so you iterate over `ctx` instead of reusing the outer service name. === "v5" ```python async with paperless.documents.reduce(title__icontains="invoice"): async for doc in paperless.documents: print(doc.title) ``` === "v6" ```python async with paperless.documents.filter(title__icontains="invoice") as ctx: async for doc in ctx: print(doc.title) ``` `pages()` is available and returns enhanced `Page` objects with `.items`, `.current_page`, `.last_page`, and more. === "v5" ```python async for page in paperless.documents.pages(page_size=25): for doc in page: print(doc.title) ``` === "v6" ```python async for page in paperless.documents.pages(page_size=25): print(f"Page {page.current_page} of {page.last_page}") for doc in page: print(doc.title) ``` You can also use the convenience helpers: ```python docs = await paperless.documents.as_list() dmap = await paperless.documents.as_dict() # {pk: Document} ``` !!! note "Removed in API v10" `all()` — which returned a flat list of primary keys — was removed together with the `all` field in paginated API responses (Paperless-ngx API v10+). To get a list of primary keys, use `as_list()` and extract `.id`: ```python ids = [doc.id for doc in await paperless.documents.as_list()] ``` --- ## CRUD In v5, CRUD operations lived on model instances. v6 moves the canonical API to the **service** level. Model-level shortcuts (`doc.update()`, `doc.delete()`, `draft.save()`) have been removed. !!! tip "New in v6: client-level dispatcher" Instead of calling the operation on a specific service, you can call it directly on the `PaperlessClient` instance. The **dispatcher** automatically routes to the correct service based on the model type — no need to know which service owns the model: ```python doc = await paperless.documents(42) doc.title = "New Title" await paperless.update(doc) # routes to DocumentService.update() await paperless.delete(doc) # routes to DocumentService.delete() draft = paperless.tags.create(name="urgent") pk = await paperless.save(draft) # routes to TagService.save() ``` This works for all dispatchable resources: documents, document notes, correspondents, document types, storage paths, tags, share links, share link bundles, and custom fields. v6 provides two equivalent ways to perform CRUD: - **Service-level** — call the operation on the service that owns the model type. - **Client-level dispatcher** — call `await paperless.update(model)` / `await paperless.delete(model)` / `await paperless.save(draft)` directly on the client; the dispatcher resolves the responsible service automatically. !!! tip "`id` is no longer optional" Resource models fetched from the API now type their `id` as a required `int` instead of `int | None`. Guards like `if doc.id is not None` are dead code in v6, and expressions such as `my_dict[doc.id]` type-check without casts. ### update() === "v5" ```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" await paperless.documents.update(doc) ``` === "v6 (dispatcher)" ```python doc = await paperless.documents(42) doc.title = "New Title" await paperless.update(doc) # resolves to DocumentService automatically ``` !!! warning "Assignments are validated" In v6, assigning a value to a model field validates (and coerces) it immediately - invalid values raise a `pydantic.ValidationError` at the assignment site instead of failing later inside `update()` with a server error: ```python doc.created = "2024-08-13" # coerced to datetime.date(2024, 8, 13) doc.created = "not a date" # ValidationError - right here ``` ### delete() `delete()` no longer returns a boolean. It raises `DeletionError` on failure (or swallows it when `silent_fail=True`). === "v5" ```python doc = await paperless.documents(42) await doc.delete() ``` === "v6 (service)" ```python doc = await paperless.documents(42) await paperless.documents.delete(doc) # raises DeletionError on failure ``` === "v6 (dispatcher)" ```python doc = await paperless.documents(42) await paperless.delete(doc) ``` This applies to every resource - correspondents, tags, custom fields, etc. ### save() / create() `draft()` was renamed to `create()`. The model-level `draft.save()` shortcut was removed; use the service or the dispatcher instead. === "v5" ```python draft = paperless.documents.draft(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) ``` === "v6 (dispatcher)" ```python draft = paperless.documents.create(document=raw_bytes, title="Invoice") task_id = await paperless.save(draft) ``` For all other resources (correspondents, tags, …): === "v5" ```python draft = paperless.tags.draft(name="urgent") pk = await draft.save() ``` === "v6 (service)" ```python draft = paperless.tags.create(name="urgent") pk = await paperless.tags.save(draft) ``` === "v6 (dispatcher)" ```python draft = paperless.tags.create(name="urgent") pk = await paperless.save(draft) ``` !!! warning "Draft models reject unknown fields" In v6, `create()` raises a `pydantic.ValidationError` for unknown keyword arguments instead of silently dropping them: ```python paperless.tags.create(tag_name="urgent") # field is called "name" # ValidationError: Extra inputs are not permitted ``` v5 ignored such typos, which usually surfaced much later as a confusing "missing field" error - or not at all. --- ## Permissions The mutable `request_permissions` setter was replaced by a `with_permissions()` context manager. The flag is now automatically reset on exit. === "v5" ```python paperless.documents.request_permissions = True doc = await paperless.documents(42) print(doc.owner, doc.permissions) paperless.documents.request_permissions = False ``` === "v6" ```python async with paperless.documents.with_permissions() as ctx: doc = await ctx(42) print(doc.owner, doc.permissions) async for doc in ctx: print(doc.owner, doc.permissions) ``` !!! note Unlike v5, `with_permissions()` resets it automatically on exit, even if an exception occurs. --- ## Document convenience methods renamed The `get_*` shortcut methods that existed in v5 on `Document` instances are **fully removed** in v6. All file and metadata access now goes through the service: | v5 (on model instance) | v6 | | --------------------------------------- | ----------------------------------------------------------- | | `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_preview()` | `await paperless.documents.preview(doc.id)` | | `await doc.get_thumbnail()` | `await paperless.documents.thumbnail(doc.id)` | | `await doc.get_metadata()` | `await paperless.documents.metadata(doc.id)` | | `await doc.get_suggestions()` | `await paperless.documents.suggestions(doc.id)` | | *(not available)* | `async for d in paperless.documents.more_like(doc.id):` | | *(not available)* | `await paperless.documents.email(doc.id, ...)` | **Sub-service shortcuts** remain available via bound sub-service properties on the `Document` instance - six of them in v6: | Access | Equivalent | | ---------------------------- | -------------------------------------------------- | | `await doc.notes()` | `await paperless.documents.notes(doc.id)` | | `await doc.history()` | `await paperless.documents.history(doc.id)` | | `await doc.share_links()` | `await paperless.documents.share_links(doc.id)` | | `await doc.ai_suggestions()` | `await paperless.documents.ai_suggestions(doc.id)` | | `await doc.root()` | `await paperless.documents.root(doc.id)` | | `doc.versions.upload(f)` | `paperless.documents.versions.upload(f, pk=doc.id)` | --- ## Document notes Note deletion moved from the model to the service, consistent with the general CRUD pattern. The model-level `note.delete()` shortcut was removed. === "v5" ```python notes = await paperless.documents.notes(42) for note in notes: await note.delete() ``` === "v6" ```python notes = await paperless.documents.notes(42) for note in notes: await paperless.documents.notes.delete(note) ``` The `doc.notes` property on `Document` instances still exposes a bound `DocumentNoteService`: Creating a new note: === "v5" ```python draft = paperless.documents.notes.draft(note="Checked.", document=42) note_id, doc_id = await draft.save() ``` === "v6" ```python doc = await paperless.documents(42) draft = doc.notes.create(note="Checked.") note_id = await doc.notes.save(draft) ``` !!! 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 = await paperless.documents.notes.save(draft) ``` !!! note `save()` now returns only the new note `id` as `int`. In v5 it returned a `(note_id, doc_id)` tuple. --- ## Changed resources ### `paperless.tasks` The `Task` model was significantly overhauled for Paperless-ngx API v10. The `type` field was renamed to `task_type`, and `task_name`, `task_file_name`, and `result` were removed. The single `related_document` integer was replaced by `related_document_ids` (a list of integers). New fields include `task_type_display`, `trigger_source`, `trigger_source_display`, `status_display`, `date_started`, `duration_seconds`, `wait_time_seconds`, `input_data`, and `result_data`. The `TaskType` enum values changed entirely — the old high-level categories (`AUTO`, `SCHEDULED`, `MANUAL`) are gone and replaced with specific task-type names such as `CONSUME_FILE`, `SANITY_CHECK`, `MAIL_FETCH`, and others. The `TaskStatus` values changed from uppercase strings to lowercase; the `RECEIVED` and `RETRY` statuses were removed. A new `TaskTriggerSource` enum was introduced to express how a task was initiated (e.g. `API_UPLOAD`, `FOLDER_CONSUME`, `EMAIL_CONSUME`). #### `filter()` is now a context manager Consistent with the general `filter()` change [described above](#iteration-and-filtering), `tasks.filter()` now returns a context manager: === "v5" ```python async for task in paperless.tasks.filter(status="SUCCESS", acknowledged=False): print(task.task_id) ``` === "v6" ```python async with paperless.tasks.filter(status=["success"], acknowledged=False) as ctx: async for task in ctx: print(task.task_id) ``` #### `run()` changed signature and return type In v5, `run()` accepted a Celery UUID and returned a `Task`. In v6 it accepts a `TaskType` (or plain string) and schedules a fresh background task, returning the new Celery UUID as a string: === "v5" ```python rerun = await paperless.tasks.run("a1b2c3d4-...") print(rerun.status) # returned a Task ``` === "v6" ```python from pypaperless.models.tasks import TaskType task_uuid = await paperless.tasks.run(TaskType.SANITY_CHECK) # returns str UUID task = await paperless.tasks(task_uuid) ``` #### New methods Two new service methods were added: - `active()` — iterates over currently pending and running tasks (capped at 50 server-side). - `summary()` — returns a list of `TaskSummary` objects with aggregated statistics (counts, durations, last run) per task type, optionally scoped to a rolling time window via the `days` parameter. ```python async for task in paperless.tasks.active(): print(task.task_id, task.status) summaries = await paperless.tasks.summary(days=7) for s in summaries: print(s.task_type, s.success_count, s.failure_count) ``` --- ## New resources Five new top-level services were added - `profile`, `trash`, `search`, `share_link_bundles` and `bulk_edit_objects` - plus six new document sub-services: `history`, `bulk_edit`, `chat`, `ai_suggestions`, `versions` and `root`. ### `paperless.profile` Access the currently authenticated user's own profile: ```python profile = await paperless.profile() print(profile.first_name, profile.email) ``` See [Profile](resources/profile.md) for details. ### `paperless.trash` Browse and manage soft-deleted documents: ```python async for doc in paperless.trash: print(doc.id, doc.title, doc.deleted_at) await paperless.trash.restore([42, 43]) await paperless.trash.empty() ``` The `Document.is_deleted` property returns `True` for documents retrieved from the trash. See [Trash](resources/trash.md) for details. ### `paperless.search` Query the global search endpoint, which matches across documents, tags, correspondents, document types and more in a single call: ```python from pypaperless.models.types import SearchQuery as Q result = await paperless.search(Q("invoice") & Q.field("tag", "unpaid")) print(result.total) for doc in result.documents or []: print(doc.title) ``` See [Search](resources/search.md) and [Search Query](concepts/search_query.md) for details. ### `paperless.share_link_bundles` Group several documents into one shareable archive. Supports full CRUD plus a `rebuild()` action: ```python from pypaperless.models.types import ShareLinkFileVersion draft = paperless.share_link_bundles.create( document_ids=[42, 43], file_version=ShareLinkFileVersion.ARCHIVE, ) bundle_id = int(await paperless.share_link_bundles.save(draft)) bundle = await paperless.share_link_bundles.rebuild(bundle_id) print(bundle.status) ``` See [Share Link Bundles](resources/share_link_bundles.md) for details. !!! note `paperless.share_links` itself is **not** new - it already existed in v5 with full CRUD. Only the v6 CRUD call pattern changed, as described under [CRUD](#crud). ### `paperless.documents.history` A new document audit-log sub-service: ```python entries = await paperless.documents.history(42) for entry in entries: print(entry.actor, entry.timestamp, entry.action) ``` ### `paperless.documents.bulk_edit` A new sub-service for performing bulk operations across many documents in a single API call. All operations accept a list of document primary keys. ```python # Assign metadata to multiple documents at once await paperless.documents.bulk_edit.set_correspondent([1, 2, 3], 5) await paperless.documents.bulk_edit.set_document_type([1, 2], 3) await paperless.documents.bulk_edit.set_storage_path([1, 2], 4) # Tag operations await paperless.documents.bulk_edit.add_tag([1, 2, 3], 7) await paperless.documents.bulk_edit.remove_tag([1, 2, 3], 7) await paperless.documents.bulk_edit.modify_tags([1, 2], add_tags=[5], remove_tags=[2]) # Custom fields await paperless.documents.bulk_edit.modify_custom_fields( [1, 2], add_custom_fields={3: "open"}, remove_custom_fields=[4], ) # Permissions from pypaperless.models.types import Permissions await paperless.documents.bulk_edit.set_permissions( [1, 2, 3], owner=1, permissions=Permissions(view_users=[2, 3], change_users=[1]), ) # Document operations await paperless.documents.bulk_edit.delete([10, 11]) # move to trash await paperless.documents.bulk_edit.reprocess([1, 2, 3]) # re-run OCR await paperless.documents.bulk_edit.rotate([1, 2], 90) await paperless.documents.bulk_edit.merge( [10, 11, 12], metadata_document_id=10, delete_originals=True, ) # PDF page operations and password removal await paperless.documents.bulk_edit.edit_pdf(42, [{"page": 1, "rotate": 90}]) await paperless.documents.bulk_edit.split(42, [[1, 2], [3]]) await paperless.documents.bulk_edit.delete_pages(42, [2, 4]) await paperless.documents.bulk_edit.remove_password([5, 6], password="secret") ``` Raises `BulkEditError` (a `ResponseError` subclass) when the API returns a non-OK result. ### `paperless.documents.chat` An LLM-backed chat query, optionally scoped to one document. Requires AI to be enabled on the Paperless-ngx side (`config.ai_enabled`): ```python response = await paperless.documents.chat("What is this invoice about?", 42) print(response.q) ``` Note that the answer itself is streamed server-side — the parsed response only echoes the query. See [Chat](concepts/documents.md#chat). ### `paperless.documents.ai_suggestions` AI-generated classifier suggestions, distinct from the rule-based `documents.suggestions`: ```python result = await paperless.documents.ai_suggestions(42) print(result.title, result.suggested_tags, result.suggested_correspondents) ``` ### `paperless.documents.versions` and `paperless.documents.root` Documents can now carry multiple file versions: ```python with open("updated.pdf", "rb") as fh: await paperless.documents.versions.upload(fh, version_label="v2", pk=42) await paperless.documents.versions.update(1, version_label="final", pk=42) await paperless.documents.versions.delete(1, pk=42) root = await paperless.documents.root(42) print(root.root_id) ``` See the [Documents concept page](concepts/documents.md#document-versions) for details. ### `paperless.bulk_edit_objects` A new top-level service for setting permissions or deleting multiple non-document objects - tags, correspondents, document types, or storage paths - in a single call. ```python from pypaperless.models.types import Permissions # Set permissions on several tags await paperless.bulk_edit_objects.set_permissions( "tags", [1, 2, 3], owner=1, permissions=Permissions(view_users=[2], change_users=[1]), ) # Permanently delete correspondents await paperless.bulk_edit_objects.delete("correspondents", [4, 5]) ``` Transport-level failures raise the standard errors (e.g. `JsonResponseWithError` for an HTTP 400, `UnexpectedStatusError` for other non-2xx responses); unlike `paperless.documents.bulk_edit`, this service does not raise `BulkEditError`. See [Bulk Edit Objects](resources/bulk_edit_objects.md) for details. --- ## Custom field values `DocumentCustomFieldList` is now a pydantic `RootModel` and the typed value classes (`CustomFieldBooleanValue`, `CustomFieldDateValue`, …) are resolved through a discriminated union on `data_type`. The container API (`add`, `remove`, `get`, `default`, `in`, iteration, `+=`/`-=`) is unchanged. Three internals moved: - `CUSTOM_FIELD_TYPE_VALUE_MAP` was removed - use `isinstance()` checks against the typed value classes, or the `AnyCustomFieldValue` union from `pypaperless.models.types`. - The `.data` property (raw payload) was removed - the validated items live in `.root`, or just iterate the container. - `model_dump()` of a `CustomFieldValue` now emits only `field` and `value` - `name`, `data_type`, and `extra_data` are client-side metadata and are excluded from serialization. --- ## Error handling `PaperlessConnectionError` is now raised for every transport-level failure (connection refused, DNS failure, broken connection mid-response) rather than `aiohttp.ClientConnectorError`. Timeouts raise the more specific `PaperlessTimeoutError`, a subclass of `PaperlessConnectionError`. If you catch library-specific exceptions, update accordingly: === "v5" ```python except aiohttp.ClientConnectorError: ... ``` === "v6" ```python except PaperlessTimeoutError: ... # host alive but slow - consider retrying except PaperlessConnectionError: ... # host not reachable ``` !!! tip `PaperlessConnectionError` works in both v5 and v6 - catching it is the most forward-compatible option. In v6 no `httpx` exception ever leaks through, so catching `httpx.ConnectError` or `httpx.ReadTimeout` is unnecessary. ### HTTP error statuses raise typed exceptions In v5, requests failing with an HTTP error status leaked the HTTP library's own exception (`aiohttp.ClientResponseError`). v6 translates every non-2xx response into a pypaperless exception, so you never need to catch `httpx` exceptions: | Status | v6 exception | | ------------------------ | ---------------------------------------------- | | 400 (with JSON body) | `JsonResponseWithError` | | 401 | `InvalidTokenError` / `InactiveOrDeletedError` | | 403 | `ForbiddenError` | | 404 | `NotFoundError` | | any other non-2xx status | `UnexpectedStatusError` | `NotFoundError` and `UnexpectedStatusError` expose the original `httpx.Response` via their `response` attribute. === "v5" ```python import aiohttp try: doc = await paperless.documents(999999) except aiohttp.ClientResponseError as exc: if exc.status == 404: print("Document does not exist.") ``` === "v6" ```python from pypaperless.exceptions import NotFoundError try: doc = await paperless.documents(999999) except NotFoundError: print("Document does not exist.") ``` ### 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`. | v5 | v6 | | --------------------------------- | ------------------------ | | `PaperlessAuthError` | `AuthError` | | `PaperlessInvalidTokenError` | `InvalidTokenError` | | `PaperlessInactiveOrDeletedError` | `InactiveOrDeletedError` | | `PaperlessForbiddenError` | `ForbiddenError` | === "v5" ```python from pypaperless.exceptions import PaperlessAuthError, PaperlessForbiddenError except PaperlessAuthError: ... except PaperlessForbiddenError: ... ``` === "v6" ```python from pypaperless.exceptions import AuthError, ForbiddenError except AuthError: ... except ForbiddenError: ... ``` ### New exception base classes v6 introduces intermediate base classes that you can use to catch whole groups of related errors: | Class | Catches | | --------------------- | ---------------------------------------------------------------------------------------------------------- | | `InitializationError` | All session/transport errors (unchanged from v5) | | `ResponseError` | `BadJsonResponseError`, `JsonResponseWithError`, `NotFoundError`, `UnexpectedStatusError`, `BulkEditError` | | `DraftError` | `DraftFieldRequiredError`, `DraftNotSupportedError` | | `ResourceError` | `DeletionError`, `ItemNotFoundError`, `PrimaryKeyRequiredError`, `TaskNotFoundError` | | `DocumentError` | `AsnRequestError`, `SendEmailError` | ### New exceptions | Exception | When raised | | ----------------------- | ------------------------------------------------------------------------ | | `DeletionError` | `delete()` call receives a non-2xx HTTP response | | `DispatchError` | `update()` / `delete()` / `save()` called on an unregistered model type | | `NotFoundError` | The requested resource does not exist (HTTP 404) | | `UnexpectedStatusError` | The API responds with an unhandled non-2xx status code (e.g. 5xx errors) | tb1337-pypaperless-5d67d60/docs/quickstart.md000066400000000000000000000064721524076126400210270ustar00rootroot00000000000000# Getting started This page walks you through the first steps with **pypaperless**: installation, creating a session and obtaining an API token. --- ## Installation Install from PyPI: ```bash pip install pypaperless ``` Requires **Python 3.13** or higher. --- ## Your first session Import the `PaperlessClient` class and create an instance with your Paperless-ngx host and API token. pypaperless is fully **async**. All API calls must be made inside an `async` function. ### Context manager (recommended) The cleanest way to use pypaperless is as an async context manager. It calls `initialize()` and `close()` automatically. ```python import asyncio from pypaperless import PaperlessClient async def main(): async with PaperlessClient("localhost:8000", "your-api-token") as paperless: # paperless is now ready to use async for document in paperless.documents: print(document.id, document.title) asyncio.run(main()) ``` ### Manual lifecycle You can also manage the lifecycle manually by calling `initialize()` and `close()` yourself: ```python async def main(): paperless = PaperlessClient("localhost:8000", "your-api-token") await paperless.initialize() # do something... await paperless.close() ``` ### Configuration via environment variables Instead of hard-coding credentials you can set environment variables and call `PaperlessClient.from_env()`: ```bash export PYPAPERLESS_URL=https://paperless.example.com export PYPAPERLESS_TOKEN=your-api-token ``` ```python from pypaperless import PaperlessClient async with PaperlessClient.from_env() as paperless: ... ``` See [Session management](session.md#configuration-modes) for all three configuration modes. --- ## URL formats The `url` parameter accepts a variety of formats. pypaperless normalises the URL automatically. | Input | Resolved to | | --------------------------------- | ------------------------------- | | `"localhost:8000"` | `https://localhost:8000` | | `"http://192.168.1.10:8000"` | `http://192.168.1.10:8000` | | `"https://paperless.example.com"` | `https://paperless.example.com` | !!! note When no scheme is provided, `https://` is assumed. --- ## Generating an API token If you don't have a token yet, you can generate one from a username and password using the `generate_api_token` helper function: ```python from pypaperless import generate_api_token, PaperlessClient token = await generate_api_token( "localhost:8000", username="admin", password="secret", ) paperless = PaperlessClient("localhost:8000", token) ``` !!! warning `generate_api_token` sends credentials as plain JSON. Do not use this in production environments or automated pipelines where security matters. --- ## Checking connection status After `initialize()`, you can inspect the host: ```python from pypaperless import PaperlessClient async with PaperlessClient("localhost:8000", "your-api-token") as paperless: print(paperless.is_initialized) # True print(paperless.host_version) # e.g. "3.0.0" print(paperless.host_api_version) # e.g. 10 ``` --- ## Next steps - [Session management](session.md) - custom HTTP clients, advanced lifecycle - [Resources](resources.md) - learn about all available resources and CRUD operations tb1337-pypaperless-5d67d60/docs/resources.md000066400000000000000000000230501524076126400206360ustar00rootroot00000000000000# Resources Every Paperless-ngx entity is exposed through a **service** on the `PaperlessClient` instance. Each service supports a consistent set of operations - fetch, iterate, create, update or delete. --- ## Capability matrix | Resource | `call` | `iterate` | `create`/`save` | `update` | `delete` | `permissions` | | ------------------- | :----: | :-------: | :-------------: | :------: | :------: | :-----------: | | `bulk_edit_objects` | | | | | | | | `config` | ✓ | | | | | | | `correspondents` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `custom_fields` | ✓ | ✓ | ✓ | ✓ | ✓ | | | `documents` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `document_types` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `groups` | ✓ | ✓ | | | | | | `mail_accounts` | ✓ | ✓ | | | | ✓ | | `mail_rules` | ✓ | ✓ | | | | ✓ | | `processed_mail` | ✓ | ✓ | | | | owner | | `profile` | ✓ | | | ✓ | | | | `saved_views` | ✓ | ✓ | | | | ✓ | | `share_link_bundles`| ✓ | ✓ | ✓ | ✓ | ✓ | | | `share_links` | ✓ | ✓ | ✓ | ✓ | ✓ | | | `statistics` | ✓ | | | | | | | `remote_version` | ✓ | | | | | | | `status` | ✓ | | | | | | | `storage_paths` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `tags` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | `tasks` | ✓ | ✓ | | | | | | `trash` | | ✓ | | | | | | `users` | ✓ | ✓ | | | | | | `workflows` | ✓ | ✓ | | | | | !!! note "Bulk-action services" `bulk_edit_objects` exposes bulk `set_permissions` and `delete` for tags, correspondents, document types and storage paths. `documents.bulk_edit` exposes bulk operations (set metadata, tags, custom fields, permissions, delete, reprocess, rotate, merge, edit PDF pages, split, delete pages, remove PDF passwords) for documents. Neither service follows the standard CRUD pattern above. See [Bulk Edit Objects](resources/bulk_edit_objects.md) and the [Documents concept page](concepts/documents.md#bulk-editing) for details. !!! note "`permissions` column" A ✓ means the resource model carries the full `owner` / `user_can_change` / `permissions` set and the service offers `with_permissions()`. `processed_mail` is the one exception: it carries `owner` only, with no permission table. --- ## Fetching a single item Call the service with a primary key to retrieve one item: ```python document = await paperless.documents(42) correspondent = await paperless.correspondents(7) tag = await paperless.tags(3) ``` ### Lazy loading Pass `lazy=True` to create a model instance without making any HTTP request. Useful when you only need the primary key reference: ```python doc = await paperless.documents(42, lazy=True) # doc.id == 42, but no data was fetched ``` --- ## Iterating over all items Use `async for` to iterate over every item in a resource. pypaperless handles pagination automatically: ```python async for correspondent in paperless.correspondents: print(correspondent.id, correspondent.name) async for tag in paperless.tags: print(tag.name, tag.color) ``` ### Pagination You can iterate page-by-page instead of item-by-item. `Page` objects provide metadata about the current page: ```python async for page in paperless.documents.pages(): print(f"Page {page.current_page} of {page.last_page} ({page.count} total)") for doc in page: print(doc.title) ``` Control the starting page and page size: ```python async for page in paperless.documents.pages(page=3, page_size=25): ... ``` --- ## Filtering with `filter()` `filter()` is an async context manager that applies server-side filters to iteration. Filter keys are fully type-checked - your IDE will autocomplete available parameters and flag unknown keys. Each service exposes its own typed filter set (e.g. `DocumentFilters`, `TagFilters`). Import them from `pypaperless.models.filters` to construct the dict separately: ```python from pypaperless.models.filters import DocumentFilters filters: DocumentFilters = { "correspondent__id": 3, "title__icontains": "invoice", } async with paperless.documents.filter(**filters) as ctx: async for document in ctx: ... ``` You can also pass filters directly as keyword arguments: ```python async with paperless.documents.filter(title__icontains="invoice") as ctx: async for document in ctx: print(document.title) ``` The filter context is automatically cleared when the `async with` block exits. Filters are task-local - concurrent asyncio tasks filtering the same service do not interfere with each other. !!! note "Resources without a typed filter set" `saved_views`, `mail_accounts`, `mail_rules`, `trash` and `workflows` (including `workflows.triggers` and `workflows.actions`) have no filter TypedDict of their own, so `filter()` accepts no keys there. Iterate them directly and narrow the result client-side. For `trash` that is a server-side limitation rather than a gap: `/api/trash/` declares no query parameters and silently returns the full trash when filters are passed. --- ## Creating items Use `create()` to construct a new draft model and `save()` to persist it: ```python from pypaperless.models.types import MatchingAlgorithm # Create a new tag draft = paperless.tags.create( name="important", color="#ff0000", is_inbox_tag=False, match="", matching_algorithm=MatchingAlgorithm.AUTO, is_insensitive=True, ) new_id = await paperless.tags.save(draft) print(f"Created tag with id {new_id}") ``` ```python # Create a new correspondent draft = paperless.correspondents.create( name="ACME Corp", match="", matching_algorithm=MatchingAlgorithm.AUTO, is_insensitive=True, ) new_id = await paperless.correspondents.save(draft) ``` You can also save via the client-level dispatcher — no need to know which service owns the draft: ```python new_id = await paperless.save(draft) ``` !!! note `save()` returns the integer `id` of the created resource for most resource types, or a `task_id` string when uploading a document. For required fields, see the model reference in the source code. If required fields are missing, `DraftFieldRequiredError` is raised. --- ## Updating items Modify fields on a fetched model and call `update()`: ```python tag = await paperless.tags(3) tag.name = "urgent" tag.color = "#ff0000" updated = await paperless.tags.update(tag) print(f"Updated: {updated}") # True if any field changed ``` `update()` returns `True` if any field was changed and sent to the API, `False` if no fields differed from the stored state. The model is refreshed in-place after a successful update. By default, only changed fields are sent via `PATCH`. Pass `only_changed=False` to replace all fields via `PUT`: ```python await paperless.tags.update(tag, only_changed=False) ``` You can also update via the client-level dispatcher: ```python await paperless.update(tag) ``` --- ## Deleting items ```python tag = await paperless.tags(3) await paperless.tags.delete(tag) ``` `delete()` raises `DeletionError` when the deletion fails. To silently ignore a failed deletion, pass `silent_fail=True`: ```python await paperless.tags.delete(tag, silent_fail=True) ``` You can also delete via the client-level dispatcher: ```python await paperless.delete(tag) ``` --- ## Model fields: matching & algorithms Resources like `Correspondent`, `Tag`, `DocumentType` and `StoragePath` include matching fields inherited from `MatchingFieldsModel`: | Field | Description | | -------------------- | -------------------------- | | `match` | The matching pattern | | `matching_algorithm` | How the pattern is applied | | `is_insensitive` | Case-insensitive matching | **`MatchingAlgorithm` values:** | Value | Meaning | | ------------- | ----------------------------- | | `NONE` (0) | No automatic matching | | `ANY` (1) | Match any word | | `ALL` (2) | Match all words | | `LITERAL` (3) | Exact literal match | | `REGEX` (4) | Regular expression | | `FUZZY` (5) | Fuzzy matching | | `AUTO` (6) | Automatic (Paperless decides) | tb1337-pypaperless-5d67d60/docs/resources/000077500000000000000000000000001524076126400203145ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/docs/resources/bulk_edit_objects.md000066400000000000000000000042471524076126400243200ustar00rootroot00000000000000# Bulk Edit Objects The `bulk_edit_objects` service lets you set permissions or permanently delete multiple non-document objects - tags, correspondents, document types or storage paths - in a single API call. It does not support the standard CRUD lifecycle (no `call`, `iterate`, `create`, `update`). It is a write-only bulk-action endpoint. ## Set permissions Assign an owner and/or view/change grants to a list of objects: ```python from pypaperless.models.types import Permissions await paperless.bulk_edit_objects.set_permissions( "tags", [1, 2, 3], owner=1, permissions=Permissions(view_users=[2, 3], change_users=[1]), ) ``` To merge with existing permissions instead of replacing them: ```python await paperless.bulk_edit_objects.set_permissions( "correspondents", [4, 5], owner=1, merge=True, ) ``` **Supported `object_type` values:** `"tags"`, `"correspondents"`, `"document_types"`, `"storage_paths"`. Parameters: | Parameter | Type | Description | | ------------- | ----------------------- | ---------------------------------------------------- | | `object_type` | `str` | Resource type (see above) | | `objects` | `list[int]` | Primary keys of objects to operate on | | `owner` | `int` or `None` | New owner user ID. `None` leaves the owner unchanged | | `permissions` | `Permissions` or `None` | View/change grants. `None` leaves them unchanged | | `merge` | `bool` | `True` merges; `False` (default) replaces | ## Delete Permanently delete a list of objects: ```python await paperless.bulk_edit_objects.delete("correspondents", [4, 5]) await paperless.bulk_edit_objects.delete("tags", [7, 8, 9]) ``` !!! warning This is a permanent deletion - objects cannot be recovered. Parameters: | Parameter | Type | Description | | ------------- | ----------- | --------------------------------- | | `object_type` | `str` | Resource type (see above) | | `objects` | `list[int]` | Primary keys of objects to delete | tb1337-pypaperless-5d67d60/docs/resources/config.md000066400000000000000000000012231524076126400221010ustar00rootroot00000000000000# Config The `config` resource exposes the Paperless-ngx application configuration. It is a single-item resource - the instance is always fetched with primary key `1`. ## Model See [`pypaperless/models/config.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/config.py) for all fields and types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch Config is a singleton — call without arguments: ```python config = await paperless.config() print(config.language) # e.g. "deu" print(config.barcodes_enabled) # True / False print(config.app_title) # e.g. "Paperless-ngx" ``` tb1337-pypaperless-5d67d60/docs/resources/correspondents.md000066400000000000000000000046151524076126400237140ustar00rootroot00000000000000# Correspondents Correspondents represent the senders or recipients that Paperless-ngx associates with documents. They support the full CRUD lifecycle and permission management. ## Models See [`pypaperless/models/correspondents.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/correspondents.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python correspondent = await paperless.correspondents(7) print(correspondent.name) # "ACME Corp" print(correspondent.document_count) # 42 ``` ## Iterate ```python async for c in paperless.correspondents: print(c.id, c.name) # Collect everything into a list all_correspondents = await paperless.correspondents.as_list() # Fetch only a subset matching a filter filtered = [c async for c in paperless.correspondents if c.document_count and c.document_count > 0] ``` ## Create `save()` calls `validate_draft()` first — all of `name`, `match`, `matching_algorithm` and `is_insensitive` are required and raise `DraftFieldRequiredError` if missing. ```python from pypaperless.models.types import MatchingAlgorithm draft = paperless.correspondents.create( name="ACME Corp", match="", matching_algorithm=MatchingAlgorithm.AUTO, is_insensitive=True, ) pk = await paperless.correspondents.save(draft) print(pk) # primary key of the new correspondent ``` ## Update ```python c = await paperless.correspondents(7) c.name = "ACME Corp (renamed)" changed = await paperless.correspondents.update(c) # True → server accepted the change # False → nothing was sent (no fields actually changed) ``` Pass `only_changed=False` to force a full `PUT` instead of a `PATCH`: ```python await paperless.correspondents.update(c, only_changed=False) ``` ## Delete ```python c = await paperless.correspondents(7) await paperless.correspondents.delete(c) ``` Raises `DeletionError` on failure. Pass `silent_fail=True` to suppress it. ## Permissions ```python async with paperless.correspondents.with_permissions(): c = await paperless.correspondents(7) print(c.owner) # owner user id print(c.permissions) # Permissions ``` See [Permissions](../concepts/permissions.md) for details on reading and modifying permission sets. tb1337-pypaperless-5d67d60/docs/resources/custom_fields.md000066400000000000000000000042051524076126400234770ustar00rootroot00000000000000# Custom Fields Custom fields let you attach arbitrary typed metadata to documents. This page covers managing the field definitions themselves. For reading and writing field *values* on documents, see [Custom Fields concepts](../concepts/custom_fields.md). ## Models See [`pypaperless/models/custom_fields.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/custom_fields.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python field = await paperless.custom_fields(3) print(field.name) # "Invoice amount" print(field.data_type) # CustomFieldType.MONETARY ``` ## Iterate ```python async for field in paperless.custom_fields: print(field.id, field.name, field.data_type) # Keyed by id fields = await paperless.custom_fields.as_dict() ``` ## Create ```python from pypaperless.models.custom_fields import CustomFieldType draft = paperless.custom_fields.create() draft.name = "Invoice amount" draft.data_type = CustomFieldType.MONETARY pk = await paperless.custom_fields.save(draft) ``` For a `select` field, provide the allowed options via `extra_data`: ```python from pypaperless.models.custom_fields import ( CustomFieldDraft, CustomFieldExtraData, CustomFieldSelectOptions, CustomFieldType, ) draft = paperless.custom_fields.create() draft.name = "Priority" draft.data_type = CustomFieldType.SELECT draft.extra_data = CustomFieldExtraData( select_options=[ CustomFieldSelectOptions(label="Low"), CustomFieldSelectOptions(label="Medium"), CustomFieldSelectOptions(label="High"), ] ) pk = await paperless.custom_fields.save(draft) ``` ## Update ```python field = await paperless.custom_fields(3) field.name = "Invoice total" changed = await paperless.custom_fields.update(field) ``` ## Delete ```python field = await paperless.custom_fields(3) await paperless.custom_fields.delete(field) ``` Raises `DeletionError` on failure. Pass `silent_fail=True` to suppress it. tb1337-pypaperless-5d67d60/docs/resources/document_types.md000066400000000000000000000037521524076126400237070ustar00rootroot00000000000000# Document Types Document types classify documents into categories (e.g. *Invoice*, *Contract*, *Receipt*). They support the full CRUD lifecycle and permission management. ## Models See [`pypaperless/models/document_types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/document_types.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python document_type = await paperless.document_types(4) print(document_type.name) # "Invoice" print(document_type.document_count) # 17 ``` ## Iterate ```python async for dt in paperless.document_types: print(dt.id, dt.name) # Keyed by id types = await paperless.document_types.as_dict() ``` ## Create `save()` calls `validate_draft()` first — all of `name`, `match`, `matching_algorithm` and `is_insensitive` are required and raise `DraftFieldRequiredError` if missing. ```python from pypaperless.models.types import MatchingAlgorithm draft = paperless.document_types.create( name="Invoice", match="", matching_algorithm=MatchingAlgorithm.AUTO, is_insensitive=True, ) pk = await paperless.document_types.save(draft) print(pk) # primary key of the new document type ``` ## Update ```python dt = await paperless.document_types(4) dt.name = "Invoice (updated)" changed = await paperless.document_types.update(dt) ``` ## Delete ```python dt = await paperless.document_types(4) await paperless.document_types.delete(dt) ``` Raises `DeletionError` on failure. Pass `silent_fail=True` to suppress it. ## Permissions ```python async with paperless.document_types.with_permissions(): dt = await paperless.document_types(4) print(dt.owner) # owner user id print(dt.permissions) # Permissions ``` See [Permissions](../concepts/permissions.md) for details on reading and modifying permission sets. tb1337-pypaperless-5d67d60/docs/resources/documents.md000066400000000000000000000107201524076126400226370ustar00rootroot00000000000000# Documents Documents are the core resource in Paperless-ngx. This page shows the essential CRUD examples. For the full feature set - downloading, searching, notes, next ASN, email, and upload - see the dedicated [Documents](../concepts/documents.md) page. ## Models See [`pypaperless/models/documents/document.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/documents/document.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python doc = await paperless.documents(42) print(doc.title) print(doc.created_date) # datetime.date ``` ## Iterate ```python async for doc in paperless.documents: print(doc.id, doc.title) # All documents in memory all_docs = await paperless.documents.as_list() ``` ## Create (upload) To upload a new document, use `create()` + `save()`. The raw file content (`bytes`) is a required draft field - pass it as `document=` to `create()` (or assign it to `draft.document`) before saving: ```python with open("invoice.pdf", "rb") as fh: raw = fh.read() draft = paperless.documents.create(document=raw) draft.title = "Invoice 2024" draft.correspondent = 7 draft.tags = [1, 3] task_id = await paperless.documents.save(draft) ``` `save()` returns a `str` task UUID. Monitor progress via `paperless.tasks`. ## Update ```python doc = await paperless.documents(42) doc.title = "Updated title" doc.tags = [1, 2, 5] changed = await paperless.documents.update(doc) ``` ## Delete ```python doc = await paperless.documents(42) await paperless.documents.delete(doc) ``` Raises `DeletionError` on failure. Pass `silent_fail=True` to suppress it. ## Document sub-service shortcuts The `Document` model exposes six **bound sub-services** as properties - `notes`, `history`, `share_links`, `ai_suggestions`, `root` and `versions`. These are the same services reachable from `paperless.documents`, but with the document's primary key pre-filled: ```python doc = await paperless.documents(42) # notes: cache-first by default, force_request=True to re-fetch from API notes = await doc.notes() # from cache (no HTTP request) notes = await doc.notes(force_request=True) # fresh from API note_draft = doc.notes.create(note="Checked.") note_id = await doc.notes.save(note_draft) await doc.notes.delete(notes[0]) # model instance await doc.notes.delete(notes[0].id) # integer shorthand (document pk implicit) # history (read-only) entries = await doc.history() # list[DocumentHistory] # share links (read-only from here, use paperless.share_links to create/delete) links = await doc.share_links() # list[ShareLink] # AI suggestions (read-only) ai = await doc.ai_suggestions() # DocumentAISuggestions # root document record (read-only) root = await doc.root() # DocumentRoot # versions: upload / relabel / delete a document's file versions (not callable) with open("updated.pdf", "rb") as fh: await doc.versions.upload(fh, version_label="v2") info = await doc.versions.update(1, version_label="final") # DocumentVersionInfo await doc.versions.delete(1) ``` See [Documents — AI suggestions](../concepts/documents.md#ai-suggestions) and [Document versions](../concepts/documents.md#document-versions) for the full signatures. All other operations (download, preview, thumbnail, metadata, suggestions, more-like, email, chat) are **service-only** — there are no model-level shortcuts for these: ```python downloaded = await paperless.documents.download(doc.id) preview = await paperless.documents.preview(doc.id) thumbnail = await paperless.documents.thumbnail(doc.id) meta = await paperless.documents.metadata(doc.id) suggestions = await paperless.documents.suggestions(doc.id) async for similar in paperless.documents.more_like(doc.id): print(similar.title) await paperless.documents.email(doc.id, addresses="alice@example.com", subject="Fwd", message="") answer = await paperless.documents.chat("What is this about?", doc.id) ``` ## Permissions ```python async with paperless.documents.with_permissions(): doc = await paperless.documents(42) print(doc.owner) # owner user id print(doc.permissions) # Permissions ``` --- !!! note For downloading, searching, note management, and more, see [Documents](../concepts/documents.md). To filter documents by custom field values, see [Custom Field Query](../concepts/custom_field_query.md). tb1337-pypaperless-5d67d60/docs/resources/groups.md000066400000000000000000000016431524076126400221610ustar00rootroot00000000000000# Groups Groups are Paperless-ngx user groups used for permission management. They are read-only from the API perspective - groups can only be fetched and iterated, not created or modified via pypaperless. ## Model See [`pypaperless/models/permissions/groups.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/permissions/groups.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python group = await paperless.groups(2) print(group.name) # "Administrators" print(group.permissions) # ["view_document", "change_document", ...] ``` ## Iterate ```python async for group in paperless.groups: print(group.id, group.name) # Keyed by id groups = await paperless.groups.as_dict() ``` tb1337-pypaperless-5d67d60/docs/resources/mail_accounts.md000066400000000000000000000024451524076126400234640ustar00rootroot00000000000000# Mail Accounts Mail accounts are the IMAP mailbox configurations that Paperless-ngx polls for incoming documents. They support fetching and iterating, with permission information available on request. ## Model See [`pypaperless/models/mails/accounts.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/mails/accounts.py) for all fields and types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python account = await paperless.mail_accounts(1) print(account.name) # "My Gmail" print(account.imap_server) # "imap.gmail.com" print(account.username) # "user@gmail.com" ``` ## Iterate ```python async for account in paperless.mail_accounts: print(account.id, account.name) # All accounts as a dict keyed by id accounts = await paperless.mail_accounts.as_dict() ``` ## Test connection ```python result = await paperless.mail_accounts.test() print(result) ``` ## Process mailbox now ```python await paperless.mail_accounts.process(1) ``` ## Permissions ```python async with paperless.mail_accounts.with_permissions(): account = await paperless.mail_accounts(1) print(account.owner) # owner user id print(account.permissions) # Permissions ``` See [Permissions](../concepts/permissions.md) for details. tb1337-pypaperless-5d67d60/docs/resources/mail_rules.md000066400000000000000000000022031524076126400227670ustar00rootroot00000000000000# Mail Rules Mail rules define how Paperless-ngx processes incoming emails from a mail account. Each rule specifies filters (sender, subject, body) and actions to apply when a matching email is found. ## Model See [`pypaperless/models/mails/rules.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/mails/rules.py) for all fields and types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python rule = await paperless.mail_rules(3) print(rule.name) # "Invoice imports" print(rule.account) # 1 (mail account id) print(rule.enabled) # True print(rule.filter_from) # "billing@vendor.com" ``` ## Iterate ```python async for rule in paperless.mail_rules: print(rule.id, rule.name, "enabled:", rule.enabled) # Only active rules active = [r async for r in paperless.mail_rules if r.enabled] ``` ## Permissions ```python async with paperless.mail_rules.with_permissions(): rule = await paperless.mail_rules(3) print(rule.owner) # owner user id print(rule.permissions) # Permissions ``` See [Permissions](../concepts/permissions.md) for details. tb1337-pypaperless-5d67d60/docs/resources/processed_mail.md000066400000000000000000000036731524076126400236400ustar00rootroot00000000000000# Processed Mail The `processed_mail` resource is a read-only log of emails that have already been processed by a mail rule. Each entry records the outcome of the import attempt. ## Model See [`pypaperless/models/mails/processed.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/mails/processed.py) for all fields and types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python entry = await paperless.processed_mail(5) print(entry.subject) # "RE: Invoice #1234" print(entry.status) # "SUCCESS" print(entry.processed) # datetime(2024, 3, 15, 10, 30, ...) ``` `status` is a free-form string upstream. Paperless-ngx writes it in upper case — `"SUCCESS"`, `"FAILED"` and `"PROCESSED_WO_CONSUMPTION"`. ## Iterate ```python async for entry in paperless.processed_mail: if entry.status == "FAILED": print(f"Failed: {entry.subject} - {entry.error}") # Collect all failures failures = [e async for e in paperless.processed_mail if e.status == "FAILED"] ``` ## Filter ```python # Entries produced by a single mail rule async with paperless.processed_mail.filter(rule=3) as ctx: async for entry in ctx: print(entry.subject, entry.status) # Combine with the processing status async with paperless.processed_mail.filter(rule=3, status="FAILED") as ctx: failures = await ctx.as_list() ``` `/api/processed_mail/` supports exactly two filters, both exact-match — see [`ProcessedMailFilters`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/filters.py). Note that `rule` must reference an existing mail rule; the API answers unknown ids with HTTP 400 rather than an empty result set. ## Owner `ProcessedMail` carries the `owner` field directly — no `with_permissions()` context is needed (the model does not expose the full permissions table). ```python entry = await paperless.processed_mail(5) print(entry.owner) # owner user id ``` tb1337-pypaperless-5d67d60/docs/resources/profile.md000066400000000000000000000023321524076126400222760ustar00rootroot00000000000000# Profile The `profile` resource exposes the Paperless-ngx user profile for the currently authenticated token. It is a parameter-free singleton call and supports partial updates via `update()`. ## Model See [`pypaperless/models/profile.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/profile.py) for all fields and types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch No primary key - call without arguments: ```python profile = await paperless.profile() print(profile.email) # "admin@example.com" print(profile.first_name) # "Admin" print(profile.auth_token) # "3e9505078d32d8ad4ecea00fa0eec8e426622b52" print(profile.has_usable_password) # True print(profile.is_mfa_enabled) # False for account in profile.social_accounts or []: print(account.provider, account.name) ``` ## Update Use `update()` to partially update the profile. Only the supplied keyword arguments are sent to the API: ```python updated = await paperless.profile.update( first_name="Jane", last_name="Doe", email="jane@example.com", ) print(updated.first_name) # "Jane" ``` `update()` returns a refreshed `Profile` instance reflecting the server response. tb1337-pypaperless-5d67d60/docs/resources/remote_version.md000066400000000000000000000013321524076126400236750ustar00rootroot00000000000000# Remote Version The `remote_version` resource reports the latest available version of Paperless-ngx as published on GitHub, along with whether an update is available relative to the running instance. ## Model See [`pypaperless/models/remote_version.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/remote_version.py) for all fields and types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch This is a parameter-free call - there is no primary key: ```python rv = await paperless.remote_version() print(rv.version) # "2.13.5" print(rv.update_available) # True / False if rv.update_available: print(f"Update available: {rv.version}") ``` tb1337-pypaperless-5d67d60/docs/resources/saved_views.md000066400000000000000000000023551524076126400231620ustar00rootroot00000000000000# Saved Views Saved views are named document filter presets that can be pinned to the dashboard or sidebar in the Paperless-ngx web UI. They support fetching and iterating, with permission information available on request. ## Model See [`pypaperless/models/saved_views.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/saved_views.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python view = await paperless.saved_views(2) print(view.name) # "Inbox" print(view.sort_field) # "created" print(view.sort_reverse) # True print(view.filter_rules) # [SavedViewFilterRule(...), ...] ``` ## Iterate ```python async for view in paperless.saved_views: print(view.id, view.name) # Keyed by id views = await paperless.saved_views.as_dict() ``` ## Permissions ```python async with paperless.saved_views.with_permissions(): view = await paperless.saved_views(2) print(view.owner) # owner user id print(view.permissions) # Permissions ``` See [Permissions](../concepts/permissions.md) for details. tb1337-pypaperless-5d67d60/docs/resources/search.md000066400000000000000000000077741524076126400221220ustar00rootroot00000000000000# Search The `search` resource exposes the Paperless-ngx global search endpoint (`/api/search/`). It searches across documents, tags, correspondents, document types, and other resource types simultaneously and returns a scored, ranked result. It also exposes the typeahead endpoint (`/api/search/autocomplete/`) via [`autocomplete()`](#autocomplete). ## Model See [`pypaperless/models/search.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/search.py) and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for all types. The upstream schema component is `SearchResult`. ### `SearchResult` | Field | Type | Description | | ---------------- | --------------------- | ------------------------------------------------- | | `total` | `int` | Total number of matching objects across all types | | `documents` | `list[Document]` | Matching documents (full `Document` objects) | | `saved_views` | `list[SavedView]` | Matching saved views | | `tags` | `list[Tag]` | Matching tags | | `correspondents` | `list[Correspondent]` | Matching correspondents | | `document_types` | `list[DocumentType]` | Matching document types | | `storage_paths` | `list[StoragePath]` | Matching storage paths | | `users` | `list[User]` | Matching users | | `groups` | `list[Group]` | Matching groups | | `mail_rules` | `list[MailRule]` | Matching mail rules | | `mail_accounts` | `list[MailAccount]` | Matching mail accounts | | `workflows` | `list[Workflow]` | Matching workflows | | `custom_fields` | `list[CustomField]` | Matching custom fields | All fields are `None`-able. Nested objects are full model instances — pass them to the matching service (`paperless.documents.update(doc)`, `paperless.tags.delete(tag)`, …) or to the client-level dispatcher (`paperless.update(doc)`, `paperless.delete(tag)`) to act on them. ## Search ### Plain string query ```python result = await paperless.search("invoice") print(result.total) # total matches across all types for doc in result.documents or []: print(doc.title, doc.id) ``` ### Using the SearchQuery builder For complex queries, use the `SearchQuery` builder (see [Search Query Builder](../concepts/search_query.md)): ```python from pypaperless.models.types import SearchQuery q = SearchQuery("invoice") & SearchQuery.field("tag", "unpaid") result = await paperless.search(q) ``` ### Database-only search Pass `db_only=True` to skip the full-text index and search only the database fields: ```python result = await paperless.search("contract", db_only=True) ``` ### Autocomplete `autocomplete()` hits `/api/search/autocomplete/` and returns a plain list of index terms - the typeahead helper behind the Paperless-ngx search bar, not resource objects: ```python terms = await paperless.search.autocomplete("inv") # ['invoice', 'invoices', 'invoiced'] terms = await paperless.search.autocomplete("inv", 3) ``` `limit` is omitted from the request when left at `None`, which leaves the server default (10) in place. An empty term or a `limit` below 1 is rejected by the server with HTTP 400, raising `UnexpectedStatusError`. ### Working with results All nested objects in `SearchResult` are `PaperlessModel` instances, so you can act on them immediately: ```python result = await paperless.search("old project") # Delete all matched documents for doc in result.documents or []: await paperless.documents.delete(doc) # The correspondents list works the same way for corr in result.correspondents or []: print(corr.name) ``` tb1337-pypaperless-5d67d60/docs/resources/share_link_bundles.md000066400000000000000000000044611524076126400244760ustar00rootroot00000000000000# Share Link Bundles Share link bundles group multiple documents into a single shareable archive. They support the full CRUD lifecycle plus a `rebuild` action. ## Models See [`pypaperless/models/share_links/bundle.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/share_links/bundle.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python bundle = await paperless.share_link_bundles(3) print(bundle.slug) # "xyz789abc" print(bundle.status) # ShareLinkBundleStatus.READY print(bundle.documents) # [42, 43] print(bundle.size_bytes) # 204800 ``` ## Iterate ```python async for bundle in paperless.share_link_bundles: print(bundle.id, bundle.status, bundle.documents) ``` ## Create `save()` calls `validate_draft()` first — only `document_ids` is required. `file_version` and `expiration_days` are optional: ```python from pypaperless.models.share_links import ShareLinkFileVersion draft = paperless.share_link_bundles.create( document_ids=[42, 43], file_version=ShareLinkFileVersion.ARCHIVE, expiration_days=7, ) bundle_id = int(await paperless.share_link_bundles.save(draft)) print(bundle_id) # 3 ``` !!! note The draft takes `document_ids` and `expiration_days`, while the created `ShareLinkBundle` exposes `documents` and an absolute `expiration` timestamp. ## Rebuild Trigger a rebuild of an existing bundle (e.g. after documents change): ```python updated = await paperless.share_link_bundles.rebuild(3) print(updated.status) # ShareLinkBundleStatus.PENDING ``` ## Update ```python import datetime bundle = await paperless.share_link_bundles(3) bundle.expiration = datetime.datetime(2026, 6, 1, tzinfo=datetime.timezone.utc) changed = await paperless.share_link_bundles.update(bundle) ``` ## Delete ```python bundle = await paperless.share_link_bundles(3) await paperless.share_link_bundles.delete(bundle) ``` Raises `DeletionError` on failure. Pass `silent_fail=True` to suppress it. ## Filter ```python async with paperless.share_link_bundles.filter(status="ready") as ctx: async for bundle in ctx: print(bundle.id, bundle.size_bytes) ``` tb1337-pypaperless-5d67d60/docs/resources/share_links.md000066400000000000000000000037101524076126400231410ustar00rootroot00000000000000# Share Links Share links create publicly accessible URLs for individual documents without requiring authentication. They support the full CRUD lifecycle. ## Models See [`pypaperless/models/share_links/share_link.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/share_links/share_link.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python link = await paperless.share_links(8) print(link.slug) # "abc123xyz" print(link.document) # 42 print(link.expiration) # datetime(2024, 12, 31, ...) or None ``` ## Iterate ```python async for link in paperless.share_links: print(link.id, link.document, link.slug) # Find links for a specific document doc_links = [lnk async for lnk in paperless.share_links if lnk.document == 42] ``` ## Create `save()` calls `validate_draft()` first — `document` and `file_version` are required. ```python import datetime from pypaperless.models.share_links import ShareLinkFileVersion draft = paperless.share_links.create( document=42, file_version=ShareLinkFileVersion.ARCHIVE, expiration=datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc), ) new_id = await paperless.share_links.save(draft) print(new_id) # primary key of the new share link # fetch the freshly created link to read its generated slug link = await paperless.share_links(new_id) print(link.slug) # e.g. "abc123xyz" ``` ## Update ```python import datetime link = await paperless.share_links(8) link.expiration = datetime.datetime(2026, 6, 1, tzinfo=datetime.timezone.utc) changed = await paperless.share_links.update(link) ``` ## Delete ```python link = await paperless.share_links(8) await paperless.share_links.delete(link) ``` Raises `DeletionError` on failure. Pass `silent_fail=True` to suppress it. tb1337-pypaperless-5d67d60/docs/resources/statistics.md000066400000000000000000000016461524076126400230370ustar00rootroot00000000000000# Statistics The `statistics` resource returns aggregate counts and metadata about the Paperless-ngx document archive. It is a parameter-free singleton call. ## Model See [`pypaperless/models/statistics.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/statistics.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch No primary key - call without arguments: ```python stats = await paperless.statistics() print(stats.documents_total) # 1024 print(stats.documents_inbox) # 3 print(stats.current_asn) # 512 print(stats.correspondent_count) # 47 for ft in stats.document_file_type_counts or []: print(ft.mime_type, ft.mime_type_count) # "application/pdf" 980 # "image/jpeg" 44 ``` tb1337-pypaperless-5d67d60/docs/resources/status.md000066400000000000000000000023211524076126400221570ustar00rootroot00000000000000# Status The `status` resource reports the health of the Paperless-ngx server - database connectivity, storage availability, background task workers, and the ML classifier. It is a parameter-free singleton call. ## Model See [`pypaperless/models/status.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/status.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch No primary key - call without arguments: ```python status = await paperless.status() print(status.pngx_version) # "2.13.5" print(status.server_os) # "Linux" # Check storage if storage := status.storage: gb_free = storage.available / 1024**3 print(f"{gb_free:.1f} GB free") # Check database health if database := status.database: print(database.status) # "OK" # Check background workers if tasks := status.tasks: print(tasks.celery_status) # "OK" print(tasks.redis_status) # "OK" # Convenience property for any errors present if status.has_errors: print("One or more components are unhealthy!") ``` tb1337-pypaperless-5d67d60/docs/resources/storage_paths.md000066400000000000000000000040411524076126400235000ustar00rootroot00000000000000# Storage Paths Storage paths define directory templates that control where Paperless-ngx stores archived documents on disk. They support the full CRUD lifecycle and permission management. ## Models See [`pypaperless/models/storage_paths.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/storage_paths.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python sp = await paperless.storage_paths(2) print(sp.name) # "By year" print(sp.path) # "{created_year}/{correspondent}/{title}" ``` ## Iterate ```python async for sp in paperless.storage_paths: print(sp.id, sp.name, sp.path) # Keyed by id paths = await paperless.storage_paths.as_dict() ``` ## Create `save()` calls `validate_draft()` first — all of `name`, `path`, `match`, `matching_algorithm` and `is_insensitive` are required and raise `DraftFieldRequiredError` if missing. ```python from pypaperless.models.types import MatchingAlgorithm draft = paperless.storage_paths.create( name="By year and type", path="{created_year}/{document_type}/{title}", match="", matching_algorithm=MatchingAlgorithm.AUTO, is_insensitive=True, ) pk = await paperless.storage_paths.save(draft) print(pk) # primary key of the new storage path ``` ## Update ```python sp = await paperless.storage_paths(2) sp.path = "{created_year}/{correspondent}/{title}" changed = await paperless.storage_paths.update(sp) ``` ## Delete ```python sp = await paperless.storage_paths(2) await paperless.storage_paths.delete(sp) ``` Raises `DeletionError` on failure. Pass `silent_fail=True` to suppress it. ## Permissions ```python async with paperless.storage_paths.with_permissions(): sp = await paperless.storage_paths(2) print(sp.owner) # owner user id print(sp.permissions) # Permissions ``` See [Permissions](../concepts/permissions.md) for details. tb1337-pypaperless-5d67d60/docs/resources/tags.md000066400000000000000000000050501524076126400215740ustar00rootroot00000000000000# Tags Tags are labels that can be applied to documents for classification and filtering. They support the full CRUD lifecycle and permission management. ## Models See [`pypaperless/models/tags.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/tags.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python tag = await paperless.tags(5) print(tag.name) # "Invoice" print(tag.color) # "#a6cee3" print(tag.document_count) # 38 ``` ## Iterate ```python async for tag in paperless.tags: print(tag.id, tag.name, tag.color) # Find the inbox tag inbox = next( (t async for t in paperless.tags if t.is_inbox_tag), None, ) ``` ## Tag hierarchy Tags can be nested. `parent` holds the parent tag's id (`None` for a top-level tag) and `children` holds the nested tags as full `Tag` instances: ```python tag = await paperless.tags(5) print(tag.parent) # 2, or None for a root tag for child in tag.children or []: print(child.id, child.name) ``` Use the `is_root` filter to iterate only top-level tags: ```python async with paperless.tags.filter(is_root=True) as ctx: async for tag in ctx: print(tag.name, len(tag.children or [])) ``` Assign a parent when creating a tag by passing `parent=` to `create()`. ## Create `save()` calls `validate_draft()` first — all of `name`, `color`, `is_inbox_tag`, `match`, `matching_algorithm` and `is_insensitive` are required and raise `DraftFieldRequiredError` if missing. `parent` and `text_color` are optional. ```python from pypaperless.models.types import MatchingAlgorithm draft = paperless.tags.create( name="Invoice", color="#a6cee3", is_inbox_tag=False, match="", matching_algorithm=MatchingAlgorithm.AUTO, is_insensitive=True, ) pk = await paperless.tags.save(draft) print(pk) # primary key of the new tag ``` ## Update ```python tag = await paperless.tags(5) tag.color = "#ff0000" changed = await paperless.tags.update(tag) ``` ## Delete ```python tag = await paperless.tags(5) await paperless.tags.delete(tag) ``` Raises `DeletionError` on failure. Pass `silent_fail=True` to suppress it. ## Permissions ```python async with paperless.tags.with_permissions(): tag = await paperless.tags(5) print(tag.owner) # owner user id print(tag.permissions) # Permissions ``` See [Permissions](../concepts/permissions.md) for details. tb1337-pypaperless-5d67d60/docs/resources/tasks.md000066400000000000000000000101761524076126400217700ustar00rootroot00000000000000# Tasks Tasks represent Celery background jobs - primarily document consumption jobs. You can look up a task by its UUID (returned when uploading a document) or by its integer primary key, and iterate over all tasks. ## Model See [`pypaperless/models/tasks.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/tasks.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch by UUID The primary use-case is monitoring a document upload by its task UUID: ```python # task_uuid was returned by paperless.documents.save(draft) task = await paperless.tasks(task_uuid) print(task.status) # TaskStatus.PENDING / STARTED / SUCCESS / FAILURE print(task.related_document_ids) # [42] - list of document pks once finished ``` ## Fetch by primary key ```python task = await paperless.tasks(1337) print(task.task_type) # TaskType.CONSUME_FILE ``` ## Poll until complete ```python import asyncio from pypaperless.models.tasks import TaskStatus task_uuid = await paperless.documents.save(draft) while True: task = await paperless.tasks(task_uuid) if task.status in (TaskStatus.SUCCESS, TaskStatus.FAILURE): break await asyncio.sleep(1) if task.status == TaskStatus.SUCCESS and task.related_document_ids: doc = await paperless.documents(task.related_document_ids[0]) print("Uploaded:", doc.title) else: print("Upload failed:", task.result_data) ``` ## Iterate ```python async for task in paperless.tasks: print(task.task_id, task.status) # Collect all pending tasks pending = [t async for t in paperless.tasks if t.status == "pending"] ``` ## Filter `filter()` is an async context manager - apply server-side filters within `async with` and iterate over the yielded `ctx`. See [`TaskFilters`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/filters.py) for all keys. ```python from pypaperless.models.tasks import TaskStatus, TaskType # Only tasks with a specific status async with paperless.tasks.filter(status=[TaskStatus.SUCCESS]) as ctx: async for task in ctx: print(task.task_id, task.result_data) # Only unacknowledged consumption tasks async with paperless.tasks.filter( acknowledged=False, task_type=[TaskType.CONSUME_FILE], ) as ctx: async for task in ctx: print(task.task_id) ``` `name` and `result` are free-text searches, not exact matches. `name` matches the input filename as well as the task type and trigger source labels; `result` matches the result reason, the error message and the document id: ```python async with paperless.tasks.filter(name="invoice.pdf") as ctx: async for task in ctx: print(task.task_id, task.status) async with paperless.tasks.filter(result="timeout") as ctx: failed = await ctx.as_list() ``` ## Active tasks `active()` iterates over currently pending or running tasks (capped at 50 by the server): ```python async for task in paperless.tasks.active(): print(task.task_id, task.status) ``` ## Summary statistics `summary()` returns aggregated counts and durations per task type, optionally scoped to a rolling time window: ```python summaries = await paperless.tasks.summary(days=7) for s in summaries: print(s.task_type, s.success_count, s.failure_count, s.avg_duration_seconds) ``` ## Status counts `status_counts()` returns the aggregated task counts used by the task UI sections: ```python counts = await paperless.tasks.status_counts() print(counts.all, counts.needs_attention, counts.in_progress, counts.completed) ``` ## Acknowledge tasks Acknowledgement happens on the service - pass the list of primary keys to acknowledge: ```python count = await paperless.tasks.acknowledge([1, 2, 3]) print("Acknowledged:", count) ``` ## Run task `run()` schedules a fresh background task by type and returns the new Celery UUID as a string: ```python from pypaperless.models.tasks import TaskType task_uuid = await paperless.tasks.run(TaskType.SANITY_CHECK) task = await paperless.tasks(task_uuid) print(task.status) ``` tb1337-pypaperless-5d67d60/docs/resources/trash.md000066400000000000000000000037421524076126400217650ustar00rootroot00000000000000# Trash The `trash` resource exposes documents that have been soft-deleted in Paperless-ngx. Trashed documents can be restored or permanently deleted. The service iterates over `Document` objects just like the main `documents` service. ## Model Trashed documents use the same `Document` model - see [`pypaperless/models/documents/document.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/documents/document.py) and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types. In the trash context, `deleted_at` is additionally populated with the timestamp when the document was moved to the trash. ## Iterate ```python async for doc in paperless.trash: print(doc.id, doc.title, doc.deleted_at) # Collect all trashed documents as a list trashed = await paperless.trash.as_list() print(f"{len(trashed)} document(s) in trash") ``` ## Filter `/api/trash/` declares no query filters. `filter()` therefore only accepts the pagination parameters, and passing document filters has no effect — the endpoint silently returns the full trash: ```python # Only page / page_size are honoured here async with paperless.trash.filter(page_size=25) as ctx: async for doc in ctx: print(doc.id, doc.title) ``` Narrow the result set client-side instead: ```python trashed = await paperless.trash.as_list() invoices = [doc for doc in trashed if "invoice" in (doc.title or "").lower()] ``` ## Restore Restore one or more documents back to the document archive: ```python await paperless.trash.restore([42, 43]) ``` ## Empty Permanently delete documents from the trash. Pass a list of IDs to delete specific documents, or call without arguments to empty the entire trash: ```python # Delete specific documents permanently await paperless.trash.empty([42, 43]) # Empty the entire trash await paperless.trash.empty() ``` !!! warning `empty()` permanently destroys documents. This action cannot be undone. tb1337-pypaperless-5d67d60/docs/resources/users.md000066400000000000000000000015631524076126400220040ustar00rootroot00000000000000# Users Users are the Paperless-ngx user accounts. They are read-only from the API perspective - only fetching and iterating is supported. ## Model See [`pypaperless/models/permissions/users.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/permissions/users.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Fetch one ```python user = await paperless.users(1) print(user.username) # "admin" print(user.is_active) # True print(user.groups) # [2, 5] ``` ## Iterate ```python async for user in paperless.users: print(user.id, user.username, user.email) # Find all superusers admins = [u async for u in paperless.users if u.is_superuser] ``` tb1337-pypaperless-5d67d60/docs/resources/workflows.md000066400000000000000000000036331524076126400227000ustar00rootroot00000000000000# Workflows Workflows automate document processing in Paperless-ngx. Each workflow has one or more **triggers** (conditions that activate it) and one or more **actions** (what to do when triggered). Triggers and actions are accessible as sub-resources via `paperless.workflows.triggers` and `paperless.workflows.actions`. ## Models See [`pypaperless/models/workflows/workflow.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/workflows/workflow.py) for all fields and [`pypaperless/models/types.py`](https://github.com/tb1337/pypaperless/blob/main/pypaperless/models/types.py) for enum and filter types, and the [Paperless-ngx API docs](https://docs.paperless-ngx.com/api/) for the upstream schema. ## Workflows ### Fetch one ```python workflow = await paperless.workflows(1) print(workflow.name) # "Auto-tag invoices" print(workflow.enabled) # True ``` ### Iterate ```python async for wf in paperless.workflows: print(wf.id, wf.name, "enabled:", wf.enabled) # Only active workflows active = [wf async for wf in paperless.workflows if wf.enabled] ``` ## Triggers Triggers are accessed via `paperless.workflows.triggers`: ```python # Fetch a trigger by id trigger = await paperless.workflows.triggers(3) print(trigger.type) # WorkflowTriggerType.CONSUMPTION print(trigger.filter_filename) # "invoice_*.pdf" # Iterate all triggers async for trigger in paperless.workflows.triggers: print(trigger.id, trigger.type) ``` ## Actions Actions are accessed via `paperless.workflows.actions`: ```python # Fetch an action by id action = await paperless.workflows.actions(5) print(action.type) # WorkflowActionType.ASSIGNMENT print(action.assign_tags) # [1, 3] print(action.assign_correspondent) # 7 # Iterate all actions async for action in paperless.workflows.actions: print(action.id, action.type) # All actions as a dict keyed by id actions_dict = await paperless.workflows.actions.as_dict() ``` tb1337-pypaperless-5d67d60/docs/session.md000066400000000000000000000145201524076126400203110ustar00rootroot00000000000000# Session management This page covers all configuration modes, custom HTTP clients and connection lifecycle details. --- ## Configuration modes **pypaperless** can be configured in three ways: ### 1. Explicit parameters Pass `url` and `token` directly to the constructor: ```python from pypaperless import PaperlessClient paperless = PaperlessClient("localhost:8000", "your-api-token") ``` ### 2. `PaperlessSettings` object Build a `PaperlessSettings` instance and pass it to `PaperlessClient.from_config()`. Useful when you want to construct or validate settings separately: ```python from pypaperless import PaperlessClient, PaperlessSettings cfg = PaperlessSettings( url="https://paperless.example.com", token="your-api-token", ) async with PaperlessClient.from_config(cfg) as paperless: ... ``` ### 3. Environment variables Set the `PYPAPERLESS_*` environment variables and call `PaperlessClient.from_env()`. Ideal for containers, CI pipelines and twelve-factor apps: | Environment variable | Field | Required | | -------------------- | --------- | :------: | | `PYPAPERLESS_URL` | Base URL | ✓ | | `PYPAPERLESS_TOKEN` | API token | | ```bash export PYPAPERLESS_URL=https://paperless.example.com export PYPAPERLESS_TOKEN=your-api-token ``` ```python from pypaperless import PaperlessClient async with PaperlessClient.from_env() as paperless: ... ``` !!! note `PYPAPERLESS_URL` is required. If it is not set, a `ValidationError` is raised immediately. --- ## The `PaperlessClient` constructor reference ```python PaperlessClient( url: str, token: str | None = None, *, client: httpx.AsyncClient | None = None, ) ``` | Parameter | Description | | --------- | --------------------------------------------------------------- | | `url` | Hostname, IP address or full URL of your Paperless-ngx instance | | `token` | API token obtained from Paperless-ngx settings | | `client` | Optional custom HTTP client (see below) | For config-object or environment-variable based initialization use the factory class methods: | Factory method | Description | | ---------------------------------- | ---------------------------------------------------------------- | | `PaperlessClient.from_config(cfg)` | Initialize from a `PaperlessSettings` instance | | `PaperlessClient.from_env()` | Initialize from `PYPAPERLESS_URL` / `PYPAPERLESS_TOKEN` env vars | --- ## Lifecycle ### Context manager The recommended approach - `initialize()` and `close()` are called automatically: ```python from pypaperless import PaperlessClient async with PaperlessClient("localhost:8000", "your-api-token") as paperless: # fully initialised here ... # connection closed here ``` ### Manual ```python paperless = PaperlessClient("localhost:8000", "your-api-token") await paperless.initialize() try: # use paperless pass finally: await paperless.close() ``` ### What `initialize()` does 1. Sends a `GET` request to `/api/schema/` to verify connectivity and authentication. 2. Reads the `x-api-version` and `x-version` response headers. 3. Sets `is_initialized = True`. Any connectivity or authentication problem raises an exception before `is_initialized` becomes `True`. See [Exceptions](exceptions.md) for details. --- ## Custom HTTP client You can supply your own `httpx.AsyncClient`. This is useful when you need to: - Configure timeouts, retries or proxy settings - Reuse an existing client for connection pooling - Inject custom headers or SSL certificates ```python import httpx from pypaperless import PaperlessClient custom_client = httpx.AsyncClient( timeout=httpx.Timeout(30.0), verify="/path/to/ca-bundle.pem", headers={"X-Custom-Header": "value"}, ) async with PaperlessClient("localhost:8000", "your-api-token", client=custom_client) as paperless: ... ``` !!! note When you provide a `client`, pypaperless does **not** close it automatically when `close()` is called. You are responsible for managing its lifecycle. --- ## Logging pypaperless uses the standard Python `logging` module under the logger name `pypaperless`. ```python import logging logging.basicConfig(level=logging.DEBUG) ``` The library logs `INFO`-level messages for `initialize()` and `close()` events. --- ## Available resources After initialisation, the following services are available on the `PaperlessClient` instance: | Attribute | Resource | | ----------------------------- | -------------------------------- | | `paperless.bulk_edit_objects` | Bulk edit (non-document objects) | | `paperless.config` | Application configuration | | `paperless.correspondents` | Correspondents | | `paperless.custom_fields` | Custom fields | | `paperless.documents` | Documents | | `paperless.document_types` | Document types | | `paperless.groups` | User groups | | `paperless.mail_accounts` | Mail accounts | | `paperless.mail_rules` | Mail rules | | `paperless.processed_mail` | Processed mail | | `paperless.profile` | User profile | | `paperless.saved_views` | Saved views | | `paperless.search` | Full-text search | | `paperless.share_link_bundles`| Share link bundles | | `paperless.share_links` | Share links | | `paperless.statistics` | Statistics | | `paperless.remote_version` | Remote version info | | `paperless.status` | System status | | `paperless.storage_paths` | Storage paths | | `paperless.tags` | Tags | | `paperless.tasks` | Background tasks | | `paperless.trash` | Trash (soft-deleted docs) | | `paperless.users` | Users | | `paperless.workflows` | Workflows | See [Resources](resources.md) for the full feature matrix. tb1337-pypaperless-5d67d60/docs/stylesheets/000077500000000000000000000000001524076126400206565ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/docs/stylesheets/extra.css000066400000000000000000000005541524076126400225170ustar00rootroot00000000000000/* Nav separators: visual border-top on selected top-level items */ .md-nav--primary > .md-nav__list > .md-nav__item:nth-child(2), .md-nav--primary > .md-nav__list > .md-nav__item:nth-child(6), .md-nav--primary > .md-nav__list > .md-nav__item:nth-child(7) { border-top: 1px solid var(--md-default-fg-color--lighter); margin-top: 0.6em; padding-top: 0.6em; } tb1337-pypaperless-5d67d60/pypaperless/000077500000000000000000000000001524076126400177215ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/__init__.py000066400000000000000000000003241524076126400220310ustar00rootroot00000000000000"""PyPaperless.""" from .client import PaperlessClient from .settings import PaperlessSettings from .transport import generate_api_token __all__ = ("PaperlessClient", "PaperlessSettings", "generate_api_token") tb1337-pypaperless-5d67d60/pypaperless/builders/000077500000000000000000000000001524076126400215325ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/builders/__init__.py000066400000000000000000000002511524076126400236410ustar00rootroot00000000000000"""PyPaperless query builders.""" from .custom_fields import CustomFieldQuery from .search import SearchQuery __all__ = ( "CustomFieldQuery", "SearchQuery", ) tb1337-pypaperless-5d67d60/pypaperless/builders/custom_fields.py000066400000000000000000000130061524076126400247440ustar00rootroot00000000000000"""Fluent builder for the ``custom_field_query`` document filter. Combine atoms with ``&`` (AND), ``|`` (OR) and ``~`` (NOT), then pass ``str(q)`` as the ``custom_field_query`` kwarg to :meth:`documents.filter`:: from pypaperless.builders.custom_fields import CustomFieldQuery q = CustomFieldQuery("Amount", "gte", 100) & ~CustomFieldQuery("Archived", "exact", True) async with paperless.documents.filter(custom_field_query=str(q)) as docs: ... Field can be referenced by integer id or name string. See :class:`CustomFieldQuery` for the full list of supported operators. """ import json from typing import Any, Literal # The following list is based on the schema defined in: # https://github.com/paperless-ngx/paperless-ngx/blob/dev/src/documents/filters.py # -> CustomFieldQueryParser type _QueryOperation = Literal[ "exact", "in", "isnull", "exists", "icontains", "istartswith", "iendswith", "gt", "gte", "lt", "lte", "range", "contains", ] class CustomFieldQuery: """A single custom-field filter atom: ``[field, op, value]``. Use the ``&``, ``|``, ``~`` operators to combine atoms into boolean expressions. Call :func:`str` on the result to obtain the JSON string for :attr:`~pypaperless.models.filters.DocumentFilters.custom_field_query`. Args: field: Custom field referenced by its integer id or by its name string. op: Comparison operator, e.g. ``"exact"``, ``"icontains"``, ``"gte"``, ``"exists"``. value: The value to compare against. Use ``True``/``False`` for boolean operators; a plain scalar for most operators; a list for ``"in"`` and ``"range"``. Example:: q = CustomFieldQuery("Status", "exact", "open") q = CustomFieldQuery(42, "gte", 100) q = CustomFieldQuery("Project", "exists", True) str(q) # '["Status", "exact", "open"]' """ def __init__(self, field: int | str, op: _QueryOperation, value: Any) -> None: """Initialise a query atom.""" self._field = field self._op = op self._value = value def build(self) -> list[Any]: """Return the JSON-serialisable list for this expression.""" return [self._field, self._op, self._value] def __str__(self) -> str: """Serialise to the JSON string expected by the API.""" return json.dumps(self.build()) def __repr__(self) -> str: """Return a developer-friendly representation.""" return f"{type(self).__name__}({self.build()!r})" def __and__(self, other: "CustomFieldQuery") -> "_CustomFieldQueryAnd": """Combine with *other* using logical AND.""" return _CustomFieldQueryAnd(self, other) def __or__(self, other: "CustomFieldQuery") -> "_CustomFieldQueryOr": """Combine with *other* using logical OR.""" return _CustomFieldQueryOr(self, other) def __invert__(self) -> "_CustomFieldQueryNot": """Negate this expression.""" return _CustomFieldQueryNot(self) class _CustomFieldQueryAnd(CustomFieldQuery): """Logical AND of two or more sub-expressions: ``["AND", [q0, q1, …]]``. Prefer the ``&`` operator over instantiating this class directly:: q = ( CustomFieldQuery("Status", "exact", "open") & CustomFieldQuery("Amount", "gte", 100) & CustomFieldQuery("Urgent", "exact", True) ) # equivalent to: q = _CustomFieldQueryAnd( CustomFieldQuery("Status", "exact", "open"), CustomFieldQuery("Amount", "gte", 100), CustomFieldQuery("Urgent", "exact", True), ) """ def __init__(self, *queries: CustomFieldQuery) -> None: """Initialise with two or more sub-expressions.""" self._queries = queries def build(self) -> list[Any]: """Return the JSON-serialisable list for this expression.""" return ["AND", [q.build() for q in self._queries]] def __and__(self, other: CustomFieldQuery) -> "_CustomFieldQueryAnd": """Flatten: extend the existing AND instead of nesting.""" return _CustomFieldQueryAnd(*self._queries, other) class _CustomFieldQueryOr(CustomFieldQuery): """Logical OR of two or more sub-expressions: ``["OR", [q0, q1, …]]``. Prefer the ``|`` operator over instantiating this class directly:: q = ( CustomFieldQuery("Category", "exact", "A") | CustomFieldQuery("Category", "exact", "B") ) """ def __init__(self, *queries: CustomFieldQuery) -> None: """Initialise with two or more sub-expressions.""" self._queries = queries def build(self) -> list[Any]: """Return the JSON-serialisable list for this expression.""" return ["OR", [q.build() for q in self._queries]] def __or__(self, other: CustomFieldQuery) -> "_CustomFieldQueryOr": """Flatten: extend the existing OR instead of nesting.""" return _CustomFieldQueryOr(*self._queries, other) class _CustomFieldQueryNot(CustomFieldQuery): """Logical NOT of a sub-expression: ``["NOT", q]``. Prefer the ``~`` prefix operator over instantiating this class directly:: q = ~CustomFieldQuery("Archived", "exact", True) """ def __init__(self, query: CustomFieldQuery) -> None: """Initialise with the sub-expression to negate.""" self._query = query def build(self) -> list[Any]: """Return the JSON-serialisable list for this expression.""" return ["NOT", self._query.build()] tb1337-pypaperless-5d67d60/pypaperless/builders/search.py000066400000000000000000000165721524076126400233640ustar00rootroot00000000000000"""Fluent builder for Tantivy search queries (``/api/search/?query=…``). Combine atoms with ``&`` (AND), ``|`` (OR) and ``~`` (NOT), then pass ``str(q)`` — or the builder itself — as the ``query`` argument to :meth:`~pypaperless.services.search.SearchService.__call__`:: from pypaperless.builders.search import SearchQuery q = SearchQuery("invoice") & SearchQuery.field("tag", "unpaid") result = await paperless.search(q) Use :meth:`SearchQuery.field` and :meth:`SearchQuery.date_range` for field-scoped and date-range terms. Raw Tantivy query strings are also accepted — just pass a ``str`` to the service instead of a builder object. """ from typing import Literal # The following list is based on the schema defined in: # https://github.com/paperless-ngx/paperless-ngx/blob/dev/src/documents/search/_schema.py # -> build_schema() type _SearchField = Literal[ "id", "title", "content", "asn", "correspondent", "correspondent_id", "tag", "tag_id", "document_type", "document_type_id", "storage_path", "storage_path_id", "created", "modified", "added", "notes.note", "notes.user", "num_notes", "custom_fields.value", "custom_fields.name", "owner", "owner_id", "viewer_id", "checksum", "page_count", "original_filename", ] type _SearchDateField = Literal["created", "modified", "added"] # The following list is based on the _DATE_KEYWORDS constant defined in: # https://github.com/paperless-ngx/paperless-ngx/blob/dev/src/documents/search/_query.py # -> rewrite_natural_date_keywords() type _SearchDateKeyword = Literal[ "today", "yesterday", "this month", "previous month", "previous week", "previous quarter", "this year", "previous year", ] class SearchQuery: """A single term or Tantivy query fragment. Use the ``&``, ``|``, ``~`` operators to combine atoms into boolean expressions. Call :func:`str` on the result to obtain the query string accepted by ``/api/search/``. Both raw strings and ``SearchQuery`` objects are accepted by the service:: q1 = SearchQuery("invoice") # plain term q2 = SearchQuery.field("tag", "unpaid") # tag:unpaid q3 = SearchQuery.date_range("created", "2020", "2022") result = await paperless.search(q1 & q2) # "(invoice AND tag:unpaid)" """ def __init__(self, term: str) -> None: """Initialise with a raw Tantivy query term.""" self._term = term @classmethod def field(cls, field_name: _SearchField, value: str | _SearchDateKeyword) -> "SearchQuery": """Build a field-scoped term: ``field_name:value``. Args: field_name: The Paperless field to scope the search to, e.g. ``"document_type"``, ``"tag"``, ``"correspondent"``. value: The exact value to match within that field. Example:: SearchQuery.field("tag", "unpaid") # → tag:unpaid SearchQuery.field("document_type", "invoice") # → document_type:invoice SearchQuery.field("correspondent", "acme") # → correspondent:acme SearchQuery.field("notes.note", "urgent") # → notes.note:urgent SearchQuery.field("custom_fields.name", "amount") # → custom_fields.name:amount """ return cls(f"{field_name}:{value}") @classmethod def date_range( cls, field_name: _SearchDateField, start: str | _SearchDateKeyword, end: str | _SearchDateKeyword, ) -> "SearchQuery": """Build a date-range term: ``field_name:[start to end]``. Args: field_name: The date field, e.g. ``"created"``, ``"added"``, ``"modified"``. start: Range start (year, ISO date, or ``"today"`` / ``"yesterday"``, ...). end: Range end (same formats as *start*). Example:: SearchQuery.date_range("created", "2005", "2009") # → created:[2005 to 2009] SearchQuery.date_range("added", "yesterday", "today") # → added:[yesterday to today] """ return cls(f"{field_name}:[{start} to {end}]") def build(self) -> str: """Return the Tantivy query fragment for this expression.""" return self._term def __str__(self) -> str: """Serialise to the query string expected by ``/api/search/``.""" return self.build() def __repr__(self) -> str: """Return a developer-friendly representation.""" return f"{type(self).__name__}({self.build()!r})" def __and__(self, other: "SearchQuery") -> "_SearchQueryAnd": """Combine with *other* using logical AND.""" return _SearchQueryAnd(self, other) def __or__(self, other: "SearchQuery") -> "_SearchQueryOr": """Combine with *other* using logical OR.""" return _SearchQueryOr(self, other) def __invert__(self) -> "_SearchQueryNot": """Negate this expression.""" return _SearchQueryNot(self) class _SearchQueryAnd(SearchQuery): """Logical AND of two or more sub-expressions: ``(q0 AND q1 …)``. Prefer the ``&`` operator over instantiating this class directly:: q = ( SearchQuery("invoice") & SearchQuery.field("tag", "unpaid") & SearchQuery.date_range("created", "2020", "2022") ) # str(q) → "(invoice AND tag:unpaid AND created:[2020 to 2022])" """ def __init__(self, *queries: SearchQuery) -> None: """Initialise with two or more sub-expressions.""" self._queries = queries def build(self) -> str: """Return the Tantivy query fragment for this AND expression.""" return "(" + " AND ".join(q.build() for q in self._queries) + ")" def __and__(self, other: SearchQuery) -> "_SearchQueryAnd": """Flatten: extend the existing AND instead of nesting.""" return _SearchQueryAnd(*self._queries, other) class _SearchQueryOr(SearchQuery): """Logical OR of two or more sub-expressions: ``(q0 OR q1 …)``. Prefer the ``|`` operator over instantiating this class directly:: q = SearchQuery.field("tag", "inbox") | SearchQuery.field("tag", "important") # str(q) → "(tag:inbox OR tag:important)" """ def __init__(self, *queries: SearchQuery) -> None: """Initialise with two or more sub-expressions.""" self._queries = queries def build(self) -> str: """Return the Tantivy query fragment for this OR expression.""" return "(" + " OR ".join(q.build() for q in self._queries) + ")" def __or__(self, other: SearchQuery) -> "_SearchQueryOr": """Flatten: extend the existing OR instead of nesting.""" return _SearchQueryOr(*self._queries, other) class _SearchQueryNot(SearchQuery): """Logical NOT of a sub-expression: ``NOT q``. Prefer the ``~`` prefix operator over instantiating this class directly:: q = ~SearchQuery.field("document_type", "letter") # str(q) → "NOT document_type:letter" """ def __init__(self, query: SearchQuery) -> None: """Initialise with the sub-expression to negate.""" self._query = query def build(self) -> str: """Return the Tantivy query fragment for this NOT expression.""" return f"NOT {self._query.build()}" tb1337-pypaperless-5d67d60/pypaperless/cache.py000066400000000000000000000012361524076126400213400ustar00rootroot00000000000000"""Provide the PaperlessCache class.""" from typing import TYPE_CHECKING if TYPE_CHECKING: from pypaperless.models.custom_fields import CustomField class PaperlessCache: """In-memory cache for Paperless master data. Held by :class:`~pypaperless.runtime.PaperlessRuntime` and accessible to services and models via ``runtime.cache``. Example:: async with PaperlessClient("localhost:8000", "token") as paperless: fields = paperless.runtime.cache.custom_fields """ def __init__(self) -> None: """Initialize an empty :class:`PaperlessCache`.""" self.custom_fields: dict[int, CustomField] | None = None tb1337-pypaperless-5d67d60/pypaperless/client.py000066400000000000000000000315661524076126400215640ustar00rootroot00000000000000"""Provide the PaperlessClient class.""" import logging from functools import cached_property from typing import Self import httpx from . import services from .cache import PaperlessCache from .dispatch import ModelDispatcher, dispatchable_cached_property from .exceptions import InitializationError from .models.base import DraftLike, PaperlessModel from .runtime import PaperlessRuntime from .settings import PaperlessSettings from .transport import PaperlessTransport class PaperlessClient: """Async client for the Paperless-ngx REST API. The primary entry point for interacting with a Paperless-ngx instance. Use as an async context manager (recommended), or call :meth:`initialize` and :meth:`close` manually. The preferred way to construct a client is directly:: async with PaperlessClient("localhost:8000", "your-token") as paperless: doc = await paperless.documents(42) print(doc.title) For config-object or environment-variable based initialization use the factory class methods :meth:`from_config` and :meth:`from_env`. """ async def __aenter__(self) -> Self: """Return context manager.""" await self.initialize() return self async def __aexit__(self, *_: object) -> None: """Exit context manager.""" await self.close() def __init__( self, url: str, token: str | None = None, *, client: httpx.AsyncClient | None = None, ) -> None: """Initialize a :class:`PaperlessClient` instance. Args: url: A hostname, IP-address, or full URL string. token: An API token from Paperless Django admin or via :func:`~pypaperless.transport.generate_api_token`. client: A custom :class:`httpx.AsyncClient` to use for requests. It is never closed by :meth:`close` — its lifecycle belongs to the caller. Example:: paperless = PaperlessClient("localhost:8000", "your-token") await paperless.initialize() doc = await paperless.documents(42) await paperless.close() """ transport = PaperlessTransport(url, token, client) cache = PaperlessCache() self._runtime = PaperlessRuntime(transport, cache) self._initialized = False self._version: str | None = None self._dispatcher = ModelDispatcher(self) self.logger = logging.getLogger(f"{__package__}") @classmethod def from_config( cls, config: PaperlessSettings, *, client: httpx.AsyncClient | None = None, ) -> "PaperlessClient": """Create a :class:`PaperlessClient` from a :class:`.PaperlessSettings`. Args: config: A :class:`~pypaperless.settings.PaperlessSettings` instance. client: A custom :class:`httpx.AsyncClient` to use for requests. Example:: cfg = PaperlessSettings(url="localhost:8000", token="your-token") async with PaperlessClient.from_config(cfg) as paperless: ... """ return cls( config.url, config.token.get_secret_value() if config.token else None, client=client, ) @classmethod def from_env( cls, *, client: httpx.AsyncClient | None = None, ) -> "PaperlessClient": """Create a :class:`PaperlessClient` from environment variables. Reads ``PYPAPERLESS_URL`` and ``PYPAPERLESS_TOKEN`` from the environment. Args: client: A custom :class:`httpx.AsyncClient` to use for requests. Example:: # export PYPAPERLESS_URL=https://paperless.example.com # export PYPAPERLESS_TOKEN=mytoken async with PaperlessClient.from_env() as paperless: ... """ return cls.from_config(PaperlessSettings(), client=client) @property def base_url(self) -> str: """Return the base URL of the Paperless API endpoint.""" return self._runtime.transport.base_url @property def is_initialized(self) -> bool: """Return ``True`` if the connection has been initialized.""" return self._initialized @property def host_api_version(self) -> int: """Return the API version reported by the Paperless host.""" return self._runtime.api_version @property def host_version(self) -> str | None: """Return the application version reported by the Paperless host.""" return self._version @property def runtime(self) -> PaperlessRuntime: """Return the :class:`~pypaperless.runtime.PaperlessRuntime` shared with services.""" return self._runtime async def close(self) -> None: """Clean up the connection. Closes the internally created HTTP client. A custom :class:`httpx.AsyncClient` passed to the constructor stays open. """ await self._runtime.transport.close() self.logger.info("Closed.") async def initialize(self) -> None: """Initialize and validate the connection to Paperless. Called automatically by :meth:`__aenter__`. Required when **not** using the async context manager:: paperless = PaperlessClient("localhost:8000", "your-token") await paperless.initialize() # … use paperless … await paperless.close() """ try: info = await self._runtime.transport.probe() except InitializationError: raise except Exception as exc: raise InitializationError from exc self._runtime.api_version = info.api_version self._version = info.version self._initialized = True self.logger.info("Initialized.") @cached_property def bulk_edit_objects(self) -> services.BulkEditObjectsService: """Return the :class:`~pypaperless.services.BulkEditObjectsService`.""" return services.BulkEditObjectsService(self._runtime) @cached_property def config(self) -> services.ConfigService: """Return the :class:`~pypaperless.services.ConfigService`.""" return services.ConfigService(self._runtime) @dispatchable_cached_property def correspondents(self) -> services.CorrespondentService: """Return the :class:`~pypaperless.services.CorrespondentService`.""" return services.CorrespondentService(self._runtime) @dispatchable_cached_property def custom_fields(self) -> services.CustomFieldService: """Return the :class:`~pypaperless.services.CustomFieldService`.""" return services.CustomFieldService(self._runtime) @dispatchable_cached_property def documents(self) -> services.DocumentService: """Return the :class:`~pypaperless.services.DocumentService`.""" return services.DocumentService(self._runtime) @dispatchable_cached_property def document_types(self) -> services.DocumentTypeService: """Return the :class:`~pypaperless.services.DocumentTypeService`.""" return services.DocumentTypeService(self._runtime) @cached_property def groups(self) -> services.GroupService: """Return the :class:`~pypaperless.services.GroupService`.""" return services.GroupService(self._runtime) @cached_property def mail_accounts(self) -> services.MailAccountService: """Return the :class:`~pypaperless.services.MailAccountService`.""" return services.MailAccountService(self._runtime) @cached_property def mail_rules(self) -> services.MailRuleService: """Return the :class:`~pypaperless.services.MailRuleService`.""" return services.MailRuleService(self._runtime) @cached_property def processed_mail(self) -> services.ProcessedMailService: """Return the :class:`~pypaperless.services.ProcessedMailService`.""" return services.ProcessedMailService(self._runtime) @cached_property def profile(self) -> services.ProfileService: """Return the :class:`~pypaperless.services.ProfileService`.""" return services.ProfileService(self._runtime) @cached_property def saved_views(self) -> services.SavedViewService: """Return the :class:`~pypaperless.services.SavedViewService`.""" return services.SavedViewService(self._runtime) @cached_property def search(self) -> services.SearchService: """Return the :class:`~pypaperless.services.SearchService`.""" return services.SearchService(self._runtime) @dispatchable_cached_property def share_link_bundles(self) -> services.ShareLinkBundleService: """Return the :class:`~pypaperless.services.ShareLinkBundleService`.""" return services.ShareLinkBundleService(self._runtime) @dispatchable_cached_property def share_links(self) -> services.ShareLinkService: """Return the :class:`~pypaperless.services.ShareLinkService`.""" return services.ShareLinkService(self._runtime) @cached_property def statistics(self) -> services.StatisticService: """Return the :class:`~pypaperless.services.StatisticService`.""" return services.StatisticService(self._runtime) @cached_property def remote_version(self) -> services.RemoteVersionService: """Return the :class:`~pypaperless.services.RemoteVersionService`.""" return services.RemoteVersionService(self._runtime) @cached_property def status(self) -> services.StatusService: """Return the :class:`~pypaperless.services.StatusService`.""" return services.StatusService(self._runtime) @dispatchable_cached_property def storage_paths(self) -> services.StoragePathService: """Return the :class:`~pypaperless.services.StoragePathService`.""" return services.StoragePathService(self._runtime) @dispatchable_cached_property def tags(self) -> services.TagService: """Return the :class:`~pypaperless.services.TagService`.""" return services.TagService(self._runtime) @cached_property def tasks(self) -> services.TaskService: """Return the :class:`~pypaperless.services.TaskService`.""" return services.TaskService(self._runtime) @cached_property def trash(self) -> services.TrashService: """Return the :class:`~pypaperless.services.TrashService`.""" return services.TrashService(self._runtime) @cached_property def users(self) -> services.UserService: """Return the :class:`~pypaperless.services.UserService`.""" return services.UserService(self._runtime) @cached_property def workflows(self) -> services.WorkflowService: """Return the :class:`~pypaperless.services.WorkflowService`.""" return services.WorkflowService(self._runtime) async def update(self, model: PaperlessModel, *, only_changed: bool = True) -> bool: """Send changed model data to Paperless via the registered service. Returns ``True`` when at least one attribute was updated, ``False`` when nothing changed. Dispatches to the service that manages *model*'s type. Args: model: The model instance with modified attributes. only_changed: When ``True`` (default), only modified fields are sent via ``PATCH``. Set to ``False`` to replace the full resource via ``PUT``. Example:: doc = await paperless.documents(42) doc.title = "New Title" if await paperless.update(doc): print("Updated.") """ return await self._dispatcher.update(model, only_changed=only_changed) async def delete(self, model: PaperlessModel, *, silent_fail: bool = False) -> None: """Delete a model from Paperless via the registered service. Raises :exc:`~pypaperless.exceptions.DeletionError` on failure unless *silent_fail* is ``True``. This action cannot be undone. Args: model: The model instance to delete. silent_fail: When ``True``, swallow :exc:`~pypaperless.exceptions.DeletionError` instead of raising. Example:: doc = await paperless.documents(42) await paperless.delete(doc) """ await self._dispatcher.delete(model, silent_fail=silent_fail) async def save(self, draft: DraftLike) -> int | str: """Persist a draft to Paperless via the registered service. Returns the created item's ``id`` (``int``) for synchronous creation, or a Celery task UUID (``str``) for asynchronous creation (e.g. documents). Args: draft: A draft model instance created by a service's ``create`` method. Example:: draft = paperless.correspondents.create(name="ACME") new_id = await paperless.save(draft) """ return await self._dispatcher.save(draft) tb1337-pypaperless-5d67d60/pypaperless/const.py000066400000000000000000000123041524076126400214210ustar00rootroot00000000000000"""PyPaperless constants.""" from enum import StrEnum from typing import Self API_VERSION = 10 ENV_PREFIX = "PYPAPERLESS_" ENV_URL = f"{ENV_PREFIX}URL" ENV_TOKEN = f"{ENV_PREFIX}TOKEN" class EndpointPath(StrEnum): """URL paths for all Paperless-ngx REST API endpoints. Members are plain :class:`str` values, so ``.format(pk=42)`` works directly on any member that contains a ``{pk}`` placeholder:: url = EndpointPath.DOCUMENTS_SINGLE.format(pk=42) # → "/api/documents/42/" """ INDEX = "/api/schema/" TOKEN = "/api/token/" BULK_EDIT_OBJECTS = "/api/bulk_edit_objects/" CONFIG = "/api/config/" CONFIG_SINGLE = "/api/config/{pk}/" CORRESPONDENTS = "/api/correspondents/" CORRESPONDENTS_SINGLE = "/api/correspondents/{pk}/" CUSTOM_FIELDS = "/api/custom_fields/" CUSTOM_FIELDS_SINGLE = "/api/custom_fields/{pk}/" DOCUMENT_TYPES = "/api/document_types/" DOCUMENT_TYPES_SINGLE = "/api/document_types/{pk}/" DOCUMENTS = "/api/documents/" DOCUMENTS_AI_SUGGESTIONS = "/api/documents/{pk}/ai_suggestions/" DOCUMENTS_BULK_EDIT = "/api/documents/bulk_edit/" DOCUMENTS_CHAT = "/api/documents/chat/" DOCUMENTS_DELETE = "/api/documents/delete/" DOCUMENTS_DOWNLOAD = "/api/documents/{pk}/download/" DOCUMENTS_EDIT_PDF = "/api/documents/edit_pdf/" DOCUMENTS_EMAIL = "/api/documents/email/" DOCUMENTS_HISTORY = "/api/documents/{pk}/history/" DOCUMENTS_MERGE = "/api/documents/merge/" DOCUMENTS_META = "/api/documents/{pk}/metadata/" DOCUMENTS_NEXT_ASN = "/api/documents/next_asn/" DOCUMENTS_NOTES = "/api/documents/{pk}/notes/" DOCUMENTS_POST = "/api/documents/post_document/" DOCUMENTS_PREVIEW = "/api/documents/{pk}/preview/" DOCUMENTS_REMOVE_PASSWORD = "/api/documents/remove_password/" DOCUMENTS_REPROCESS = "/api/documents/reprocess/" DOCUMENTS_ROTATE = "/api/documents/rotate/" DOCUMENTS_ROOT = "/api/documents/{pk}/root/" DOCUMENTS_SHARE_LINKS = "/api/documents/{pk}/share_links/" DOCUMENTS_SINGLE = "/api/documents/{pk}/" DOCUMENTS_SUGGESTIONS = "/api/documents/{pk}/suggestions/" DOCUMENTS_THUMBNAIL = "/api/documents/{pk}/thumb/" DOCUMENTS_UPDATE_VERSION = "/api/documents/{pk}/update_version/" DOCUMENTS_VERSION = "/api/documents/{pk}/versions/{version_id}/" GROUPS = "/api/groups/" GROUPS_SINGLE = "/api/groups/{pk}/" MAIL_ACCOUNTS = "/api/mail_accounts/" MAIL_ACCOUNTS_PROCESS = "/api/mail_accounts/{pk}/process/" MAIL_ACCOUNTS_SINGLE = "/api/mail_accounts/{pk}/" MAIL_ACCOUNTS_TEST = "/api/mail_accounts/test/" MAIL_RULES = "/api/mail_rules/" MAIL_RULES_SINGLE = "/api/mail_rules/{pk}/" PROCESSED_MAIL = "/api/processed_mail/" PROCESSED_MAIL_SINGLE = "/api/processed_mail/{pk}/" PROFILE = "/api/profile/" REMOTE_VERSION = "/api/remote_version/" SAVED_VIEWS = "/api/saved_views/" SAVED_VIEWS_SINGLE = "/api/saved_views/{pk}/" SEARCH = "/api/search/" SEARCH_AUTOCOMPLETE = "/api/search/autocomplete/" SHARE_LINKS = "/api/share_links/" SHARE_LINKS_SINGLE = "/api/share_links/{pk}/" SHARE_LINK_BUNDLES = "/api/share_link_bundles/" SHARE_LINK_BUNDLES_REBUILD = "/api/share_link_bundles/{pk}/rebuild/" SHARE_LINK_BUNDLES_SINGLE = "/api/share_link_bundles/{pk}/" STATISTICS = "/api/statistics/" STATUS = "/api/status/" STORAGE_PATHS = "/api/storage_paths/" STORAGE_PATHS_SINGLE = "/api/storage_paths/{pk}/" TAGS = "/api/tags/" TAGS_SINGLE = "/api/tags/{pk}/" TASKS = "/api/tasks/" TASKS_ACKNOWLEDGE = "/api/tasks/acknowledge/" TASKS_ACTIVE = "/api/tasks/active/" TASKS_RUN = "/api/tasks/run/" TASKS_SINGLE = "/api/tasks/{pk}/" TASKS_STATUS_COUNTS = "/api/tasks/status_counts/" TASKS_SUMMARY = "/api/tasks/summary/" TRASH = "/api/trash/" USERS = "/api/users/" USERS_SINGLE = "/api/users/{pk}/" WORKFLOW_ACTIONS = "/api/workflow_actions/" WORKFLOW_ACTIONS_SINGLE = "/api/workflow_actions/{pk}/" WORKFLOW_TRIGGERS = "/api/workflow_triggers/" WORKFLOW_TRIGGERS_SINGLE = "/api/workflow_triggers/{pk}/" WORKFLOWS = "/api/workflows/" WORKFLOWS_SINGLE = "/api/workflows/{pk}/" class PaperlessResource(StrEnum): """Represent resource names of Paperless-ngx API endpoints.""" BULK_EDIT_OBJECTS = "bulk_edit_objects" CONFIG = "config" CORRESPONDENTS = "correspondents" CUSTOM_FIELDS = "custom_fields" DOCUMENTS = "documents" DOCUMENT_TYPES = "document_types" GROUPS = "groups" MAIL_ACCOUNTS = "mail_accounts" MAIL_RULES = "mail_rules" PROCESSED_MAIL = "processed_mail" PROFILE = "profile" SAVED_VIEWS = "saved_views" SEARCH = "search" SHARE_LINK_BUNDLES = "share_link_bundles" SHARE_LINKS = "share_links" STATISTICS = "statistics" REMOTE_VERSION = "remote_version" STATUS = "status" STORAGE_PATHS = "storage_paths" TAGS = "tags" TASKS = "tasks" TRASH = "trash" USERS = "users" WORKFLOWS = "workflows" WORKFLOW_ACTIONS = "workflow_actions" WORKFLOW_TRIGGERS = "workflow_triggers" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Set default member on unknown value.""" return cls.UNKNOWN tb1337-pypaperless-5d67d60/pypaperless/dispatch.py000066400000000000000000000170441524076126400221000ustar00rootroot00000000000000"""Model operation dispatcher for PyPaperless.""" from __future__ import annotations from typing import TYPE_CHECKING, cast, get_type_hints, overload from pypaperless.exceptions import DispatchError from pypaperless.services.base import PaperlessService from pypaperless.services.mixins import CreatableService, DeletableService, UpdatableService if TYPE_CHECKING: from collections.abc import Callable from pypaperless.client import PaperlessClient from pypaperless.models.base import DraftLike, PaperlessModel __all__ = ("ModelDispatcher", "dispatchable_cached_property") _MODEL_TO_PROP_NAME: dict[type[PaperlessModel], tuple[str, ...]] = {} def _is_writable_service(cls: type[PaperlessService]) -> bool: """Return True if *cls* implements any write operation (create, update, or delete).""" return issubclass(cls, (CreatableService, DeletableService, UpdatableService)) class DispatchableCachedProperty[ServiceT: PaperlessService]: """Cached-property decorator that lazily instantiates a service and registers its models. Works like :func:`functools.cached_property` but additionally reads the decorated function's ``return`` annotation to find the service class and, at class-definition time via :meth:`__set_name__`, registers ``_resource_cls`` and ``_draft_cls`` from that service class into the module-level :data:`_MODEL_TO_PROP_NAME` registry. This enables :class:`ModelDispatcher` to route :meth:`~ModelDispatcher.update`, :meth:`~ModelDispatcher.delete`, and :meth:`~ModelDispatcher.save` calls without the caller knowing which service to use. Example:: class MyClient: @dispatchable_cached_property def correspondents(self) -> CorrespondentService: return CorrespondentService(self._runtime) """ _attr_name: str def __init__(self, func: Callable[..., ServiceT]) -> None: """Store the factory function and copy its docstring.""" self._func = func self._attr_name = "" self.__doc__ = func.__doc__ def __set_name__(self, owner: type[object], name: str) -> None: """Cache the attribute name and register model types from the return annotation.""" self._attr_name = name try: hints = get_type_hints(self._func) except (NameError, AttributeError, TypeError): return service_cls = hints.get("return") if not (isinstance(service_cls, type) and issubclass(service_cls, PaperlessService)): return for cls_attr in ("_resource_cls", "_draft_cls"): model_cls: type[PaperlessModel] | None = getattr(service_cls, cls_attr, None) if model_cls is not None: _MODEL_TO_PROP_NAME[model_cls] = (name,) for sub_name, attr_val in vars(service_cls).items(): if not isinstance(attr_val, property) or attr_val.fget is None: continue try: sub_hints = get_type_hints(attr_val.fget) except (NameError, AttributeError, TypeError): continue sub_cls = sub_hints.get("return") if not (isinstance(sub_cls, type) and issubclass(sub_cls, PaperlessService)): continue if not _is_writable_service(sub_cls): continue for cls_attr in ("_resource_cls", "_draft_cls"): sub_model_cls: type[PaperlessModel] | None = getattr(sub_cls, cls_attr, None) if sub_model_cls is not None: _MODEL_TO_PROP_NAME[sub_model_cls] = (name, sub_name) @overload def __get__(self, obj: None, objtype: type[object]) -> DispatchableCachedProperty[ServiceT]: ... @overload def __get__(self, obj: object, objtype: type[object] | None = ...) -> ServiceT: ... def __get__( self, obj: object | None, objtype: type[object] | None = None ) -> DispatchableCachedProperty[ServiceT] | ServiceT: """Return ``self`` on class access; return (and cache) the service on instance access.""" if obj is None: return self result = self._func(obj) vars(obj)[self._attr_name] = result return result dispatchable_cached_property = DispatchableCachedProperty class ModelDispatcher: """Dispatch :meth:`update`, :meth:`delete`, and :meth:`save` to the registered service. :class:`ModelDispatcher` is the bridge between a :class:`~pypaperless.client.PaperlessClient` and its services when operating purely on model instances. It looks up the responsible service from the module-level registry (populated by :class:`DispatchableCachedProperty` at import time) and delegates the call — without the caller needing to know which service to use. Args: client: The :class:`~pypaperless.client.PaperlessClient` that owns this dispatcher. Example:: async with PaperlessClient("localhost:8000", "token") as paperless: doc = await paperless.documents(42) doc.title = "Renamed" await paperless.update(doc) # delegates to DocumentService.update await paperless.delete(doc) # delegates to DocumentService.delete draft = paperless.tags.create(name="invoice") new_id = await paperless.save(draft) # delegates to TagService.save """ def __init__(self, client: PaperlessClient) -> None: """Store a reference to the owning client.""" self._client = client def _get_service(self, model_type: type[PaperlessModel]) -> PaperlessService: """Return the service registered for *model_type*, or raise on unknown types.""" path = _MODEL_TO_PROP_NAME.get(model_type) if path is None: msg = ( f"No service registered for {model_type.__name__!r}. " "Only models managed by a CRUD service can be dispatched." ) raise DispatchError(msg) obj: object = self._client for attr in path: obj = getattr(obj, attr) return cast("PaperlessService", obj) async def update(self, model: PaperlessModel, *, only_changed: bool = True) -> bool: """Resolve the service for *model* and delegate to its ``update`` method.""" service = self._get_service(type(model)) if not isinstance(service, UpdatableService): msg = f"Service for {type(model).__name__!r} does not support 'update'." raise DispatchError(msg) return await cast("UpdatableService[PaperlessModel]", service).update( model, only_changed=only_changed ) async def delete(self, model: PaperlessModel, *, silent_fail: bool = False) -> None: """Resolve the service for *model* and delegate to its ``delete`` method.""" service = self._get_service(type(model)) if not isinstance(service, DeletableService): msg = f"Service for {type(model).__name__!r} does not support 'delete'." raise DispatchError(msg) await cast("DeletableService[PaperlessModel]", service).delete( model, silent_fail=silent_fail ) async def save(self, draft: DraftLike) -> int | str: """Resolve the service for *draft* and delegate to its ``save`` method.""" model_type = type(cast("PaperlessModel", draft)) service = self._get_service(model_type) if not isinstance(service, CreatableService): msg = f"Service for {type(draft).__name__!r} does not support 'save'." raise DispatchError(msg) return await cast("CreatableService[PaperlessModel]", service).save(draft) tb1337-pypaperless-5d67d60/pypaperless/exceptions.py000066400000000000000000000125161524076126400224610ustar00rootroot00000000000000"""PyPaperless exceptions.""" from typing import TYPE_CHECKING, Any if TYPE_CHECKING: import httpx class PaperlessError(Exception): """Base exception for PyPaperless.""" # Session / transport class InitializationError(PaperlessError): """Raised when initializing a `Paperless` instance without valid url or token.""" class PaperlessConnectionError(InitializationError): """Raised when connection to Paperless is not possible.""" class PaperlessTimeoutError(PaperlessConnectionError): """Raised when a request to Paperless times out. The host is reachable but did not respond in time — retrying, or using a custom :class:`httpx.AsyncClient` with a higher timeout, may help. """ class AuthError(InitializationError): """Raised when response is 401 code.""" class InvalidTokenError(AuthError): """Raised when response is 401 due invalid access token.""" class InactiveOrDeletedError(AuthError): """Raised when response is 401 code due user is inactive or deleted.""" class ForbiddenError(InitializationError): """Raised when response is 403 code.""" # Response parsing class ResponseError(PaperlessError): """Raised when the API returns an unexpected or error response.""" class BadJsonResponseError(ResponseError): """Raised when response is no valid json.""" class JsonResponseWithError(ResponseError): """Raised when Paperless accepted the request, but responded with an error payload.""" def __init__(self, payload: Any) -> None: """Initialize a `JsonResponseWithError` instance.""" def _parse_payload(payload: Any, key: list[str] | None = None) -> tuple[list[str], str]: """Parse first suitable error from payload without mutating it.""" if key is None: key = [] if isinstance(payload, list) and payload: return _parse_payload(payload[0], key) if isinstance(payload, dict) and payload: if "error" in payload: key.append("error") return _parse_payload(payload["error"], key) new_key = next(iter(payload)) key.append(new_key) return _parse_payload(payload[new_key], key) if isinstance(payload, list | dict): return key, "unknown error" return key, payload key, message = _parse_payload(payload) if not key: key.append("error") key_chain = " -> ".join(key) super().__init__(f"Paperless [{key_chain}]: {message}") class NotFoundError(ResponseError): """Raised when the requested resource does not exist (HTTP 404).""" def __init__(self, response: "httpx.Response") -> None: """Initialize a `NotFoundError` instance.""" self.response = response super().__init__(f"Requested resource does not exist: {response.request.url}") class UnexpectedStatusError(ResponseError): """Raised when Paperless responds with an unhandled non-2xx status code.""" def __init__(self, response: "httpx.Response") -> None: """Initialize an `UnexpectedStatusError` instance.""" self.response = response super().__init__( f"Paperless responded with HTTP {response.status_code} ({response.reason_phrase})." ) class BulkEditError(ResponseError): """Raised when a bulk edit operation returned a non-OK result.""" def __init__(self, result: str) -> None: """Initialize a `BulkEditError` instance.""" super().__init__(f"Bulk edit operation returned a non-OK result: {result!r}") # Draft lifecycle class DraftError(PaperlessError): """Raised when a draft lifecycle operation fails.""" class DraftFieldRequiredError(DraftError): """Raised when trying to save models with missing required fields.""" class DraftNotSupportedError(DraftError): """Raised when trying to draft unsupported models.""" # Dispatch class DispatchError(PaperlessError): """Raised when a model dispatch operation cannot be completed.""" # Resource access class ResourceError(PaperlessError): """Raised when a resource access or lookup operation fails.""" class DeletionError(ResourceError): """Raised when a delete operation fails (non-2xx HTTP response).""" class ItemNotFoundError(ResourceError): """Raised when trying to access non-existing items in DocumentCustomFieldList classes.""" class PrimaryKeyRequiredError(ResourceError): """Raised when trying to access model data without supplying a pk.""" class TaskNotFoundError(ResourceError): """Raised when trying to access a task by non-existing uuid.""" def __init__(self, task_id: str) -> None: """Initialize a `TaskNotFound` instance.""" super().__init__(f"Task with UUID {task_id} not found.") # Document operations class DocumentError(PaperlessError): """Raised when a document-specific service operation fails.""" class AsnRequestError(DocumentError): """Raised when getting an error during requesting the next asn.""" class SendEmailError(DocumentError): """Raised when sending email for a document fails.""" class BulkEditPagesError(DocumentError, ValueError): """Raised when a page selection for a PDF page operation cannot be applied. Also a :class:`ValueError`, so callers may catch either that or :class:`PaperlessError`. """ tb1337-pypaperless-5d67d60/pypaperless/models/000077500000000000000000000000001524076126400212045ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/models/__init__.py000066400000000000000000000041421524076126400233160ustar00rootroot00000000000000"""PyPaperless models.""" from pypaperless.pagination import Page from .config import Config from .correspondents import Correspondent, CorrespondentDraft from .custom_fields import CustomField, CustomFieldDraft from .document_types import DocumentType, DocumentTypeDraft from .documents import ( Document, DocumentAISuggestions, DocumentChat, DocumentCustomFieldList, DocumentDraft, DocumentHistory, DocumentHistoryAction, DocumentMeta, DocumentNote, DocumentNoteDraft, DocumentRoot, DocumentSuggestions, DocumentVersionInfo, DownloadedDocument, ) from .mails import MailAccount, MailRule, ProcessedMail from .permissions import Group, User from .profile import Profile, ProfileSocialAccount from .remote_version import RemoteVersion from .saved_views import SavedView from .search import SearchResult from .share_links import ShareLink, ShareLinkBundle, ShareLinkBundleDraft, ShareLinkDraft from .statistics import Statistic from .status import Status from .storage_paths import StoragePath, StoragePathDraft from .tags import Tag, TagDraft from .tasks import Task from .workflows import Workflow, WorkflowAction, WorkflowTrigger __all__ = ( "Config", "Correspondent", "CorrespondentDraft", "CustomField", "CustomFieldDraft", "Document", "DocumentAISuggestions", "DocumentChat", "DocumentCustomFieldList", "DocumentDraft", "DocumentHistory", "DocumentHistoryAction", "DocumentMeta", "DocumentNote", "DocumentNoteDraft", "DocumentRoot", "DocumentSuggestions", "DocumentType", "DocumentTypeDraft", "DocumentVersionInfo", "DownloadedDocument", "Group", "MailAccount", "MailRule", "Page", "ProcessedMail", "Profile", "ProfileSocialAccount", "RemoteVersion", "SavedView", "SearchResult", "ShareLink", "ShareLinkBundle", "ShareLinkBundleDraft", "ShareLinkDraft", "Statistic", "Status", "StoragePath", "StoragePathDraft", "Tag", "TagDraft", "Task", "User", "Workflow", "WorkflowAction", "WorkflowTrigger", ) tb1337-pypaperless-5d67d60/pypaperless/models/base.py000066400000000000000000000131461524076126400224750ustar00rootroot00000000000000"""Provide base classes.""" from typing import TYPE_CHECKING, Any, ClassVar, Protocol, Self, TypeVar, final from pydantic import BaseModel, ConfigDict, PrivateAttr from pypaperless.const import EndpointPath if TYPE_CHECKING: from pypaperless.runtime import PaperlessRuntime ResourceT = TypeVar("ResourceT", bound="PaperlessModel") IdentifiedT = TypeVar("IdentifiedT", bound="IdentifiedModel") class DraftLike(Protocol): """Protocol satisfied by all draft model classes. Any object that exposes :attr:`api_path`, :meth:`validate_draft`, and :meth:`serialize` is a valid draft that can be passed to :meth:`~pypaperless.client.PaperlessClient.save`. """ @property def api_path(self) -> str: """Return the API path for this draft.""" ... def validate_draft(self) -> None: """Raise if required fields are missing.""" ... def serialize(self) -> dict[str, Any]: """Return a serialized representation suitable for the API.""" ... class _PaperlessBase(BaseModel): """Internal base: binds ``_runtime`` from validation context and provides ``from_data``.""" model_config = ConfigDict(arbitrary_types_allowed=True) _runtime: "PaperlessRuntime" = PrivateAttr() def model_post_init(self, __context: Any, /) -> None: """Bind ``_runtime`` from validation context.""" if isinstance(__context, dict) and "runtime" in __context: self._runtime = __context["runtime"] @classmethod def from_data(cls, runtime: "PaperlessRuntime", data: Any, **context: Any) -> Self: """Return a new instance of ``cls`` from ``data``.""" return cls.model_validate(data, context={"runtime": runtime, **context}) class PaperlessModel(_PaperlessBase): """Base class for all models in PyPaperless.""" model_config = ConfigDict( arbitrary_types_allowed=True, populate_by_name=True, use_enum_values=False, validate_assignment=True, ) _api_path: ClassVar[str] = EndpointPath.INDEX _pk_field: ClassVar[str] = "id" _dump_exclude: ClassVar[set[str]] = set() _snapshot_source: ClassVar[dict[str, Any] | None] = None _snapshot_cache: dict[str, Any] | None = PrivateAttr(default=None) def model_post_init(self, __context: Any, /) -> None: """Bind `_runtime` from validation context and resolve the instance API path.""" super().model_post_init(__context) pk = getattr(self, self._pk_field, None) if pk is not None: object.__setattr__(self, "_api_path", self._api_path.format(pk=pk)) if not (isinstance(__context, dict) and "runtime" in __context): # direct construction without an API payload - freeze the state now, # since there is no raw source to derive the snapshot from later self._snapshot_cache = self.api_dump() def api_dump(self) -> dict[str, Any]: """Return the JSON-safe field state as it is sent to the Paperless API. Keys are the API field names (aliases), fields marked ``exclude=True`` and binary payload fields listed in ``_dump_exclude`` are omitted. Example:: document = await paperless.documents(42) payload = document.api_dump() print(payload["title"]) """ return self.model_dump(mode="json", by_alias=True, exclude=self._dump_exclude or None) @classmethod def format_api_path(cls, **kwargs: Any) -> str: """Return the formatted API path for this model class.""" return cls._api_path.format(**kwargs) @final @classmethod def from_data( cls, runtime: "PaperlessRuntime", data: dict[str, Any], **_context: Any, ) -> Self: """Return a new instance of `cls` from `data`. Primarily used by service-level factory methods. The raw payload is kept as the snapshot source for lazy change tracking - it must not be mutated afterwards. """ instance = cls.model_validate(data, context={"runtime": runtime}) object.__setattr__(instance, "_snapshot_source", data) return instance @property def api_path(self) -> str: """Return the API path for this model instance.""" return self._api_path @property def snapshot(self) -> dict[str, Any]: """Return the serialized field state as of the last API sync. Computed lazily on first access from the raw API payload, so plain reads and iteration never pay for change tracking. Both the snapshot and the current state pass through :meth:`api_dump`, which keeps the change detection in ``update()`` free of normalization artifacts. """ if self._snapshot_cache is None: if self._snapshot_source is not None: fresh = type(self).from_data(self._runtime, self._snapshot_source) self._snapshot_cache = fresh.api_dump() else: self._snapshot_cache = self.api_dump() return self._snapshot_cache def refresh_from(self, data: dict[str, Any]) -> None: """Replace all field values and snapshot source in-place from a fresh API response.""" fresh = type(self).from_data(self._runtime, data) for name in self.__class__.model_fields: setattr(self, name, getattr(fresh, name)) object.__setattr__(self, "_snapshot_source", data) self._snapshot_cache = None class IdentifiedModel(PaperlessModel): """Base class for models whose API endpoint always provides an ``id``. Subclasses expose ``id`` as a required, non-optional ``int``. """ id: int tb1337-pypaperless-5d67d60/pypaperless/models/bulk_edit.py000066400000000000000000000010241524076126400235150ustar00rootroot00000000000000"""Models and types for bulk-edit operations.""" from typing import Any, Literal, TypedDict type BulkEditObjectType = Literal["tags", "correspondents", "document_types", "storage_paths"] type SourceMode = Literal["latest_version", "explicit_selection"] type CustomFieldsInput = list[int] | dict[int, Any] class _EditPdfOperationRequired(TypedDict): page: int class EditPdfOperation(_EditPdfOperationRequired, total=False): """Represent one page operation for the edit_pdf bulk action.""" rotate: int doc: int tb1337-pypaperless-5d67d60/pypaperless/models/config.py000066400000000000000000000103471524076126400230300ustar00rootroot00000000000000"""Provide `Config` related models.""" from enum import StrEnum from typing import ClassVar, Self from pypaperless.const import EndpointPath from .base import IdentifiedModel class ArchiveFileGeneration(StrEnum): """Represent a subtype of `Config`.""" AUTO = "auto" ALWAYS = "always" NEVER = "never" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class ColorConversionStrategy(StrEnum): """Represent a subtype of `Config`.""" UNCHANGED = "LeaveColorUnchanged" RGB = "RGB" INDEPENDENT = "UseDeviceIndependentColor" GRAY = "Gray" CMYK = "CMYK" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class LLMBackend(StrEnum): """Represent a subtype of `Config`.""" OPENAI_LIKE = "openai-like" OLLAMA = "ollama" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class LLMEmbeddingBackend(StrEnum): """Represent a subtype of `Config`.""" OPENAI_LIKE = "openai-like" HUGGINGFACE = "huggingface" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class OcrMode(StrEnum): """Represent a subtype of `Config`.""" AUTO = "auto" FORCE = "force" REDO = "redo" OFF = "off" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class OutputType(StrEnum): """Represent a subtype of `Config`.""" PDF = "pdf" PDF_A = "pdfa" PDF_A1 = "pdfa-1" PDF_A2 = "pdfa-2" PDF_A3 = "pdfa-3" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class UnpaperClean(StrEnum): """Represent a subtype of `Config`.""" CLEAN = "clean" FINAL = "clean-final" NONE = "none" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class Config(IdentifiedModel): """Represent a Paperless `Config`.""" _api_path: ClassVar[str] = EndpointPath.CONFIG_SINGLE user_args: str | None = None output_type: OutputType | None = None pages: int | None = None language: str | None = None mode: OcrMode | None = None image_dpi: int | None = None unpaper_clean: UnpaperClean | None = None deskew: bool | None = None rotate_pages: bool | None = None rotate_pages_threshold: float | None = None max_image_pixels: float | None = None color_conversion_strategy: ColorConversionStrategy | None = None app_title: str | None = None app_logo: str | None = None archive_file_generation: ArchiveFileGeneration | None = None barcode_tag_mapping: dict[str, str] | None = None barcodes_enabled: bool | None = None barcode_enable_tiff_support: bool | None = None barcode_string: str | None = None barcode_retain_split_pages: bool | None = None barcode_enable_asn: bool | None = None barcode_asn_prefix: str | None = None barcode_upscale: float | None = None barcode_dpi: int | None = None barcode_max_pages: int | None = None barcode_enable_tag: bool | None = None barcode_tag_split: bool | None = None ai_enabled: bool | None = None llm_embedding_backend: LLMEmbeddingBackend | None = None llm_embedding_model: str | None = None llm_embedding_endpoint: str | None = None llm_embedding_chunk_size: int | None = None llm_context_size: int | None = None llm_endpoint: str | None = None llm_model: str | None = None llm_api_key: str | None = None llm_backend: LLMBackend | None = None llm_output_language: str | None = None llm_request_timeout: int | None = None tb1337-pypaperless-5d67d60/pypaperless/models/correspondents.py000066400000000000000000000022741524076126400246330ustar00rootroot00000000000000"""Provide `Correspondent` related models.""" import datetime from typing import ClassVar from pypaperless.const import EndpointPath, PaperlessResource from . import mixins from .base import IdentifiedModel, PaperlessModel class Correspondent( IdentifiedModel, mixins.MatchingFieldsModel, mixins.SecurableModel, ): """Represent a Paperless `Correspondent`.""" _api_path: ClassVar[str] = EndpointPath.CORRESPONDENTS_SINGLE _resource: ClassVar[PaperlessResource] = PaperlessResource.CORRESPONDENTS slug: str | None = None name: str | None = None document_count: int | None = None last_correspondence: datetime.date | None = None class CorrespondentDraft( PaperlessModel, mixins.MatchingFieldsModel, mixins.SecurableDraftModel, mixins.CreatableModel, ): """Represent a new `Correspondent`, which is not yet stored in Paperless.""" _api_path: ClassVar[str] = EndpointPath.CORRESPONDENTS _resource: ClassVar[PaperlessResource] = PaperlessResource.CORRESPONDENTS _create_required_fields: ClassVar[set[str]] = { "name", "match", "matching_algorithm", "is_insensitive", } name: str | None = None tb1337-pypaperless-5d67d60/pypaperless/models/custom_fields.py000066400000000000000000000220001524076126400244100ustar00rootroot00000000000000"""Provide `CustomField` related models.""" import contextlib import datetime import re from enum import StrEnum from typing import Annotated, Any, ClassVar, Literal, Self, TypeVar, overload from pydantic import BaseModel, Field, TypeAdapter, field_validator from pypaperless.const import EndpointPath, PaperlessResource from . import mixins from .base import IdentifiedModel, PaperlessModel class CustomFieldSelectOptions(BaseModel): """Represent the `extra_data.select_options` field of a `CustomField`.""" id: str | None = None label: str | None = None class CustomFieldExtraData(BaseModel): """Represent the `extra_data` field of a `CustomField`.""" default_currency: str | None = None select_options: list[CustomFieldSelectOptions | None] = Field(default_factory=list) class CustomFieldType(StrEnum): """Represent a subtype of `CustomField`.""" STRING = "string" URL = "url" DATE = "date" BOOLEAN = "boolean" INTEGER = "integer" FLOAT = "float" MONETARY = "monetary" DOCUMENT_LINK = "documentlink" SELECT = "select" LONGTEXT = "longtext" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class CustomFieldValue(BaseModel): """Represent a subtype of `CustomField`. ``name``, ``data_type``, and ``extra_data`` are enriched client-side from the custom-fields cache and excluded from serialization - the API only accepts ``field`` and ``value``. """ field: int | None = None value: Any | None = None name: str | None = Field(default=None, exclude=True) data_type: CustomFieldType | None = Field(default=None, exclude=True) extra_data: CustomFieldExtraData | None = Field(default=None, exclude=True) CustomFieldValueT = TypeVar("CustomFieldValueT", bound=CustomFieldValue) class CustomFieldBooleanValue(CustomFieldValue): """Represent a boolean `CustomFieldValue`.""" value: bool | None = None data_type: Literal[CustomFieldType.BOOLEAN] = Field( default=CustomFieldType.BOOLEAN, exclude=True ) class CustomFieldDateValue(CustomFieldValue): """Represent a date `CustomFieldValue`.""" value: datetime.date | str | None = None data_type: Literal[CustomFieldType.DATE] = Field(default=CustomFieldType.DATE, exclude=True) @field_validator("value", mode="before") @classmethod def _parse_date(cls, v: datetime.date | str | None) -> datetime.date | str | None: """Convert the value to a date.""" if isinstance(v, str): with contextlib.suppress(ValueError): dt = datetime.datetime.fromisoformat(v) return dt.date() return v class CustomFieldDocumentLinkValue(CustomFieldValue): """Represent a document link `CustomFieldValue`.""" value: list[int] | int | None = None data_type: Literal[CustomFieldType.DOCUMENT_LINK] = Field( default=CustomFieldType.DOCUMENT_LINK, exclude=True ) class CustomFieldFloatValue(CustomFieldValue): """Represent a float `CustomFieldValue`.""" value: float | None = None data_type: Literal[CustomFieldType.FLOAT] = Field(default=CustomFieldType.FLOAT, exclude=True) class CustomFieldIntegerValue(CustomFieldValue): """Represent an integer `CustomFieldValue`.""" value: int | None = None data_type: Literal[CustomFieldType.INTEGER] = Field( default=CustomFieldType.INTEGER, exclude=True ) class CustomFieldMonetaryValue(CustomFieldValue): """Represent a monetary `CustomFieldValue`.""" value: str | None = None data_type: Literal[CustomFieldType.MONETARY] = Field( default=CustomFieldType.MONETARY, exclude=True ) @field_validator("value", mode="before") @classmethod def _coerce_value_to_str(cls, v: Any) -> str | None: """Coerce a non-string monetary value to a string.""" if v is None: return None return str(v) @property def currency(self) -> str | None: """Return the currency of the `value` field.""" if self.value and (match := re.match(r"^([a-zA-Z]{3})", self.value)): return match.group(1) if self.extra_data and self.extra_data.default_currency: return self.extra_data.default_currency return "" @currency.setter def currency(self, new_currency: str) -> None: """Override the currency of the field.""" value = self.value or "" value = re.sub(r"^[a-zA-Z]{3}", "", value) if new_currency and re.match(r"^[A-Z]{3}$", new_currency): self.value = f"{new_currency}{value}" else: self.value = value @property def amount(self) -> float | None: """Return the amount without currentcy of the `value` field.""" if self.value: numeric = re.sub(r"[^\d.]", "", self.value) return float(numeric) return None @amount.setter def amount(self, new_amount: float) -> None: """Set a new amount and construct the internal needed value.""" self.value = f"{self.currency}{new_amount:.2f}" class CustomFieldSelectValue(CustomFieldValue): """Represent a select `CustomFieldValue`.""" value: int | str | None = None data_type: Literal[CustomFieldType.SELECT] = Field(default=CustomFieldType.SELECT, exclude=True) @property def labels(self) -> list[CustomFieldSelectOptions | None]: """Return the list of labels of the `CustomField`.""" if not self.extra_data: return [] return self.extra_data.select_options @property def label(self) -> str | None: """Return the label for `value` or fall back to `None`.""" for opt in self.labels: if opt and opt.id == self.value: return opt.label return None class CustomFieldStringValue(CustomFieldValue): """Represent a string or longtext `CustomFieldValue`.""" value: str | None = None data_type: Literal[CustomFieldType.STRING, CustomFieldType.LONGTEXT] = Field( default=CustomFieldType.STRING, exclude=True ) class CustomFieldURLValue(CustomFieldValue): """Represent an url `CustomFieldValue`.""" value: str | None = None data_type: Literal[CustomFieldType.URL] = Field(default=CustomFieldType.URL, exclude=True) # Discriminated union resolving a value payload to its typed class via ``data_type``. TypedCustomFieldValue = Annotated[ CustomFieldBooleanValue | CustomFieldDateValue | CustomFieldDocumentLinkValue | CustomFieldFloatValue | CustomFieldIntegerValue | CustomFieldMonetaryValue | CustomFieldSelectValue | CustomFieldStringValue | CustomFieldURLValue, Field(discriminator="data_type"), ] # A typed value when ``data_type`` is known, a plain ``CustomFieldValue`` otherwise. AnyCustomFieldValue = Annotated[ TypedCustomFieldValue | CustomFieldValue, Field(union_mode="left_to_right"), ] _ANY_VALUE_ADAPTER = TypeAdapter[CustomFieldValue](AnyCustomFieldValue) class CustomField( IdentifiedModel, ): """Represent a Paperless `CustomField`.""" _api_path: ClassVar[str] = EndpointPath.CUSTOM_FIELDS_SINGLE _resource: ClassVar[PaperlessResource] = PaperlessResource.CUSTOM_FIELDS name: str | None = None data_type: CustomFieldType | None = None extra_data: CustomFieldExtraData | None = None document_count: int | None = None @overload def draft_value(self, value: Any) -> CustomFieldValue: ... @overload def draft_value( self, value: Any, expected_type: type[CustomFieldValueT] ) -> CustomFieldValueT: ... def draft_value( self, value: Any, expected_type: type[CustomFieldValueT] | None = None, ) -> CustomFieldValue | CustomFieldValueT: """Draft a new `CustomFieldValue` instance.""" cache = self._runtime.cache.custom_fields if cache and self.id in cache: result: CustomFieldValue = _ANY_VALUE_ADAPTER.validate_python( { "field": self.id, "value": value, "name": self.name, "data_type": self.data_type, "extra_data": self.extra_data, } ) else: result = CustomFieldValue(field=self.id, value=value) if expected_type is not None and not isinstance(result, expected_type): msg = f"Expected {expected_type.__name__}, got {type(result).__name__}" raise TypeError(msg) return result class CustomFieldDraft(PaperlessModel, mixins.CreatableModel): """Represent a new Paperless `CustomField`, which is not stored in Paperless.""" _api_path: ClassVar[str] = EndpointPath.CUSTOM_FIELDS _resource: ClassVar[PaperlessResource] = PaperlessResource.CUSTOM_FIELDS _create_required_fields: ClassVar[set[str]] = {"name", "data_type"} name: str | None = None data_type: CustomFieldType | None = None extra_data: CustomFieldExtraData | None = None tb1337-pypaperless-5d67d60/pypaperless/models/document_types.py000066400000000000000000000022171524076126400246220ustar00rootroot00000000000000"""Provide `DocumentType` related models.""" from typing import ClassVar from pypaperless.const import EndpointPath, PaperlessResource from . import mixins from .base import IdentifiedModel, PaperlessModel class DocumentType( IdentifiedModel, mixins.MatchingFieldsModel, mixins.SecurableModel, ): """Represent a Paperless `DocumentType`.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENT_TYPES_SINGLE _resource: ClassVar[PaperlessResource] = PaperlessResource.DOCUMENT_TYPES slug: str | None = None name: str | None = None document_count: int | None = None class DocumentTypeDraft( PaperlessModel, mixins.MatchingFieldsModel, mixins.SecurableDraftModel, mixins.CreatableModel, ): """Represent a new `DocumentType`, which is not yet stored in Paperless.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENT_TYPES _resource: ClassVar[PaperlessResource] = PaperlessResource.DOCUMENT_TYPES _create_required_fields: ClassVar[set[str]] = { "name", "match", "matching_algorithm", "is_insensitive", } name: str | None = None owner: int | None = None tb1337-pypaperless-5d67d60/pypaperless/models/documents/000077500000000000000000000000001524076126400232055ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/models/documents/__init__.py000066400000000000000000000011451524076126400253170ustar00rootroot00000000000000"""PyPaperless document models.""" from .ai_suggestions import DocumentAISuggestions # noqa: F401 from .chat import DocumentChat # noqa: F401 from .document import ( # noqa: F401 Document, DocumentCustomFieldList, DocumentDraft, DocumentMeta, DocumentMetaEntry, DocumentSearchHit, DocumentSuggestions, DownloadedDocument, FileRetrieveMode, ) from .history import DocumentHistory, DocumentHistoryAction, DocumentHistoryActor # noqa: F401 from .notes import DocumentNote, DocumentNoteDraft # noqa: F401 from .versions import DocumentRoot, DocumentVersionInfo # noqa: F401 tb1337-pypaperless-5d67d60/pypaperless/models/documents/ai_suggestions.py000066400000000000000000000014431524076126400266040ustar00rootroot00000000000000"""Provide `DocumentAISuggestions` related models.""" from typing import ClassVar from pypaperless.const import EndpointPath from pypaperless.models.base import IdentifiedModel class DocumentAISuggestions(IdentifiedModel): """Represent a Paperless `Document`'s AI-generated suggestions.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS_AI_SUGGESTIONS title: str | None = None correspondents: list[int] | None = None suggested_correspondents: list[str] | None = None tags: list[int] | None = None suggested_tags: list[str] | None = None document_types: list[int] | None = None suggested_document_types: list[str] | None = None storage_paths: list[int] | None = None suggested_storage_paths: list[str] | None = None dates: list[str] | None = None tb1337-pypaperless-5d67d60/pypaperless/models/documents/chat.py000066400000000000000000000007361524076126400245040ustar00rootroot00000000000000"""Provide `DocumentChat` related models.""" from typing import ClassVar from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.base import PaperlessModel class DocumentChat(PaperlessModel): """Represent a Paperless `Document` chat response.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS_CHAT _resource: ClassVar[PaperlessResource] = PaperlessResource.DOCUMENTS q: str | None = None document_id: int | None = None tb1337-pypaperless-5d67d60/pypaperless/models/documents/document.py000066400000000000000000000352761524076126400254120ustar00rootroot00000000000000"""Provide `Document` related models.""" import datetime import json from collections.abc import Iterator from enum import StrEnum from typing import Any, ClassVar, Self, cast, overload from pydantic import ( BaseModel, Field, PrivateAttr, RootModel, ValidationInfo, model_validator, ) from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.exceptions import ItemNotFoundError from pypaperless.models import mixins from pypaperless.models.base import IdentifiedModel, PaperlessModel from pypaperless.models.custom_fields import ( AnyCustomFieldValue, CustomField, CustomFieldValue, CustomFieldValueT, ) from pypaperless.models.documents.notes import DocumentNote from pypaperless.models.documents.versions import DocumentVersionInfo from pypaperless.services.documents.ai_suggestions import DocumentAISuggestionsService from pypaperless.services.documents.history import DocumentHistoryService from pypaperless.services.documents.notes import DocumentNoteService from pypaperless.services.documents.share_links import DocumentShareLinkService from pypaperless.services.documents.versions import DocumentRootService, DocumentVersionService class DocumentMetaEntry(BaseModel): """Represent a subtype of `DocumentMeta`.""" namespace: str | None = None prefix: str | None = None key: str | None = None value: str | None = None class DocumentSearchHit(BaseModel): """Represent a subtype of `Document`.""" score: float | None = None highlights: str | None = None note_highlights: str | None = None rank: int | None = None class DuplicateDocumentSummary(BaseModel): """Represent a subtype of `Document`.""" id: int | None = None title: str | None = None deleted_at: datetime.datetime | None = None class FileRetrieveMode(StrEnum): """Represent a subtype of `DownloadedDocument`.""" DOWNLOAD = "download" PREVIEW = "preview" THUMBNAIL = "thumb" class DocumentCustomFieldList(RootModel[list[AnyCustomFieldValue]]): """Represent a list of Paperless custom field instances typically on documents. Items are validated into their typed :class:`CustomFieldValue` subclass via the ``data_type`` discriminator. Raw API payloads carry only ``field`` and ``value`` - when the custom-fields cache is populated, each item is enriched with ``name``, ``data_type``, and ``extra_data`` before validation. """ root: list[AnyCustomFieldValue] = Field(default_factory=list) @model_validator(mode="before") @classmethod def _enrich_from_cache(cls, data: Any, info: ValidationInfo) -> Any: """Merge cached CustomField metadata into raw items so the discriminator resolves.""" context = info.context if isinstance(info.context, dict) else {} runtime = context.get("runtime") cache = runtime.cache.custom_fields if runtime is not None else None if not isinstance(data, list) or not cache: return data enriched: list[Any] = [] for item in data: if isinstance(item, dict) and (field := cache.get(item.get("field"))): enriched.append( { **item, "name": field.name, "data_type": field.data_type, "extra_data": field.extra_data, } ) else: enriched.append(item) return enriched @classmethod def from_data(cls, runtime: Any, data: list[Any]) -> Self: """Return a new instance from raw API data, enriching from *runtime*'s cache.""" return cls.model_validate(data, context={"runtime": runtime}) def __contains__(self, field: int | CustomField) -> bool: """Check if the given `CustomField` or its id is present in `DocumentCustomFieldList`.""" item_id = field.id if isinstance(field, CustomField) else field return any(item.field == item_id for item in self.root) 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:: for item in document.custom_fields: print(item.field, item.value) """ yield from self.root def __iadd__(self, field: CustomFieldValue) -> Self: """Add a new `CustomFieldValue` to a document.""" return self.add(field) def add(self, field: CustomFieldValue) -> Self: """Add a new `CustomFieldValue` to a document.""" self.root.append(field) return self def __isub__(self, field: CustomFieldValue | CustomField | int) -> Self: """Remove a `CustomFieldValue` from a document.""" return self.remove(field) def remove(self, field: CustomFieldValue | CustomField | int) -> Self: """Remove a `CustomFieldValue` from a document.""" item_id = ( field.id if isinstance(field, CustomField) else field.field if isinstance(field, CustomFieldValue) else field ) self.root = [field for field in self.root if field.field != item_id] return self @overload def default(self, field: int | CustomField) -> CustomFieldValue | None: ... @overload def default( self, field: int | CustomField, expected_type: type[CustomFieldValueT] ) -> CustomFieldValueT | None: ... def default( self, field: int | CustomField, expected_type: type[CustomFieldValueT] | None = None ) -> CustomFieldValue | CustomFieldValueT | None: """Access and return a (typed) `CustomFieldValue`, or `None`.""" try: value = self.get(field) except ItemNotFoundError: return None if expected_type is not None and not isinstance(value, expected_type): msg = f"Expected {expected_type.__name__}, got {type(value).__name__}" raise TypeError(msg) return value @overload def get(self, field: int | CustomField) -> CustomFieldValue: ... @overload def get( self, field: int | CustomField, expected_type: type[CustomFieldValueT] ) -> CustomFieldValueT: ... def get( self, field: int | CustomField, expected_type: type[CustomFieldValueT] | None = None ) -> 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.root: 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 def serialize(self) -> list[dict[str, Any]]: """Return the JSON-safe ``[{"field": ..., "value": ...}]`` payload for the API.""" return cast("list[dict[str, Any]]", self.model_dump(mode="json")) class Document( IdentifiedModel, mixins.SecurableModel, ): """Represent a Paperless `Document`.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS_SINGLE _resource: ClassVar[PaperlessResource] = PaperlessResource.DOCUMENTS _history: DocumentHistoryService | None = PrivateAttr(default=None) _ai_suggestions: DocumentAISuggestionsService | None = PrivateAttr(default=None) _notes: DocumentNoteService | None = PrivateAttr(default=None) _root: DocumentRootService | None = PrivateAttr(default=None) _share_links: DocumentShareLinkService | None = PrivateAttr(default=None) _versions: DocumentVersionService | None = PrivateAttr(default=None) correspondent: int | None = None document_type: int | None = None storage_path: int | None = None title: str | None = None content: str | None = None tags: list[int] | None = None created: datetime.date | None = None modified: datetime.datetime | None = None added: datetime.datetime | None = None deleted_at: datetime.datetime | None = None archive_serial_number: int | None = None original_file_name: str | None = None archived_file_name: str | None = None duplicate_documents: list[DuplicateDocumentSummary] | None = None is_shared_by_requester: bool | None = None custom_fields: DocumentCustomFieldList | None = None notes_: list[DocumentNote] | None = Field(default=None, alias="notes", exclude=True, repr=False) page_count: int | None = None mime_type: str | None = None root_document: int | None = None versions_: list[DocumentVersionInfo] | None = Field( default=None, alias="versions", exclude=True, repr=False ) search_hit_: DocumentSearchHit | None = Field(default=None, alias="__search_hit__") @model_validator(mode="after") def _init_notes_cache(self) -> "Document": """Backfill note.document from self.id when the API omits it in the embedded payload.""" if self.notes_ is not None and self.id is not None: for note in self.notes_: if note.document is None: note.document = self.id return self @property def ai_suggestions(self) -> DocumentAISuggestionsService: """Return the AI suggestions service for this document.""" if self._ai_suggestions is None: self._ai_suggestions = DocumentAISuggestionsService(self._runtime, self.id) return self._ai_suggestions @property def history(self) -> DocumentHistoryService: """Return the history service for this document.""" if self._history is None: self._history = DocumentHistoryService(self._runtime, self.id) return self._history @property def notes(self) -> DocumentNoteService: """Return the notes service for this document.""" if self._notes is None: self._notes = DocumentNoteService(self._runtime, document=self) return self._notes @property def root(self) -> DocumentRootService: """Return the root service for this document.""" if self._root is None: self._root = DocumentRootService(self._runtime, self.id) return self._root @property def share_links(self) -> DocumentShareLinkService: """Return the share links service for this document.""" if self._share_links is None: self._share_links = DocumentShareLinkService(self._runtime, self.id) return self._share_links @property def versions(self) -> DocumentVersionService: """Return the versions service for this document.""" if self._versions is None: self._versions = DocumentVersionService(self._runtime, self.id) return self._versions @property def created_date(self) -> datetime.date | None: """Backward compatibility for the removed `created_date` field.""" return self.created @property def is_deleted(self) -> bool: """Return True if the document is in the trash.""" return self.deleted_at is not None @property def has_search_hit(self) -> bool: """Return if the document has a search hit attached.""" return self.search_hit_ is not None @property def search_hit(self) -> DocumentSearchHit | None: """Return the document search hit.""" return self.search_hit_ class DocumentDraft(PaperlessModel, mixins.CreatableModel): """Represent a new Paperless `Document`, which is not stored in Paperless.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS_POST _resource: ClassVar[PaperlessResource] = PaperlessResource.DOCUMENTS _create_required_fields: ClassVar[set[str]] = {"document"} _dump_exclude: ClassVar[set[str]] = {"document"} document: bytes | None = None filename: str | None = None title: str | None = None created: datetime.datetime | None = None correspondent: int | None = None document_type: int | None = None storage_path: int | None = None tags: int | list[int] | None = None archive_serial_number: int | None = None custom_fields: list[int] | DocumentCustomFieldList | None = None def serialize(self) -> dict[str, Any]: """Return the multipart form data payload for POSTing a new document.""" data: dict[str, dict[str, Any]] = { "form": self.model_dump( mode="json", by_alias=True, exclude={"document", "filename", "custom_fields"}, ) } if self.custom_fields is not None: if isinstance(self.custom_fields, DocumentCustomFieldList): cf_map = { str(item["field"]): item["value"] for item in self.custom_fields.serialize() } data["form"]["custom_fields"] = json.dumps(cf_map) else: data["form"]["custom_fields"] = self.custom_fields data["form"].update( { "document": ( (self.document, self.filename) if self.filename is not None else self.document ) } ) return data class DocumentMeta(PaperlessModel): """Represent a Paperless `Document`'s metadata.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS_META id: int | None = None original_checksum: str | None = None original_size: int | None = None original_mime_type: str | None = None media_filename: str | None = None has_archive_version: bool | None = None original_metadata: list[DocumentMetaEntry] | None = None archive_checksum: str | None = None archive_media_filename: str | None = None original_filename: str | None = None lang: str | None = None archive_size: int | None = None archive_metadata: list[DocumentMetaEntry] | None = None class DownloadedDocument(IdentifiedModel): """Represent a Paperless `Document`'s downloaded file.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS _dump_exclude: ClassVar[set[str]] = {"content"} mode: FileRetrieveMode | None = None original: bool | None = None content: bytes | None = None content_type: str | None = None disposition_filename: str | None = None disposition_type: str | None = None class DocumentSuggestions(IdentifiedModel): """Represent a Paperless `Document`'s suggestions.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS_SUGGESTIONS correspondents: list[int] | None = None tags: list[int] | None = None document_types: list[int] | None = None storage_paths: list[int] | None = None dates: list[datetime.date] | None = None tb1337-pypaperless-5d67d60/pypaperless/models/documents/history.py000066400000000000000000000017331524076126400252640ustar00rootroot00000000000000"""Provide `DocumentHistory` related models.""" import datetime from enum import StrEnum from typing import Any, ClassVar from pydantic import BaseModel, Field from pypaperless.const import EndpointPath from pypaperless.models.base import IdentifiedModel class DocumentHistoryAction(StrEnum): """Represent the action type of a `DocumentHistory` entry.""" CREATE = "create" UPDATE = "update" class DocumentHistoryActor(BaseModel): """Represent the actor field of a `DocumentHistory` entry.""" id: int | None = None username: str | None = None class DocumentHistory(IdentifiedModel): """Represent a single Paperless document history (audit-log) entry.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS_HISTORY document: int | None = None timestamp: datetime.datetime | None = None action: DocumentHistoryAction | None = None changes: dict[str, Any] = Field(default_factory=dict) actor: DocumentHistoryActor | None = None tb1337-pypaperless-5d67d60/pypaperless/models/documents/notes.py000066400000000000000000000023231524076126400247070ustar00rootroot00000000000000"""Provide `DocumentNote` related models.""" import datetime from typing import Any, ClassVar from pydantic import field_validator from pypaperless.const import EndpointPath from pypaperless.models import mixins from pypaperless.models.base import IdentifiedModel, PaperlessModel class DocumentNote(IdentifiedModel): """Represent a Paperless `DocumentNote`.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS_NOTES _pk_field: ClassVar[str] = "document" note: str | None = None created: datetime.datetime | None = None document: int | None = None user: int | None = None @field_validator("user", mode="before") @classmethod def _coerce_user(cls, v: Any) -> Any: """Normalize a user dict from the API to its id.""" if isinstance(v, dict): return v["id"] return v class DocumentNoteDraft(PaperlessModel, mixins.CreatableModel): """Represent a new Paperless `DocumentNote`, which is not stored in Paperless.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS_NOTES _pk_field: ClassVar[str] = "document" _create_required_fields: ClassVar[set[str]] = {"note", "document"} note: str | None = None document: int | None = None tb1337-pypaperless-5d67d60/pypaperless/models/documents/versions.py000066400000000000000000000015171524076126400254330ustar00rootroot00000000000000"""Provide `DocumentVersionInfo` and `DocumentRoot` models.""" import datetime from typing import ClassVar from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.base import IdentifiedModel, PaperlessModel class DocumentVersionInfo(IdentifiedModel): """Represent version metadata for a Paperless `Document`.""" _resource: ClassVar[PaperlessResource] = PaperlessResource.DOCUMENTS added: datetime.datetime | None = None version_label: str | None = None checksum: str | None = None is_root: bool | None = None class DocumentRoot(PaperlessModel): """Represent the root-document response for a Paperless `Document`.""" _api_path: ClassVar[str] = EndpointPath.DOCUMENTS_ROOT _resource: ClassVar[PaperlessResource] = PaperlessResource.DOCUMENTS root_id: int | None = None tb1337-pypaperless-5d67d60/pypaperless/models/filters.py000066400000000000000000000174621524076126400232400ustar00rootroot00000000000000"""Query filter TypedDicts for pypaperless services. Each TypedDict reflects the server-side ``FilterSet`` class for the corresponding Paperless-ngx resource, so IDEs can offer autocomplete and type-checking when using :meth:`~pypaperless.services.mixins.IterableService.filter`. Usage:: async with paperless.documents.filter( title__icontains="invoice", correspondent__id=3, is_tagged=True, ) as docs: async for doc in docs: ... All fields are optional (``total=False``). Pagination parameters (``page``, ``page_size``) are intentionally excluded — pass them directly as kwargs. """ from typing import TypedDict from pypaperless.builders import CustomFieldQuery, SearchQuery from pypaperless.models.share_links import ShareLinkBundleStatus from pypaperless.models.tasks import TaskStatus, TaskTriggerSource, TaskType class _CreatedFilters(TypedDict, total=False): """Common created-date filter fields.""" created__date__gt: str # backwards-compat alias created__date__gte: str created__date__lt: str created__date__lte: str created__day: int created__gt: str created__gte: str created__lt: str created__lte: str created__month: int created__year: int class _IdFilters(TypedDict, total=False): """Common id filter fields shared across multiple resources.""" id: int id__in: str # comma-separated PKs class _ExpirationFilters(TypedDict, total=False): """Common expiration-date filter fields.""" expiration__date__gt: str expiration__date__gte: str expiration__date__lt: str expiration__date__lte: str expiration__day: int expiration__gt: str expiration__gte: str expiration__lt: str expiration__lte: str expiration__month: int expiration__year: int class _NameFilters(TypedDict, total=False): """Common name filter fields for named resources.""" name__icontains: str name__iendswith: str name__iexact: str name__istartswith: str class _NameIdFilters(_IdFilters, _NameFilters, total=False): """Common name and id filter fields for classifier resources. ``/api/groups/`` is name-filterable but exposes no ``id`` filters, so it inherits :class:`_NameFilters` directly instead. """ class CorrespondentFilters(_NameIdFilters, total=False): """Filters for :attr:`PaperlessClient.correspondents`.""" class CustomFieldFilters(_NameIdFilters, total=False): """Filters for :attr:`PaperlessClient.custom_fields`.""" class DocumentFilters(_IdFilters, _CreatedFilters, total=False): """Filters for :attr:`PaperlessClient.documents`. These map 1-to-1 to the ``DocumentFilterSet`` in paperless-ngx. """ added__date__gt: str added__date__gte: str added__date__lt: str added__date__lte: str added__day: int added__gt: str added__gte: str added__lt: str added__lte: str added__month: int added__year: int archive_serial_number: int archive_serial_number__gt: int archive_serial_number__gte: int archive_serial_number__isnull: bool archive_serial_number__lt: int archive_serial_number__lte: int checksum__icontains: str checksum__iendswith: str checksum__iexact: str checksum__istartswith: str content__icontains: str content__iendswith: str content__iexact: str content__istartswith: str correspondent__id: int correspondent__id__in: str correspondent__id__none: str # comma-separated PKs to exclude correspondent__isnull: bool correspondent__name__icontains: str correspondent__name__iendswith: str correspondent__name__iexact: str correspondent__name__istartswith: str custom_field_query: str | CustomFieldQuery custom_fields__icontains: str custom_fields__id__all: str custom_fields__id__in: str custom_fields__id__none: str document_type__id: int document_type__id__in: str document_type__id__none: str document_type__isnull: bool document_type__name__icontains: str document_type__name__iendswith: str document_type__name__iexact: str document_type__name__istartswith: str has_custom_fields: bool is_in_inbox: bool # True → document has an inbox tag is_tagged: bool # True → document has at least one tag mime_type: str modified__date__gt: str modified__date__gte: str modified__date__lt: str modified__date__lte: str modified__day: int modified__gt: str modified__gte: str modified__lt: str modified__lte: str modified__month: int modified__year: int more_like_id: int # semantic more-like-this search by document pk original_filename__icontains: str original_filename__iendswith: str original_filename__iexact: str original_filename__istartswith: str owner__id: int owner__id__in: str owner__id__none: str owner__isnull: bool query: str | SearchQuery search: str shared_by__id: int storage_path__id: int storage_path__id__in: str storage_path__id__none: str storage_path__isnull: bool storage_path__name__icontains: str storage_path__name__iendswith: str storage_path__name__iexact: str storage_path__name__istartswith: str tags__id: int tags__id__all: str # document must have ALL listed tag ids tags__id__in: str tags__id__none: str # document must have NONE of the listed tag ids tags__name__icontains: str tags__name__iendswith: str tags__name__iexact: str tags__name__istartswith: str text: str title__icontains: str title__iendswith: str title__iexact: str title__istartswith: str title_content: str # searches title AND content simultaneously title_search: str class DocumentTypeFilters(_NameIdFilters, total=False): """Filters for :attr:`PaperlessClient.document_types`.""" class GroupFilters(_NameFilters, total=False): """Filters for :attr:`PaperlessClient.groups`.""" class ProcessedMailFilters(TypedDict, total=False): """Filters for :attr:`PaperlessClient.processed_mail`.""" rule: int # must be an existing mail rule pk, the API rejects unknown ones with 400 status: str # free CharField upstream, values are upper-case: "SUCCESS", "FAILED", … class ShareLinkFilters(_CreatedFilters, _ExpirationFilters, total=False): """Filters for :attr:`PaperlessClient.share_links`.""" class ShareLinkBundleFilters(_CreatedFilters, _ExpirationFilters, total=False): """Filters for :attr:`PaperlessClient.share_link_bundles`.""" documents: int status: ShareLinkBundleStatus | str class StoragePathFilters(_NameIdFilters, total=False): """Filters for :attr:`PaperlessClient.storage_paths`.""" path__icontains: str path__iendswith: str path__iexact: str path__istartswith: str class TagFilters(_NameIdFilters, total=False): """Filters for :attr:`PaperlessClient.tags`.""" is_root: bool class TaskFilters(TypedDict, total=False): """Filters for :attr:`PaperlessClient.tasks` and :meth:`TaskService.active`.""" acknowledged: bool date_created_after: str date_created_before: str is_complete: bool name: str # free-text over input filename, task type and trigger source labels owner: int result: str # free-text over result reason, error message and document id status: TaskStatus | str | list[TaskStatus | str] task_id: str task_type: TaskType | str | list[TaskType | str] trigger_source: TaskTriggerSource | str | list[TaskTriggerSource | str] class TaskSummaryFilters(TaskFilters, total=False): """Additional filters for :meth:`~pypaperless.services.tasks.TaskService.summary`.""" days: int class UserFilters(TypedDict, total=False): """Filters for :attr:`PaperlessClient.users`.""" username__icontains: str username__iendswith: str username__iexact: str username__istartswith: str tb1337-pypaperless-5d67d60/pypaperless/models/mails/000077500000000000000000000000001524076126400223115ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/models/mails/__init__.py000066400000000000000000000002551524076126400244240ustar00rootroot00000000000000"""PyPaperless mail models.""" from .accounts import MailAccount # noqa: F401 from .processed import ProcessedMail # noqa: F401 from .rules import MailRule # noqa: F401 tb1337-pypaperless-5d67d60/pypaperless/models/mails/accounts.py000066400000000000000000000026151524076126400245060ustar00rootroot00000000000000"""Provide `MailAccount` related models.""" import datetime from enum import IntEnum from typing import ClassVar, Self from pypaperless.const import EndpointPath from pypaperless.models import mixins from pypaperless.models.base import IdentifiedModel class ImapSecurity(IntEnum): """Represent a subtype of `MailAccount`.""" NONE = 1 SSL = 2 STARTTLS = 3 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class MailAccountType(IntEnum): """Represent a subtype of `MailAccount`.""" IMAP = 1 GMAIL_OAUTH = 2 OUTLOOK_OAUTH = 3 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class MailAccount(IdentifiedModel, mixins.SecurableModel): """Represent a Paperless `MailAccount`.""" _api_path: ClassVar[str] = EndpointPath.MAIL_ACCOUNTS_SINGLE name: str | None = None imap_server: str | None = None imap_port: int | None = None imap_security: ImapSecurity | None = None username: str | None = None # password:intentionally excluded character_set: str | None = None is_token: bool | None = None account_type: MailAccountType | None = None expiration: datetime.datetime | None = None tb1337-pypaperless-5d67d60/pypaperless/models/mails/processed.py000066400000000000000000000012011524076126400246440ustar00rootroot00000000000000"""Provide `ProcessedMail` related models.""" import datetime from typing import ClassVar from pypaperless.const import EndpointPath from pypaperless.models.base import IdentifiedModel class ProcessedMail(IdentifiedModel): """Represent a Paperless `ProcessedMail`.""" _api_path: ClassVar[str] = EndpointPath.PROCESSED_MAIL_SINGLE owner: int | None = None rule: int | None = None folder: str | None = None uid: str | None = None subject: str | None = None received: datetime.datetime | None = None processed: datetime.datetime | None = None status: str | None = None error: str | None = None tb1337-pypaperless-5d67d60/pypaperless/models/mails/rules.py000066400000000000000000000065231524076126400240230ustar00rootroot00000000000000"""Provide `MailRule` related models.""" from enum import IntEnum from typing import ClassVar, Self from pypaperless.const import EndpointPath from pypaperless.models import mixins from pypaperless.models.base import IdentifiedModel class MailRuleAction(IntEnum): """Represent a subtype of `MailRule`.""" DELETE = 1 MOVE = 2 MARK_READ = 3 FLAG = 4 TAG = 5 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class MailRuleTitleSource(IntEnum): """Represent a subtype of `MailRule`.""" FROM_SUBJECT = 1 FROM_FILENAME = 2 NONE = 3 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class MailRuleCorrespondentSource(IntEnum): """Represent a subtype of `MailRule`.""" FROM_NOTHING = 1 FROM_EMAIL = 2 FROM_NAME = 3 FROM_CUSTOM = 4 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class MailRuleAttachmentType(IntEnum): """Represent a subtype of `MailRule`.""" ATTACHMENTS_ONLY = 1 EVERYTHING = 2 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class MailRuleConsumptionScope(IntEnum): """Represent a subtype of `MailRule`.""" ATTACHMENTS_ONLY = 1 EML_ONLY = 2 EVERYTHING = 3 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class MailRulePdfLayout(IntEnum): """Represent a subtype of `MailRule`.""" DEFAULT = 0 TEXT_HTML = 1 HTML_TEXT = 2 HTML_ONLY = 3 TEXT_ONLY = 4 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class MailRule(IdentifiedModel, mixins.SecurableModel): """Represent a Paperless `MailRule`.""" _api_path: ClassVar[str] = EndpointPath.MAIL_RULES_SINGLE name: str | None = None account: int | None = None enabled: bool | None = None folder: str | None = None filter_from: str | None = None filter_to: str | None = None filter_subject: str | None = None filter_body: str | None = None filter_attachment_filename_include: str | None = None filter_attachment_filename_exclude: str | None = None maximum_age: int | None = None action: MailRuleAction | None = None action_parameter: str | None = None assign_title_from: MailRuleTitleSource | None = None assign_tags: list[int] | None = None assign_correspondent_from: MailRuleCorrespondentSource | None = None assign_correspondent: int | None = None assign_document_type: int | None = None assign_owner_from_rule: bool | None = None order: int | None = None attachment_type: MailRuleAttachmentType | None = None consumption_scope: MailRuleConsumptionScope | None = None pdf_layout: MailRulePdfLayout | None = None stop_processing: bool | None = None tb1337-pypaperless-5d67d60/pypaperless/models/mixins/000077500000000000000000000000001524076126400225135ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/models/mixins/__init__.py000066400000000000000000000004451524076126400246270ustar00rootroot00000000000000"""Mixins for PyPaperless models.""" from .creatable import CreatableModel from .data_fields import MatchingFieldsModel from .securable import SecurableDraftModel, SecurableModel __all__ = ( "CreatableModel", "MatchingFieldsModel", "SecurableDraftModel", "SecurableModel", ) tb1337-pypaperless-5d67d60/pypaperless/models/mixins/creatable.py000066400000000000000000000025101524076126400250050ustar00rootroot00000000000000"""CreatableModel for PyPaperless models.""" from typing import Any, ClassVar from pydantic import BaseModel, ConfigDict from pypaperless.exceptions import DraftFieldRequiredError class CreatableModel(BaseModel): """Provide draft validation and serialization for PyPaperless models. Draft models forbid unknown fields: a typo in a ``create()`` keyword argument raises a :exc:`pydantic.ValidationError` immediately instead of being silently dropped. """ model_config = ConfigDict(extra="forbid") _create_required_fields: ClassVar[set[str]] def serialize(self) -> dict[str, Any]: """Serialize draft fields for API submission.""" payload = self.model_dump(mode="json", by_alias=True) # empty permissions raise server-side, so drop the field when unset if payload.get("set_permissions") is None: payload.pop("set_permissions", None) return {"json": payload} def validate_draft(self) -> None: """Check required fields before persisting the item to Paperless.""" missing = [field for field in self._create_required_fields if getattr(self, field) is None] if not missing: return message = f"Missing fields for saving a `{type(self).__name__}`: {', '.join(missing)}." raise DraftFieldRequiredError(message) tb1337-pypaperless-5d67d60/pypaperless/models/mixins/data_fields.py000066400000000000000000000013751524076126400253320ustar00rootroot00000000000000"""MatchingFieldsModel for PyPaperless models.""" from enum import Enum from typing import Self from pydantic import BaseModel class MatchingAlgorithm(Enum): """Text-matching algorithm used by `Correspondent`, `DocumentType`, `StoragePath`, and `Tag`.""" NONE = 0 ANY = 1 ALL = 2 LITERAL = 3 REGEX = 4 FUZZY = 5 AUTO = 6 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class MatchingFieldsModel(BaseModel): """Provide shared matching fields for PyPaperless models.""" match: str | None = None matching_algorithm: MatchingAlgorithm | None = None is_insensitive: bool | None = None tb1337-pypaperless-5d67d60/pypaperless/models/mixins/securable.py000066400000000000000000000044541524076126400250410ustar00rootroot00000000000000"""SecurableModel for PyPaperless models.""" from pydantic import BaseModel, Field, model_validator class _PermissionScope(BaseModel): """Internal: user and group IDs for a single permission action.""" users: list[int] = Field(default_factory=list) groups: list[int] = Field(default_factory=list) class Permissions(BaseModel): """Object-level permissions (view + change) for a Paperless resource. Can be constructed with flat keyword arguments:: Permissions(view_users=[2, 3], change_users=[2], change_groups=[1]) Or directly from the nested API structure (used internally during JSON deserialization):: Permissions( view={"users": [2, 3], "groups": []}, change={"users": [2], "groups": []} ) Access is always via the nested attributes:: perms.view.users # list[int] perms.change.groups # list[int] """ view: _PermissionScope = Field(default_factory=_PermissionScope) change: _PermissionScope = Field(default_factory=_PermissionScope) @model_validator(mode="before") @classmethod def _accept_flat(cls, data: object) -> object: """Accept flat kwargs (view_users, change_groups, …) alongside the nested form.""" if not isinstance(data, dict): return data flat_keys = {"view_users", "view_groups", "change_users", "change_groups"} if flat_keys.isdisjoint(data): return data return { "view": { "users": data.get("view_users", []), "groups": data.get("view_groups", []), }, "change": { "users": data.get("change_users", []), "groups": data.get("change_groups", []), }, } class SecurableModel(BaseModel): """Provide permission fields for PyPaperless models.""" owner: int | None = None user_can_change: bool | None = None permissions: Permissions | None = None @property def has_permissions(self) -> bool: """Return if the model data includes the permission field.""" return self.permissions is not None class SecurableDraftModel(BaseModel): """Provide permission fields for PyPaperless draft models.""" owner: int | None = None set_permissions: Permissions | None = None tb1337-pypaperless-5d67d60/pypaperless/models/permissions/000077500000000000000000000000001524076126400235575ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/models/permissions/__init__.py000066400000000000000000000001641524076126400256710ustar00rootroot00000000000000"""PyPaperless permission models.""" from .groups import Group # noqa: F401 from .users import User # noqa: F401 tb1337-pypaperless-5d67d60/pypaperless/models/permissions/groups.py000066400000000000000000000005551524076126400254550ustar00rootroot00000000000000"""Provide `Group` related models.""" from typing import ClassVar from pypaperless.const import EndpointPath from pypaperless.models.base import IdentifiedModel class Group(IdentifiedModel): """Represent a Paperless `Group`.""" _api_path: ClassVar[str] = EndpointPath.GROUPS_SINGLE name: str | None = None permissions: list[str] | None = None tb1337-pypaperless-5d67d60/pypaperless/models/permissions/users.py000066400000000000000000000014401524076126400252710ustar00rootroot00000000000000"""Provide `User` related models.""" import datetime from typing import ClassVar from pypaperless.const import EndpointPath from pypaperless.models.base import IdentifiedModel class User(IdentifiedModel): """Represent a Paperless `User`.""" _api_path: ClassVar[str] = EndpointPath.USERS_SINGLE username: str | None = None # password: intentionally excluded email: str | None = None first_name: str | None = None last_name: str | None = None date_joined: datetime.datetime | None = None is_staff: bool | None = None is_active: bool | None = None is_superuser: bool | None = None groups: list[int] | None = None user_permissions: list[str] | None = None inherited_permissions: list[str] | None = None is_mfa_enabled: bool | None = None tb1337-pypaperless-5d67d60/pypaperless/models/profile.py000066400000000000000000000015011524076126400232130ustar00rootroot00000000000000"""Provide `Profile` related models.""" from typing import ClassVar from pydantic import BaseModel from pypaperless.const import EndpointPath from .base import PaperlessModel class ProfileSocialAccount(BaseModel): """Represent a social account linked to the Paperless user profile.""" id: int | None = None provider: str | None = None name: str | None = None class Profile(PaperlessModel): """Represent the Paperless user `Profile`.""" _api_path: ClassVar[str] = EndpointPath.PROFILE email: str | None = None # password: intentionally excluded first_name: str | None = None last_name: str | None = None auth_token: str | None = None social_accounts: list[ProfileSocialAccount] | None = None has_usable_password: bool | None = None is_mfa_enabled: bool | None = None tb1337-pypaperless-5d67d60/pypaperless/models/remote_version.py000066400000000000000000000005661524076126400246250ustar00rootroot00000000000000"""Provide `Remote Version` related models.""" from typing import ClassVar from pypaperless.const import EndpointPath from .base import PaperlessModel class RemoteVersion(PaperlessModel): """Represent Paperless `Remote Version`.""" _api_path: ClassVar[str] = EndpointPath.REMOTE_VERSION version: str | None = None update_available: bool | None = None tb1337-pypaperless-5d67d60/pypaperless/models/saved_views.py000066400000000000000000000070051524076126400240770ustar00rootroot00000000000000"""Provide `SavedView` related models.""" import re from enum import StrEnum from typing import Annotated, ClassVar, Self from pydantic import BaseModel, PlainValidator from pypaperless.const import EndpointPath from . import mixins from .base import IdentifiedModel _CUSTOM_FIELD_RE: re.Pattern[str] = re.compile(r"^custom_field_(\d+)$") class SavedViewDisplayMode(StrEnum): """Represent a subtype of `SavedView`.""" TABLE = "table" SMALL_CARDS = "smallCards" LARGE_CARDS = "largeCards" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class SavedViewDisplayField(StrEnum): """Represent a subtype of `SavedView`.""" TITLE = "title" CREATED = "created" ADDED = "added" TAGS = "tag" CORRESPONDENT = "correspondent" DOCUMENT_TYPE = "documenttype" STORAGE_PATH = "storagepath" NOTES = "notes" OWNER = "owner" SHARED = "shared" ASN = "asn" PAGE_COUNT = "pagecount" class SavedViewCustomFieldDisplay(str): """Represent a dynamic custom-field column entry in :class:`SavedView`. Paperless-ngx encodes custom field columns as ``"custom_field_"`` strings. This class wraps such a value and exposes the numeric PK via the :attr:`pk` property while remaining a plain :class:`str` so it serialises transparently. Example:: entry = SavedViewCustomFieldDisplay("custom_field_8") print(entry.pk) # 8 """ __slots__ = () def __new__(cls, value: str) -> Self: """Validate and intern the raw string value.""" if not _CUSTOM_FIELD_RE.match(value): msg = f"Not a custom field display value: {value!r}" raise ValueError(msg) return super().__new__(cls, value) def __repr__(self) -> str: """Return repr(self).""" return f"SavedViewCustomFieldDisplay({super().__repr__()})" @property def pk(self) -> int: """Return the custom field PK encoded in this display value. Example:: entry = SavedViewCustomFieldDisplay("custom_field_8") assert entry.pk == 8 """ return int(self[len("custom_field_") :]) def _coerce_display_field(v: object) -> "SavedViewDisplayField | SavedViewCustomFieldDisplay": """Try SavedViewDisplayField first, then SavedViewCustomFieldDisplay; raise on mismatch.""" if isinstance(v, (SavedViewDisplayField, SavedViewCustomFieldDisplay)): return v if isinstance(v, str): try: return SavedViewDisplayField(v) except ValueError: pass return SavedViewCustomFieldDisplay(v) msg = f"Expected str, got {type(v).__name__!r}" raise ValueError(msg) type _DisplayFieldValue = Annotated[ SavedViewDisplayField | SavedViewCustomFieldDisplay, PlainValidator(_coerce_display_field), ] class SavedViewFilterRule(BaseModel): """Represent a subtype of `SavedView`.""" rule_type: int | None = None value: str | None = None class SavedView(IdentifiedModel, mixins.SecurableModel): """Represent a Paperless `SavedView`.""" _api_path: ClassVar[str] = EndpointPath.SAVED_VIEWS_SINGLE name: str | None = None sort_field: str | None = None sort_reverse: bool | None = None filter_rules: list[SavedViewFilterRule] | None = None page_size: int | None = None display_mode: SavedViewDisplayMode | None = None display_fields: list[_DisplayFieldValue] | None = None tb1337-pypaperless-5d67d60/pypaperless/models/search.py000066400000000000000000000026001524076126400230210ustar00rootroot00000000000000"""Provide `Search` related models.""" from typing import ClassVar from pypaperless.const import EndpointPath from pypaperless.models.correspondents import Correspondent from pypaperless.models.custom_fields import CustomField from pypaperless.models.document_types import DocumentType from pypaperless.models.documents import Document from pypaperless.models.mails import MailAccount, MailRule from pypaperless.models.permissions import Group, User from pypaperless.models.saved_views import SavedView from pypaperless.models.storage_paths import StoragePath from pypaperless.models.tags import Tag from pypaperless.models.workflows import Workflow from .base import PaperlessModel class SearchResult(PaperlessModel): """Represent a Paperless global search result.""" _api_path: ClassVar[str] = EndpointPath.SEARCH total: int | None = None documents: list[Document] | None = None saved_views: list[SavedView] | None = None tags: list[Tag] | None = None correspondents: list[Correspondent] | None = None document_types: list[DocumentType] | None = None storage_paths: list[StoragePath] | None = None users: list[User] | None = None groups: list[Group] | None = None mail_rules: list[MailRule] | None = None mail_accounts: list[MailAccount] | None = None workflows: list[Workflow] | None = None custom_fields: list[CustomField] | None = None tb1337-pypaperless-5d67d60/pypaperless/models/share_links/000077500000000000000000000000001524076126400235065ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/models/share_links/__init__.py000066400000000000000000000003331524076126400256160ustar00rootroot00000000000000"""PyPaperless share link models.""" from .bundle import ShareLinkBundle, ShareLinkBundleDraft, ShareLinkBundleStatus # noqa: F401 from .share_link import ShareLink, ShareLinkDraft, ShareLinkFileVersion # noqa: F401 tb1337-pypaperless-5d67d60/pypaperless/models/share_links/bundle.py000066400000000000000000000036451524076126400253410ustar00rootroot00000000000000"""Provide `ShareLinkBundle` related models.""" import datetime from enum import StrEnum from typing import Any, ClassVar, Self from pydantic import Field from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models import mixins from pypaperless.models.base import IdentifiedModel, PaperlessModel from .share_link import ShareLinkFileVersion class ShareLinkBundleStatus(StrEnum): """Represent the processing status of a ``ShareLinkBundle``.""" PENDING = "pending" PROCESSING = "processing" READY = "ready" FAILED = "failed" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class ShareLinkBundle(IdentifiedModel): """Represent a Paperless ``ShareLinkBundle``.""" _api_path: ClassVar[str] = EndpointPath.SHARE_LINK_BUNDLES_SINGLE _resource: ClassVar[PaperlessResource] = PaperlessResource.SHARE_LINK_BUNDLES created: datetime.datetime | None = None expiration: datetime.datetime | None = None slug: str | None = None file_version: ShareLinkFileVersion | None = None status: ShareLinkBundleStatus | None = None size_bytes: int | None = None last_error: Any = None built_at: datetime.datetime | None = None documents: list[int] = Field(default_factory=list) document_count: int | None = None class ShareLinkBundleDraft(PaperlessModel, mixins.CreatableModel): """Represent a new Paperless ``ShareLinkBundle``, which is not stored in Paperless.""" _api_path: ClassVar[str] = EndpointPath.SHARE_LINK_BUNDLES _resource: ClassVar[PaperlessResource] = PaperlessResource.SHARE_LINK_BUNDLES _create_required_fields: ClassVar[set[str]] = {"document_ids"} document_ids: list[int] = Field(default_factory=list) expiration_days: int | None = None file_version: ShareLinkFileVersion | None = None tb1337-pypaperless-5d67d60/pypaperless/models/share_links/share_link.py000066400000000000000000000027751524076126400262120ustar00rootroot00000000000000"""Provide `ShareLink` related models.""" import datetime from enum import StrEnum from typing import ClassVar, Self from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models import mixins from pypaperless.models.base import IdentifiedModel, PaperlessModel class ShareLinkFileVersion(StrEnum): """Represent a subtype of `ShareLink`.""" ARCHIVE = "archive" ORIGINAL = "original" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class ShareLink( IdentifiedModel, ): """Represent a Paperless `ShareLink`.""" _api_path: ClassVar[str] = EndpointPath.SHARE_LINKS_SINGLE _resource: ClassVar[PaperlessResource] = PaperlessResource.SHARE_LINKS created: datetime.datetime | None = None expiration: datetime.datetime | None = None slug: str | None = None document: int | None = None file_version: ShareLinkFileVersion | None = None class ShareLinkDraft(PaperlessModel, mixins.CreatableModel): """Represent a new Paperless `ShareLink`, which is not stored in Paperless.""" _api_path: ClassVar[str] = EndpointPath.SHARE_LINKS _resource: ClassVar[PaperlessResource] = PaperlessResource.SHARE_LINKS _create_required_fields: ClassVar[set[str]] = {"document", "file_version"} expiration: datetime.datetime | None = None document: int | None = None file_version: ShareLinkFileVersion | None = None tb1337-pypaperless-5d67d60/pypaperless/models/statistics.py000066400000000000000000000017111524076126400237500ustar00rootroot00000000000000"""Provide `Statistics` related models.""" from typing import ClassVar from pydantic import BaseModel from pypaperless.const import EndpointPath from .base import PaperlessModel class StatisticDocumentFileTypeCount(BaseModel): """Represent a Paperless statistics file type count.""" mime_type: str | None = None mime_type_count: int | None = None class Statistic(PaperlessModel): """Represent Paperless `Statistic`.""" _api_path: ClassVar[str] = EndpointPath.STATISTICS documents_total: int | None = None documents_inbox: int | None = None inbox_tag: int | None = None inbox_tags: list[int] | None = None document_file_type_counts: list[StatisticDocumentFileTypeCount] | None = None character_count: int | None = None tag_count: int | None = None correspondent_count: int | None = None document_type_count: int | None = None storage_path_count: int | None = None current_asn: int | None = None tb1337-pypaperless-5d67d60/pypaperless/models/status.py000066400000000000000000000061001524076126400230760ustar00rootroot00000000000000"""Provide `Status` related models.""" import datetime from enum import StrEnum from typing import ClassVar, Self from pydantic import BaseModel, Field from pypaperless.const import EndpointPath from .base import PaperlessModel class StatusType(StrEnum): """Represent a subtype of `Status`.""" OK = "OK" ERROR = "ERROR" WARNING = "WARNING" UNKNOWN = "UNKNOWN" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class StatusDatabaseMigration(BaseModel): """Represent a subtype of `StatusDatabase`.""" latest_migration: str | None = None unapplied_migrations: list[str] = Field(default_factory=list) class StatusDatabase(BaseModel): """Represent a subtype of `Status`.""" type: str | None = None url: str | None = None status: StatusType | None = None error: str | None = None migration_status: StatusDatabaseMigration | None = None class StatusStorage(BaseModel): """Represent a subtype of `Status`.""" total: int | None = None available: int | None = None class StatusTasksSummary(BaseModel): """Represent a subtype of `StatusTasks`.""" days: int | None = None total_count: int | None = None pending_count: int | None = None success_count: int | None = None failure_count: int | None = None class StatusTasks(BaseModel): """Represent a subtype of `Status`.""" redis_url: str | None = None redis_status: StatusType | None = None redis_error: str | None = None celery_status: StatusType | None = None celery_url: str | None = None celery_error: str | None = None index_status: StatusType | None = None index_last_modified: datetime.datetime | None = None index_error: str | None = None classifier_status: StatusType | None = None classifier_last_trained: datetime.datetime | None = None classifier_error: str | None = None sanity_check_status: StatusType | None = None sanity_check_last_run: datetime.datetime | None = None sanity_check_error: str | None = None summary: StatusTasksSummary | None = None class Status(PaperlessModel): """Represent a Paperless `Status`.""" _api_path: ClassVar[str] = EndpointPath.STATUS pngx_version: str | None = None server_os: str | None = None install_type: str | None = None storage: StatusStorage | None = None database: StatusDatabase | None = None tasks: StatusTasks | None = None @property def has_errors(self) -> bool: """Return whether any status flag is `ERROR`.""" statuses: list[StatusType] = [ self.database.status if self.database and self.database.status else StatusType.OK, ] if self.tasks: statuses.extend( [ self.tasks.redis_status or StatusType.OK, self.tasks.celery_status or StatusType.OK, self.tasks.classifier_status or StatusType.OK, ] ) return StatusType.ERROR in statuses tb1337-pypaperless-5d67d60/pypaperless/models/storage_paths.py000066400000000000000000000023161524076126400244230ustar00rootroot00000000000000"""Provide `StoragePath` related models.""" from typing import ClassVar from pypaperless.const import EndpointPath, PaperlessResource from . import mixins from .base import IdentifiedModel, PaperlessModel class StoragePath( IdentifiedModel, mixins.MatchingFieldsModel, mixins.SecurableModel, ): """Represent a Paperless `StoragePath`.""" _api_path: ClassVar[str] = EndpointPath.STORAGE_PATHS_SINGLE _resource: ClassVar[PaperlessResource] = PaperlessResource.STORAGE_PATHS slug: str | None = None name: str | None = None path: str | None = None document_count: int | None = None class StoragePathDraft( PaperlessModel, mixins.MatchingFieldsModel, mixins.SecurableDraftModel, mixins.CreatableModel, ): """Represent a new `StoragePath`, which is not yet stored in Paperless.""" _api_path: ClassVar[str] = EndpointPath.STORAGE_PATHS _resource: ClassVar[PaperlessResource] = PaperlessResource.STORAGE_PATHS _create_required_fields: ClassVar[set[str]] = { "name", "path", "match", "matching_algorithm", "is_insensitive", } name: str | None = None path: str | None = None owner: int | None = None tb1337-pypaperless-5d67d60/pypaperless/models/tags.py000066400000000000000000000026171524076126400225220ustar00rootroot00000000000000"""Provide `Tag` related models.""" from typing import ClassVar from pypaperless.const import EndpointPath, PaperlessResource from . import mixins from .base import IdentifiedModel, PaperlessModel class Tag( IdentifiedModel, mixins.MatchingFieldsModel, mixins.SecurableModel, ): """Represent a Paperless `Tag`.""" _api_path: ClassVar[str] = EndpointPath.TAGS_SINGLE _resource: ClassVar[PaperlessResource] = PaperlessResource.TAGS slug: str | None = None name: str | None = None color: str | None = None text_color: str | None = None is_inbox_tag: bool | None = None document_count: int | None = None parent: int | None = None children: list["Tag"] | None = None class TagDraft( PaperlessModel, mixins.MatchingFieldsModel, mixins.SecurableDraftModel, mixins.CreatableModel, ): """Represent a new `Tag`, which is not yet stored in Paperless.""" _api_path: ClassVar[str] = EndpointPath.TAGS _resource: ClassVar[PaperlessResource] = PaperlessResource.TAGS _create_required_fields: ClassVar[set[str]] = { "name", "color", "is_inbox_tag", "match", "matching_algorithm", "is_insensitive", } name: str | None = None color: str | None = None text_color: str | None = None is_inbox_tag: bool | None = None owner: int | None = None parent: int | None = None tb1337-pypaperless-5d67d60/pypaperless/models/tasks.py000066400000000000000000000072401524076126400227060ustar00rootroot00000000000000"""Provide `Task` related models.""" import datetime from enum import StrEnum from typing import Any, ClassVar, Self from pydantic import BaseModel, Field from pypaperless.const import EndpointPath from .base import IdentifiedModel class TaskType(StrEnum): """Represent the type of work a ``Task`` performs.""" CONSUME_FILE = "consume_file" TRAIN_CLASSIFIER = "train_classifier" SANITY_CHECK = "sanity_check" INDEX_OPTIMIZE = "index_optimize" MAIL_FETCH = "mail_fetch" LLM_INDEX = "llm_index" EMPTY_TRASH = "empty_trash" CHECK_WORKFLOWS = "check_workflows" BULK_UPDATE = "bulk_update" REPROCESS_DOCUMENT = "reprocess_document" BUILD_SHARE_LINK = "build_share_link" BULK_DELETE = "bulk_delete" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class TaskStatus(StrEnum): """Represent the execution status of a ``Task``.""" FAILURE = "failure" PENDING = "pending" REVOKED = "revoked" STARTED = "started" SUCCESS = "success" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class TaskTriggerSource(StrEnum): """Represent what initiated a ``Task``.""" SCHEDULED = "scheduled" WEB_UI = "web_ui" API_UPLOAD = "api_upload" FOLDER_CONSUME = "folder_consume" EMAIL_CONSUME = "email_consume" SYSTEM = "system" MANUAL = "manual" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class Task(IdentifiedModel): """Represent a Paperless ``Task``.""" _api_path: ClassVar[str] = EndpointPath.TASKS_SINGLE task_id: str | None = None task_type: TaskType | None = None task_type_display: str | None = None trigger_source: TaskTriggerSource | None = None trigger_source_display: str | None = None status: TaskStatus | None = None status_display: str | None = None date_created: datetime.datetime | None = None date_started: datetime.datetime | None = None date_done: datetime.datetime | None = None duration_seconds: float | None = None wait_time_seconds: float | None = None input_data: Any = None result_data: Any = None related_document_ids: list[int] = Field(default_factory=list) acknowledged: bool | None = None owner: int | None = None class TaskSummary(BaseModel): """Represent aggregated statistics for a single task type. Returned by :meth:`~pypaperless.services.tasks.TaskService.summary`. Example:: summaries = await paperless.tasks.summary(days=7) for s in summaries: print(s.task_type, s.success_count, s.failure_count) """ task_type: str total_count: int = 0 pending_count: int = 0 success_count: int = 0 failure_count: int = 0 avg_duration_seconds: float | None = None avg_wait_time_seconds: float | None = None last_run: datetime.datetime | None = None last_success: datetime.datetime | None = None last_failure: datetime.datetime | None = None class TaskStatusCounts(BaseModel): """Represent aggregated task counts for the task UI sections. Returned by :meth:`~pypaperless.services.tasks.TaskService.status_counts`. Example:: counts = await paperless.tasks.status_counts() print(counts.all, counts.needs_attention) """ all: int = 0 needs_attention: int = 0 in_progress: int = 0 completed: int = 0 tb1337-pypaperless-5d67d60/pypaperless/models/types.py000066400000000000000000000101661524076126400227260ustar00rootroot00000000000000"""Re-export all public types from PyPaperless models. This module provides a single, stable import location for all enum and data types used across the PyPaperless model layer: from pypaperless.models.types import CustomFieldType, FileRetrieveMode, ... """ from pypaperless.builders import ( CustomFieldQuery, SearchQuery, ) from .base import DraftLike from .bulk_edit import BulkEditObjectType, CustomFieldsInput, EditPdfOperation, SourceMode from .config import OutputType from .custom_fields import ( AnyCustomFieldValue, CustomFieldBooleanValue, CustomFieldDateValue, CustomFieldDocumentLinkValue, CustomFieldExtraData, CustomFieldFloatValue, CustomFieldIntegerValue, CustomFieldMonetaryValue, CustomFieldSelectOptions, CustomFieldSelectValue, CustomFieldStringValue, CustomFieldType, CustomFieldURLValue, CustomFieldValue, CustomFieldValueT, ) from .documents import ( DocumentMetaEntry, DocumentSearchHit, FileRetrieveMode, ) from .filters import ( CorrespondentFilters, CustomFieldFilters, DocumentFilters, DocumentTypeFilters, GroupFilters, ProcessedMailFilters, ShareLinkBundleFilters, ShareLinkFilters, StoragePathFilters, TagFilters, TaskFilters, TaskSummaryFilters, UserFilters, ) from .mails.accounts import ImapSecurity, MailAccountType from .mails.rules import ( MailRuleAction, MailRuleAttachmentType, MailRuleConsumptionScope, MailRuleCorrespondentSource, MailRulePdfLayout, MailRuleTitleSource, ) from .mixins.data_fields import MatchingAlgorithm from .mixins.securable import Permissions from .saved_views import ( SavedViewCustomFieldDisplay, SavedViewDisplayField, SavedViewDisplayMode, SavedViewFilterRule, ) from .share_links import ShareLinkBundleStatus, ShareLinkFileVersion from .statistics import StatisticDocumentFileTypeCount from .status import ( StatusDatabase, StatusDatabaseMigration, StatusStorage, StatusTasks, StatusType, ) from .tasks import TaskStatus, TaskStatusCounts, TaskSummary, TaskTriggerSource, TaskType from .workflows import ( WorkflowActionEmail, WorkflowActionType, WorkflowActionWebhook, WorkflowTriggerScheduleDateField, WorkflowTriggerSource, WorkflowTriggerType, ) __all__ = ( "AnyCustomFieldValue", "BulkEditObjectType", "CorrespondentFilters", "CustomFieldBooleanValue", "CustomFieldDateValue", "CustomFieldDocumentLinkValue", "CustomFieldExtraData", "CustomFieldFilters", "CustomFieldFloatValue", "CustomFieldIntegerValue", "CustomFieldMonetaryValue", "CustomFieldQuery", "CustomFieldSelectOptions", "CustomFieldSelectValue", "CustomFieldStringValue", "CustomFieldType", "CustomFieldURLValue", "CustomFieldValue", "CustomFieldValueT", "CustomFieldsInput", "DocumentFilters", "DocumentMetaEntry", "DocumentSearchHit", "DocumentTypeFilters", "DraftLike", "EditPdfOperation", "FileRetrieveMode", "GroupFilters", "ImapSecurity", "MailAccountType", "MailRuleAction", "MailRuleAttachmentType", "MailRuleConsumptionScope", "MailRuleCorrespondentSource", "MailRulePdfLayout", "MailRuleTitleSource", "MatchingAlgorithm", "OutputType", "Permissions", "ProcessedMailFilters", "SavedViewCustomFieldDisplay", "SavedViewDisplayField", "SavedViewDisplayMode", "SavedViewFilterRule", "SearchQuery", "ShareLinkBundleFilters", "ShareLinkBundleStatus", "ShareLinkFileVersion", "ShareLinkFilters", "SourceMode", "StatisticDocumentFileTypeCount", "StatusDatabase", "StatusDatabaseMigration", "StatusStorage", "StatusTasks", "StatusType", "StoragePathFilters", "TagFilters", "TaskFilters", "TaskStatus", "TaskStatusCounts", "TaskSummary", "TaskSummaryFilters", "TaskTriggerSource", "TaskType", "UserFilters", "WorkflowActionEmail", "WorkflowActionType", "WorkflowActionWebhook", "WorkflowTriggerScheduleDateField", "WorkflowTriggerSource", "WorkflowTriggerType", ) tb1337-pypaperless-5d67d60/pypaperless/models/workflows/000077500000000000000000000000001524076126400232415ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/models/workflows/__init__.py000066400000000000000000000005571524076126400253610ustar00rootroot00000000000000"""PyPaperless workflow models.""" from .actions import ( # noqa: F401 WorkflowAction, WorkflowActionEmail, WorkflowActionType, WorkflowActionWebhook, ) from .triggers import ( # noqa: F401 WorkflowTrigger, WorkflowTriggerScheduleDateField, WorkflowTriggerSource, WorkflowTriggerType, ) from .workflow import Workflow # noqa: F401 tb1337-pypaperless-5d67d60/pypaperless/models/workflows/actions.py000066400000000000000000000055361524076126400252640ustar00rootroot00000000000000"""Provide `WorkflowAction` related models.""" from enum import Enum from typing import Any, ClassVar, Self from pydantic import BaseModel from pypaperless.const import EndpointPath from pypaperless.models.base import IdentifiedModel class WorkflowActionType(Enum): """Represent a subtype of `Workflow`.""" ASSIGNMENT = 1 REMOVAL = 2 EMAIL = 3 WEBHOOK = 4 PASSWORD_REMOVAL = 5 MOVE_TO_TRASH = 6 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class WorkflowActionEmail(BaseModel): """Represent a subtype of `WorkflowAction`.""" id: int | None = None to: str | None = None subject: str | None = None body: str | None = None include_document: bool | None = None class WorkflowActionWebhook(BaseModel): """Represent a subtype of `WorkflowAction`.""" id: int | None = None url: str | None = None use_params: bool | None = None as_json: bool | None = None params: dict[str, Any] | None = None body: str | None = None headers: dict[str, str] | None = None include_document: bool | None = None class WorkflowAction(IdentifiedModel): """Represent a Paperless `WorkflowAction`.""" _api_path: ClassVar[str] = EndpointPath.WORKFLOW_ACTIONS_SINGLE type: WorkflowActionType | None = None assign_title: str | None = None assign_tags: list[int] | None = None assign_correspondent: int | None = None assign_document_type: int | None = None assign_storage_path: int | None = None assign_owner: int | None = None assign_view_users: list[int] | None = None assign_view_groups: list[int] | None = None assign_change_users: list[int] | None = None assign_change_groups: list[int] | None = None assign_custom_fields: list[int] | None = None assign_custom_fields_values: dict[str, Any] | None = None remove_all_tags: bool | None = None remove_tags: list[int] | None = None remove_all_correspondents: bool | None = None remove_correspondents: list[int] | None = None remove_all_document_types: bool | None = None remove_document_types: list[int] | None = None remove_all_storage_paths: bool | None = None remove_storage_paths: list[int] | None = None remove_custom_fields: list[int] | None = None remove_all_custom_fields: bool | None = None remove_all_owners: bool | None = None remove_owners: list[int] | None = None remove_all_permissions: bool | None = None remove_view_users: list[int] | None = None remove_view_groups: list[int] | None = None remove_change_users: list[int] | None = None remove_change_groups: list[int] | None = None email: WorkflowActionEmail | None = None webhook: WorkflowActionWebhook | None = None passwords: list[str] | None = None tb1337-pypaperless-5d67d60/pypaperless/models/workflows/triggers.py000066400000000000000000000051671524076126400254520ustar00rootroot00000000000000"""Provide `WorkflowTrigger` related models.""" from enum import Enum, StrEnum from typing import ClassVar, Self from pypaperless.const import EndpointPath from pypaperless.models import mixins from pypaperless.models.base import IdentifiedModel class WorkflowTriggerType(Enum): """Represent a subtype of `Workflow`.""" CONSUMPTION = 1 DOCUMENT_ADDED = 2 DOCUMENT_UPDATED = 3 SCHEDULED = 4 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class WorkflowTriggerScheduleDateField(StrEnum): """Represent a subtype of `WorkflowTrigger`.""" ADDED = "added" CREATED = "created" MODIFIED = "modified" CUSTOM_FIELD = "custom_field" UNKNOWN = "unknown" @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class WorkflowTriggerSource(Enum): """Represent a subtype of `Workflow`.""" CONSUME_FOLDER = 1 API_UPLOAD = 2 MAIL_FETCH = 3 WEB_UI = 4 UNKNOWN = -1 @classmethod def _missing_(cls, *_: object) -> Self: """Return the UNKNOWN member for any unrecognised value.""" return cls["UNKNOWN"] class WorkflowTrigger(IdentifiedModel, mixins.MatchingFieldsModel): """Represent a Paperless `WorkflowTrigger`.""" _api_path: ClassVar[str] = EndpointPath.WORKFLOW_TRIGGERS_SINGLE sources: list[WorkflowTriggerSource] | None = None type: WorkflowTriggerType | None = None filter_path: str | None = None filter_filename: str | None = None filter_mailrule: int | None = None filter_has_tags: list[int] | None = None filter_has_all_tags: list[int] | None = None filter_has_not_tags: list[int] | None = None filter_custom_field_query: str | None = None filter_has_any_correspondents: list[int] | None = None filter_has_any_document_types: list[int] | None = None filter_has_any_storage_paths: list[int] | None = None filter_has_not_correspondents: list[int] | None = None filter_has_not_document_types: list[int] | None = None filter_has_not_storage_paths: list[int] | None = None filter_has_correspondent: int | None = None filter_has_document_type: int | None = None filter_has_storage_path: int | None = None schedule_offset_days: int | None = None schedule_is_recurring: bool | None = None schedule_recurring_interval_days: int | None = None schedule_date_field: WorkflowTriggerScheduleDateField | None = None schedule_date_custom_field: int | None = None tb1337-pypaperless-5d67d60/pypaperless/models/workflows/workflow.py000066400000000000000000000007351524076126400254720ustar00rootroot00000000000000"""Provide `Workflow` related models.""" from typing import Any, ClassVar from pypaperless.const import EndpointPath from pypaperless.models.base import IdentifiedModel class Workflow(IdentifiedModel): """Represent a Paperless `Workflow`.""" _api_path: ClassVar[str] = EndpointPath.WORKFLOWS_SINGLE name: str | None = None order: int | None = None enabled: bool | None = None actions: list[Any] | None = None triggers: list[Any] | None = None tb1337-pypaperless-5d67d60/pypaperless/pagination.py000066400000000000000000000156141524076126400224330ustar00rootroot00000000000000"""Provide pagination primitives: Page and PageGenerator.""" import asyncio import math from collections.abc import AsyncIterator, Iterator from typing import TYPE_CHECKING, Any from pydantic import Field, PrivateAttr from pypaperless.models.base import _PaperlessBase if TYPE_CHECKING: from pypaperless.models.base import PaperlessModel from pypaperless.runtime import PaperlessRuntime def _mark_exception_retrieved(task: "asyncio.Task[Any]") -> None: """Consume a finished task's exception so abandoned prefetches never warn.""" if not task.cancelled(): task.exception() class Page[ResourceT: "PaperlessModel"](_PaperlessBase): """Represent a single paginated response page from the Paperless API.""" _resource_cls: type[ResourceT] | None = PrivateAttr(default=None) _current_page: int = PrivateAttr(default=0) _page_size: int = PrivateAttr(default=0) count: int = 0 next: str | None = None previous: str | None = None results: list[dict[str, Any]] = Field(default_factory=list) def model_post_init(self, __context: Any, /) -> None: """Bind ``_runtime``, ``_resource_cls``, and pagination state from context.""" super().model_post_init(__context) if isinstance(__context, dict): if "resource_cls" in __context: self._resource_cls = __context["resource_cls"] if "current_page" in __context: self._current_page = __context["current_page"] if "page_size" in __context: self._page_size = __context["page_size"] @property def current_page(self) -> int: """Return the current page number.""" return self._current_page @property def page_size(self) -> int: """Return the page size used for this request.""" return self._page_size @property def current_count(self) -> int: """Return the item count of the current page.""" return len(self.results) @property def has_next_page(self) -> bool: """Return whether there is a next page or not.""" return self.next_page is not None @property def has_previous_page(self) -> bool: """Return whether there is a previous page or not.""" return self.previous_page is not None @property def items(self) -> list[ResourceT]: """Return this page's results deserialized as model instances. Unlike the raw :attr:`results` list (which contains plain dicts), this property deserializes each entry into the appropriate model class. Example:: async for page in paperless.documents.pages(): for doc in page.items: print(doc.title) # doc is a Document instance """ def mapper(data: dict[str, Any]) -> ResourceT: if self._resource_cls is None: msg = "Page was created without a resource_cls; pass resource_cls= to from_data()" raise RuntimeError(msg) return self._resource_cls.from_data(self._runtime, data) return list(map(mapper, self.results)) @property def is_last_page(self) -> bool: """Return whether we are on the last page or not.""" return not self.has_next_page @property def last_page(self) -> int: """Return the last page number.""" if self.page_size <= 0: msg = "Page was created without pagination context; pass page_size= to from_data()" raise RuntimeError(msg) return math.ceil(self.count / self.page_size) @property def next_page(self) -> int | None: """Return the next page number if a next page exists.""" if self.next is None: return None return self.current_page + 1 @property def previous_page(self) -> int | None: """Return the previous page number if a previous page exists.""" 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) class PageGenerator[ResourceT: "PaperlessModel"](AsyncIterator[Page[ResourceT]]): """Async iterator that yields :class:`Page` objects. Used internally by :meth:`~pypaperless.services.mixins.iterable.IterableService.pages` to fetch and paginate through API results. The first request is built from *url* and *params*; every subsequent request follows the server-provided ``next`` URL. While a page is being consumed, the following one is already prefetched concurrently, hiding the request latency. Args: runtime: A :class:`~pypaperless.runtime.PaperlessRuntime` instance. url: The API endpoint URL returning paginated results. resource_cls: The model class used to map raw result dicts. params: Optional query string parameters. """ def __init__( self, runtime: "PaperlessRuntime", url: str, resource_cls: type[ResourceT], params: dict[str, Any] | None = None, ) -> None: """Initialize a :class:`PageGenerator` instance.""" self._runtime = runtime self._resource_cls = resource_cls self._url = url self.params = dict(params) if params else {} self.params.setdefault("page", 1) self.params.setdefault("page_size", 150) self._current_page_number = int(self.params["page"]) self._prefetch: asyncio.Task[Any] | None = None self._exhausted = False def __aiter__(self) -> "PageGenerator[ResourceT]": """Return self as iterator.""" return self async def __anext__(self) -> Page[ResourceT]: """Return the next page, kicking off the prefetch for the one after it.""" if self._exhausted: raise StopAsyncIteration if self._prefetch is not None: res = await self._prefetch self._prefetch = None else: res = await self._runtime.transport.get(self._url, params=self.params) next_url = res.get("next") if isinstance(res, dict) else None if next_url: task = asyncio.ensure_future(self._runtime.transport.get(next_url)) task.add_done_callback(_mark_exception_retrieved) self._prefetch = task else: self._exhausted = True page: Page[ResourceT] = Page.from_data( self._runtime, res, resource_cls=self._resource_cls, current_page=self._current_page_number, page_size=self.params["page_size"], ) self._current_page_number += 1 return page async def aclose(self) -> None: """Cancel a pending prefetch; call when abandoning iteration early.""" if self._prefetch is not None: self._prefetch.cancel() self._prefetch = None self._exhausted = True tb1337-pypaperless-5d67d60/pypaperless/py.typed000066400000000000000000000000001524076126400214060ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/runtime.py000066400000000000000000000020031524076126400217510ustar00rootroot00000000000000"""Provide the PaperlessRuntime class.""" from .cache import PaperlessCache from .const import API_VERSION from .transport import PaperlessTransport class PaperlessRuntime: """Container that binds a transport and a cache for use by services. Constructed by :class:`~pypaperless.client.PaperlessClient` and passed to every service instance. Services access HTTP via ``self._runtime.transport`` and the in-memory cache via ``self._runtime.cache``. Args: transport: The :class:`~pypaperless.transport.PaperlessTransport` instance. cache: The :class:`~pypaperless.cache.PaperlessCache` instance. Example:: runtime = PaperlessRuntime(transport, cache) await runtime.transport.get("/api/documents/") """ def __init__(self, transport: PaperlessTransport, cache: PaperlessCache) -> None: """Initialize a :class:`PaperlessRuntime` instance.""" self.transport = transport self.cache = cache self.api_version: int = API_VERSION tb1337-pypaperless-5d67d60/pypaperless/services/000077500000000000000000000000001524076126400215445ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/services/__init__.py000066400000000000000000000024571524076126400236650ustar00rootroot00000000000000"""PyPaperless services.""" from .base import ResourceService # noqa: F401 from .bulk_edit_objects import BulkEditObjectsService # noqa: F401 from .config import ConfigService # noqa: F401 from .correspondents import CorrespondentService # noqa: F401 from .custom_fields import CustomFieldService # noqa: F401 from .document_types import DocumentTypeService # noqa: F401 from .documents.document import DocumentMetaService, DocumentService # noqa: F401 from .documents.notes import DocumentNoteService # noqa: F401 from .mails import MailAccountService, MailRuleService, ProcessedMailService # noqa: F401 from .permissions import GroupService, UserService # noqa: F401 from .profile import ProfileService # noqa: F401 from .remote_version import RemoteVersionService # noqa: F401 from .saved_views import SavedViewService # noqa: F401 from .search import SearchService # noqa: F401 from .share_links import ShareLinkBundleService, ShareLinkService # noqa: F401 from .statistics import StatisticService # noqa: F401 from .status import StatusService # noqa: F401 from .storage_paths import StoragePathService # noqa: F401 from .tags import TagService # noqa: F401 from .tasks import TaskService # noqa: F401 from .trash import TrashService # noqa: F401 from .workflows import WorkflowService # noqa: F401 tb1337-pypaperless-5d67d60/pypaperless/services/base.py000066400000000000000000000017721524076126400230370ustar00rootroot00000000000000"""Provide base classes for services.""" from typing import TYPE_CHECKING, Protocol from pypaperless.const import EndpointPath, PaperlessResource if TYPE_CHECKING: from pypaperless.runtime import PaperlessRuntime class PaperlessService: """Base class for all services in PyPaperless.""" def __init__(self, runtime: "PaperlessRuntime") -> None: """Initialize a `PaperlessService` instance.""" self._runtime = runtime class ResourceServiceProtocol[ResourceT](Protocol): """Protocol capturing the minimum interface required by all resource service mixins.""" _runtime: "PaperlessRuntime" _api_path: str _resource: PaperlessResource _resource_cls: type[ResourceT] class ResourceService(PaperlessService): """Base class for all resource services in PyPaperless.""" _api_path = EndpointPath.INDEX _resource: PaperlessResource @property def api_path(self) -> str: """Return the API path for the object.""" return self._api_path tb1337-pypaperless-5d67d60/pypaperless/services/bulk_edit_objects.py000066400000000000000000000056621524076126400256020ustar00rootroot00000000000000"""Provide `BulkEditObjects` service.""" from pypaperless.const import EndpointPath from pypaperless.models.bulk_edit import BulkEditObjectType from pypaperless.models.mixins.securable import Permissions from .base import PaperlessService class BulkEditObjectsService(PaperlessService): """Perform bulk operations on non-document objects (tags, correspondents, etc.).""" _api_path = EndpointPath.BULK_EDIT_OBJECTS async def set_permissions( self, object_type: BulkEditObjectType, objects: list[int], *, owner: int | None = None, permissions: Permissions | None = None, merge: bool = False, ) -> None: """Set owner and/or permissions on a list of objects in bulk. Args: object_type: Resource type — one of ``"tags"``, ``"correspondents"``, ``"document_types"``, or ``"storage_paths"``. objects: List of primary keys to operate on. owner: New owner user ID. Pass ``None`` to leave unchanged. permissions: A :class:`~pypaperless.models.mixins.securable.Permissions` object describing view/change grants. Pass ``None`` to leave existing permissions unchanged. merge: When ``True``, the provided permissions are *merged* with the existing ones instead of replacing them. Ownership is only updated for objects that currently have no owner. Example:: from pypaperless.models.types import Permissions await paperless.bulk_edit_objects.set_permissions( "tags", [1, 2, 3], owner=1, permissions=Permissions(view_users=[2], change_users=[1]), ) """ payload: dict = { "objects": objects, "object_type": object_type, "operation": "set_permissions", "merge": merge, } if owner is not None: payload["owner"] = owner if permissions is not None: payload["permissions"] = permissions.model_dump() await self._runtime.transport.post(self._api_path, json=payload) async def delete( self, object_type: BulkEditObjectType, objects: list[int], ) -> None: """Permanently delete a list of objects. Args: object_type: Resource type — one of ``"tags"``, ``"correspondents"``, ``"document_types"``, or ``"storage_paths"``. objects: List of primary keys to delete. Example:: await paperless.bulk_edit_objects.delete("correspondents", [4, 5]) """ payload = { "objects": objects, "object_type": object_type, "operation": "delete", } await self._runtime.transport.post(self._api_path, json=payload) tb1337-pypaperless-5d67d60/pypaperless/services/config.py000066400000000000000000000014041524076126400233620ustar00rootroot00000000000000"""Provide `Config` service.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.config import Config from .base import ResourceService class ConfigService(ResourceService): """Represent a factory for Paperless `Config` models.""" _api_path = EndpointPath.CONFIG _resource = PaperlessResource.CONFIG _resource_cls = Config async def __call__(self) -> Config: """Fetch the Paperless application configuration. Example:: cfg = await paperless.config() print(cfg.user_args) """ api_path = self._resource_cls.format_api_path(pk=1) res = await self._runtime.transport.get(api_path) return self._resource_cls.from_data(self._runtime, res) tb1337-pypaperless-5d67d60/pypaperless/services/correspondents.py000066400000000000000000000027551524076126400251770ustar00rootroot00000000000000"""Provide `Correspondent` related services.""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Self, Unpack from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.correspondents import Correspondent, CorrespondentDraft from pypaperless.models.filters import CorrespondentFilters from . import mixins from .base import ResourceService class CorrespondentService( ResourceService, mixins.SecurableService, mixins.CallableService[Correspondent], mixins.CreatableService[CorrespondentDraft], mixins.IterableService[Correspondent], mixins.UpdatableService[Correspondent], mixins.DeletableService[Correspondent], ): """Represent a factory for Paperless `Correspondent` models.""" _api_path = EndpointPath.CORRESPONDENTS _resource = PaperlessResource.CORRESPONDENTS _draft_cls = CorrespondentDraft _resource_cls = Correspondent @asynccontextmanager async def filter(self, **kwargs: Unpack[CorrespondentFilters]) -> AsyncGenerator[Self]: """Iterate correspondents with server-side filters. See :class:`~pypaperless.models.filters.CorrespondentFilters` for all available keys. Example:: async with paperless.correspondents.filter(name__icontains="acme") as filtered: async for c in filtered: print(c.name) """ async with self._store_filters(**kwargs) as ctx: yield ctx tb1337-pypaperless-5d67d60/pypaperless/services/custom_fields.py000066400000000000000000000026501524076126400247610ustar00rootroot00000000000000"""Provide `CustomField` service.""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Self, Unpack from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.custom_fields import CustomField, CustomFieldDraft from pypaperless.models.filters import CustomFieldFilters from . import mixins from .base import ResourceService class CustomFieldService( ResourceService, mixins.CallableService[CustomField], mixins.CreatableService[CustomFieldDraft], mixins.IterableService[CustomField], mixins.UpdatableService[CustomField], mixins.DeletableService[CustomField], ): """Represent a factory for Paperless `CustomField` models.""" _api_path = EndpointPath.CUSTOM_FIELDS _resource = PaperlessResource.CUSTOM_FIELDS _draft_cls = CustomFieldDraft _resource_cls = CustomField @asynccontextmanager async def filter(self, **kwargs: Unpack[CustomFieldFilters]) -> AsyncGenerator[Self]: """Iterate custom fields with server-side filters. See :class:`~pypaperless.models.filters.CustomFieldFilters` for all available keys. Example:: async with paperless.custom_fields.filter(name__icontains="amount") as filtered: async for cf in filtered: print(cf.name) """ async with self._store_filters(**kwargs) as ctx: yield ctx tb1337-pypaperless-5d67d60/pypaperless/services/document_types.py000066400000000000000000000027431524076126400251660ustar00rootroot00000000000000"""Provide `DocumentType` related services.""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Self, Unpack from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.document_types import DocumentType, DocumentTypeDraft from pypaperless.models.filters import DocumentTypeFilters from . import mixins from .base import ResourceService class DocumentTypeService( ResourceService, mixins.SecurableService, mixins.CallableService[DocumentType], mixins.CreatableService[DocumentTypeDraft], mixins.IterableService[DocumentType], mixins.UpdatableService[DocumentType], mixins.DeletableService[DocumentType], ): """Represent a factory for Paperless `DocumentType` models.""" _api_path = EndpointPath.DOCUMENT_TYPES _resource = PaperlessResource.DOCUMENT_TYPES _draft_cls = DocumentTypeDraft _resource_cls = DocumentType @asynccontextmanager async def filter(self, **kwargs: Unpack[DocumentTypeFilters]) -> AsyncGenerator[Self]: """Iterate document types with server-side filters. See :class:`~pypaperless.models.filters.DocumentTypeFilters` for all available keys. Example:: async with paperless.document_types.filter(name__icontains="invoice") as filtered: async for dt in filtered: print(dt.name) """ async with self._store_filters(**kwargs) as ctx: yield ctx tb1337-pypaperless-5d67d60/pypaperless/services/documents/000077500000000000000000000000001524076126400235455ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/services/documents/__init__.py000066400000000000000000000000451524076126400256550ustar00rootroot00000000000000"""PyPaperless document services.""" tb1337-pypaperless-5d67d60/pypaperless/services/documents/ai_suggestions.py000066400000000000000000000024051524076126400271430ustar00rootroot00000000000000"""Provide `DocumentAISuggestions` service.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.documents.ai_suggestions import DocumentAISuggestions from .base import DocumentScopedServiceBase class DocumentAISuggestionsService(DocumentScopedServiceBase): """Represent a factory for Paperless `DocumentAISuggestions` models.""" _api_path = EndpointPath.DOCUMENTS_AI_SUGGESTIONS _resource = PaperlessResource.DOCUMENTS _resource_cls = DocumentAISuggestions async def __call__(self, pk: int | None = None) -> DocumentAISuggestions: """Return AI-generated suggestions for a document. Args: pk: Document primary key. May be omitted when the service is accessed via a :class:`~pypaperless.models.documents.document.Document` instance (``doc.ai_suggestions()``). Example:: result = await paperless.documents.ai_suggestions(42) print(result.title, result.suggested_tags) """ doc_pk = self._get_document_pk(pk) api_path = self._api_path.format(pk=doc_pk) data = await self._runtime.transport.get(api_path) data["id"] = doc_pk return self._resource_cls.from_data(self._runtime, data) tb1337-pypaperless-5d67d60/pypaperless/services/documents/base.py000066400000000000000000000021641524076126400250340ustar00rootroot00000000000000"""Provide the shared base for document-scoped sub-services.""" from typing import TYPE_CHECKING, cast from pypaperless.exceptions import PrimaryKeyRequiredError from pypaperless.services.base import ResourceService if TYPE_CHECKING: from pypaperless.runtime import PaperlessRuntime class DocumentScopedServiceBase(ResourceService): """Base class for sub-services scoped to a specific document. Manages the optional `attached_to` document pk and provides `_get_document_pk` to resolve the effective pk at call time. """ def __init__(self, runtime: "PaperlessRuntime", attached_to: int | None = None) -> None: """Initialize with an optional attached document pk.""" super().__init__(runtime) self._attached_to = attached_to def _get_document_pk(self, pk: int | None = None) -> int: """Return the attached document pk, or the parameter.""" if not any((self._attached_to, pk)): message = f"Accessing {type(self).__name__} data without a primary key." raise PrimaryKeyRequiredError(message) return cast("int", self._attached_to or pk) tb1337-pypaperless-5d67d60/pypaperless/services/documents/bulk_edit.py000066400000000000000000000470211524076126400260650ustar00rootroot00000000000000"""Provide `DocumentBulkEdit` service.""" from pypaperless.const import EndpointPath from pypaperless.exceptions import BulkEditError, BulkEditPagesError from pypaperless.models.bulk_edit import CustomFieldsInput, EditPdfOperation, SourceMode from pypaperless.models.mixins.securable import Permissions from pypaperless.services.base import PaperlessService class DocumentBulkEditService(PaperlessService): """Perform bulk operations on a list of documents.""" _api_path = EndpointPath.DOCUMENTS_BULK_EDIT async def _post(self, path: str, *, json: dict) -> None: """POST to *path* and raise `BulkEditError` when the result is not ``"OK"``.""" data = await self._runtime.transport.post(path, json=json) if data.get("result") != "OK": raise BulkEditError(str(data.get("result"))) async def _page_count(self, document: int) -> int: """Read `page_count` off the document record; the kept-pages complement needs it.""" data = await self._runtime.transport.get(EndpointPath.DOCUMENTS_SINGLE.format(pk=document)) page_count: int | None = data.get("page_count") if page_count is None: msg = ( f"Document {document} reports no page count (not a PDF, or not processed yet) " "— pass page_count explicitly." ) raise BulkEditPagesError(msg) return page_count async def set_correspondent( self, documents: list[int], correspondent: int | None, ) -> None: """Assign a correspondent to a list of documents in bulk. Args: documents: List of document primary keys. correspondent: Correspondent primary key to assign, or ``None`` to clear. Example:: await paperless.documents.bulk_edit.set_correspondent([1, 2, 3], 5) await paperless.documents.bulk_edit.set_correspondent([1, 2, 3], None) # clear """ payload = { "documents": documents, "method": "set_correspondent", "parameters": {"correspondent": correspondent}, } await self._post(self._api_path, json=payload) async def set_document_type( self, documents: list[int], document_type: int | None, ) -> None: """Assign a document type to a list of documents in bulk. Args: documents: List of document primary keys. document_type: DocumentType primary key to assign, or ``None`` to clear. Example:: await paperless.documents.bulk_edit.set_document_type([1, 2], 3) await paperless.documents.bulk_edit.set_document_type([1, 2], None) # clear """ payload = { "documents": documents, "method": "set_document_type", "parameters": {"document_type": document_type}, } await self._post(self._api_path, json=payload) async def set_storage_path( self, documents: list[int], storage_path: int | None, ) -> None: """Assign a storage path to a list of documents in bulk. Args: documents: List of document primary keys. storage_path: StoragePath primary key to assign, or ``None`` to clear. Example:: await paperless.documents.bulk_edit.set_storage_path([1, 2], 4) await paperless.documents.bulk_edit.set_storage_path([1, 2], None) # clear """ payload = { "documents": documents, "method": "set_storage_path", "parameters": {"storage_path": storage_path}, } await self._post(self._api_path, json=payload) async def add_tag( self, documents: list[int], tag: int, ) -> None: """Add a tag to a list of documents in bulk. Args: documents: List of document primary keys. tag: Tag primary key to add. Example:: await paperless.documents.bulk_edit.add_tag([1, 2, 3], 7) """ payload = { "documents": documents, "method": "add_tag", "parameters": {"tag": tag}, } await self._post(self._api_path, json=payload) async def remove_tag( self, documents: list[int], tag: int, ) -> None: """Remove a tag from a list of documents in bulk. Args: documents: List of document primary keys. tag: Tag primary key to remove. Example:: await paperless.documents.bulk_edit.remove_tag([1, 2, 3], 7) """ payload = { "documents": documents, "method": "remove_tag", "parameters": {"tag": tag}, } await self._post(self._api_path, json=payload) async def modify_tags( self, documents: list[int], *, add_tags: list[int], remove_tags: list[int], ) -> None: """Add and/or remove tags on a list of documents in bulk. Args: documents: List of document primary keys. add_tags: List of tag primary keys to add. remove_tags: List of tag primary keys to remove. Example:: await paperless.documents.bulk_edit.modify_tags( [1, 2, 3], add_tags=[5, 6], remove_tags=[2], ) """ payload = { "documents": documents, "method": "modify_tags", "parameters": {"add_tags": add_tags, "remove_tags": remove_tags}, } await self._post(self._api_path, json=payload) async def modify_custom_fields( self, documents: list[int], *, add_custom_fields: CustomFieldsInput, remove_custom_fields: CustomFieldsInput, ) -> None: """Add and/or remove custom field values on a list of documents in bulk. Args: documents: List of document primary keys. add_custom_fields: Custom fields to add — either a list of PKs or a ``{pk: value}`` dict. remove_custom_fields: Custom fields to remove — either a list of PKs or a ``{pk: value}`` dict. Example:: await paperless.documents.bulk_edit.modify_custom_fields( [1, 2], add_custom_fields={3: "open"}, remove_custom_fields=[4], ) """ payload = { "documents": documents, "method": "modify_custom_fields", "parameters": { "add_custom_fields": add_custom_fields, "remove_custom_fields": remove_custom_fields, }, } await self._post(self._api_path, json=payload) async def set_permissions( self, documents: list[int], *, owner: int | None = None, permissions: Permissions | None = None, merge: bool = False, ) -> None: """Set owner and/or permissions on a list of documents in bulk. Args: documents: List of document primary keys. owner: New owner user ID, or ``None`` to leave unchanged. permissions: A :class:`~pypaperless.models.mixins.securable.Permissions` object describing view/change grants. Pass ``None`` to leave existing permissions unchanged. merge: When ``True``, permissions are *merged* with existing ones instead of replacing them. Owner is only updated for documents that currently have no owner. Example:: from pypaperless.models.types import Permissions await paperless.documents.bulk_edit.set_permissions( [1, 2, 3], owner=1, permissions=Permissions(view_users=[2, 3], change_users=[1]), ) """ parameters: dict = {"merge": merge} if owner is not None: parameters["owner"] = owner if permissions is not None: parameters["set_permissions"] = permissions.model_dump() payload = { "documents": documents, "method": "set_permissions", "parameters": parameters, } await self._post(self._api_path, json=payload) async def delete(self, documents: list[int]) -> None: """Move a list of documents to the trash. Args: documents: List of document primary keys to move to trash. Example:: await paperless.documents.bulk_edit.delete([10, 11, 12]) """ await self._post(EndpointPath.DOCUMENTS_DELETE, json={"documents": documents}) async def reprocess(self, documents: list[int]) -> None: """Reprocess (re-run OCR) on a list of documents. Args: documents: List of document primary keys to reprocess. Example:: await paperless.documents.bulk_edit.reprocess([1, 2, 3]) """ await self._post(EndpointPath.DOCUMENTS_REPROCESS, json={"documents": documents}) async def rotate( self, documents: list[int], degrees: int, *, source_mode: SourceMode = "latest_version", ) -> None: """Rotate one or more documents by the given degrees. Args: documents: List of document primary keys to rotate. degrees: Rotation angle in degrees (e.g. ``90``, ``180``, ``270``). source_mode: Whether to operate on ``"latest_version"`` or ``"explicit_selection"``. Example:: await paperless.documents.bulk_edit.rotate([1, 2], 90) """ payload = { "documents": documents, "degrees": degrees, "source_mode": source_mode, } await self._post(EndpointPath.DOCUMENTS_ROTATE, json=payload) async def merge( self, documents: list[int], *, metadata_document_id: int | None = None, delete_originals: bool = False, archive_fallback: bool = False, source_mode: SourceMode = "latest_version", ) -> None: """Merge multiple documents into a single new document. Args: documents: List of document primary keys to merge. metadata_document_id: Primary key of the document whose metadata should be used for the merged result. Pass ``None`` to use defaults. delete_originals: When ``True``, source documents are moved to trash after merging. archive_fallback: When ``True``, use the archived version when the original is unavailable. source_mode: Whether to operate on ``"latest_version"`` or ``"explicit_selection"``. Example:: await paperless.documents.bulk_edit.merge( [10, 11, 12], metadata_document_id=10, delete_originals=True, ) """ payload: dict = { "documents": documents, "delete_originals": delete_originals, "archive_fallback": archive_fallback, "source_mode": source_mode, } if metadata_document_id is not None: payload["metadata_document_id"] = metadata_document_id await self._post(EndpointPath.DOCUMENTS_MERGE, json=payload) async def edit_pdf( self, document: int, operations: list[EditPdfOperation], *, delete_original: bool = False, update_document: bool = False, include_metadata: bool = True, source_mode: SourceMode = "latest_version", ) -> None: """Apply PDF page-level operations (rotate, reorder, remove) to a document. Note: the API only supports a **single** document per request. Args: document: Primary key of the document to edit. operations: List of page operations. Each entry must have a required ``"page"`` key and optional ``"rotate"`` and ``"doc"`` keys. delete_original: When ``True``, the source document is moved to trash after editing. update_document: When ``True``, the source document is updated in-place rather than creating a new document. include_metadata: When ``True``, metadata is carried over to the resulting document. source_mode: Whether to operate on ``"latest_version"`` or ``"explicit_selection"``. Example:: await paperless.documents.bulk_edit.edit_pdf( 42, operations=[{"page": 1, "rotate": 90}, {"page": 3}], delete_original=True, ) """ payload = { "documents": [document], "operations": list(operations), "delete_original": delete_original, "update_document": update_document, "include_metadata": include_metadata, "source_mode": source_mode, } await self._post(EndpointPath.DOCUMENTS_EDIT_PDF, json=payload) async def split( self, document: int, pages: list[list[int]], *, delete_originals: bool = False, source_mode: SourceMode = "latest_version", ) -> None: """Split a document into several new documents, one per page group. Note: the API only supports a **single** document per request. This is a convenience wrapper around :meth:`edit_pdf`, so the results inherit the source metadata unchanged — there is no ``(split N)`` title suffix. Args: document: Primary key of the document to split. pages: One list of 1-based page numbers per resulting document. ``[[1, 2], [3]]`` yields two documents: pages 1-2 and page 3. Pages listed in no group are dropped; the server validates the page numbers. delete_originals: When ``True``, the source document is moved to trash once the new documents are consumed. source_mode: Whether to operate on ``"latest_version"`` or ``"explicit_selection"``. Raises: BulkEditPagesError: When *pages* is empty or contains an empty group. Example:: await paperless.documents.bulk_edit.split(42, [[1, 2], [3]]) """ if not pages: msg = "split() requires at least one page group." raise BulkEditPagesError(msg) if any(not group for group in pages): msg = "split() page groups must not be empty." raise BulkEditPagesError(msg) operations: list[EditPdfOperation] = [ {"page": page, "doc": index} for index, group in enumerate(pages) for page in group ] await self.edit_pdf( document, operations, delete_original=delete_originals, source_mode=source_mode, ) async def delete_pages( self, document: int, pages: list[int], *, page_count: int | None = None, source_mode: SourceMode = "latest_version", ) -> None: """Remove pages from a document, creating a new version of it. Note: the API has no "remove" operation — :meth:`edit_pdf` keeps exactly the pages it is handed — so the complement of *pages* is computed here, which needs the total page count. Because that count comes from an earlier request, the pages to keep are a snapshot: if the document gains a version between the lookup and the edit, the wrong pages survive. A shorter file is rejected by the server as out of bounds, but a longer one silently loses its extra pages. Passing *page_count* does not close that window — it only skips the lookup, moving the staleness to the caller's own read. Args: document: Primary key of the document to edit. pages: 1-based page numbers to remove. page_count: Total pages of the source file. Looked up from the document record when ``None``. Pass it to save that request, for documents whose record carries no page count, or when ``source_mode`` selects a file with a different number of pages than the document record. source_mode: Whether to operate on ``"latest_version"`` or ``"explicit_selection"``. Raises: BulkEditPagesError: When *pages* is empty, falls outside the document, or would remove every page. Example:: await paperless.documents.bulk_edit.delete_pages(42, [2, 4]) """ if not pages: msg = "delete_pages() requires at least one page to remove." raise BulkEditPagesError(msg) if page_count is None: page_count = await self._page_count(document) out_of_range = sorted({page for page in pages if not 1 <= page <= page_count}) if out_of_range: msg = f"Pages {out_of_range} are out of range for a {page_count} page document." raise BulkEditPagesError(msg) remaining = sorted(set(range(1, page_count + 1)) - set(pages)) if not remaining: msg = "delete_pages() must keep at least one page." raise BulkEditPagesError(msg) operations: list[EditPdfOperation] = [{"page": page} for page in remaining] await self.edit_pdf( document, operations, update_document=True, source_mode=source_mode, ) async def remove_password( self, documents: list[int], password: str, *, update_document: bool = False, delete_original: bool = False, include_metadata: bool = True, source_mode: SourceMode = "latest_version", ) -> None: """Remove password protection from one or more PDF documents. Args: documents: List of document primary keys. password: Current PDF password to unlock the file. update_document: When ``True``, the source documents are updated in-place. delete_original: When ``True``, the original documents are moved to trash after decryption. include_metadata: When ``True``, metadata is carried over. source_mode: Whether to operate on ``"latest_version"`` or ``"explicit_selection"``. Example:: await paperless.documents.bulk_edit.remove_password( [5, 6], password="secret", delete_original=True, ) """ payload = { "documents": documents, "password": password, "update_document": update_document, "delete_original": delete_original, "include_metadata": include_metadata, "source_mode": source_mode, } await self._post(EndpointPath.DOCUMENTS_REMOVE_PASSWORD, json=payload) tb1337-pypaperless-5d67d60/pypaperless/services/documents/chat.py000066400000000000000000000020721524076126400250370ustar00rootroot00000000000000"""Provide `DocumentChat` service.""" from pypaperless.const import EndpointPath from pypaperless.models.documents.chat import DocumentChat from pypaperless.services.base import PaperlessService class DocumentChatService(PaperlessService): """Perform an LLM-powered chat query against Paperless documents.""" _api_path = EndpointPath.DOCUMENTS_CHAT async def __call__(self, q: str, document_id: int | None = None) -> DocumentChat: """Send a chat query to Paperless and return the response. Args: q: The question or query string. document_id: Optional document primary key to scope the query. Example:: response = await paperless.documents.chat("What is this invoice about?", 42) print(response.q) """ payload: dict[str, object] = {"q": q} if document_id is not None: payload["document_id"] = document_id data = await self._runtime.transport.post(self._api_path, json=payload) return DocumentChat.from_data(self._runtime, data) tb1337-pypaperless-5d67d60/pypaperless/services/documents/document.py000066400000000000000000000336451524076126400257500ustar00rootroot00000000000000"""Provide `Document` related services.""" import asyncio import hashlib from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Self, Unpack from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.exceptions import AsnRequestError, SendEmailError from pypaperless.models.documents.document import ( Document, DocumentDraft, DocumentMeta, DocumentSuggestions, ) from pypaperless.models.filters import DocumentFilters from pypaperless.services import mixins from pypaperless.services.base import ResourceService from .ai_suggestions import DocumentAISuggestionsService from .bulk_edit import DocumentBulkEditService from .chat import DocumentChatService from .files import ( DocumentFileDownloadService, DocumentFilePreviewService, DocumentFileThumbnailService, ) from .history import DocumentHistoryService from .notes import DocumentNoteService from .share_links import DocumentShareLinkService from .versions import DocumentRootService, DocumentVersionService if TYPE_CHECKING: from pypaperless.runtime import PaperlessRuntime class DocumentSuggestionsService(ResourceService): """Represent a factory for Paperless `DocumentSuggestions` models.""" _api_path = EndpointPath.DOCUMENTS_SUGGESTIONS _resource = PaperlessResource.DOCUMENTS _resource_cls = DocumentSuggestions async def __call__(self, pk: int) -> DocumentSuggestions: """Request exactly one resource item.""" api_path = self._resource_cls.format_api_path(pk=pk) data = await self._runtime.transport.get(api_path) data["id"] = pk return self._resource_cls.from_data(self._runtime, data) class DocumentMetaService(ResourceService, mixins.CallableService[DocumentMeta]): """Represent a factory for Paperless `DocumentMeta` models.""" _api_path = EndpointPath.DOCUMENTS_META _resource = PaperlessResource.DOCUMENTS _resource_cls = DocumentMeta class DocumentService( ResourceService, mixins.SecurableService, mixins.CallableService[Document], mixins.CreatableService[DocumentDraft], mixins.IterableService[Document], mixins.UpdatableService[Document], mixins.DeletableService[Document], ): """Represent a factory for Paperless `Document` models.""" _api_path = EndpointPath.DOCUMENTS _resource = PaperlessResource.DOCUMENTS _draft_cls = DocumentDraft _resource_cls = Document @asynccontextmanager async def filter(self, **kwargs: Unpack[DocumentFilters]) -> AsyncGenerator[Self]: """Iterate documents with server-side filters. See :class:`~pypaperless.models.filters.DocumentFilters` for all available keys. Example:: async with paperless.documents.filter( title__icontains="invoice", tags__id__all="3,7", ) as filtered: async for doc in filtered: print(doc.title) """ async with self._store_filters(**kwargs) as ctx: yield ctx def __init__(self, runtime: "PaperlessRuntime") -> None: """Initialize a `DocumentService` instance.""" super().__init__(runtime) self._ai_suggestions = DocumentAISuggestionsService(runtime) self._bulk_edit = DocumentBulkEditService(runtime) self._chat = DocumentChatService(runtime) self._download = DocumentFileDownloadService(runtime) self._history = DocumentHistoryService(runtime) self._meta = DocumentMetaService(runtime) self._notes = DocumentNoteService(runtime) self._preview = DocumentFilePreviewService(runtime) self._share_links = DocumentShareLinkService(runtime) self._suggestions = DocumentSuggestionsService(runtime) self._thumbnail = DocumentFileThumbnailService(runtime) self._root = DocumentRootService(runtime) self._versions = DocumentVersionService(runtime) @property def ai_suggestions(self) -> DocumentAISuggestionsService: """Return the ``DocumentAISuggestionsService`` sub-service. Example:: result = await paperless.documents.ai_suggestions(42) print(result.title, result.suggested_tags) """ return self._ai_suggestions @property def bulk_edit(self) -> DocumentBulkEditService: """Return the ``DocumentBulkEditService`` sub-service. Example:: await paperless.documents.bulk_edit.set_correspondent([1, 2], 5) """ return self._bulk_edit @property def chat(self) -> DocumentChatService: """Return the ``DocumentChatService`` sub-service. Example:: response = await paperless.documents.chat("What is this invoice about?", 42) print(response.q) """ return self._chat @property def download(self) -> DocumentFileDownloadService: """Return the ``DocumentFileDownloadService`` sub-service. Example:: result = await paperless.documents.download(42) with open(result.disposition_filename, "wb") as f: f.write(result.content) """ return self._download @property def history(self) -> DocumentHistoryService: """Return the ``DocumentHistoryService`` sub-service. Example:: # fetch history for a document by pk entries = await paperless.documents.history(42) # or via an already-fetched document doc = await paperless.documents(42) entries = await doc.history() """ return self._history @property def metadata(self) -> DocumentMetaService: """Return the ``DocumentMetaService`` sub-service. Example:: meta = await paperless.documents.metadata(42) print(meta.media_filename) """ return self._meta @property def notes(self) -> DocumentNoteService: """Return the ``DocumentNoteService`` sub-service. Example:: # fetch notes by document pk notes = await paperless.documents.notes(42) # or via an already-fetched document doc = await paperless.documents(42) notes = await doc.notes() """ return self._notes @property def share_links(self) -> DocumentShareLinkService: """Return the ``DocumentShareLinkService`` sub-service. Example:: # fetch share links by document pk links = await paperless.documents.share_links(42) # or via an already-fetched document doc = await paperless.documents(42) links = await doc.share_links() """ return self._share_links @property def preview(self) -> DocumentFilePreviewService: """Return the ``DocumentFilePreviewService`` sub-service. Example:: result = await paperless.documents.preview(42) """ return self._preview @property def suggestions(self) -> DocumentSuggestionsService: """Return the ``DocumentSuggestionsService`` sub-service. Example:: suggestions = await paperless.documents.suggestions(42) print(suggestions.correspondents) """ return self._suggestions @property def thumbnail(self) -> DocumentFileThumbnailService: """Return the ``DocumentFileThumbnailService`` sub-service. Example:: result = await paperless.documents.thumbnail(42) """ return self._thumbnail @property def root(self) -> DocumentRootService: """Return the ``DocumentRootService`` sub-service. Example:: result = await paperless.documents.root(42) print(result.root_id) """ return self._root @property def versions(self) -> DocumentVersionService: """Return the ``DocumentVersionService`` sub-service. Example:: await paperless.documents.versions.upload(file, pk=42) result = await paperless.documents.versions.update(1, version_label="v2", pk=42) await paperless.documents.versions.delete(1, pk=42) """ return self._versions async def get_next_asn(self) -> int: """Request the next available archive serial number from Paperless. Example:: asn = await paperless.documents.get_next_asn() draft.archive_serial_number = asn """ res = await self._runtime.transport.request_raw("get", EndpointPath.DOCUMENTS_NEXT_ASN) try: res.raise_for_status() return int(res.text) except Exception as exc: raise AsnRequestError from exc async def more_like(self, pk: int) -> AsyncGenerator[Document]: """Iterate documents similar to the given document. Uses the full-text index to find semantically related documents. Shortcut for :meth:`filter` with ``more_like_id``. Args: pk: Primary key of the reference document. Example:: async for doc in paperless.documents.more_like(42): print(doc.title) """ async with self.filter(more_like_id=pk) as filtered: async for item in filtered: yield item async def search( self, query: str | None = None, custom_field_query: str | None = None ) -> AsyncGenerator[Document]: """Iterate documents matching a full-text search and/or custom field query. When neither argument is provided, all documents are returned. This is a shortcut for :meth:`filter` with ``query`` / ``custom_field_query``. Args: query: Whoosh-style full-text query string, or a :class:`~pypaperless.builders.search.SearchQuery` builder object cast to ``str``. custom_field_query: JSON-encoded custom field query, typically produced by a :class:`~pypaperless.builders.custom_fields.CustomFieldQuery` builder object. Example:: async for doc in paperless.documents.search("invoice"): print(doc.title) # with a custom field filter from pypaperless.builders.custom_fields import CustomFieldQuery q = CustomFieldQuery("Status", "exact", "open") async for doc in paperless.documents.search(custom_field_query=str(q)): print(doc.title) """ filters: DocumentFilters = {} if query is not None: filters["query"] = query if custom_field_query is not None: filters["custom_field_query"] = custom_field_query async with self.filter(**filters) as filtered: async for item in filtered: yield item async def find_duplicate( self, content: bytes, *, filename: str | None = None, ) -> Document | None: """Return an existing document with matching MD5 checksum, or ``None``. Paperless-ngx stores an MD5 hash of every uploaded file. This helper computes the hash client-side and queries the API before you upload, so you can skip the round-trip through the consume queue for known duplicates. When ``filename`` is given, it is additionally matched against ``original_filename`` (case-insensitive). Args: content: Raw file bytes you intend to upload. filename: Optional original filename to narrow the match further. Example:: with open("invoice.pdf", "rb") as f: content = f.read() existing = await paperless.documents.find_duplicate( content, filename="invoice.pdf" ) if existing is not None: print(f"Already uploaded as #{existing.id}: {existing.title}") else: draft = paperless.documents.create( document=content, filename="invoice.pdf" ) await paperless.documents.save(draft) """ checksum = await asyncio.to_thread( lambda: hashlib.md5(content, usedforsecurity=False).hexdigest() ) filters: DocumentFilters = {"checksum__iexact": checksum} if filename is not None: filters["original_filename__iexact"] = filename async with self.filter(**filters) as filtered: async for doc in filtered: return doc return None async def email( self, documents: int | list[int], *, addresses: str, subject: str, message: str, use_archive_version: bool = True, ) -> None: """Email one or more documents to recipients as attachments. Args: documents: Single document pk or a list of pks to send. addresses: Comma-separated list of recipient e-mail addresses. subject: E-mail subject line. message: E-mail body text. use_archive_version: When ``True`` (default), the archived PDF is attached instead of the original file. Example:: await paperless.documents.email( [23, 42], addresses="alice@example.com, bob@example.com", subject="Your documents", message="Please find the attached files.", ) """ data = { "documents": [documents] if isinstance(documents, int) else documents, "addresses": addresses, "subject": subject, "message": message, "use_archive_version": use_archive_version, } try: await self._runtime.transport.post(EndpointPath.DOCUMENTS_EMAIL, json=data) except Exception as exc: raise SendEmailError from exc tb1337-pypaperless-5d67d60/pypaperless/services/documents/files.py000066400000000000000000000045151524076126400252260ustar00rootroot00000000000000"""Provide `Document` file retrieval services.""" from typing import Any, ClassVar from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.documents.document import DownloadedDocument, FileRetrieveMode from pypaperless.services.base import ResourceService class _DocumentFileServiceBase(ResourceService): """Base class for document file retrieval services (download, preview, thumbnail).""" _api_path = EndpointPath.DOCUMENTS_DOWNLOAD _resource = PaperlessResource.DOCUMENTS _mode: ClassVar[FileRetrieveMode] _resource_cls = DownloadedDocument async def __call__(self, pk: int, *, original: bool = False) -> DownloadedDocument: """Request exactly one resource item.""" params = { "original": "true" if original else "false", } res = await self._runtime.transport.request_raw( "get", self._api_path.format(pk=pk), params=params ) self._runtime.transport.raise_for_status(res) data: dict[str, Any] = { "id": pk, "mode": self._mode, "original": original, "content": res.content, "content_type": res.headers.get("content-type"), } content_disposition = res.headers.get("content-disposition") if content_disposition is not None: parts = content_disposition.split(";") data["disposition_type"] = parts[0].strip() for part in parts[1:]: stripped = part.strip() if stripped.startswith("filename="): data["disposition_filename"] = stripped.split("=", 1)[1].strip('"') return self._resource_cls.from_data(self._runtime, data) class DocumentFileDownloadService(_DocumentFileServiceBase): """Retrieve the archived file of a document for download.""" _api_path = EndpointPath.DOCUMENTS_DOWNLOAD _mode = FileRetrieveMode.DOWNLOAD class DocumentFilePreviewService(_DocumentFileServiceBase): """Retrieve the archived file of a document for inline preview.""" _api_path = EndpointPath.DOCUMENTS_PREVIEW _mode = FileRetrieveMode.PREVIEW class DocumentFileThumbnailService(_DocumentFileServiceBase): """Retrieve the thumbnail image of a document.""" _api_path = EndpointPath.DOCUMENTS_THUMBNAIL _mode = FileRetrieveMode.THUMBNAIL tb1337-pypaperless-5d67d60/pypaperless/services/documents/history.py000066400000000000000000000024031524076126400256170ustar00rootroot00000000000000"""Provide `DocumentHistory` related services.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.documents.history import DocumentHistory from .base import DocumentScopedServiceBase class DocumentHistoryService(DocumentScopedServiceBase): """Represent a factory for Paperless `DocumentHistory` models.""" _api_path = EndpointPath.DOCUMENTS_HISTORY _resource = PaperlessResource.DOCUMENTS _resource_cls = DocumentHistory async def __call__(self, pk: int | None = None) -> list[DocumentHistory]: """Return all history entries for a document. Args: pk: Document primary key. May be omitted when the service is accessed via a :class:`~pypaperless.models.documents.document.Document` instance (``doc.history()``). Example:: entries = await paperless.documents.history(42) for entry in entries: print(entry.action, entry.timestamp) """ doc_pk = self._get_document_pk(pk) res = await self._runtime.transport.get(self._api_path.format(pk=doc_pk)) return [ self._resource_cls.from_data(self._runtime, {**item, "document": doc_pk}) for item in res ] tb1337-pypaperless-5d67d60/pypaperless/services/documents/notes.py000066400000000000000000000200171524076126400252470ustar00rootroot00000000000000"""Provide `DocumentNote` related services.""" from typing import TYPE_CHECKING, Any, cast from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.exceptions import ( DeletionError, JsonResponseWithError, PrimaryKeyRequiredError, ) from pypaperless.models.base import DraftLike from pypaperless.models.documents.notes import DocumentNote, DocumentNoteDraft from pypaperless.services.mixins import CreatableService, DeletableService from .base import DocumentScopedServiceBase if TYPE_CHECKING: from pypaperless.models.documents.document import Document from pypaperless.runtime import PaperlessRuntime class DocumentNoteService( DocumentScopedServiceBase, DeletableService[DocumentNote], CreatableService[DocumentNoteDraft], ): """Represent a factory for Paperless `DocumentNote` models.""" _api_path = EndpointPath.DOCUMENTS_NOTES _resource = PaperlessResource.DOCUMENTS _draft_cls = DocumentNoteDraft _resource_cls = DocumentNote def __init__( self, runtime: "PaperlessRuntime", document: "Document | None" = None, ) -> None: """Initialize with an optional attached document instance for cache-first reads.""" super().__init__(runtime) self._document: Document | None = document def _get_document_pk(self, pk: int | None = None) -> int: """Return the effective document pk from the call-time argument or document instance.""" resolved = pk or (self._document.id if self._document else None) if not resolved: message = f"Accessing {type(self).__name__} data without a primary key." raise PrimaryKeyRequiredError(message) return resolved async def __call__( self, pk: int | None = None, *, force_request: bool = False, ) -> list[DocumentNote]: """Return all notes for a document. By default the notes already embedded in the parent :class:`~pypaperless.models.documents.document.Document` are returned directly, without a second API request. Pass ``force_request=True`` to always fetch fresh data from the API and refresh the embedded cache. Args: pk: Document primary key. May be omitted when the service is accessed via a :class:`~pypaperless.models.documents.document.Document` instance (``doc.notes()``). force_request: When ``True``, always fetch from the API even if embedded notes are available. Example:: notes = await paperless.documents.notes(42) fresh = await doc.notes(force_request=True) """ doc_pk = self._get_document_pk(pk) # Return embedded notes from the parent Document when available, avoiding a # redundant API request. The cache is kept current by save() and delete(). if not force_request and self._document is not None and self._document.notes_ is not None: return list(self._document.notes_) res = await self._runtime.transport.get(self._get_api_path(doc_pk)) # The notes endpoint does not include the document pk in its response items, # so we inject it here. The user dict normalisation is handled by DocumentNote's # field_validator. notes = [ self._resource_cls.from_data( self._runtime, {**item, "document": doc_pk}, ) for item in res ] if self._document is not None: self._document.notes_ = notes return notes def _get_api_path(self, pk: int) -> str: """Return the formatted api path.""" return self._api_path.format(pk=pk) def create(self, pk: int | None = None, **kwargs: Any) -> DocumentNoteDraft: """Return a new :class:`~pypaperless.models.documents.notes.DocumentNoteDraft` instance. Args: pk: Document primary key. May be omitted when the service is accessed via a document instance. Example:: draft = paperless.documents.notes.create(42, note="Checked and approved.") note_id = await paperless.documents.notes.save(draft) """ kwargs.update({"document": self._get_document_pk(pk)}) return DocumentNoteDraft.from_data( self._runtime, data=kwargs, ) async def save(self, draft: DraftLike) -> int: """Persist a note draft to Paperless and return the new note id. Args: draft: A draft instance created by :meth:`create`. Example:: draft = paperless.documents.notes.create(42, note="Approved.") note_id = await paperless.documents.notes.save(draft) """ draft.validate_draft() kwdict = draft.serialize() res = await self._runtime.transport.post(draft.api_path, **kwdict) if isinstance(res, dict): # Paperless responds with HTTP 200 and an error payload when it # cannot save the note (e.g. the search index is locked). raise JsonResponseWithError(res) new_id = cast("int", max(item.get("id") for item in res)) if self._document is not None: # The POST response is the full updated notes list — keep the cache current. self._document.notes_ = [ self._resource_cls.from_data(self._runtime, {**item, "document": self._document.id}) for item in res ] return new_id async def delete( self, note: DocumentNote | int, pk: int | None = None, *, silent_fail: bool = False, ) -> None: """Delete a document note. Raises :exc:`~pypaperless.exceptions.DeletionError` on failure unless *silent_fail* is ``True``. *note* may be either a :class:`~pypaperless.models.documents.notes.DocumentNote` instance **or** the integer id of the note to delete. When an integer is given the document primary key must be resolvable — either from the parent :class:`~pypaperless.models.documents.document.Document` (when accessed via ``doc.notes``) or from the *pk* parameter (standalone use). Args: note: The :class:`~pypaperless.models.documents.notes.DocumentNote` instance **or** the integer note id to delete. pk: Document primary key. May be omitted when the service is accessed via a document instance or when *note* is a :class:`~pypaperless.models.documents.notes.DocumentNote` (whose document pk is embedded in the model). silent_fail: When ``True``, swallow :exc:`~pypaperless.exceptions.DeletionError` instead of raising it. Example:: # model-based (existing style) notes = await paperless.documents.notes(42) await paperless.documents.notes.delete(notes[0]) # integer shorthand — document context provides the document pk implicitly doc = await paperless.documents(42) await doc.notes.delete(7) # integer shorthand — standalone, document pk passed explicitly await paperless.documents.notes.delete(7, pk=42) """ if isinstance(note, int): doc_pk = self._get_document_pk(pk) note_id: int | None = note path = self._get_api_path(doc_pk) else: note_id = note.id path = note.api_path params = {"id": note_id} try: await self._runtime.transport.delete(path, params=params) except DeletionError: if not silent_fail: raise else: if self._document is not None and self._document.notes_ is not None: # Remove the deleted note from the cache directly. self._document.notes_ = [n for n in self._document.notes_ if n.id != note_id] tb1337-pypaperless-5d67d60/pypaperless/services/documents/share_links.py000066400000000000000000000022561524076126400264260ustar00rootroot00000000000000"""Provide document-scoped `ShareLink` services.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.share_links import ShareLink from .base import DocumentScopedServiceBase class DocumentShareLinkService(DocumentScopedServiceBase): """Represent a factory for document-scoped Paperless `ShareLink` models.""" _api_path = EndpointPath.DOCUMENTS_SHARE_LINKS _resource = PaperlessResource.DOCUMENTS _resource_cls = ShareLink async def __call__(self, pk: int | None = None) -> list[ShareLink]: """Return all share links for a document. Args: pk: Document primary key. May be omitted when the service is accessed via a :class:`~pypaperless.models.documents.document.Document` instance (``doc.share_links()``). Example:: links = await paperless.documents.share_links(42) for link in links: print(link.slug) """ doc_pk = self._get_document_pk(pk) res = await self._runtime.transport.get(self._api_path.format(pk=doc_pk)) return [self._resource_cls.from_data(self._runtime, item) for item in res] tb1337-pypaperless-5d67d60/pypaperless/services/documents/versions.py000066400000000000000000000107511524076126400257730ustar00rootroot00000000000000"""Provide `DocumentVersionService` and `DocumentRootService`.""" from typing import IO from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.documents.versions import DocumentRoot, DocumentVersionInfo from .base import DocumentScopedServiceBase class DocumentVersionService(DocumentScopedServiceBase): """Represent a service for managing versions of a Paperless `Document`.""" _api_path = EndpointPath.DOCUMENTS_VERSION _resource = PaperlessResource.DOCUMENTS _resource_cls = DocumentVersionInfo async def upload( self, file: IO[bytes], *, version_label: str | None = None, pk: int | None = None, ) -> None: """Upload a new version of a document. Args: file: Binary file-like object to upload as the new version. version_label: Optional human-readable label for this version. pk: Document primary key. May be omitted when the service is accessed via a :class:`~pypaperless.models.documents.document.Document` instance (``doc.versions.upload(file)``). Example:: with open("updated.pdf", "rb") as f: await paperless.documents.versions.upload(f, version_label="v2", pk=42) """ doc_pk = self._get_document_pk(pk) api_path = EndpointPath.DOCUMENTS_UPDATE_VERSION.format(pk=doc_pk) form: dict[str, object] = {"document": file} if version_label is not None: form["version_label"] = version_label res = await self._runtime.transport.request_raw("post", api_path, form=form) self._runtime.transport.raise_for_status(res) async def update( self, version_id: int, *, version_label: str | None = None, pk: int | None = None, ) -> DocumentVersionInfo: """Update the label of a specific document version. Args: version_id: ID of the version to update. version_label: New label to assign to the version. pk: Document primary key. May be omitted when the service is accessed via a :class:`~pypaperless.models.documents.document.Document` instance (``doc.versions.update(1, version_label="v2")``). Example:: result = await paperless.documents.versions.update( 1, version_label="v2", pk=42 ) print(result.version_label) """ doc_pk = self._get_document_pk(pk) api_path = EndpointPath.DOCUMENTS_VERSION.format(pk=doc_pk, version_id=version_id) data = await self._runtime.transport.patch(api_path, json={"version_label": version_label}) return self._resource_cls.from_data(self._runtime, data) async def delete( self, version_id: int, *, pk: int | None = None, ) -> None: """Delete a specific document version. Args: version_id: ID of the version to delete. pk: Document primary key. May be omitted when the service is accessed via a :class:`~pypaperless.models.documents.document.Document` instance (``doc.versions.delete(1)``). Example:: await paperless.documents.versions.delete(1, pk=42) """ doc_pk = self._get_document_pk(pk) api_path = EndpointPath.DOCUMENTS_VERSION.format(pk=doc_pk, version_id=version_id) await self._runtime.transport.delete(api_path) class DocumentRootService(DocumentScopedServiceBase): """Represent a factory for Paperless `DocumentRoot` models.""" _api_path = EndpointPath.DOCUMENTS_ROOT _resource = PaperlessResource.DOCUMENTS _resource_cls = DocumentRoot async def __call__(self, pk: int | None = None) -> DocumentRoot: """Return the root-document record for a document. Args: pk: Document primary key. May be omitted when the service is accessed via a :class:`~pypaperless.models.documents.document.Document` instance (``doc.root()``). Example:: result = await paperless.documents.root(42) print(result.root_id) """ doc_pk = self._get_document_pk(pk) api_path = self._api_path.format(pk=doc_pk) data = await self._runtime.transport.get(api_path) return self._resource_cls.from_data(self._runtime, data) tb1337-pypaperless-5d67d60/pypaperless/services/mails/000077500000000000000000000000001524076126400226515ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/services/mails/__init__.py000066400000000000000000000003041524076126400247570ustar00rootroot00000000000000"""PyPaperless mail services.""" from .accounts import MailAccountService # noqa: F401 from .processed import ProcessedMailService # noqa: F401 from .rules import MailRuleService # noqa: F401 tb1337-pypaperless-5d67d60/pypaperless/services/mails/accounts.py000066400000000000000000000030311524076126400250370ustar00rootroot00000000000000"""Provide `MailAccount` related services.""" from typing import cast from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.mails.accounts import MailAccount from pypaperless.services import mixins from pypaperless.services.base import ResourceService class MailAccountService( ResourceService, mixins.SecurableService, mixins.CallableService[MailAccount], mixins.IterableService[MailAccount], ): """Represent a factory for Paperless `MailAccount` models.""" _api_path = EndpointPath.MAIL_ACCOUNTS _resource = PaperlessResource.MAIL_ACCOUNTS _resource_cls = MailAccount async def test(self) -> dict[str, object]: """Test all configured mail account connections. Returns a dict with per-account test results from Paperless. Example:: results = await paperless.mail_accounts.test() """ return cast( "dict[str, object]", await self._runtime.transport.post(EndpointPath.MAIL_ACCOUNTS_TEST, json={}), ) async def process(self, pk: int) -> None: """Trigger processing of unread mail for the given account. Args: pk: Primary key of the mail account to process. Example:: await paperless.mail_accounts.process(1) """ res = await self._runtime.transport.request_raw( "post", EndpointPath.MAIL_ACCOUNTS_PROCESS.format(pk=pk), json={}, ) self._runtime.transport.raise_for_status(res) tb1337-pypaperless-5d67d60/pypaperless/services/mails/processed.py000066400000000000000000000025451524076126400252200ustar00rootroot00000000000000"""Provide `ProcessedMail` related services.""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Self, Unpack from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.filters import ProcessedMailFilters from pypaperless.models.mails.processed import ProcessedMail from pypaperless.services import mixins from pypaperless.services.base import ResourceService class ProcessedMailService( ResourceService, mixins.SecurableService, mixins.CallableService[ProcessedMail], mixins.IterableService[ProcessedMail], ): """Represent a factory for Paperless `ProcessedMail` models.""" _api_path = EndpointPath.PROCESSED_MAIL _resource = PaperlessResource.PROCESSED_MAIL _resource_cls = ProcessedMail @asynccontextmanager async def filter(self, **kwargs: Unpack[ProcessedMailFilters]) -> AsyncGenerator[Self]: """Iterate processed mails with server-side filters. See :class:`~pypaperless.models.filters.ProcessedMailFilters` for all available keys. Example:: async with paperless.processed_mail.filter(status="FAILED") as filtered: async for entry in filtered: print(entry.subject, entry.error) """ async with self._store_filters(**kwargs) as ctx: yield ctx tb1337-pypaperless-5d67d60/pypaperless/services/mails/rules.py000066400000000000000000000011111524076126400243470ustar00rootroot00000000000000"""Provide `MailRule` related services.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.mails.rules import MailRule from pypaperless.services import mixins from pypaperless.services.base import ResourceService class MailRuleService( ResourceService, mixins.SecurableService, mixins.CallableService[MailRule], mixins.IterableService[MailRule], ): """Represent a factory for Paperless `MailRule` models.""" _api_path = EndpointPath.MAIL_RULES _resource = PaperlessResource.MAIL_RULES _resource_cls = MailRule tb1337-pypaperless-5d67d60/pypaperless/services/mixins/000077500000000000000000000000001524076126400230535ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/services/mixins/__init__.py000066400000000000000000000006611524076126400251670ustar00rootroot00000000000000"""Mixins for PyPaperless services.""" from .callable import CallableService from .creatable import CreatableService from .deletable import DeletableService from .iterable import IterableService from .securable import SecurableService from .updatable import UpdatableService __all__ = ( "CallableService", "CreatableService", "DeletableService", "IterableService", "SecurableService", "UpdatableService", ) tb1337-pypaperless-5d67d60/pypaperless/services/mixins/callable.py000066400000000000000000000025531524076126400251710ustar00rootroot00000000000000"""CallableService for PyPaperless services.""" from typing import Any from pypaperless.models.base import ResourceT from pypaperless.services.base import ResourceServiceProtocol class CallableService(ResourceServiceProtocol[ResourceT]): """Provide methods for calling a specific resource item.""" async def __call__( self, pk: int, *, lazy: bool = False, ) -> ResourceT: """Request exactly one resource item by primary key. Args: pk: Primary key of the resource item to retrieve. lazy: When ``True``, return a model instance without hitting the API — only the ``id`` field is populated. Example:: # fetch a document from the API document = await paperless.documents(42) # initialise a stub without network access document = await paperless.documents(42, lazy=True) """ if lazy: return self._resource_cls.from_data(self._runtime, {"id": pk}) params: dict[str, Any] = {} if getattr(self, "request_permissions", False): params["full_perms"] = "true" api_path = self._resource_cls.format_api_path(pk=pk) data = await self._runtime.transport.get(api_path, params=params or None) return self._resource_cls.from_data(self._runtime, data) tb1337-pypaperless-5d67d60/pypaperless/services/mixins/creatable.py000066400000000000000000000035701524076126400253540ustar00rootroot00000000000000"""CreatableService for PyPaperless services.""" from typing import Any from pypaperless.exceptions import DraftNotSupportedError from pypaperless.models.base import DraftLike, ResourceT from pypaperless.services.base import ResourceServiceProtocol class CreatableService(ResourceServiceProtocol[ResourceT]): """Provide the `create` and `save` methods for PyPaperless services.""" _draft_cls: type[ResourceT] def create(self, **kwargs: Any) -> ResourceT: """Return a new draft :class:`~pypaperless.models.base.PaperlessModel` instance. The returned draft is not persisted until :meth:`save` is called. Example:: draft = paperless.tags.create(name="urgent") draft.color = "#ff0000" new_id = await paperless.tags.save(draft) """ if not hasattr(self, "_draft_cls"): message = "Service class has no _draft_cls attribute." raise DraftNotSupportedError(message) return self._draft_cls.from_data(self._runtime, data=kwargs) async def save(self, draft: DraftLike) -> int | str: """Persist a draft to Paperless and return the new resource identifier. Returns the created item ``id`` (``int``) for synchronous creation, or a Celery task UUID (``str``) for asynchronous creation (e.g. documents). Args: draft: A draft model instance created by :meth:`create`. Example:: draft = paperless.documents.create( document=open("invoice.pdf", "rb").read(), title="Invoice", ) task_id = await paperless.documents.save(draft) """ draft.validate_draft() kwdict = draft.serialize() res = await self._runtime.transport.post(draft.api_path, **kwdict) if isinstance(res, dict): return int(res["id"]) return str(res) tb1337-pypaperless-5d67d60/pypaperless/services/mixins/deletable.py000066400000000000000000000024661524076126400253560ustar00rootroot00000000000000"""DeletableService for PyPaperless services.""" from pypaperless.exceptions import DeletionError from pypaperless.models.base import ResourceT from pypaperless.services.base import ResourceServiceProtocol class DeletableService(ResourceServiceProtocol[ResourceT]): """Provide the `delete` method for PyPaperless services.""" async def delete(self, model: ResourceT, *, silent_fail: bool = False) -> None: """Delete a resource item from Paperless. This action cannot be undone. Raises :exc:`~pypaperless.exceptions.DeletionError` on failure unless *silent_fail* is ``True``. Args: model: The model instance to delete. silent_fail: When ``True``, swallow :exc:`~pypaperless.exceptions.DeletionError` instead of raising it. All other exceptions still propagate. Example:: document = await paperless.documents(42) # raises DeletionError on failure await paperless.documents.delete(document) # silently ignore a failed deletion await paperless.documents.delete(document, silent_fail=True) """ try: await self._runtime.transport.delete(model.api_path) except DeletionError: if not silent_fail: raise tb1337-pypaperless-5d67d60/pypaperless/services/mixins/iterable.py000066400000000000000000000127411524076126400252210ustar00rootroot00000000000000"""IterableService for PyPaperless services.""" from collections.abc import AsyncGenerator, AsyncIterator, Mapping from contextlib import asynccontextmanager from contextvars import ContextVar from types import MappingProxyType from typing import Any, Self, TypedDict, Unpack from pypaperless.models.base import IdentifiedT from pypaperless.pagination import PageGenerator from pypaperless.services.base import ResourceServiceProtocol # Task-local query filters, keyed by service identity. Values are immutable # mappings that are replaced (never mutated) on entry and restored via token # reset on exit, so concurrent asyncio tasks filtering the same (client-cached) # service instance cannot clobber each other. _SCOPED_FILTERS: ContextVar[Mapping[int, dict[str, Any]]] = ContextVar( "_SCOPED_FILTERS", default=MappingProxyType({}) ) class _BaseFilters(TypedDict, total=False): """Empty base TypedDict used by IterableService.filter(). Being empty, every concrete filter TypedDict is a structural supertype of this class (PEP 692), so subclass overrides that narrow **kwargs to a specific filter TypedDict satisfy the Liskov substitution principle without requiring ``# type: ignore[override]``. """ class IterableService(ResourceServiceProtocol[IdentifiedT]): """Provide methods for iterating over resource items.""" async def __aiter__(self) -> AsyncIterator[IdentifiedT]: """Iterate over all resource items, page by page. Example:: async for item in paperless.documents: print(item.title) """ pages = self.pages() try: async for page in pages: for item in page: yield item finally: await pages.aclose() @asynccontextmanager async def _store_filters(self: Self, **kwargs: Any) -> AsyncGenerator[Self]: """Store query filters in :data:`_SCOPED_FILTERS` for the duration of the context. This is the private implementation backing :meth:`filter`. Subclasses call ``self._store_filters(**kwargs)`` rather than ``super().filter()`` so that the typed public override does not need to widen its signature back to ``**kwargs: Any``. """ scoped = MappingProxyType({**_SCOPED_FILTERS.get(), id(self): kwargs}) token = _SCOPED_FILTERS.set(scoped) try: yield self finally: _SCOPED_FILTERS.reset(token) @asynccontextmanager async def filter( self: Self, **kwargs: Unpack[_BaseFilters], ) -> AsyncGenerator[Self]: """Context manager for iterating over resource items with query parameters. The context variable is the service itself, scoped to the given filters. Use :meth:`__aiter__` or :meth:`pages` inside the block. The filters are task-local — concurrent asyncio tasks filtering the same service do not interfere with each other. Example:: async with paperless.documents.filter( page_size=25, title__icontains="2023", ) as filtered: async for item in filtered: print(item.title) async for page in filtered.pages(): print(page.count) """ async with self._store_filters(**kwargs) as ctx: yield ctx async def as_dict(self) -> dict[int, IdentifiedT]: """Return a ``{pk: model}`` mapping of all resource items. When used within a :meth:`filter` context, only filtered items are included. Example:: docs = await paperless.documents.as_dict() doc = docs[42] """ return {item.id: item async for item in self} async def as_list(self) -> list[IdentifiedT]: """Return a flat list of all resource items. When used within a :meth:`filter` context, only filtered items are included. Example:: tags = await paperless.tags.as_list() """ return [item async for item in self] def pages( self, page: int = 1, page_size: int = 150, ) -> PageGenerator[IdentifiedT]: """Iterate over resource pages. Each yielded :class:`~pypaperless.pagination.Page` contains up to *page_size* items. Use within a :meth:`filter` context to apply server-side filters. Args: page: Page number to start from (1-based). page_size: Maximum number of items per page. Example:: async for page in paperless.documents.pages(page_size=50): print(f"Page {page.current_page} / {page.last_page}") for doc in page: print(doc.title) """ params: dict[str, Any] = dict(_SCOPED_FILTERS.get().get(id(self), {})) for param, value in params.items(): # Paperless expects comma-separated values; a plain list would be sent as # repeated query params, of which Django only reads the last one. if param.endswith(("__in", "__all")) and isinstance(value, list): params[param] = ",".join(map(str, value)) params.setdefault("page", page) params.setdefault("page_size", page_size) # set requesting full permissions if getattr(self, "request_permissions", False): params.update({"full_perms": "true"}) return PageGenerator(self._runtime, self._api_path, self._resource_cls, params=params) tb1337-pypaperless-5d67d60/pypaperless/services/mixins/securable.py000066400000000000000000000044111524076126400253720ustar00rootroot00000000000000"""SecurableService for PyPaperless services.""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from contextvars import ContextVar from typing import Self # Task-local identities of services with the full-permissions payload enabled. # Values are immutable sets that are replaced (never mutated) on entry and # restored via token reset on exit, so concurrent asyncio tasks using # with_permissions() on the same service cannot interfere with each other. _SCOPED_FULL_PERMS: ContextVar[frozenset[int]] = ContextVar( "_SCOPED_FULL_PERMS", default=frozenset() ) class SecurableService: """Provide `request_permissions` and `with_permissions()` for PyPaperless services.""" _request_full_perms: bool = False @property def request_permissions(self) -> bool: """Return whether the service requests items with the `permissions` table, or not. Documentation: https://docs.paperless-ngx.com/api/#permissions """ return self._request_full_perms or id(self) in _SCOPED_FULL_PERMS.get() @request_permissions.setter def request_permissions(self, value: bool) -> None: """Set whether the service requests items with the `permissions` table, or not. Documentation: https://docs.paperless-ngx.com/api/#permissions """ self._request_full_perms = value @asynccontextmanager async def with_permissions(self: Self) -> AsyncGenerator[Self]: """Context manager that enables the full permissions payload for a block. The flag is reset automatically on exit, even if an exception is raised. It is task-local — concurrent asyncio tasks cannot interfere with each other. Combine with :meth:`~pypaperless.services.mixins.iterable.IterableService.filter` or direct service calls. Example:: async with paperless.documents.with_permissions() as docs: doc = await docs(42) print(doc.permissions.view.users) async for doc in docs: print(doc.owner, doc.permissions) """ token = _SCOPED_FULL_PERMS.set(_SCOPED_FULL_PERMS.get() | {id(self)}) try: yield self finally: _SCOPED_FULL_PERMS.reset(token) tb1337-pypaperless-5d67d60/pypaperless/services/mixins/updatable.py000066400000000000000000000063721524076126400253760ustar00rootroot00000000000000"""UpdatableService for PyPaperless services.""" from copy import deepcopy from typing import Any, cast from pypaperless.models.base import ResourceT from pypaperless.services.base import ResourceServiceProtocol class UpdatableService(ResourceServiceProtocol[ResourceT]): """Provide the `update` method for PyPaperless services.""" async def update(self, model: ResourceT, *, only_changed: bool = True) -> bool: """Send changed model data to Paperless. Returns ``True`` when at least one attribute was updated, ``False`` when nothing changed. Args: model: The model instance with modified attributes. only_changed: When ``True`` (default), only changed fields are sent via ``PATCH``. Set to ``False`` to replace the full resource via ``PUT``. Example:: document = await paperless.documents(42) document.title = "New Title" if await paperless.documents.update(document): print("Updated.") """ params = self._get_request_params() if only_changed: response = await self._patch_fields(model, params) else: response = await self._put_fields(model, params) if response is not None: model.refresh_from(response) return True return False def _get_request_params(self) -> dict[str, Any]: """Build request parameters.""" params: dict[str, Any] = {} if getattr(self, "request_permissions", False): params["full_perms"] = "true" return params @staticmethod def _check_permissions_field(model: ResourceT, data: dict) -> None: """Rewrite the permissions field to set_permissions before sending to Paperless.""" if not hasattr(model, "has_permissions"): return if not model.has_permissions: return if "permissions" in data: data["set_permissions"] = deepcopy(data["permissions"]) del data["permissions"] async def _patch_fields( self, model: ResourceT, params: dict[str, Any] ) -> dict[str, Any] | None: """Use the http `PATCH` method for updating only changed fields.""" snapshot = model.snapshot changed: dict[str, Any] = { key: value for key, value in model.api_dump().items() if key in snapshot and value != snapshot[key] } if not changed: return None self._check_permissions_field(model, changed) return cast( "dict[str, Any]", await self._runtime.transport.patch( model.api_path, json=changed, params=params or None, ), ) async def _put_fields(self, model: ResourceT, params: dict[str, Any]) -> dict[str, Any]: """Use the http `PUT` method to replace all fields.""" data = model.api_dump() self._check_permissions_field(model, data) return cast( "dict[str, Any]", await self._runtime.transport.put( model.api_path, json=data, params=params or None, ), ) tb1337-pypaperless-5d67d60/pypaperless/services/permissions/000077500000000000000000000000001524076126400241175ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/services/permissions/__init__.py000066400000000000000000000002041524076126400262240ustar00rootroot00000000000000"""PyPaperless permission services.""" from .groups import GroupService # noqa: F401 from .users import UserService # noqa: F401 tb1337-pypaperless-5d67d60/pypaperless/services/permissions/groups.py000066400000000000000000000020121524076126400260030ustar00rootroot00000000000000"""Provide `Group` related services.""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Self, Unpack from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.filters import GroupFilters from pypaperless.models.permissions.groups import Group from pypaperless.services import mixins from pypaperless.services.base import ResourceService class GroupService( ResourceService, mixins.CallableService[Group], mixins.IterableService[Group], ): """Represent a factory for Paperless `Group` models.""" _api_path = EndpointPath.GROUPS _resource = PaperlessResource.GROUPS _resource_cls = Group @asynccontextmanager async def filter(self, **kwargs: Unpack[GroupFilters]) -> AsyncGenerator[Self]: """Iterate with server-side filters. See :class:`~pypaperless.models.filters.GroupFilters` for available keys. """ async with self._store_filters(**kwargs) as ctx: yield ctx tb1337-pypaperless-5d67d60/pypaperless/services/permissions/users.py000066400000000000000000000017751524076126400256440ustar00rootroot00000000000000"""Provide `User` related services.""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Self, Unpack from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.filters import UserFilters from pypaperless.models.permissions.users import User from pypaperless.services import mixins from pypaperless.services.base import ResourceService class UserService( ResourceService, mixins.CallableService[User], mixins.IterableService[User], ): """Represent a factory for Paperless `User` models.""" _api_path = EndpointPath.USERS _resource = PaperlessResource.USERS _resource_cls = User @asynccontextmanager async def filter(self, **kwargs: Unpack[UserFilters]) -> AsyncGenerator[Self]: """Iterate with server-side filters. See :class:`~pypaperless.models.filters.UserFilters` for available keys. """ async with self._store_filters(**kwargs) as ctx: yield ctx tb1337-pypaperless-5d67d60/pypaperless/services/profile.py000066400000000000000000000037701524076126400235650ustar00rootroot00000000000000"""Provide `Profile` service.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.profile import Profile from .base import ResourceService class ProfileService(ResourceService): """Represent a factory for the Paperless `Profile` model.""" _api_path = EndpointPath.PROFILE _resource = PaperlessResource.PROFILE _resource_cls = Profile async def __call__(self) -> Profile: """Fetch the current user's Paperless profile. Example:: profile = await paperless.profile() print(profile.email, profile.first_name) """ res = await self._runtime.transport.get(self._api_path) return self._resource_cls.from_data(self._runtime, res) async def update( self, *, email: str | None = None, password: str | None = None, first_name: str | None = None, last_name: str | None = None, ) -> Profile: """Update the current user's profile partially via ``PATCH``. Only fields that are explicitly provided (non-``None``) are sent to the API. Returns a refreshed :class:`~pypaperless.models.profile.Profile`. Args: email: New e-mail address. password: New plain-text password. first_name: First name. last_name: Last name. Example:: profile = await paperless.profile.update( email="new@example.com", first_name="Ada", ) """ payload: dict[str, object] = {} if email is not None: payload["email"] = email if password is not None: payload["password"] = password if first_name is not None: payload["first_name"] = first_name if last_name is not None: payload["last_name"] = last_name res = await self._runtime.transport.patch(self._api_path, json=payload) return self._resource_cls.from_data(self._runtime, res) tb1337-pypaperless-5d67d60/pypaperless/services/remote_version.py000066400000000000000000000014441524076126400251610ustar00rootroot00000000000000"""Provide `RemoteVersion` service.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.remote_version import RemoteVersion from .base import ResourceService class RemoteVersionService(ResourceService): """Represent a factory for Paperless `Remote Version` models.""" _api_path = EndpointPath.REMOTE_VERSION _resource = PaperlessResource.REMOTE_VERSION _resource_cls = RemoteVersion async def __call__(self) -> RemoteVersion: """Fetch the latest Paperless-ngx release information. Example:: version = await paperless.remote_version() print(version.version) """ res = await self._runtime.transport.get(self._api_path) return self._resource_cls.from_data(self._runtime, res) tb1337-pypaperless-5d67d60/pypaperless/services/saved_views.py000066400000000000000000000010431524076126400244330ustar00rootroot00000000000000"""Provide `SavedView` service.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.saved_views import SavedView from . import mixins from .base import ResourceService class SavedViewService( ResourceService, mixins.SecurableService, mixins.CallableService[SavedView], mixins.IterableService[SavedView], ): """Represent a factory for Paperless `SavedView` models.""" _api_path = EndpointPath.SAVED_VIEWS _resource = PaperlessResource.SAVED_VIEWS _resource_cls = SavedView tb1337-pypaperless-5d67d60/pypaperless/services/search.py000066400000000000000000000054311524076126400233660ustar00rootroot00000000000000"""Provide `Search` service.""" from typing import cast from pypaperless.builders import SearchQuery from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.search import SearchResult from .base import ResourceService class SearchService(ResourceService): """Represent a factory for Paperless global search results.""" _api_path = EndpointPath.SEARCH _resource = PaperlessResource.SEARCH _resource_cls = SearchResult async def __call__( self, query: str | SearchQuery, *, db_only: bool | None = None, ) -> SearchResult: """Perform a global search and return a ``SearchResult``. Args: query: The search query — a plain Whoosh string or a :class:`~pypaperless.builders.search.SearchQuery` builder object. db_only: When ``True``, only the database is searched (no full-text index). Defaults to ``None`` (server decides). Example:: # Plain string result = await paperless.search("invoice") # Builder from pypaperless.models.types import SearchQuery q = SearchQuery("invoice") & SearchQuery.field("tag", "unpaid") result = await paperless.search(q) """ params: dict[str, str | bool] = {"query": str(query)} if db_only is not None: params["db_only"] = db_only res = await self._runtime.transport.get(self._api_path, params=params) return self._resource_cls.from_data(self._runtime, res) async def autocomplete(self, term: str, limit: int | None = None) -> list[str]: """Return full-text index terms completing a partial search term. This is the typeahead helper behind the Paperless-ngx search bar — it answers with bare strings from the search index, not resource objects. Args: term: Prefix to complete. An empty term is rejected by the server with HTTP 400, surfacing as :exc:`~pypaperless.exceptions.UnexpectedStatusError`. limit: Maximum number of terms to return. Defaults to ``None``, which omits the parameter and leaves the server default (10) in place. Values below ``1`` are rejected by the server. Example:: terms = await paperless.search.autocomplete("inv") # ["invoice", "invoices", "invoiced"] terms = await paperless.search.autocomplete("inv", limit=3) """ params: dict[str, str | int] = {"term": term} if limit is not None: params["limit"] = limit res = await self._runtime.transport.get(EndpointPath.SEARCH_AUTOCOMPLETE, params=params) return cast("list[str]", res) tb1337-pypaperless-5d67d60/pypaperless/services/share_links/000077500000000000000000000000001524076126400240465ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/services/share_links/__init__.py000066400000000000000000000002301524076126400261520ustar00rootroot00000000000000"""PyPaperless share link services.""" from .bundle import ShareLinkBundleService # noqa: F401 from .share_link import ShareLinkService # noqa: F401 tb1337-pypaperless-5d67d60/pypaperless/services/share_links/bundle.py000066400000000000000000000042231524076126400256720ustar00rootroot00000000000000"""Provide `ShareLinkBundle` service.""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Self, Unpack from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.filters import ShareLinkBundleFilters from pypaperless.models.share_links.bundle import ShareLinkBundle, ShareLinkBundleDraft from pypaperless.services import mixins from pypaperless.services.base import ResourceService class ShareLinkBundleService( ResourceService, mixins.CallableService[ShareLinkBundle], mixins.CreatableService[ShareLinkBundleDraft], mixins.IterableService[ShareLinkBundle], mixins.UpdatableService[ShareLinkBundle], mixins.DeletableService[ShareLinkBundle], ): """Represent a factory for Paperless ``ShareLinkBundle`` models.""" _api_path = EndpointPath.SHARE_LINK_BUNDLES _resource = PaperlessResource.SHARE_LINK_BUNDLES _draft_cls = ShareLinkBundleDraft _resource_cls = ShareLinkBundle @asynccontextmanager async def filter(self, **kwargs: Unpack[ShareLinkBundleFilters]) -> AsyncGenerator[Self]: """Iterate share link bundles with server-side filters. See :class:`~pypaperless.models.filters.ShareLinkBundleFilters` for all available keys. Example:: async with paperless.share_link_bundles.filter(status="ready") as filtered: async for bundle in filtered: print(bundle.slug) """ async with self._store_filters(**kwargs) as ctx: yield ctx async def rebuild(self, bundle_id: int) -> ShareLinkBundle: """Trigger a rebuild of a share link bundle. Resets and re-queues the bundle for processing. Returns the updated bundle. Args: bundle_id: Primary key of the bundle to rebuild. Example:: bundle = await paperless.share_link_bundles.rebuild(42) print(bundle.status) """ api_path = EndpointPath.SHARE_LINK_BUNDLES_REBUILD.format(pk=bundle_id) data = await self._runtime.transport.post(api_path, json={}) return ShareLinkBundle.from_data(self._runtime, data) tb1337-pypaperless-5d67d60/pypaperless/services/share_links/share_link.py000066400000000000000000000026501524076126400265420ustar00rootroot00000000000000"""Provide `ShareLink` service.""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Self, Unpack from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.filters import ShareLinkFilters from pypaperless.models.share_links.share_link import ShareLink, ShareLinkDraft from pypaperless.services import mixins from pypaperless.services.base import ResourceService class ShareLinkService( ResourceService, mixins.CallableService[ShareLink], mixins.CreatableService[ShareLinkDraft], mixins.IterableService[ShareLink], mixins.UpdatableService[ShareLink], mixins.DeletableService[ShareLink], ): """Represent a factory for Paperless `ShareLink` models.""" _api_path = EndpointPath.SHARE_LINKS _resource = PaperlessResource.SHARE_LINKS _draft_cls = ShareLinkDraft _resource_cls = ShareLink @asynccontextmanager async def filter(self, **kwargs: Unpack[ShareLinkFilters]) -> AsyncGenerator[Self]: """Iterate share links with server-side filters. See :class:`~pypaperless.models.filters.ShareLinkFilters` for all available keys. Example:: async with paperless.share_links.filter(document=42) as filtered: async for link in filtered: print(link.slug) """ async with self._store_filters(**kwargs) as ctx: yield ctx tb1337-pypaperless-5d67d60/pypaperless/services/statistics.py000066400000000000000000000014121524076126400243060ustar00rootroot00000000000000"""Provide `Statistic` service.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.statistics import Statistic from .base import ResourceService class StatisticService(ResourceService): """Represent a factory for Paperless `Statistic` models.""" _api_path = EndpointPath.STATISTICS _resource = PaperlessResource.STATISTICS _resource_cls = Statistic async def __call__(self) -> Statistic: """Fetch document statistics from Paperless. Example:: stats = await paperless.statistics() print(f"{stats.documents_total} documents total") """ res = await self._runtime.transport.get(self._api_path) return self._resource_cls.from_data(self._runtime, res) tb1337-pypaperless-5d67d60/pypaperless/services/status.py000066400000000000000000000013161524076126400234420ustar00rootroot00000000000000"""Provide `Status` service.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.status import Status from .base import ResourceService class StatusService(ResourceService): """Represent a factory for the Paperless `Status` model.""" _api_path = EndpointPath.STATUS _resource = PaperlessResource.STATUS _resource_cls = Status async def __call__(self) -> Status: """Fetch the Paperless system status. Example:: status = await paperless.status() print(status.storage_type) """ res = await self._runtime.transport.get(self._api_path) return self._resource_cls.from_data(self._runtime, res) tb1337-pypaperless-5d67d60/pypaperless/services/storage_paths.py000066400000000000000000000027171524076126400247700ustar00rootroot00000000000000"""Provide `StoragePath` related services.""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Self, Unpack from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.filters import StoragePathFilters from pypaperless.models.storage_paths import StoragePath, StoragePathDraft from . import mixins from .base import ResourceService class StoragePathService( ResourceService, mixins.SecurableService, mixins.CallableService[StoragePath], mixins.CreatableService[StoragePathDraft], mixins.IterableService[StoragePath], mixins.UpdatableService[StoragePath], mixins.DeletableService[StoragePath], ): """Represent a factory for Paperless `StoragePath` models.""" _api_path = EndpointPath.STORAGE_PATHS _resource = PaperlessResource.STORAGE_PATHS _draft_cls = StoragePathDraft _resource_cls = StoragePath @asynccontextmanager async def filter(self, **kwargs: Unpack[StoragePathFilters]) -> AsyncGenerator[Self]: """Iterate storage paths with server-side filters. See :class:`~pypaperless.models.filters.StoragePathFilters` for all available keys. Example:: async with paperless.storage_paths.filter(name__icontains="archive") as filtered: async for sp in filtered: print(sp.path) """ async with self._store_filters(**kwargs) as ctx: yield ctx tb1337-pypaperless-5d67d60/pypaperless/services/tags.py000066400000000000000000000024531524076126400230600ustar00rootroot00000000000000"""Provide `Tag` related services.""" from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Self, Unpack from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.filters import TagFilters from pypaperless.models.tags import Tag, TagDraft from . import mixins from .base import ResourceService class TagService( ResourceService, mixins.SecurableService, mixins.CallableService[Tag], mixins.CreatableService[TagDraft], mixins.IterableService[Tag], mixins.UpdatableService[Tag], mixins.DeletableService[Tag], ): """Represent a factory for Paperless `Tag` models.""" _api_path = EndpointPath.TAGS _resource = PaperlessResource.TAGS _draft_cls = TagDraft _resource_cls = Tag @asynccontextmanager async def filter(self, **kwargs: Unpack[TagFilters]) -> AsyncGenerator[Self]: """Iterate tags with server-side filters. See :class:`~pypaperless.models.filters.TagFilters` for all available keys. Example:: async with paperless.tags.filter(name__icontains="urgent") as filtered: async for tag in filtered: print(tag.name) """ async with self._store_filters(**kwargs) as ctx: yield ctx tb1337-pypaperless-5d67d60/pypaperless/services/tasks.py000066400000000000000000000124651524076126400232530ustar00rootroot00000000000000"""Provide `Task` service.""" from collections.abc import AsyncGenerator, AsyncIterator from contextlib import asynccontextmanager from typing import Self, Unpack, cast from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.exceptions import TaskNotFoundError from pypaperless.models.filters import TaskFilters, TaskSummaryFilters from pypaperless.models.tasks import Task, TaskStatusCounts, TaskSummary, TaskType from . import mixins from .base import ResourceService class TaskService( ResourceService, mixins.IterableService[Task], ): """Represent a factory for Paperless `Task` models.""" _api_path = EndpointPath.TASKS _resource = PaperlessResource.TASKS _resource_cls = Task @asynccontextmanager async def filter(self, **kwargs: Unpack[TaskFilters]) -> AsyncGenerator[Self]: """Iterate over task items with optional server-side filters. See :class:`~pypaperless.models.filters.TaskFilters` for available keys. When called with no arguments, iterates over all tasks (same as iterating directly). Example:: async with paperless.tasks.filter(status=["pending", "started"]) as filtered: async for task in filtered: print(task.task_id) """ async with self._store_filters(**kwargs) as ctx: yield ctx async def __call__(self, task_id: int | str) -> Task: """Fetch a single task by primary key or Celery UUID. Args: task_id: An ``int`` primary key, or a ``str`` Celery task UUID. Example:: task = await paperless.tasks("a1b2c3d4-...") task = await paperless.tasks(1337) """ if isinstance(task_id, str): params = { "task_id": task_id, } res = await self._runtime.transport.get(self._api_path, params=params) results: list[dict[str, object]] = res.get("results", []) try: return self._resource_cls.from_data(self._runtime, results.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._runtime.transport.get(api_path) return self._resource_cls.from_data(self._runtime, data) async def active(self, **kwargs: Unpack[TaskFilters]) -> AsyncIterator[Task]: """Iterate over currently pending and running tasks (capped at 50 server-side). See :class:`~pypaperless.models.filters.TaskFilters` for available keys. Example:: async for task in paperless.tasks.active(): print(task.task_id, task.status) """ res = await self._runtime.transport.get(EndpointPath.TASKS_ACTIVE, params=dict(kwargs)) for data in res: yield self._resource_cls.from_data(self._runtime, data) async def acknowledge(self, tasks: list[int]) -> int: """Acknowledge a list of tasks by primary key. Returns the number of tasks that were acknowledged. Args: tasks: List of task primary keys to acknowledge. Example:: count = await paperless.tasks.acknowledge([1, 2, 3]) """ data = cast( "dict[str, object]", await self._runtime.transport.post( EndpointPath.TASKS_ACKNOWLEDGE, json={"tasks": tasks}, ), ) return cast("int", data["result"]) async def summary(self, **kwargs: Unpack[TaskSummaryFilters]) -> list[TaskSummary]: """Return aggregated task statistics per task type. See :class:`~pypaperless.models.filters.TaskSummaryFilters` for available keys. Pass ``days=N`` to limit the aggregation window (default 30, max 365). Example:: summaries = await paperless.tasks.summary(days=7) for s in summaries: print(s.task_type, s.success_count, s.failure_count) """ res = await self._runtime.transport.get( EndpointPath.TASKS_SUMMARY, params=dict(kwargs) or None ) return [TaskSummary.model_validate(data) for data in res] async def status_counts(self) -> TaskStatusCounts: """Return aggregated task counts for the task UI sections. Example:: counts = await paperless.tasks.status_counts() print(counts.all, counts.needs_attention) """ res = await self._runtime.transport.get(EndpointPath.TASKS_STATUS_COUNTS) return TaskStatusCounts.model_validate(res) async def run(self, task_type: str | TaskType) -> str: """Trigger a background task by type and return the resulting Celery UUID. Args: task_type: One of the :class:`~pypaperless.models.tasks.TaskType` values, or a plain string matching the server-side task type name. Example:: task_uuid = await paperless.tasks.run(TaskType.SANITY_CHECK) task = await paperless.tasks(task_uuid) """ data = cast( "dict[str, object]", await self._runtime.transport.post( EndpointPath.TASKS_RUN, json={"task_type": task_type}, ), ) return cast("str", data["task_id"]) tb1337-pypaperless-5d67d60/pypaperless/services/trash.py000066400000000000000000000032231524076126400232370ustar00rootroot00000000000000"""Provide `Trash` related services.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.documents import Document from . import mixins from .base import ResourceService class TrashService( ResourceService, mixins.IterableService[Document], ): """Represent a factory for Paperless trashed `Document` models. ``/api/trash/`` declares no query filters and silently ignores the document ones, so :meth:`filter` supports pagination only. """ _api_path = EndpointPath.TRASH _resource = PaperlessResource.TRASH _resource_cls = Document async def restore(self, documents: list[int]) -> None: """Restore the given documents from the trash. Args: documents: List of document primary keys to restore. Example:: await paperless.trash.restore([10, 11]) """ await self._runtime.transport.post( self._api_path, json={"action": "restore", "documents": documents} ) async def empty(self, documents: list[int] | None = None) -> None: """Permanently delete documents from the trash. Args: documents: List of document primary keys to permanently delete. When ``None``, the entire trash is emptied. Example:: await paperless.trash.empty([10, 11]) # specific documents await paperless.trash.empty() # empty entire trash """ payload: dict = {"action": "empty"} if documents is not None: payload["documents"] = documents await self._runtime.transport.post(self._api_path, json=payload) tb1337-pypaperless-5d67d60/pypaperless/services/workflows/000077500000000000000000000000001524076126400236015ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/pypaperless/services/workflows/__init__.py000066400000000000000000000003161524076126400257120ustar00rootroot00000000000000"""PyPaperless workflow services.""" from .actions import WorkflowActionService # noqa: F401 from .triggers import WorkflowTriggerService # noqa: F401 from .workflow import WorkflowService # noqa: F401 tb1337-pypaperless-5d67d60/pypaperless/services/workflows/actions.py000066400000000000000000000011501524076126400256100ustar00rootroot00000000000000"""Provide `WorkflowAction` related services.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.workflows.actions import WorkflowAction from pypaperless.services import mixins from pypaperless.services.base import ResourceService class WorkflowActionService( ResourceService, mixins.CallableService[WorkflowAction], mixins.IterableService[WorkflowAction], ): """Represent a factory for Paperless `WorkflowAction` models.""" _api_path = EndpointPath.WORKFLOW_ACTIONS _resource = PaperlessResource.WORKFLOW_ACTIONS _resource_cls = WorkflowAction tb1337-pypaperless-5d67d60/pypaperless/services/workflows/triggers.py000066400000000000000000000011621524076126400260010ustar00rootroot00000000000000"""Provide `WorkflowTrigger` related services.""" from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.workflows.triggers import WorkflowTrigger from pypaperless.services import mixins from pypaperless.services.base import ResourceService class WorkflowTriggerService( ResourceService, mixins.CallableService[WorkflowTrigger], mixins.IterableService[WorkflowTrigger], ): """Represent a factory for Paperless `WorkflowTrigger` models.""" _api_path = EndpointPath.WORKFLOW_TRIGGERS _resource = PaperlessResource.WORKFLOW_TRIGGERS _resource_cls = WorkflowTrigger tb1337-pypaperless-5d67d60/pypaperless/services/workflows/workflow.py000066400000000000000000000027531524076126400260340ustar00rootroot00000000000000"""Provide `Workflow` related services.""" from typing import TYPE_CHECKING from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.models.workflows.workflow import Workflow from pypaperless.services import mixins from pypaperless.services.base import ResourceService from .actions import WorkflowActionService from .triggers import WorkflowTriggerService if TYPE_CHECKING: from pypaperless.runtime import PaperlessRuntime class WorkflowService( ResourceService, mixins.CallableService[Workflow], mixins.IterableService[Workflow], ): """Represent a factory for Paperless `Workflow` models.""" _api_path = EndpointPath.WORKFLOWS _resource = PaperlessResource.WORKFLOWS _resource_cls = Workflow def __init__(self, runtime: "PaperlessRuntime") -> None: """Initialize a `WorkflowService` instance.""" super().__init__(runtime) self._actions = WorkflowActionService(runtime) self._triggers = WorkflowTriggerService(runtime) @property def actions(self) -> WorkflowActionService: """Return the ``WorkflowActionService`` sub-service. Example:: action = await paperless.workflows.actions(5) """ return self._actions @property def triggers(self) -> WorkflowTriggerService: """Return the ``WorkflowTriggerService`` sub-service. Example:: trigger = await paperless.workflows.triggers(23) """ return self._triggers tb1337-pypaperless-5d67d60/pypaperless/settings.py000066400000000000000000000030011524076126400221250ustar00rootroot00000000000000"""PyPaperless client configuration.""" from pydantic import SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from .const import ENV_PREFIX, ENV_URL class PaperlessSettings(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 The token is held as a :class:`pydantic.SecretStr`, so it never appears in ``repr()``, logs, or tracebacks. Use ``token.get_secret_value()`` to read the raw value. Example — from environment:: # export PYPAPERLESS_URL=https://paperless.example.com # export PYPAPERLESS_TOKEN=mytoken async with PaperlessClient.from_env() as paperless: ... Example — explicit config object:: cfg = PaperlessSettings(url="https://paperless.example.com", token="mytoken") async with PaperlessClient.from_config(cfg) as paperless: ... """ model_config = SettingsConfigDict(env_prefix=ENV_PREFIX) url: str = "" token: SecretStr | None = None @model_validator(mode="after") def _require_url(self) -> "PaperlessSettings": if not self.url: msg = ( "PaperlessSettings requires a URL. " f"Pass url= explicitly or set the {ENV_URL} environment variable." ) raise ValueError(msg) return self tb1337-pypaperless-5d67d60/pypaperless/transport.py000066400000000000000000000305371524076126400223370ustar00rootroot00000000000000"""Provide the HTTP transport layer for PyPaperless.""" from json import JSONDecodeError from typing import Any, NamedTuple import httpx from .const import API_VERSION, EndpointPath from .exceptions import ( BadJsonResponseError, DeletionError, ForbiddenError, InactiveOrDeletedError, InvalidTokenError, JsonResponseWithError, NotFoundError, PaperlessConnectionError, PaperlessTimeoutError, UnexpectedStatusError, ) from .utils import normalize_base_url, process_form_data class _HostInfo(NamedTuple): """Version metadata returned by :meth:`PaperlessTransport.probe`.""" api_version: int version: str | None class PaperlessTransport: """Handle all HTTP communication with a Paperless-ngx instance. Constructed by :class:`~pypaperless.client.PaperlessClient` and passed to :class:`~pypaperless.runtime.PaperlessRuntime`. Not intended for direct instantiation by library consumers. Args: base_url: Hostname, IP-address, or full URL string. token: API token, or ``None`` for anonymous access. client: Optional :class:`httpx.AsyncClient` to reuse. Example:: transport = PaperlessTransport("localhost:8000", "mytoken") data = await transport.get("/api/documents/") """ def __init__( self, base_url: str, token: str | None, client: httpx.AsyncClient | None = None, ) -> None: """Initialize a :class:`PaperlessTransport` instance.""" self._base_url = normalize_base_url(base_url) self._token = token self._httpx_client = client self._owns_client = client is None @property def base_url(self) -> str: """Return the base URL of the Paperless API endpoint.""" return self._base_url async def close(self) -> None: """Close the :class:`httpx.AsyncClient` if this transport created it. A client passed in by the caller is left open — its lifecycle belongs to the caller. """ if self._owns_client and self._httpx_client: await self._httpx_client.aclose() async def probe(self) -> "_HostInfo": """Request the API index and return parsed host version info. Validates that the response is reachable, returns HTTP 2xx, and contains a JSON-parseable body. Raises on any failure — the caller is responsible for wrapping exceptions. Example:: info = await transport.probe() print(info.api_version, info.version) """ res = await self.request_raw("get", EndpointPath.INDEX) res.raise_for_status() res.json() return _HostInfo( api_version=int(res.headers.get("x-api-version", API_VERSION)), version=res.headers.get("x-version"), ) async def _send( self, method: str, path: str, *, json: dict[str, Any] | None = None, data: dict[str, Any] | None = None, form: dict[str, Any] | None = None, params: dict[str, Any] | None = None, **kwargs: Any, ) -> httpx.Response: """Send an authenticated HTTP request; handle auth, transport errors, and 401/403.""" if self._httpx_client is None: self._httpx_client = httpx.AsyncClient() headers: dict[str, str] = { "Accept": f"application/json; version={API_VERSION}", } if self._token: headers["Authorization"] = f"Token {self._token}" # caller-supplied headers win over the defaults; never mutate the caller's dict kwargs["headers"] = {**headers, **kwargs.get("headers", {})} files = None if isinstance(form, dict): data, files = process_form_data(form) url = f"{self._base_url}{path}" if not path.startswith("http") else path try: res = await self._httpx_client.request( method=method.upper(), url=url, json=json, data=data, files=files, params=params, **kwargs, ) except httpx.TimeoutException as err: raise PaperlessTimeoutError from err except httpx.TransportError as err: raise PaperlessConnectionError from err if res.status_code == 401: try: error_data = res.json() detail = error_data.get("detail", "") except ValueError: detail = "" if "inactive" in detail.lower() or "deleted" in detail.lower(): raise InactiveOrDeletedError(res) raise InvalidTokenError(res) if res.status_code == 403: raise ForbiddenError(res) return res @staticmethod def raise_for_status(res: httpx.Response) -> None: """Raise a typed pypaperless exception for any non-2xx response. Raises :exc:`~pypaperless.exceptions.NotFoundError` for HTTP 404 and :exc:`~pypaperless.exceptions.UnexpectedStatusError` for any other non-2xx status code. Responses with a 2xx status pass silently. Args: res: The :class:`httpx.Response` to check. Example:: res = await transport.request_raw("get", "/api/documents/42/download/") transport.raise_for_status(res) """ if res.status_code == 404: raise NotFoundError(res) try: res.raise_for_status() except httpx.HTTPStatusError as exc: raise UnexpectedStatusError(res) from exc def _parse_json(self, res: httpx.Response) -> Any: """Parse a JSON response; raise on non-JSON, bad JSON, HTTP 400, or other errors.""" if "application/json" not in res.headers.get("content-type", ""): self.raise_for_status(res) raise BadJsonResponseError(res) try: payload = res.json() except (ValueError, JSONDecodeError): raise BadJsonResponseError(res) from None if res.status_code == 400: raise JsonResponseWithError(payload) self.raise_for_status(res) return payload async def get( self, path: str, *, params: dict[str, Any] | None = None, **kwargs: Any, ) -> Any: """Send a GET request and return the parsed JSON response. Args: path: API path relative to the base URL, or an absolute URL. params: Optional query string parameters. **kwargs: Forwarded to :meth:`_send`. Example:: data = await transport.get("/api/documents/", params={"page": 1}) """ return self._parse_json(await self._send("get", path, params=params, **kwargs)) async def post( self, path: str, *, json: dict[str, Any] | None = None, data: dict[str, Any] | None = None, form: dict[str, Any] | None = None, params: dict[str, Any] | None = None, **kwargs: Any, ) -> Any: """Send a POST request and return the parsed JSON response. Args: path: API path relative to the base URL, or an absolute URL. json: Dict to send as JSON request body. data: Dict to send as form-encoded body. form: Dict converted to multipart form data. params: Optional query string parameters. **kwargs: Forwarded to :meth:`_send`. Example:: data = await transport.post("/api/documents/", json={"title": "My Doc"}) """ return self._parse_json( await self._send("post", path, json=json, data=data, form=form, params=params, **kwargs) ) async def patch( self, path: str, *, json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, **kwargs: Any, ) -> Any: """Send a PATCH request and return the parsed JSON response. Args: path: API path relative to the base URL, or an absolute URL. json: Dict to send as JSON request body. params: Optional query string parameters. **kwargs: Forwarded to :meth:`_send`. Example:: data = await transport.patch("/api/documents/42/", json={"title": "Updated"}) """ return self._parse_json(await self._send("patch", path, json=json, params=params, **kwargs)) async def put( self, path: str, *, json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, **kwargs: Any, ) -> Any: """Send a PUT request and return the parsed JSON response. Args: path: API path relative to the base URL, or an absolute URL. json: Dict to send as JSON request body. params: Optional query string parameters. **kwargs: Forwarded to :meth:`_send`. Example:: data = await transport.put("/api/documents/42/", json={"title": "Replaced"}) """ return self._parse_json(await self._send("put", path, json=json, params=params, **kwargs)) async def delete( self, path: str, *, params: dict[str, Any] | None = None, **kwargs: Any, ) -> None: """Send a DELETE request and raise on any non-2xx response. Args: path: API path relative to the base URL, or an absolute URL. params: Optional query string parameters. **kwargs: Forwarded to :meth:`_send`. Example:: await transport.delete("/api/documents/42/") """ res = await self._send("delete", path, params=params, **kwargs) try: res.raise_for_status() except httpx.HTTPStatusError as exc: raise DeletionError(str(exc)) from exc async def request_raw( self, method: str, path: str, **kwargs: Any, ) -> httpx.Response: """Send a request and return the raw :class:`httpx.Response`. Use for binary downloads, header-only checks, or other cases where the caller needs access to the full response object. Args: method: HTTP method string. path: API path relative to the base URL, or an absolute URL. **kwargs: Forwarded to :meth:`_send`. Example:: res = await transport.request_raw("get", "/api/documents/42/download/") content = res.content """ return await self._send(method, path, **kwargs) async def generate_api_token( url: str, username: str, password: str, client: httpx.AsyncClient | None = None, ) -> str: """Request Paperless to generate an API token for the given credentials. .. warning:: The credentials are transmitted in the request body. Make sure the URL points to an HTTPS endpoint when used on untrusted networks. Args: url: Hostname, IP-address, or full URL of the Paperless instance. Scheme-less values default to ``https://``. username: Paperless user name. password: Paperless user password. client: Optional :class:`httpx.AsyncClient` to reuse. A new client is created and closed automatically when not provided. Example:: token = await generate_api_token("example.com:8000", "api_user", "secret") async with PaperlessClient("example.com:8000", token) as paperless: ... """ external_client = client is not None client = client or httpx.AsyncClient() try: url = normalize_base_url(url) json_data = { "username": username, "password": password, } res = await client.post(f"{url}{EndpointPath.TOKEN}", json=json_data) data = res.json() res.raise_for_status() return str(data["token"]) except httpx.TimeoutException as exc: raise PaperlessTimeoutError from exc except httpx.TransportError as exc: raise PaperlessConnectionError from exc except (JSONDecodeError, KeyError) as exc: message = "Token is missing in response." raise BadJsonResponseError(message) from exc except httpx.HTTPStatusError as exc: raise JsonResponseWithError(payload={"error": data}) from exc finally: if not external_client: await client.aclose() tb1337-pypaperless-5d67d60/pypaperless/utils.py000066400000000000000000000051671524076126400214440ustar00rootroot00000000000000"""Utility functions for pypaperless.""" from io import BytesIO from typing import Any def normalize_base_url(url: str) -> str: """Normalize a URL string for use as a Paperless API base URL.""" url = url.rstrip("/") if not url.startswith(("https://", "http://")): url = f"https://{url}" return url class _FormDataBuilder: """Build httpx-compatible (data, files) tuples from a raw form dict.""" def __init__(self) -> None: self._data: dict[str, Any] = {} self._files: list[tuple[str, Any]] = [] def _add_file(self, name: str, value: tuple | bytes) -> None: """Append a file field to the files list.""" if isinstance(value, tuple): if len(value) == 2: self._files.append((name, (f"{value[1]}", BytesIO(value[0])))) else: self._files.append((name, BytesIO(value[0]))) else: self._files.append((name, BytesIO(value))) def _add_scalar(self, name: str, value: Any) -> None: """Append or accumulate a scalar data field.""" if name in self._data: existing = self._data[name] if isinstance(existing, list): existing.append(f"{value}") else: self._data[name] = [existing, f"{value}"] else: self._data[name] = f"{value}" def _add(self, name: str, value: Any) -> None: """Recursively classify and dispatch a form value.""" if value is None: return if isinstance(value, dict): for k, v in value.items(): self._add(k, v) elif isinstance(value, list | set): for item in value: self._add(name, item) elif isinstance(value, tuple | bytes): self._add_file(name, value) elif hasattr(value, "read"): # file-like objects go to httpx as-is; stringifying them would upload # the object's repr instead of its content self._files.append((name, value)) else: self._add_scalar(name, value) def build(self, data: dict[str, Any]) -> tuple[dict[str, Any], list[tuple[str, Any]]]: """Process all entries and return the (data_fields, file_fields) tuple.""" for key, value in data.items(): self._add(key, value) return self._data, self._files def process_form_data(data: dict[str, Any]) -> tuple[dict[str, Any], list[tuple[str, Any]]]: """Process form data and create httpx-compatible data/files tuples. Returns a tuple of (data_fields, file_fields) for httpx. """ return _FormDataBuilder().build(data) tb1337-pypaperless-5d67d60/pyproject.toml000066400000000000000000000062611524076126400202730ustar00rootroot00000000000000[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "pypaperless" version = "0.0.0" license = {text = "MIT"} description = "Little api client for paperless(-ngx)." readme = "README.md" authors = [ {name = "Tobias Schulz", email = "public.dev@tbsch.de"} ] keywords = ["library", "async", "api-client", "python3", "paperless-ngx"] classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries :: Python Modules" ] requires-python = ">=3.13" dependencies = [ "httpx>=0.28.1", "pydantic>=2.13.4", "pydantic-settings>=2.15.0", ] [dependency-groups] dev = [ "codespell>=2.4.3", "covdefaults>=2.3.0", "coverage>=7.15.4", "mypy>=2.3.1", "prek>=0.4.14", "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", "pytest-httpx>=0.36.2", "ruff>=0.16.3", "yamllint>=1.38.0", ] docs = [ "zensical>=0.0.55", ] [project.urls] "Homepage" = "https://pypaperless.docs.tbsch.de" "Source Code" = "https://github.com/tb1337/pypaperless" "Bug Reports" = "https://github.com/tb1337/pypaperless/issues" "Coverage" = "https://codecov.io/gh/tb1337/pypaperless" [tool.codespell] skip = "./site,./uv.lock" [tool.coverage.run] plugins = ["covdefaults"] source = ["pypaperless"] [tool.coverage.report] fail_under = 95 show_missing = true [tool.mypy] platform = "linux" python_version = "3.13" files = ["pypaperless"] follow_imports = "normal" ignore_missing_imports = true check_untyped_defs = true disallow_any_generics = false disallow_incomplete_defs = true disallow_subclassing_any = true disallow_untyped_calls = true disallow_untyped_decorators = true disallow_untyped_defs = true no_implicit_optional = true show_error_codes = true warn_incomplete_stub = true warn_no_return = true warn_redundant_casts = true warn_return_any = true warn_unreachable = true warn_unused_configs = true warn_unused_ignores = true [tool.pytest.ini_options] addopts = "--cov --cov-report=term --cov-report=xml" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" [tool.ruff] line-length = 100 target-version = "py313" [tool.ruff.lint] ignore = [ "ANN401", # Opinioated warning on disallowing dynamically typed expressions "CPY001", # Eh what? Disable that annoying one. "D203", # Conflicts with other rules "D213", # Conflicts with other rules "D417", # False positives in some occasions "PLR2004", # Just annoying, not really useful "PLR0913", # Too many arguments: not actionable without breaking public API "RUF012", # Just annoying # Conflicts with the Ruff formatter "COM812", "ISC001", ] select = ["ALL"] [tool.ruff.lint.per-file-ignores] # S105: false positive – StrEnum members named TOKEN/REMOVE_PASSWORD hold URL paths, not passwords "pypaperless/const.py" = ["S105"] [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false mark-parentheses = false [tool.ruff.lint.isort] known-first-party = ["pypaperless"] [tool.ruff.lint.mccabe] max-complexity = 25 tb1337-pypaperless-5d67d60/script/000077500000000000000000000000001524076126400166565ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/script/_dev_env.py000066400000000000000000000020421524076126400210130ustar00rootroot00000000000000"""Dev-instance connection config for the maintenance scripts and run/debug.py. Reads ``PYPAPERLESS_URL`` / ``PYPAPERLESS_TOKEN`` / ``PYPAPERLESS_TEST_DOC`` from the git-ignored ``.env`` in the repo root. Copy ``.env.example`` to ``.env`` and fill in your throwaway dev values to get started. Example:: from _dev_env import load_dev_env env = load_dev_env() client = PaperlessClient(env.url, env.token.get_secret_value()) """ # ruff: noqa # mypy: ignore-errors import pathlib from pydantic import SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict _ENV_FILE = pathlib.Path(__file__).resolve().parent.parent / ".env" class _DevEnv(BaseSettings): model_config = SettingsConfigDict(env_prefix="PYPAPERLESS_", env_file=_ENV_FILE, extra="ignore") url: str token: SecretStr test_doc: int def load_dev_env() -> _DevEnv: """Load the dev-instance settings from the repo-root ``.env``. Raises: pydantic.ValidationError: if ``.env`` is missing or incomplete. """ return _DevEnv() tb1337-pypaperless-5d67d60/script/bootstrap000077500000000000000000000003361524076126400206230ustar00rootroot00000000000000#!/bin/sh # Resolve all dependencies that the application requires to run. # Stop on errors set -e cd "$(dirname "$0")/.." uv venv --clear $VIRTUAL_ENV echo "Installing development dependencies..." uv sync --group dev tb1337-pypaperless-5d67d60/script/pngx_audit_coverage.py000066400000000000000000001344151524076126400232550ustar00rootroot00000000000000"""API coverage audit for pypaperless. Connects to the live PaperlessClient instance and: 1. Fetches the OpenAPI schema (/api/schema/?format=json) as the authoritative source of field definitions for every endpoint. 2. Also fetches a live object from each endpoint where possible. 3. Compares both against the model_fields of the matching pypaperless model. Status per model: OK – model covers every field from the live response SCHEMA ✓ – no live data; model covers every field listed in the schema GAPS FOUND – missing or extra fields detected NO DATA – endpoint empty AND no schema component available (untyped) FETCH ERROR – HTTP / parse error The final summary also lists: • Schema paths not covered by any entry in const.EndpointPath (unimplemented) Usage ----- # Activate your dev venv, then create .env from .env.example # (PYPAPERLESS_URL / PYPAPERLESS_TOKEN / PYPAPERLESS_TEST_DOC). python script/pngx_audit_coverage.py """ # ruff: noqa # mypy: ignore-errors # This file was fully automatically generated by GitHub Copilot (Claude Sonnet 4.6). import argparse import asyncio import re from dataclasses import dataclass, field from enum import Enum from typing import Any import httpx from pypaperless import PaperlessClient from pypaperless.const import EndpointPath from pypaperless.models import ( Config, Correspondent, CustomField, Document, DocumentAISuggestions, DocumentHistory, DocumentMeta, DocumentNote, DocumentRoot, DocumentSuggestions, Group, MailAccount, MailRule, ProcessedMail, Profile, RemoteVersion, SavedView, SearchResult, ShareLink, ShareLinkBundle, Statistic, Status, StoragePath, Tag, Task, User, Workflow, WorkflowAction, WorkflowTrigger, ) from pypaperless.models.config import ( ArchiveFileGeneration, ColorConversionStrategy, OcrMode, OutputType, UnpaperClean, ) from pypaperless.models.custom_fields import ( CustomFieldType, CustomFieldValue, ) from pypaperless.models.document_types import DocumentType from pypaperless.models.mails.accounts import ImapSecurity, MailAccountType from pypaperless.models.mails.rules import ( MailRuleAction, MailRuleAttachmentType, MailRuleConsumptionScope, MailRuleCorrespondentSource, MailRulePdfLayout, MailRuleTitleSource, ) from pypaperless.models.mixins.data_fields import MatchingAlgorithm from pypaperless.models.saved_views import SavedViewDisplayMode, SavedViewFilterRule from pypaperless.models.share_links import ShareLinkFileVersion from pypaperless.models.status import ( StatusDatabase, StatusDatabaseMigration, StatusStorage, StatusTasks, ) from pypaperless.models.tasks import TaskStatus, TaskTriggerSource, TaskType from pypaperless.models.workflows import ( WorkflowActionEmail, WorkflowActionType, WorkflowActionWebhook, WorkflowTriggerScheduleDateField, WorkflowTriggerSource, WorkflowTriggerType, ) from _dev_env import load_dev_env # ── credentials ─────────────────────────────────────────────────────────────── _env = load_dev_env() PAPERLESS_URL = _env.url PAPERLESS_TOKEN = _env.token.get_secret_value() TEST_DOC_ID = _env.test_doc # ── terminal colours ────────────────────────────────────────────────────────── RED = "\033[31m" GREEN = "\033[32m" YELLOW = "\033[33m" CYAN = "\033[36m" BOLD = "\033[1m" DIM = "\033[2m" RESET = "\033[0m" # ── Fields intentionally omitted from the model (API key not in model_fields) ─ # These are keys the live API DOES return but the model deliberately omits. # Map: model_class_name → {api_key: explanation} KNOWN_OMISSIONS: dict[str, dict[str, str]] = { "Document": { "created_date": "legacy alias for `created`; model exposes a `created_date` property instead", "notes": "note IDs returned in list view; use document.notes service for full objects", "versions": "version IDs returned in list view; use document.versions service for full objects", }, "User": { "password": "write-only credential; intentionally excluded from model", }, "Profile": { "password": "write-only credential; intentionally excluded from model", }, "MailAccount": { "password": "write-only credential; intentionally excluded from model", }, } # ── Model fields NOT returned by the standard live API endpoint ─────────────── # These fields ARE declared in the model but require special query parameters # (e.g. ?full_perms=true) or are injected by the pypaperless service layer. # Map: model_class_name → {model_field: explanation} KNOWN_EXTRAS: dict[str, dict[str, str]] = { "Document": { "search_hit_": "synthesised from __search_hit__ alias only present in search results", "permissions": "only returned with ?full_perms=true; accessed via SecurableMixin", "notes_": "injected by service from separate notes endpoint; not in standard Document response", "versions_": "injected by service from separate versions endpoint; not in standard Document response", }, "Correspondent": { "permissions": "only returned with ?full_perms=true; accessed via SecurableMixin", "last_correspondence": "not returned in standard list; confirmed in schema, requires custom query", }, "DocumentType": { "permissions": "only returned with ?full_perms=true", }, "StoragePath": { "permissions": "only returned with ?full_perms=true", }, "Tag": { "permissions": "only returned with ?full_perms=true", }, "DocumentNote": { "document": "injected by service from URL path; not part of the Notes JSON body", }, "DocumentHistory": { "document": "injected by service from URL path; not part of the LogEntry JSON body", }, "DocumentMeta": { "id": "injected by service from path parameter; not in API response", }, "DocumentSuggestions": { "id": "injected by service from path parameter; not in API response", }, "DocumentAISuggestions": { "id": "injected by service from path parameter; not in API response", }, "SavedView": { "permissions": "only returned with ?full_perms=true", }, "MailAccount": { "permissions": "only returned with ?full_perms=true", }, "MailRule": { "permissions": "only returned with ?full_perms=true", }, } # ── Model fields that the SCHEMA does not document (schema comparison) ──────── # These fields exist in the model but NOT in the OpenAPI component schema. KNOWN_SCHEMA_EXTRAS: dict[str, dict[str, str]] = { "Document": { "search_hit_": "synthesised from __search_hit__ alias; not in OpenAPI schema", "permissions": "only returned with full_perms=true; not in standard Document schema", "versions_": "injected by service from separate versions endpoint; not in OpenAPI Document schema", }, "Correspondent": { "permissions": "only returned with full_perms=true; not in standard Correspondent schema", }, "DocumentType": { "permissions": "only returned with full_perms=true; not in standard DocumentType schema", }, "StoragePath": { "permissions": "only returned with full_perms=true; not in standard StoragePath schema", }, "Tag": { "permissions": "only returned with full_perms=true; not in standard Tag schema", "children": "pypaperless extension for nested tags; not in OpenAPI schema", "parent": "pypaperless extension for nested tags; not in OpenAPI schema", }, "SavedView": { "permissions": "only returned with full_perms=true; not in standard SavedView schema", }, "MailAccount": { "permissions": "only returned with full_perms=true; not in standard MailAccount schema", "account_type": "not listed in MailAccount schema properties", "expiration": "not listed in MailAccount schema properties", }, "MailRule": { "permissions": "only returned with full_perms=true; not in standard MailRule schema", }, "DocumentNote": { "document": "injected by service; Notes schema has no 'document' property", }, "DocumentHistory": { "document": "injected by service; LogEntry schema has no 'document' property", }, "DocumentMeta": { "id": "injected by service from path parameter; Metadata schema has no 'id'", }, "DocumentSuggestions": { "id": "injected by service from path parameter; Suggestions schema has no 'id'", }, "DocumentAISuggestions": { "id": "injected by service from path parameter; AISuggestions schema has no 'id'", }, } # ── Schema fields that the model deliberately omits (schema comparison) ─────── # These appear in the OpenAPI component schema but are intentionally absent. KNOWN_SCHEMA_OMISSIONS: dict[str, dict[str, str]] = { "MailAccount": { "password": "write-only credential; API never returns it", }, "User": { "password": "write-only credential; API never returns it", }, "Document": { "created_date": "legacy alias for `created`; model uses `created` with a property alias", "notes": "inline note IDs only; full objects accessed via the document.notes service", "versions": "inline version IDs only; full objects accessed via the document.versions service", }, "Status": { "index": "SystemStatus.index sub-object not yet modelled", "classifier": "SystemStatus.classifier sub-object not yet modelled", "sanity_check": "SystemStatus.sanity_check sub-object not yet modelled", }, } # ── Sub-model classes mapped to their OpenAPI schema component ─────────────── # (pypaperless_class, openapi_schema_name, description) SUBMODEL_MAPPINGS: list[tuple[type, str, str]] = [ (StatusDatabaseMigration, "MigrationStatus", "Status.database.migration_status"), (StatusDatabase, "Database", "Status.database"), (StatusStorage, "Storage", "Status.storage"), (StatusTasks, "Tasks", "Status.tasks"), (CustomFieldValue, "CustomFieldInstance", "Document.custom_fields[]"), (WorkflowActionEmail, "WorkflowActionEmail", "WorkflowAction.email"), (WorkflowActionWebhook, "WorkflowActionWebhook", "WorkflowAction.webhook"), (SavedViewFilterRule, "SavedViewFilterRule", "SavedView.filter_rules[]"), ] # Fields in the pypaperless sub-model class that are not in the schema component # (intentional extras – e.g. injected by service layer or schema is incomplete) SUBMODEL_KNOWN_EXTRAS: dict[str, dict[str, str]] = { "StatusTasks": { # Schema Tasks only documents 4 fields; live API returns all 15 flat fields. # PaperlessClient consolidates index/classifier/sanity_check into this object. "celery_url": "not in schema; live API returns it in tasks object", "celery_error": "not in schema; live API returns it in tasks object", "index_status": "schema has separate Index component but API flattens into tasks", "index_last_modified": "schema has separate Index component but API flattens into tasks", "index_error": "schema has separate Index component but API flattens into tasks", "classifier_status": "schema has separate Classifier component but API flattens into tasks", "classifier_last_trained": "schema has separate Classifier component but API flattens into tasks", "classifier_error": "schema has separate Classifier component but API flattens into tasks", "sanity_check_status": "schema has separate SanityCheck component but API flattens into tasks", "sanity_check_last_run": "schema has separate SanityCheck component but API flattens into tasks", "sanity_check_error": "schema has separate SanityCheck component but API flattens into tasks", }, "CustomFieldValue": { "name": "injected from custom field cache by pypaperless service layer", "data_type": "injected from custom field cache by pypaperless service layer", "extra_data": "injected from custom field cache by pypaperless service layer", }, } # Fields in the schema component that are intentionally absent from the sub-model SUBMODEL_KNOWN_OMISSIONS: dict[str, dict[str, str]] = {} # Sub-models that have no direct OpenAPI schema component (cannot verify field-by-field) # (class_name, source_file, usage / why it has no schema) UNSCHEMA_SUBMODELS: list[tuple[str, str, str]] = [ ( "DocumentMetaEntry", "documents.py", "DocumentMeta.original_metadata/archive_metadata — schema: untyped 'object'", ), ( "DocumentSearchHit", "documents.py", "Document.__search_hit__ — internal search enrichment, not in schema", ), ( "StatisticDocumentFileTypeCount", "statistics.py", "Statistic.document_file_type_counts[] — schema: Statistic component is untyped", ), ( "CustomFieldExtraData", "custom_fields.py", "CustomField.extra_data — schema: untyped nullable object", ), ( "CustomFieldSelectOptions", "custom_fields.py", "CustomFieldExtraData.select_options[] — schema: untyped nullable object", ), ( "_PermissionScope", "mixins/securable.py", "Permissions.view/.change — schema: inlined, no standalone component", ), ( "Permissions", "mixins/securable.py", "SecurableMixin.permissions — schema: inlined, no standalone component", ), ] # ── Typed enum classes mapped to their OpenAPI schema component ────────────── # (pypaperless_class, openapi_schema_name, description) ENUM_MAPPINGS: list[tuple[type[Enum], str, str]] = [ (MatchingAlgorithm, "MatchingAlgorithm", "Correspondent / DocumentType / StoragePath / Tag"), (CustomFieldType, "DataTypeEnum", "CustomField.data_type"), (ShareLinkFileVersion, "FileVersionEnum", "ShareLink.file_version"), (TaskType, "TaskTypeEnum", "Task.task_type"), (TaskStatus, "TaskSerializerV10StatusEnum", "Task.status"), (TaskTriggerSource, "TriggerSourceEnum", "Task.trigger_source"), (WorkflowActionType, "WorkflowActionTypeEnum", "WorkflowAction.type"), (WorkflowTriggerType, "WorkflowTriggerTypeEnum", "WorkflowTrigger.type"), ( WorkflowTriggerScheduleDateField, "ScheduleDateFieldEnum", "WorkflowTrigger.schedule_date_field", ), (WorkflowTriggerSource, "SourcesEnum", "WorkflowTrigger.sources"), (OutputType, "OutputTypeEnum", "Config.output_type"), (OcrMode, "ModeEnum", "Config.mode"), (ArchiveFileGeneration, "ArchiveFileGenerationEnum", "Config.archive_file_generation"), (UnpaperClean, "UnpaperCleanEnum", "Config.unpaper_clean"), (ColorConversionStrategy, "ColorConversionStrategyEnum", "Config.color_conversion_strategy"), (SavedViewDisplayMode, "DisplayModeEnum", "SavedView.display_mode"), (ImapSecurity, "ImapSecurityEnum", "MailAccount.imap_security"), (MailAccountType, "AccountTypeEnum", "MailAccount.account_type"), (MailRuleAction, "MailRuleActionEnum", "MailRule.action"), (MailRuleTitleSource, "AssignTitleFromEnum", "MailRule.assign_title_from"), ( MailRuleCorrespondentSource, "AssignCorrespondentFromEnum", "MailRule.assign_correspondent_from", ), (MailRuleAttachmentType, "AttachmentTypeEnum", "MailRule.attachment_type"), (MailRuleConsumptionScope, "ConsumptionScopeEnum", "MailRule.consumption_scope"), (MailRulePdfLayout, "PdfLayoutEnum", "MailRule.pdf_layout"), ] # ── Model fields that use raw int/str where schema defines a typed enum ─────── # (model_name, field_name, openapi_schema_name) UNTYPED_ENUM_FIELDS: list[tuple[str, str, str]] = [] # ── Schema paths that are action/utility endpoints (no resource model needed) ─ KNOWN_UTILITY_PATHS: set[str] = { "/api/bulk_edit_objects/", "/api/schema/", "/api/token/", "/api/oauth/callback/", "/api/documents/bulk_download/", "/api/documents/bulk_edit/", "/api/documents/delete/", "/api/documents/edit_pdf/", "/api/documents/email/", "/api/documents/merge/", "/api/documents/remove_password/", "/api/documents/reprocess/", "/api/documents/rotate/", "/api/documents/next_asn/", "/api/documents/post_document/", "/api/documents/selection_data/", "/api/documents/{id}/download/", "/api/documents/{id}/email/", "/api/documents/{id}/preview/", "/api/documents/{id}/thumb/", "/api/documents/{id}/update_version/", "/api/documents/{id}/versions/{version_id}/", "/api/mail_accounts/test/", "/api/mail_accounts/{id}/process/", "/api/processed_mail/bulk_delete/", "/api/profile/disconnect_social_account/", "/api/profile/generate_auth_token/", "/api/profile/social_account_providers/", "/api/profile/totp/", "/api/search/autocomplete/", "/api/storage_paths/test/", "/api/tasks/acknowledge/", "/api/tasks/active/", "/api/tasks/run/", "/api/tasks/status_counts/", "/api/tasks/summary/", "/api/users/{id}/deactivate_totp/", } # ── Schema paths intentionally out of scope for pypaperless ────────────────── INTENTIONALLY_SKIPPED_PATHS: set[str] = { "/api/logs/", # server log streaming, not a resource "/api/logs/{id}/", # server log streaming, not a resource "/api/ui_settings/", # paperless-ngx front-end settings, not exposed } # ───────────────────────────────────────────────────────────────────────────── # Data structures # ───────────────────────────────────────────────────────────────────────────── @dataclass class EndpointSpec: """Describe how to fetch and parse one API endpoint.""" label: str path: str model_cls: type unwrap: str = "paginated" # "paginated" | "direct" | "list_index0" schema_component: str | None = None # OpenAPI components/schemas/ @dataclass class AuditResult: label: str model_name: str api_keys: set[str] = field(default_factory=set) schema_keys: set[str] = field(default_factory=set) model_keys: set[str] = field(default_factory=set) missing_in_model: list[str] = field(default_factory=list) # in reference, not in model extra_in_model: list[str] = field(default_factory=list) # in model, not in reference known_omissions: list[str] = field(default_factory=list) known_extras: list[str] = field(default_factory=list) fetch_error: str | None = None no_data: bool = False # empty endpoint AND no schema component schema_only: bool = False # no live data, verified via schema only # ───────────────────────────────────────────────────────────────────────────── # Endpoint catalogue # ───────────────────────────────────────────────────────────────────────────── ENDPOINTS: list[EndpointSpec] = [ EndpointSpec("Config", "/api/config/1/", Config, "direct", "ApplicationConfiguration"), EndpointSpec( "Correspondent", "/api/correspondents/?page_size=1", Correspondent, "paginated", "Correspondent", ), EndpointSpec( "CustomField", "/api/custom_fields/?page_size=1", CustomField, "paginated", "CustomField" ), EndpointSpec("Document", f"/api/documents/{TEST_DOC_ID}/", Document, "direct", "Document"), EndpointSpec( "DocumentMeta", f"/api/documents/{TEST_DOC_ID}/metadata/", DocumentMeta, "direct", "Metadata", ), EndpointSpec( "DocumentNote", f"/api/documents/{TEST_DOC_ID}/notes/", DocumentNote, "list_index0", "Notes" ), EndpointSpec( "DocumentHistory", f"/api/documents/{TEST_DOC_ID}/history/", DocumentHistory, "list_index0", "LogEntry", ), EndpointSpec( "DocumentSuggestions", f"/api/documents/{TEST_DOC_ID}/suggestions/", DocumentSuggestions, "direct", "Suggestions", ), EndpointSpec( "DocumentRoot", f"/api/documents/{TEST_DOC_ID}/root/", DocumentRoot, "direct", "DocumentRootResponse", ), EndpointSpec( "DocumentType", "/api/document_types/?page_size=1", DocumentType, "paginated", "DocumentType", ), EndpointSpec("Group", "/api/groups/?page_size=1", Group, "paginated", "Group"), EndpointSpec( "MailAccount", "/api/mail_accounts/?page_size=1", MailAccount, "paginated", "MailAccount" ), EndpointSpec("MailRule", "/api/mail_rules/?page_size=1", MailRule, "paginated", "MailRule"), EndpointSpec( "ProcessedMail", "/api/processed_mail/?page_size=1", ProcessedMail, "paginated", "ProcessedMail", ), EndpointSpec("Profile", "/api/profile/", Profile, "direct", "Profile"), EndpointSpec( "RemoteVersion", "/api/remote_version/", RemoteVersion, "direct", None ), # untyped in schema EndpointSpec("SavedView", "/api/saved_views/?page_size=1", SavedView, "paginated", "SavedView"), EndpointSpec( "SearchResult", "/api/search/?query=invoice", SearchResult, "direct", "SearchResult" ), EndpointSpec("ShareLink", "/api/share_links/?page_size=1", ShareLink, "paginated", "ShareLink"), EndpointSpec( "ShareLinkBundle", "/api/share_link_bundles/?page_size=1", ShareLinkBundle, "paginated", "ShareLinkBundle", ), EndpointSpec("Statistic", "/api/statistics/", Statistic, "direct", None), # untyped in schema EndpointSpec("Status", "/api/status/", Status, "direct", "SystemStatus"), EndpointSpec( "StoragePath", "/api/storage_paths/?page_size=1", StoragePath, "paginated", "StoragePath" ), EndpointSpec("Tag", "/api/tags/?page_size=1", Tag, "paginated", "Tag"), EndpointSpec("Task", "/api/tasks/?page_size=1", Task, "paginated", "TaskSerializerV10"), EndpointSpec("Trash", "/api/trash/?page_size=1", Document, "paginated", "Document"), EndpointSpec("User", "/api/users/?page_size=1", User, "paginated", "User"), EndpointSpec("Workflow", "/api/workflows/?page_size=1", Workflow, "paginated", "Workflow"), EndpointSpec( "WorkflowAction", "/api/workflow_actions/?page_size=1", WorkflowAction, "paginated", "WorkflowAction", ), EndpointSpec( "WorkflowTrigger", "/api/workflow_triggers/?page_size=1", WorkflowTrigger, "paginated", "WorkflowTrigger", ), ] # ── AI endpoints — only audited when --with-ai is passed ────────────────────── AI_ENDPOINTS: list[EndpointSpec] = [ EndpointSpec( "DocumentAISuggestions", f"/api/documents/{TEST_DOC_ID}/ai_suggestions/", DocumentAISuggestions, "direct", "AISuggestions", ), ] # Schema paths that belong to AI features — excluded from the unimplemented-path # check unless --with-ai is active. AI_PATHS: set[str] = { "/api/documents/chat/", "/api/documents/{id}/ai_suggestions/", } # ───────────────────────────────────────────────────────────────────────────── # Schema helpers # ───────────────────────────────────────────────────────────────────────────── async def _fetch_openapi_schema(url: str, token: str) -> dict[str, Any]: """Fetch and return the full OpenAPI schema JSON.""" async with httpx.AsyncClient(verify=False) as client: resp = await client.get( f"{url}/api/schema/?format=json", headers={"Authorization": f"Token {token}"}, ) resp.raise_for_status() return resp.json() def _schema_keys(schema: dict[str, Any], component: str | None) -> set[str]: """Return schema property names for a given component, or empty set.""" if component is None: return set() return set( schema.get("components", {}) .get("schemas", {}) .get(component, {}) .get("properties", {}) .keys() ) def _unimplemented_paths(schema: dict[str, Any], *, with_ai: bool = False) -> list[tuple[str, str]]: """Return (path, http_methods) for schema paths not in const.EndpointPath. Normalises ``{id}`` placeholders to ``{pk}`` before comparing. Excludes utility/action paths from KNOWN_UTILITY_PATHS. When *with_ai* is ``False``, AI-related paths are also excluded. """ def _norm(p: str) -> str: return re.sub(r"\{[^}]+\}", "{pk}", p) known_normalised = {_norm(v) for v in EndpointPath} skip = KNOWN_UTILITY_PATHS | INTENTIONALLY_SKIPPED_PATHS if not with_ai: skip = skip | AI_PATHS result: list[tuple[str, str]] = [] for path, path_item in sorted(schema["paths"].items()): if path in skip: continue if _norm(path) not in known_normalised: methods = ", ".join( m.upper() for m in path_item if m in {"get", "post", "put", "patch", "delete"} ) result.append((path, methods)) return result # ───────────────────────────────────────────────────────────────────────────── # Fetch helpers # ───────────────────────────────────────────────────────────────────────────── async def _fetch_item(p: PaperlessClient, spec: EndpointSpec) -> dict[str, Any] | None: """Return a representative raw JSON object for ``spec``, or None on error/no-data.""" raw = await p._runtime.transport.get(spec.path) if spec.unwrap == "paginated": results = raw.get("results", []) if not results: return None # no data on this instance return results[0] if spec.unwrap == "list_index0": if not isinstance(raw, list) or not raw: return None return raw[0] # "direct" — raw IS the object return raw # ───────────────────────────────────────────────────────────────────────────── # Audit logic # ───────────────────────────────────────────────────────────────────────────── def _audit_one(spec: EndpointSpec, item: dict[str, Any] | None, s_keys: set[str]) -> AuditResult: result = AuditResult(label=spec.label, model_name=spec.model_cls.__name__) model_keys = set(spec.model_cls.model_fields.keys()) result.model_keys = model_keys result.schema_keys = s_keys # ── No live data – fall back to schema if available ─────────────────────── if item is None: if not s_keys: result.no_data = True return result result.schema_only = True ref_keys = s_keys omissions = KNOWN_SCHEMA_OMISSIONS.get(spec.model_cls.__name__, {}) extras = KNOWN_SCHEMA_EXTRAS.get(spec.model_cls.__name__, {}) else: # ── Live data available ─────────────────────────────────────────────── ref_keys = set(item.keys()) result.api_keys = ref_keys omissions = KNOWN_OMISSIONS.get(spec.model_cls.__name__, {}) extras = KNOWN_EXTRAS.get(spec.model_cls.__name__, {}) raw_missing = sorted(ref_keys - model_keys) raw_extra = sorted(model_keys - ref_keys) for k in raw_missing: if k in omissions: result.known_omissions.append(k) else: result.missing_in_model.append(k) for k in raw_extra: if k in extras: result.known_extras.append(k) else: result.extra_in_model.append(k) return result # ───────────────────────────────────────────────────────────────────────────── # Reporting # ───────────────────────────────────────────────────────────────────────────── def _print_result(r: AuditResult) -> None: if r.schema_only: omissions = KNOWN_SCHEMA_OMISSIONS.get(r.model_name, {}) extras = KNOWN_SCHEMA_EXTRAS.get(r.model_name, {}) ref_label = "schema" else: omissions = KNOWN_OMISSIONS.get(r.model_name, {}) extras = KNOWN_EXTRAS.get(r.model_name, {}) ref_label = "API" has_gaps = bool(r.missing_in_model or r.extra_in_model) if r.fetch_error: status = f"{RED}FETCH ERROR{RESET}" elif r.no_data: status = f"{YELLOW}NO DATA{RESET} (no live data and no typed schema component)" elif has_gaps: status = f"{RED}GAPS FOUND{RESET}" elif r.schema_only: status = f"{CYAN}SCHEMA ✓{RESET} (no live data; verified via OpenAPI schema)" else: status = f"{GREEN}OK{RESET}" print(f"\n{BOLD}{r.label}{RESET} ({DIM}{r.model_name}{RESET}) → {status}") if r.fetch_error: print(f" {RED} {r.fetch_error}{RESET}") return if r.no_data: print(f" {DIM}model fields: {sorted(r.model_keys)}{RESET}") return if r.missing_in_model: print(f" {RED}MISSING IN MODEL{RESET} – {ref_label} field not captured by model:") for k in r.missing_in_model: print(f" {RED}✗ {k!r}{RESET}") if r.extra_in_model: print(f" {YELLOW}EXTRA IN MODEL{RESET} – model field not present in {ref_label}:") for k in r.extra_in_model: print(f" {YELLOW}⚠ {k!r}{RESET}") if r.known_omissions: print(f" {DIM}known omissions (intentional, not in model):{RESET}") for k in r.known_omissions: print(f" {DIM}· {k!r} → {omissions[k]}{RESET}") if r.known_extras: print(f" {DIM}known extras (intentional, not in {ref_label}):{RESET}") for k in r.known_extras: print(f" {DIM}· {k!r} → {extras[k]}{RESET}") if not has_gaps: ref_count = len(r.schema_keys) if r.schema_only else len(r.api_keys) print( f" {DIM}all {ref_count} {ref_label} fields covered " f"(+ {len(r.known_omissions)} known omissions, " f"{len(r.known_extras)} known extras){RESET}" ) # ── Schema cross-validation (when live data was used) ──────────────────── if not r.schema_only and r.schema_keys and not r.fetch_error: schema_vs_model_missing = sorted( r.schema_keys - r.model_keys - set(KNOWN_SCHEMA_OMISSIONS.get(r.model_name, {})) ) schema_vs_model_extra = sorted( r.model_keys - r.schema_keys - set(KNOWN_SCHEMA_EXTRAS.get(r.model_name, {})) ) if schema_vs_model_missing or schema_vs_model_extra: print(f" {DIM}┄ schema cross-check:{RESET}") for k in schema_vs_model_missing: print(f" {DIM}schema has {k!r} but model doesn't (consider adding){RESET}") for k in schema_vs_model_extra: print(f" {DIM}model has {k!r} but schema doesn't (extension or stale){RESET}") def _audit_submodels( oa_schema: dict[str, Any], ) -> list[tuple[str, str, list[str], list[str]]]: """Compare pypaperless sub-model classes against OpenAPI schema component fields.""" schemas = oa_schema.get("components", {}).get("schemas", {}) print(f"\n{'─' * 70}") print(f" {BOLD}Sub-model Field Audit{RESET}") print(f"{'─' * 70}") all_ok = True gaps: list[tuple[str, str, list[str], list[str]]] = [] for cls, schema_name, usage in SUBMODEL_MAPPINGS: schema_component = schemas.get(schema_name, {}) if not schema_component: print( f" {YELLOW}MISSING{RESET} {cls.__name__} ↔ {DIM}{schema_name}{RESET} ({DIM}schema component not found{RESET})" ) all_ok = False continue schema_keys = set(schema_component.get("properties", {}).keys()) model_keys = set(cls.model_fields.keys()) known_omissions = SUBMODEL_KNOWN_OMISSIONS.get(cls.__name__, {}) known_extras = SUBMODEL_KNOWN_EXTRAS.get(cls.__name__, {}) raw_missing = sorted(schema_keys - model_keys) raw_extra = sorted(model_keys - schema_keys) missing = [k for k in raw_missing if k not in known_omissions] extra = [k for k in raw_extra if k not in known_extras] k_omissions = [k for k in raw_missing if k in known_omissions] k_extras = [k for k in raw_extra if k in known_extras] if missing or extra: all_ok = False gaps.append((cls.__name__, schema_name, missing, extra)) print( f"\n {RED}GAPS{RESET} {cls.__name__} ↔ {DIM}{schema_name}{RESET} ({DIM}{usage}{RESET})" ) for k in missing: print(f" {RED}✗ schema has {k!r} — missing in {cls.__name__}{RESET}") for k in extra: print(f" {YELLOW}⚠ {cls.__name__} has {k!r} — not in schema{RESET}") else: note = f"{len(schema_keys)} fields" if k_omissions or k_extras: note += f" · +{len(k_omissions)} omissions, +{len(k_extras)} extras" print( f" {GREEN}OK{RESET} {cls.__name__} ↔ {DIM}{schema_name}{RESET} ({DIM}{note} · {usage}{RESET})" ) for k in k_omissions: print(f" {DIM}· omission: {k!r} → {known_omissions[k]}{RESET}") for k in k_extras: print(f" {DIM}· extra: {k!r} → {known_extras[k]}{RESET}") if UNSCHEMA_SUBMODELS: print( f"\n {CYAN}Sub-models without a direct schema component (field-by-field verification not possible):{RESET}" ) for cls_name, src, usage in UNSCHEMA_SUBMODELS: print(f" {DIM}· {cls_name:<35s} ({src})") print(f" {DIM} └─ {usage}{RESET}") if all_ok: print(f"\n {GREEN}{BOLD}All mappable sub-models match schema fields.{RESET}") return gaps def _audit_enums(oa_schema: dict[str, Any]) -> list[tuple[str, str, list, list]]: """Compare pypaperless typed enum classes against OpenAPI schema enum values.""" schemas = oa_schema.get("components", {}).get("schemas", {}) print(f"\n{'─' * 70}") print(f" {BOLD}Enum Value Audit{RESET}") print(f"{'─' * 70}") all_ok = True gaps: list[tuple[str, str, list, list]] = [] for cls, schema_name, usage in ENUM_MAPPINGS: schema_component = schemas.get(schema_name, {}) if not schema_component: print( f" {YELLOW}MISSING{RESET} {cls.__name__} ↔ {DIM}{schema_name}{RESET} ({DIM}schema component not found{RESET})" ) all_ok = False gaps.append((cls.__name__, schema_name, [""], [])) continue schema_vals: set = set(schema_component.get("enum", [])) model_vals: set = {m.value for m in cls if m.name != "UNKNOWN"} missing = sorted(schema_vals - model_vals, key=str) extra = sorted(model_vals - schema_vals, key=str) if missing or extra: all_ok = False gaps.append((cls.__name__, schema_name, missing, extra)) print( f"\n {RED}GAPS{RESET} {cls.__name__} ↔ {DIM}{schema_name}{RESET} ({DIM}{usage}{RESET})" ) for v in missing: print(f" {RED}✗ schema has {v!r} — missing in {cls.__name__}{RESET}") for v in extra: print(f" {YELLOW}⚠ {cls.__name__} has {v!r} — not in schema{RESET}") else: print( f" {GREEN}OK{RESET} {cls.__name__} ↔ {DIM}{schema_name}{RESET} ({DIM}{len(schema_vals)} values · {usage}{RESET})" ) if UNTYPED_ENUM_FIELDS: print(f"\n {CYAN}Untyped enum fields (raw int/str; schema has a typed enum):{RESET}") for model_name, field_name, schema_name in UNTYPED_ENUM_FIELDS: vals = schemas.get(schema_name, {}).get("enum", ["schema not found"]) print(f" {DIM}· {model_name}.{field_name:<35s} → {schema_name}: {vals}{RESET}") if all_ok: print(f"\n {GREEN}{BOLD}All typed enums match schema values.{RESET}") return gaps def _print_summary( results: list[AuditResult], schema: dict[str, Any], submodel_gaps: list[tuple[str, str, list[str], list[str]]], enum_gaps: list[tuple[str, str, list, list]], *, with_ai: bool = False, ) -> None: total = len(results) live_ok = sum( 1 for r in results if not r.fetch_error and not r.no_data and not r.schema_only and not r.missing_in_model and not r.extra_in_model ) schema_ok = sum( 1 for r in results if r.schema_only and not r.missing_in_model and not r.extra_in_model ) gap_count = sum(1 for r in results if r.missing_in_model or r.extra_in_model) no_data_count = sum(1 for r in results if r.no_data) error_count = sum(1 for r in results if r.fetch_error) print(f"\n{'═' * 70}") print(f" {BOLD}Summary{RESET}") print(f"{'─' * 70}") print(f" Total endpoints audited : {total}") print(f" {GREEN}Fully covered (live) : {live_ok}{RESET}") if schema_ok: print(f" {CYAN}Verified via schema only : {schema_ok}{RESET}") if gap_count: print(f" {RED}Gaps found : {gap_count}{RESET}") if no_data_count: print(f" {YELLOW}Unverifiable (no data) : {no_data_count}{RESET}") if error_count: print(f" {RED}Fetch errors : {error_count}{RESET}") if submodel_gaps: print(f" {RED}Sub-model gaps : {len(submodel_gaps)}{RESET}") if enum_gaps: print(f" {RED}Enum gaps : {len(enum_gaps)}{RESET}") # Collect all gaps all_missing: list[tuple[str, str, bool]] = [] all_extra: list[tuple[str, str, bool]] = [] for r in results: for k in r.missing_in_model: all_missing.append((r.label, k, r.schema_only)) for k in r.extra_in_model: all_extra.append((r.label, k, r.schema_only)) # ── Schema-only endpoints ───────────────────────────────────────────────── schema_only_list = [ r for r in results if r.schema_only and not r.missing_in_model and not r.extra_in_model ] if schema_only_list: print(f"\n {CYAN}{BOLD}Verified via schema only (no live data available):{RESET}") for r in schema_only_list: print( f" {CYAN}· {r.label:<30s} {DIM}({r.model_name} · {len(r.schema_keys)} schema fields){RESET}" ) # ── No-data endpoints ───────────────────────────────────────────────────── no_data_list = [r for r in results if r.no_data] if no_data_list: print( f"\n {YELLOW}{BOLD}Unverifiable — endpoint empty and no typed schema component:{RESET}" ) for r in no_data_list: print(f" {YELLOW}· {r.label:<30s} {DIM}({r.model_name}){RESET}") # ── Fetch errors ────────────────────────────────────────────────────────── error_list = [r for r in results if r.fetch_error] if error_list: print(f"\n {RED}{BOLD}Fetch errors:{RESET}") for r in error_list: print(f" {RED}✗ {r.label:<30s} {DIM}{r.fetch_error}{RESET}") if all_missing: print(f"\n {RED}{BOLD}Fields missing in model (not captured by any field):{RESET}") for label, k, via_schema in all_missing: badge = f" {DIM}(schema){RESET}" if via_schema else "" print(f" {RED}✗ {label}.{k}{badge}{RESET}") if all_extra: print( f"\n {YELLOW}{BOLD}Fields extra in model (declared but reference never returns):{RESET}" ) for label, k, via_schema in all_extra: badge = f" {DIM}(schema){RESET}" if via_schema else "" print(f" {YELLOW}⚠ {label}.{k}{badge}{RESET}") # ── Sub-model gaps ──────────────────────────────────────────────────────── if submodel_gaps: print(f"\n {RED}{BOLD}Sub-model field gaps:{RESET}") for cls_name, schema_name, missing, extra in submodel_gaps: print(f" {RED}↔ {cls_name} ↔ {DIM}{schema_name}{RESET}") for k in missing: print(f" {RED}✗ schema has {k!r} — missing in model{RESET}") for k in extra: print(f" {YELLOW}⚠ model has {k!r} — not in schema{RESET}") # ── Enum gaps ───────────────────────────────────────────────────────────── if enum_gaps: print(f"\n {RED}{BOLD}Enum value gaps:{RESET}") for cls_name, schema_name, missing, extra in enum_gaps: print(f" {RED}↔ {cls_name} ↔ {DIM}{schema_name}{RESET}") for v in missing: print(f" {RED}✗ schema has {v!r} — missing in enum{RESET}") for v in extra: print(f" {YELLOW}⚠ enum has {v!r} — not in schema{RESET}") # ── Unimplemented schema paths ──────────────────────────────────────────── unimplemented = _unimplemented_paths(schema, with_ai=with_ai) if not with_ai: print( f" {DIM}AI paths excluded from unimplemented check (pass --with-ai to include){RESET}" ) if unimplemented: print(f"\n {CYAN}{BOLD}Schema paths not covered by const.EndpointPath:{RESET}") for path, methods in unimplemented: print(f" {CYAN}· {path:<55s} {DIM}[{methods}]{RESET}") print(f"\n{'═' * 70}\n") # ───────────────────────────────────────────────────────────────────────────── # Main # ───────────────────────────────────────────────────────────────────────────── async def main(*, with_ai: bool = False) -> None: print(f"\n{BOLD}pypaperless · API coverage audit (live + OpenAPI schema){RESET}") print(f"Target : {PAPERLESS_URL}") if not with_ai: print(f"{BOLD}AI endpoints skipped{RESET} (pass --with-ai to include)") print(f"{'─' * 70}") # ── Fetch OpenAPI schema first ──────────────────────────────────────────── print("Fetching OpenAPI schema …", end=" ", flush=True) try: oa_schema = await _fetch_openapi_schema(PAPERLESS_URL, PAPERLESS_TOKEN) oa_version = oa_schema.get("info", {}).get("version", "?") print(f"{GREEN}OK{RESET} (server version {oa_version})") except Exception as exc: print(f"{RED}FAILED{RESET}: {exc}") oa_schema = {} p = PaperlessClient( PAPERLESS_URL, PAPERLESS_TOKEN, client=httpx.AsyncClient(verify=False), ) results: list[AuditResult] = [] endpoints = ENDPOINTS + (AI_ENDPOINTS if with_ai else []) async with p: p.runtime.cache.custom_fields = await p.custom_fields.as_dict() for spec in endpoints: try: item = await _fetch_item(p, spec) s_keys = _schema_keys(oa_schema, spec.schema_component) result = _audit_one(spec, item, s_keys) except Exception as exc: result = AuditResult(label=spec.label, model_name=spec.model_cls.__name__) result.fetch_error = str(exc) _print_result(result) results.append(result) submodel_gaps = _audit_submodels(oa_schema) enum_gaps = _audit_enums(oa_schema) _print_summary(results, oa_schema, submodel_gaps, enum_gaps, with_ai=with_ai) if __name__ == "__main__": _parser = argparse.ArgumentParser(description="pypaperless API coverage audit") _parser.add_argument( "--with-ai", action="store_true", default=False, help="Include AI endpoints in the audit (ai_suggestions, chat). Disabled by default.", ) _args = _parser.parse_args() asyncio.run(main(with_ai=_args.with_ai)) tb1337-pypaperless-5d67d60/script/pngx_fetch_schema.py000066400000000000000000000021211524076126400226710ustar00rootroot00000000000000"""Fetch the OpenAPI schema from the dev Paperless-ngx instance and write it to tests/data/schema.json.""" # ruff: noqa # mypy: ignore-errors import json import pathlib import sys import httpx from _dev_env import load_dev_env _env = load_dev_env() SCHEMA_URL = f"{_env.url}/api/schema/?format=json" PAPERLESS_TOKEN = _env.token.get_secret_value() OUTPUT_PATH = pathlib.Path(__file__).parent.parent / "tests" / "data" / "schema.json" def main() -> None: print(f"Fetching {SCHEMA_URL} …") response = httpx.get( SCHEMA_URL, headers={"accept": "application/json", "authorization": f"Token {PAPERLESS_TOKEN}"}, follow_redirects=True, timeout=30, ) response.raise_for_status() schema = response.json() OUTPUT_PATH.write_text( json.dumps(schema, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" ) print(f"Written to {OUTPUT_PATH} ({OUTPUT_PATH.stat().st_size:,} bytes)") if __name__ == "__main__": try: main() except Exception as exc: print(f"ERROR: {exc}", file=sys.stderr) sys.exit(1) tb1337-pypaperless-5d67d60/script/pngx_smoketest.py000066400000000000000000002037221524076126400223100ustar00rootroot00000000000000"""Comprehensive API smoke-test for pypaperless. Runs against the dev PaperlessClient instance configured via the repo-root .env (PYPAPERLESS_URL / PYPAPERLESS_TOKEN / PYPAPERLESS_TEST_DOC; see .env.example). PYPAPERLESS_TEST_DOC is used as the test document. Exit code 0 → all sections green Exit code != 0 → at least one section failed (printed in red) """ # ruff: noqa # mypy: ignore-errors # This file was fully automatically generated by GitHub Copilot (Claude Sonnet 4.6). import argparse import asyncio import logging import traceback import uuid from collections.abc import Callable, Coroutine from typing import Any import httpx from pypaperless import PaperlessClient from pypaperless.builders import SearchQuery from pypaperless.models.correspondents import CorrespondentDraft from pypaperless.models.custom_fields import ( CustomFieldIntegerValue, CustomFieldStringValue, ) from pypaperless.models.document_types import DocumentTypeDraft from pypaperless.models.documents import DocumentCustomFieldList, DocumentDraft, DocumentNoteDraft from pypaperless.models.share_links import ( ShareLinkBundleDraft, ShareLinkDraft, ShareLinkFileVersion, ) from pypaperless.models.storage_paths import StoragePathDraft from pypaperless.models.tags import TagDraft from pypaperless.builders.custom_fields import CustomFieldQuery from pypaperless.exceptions import DeletionError, JsonResponseWithError from _dev_env import load_dev_env async def _retry_flaky[T](coro_factory: Callable[[], Coroutine[Any, Any, T]]) -> T: """Run a server-flaky operation, retrying once after a short delay. The Paperless-ngx notes endpoints intermittently fail while the search index writer is busy (HTTP 500 on delete, HTTP 200 with an error payload on save). One retry after a pause reliably clears the transient without masking real regressions - the retried call still has to succeed. """ try: return await coro_factory() except (DeletionError, JsonResponseWithError): await asyncio.sleep(5) return await coro_factory() # ── PDF helper ──────────────────────────────────────────────────────────────── def _make_unique_pdf(token: str) -> bytes: """Build a minimal valid single-page PDF with *token* embedded in the Info dict. Xref offsets are computed dynamically so the file is always valid — unlike a hardcoded template that breaks when any bytes are prepended. Each call with a different token produces a unique hash, which defeats PaperlessClient-ngx duplicate detection. """ token_bytes = token.encode() header = b"%PDF-1.4\n" obj1 = b"1 0 obj<>endobj\n" obj2 = b"2 0 obj<>endobj\n" obj3 = b"3 0 obj<>endobj\n" obj4 = b"4 0 obj<>endobj\n" off1 = len(header) off2 = off1 + len(obj1) off3 = off2 + len(obj2) off4 = off3 + len(obj3) xref_off = off4 + len(obj4) xref = ( b"xref\n0 5\n" b"0000000000 65535 f \n" + f"{off1:010d} 00000 n \n".encode() + f"{off2:010d} 00000 n \n".encode() + f"{off3:010d} 00000 n \n".encode() + f"{off4:010d} 00000 n \n".encode() + b"trailer<>\n" + f"startxref\n{xref_off}\n%%EOF\n".encode() ) return header + obj1 + obj2 + obj3 + obj4 + xref async def _await_task_and_cleanup( p: PaperlessClient, task_id: str, label: str, *, timeout: int = 45, ) -> None: """Poll *task_id* until it finishes, then delete the resulting document. Uses ``Task.related_document_ids`` to obtain the created doc ID. """ from pypaperless.models.tasks import TaskStatus doc_id: int | None = None deadline = asyncio.get_event_loop().time() + timeout task = None while asyncio.get_event_loop().time() < deadline: task = await p.tasks(task_id) if task is None: break if task.status in ( TaskStatus.SUCCESS, TaskStatus.FAILURE, TaskStatus.REVOKED, ): if task.related_document_ids: doc_id = task.related_document_ids[0] break await asyncio.sleep(1) if task is not None and task.status == TaskStatus.FAILURE: ok( f"cleanup [{label}]: task failed – nothing to delete", f"result_data={task.result_data!r}", ) return if doc_id is not None: try: doc = await p.documents(doc_id) deleted = await p.documents.delete(doc) ok(f"cleanup [{label}]: deleted doc #{doc_id}", f"deleted={deleted}") except Exception as exc: fail(f"cleanup [{label}]: delete doc #{doc_id}", exc) else: ok( f"cleanup [{label}]: doc not found after {timeout}s", "still processing or duplicate-rejected — skip", ) logging.basicConfig(level=logging.WARNING) # ── Connection ──────────────────────────────────────────────────────────────── _env = load_dev_env() PAPERLESS_URL = _env.url PAPERLESS_TOKEN = _env.token.get_secret_value() TEST_DOCUMENT_ID = _env.test_doc PAGE_SIZE = 500 # ── Terminal colours ────────────────────────────────────────────────────────── GREEN = "\033[32m" RED = "\033[31m" YELLOW = "\033[33m" CYAN = "\033[36m" RESET = "\033[0m" BOLD = "\033[1m" # ── Result tracking ─────────────────────────────────────────────────────────── _passed: list[str] = [] _failed: list[tuple[str, str]] = [] def _hdr(title: str) -> None: print(f"\n{BOLD}{CYAN}{'─' * 60}{RESET}") print(f"{BOLD}{CYAN} {title}{RESET}") print(f"{BOLD}{CYAN}{'─' * 60}{RESET}") def ok(label: str, detail: str = "") -> None: suffix = f" {YELLOW}{detail}{RESET}" if detail else "" print(f" {GREEN}✓{RESET} {label}{suffix}") _passed.append(label) def fail(label: str, exc: BaseException) -> None: msg = f"{type(exc).__name__}: {exc}" print(f" {RED}✗{RESET} {label}") print(f" {RED}{msg}{RESET}") _failed.append((label, msg)) async def check( label: str, coro: Coroutine[Any, Any, Any], *, detail_fn: Callable[[Any], str] | None = None, ) -> Any | None: """Await *coro* and record pass/fail. Return the result or None.""" try: result = await coro detail = detail_fn(result) if detail_fn else "" ok(label, detail) return result except Exception as exc: fail(label, exc) traceback.print_exc() return None # ────────────────────────────────────────────────────────────────────────────── async def test_system(p: PaperlessClient) -> None: _hdr("System: status / statistics / remote_version") await check( "status()", p.status(), detail_fn=lambda r: f"db={r.database.status.value if r.database else '?'}", ) await check( "statistics()", p.statistics(), detail_fn=lambda r: f"total_docs={r.documents_total}", ) await check( "remote_version()", p.remote_version(), detail_fn=lambda r: f"version={r.version}, update_available={r.update_available}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_search(p: PaperlessClient) -> None: _hdr("Search") await check( "search('invoice')", p.search("invoice"), detail_fn=lambda r: ( f"total={r.total}, documents={len(r.documents or [])}, tags={len(r.tags or [])}" ), ) q = SearchQuery("invoice") & SearchQuery.field("tag", "unpaid") await check( "search(SearchQuery builder)", p.search(q), detail_fn=lambda r: f"total={r.total}, query={str(q)!r}", ) await check( "search.autocomplete('inv')", p.search.autocomplete("inv", 5), detail_fn=lambda r: f"terms={r}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_config(p: PaperlessClient) -> None: _hdr("Config") await check("config()", p.config(), detail_fn=lambda r: f"id={r.id}") # ────────────────────────────────────────────────────────────────────────────── async def test_documents(p: PaperlessClient) -> None: _hdr("Documents – fetch, iterate, search, more_like, pages") # Fetch by pk doc = await check( f"documents({TEST_DOCUMENT_ID})", p.documents(TEST_DOCUMENT_ID), detail_fn=lambda r: f"title={r.title!r}", ) # Fetch with permissions async with p.documents.with_permissions(): await check( f"documents({TEST_DOCUMENT_ID}) with permissions", p.documents(TEST_DOCUMENT_ID), detail_fn=lambda r: f"has_permissions={r.has_permissions}", ) # Lazy fetch await check( f"documents({TEST_DOCUMENT_ID}, lazy=True)", p.documents(TEST_DOCUMENT_ID, lazy=True), detail_fn=lambda r: f"id={r.id}", ) # pages() iteration try: pages = aiter(p.documents.pages(page=1, page_size=PAGE_SIZE)) page = await anext(pages) ok( f"documents.pages(page=1, page_size={PAGE_SIZE})", f"count={page.count}, items_on_page={page.current_count}", ) except Exception as exc: fail("documents.pages()", exc) # as_list / as_dict shortcuts (limit via filter) try: async with p.documents.filter(page_size=PAGE_SIZE): docs_list = await p.documents.as_list() ok(f"documents.as_list() [page_size={PAGE_SIZE}]", f"len={len(docs_list)}") except Exception as exc: fail("documents.as_list()", exc) try: async with p.documents.filter(page_size=PAGE_SIZE): docs_dict = await p.documents.as_dict() ok(f"documents.as_dict() [page_size={PAGE_SIZE}]", f"len={len(docs_dict)}") except Exception as exc: fail("documents.as_dict()", exc) # search try: results = [d async for d in p.documents.search("invoice")] ok("documents.search('invoice')", f"results={len(results)}") except Exception as exc: fail("documents.search()", exc) # more_like try: similar = [d async for d in p.documents.more_like(TEST_DOCUMENT_ID)] ok(f"documents.more_like({TEST_DOCUMENT_ID})", f"similar={len(similar)}") except Exception as exc: fail("documents.more_like()", exc) # next ASN await check( "documents.get_next_asn()", p.documents.get_next_asn(), detail_fn=lambda r: f"asn={r}" ) # Metadata await check( f"documents.metadata({TEST_DOCUMENT_ID})", p.documents.metadata(TEST_DOCUMENT_ID), detail_fn=lambda r: f"mime={r.original_mime_type}", ) # thumbnail await check( f"documents.thumbnail({TEST_DOCUMENT_ID})", p.documents.thumbnail(TEST_DOCUMENT_ID), detail_fn=lambda r: f"content_type={r.content_type}, bytes={len(r.content or b'')}", ) # preview await check( f"documents.preview({TEST_DOCUMENT_ID})", p.documents.preview(TEST_DOCUMENT_ID), detail_fn=lambda r: f"content_type={r.content_type}", ) # download await check( f"documents.download({TEST_DOCUMENT_ID})", p.documents.download(TEST_DOCUMENT_ID), detail_fn=lambda r: f"filename={r.disposition_filename}", ) # suggestions 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: try: _ = doc.has_search_hit _ = doc.created_date ok("doc.has_search_hit / doc.created_date", f"created={doc.created_date}") except Exception as exc: fail("doc properties", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_document_history(p: PaperlessClient) -> None: _hdr("Document History – list via service and document property") entries = await check( f"documents.history({TEST_DOCUMENT_ID})", p.documents.history(TEST_DOCUMENT_ID), detail_fn=lambda r: f"count={len(r)}, first_action={r[0].action.value if r else '—'}", ) if entries: try: doc = await p.documents(TEST_DOCUMENT_ID) entries_via_doc = await doc.history() ok( f"doc.history() property [{TEST_DOCUMENT_ID}]", f"count={len(entries_via_doc)}", ) except Exception as exc: fail("doc.history() property", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_document_ai_suggestions(p: PaperlessClient) -> None: _hdr("Document AI Suggestions – GET per-document") try: result = await p.documents.ai_suggestions(TEST_DOCUMENT_ID) ok( f"documents.ai_suggestions({TEST_DOCUMENT_ID})", f"title={result.title!r}, suggested_tags={result.suggested_tags}", ) try: doc = await p.documents(TEST_DOCUMENT_ID) result_via_doc = await doc.ai_suggestions(TEST_DOCUMENT_ID) ok( f"doc.ai_suggestions() property [{TEST_DOCUMENT_ID}]", f"title={result_via_doc.title!r}", ) except Exception as exc: fail("doc.ai_suggestions() property", exc) except Exception as exc: # LLM / AI backend may not be configured on the test instance – treat as non-fatal ok( f"documents.ai_suggestions({TEST_DOCUMENT_ID}) [skipped – LLM not configured]", f"error={exc}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_document_chat(p: PaperlessClient) -> None: _hdr("Document Chat – POST LLM query") try: result = await p.documents.chat("What is this document about?", TEST_DOCUMENT_ID) ok( f"documents.chat('...', {TEST_DOCUMENT_ID})", f"q={result.q!r}, document_id={result.document_id}", ) except Exception as exc: # LLM may not be configured on the test instance – treat as non-fatal ok( f"documents.chat('...', {TEST_DOCUMENT_ID}) [skipped – LLM not configured]", f"error={exc}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_document_root(p: PaperlessClient) -> None: _hdr("Document Root – GET per-document") try: result = await p.documents.root(TEST_DOCUMENT_ID) ok( f"documents.root({TEST_DOCUMENT_ID})", f"root_id={result.root_id!r}", ) try: doc = await p.documents(TEST_DOCUMENT_ID) result_via_doc = await doc.root() ok( f"doc.root() property [{TEST_DOCUMENT_ID}]", f"root_id={result_via_doc.root_id!r}", ) except Exception as exc: fail("doc.root() property", exc) except Exception as exc: fail(f"documents.root({TEST_DOCUMENT_ID})", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_document_versions(p: PaperlessClient) -> None: _hdr("Document Versions – update label (PATCH) per-document") try: # Fetch the document to find existing versions doc = await p.documents(TEST_DOCUMENT_ID) if doc.versions_ and len(doc.versions_) > 0: version_id = doc.versions_[0].id result = await p.documents.versions.update( version_id, version_label=None, pk=TEST_DOCUMENT_ID ) ok( f"documents.versions.update({version_id}, pk={TEST_DOCUMENT_ID})", f"version_label={result.version_label!r}, is_root={result.is_root}", ) else: ok( f"documents.versions.update [skipped – no versions on doc {TEST_DOCUMENT_ID}]", "no versions found", ) except Exception as exc: ok( f"documents.versions.update [skipped – error: {exc}]", f"error={exc}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_document_share_links(p: PaperlessClient) -> None: _hdr("Document Share Links – list via service and document property") links = await check( f"documents.share_links({TEST_DOCUMENT_ID})", p.documents.share_links(TEST_DOCUMENT_ID), detail_fn=lambda r: f"count={len(r)}", ) if links is not None: try: doc = await p.documents(TEST_DOCUMENT_ID) links_via_doc = await doc.share_links() ok( f"doc.share_links() property [{TEST_DOCUMENT_ID}]", f"count={len(links_via_doc)}", ) except Exception as exc: fail("doc.share_links() property", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_trash(p: PaperlessClient) -> None: _hdr("Trash – list deleted documents") await check( "trash.as_list()", p.trash.as_list(), detail_fn=lambda r: f"count={len(r)}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_bulk_edit_objects(p: PaperlessClient) -> None: _hdr("BulkEditObjects – set_permissions and delete") from pypaperless.models.types import BulkEditObjectType from pypaperless.models.mixins.securable import Permissions # Create a temporary tag to exercise bulk operations on from pypaperless.models.tags import TagDraft tmp_tag_id: int | None = None try: draft = TagDraft.from_data( p, { "name": "pypaperless-bulk-edit-smoke-test", "color": "#aabbcc", "is_inbox_tag": False, "match": "", "matching_algorithm": 0, "is_insensitive": True, }, ) tmp_tag_id = int(await p.tags.save(draft)) ok("tags.save(draft)", f"id={tmp_tag_id} (temporary tag for bulk test)") except Exception as exc: fail("tags.save(draft)", exc) if tmp_tag_id is not None: # set_permissions: change owner to user 1 try: await p.bulk_edit_objects.set_permissions( "tags", [tmp_tag_id], owner=1, permissions=Permissions(view_users=[], change_users=[]), ) ok("bulk_edit_objects.set_permissions()", f"tag_id={tmp_tag_id}") except Exception as exc: fail("bulk_edit_objects.set_permissions()", exc) # delete: permanently remove the temporary tag try: await p.bulk_edit_objects.delete("tags", [tmp_tag_id]) ok("bulk_edit_objects.delete()", f"tag_id={tmp_tag_id} deleted") except Exception as exc: fail("bulk_edit_objects.delete()", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_documents_bulk_edit(p: PaperlessClient) -> None: _hdr("DocumentsBulkEdit – set_permissions, reprocess, modify_tags") from pypaperless.models.mixins.securable import Permissions # set_permissions with merge=True — idempotent, safe on any document try: await p.documents.bulk_edit.set_permissions( [TEST_DOCUMENT_ID], owner=1, permissions=Permissions(view_users=[], change_users=[]), merge=True, ) ok("documents.bulk_edit.set_permissions()", f"doc_id={TEST_DOCUMENT_ID}") except Exception as exc: fail("documents.bulk_edit.set_permissions()", exc) # modify_tags with empty lists — no-op, verifies endpoint plumbing try: await p.documents.bulk_edit.modify_tags( [TEST_DOCUMENT_ID], add_tags=[], remove_tags=[], ) ok("documents.bulk_edit.modify_tags() empty", f"doc_id={TEST_DOCUMENT_ID}") except Exception as exc: fail("documents.bulk_edit.modify_tags() empty", exc) # reprocess — queues document for OCR, harmless. # NOTE: This is a PaperlessClient-ngx v10+ dedicated endpoint; skip gracefully on older servers. try: await p.documents.bulk_edit.reprocess([TEST_DOCUMENT_ID]) ok("documents.bulk_edit.reprocess()", f"doc_id={TEST_DOCUMENT_ID}") except Exception as exc: import httpx as _httpx if isinstance(exc, _httpx.HTTPStatusError) and exc.response.status_code == 405: ok( "documents.bulk_edit.reprocess() [skipped – server None: _hdr("Document Notes – list, create, delete") notes = await check( f"documents.notes({TEST_DOCUMENT_ID})", p.documents.notes(TEST_DOCUMENT_ID), detail_fn=lambda r: f"count={len(r)}", ) # create a note note_id = None try: draft: DocumentNoteDraft = p.documents.notes.create( TEST_DOCUMENT_ID, note="pypaperless smoke-test note" ) note_id = await _retry_flaky(lambda: p.documents.notes.save(draft)) ok("documents.notes.save(draft)", f"note_id={note_id}") except Exception as exc: fail("documents.notes.save()", exc) # delete the created note if note_id is not None: try: # force_request=True to bypass the cache and get the freshly created note all_notes = await p.documents.notes(TEST_DOCUMENT_ID, force_request=True) created = next((n for n in all_notes if n.id == note_id), None) if created is not None: deleted = await _retry_flaky(lambda: p.documents.notes.delete(created)) ok("documents.notes.delete(note)", f"deleted={deleted}") else: ok("documents.notes.delete(note)", "note not found – skipped") except Exception as exc: fail("documents.notes.delete()", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_custom_fields(p: PaperlessClient) -> None: _hdr("Custom Fields – list, cache, access on document") # Populate cache try: p.runtime.cache.custom_fields = await p.custom_fields.as_dict() ok("custom_fields.as_dict() → cache", f"count={len(p.runtime.cache.custom_fields)}") except Exception as exc: fail("custom_fields.as_dict()", exc) # list await check( "custom_fields.as_list()", p.custom_fields.as_list(), detail_fn=lambda r: f"count={len(r)}", ) # Access custom fields on the test document try: doc = await p.documents(TEST_DOCUMENT_ID) if doc.custom_fields is not None: field_count = sum(1 for _ in doc.custom_fields) ok(f"doc.custom_fields on doc {TEST_DOCUMENT_ID}", f"fields={field_count}") else: ok(f"doc.custom_fields on doc {TEST_DOCUMENT_ID}", "no custom fields") except Exception as exc: fail("doc.custom_fields access", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_custom_field_values_on_document(p: PaperlessClient) -> None: _hdr(f"Custom Field Values – all 9 types, modify & restore on doc {TEST_DOCUMENT_ID}") import datetime from pypaperless.models.custom_fields import ( CustomFieldBooleanValue, CustomFieldDateValue, CustomFieldDocumentLinkValue, CustomFieldFloatValue, CustomFieldIntegerValue, CustomFieldMonetaryValue, CustomFieldSelectValue, CustomFieldStringValue, CustomFieldURLValue, ) if not p.runtime.cache.custom_fields: p.runtime.cache.custom_fields = await p.custom_fields.as_dict() doc = await check( f"documents({TEST_DOCUMENT_ID}) fetch", p.documents(TEST_DOCUMENT_ID), detail_fn=lambda r: f"title={r.title!r}", ) if doc is None or doc.custom_fields is None: fail("custom_fields pre-condition", Exception("doc or custom_fields is None – aborting")) return cf = doc.custom_fields # DocumentCustomFieldList, not None # ── Snapshot originals ──────────────────────────────────────────────── originals: dict[int, object] = {cfv.field: cfv.value for cfv in cf} ok("custom_fields snapshot", f"count={len(originals)} ids={sorted(originals)}") # ── Helper: pick a different SELECT option from cache ───────────────── def _other_select_option(field_id: int, current: object) -> object: cached_cf = ( p.runtime.cache.custom_fields.get(field_id) if p.runtime.cache.custom_fields else None ) if cached_cf is None or cached_cf.extra_data is None: return current opts = [o for o in cached_cf.extra_data.select_options if o and o.id != current] return opts[0].id if opts else current # ══════════════════════════════════════════════════════════════════════ # MODIFY – one field per type, using typed getters # ══════════════════════════════════════════════════════════════════════ # BOOLEAN id=1 cfv1 = cf.get(1, CustomFieldBooleanValue) new_bool = not cfv1.value cfv1.value = new_bool ok("BOOLEAN modify id=1", f"{originals[1]!r} → {cfv1.value!r}") # DATE id=2 cfv2 = cf.get(2, CustomFieldDateValue) new_date = datetime.date(2026, 3, 11) cfv2.value = new_date ok("DATE modify id=2", f"{originals[2]!r} → {cfv2.value!r}") # INTEGER id=3 cfv3 = cf.get(3, CustomFieldIntegerValue) cfv3.value = 999 ok("INTEGER modify id=3", f"{originals[3]!r} → {cfv3.value!r}") # FLOAT id=4 cfv4 = cf.get(4, CustomFieldFloatValue) cfv4.value = 99.9999 ok("FLOAT modify id=4", f"{originals[4]!r} → {cfv4.value!r}") # MONETARY id=5 – via .currency / .amount setters cfv5 = cf.get(5, CustomFieldMonetaryValue) cfv5.currency = "USD" cfv5.amount = 1111.11 ok("MONETARY modify id=5 (EUR→USD, new amount)", f"{originals[5]!r} → {cfv5.value!r}") # MONETARY id=6 – amount only cfv6 = cf.get(6, CustomFieldMonetaryValue) cfv6.amount = 9876.54 ok("MONETARY modify id=6 (amount only)", f"{originals[6]!r} → {cfv6.value!r}") # MONETARY id=7 – currency + amount cfv7 = cf.get(7, CustomFieldMonetaryValue) cfv7.currency = "EUR" cfv7.amount = 500.00 ok("MONETARY modify id=7 (USD→EUR, new amount)", f"{originals[7]!r} → {cfv7.value!r}") # STRING id=8 cfv8 = cf.get(8, CustomFieldStringValue) cfv8.value = "pypaperless-smoke-modified" ok("STRING modify id=8", f"{originals[8]!r} → {cfv8.value!r}") # URL id=9 cfv9 = cf.get(9, CustomFieldURLValue) cfv9.value = "https://github.com/tb1337/paperless-api" ok("URL modify id=9", f"{originals[9]!r} → {cfv9.value!r}") # DOCUMENT_LINK id=10 cfv10 = cf.get(10, CustomFieldDocumentLinkValue) cfv10.value = [TEST_DOCUMENT_ID] ok("DOCLINK modify id=10", f"{originals[10]!r} → {cfv10.value!r}") # SELECT id=11 – pick a different option dynamically cfv11 = cf.get(11, CustomFieldSelectValue) new_sel = _other_select_option(11, cfv11.value) cfv11.value = new_sel ok("SELECT modify id=11", f"{originals[11]!r} → {cfv11.value!r} (label={cfv11.label!r})") # ══════════════════════════════════════════════════════════════════════ # ADD new fields (not yet on the test document) # ══════════════════════════════════════════════════════════════════════ NEW_FIELDS: list[tuple[int, object, type]] = [ (13, 777, CustomFieldIntegerValue), # INTEGER – Add Test Field (14, "EUR2500.00", CustomFieldMonetaryValue), # MONETARY – LA_Brutto (15, "EUR2000.00", CustomFieldMonetaryValue), # MONETARY – LA_Netto ] for nf_id, nf_val, nf_cls in NEW_FIELDS: try: cf += nf_cls(field=nf_id, value=nf_val) ok(f"add field id={nf_id} ({nf_cls.__name__})", f"value={nf_val!r}") except Exception as exc: fail(f"add field id={nf_id}", exc) # ── PATCH ────────────────────────────────────────────────────────────── await check( "documents.update() – all-type modifications + 3 new fields", p.documents.update(doc), detail_fn=lambda r: f"changed={r}", ) # ══════════════════════════════════════════════════════════════════════ # RE-FETCH + VERIFY with typed getters # ══════════════════════════════════════════════════════════════════════ doc2 = await p.documents(TEST_DOCUMENT_ID) if doc2 is None or doc2.custom_fields is None: fail("re-fetch after update", Exception("doc2 is None")) return cf2 = doc2.custom_fields def _verify(label: str, field_id: int, expected: object, cls: type) -> None: try: got = cf2.get(field_id, cls) if got.value == expected: ok(f"verify {label} id={field_id}", f"value={got.value!r}") else: fail( f"verify {label} id={field_id}", AssertionError(f"expected={expected!r}, got={got.value!r}"), ) except Exception as exc: fail(f"verify {label} id={field_id}", exc) _verify("BOOLEAN", 1, new_bool, CustomFieldBooleanValue) _verify("DATE", 2, new_date, CustomFieldDateValue) _verify("INTEGER", 3, 999, CustomFieldIntegerValue) _verify("FLOAT", 4, 99.9999, CustomFieldFloatValue) _verify("MONETARY", 5, cfv5.value, CustomFieldMonetaryValue) _verify("MONETARY", 6, cfv6.value, CustomFieldMonetaryValue) _verify("MONETARY", 7, cfv7.value, CustomFieldMonetaryValue) _verify("STRING", 8, "pypaperless-smoke-modified", CustomFieldStringValue) _verify("URL", 9, "https://github.com/tb1337/paperless-api", CustomFieldURLValue) _verify("DOCLINK", 10, [TEST_DOCUMENT_ID], CustomFieldDocumentLinkValue) _verify("SELECT", 11, new_sel, CustomFieldSelectValue) _verify("INTEGER (new)", 13, 777, CustomFieldIntegerValue) _verify("MONETARY (new)", 14, "EUR2500.00", CustomFieldMonetaryValue) _verify("MONETARY (new)", 15, "EUR2000.00", CustomFieldMonetaryValue) # ══════════════════════════════════════════════════════════════════════ # RESTORE originals + remove added fields # ══════════════════════════════════════════════════════════════════════ for field_id, orig_val in originals.items(): try: cf2.get(field_id).value = orig_val except Exception as exc: fail(f"restore field id={field_id}", exc) for nf_id, _, _ in NEW_FIELDS: try: cf2 -= nf_id ok(f"remove added id={nf_id}", "removed") except Exception as exc: fail(f"remove added id={nf_id}", exc) await check( "documents.update() – restore originals", p.documents.update(doc2), detail_fn=lambda r: f"changed={r}", ) # ── Final verify ─────────────────────────────────────────────────────── doc3 = await p.documents(TEST_DOCUMENT_ID) if doc3 is None or doc3.custom_fields is None: fail("final re-fetch", Exception("doc3 is None")) return cf3 = doc3.custom_fields all_restored = True for field_id, orig_val in originals.items(): cfv3 = cf3.default(field_id) if cfv3 is None or cfv3.value != orig_val: all_restored = False fail( f"final restore id={field_id}", AssertionError(f"expected={orig_val!r}, got={cfv3.value if cfv3 else None!r}"), ) if all_restored: ok("all original values restored", f"fields={len(originals)}") for nf_id, _, _ in NEW_FIELDS: if cf3.default(nf_id) is None: ok(f"added field id={nf_id} absent after restore", "not present") else: fail(f"added field id={nf_id} absent after restore", AssertionError("still present")) # ────────────────────────────────────────────────────────────────────────────── async def test_custom_field_query(p: PaperlessClient) -> None: _hdr("CustomFieldQuery – live filter") try: q_exists = CustomFieldQuery(1, "exists", True) async with p.documents.filter(page_size=PAGE_SIZE, custom_field_query=str(q_exists)): count = 0 async for _ in p.documents: count += 1 if count >= PAGE_SIZE: break ok("documents.filter(custom_field_query=exists)", f"iterated={count}") except Exception as exc: fail("documents.filter(custom_field_query=exists)", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_correspondents(p: PaperlessClient) -> None: _hdr("Correspondents – list, create, update, permissions, delete") await check( "correspondents.as_list()", p.correspondents.as_list(), detail_fn=lambda r: f"count={len(r)}", ) # create draft = CorrespondentDraft.from_data( p, { "name": "pypaperless Smoke Test Corp", "match": "", "matching_algorithm": 0, "is_insensitive": True, }, ) created_id: int | None = None try: created_id = int(await p.correspondents.save(draft)) ok("correspondents.save(draft)", f"id={created_id}") except Exception as exc: fail("correspondents.save(draft)", exc) if created_id is not None: # fetch back corr = await check( f"correspondents({created_id})", p.correspondents(created_id), detail_fn=lambda r: f"name={r.name!r}", ) # update if corr is not None: corr.name = "pypaperless Smoke Test Corp (updated)" updated = await check( "correspondents.update(corr)", p.correspondents.update(corr), detail_fn=lambda r: f"changed={r}", ) # dispatcher – update() and save() via client try: ar_draft = p.correspondents.create( name="pypaperless Dispatcher Corp", match="", matching_algorithm=0, is_insensitive=True, ) ar_id = int(await p.save(ar_draft)) ok("p.save(correspondent_draft)", f"id={ar_id} (dispatcher)") ar_corr = await p.correspondents(ar_id) ar_corr.name = "pypaperless Dispatcher Corp (updated)" ar_changed = await p.update(ar_corr) ok("p.update(correspondent)", f"changed={ar_changed} (dispatcher)") await p.delete(ar_corr) ok("p.delete(correspondent)", "deleted (dispatcher)") except Exception as exc: fail("dispatcher (correspondent)", exc) # permissions async with p.correspondents.with_permissions(): await check( f"correspondents({created_id}) with permissions", p.correspondents(created_id), detail_fn=lambda r: f"has_permissions={r.has_permissions}", ) # delete try: corr_to_delete = await p.correspondents(created_id) deleted = await p.correspondents.delete(corr_to_delete) ok(f"correspondents.delete({created_id})", f"deleted={deleted}") except Exception as exc: fail("correspondents.delete()", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_tags(p: PaperlessClient) -> None: _hdr("Tags – list, create, update, delete") await check("tags.as_list()", p.tags.as_list(), detail_fn=lambda r: f"count={len(r)}") draft = TagDraft.from_data( p, { "name": "pypaperless-smoke-test", "color": "#ff0000", "is_inbox_tag": False, "match": "", "matching_algorithm": 0, "is_insensitive": True, }, ) tag_id: int | None = None try: tag_id = int(await p.tags.save(draft)) ok("tags.save(draft)", f"id={tag_id}") except Exception as exc: fail("tags.save(draft)", exc) if tag_id is not None: tag = await check( f"tags({tag_id})", p.tags(tag_id), detail_fn=lambda r: f"name={r.name!r}", ) if tag is not None: tag.name = "pypaperless-smoke-test-updated" await check("tags.update(tag)", p.tags.update(tag), detail_fn=lambda r: f"changed={r}") try: t = await p.tags(tag_id) deleted = await p.tags.delete(t) ok(f"tags.delete({tag_id})", f"deleted={deleted}") except Exception as exc: fail("tags.delete()", exc) # ── Nested-Tag smoke-test ───────────────────────────────────────────── _hdr("Tags – nested children validator") from pypaperless.models.tags import Tag # Tag 17 ('Kfz') already has known children on the test system t17 = await check( "tags(17) – known tag with children", p.tags(17), detail_fn=lambda r: f"name={r.name!r}, children={[c.id for c in (r.children or [])]}", ) if t17 is not None: children17 = t17.children or [] if children17 and isinstance(children17[0], Tag): ok( "Tag.children contains Tag instances (tag 17)", f"count={len(children17)}, ids={[c.id for c in children17]}", ) else: fail( "Tag.children contains Tag instances (tag 17)", AssertionError(f"children={children17!r}"), ) # Also verify end-to-end with dynamically created parent/child pair parent_id: int | None = None child_id: int | None = None _parent_draft = TagDraft.from_data( p, { "name": "pypaperless-smoke-parent", "color": "#0000ff", "is_inbox_tag": False, "match": "", "matching_algorithm": 0, "is_insensitive": True, }, ) try: parent_id = int(await p.tags.save(_parent_draft)) ok("tags.save(parent_draft)", f"id={parent_id}") except Exception as exc: fail("tags.save(parent_draft)", exc) if parent_id is not None: _child_draft = TagDraft.from_data( p, { "name": "pypaperless-smoke-child", "color": "#00ff00", "is_inbox_tag": False, "match": "", "matching_algorithm": 0, "is_insensitive": True, "parent": parent_id, }, ) try: child_id = int(await p.tags.save(_child_draft)) ok("tags.save(child_draft)", f"id={child_id}, parent={parent_id}") except Exception as exc: fail("tags.save(child_draft)", exc) if parent_id is not None and child_id is not None: parent_tag = await check( f"tags({parent_id}) after child created", p.tags(parent_id), detail_fn=lambda r: f"children={[c.id for c in (r.children or [])]}", ) if parent_tag is not None: children = parent_tag.children or [] if children and isinstance(children[0], Tag) and children[0].id == child_id: ok( "dynamic Tag.children[0] is Tag with correct id", f"child.id={children[0].id}, child.name={children[0].name!r}", ) else: fail( "dynamic Tag.children[0] is Tag with correct id", AssertionError(f"children={children!r}"), ) # cleanup – child first (FK constraint), then parent for del_id, label in ((child_id, "child"), (parent_id, "parent")): try: t = await p.tags(del_id) deleted = await p.tags.delete(t) ok(f"tags.delete({label} id={del_id})", f"deleted={deleted}") except Exception as exc: fail(f"tags.delete({label} id={del_id})", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_document_types(p: PaperlessClient) -> None: _hdr("Document Types – list, create, update, delete") await check( "document_types.as_list()", p.document_types.as_list(), detail_fn=lambda r: f"count={len(r)}", ) draft = DocumentTypeDraft.from_data( p, { "name": "pypaperless Smoke Test Type", "match": "", "matching_algorithm": 0, "is_insensitive": True, }, ) dt_id: int | None = None try: dt_id = int(await p.document_types.save(draft)) ok("document_types.save(draft)", f"id={dt_id}") except Exception as exc: fail("document_types.save(draft)", exc) if dt_id is not None: dt = await check( f"document_types({dt_id})", p.document_types(dt_id), detail_fn=lambda r: f"name={r.name!r}", ) if dt is not None: dt.name = "pypaperless Smoke Test Type (updated)" await check("document_types.update(dt)", p.document_types.update(dt)) try: t = await p.document_types(dt_id) deleted = await p.document_types.delete(t) ok(f"document_types.delete({dt_id})", f"deleted={deleted}") except Exception as exc: fail("document_types.delete()", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_storage_paths(p: PaperlessClient) -> None: _hdr("Storage Paths – list, create, update, delete") await check( "storage_paths.as_list()", p.storage_paths.as_list(), detail_fn=lambda r: f"count={len(r)}", ) draft = StoragePathDraft.from_data( p, { "name": "pypaperless Smoke Test Path", "path": "smoke-test/{correspondent}/{title}", "match": "", "matching_algorithm": 0, "is_insensitive": True, }, ) sp_id: int | None = None try: sp_id = int(await p.storage_paths.save(draft)) ok("storage_paths.save(draft)", f"id={sp_id}") except Exception as exc: fail("storage_paths.save(draft)", exc) if sp_id is not None: sp = await check( f"storage_paths({sp_id})", p.storage_paths(sp_id), detail_fn=lambda r: f"name={r.name!r}", ) if sp is not None: sp.name = "pypaperless Smoke Test Path (updated)" await check("storage_paths.update(sp)", p.storage_paths.update(sp)) try: s = await p.storage_paths(sp_id) deleted = await p.storage_paths.delete(s) ok(f"storage_paths.delete({sp_id})", f"deleted={deleted}") except Exception as exc: fail("storage_paths.delete()", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_share_links(p: PaperlessClient) -> None: _hdr("Share Links – list, create, delete") await check( "share_links.as_list()", p.share_links.as_list(), detail_fn=lambda r: f"count={len(r)}", ) draft = ShareLinkDraft.from_data( p, { "document": TEST_DOCUMENT_ID, "file_version": ShareLinkFileVersion.ARCHIVE, "expiration": None, }, ) link_id: int | None = None try: link_id = int(await p.share_links.save(draft)) ok("share_links.save(draft)", f"id={link_id}") except Exception as exc: fail("share_links.save(draft)", exc) if link_id is not None: link = await check( f"share_links({link_id})", p.share_links(link_id), detail_fn=lambda r: f"slug={r.slug}", ) if link is not None: deleted = await check( f"share_links.delete({link_id})", p.share_links.delete(link), detail_fn=lambda r: f"deleted={r}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_share_link_bundles(p: PaperlessClient) -> None: _hdr("Share Link Bundles – list, create, rebuild, delete") await check( "share_link_bundles.as_list()", p.share_link_bundles.as_list(), detail_fn=lambda r: f"count={len(r)}", ) draft = ShareLinkBundleDraft.from_data( p, { "document_ids": [TEST_DOCUMENT_ID], "file_version": ShareLinkFileVersion.ARCHIVE, }, ) bundle_id: int | None = None try: bundle_id = int(await p.share_link_bundles.save(draft)) ok("share_link_bundles.save(draft)", f"id={bundle_id}") except Exception as exc: fail("share_link_bundles.save(draft)", exc) if bundle_id is not None: bundle = await check( f"share_link_bundles({bundle_id})", p.share_link_bundles(bundle_id), detail_fn=lambda r: f"status={r.status}, documents={r.documents}", ) # rebuild await check( f"share_link_bundles.rebuild({bundle_id})", p.share_link_bundles.rebuild(bundle_id), detail_fn=lambda r: f"status={r.status}", ) # delete if bundle is not None: await check( f"share_link_bundles.delete({bundle_id})", p.share_link_bundles.delete(bundle), detail_fn=lambda r: f"deleted={r}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_saved_views(p: PaperlessClient) -> None: _hdr("Saved Views – list, fetch") views = await check( "saved_views.as_list()", p.saved_views.as_list(), detail_fn=lambda r: f"count={len(r)}", ) if views: await check( f"saved_views({views[0].id})", p.saved_views(views[0].id), detail_fn=lambda r: f"name={r.name!r}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_users_groups(p: PaperlessClient) -> None: _hdr("Users & Groups – list, fetch") users = await check( "users.as_list()", p.users.as_list(), detail_fn=lambda r: f"count={len(r)}", ) if users: await check( f"users({users[0].id})", p.users(users[0].id), detail_fn=lambda r: f"username={r.username!r}", ) groups = await check( "groups.as_list()", p.groups.as_list(), detail_fn=lambda r: f"count={len(r)}", ) if groups: await check( f"groups({groups[0].id})", p.groups(groups[0].id), detail_fn=lambda r: f"name={r.name!r}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_tasks(p: PaperlessClient) -> None: _hdr("Tasks – iterate, fetch by uuid, active, summary, status_counts") tasks = [] try: async for task in p.tasks: tasks.append(task) ok("tasks async for", f"count={len(tasks)}") except Exception as exc: fail("tasks async for", exc) if tasks: first = tasks[0] if first.task_id: await check( f"tasks(task_id={first.task_id!r})", p.tasks(first.task_id), detail_fn=lambda r: f"status={r.status}", ) if first.id: await check( f"tasks(pk={first.id})", p.tasks(first.id), detail_fn=lambda r: f"status={r.status}", ) active_tasks = [] try: async for task in p.tasks.active(): active_tasks.append(task) ok("tasks.active()", f"count={len(active_tasks)}") except Exception as exc: fail("tasks.active()", exc) await check( "tasks.summary(days=7)", p.tasks.summary(days=7), detail_fn=lambda r: f"types={len(r)}", ) await check( "tasks.status_counts()", p.tasks.status_counts(), detail_fn=lambda r: f"all={r.all} needs_attention={r.needs_attention}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_mail(p: PaperlessClient) -> None: _hdr("Mail Accounts / Mail Rules / Processed Mail") await check( "mail_accounts.as_list()", p.mail_accounts.as_list(), detail_fn=lambda r: f"count={len(r)}", ) await check( "mail_rules.as_list()", p.mail_rules.as_list(), detail_fn=lambda r: f"count={len(r)}", ) await check( "processed_mail.as_list()", p.processed_mail.as_list(), detail_fn=lambda r: f"count={len(r)}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_workflows(p: PaperlessClient) -> None: _hdr("Workflows / Actions / Triggers") await check( "workflows.as_list()", p.workflows.as_list(), detail_fn=lambda r: f"count={len(r)}", ) await check( "workflows.actions.as_list()", p.workflows.actions.as_list(), detail_fn=lambda r: f"count={len(r)}", ) await check( "workflows.triggers.as_list()", p.workflows.triggers.as_list(), detail_fn=lambda r: f"count={len(r)}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_document_post(p: PaperlessClient) -> None: _hdr("Document POST – upload a minimal PDF") token = str(uuid.uuid4()) draft = DocumentDraft.from_data( p, { "document": _make_unique_pdf(token), "filename": "smoke_test.pdf", "title": f"pypaperless Smoke Test Upload [{token[:8]}]", }, ) task_id: str | None = None try: task_id = str(await p.documents.save(draft)) ok("documents.save(draft) – post_document", f"task_id={task_id!r}") except Exception as exc: fail("documents.save(draft) – post_document", exc) if task_id is not None: await _await_task_and_cleanup(p, task_id, "post_document") # ────────────────────────────────────────────────────────────────────────────── async def test_document_post_with_cf_mapping(p: PaperlessClient) -> None: _hdr("Document POST – upload with custom_fields object mapping") # Ensure custom field cache is populated if not p.runtime.cache.custom_fields: try: p.runtime.cache.custom_fields = await p.custom_fields.as_dict() except Exception as exc: fail("custom_fields cache (pre-condition)", exc) return # Variant A: list[int] – assign field IDs only (empty values) token_a = str(uuid.uuid4()) draft_a = DocumentDraft.from_data( p, { "document": _make_unique_pdf(token_a), "filename": "smoke_cf_ids.pdf", "title": f"pypaperless CF id-list test [{token_a[:8]}]", "custom_fields": [8, 3], }, ) task_id_a: str | None = None try: task_id_a = str(await p.documents.save(draft_a)) ok("documents.save(draft) – custom_fields=list[int]", f"task_id={task_id_a!r}") except Exception as exc: fail("documents.save(draft) – custom_fields=list[int]", exc) # Variant B: DocumentCustomFieldList – object mapping with typed values token_b = str(uuid.uuid4()) cf = DocumentCustomFieldList.from_data(p.runtime, []) cf += CustomFieldStringValue(field=8, value="pypaperless-smoke") cf += CustomFieldIntegerValue(field=3, value=1) draft_b = DocumentDraft.from_data( p, { "document": _make_unique_pdf(token_b), "filename": "smoke_cf_mapping.pdf", "title": f"pypaperless CF object-mapping test [{token_b[:8]}]", }, ) draft_b.custom_fields = cf task_id_b: str | None = None try: task_id_b = str(await p.documents.save(draft_b)) ok( "documents.save(draft) – custom_fields=DocumentCustomFieldList", f"task_id={task_id_b!r}", ) except Exception as exc: fail("documents.save(draft) – custom_fields=DocumentCustomFieldList", exc) # Clean up both uploaded documents if task_id_a is not None: await _await_task_and_cleanup(p, task_id_a, "cf-id-list") if task_id_b is not None: await _await_task_and_cleanup(p, task_id_b, "cf-mapping") # ────────────────────────────────────────────────────────────────────────────── async def test_filter_context(p: PaperlessClient) -> None: _hdr("filter() context manager – filter & iterate") try: async with p.documents.filter(page_size=PAGE_SIZE, title__icontains="a"): count = 0 async for _ in p.documents: count += 1 if count >= PAGE_SIZE: break ok(f"documents.filter(page_size={PAGE_SIZE}, title__icontains='a')", f"iterated={count}") except Exception as exc: fail("documents.filter()", exc) try: async with p.correspondents.filter(name__icontains="a"): count = 0 async for _ in p.correspondents: count += 1 if count >= PAGE_SIZE: break ok("correspondents.filter(name__icontains='a')", f"iterated={count}") except Exception as exc: fail("correspondents.filter()", exc) try: async with p.tags.filter(is_root=True): count = 0 async for _ in p.tags: count += 1 if count >= PAGE_SIZE: break ok("tags.filter(is_root=True)", f"iterated={count}") except Exception as exc: fail("tags.filter()", exc) try: async with p.processed_mail.filter(status="SUCCESS"): count = 0 async for _ in p.processed_mail: count += 1 if count >= PAGE_SIZE: break ok("processed_mail.filter(status='SUCCESS')", f"iterated={count}") except Exception as exc: fail("processed_mail.filter()", exc) try: async with p.tasks.filter(name="consume"): count = 0 async for _ in p.tasks: count += 1 if count >= PAGE_SIZE: break ok("tasks.filter(name='consume')", f"iterated={count}") except Exception as exc: fail("tasks.filter()", exc) # ────────────────────────────────────────────────────────────────────────────── async def test_profile(p: PaperlessClient) -> None: _hdr("Profile – fetch and update") profile = await check( "profile()", p.profile(), detail_fn=lambda r: f"email={r.email!r}, mfa={r.is_mfa_enabled}", ) if profile is not None: await check( "profile.update(first_name=profile.first_name)", p.profile.update(first_name=profile.first_name), detail_fn=lambda r: f"first_name={r.first_name!r}", ) # ────────────────────────────────────────────────────────────────────────────── async def test_generate_api_token() -> None: _hdr("Static helper – generate_api_token (expected failure on bad creds)") try: await PaperlessClient.generate_api_token( PAPERLESS_URL, "wrong_user", "wrong_password", client=httpx.AsyncClient(verify=False), ) fail("generate_api_token(bad creds) should have raised", RuntimeError("no exception")) except Exception: ok("generate_api_token(bad creds) → exception as expected") # ────────────────────────────────────────────────────────────────────────────── async def main(*, with_ai: bool = False) -> int: paperless = PaperlessClient( PAPERLESS_URL, PAPERLESS_TOKEN, client=httpx.AsyncClient(verify=False), ) print(f"\n{BOLD}pypaperless API smoke-test{RESET}") print(f"Target: {PAPERLESS_URL} | Document under test: #{TEST_DOCUMENT_ID}") if not with_ai: print(f"{BOLD}AI tests skipped{RESET} (pass --with-ai to enable)") async with paperless: await test_system(paperless) await test_search(paperless) await test_profile(paperless) await test_config(paperless) await test_documents(paperless) await test_document_history(paperless) if with_ai: await test_document_ai_suggestions(paperless) await test_document_chat(paperless) await test_document_root(paperless) await test_document_versions(paperless) await test_document_share_links(paperless) await test_trash(paperless) await test_bulk_edit_objects(paperless) await test_documents_bulk_edit(paperless) await test_document_notes(paperless) await test_custom_fields(paperless) await test_custom_field_values_on_document(paperless) await test_custom_field_query(paperless) await test_correspondents(paperless) await test_tags(paperless) await test_document_types(paperless) await test_storage_paths(paperless) await test_share_links(paperless) await test_share_link_bundles(paperless) await test_saved_views(paperless) await test_users_groups(paperless) await test_tasks(paperless) await test_mail(paperless) await test_workflows(paperless) await test_document_post(paperless) await test_document_post_with_cf_mapping(paperless) await test_filter_context(paperless) await test_generate_api_token() # ── Summary ─────────────────────────────────────────────────────────────── total = len(_passed) + len(_failed) print(f"\n{BOLD}{'═' * 60}{RESET}") print( f"{BOLD} Results: {GREEN}{len(_passed)} passed{RESET} / {RED}{len(_failed)} failed{RESET} (total {total}){RESET}" ) if _failed: print(f"\n{RED}Failed checks:{RESET}") for label, msg in _failed: print(f" • {label}") print(f" {RED}{msg}{RESET}") print(f"{BOLD}{'═' * 60}{RESET}\n") return 1 if _failed else 0 if __name__ == "__main__": _parser = argparse.ArgumentParser(description="pypaperless API smoke-test") _parser.add_argument( "--with-ai", action="store_true", default=False, help="Run AI-related tests (ai_suggestions, chat). Disabled by default.", ) _args = _parser.parse_args() raise SystemExit(asyncio.run(main(with_ai=_args.with_ai))) tb1337-pypaperless-5d67d60/script/ruff.toml000066400000000000000000000000611524076126400205120ustar00rootroot00000000000000extend = "../pyproject.toml" [lint] select = [] tb1337-pypaperless-5d67d60/script/setup000077500000000000000000000005421524076126400177450ustar00rootroot00000000000000#!/usr/bin/env bash # Setups the repository. # Stop on errors set -e cd "$(dirname "$0")/.." if [ ! -n "$VIRTUAL_ENV" ]; then if [ -x "$(command -v uv)" ]; then uv venv .venv else python3 -m venv .venv fi source .venv/bin/activate fi if ! [ -x "$(command -v uv)" ]; then python3 -m pip install uv fi script/bootstrap prek install tb1337-pypaperless-5d67d60/tests/000077500000000000000000000000001524076126400165145ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/tests/__init__.py000066400000000000000000000000351524076126400206230ustar00rootroot00000000000000"""Tests for pypaperless.""" tb1337-pypaperless-5d67d60/tests/conftest.py000066400000000000000000000024201524076126400207110ustar00rootroot00000000000000"""Setup pytest.""" from collections.abc import AsyncGenerator from typing import Any import pytest from pytest_httpx import HTTPXMock from pypaperless import PaperlessClient from pypaperless.const import EndpointPath from .const import ( PAPERLESS_TEST_API_VERSION, PAPERLESS_TEST_TOKEN, PAPERLESS_TEST_URL, PAPERLESS_TEST_VERSION, ) from .data import DATA_SCHEMA @pytest.fixture(name="api") async def api_obj_fixture() -> AsyncGenerator[PaperlessClient, Any]: """Return an uninitialized PaperlessClient and close its httpx client afterwards.""" api = PaperlessClient( PAPERLESS_TEST_URL, PAPERLESS_TEST_TOKEN, ) try: yield api finally: await api.close() @pytest.fixture(name="paperless") async def paperless_fixture( httpx_mock: HTTPXMock, api: PaperlessClient, ) -> AsyncGenerator[PaperlessClient, Any]: """Return a PaperlessClient object with given version.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}{EndpointPath.INDEX}", method="GET", status_code=200, json=DATA_SCHEMA, headers={ "x-api-version": str(PAPERLESS_TEST_API_VERSION), "x-version": PAPERLESS_TEST_VERSION, }, ) async with api: yield api tb1337-pypaperless-5d67d60/tests/const.py000066400000000000000000000004261524076126400202160ustar00rootroot00000000000000"""Test constants.""" PAPERLESS_TEST_URL = "https://paperless.not-existing.internal" PAPERLESS_TEST_TOKEN = "abcdef0123456789" PAPERLESS_TEST_USER = "test-user" PAPERLESS_TEST_PASSWORD = "not-so-secret-password" PAPERLESS_TEST_API_VERSION = 9 PAPERLESS_TEST_VERSION = "2.18.4" tb1337-pypaperless-5d67d60/tests/data/000077500000000000000000000000001524076126400174255ustar00rootroot00000000000000tb1337-pypaperless-5d67d60/tests/data/__init__.py000066400000000000000000000062641524076126400215460ustar00rootroot00000000000000"""Raw data constants.""" import json from pathlib import Path from .bulk_edit_objects import DATA_BULK_EDIT_OBJECTS from .config import DATA_CONFIG from .correspondents import DATA_CORRESPONDENTS from .custom_fields import DATA_CUSTOM_FIELDS from .document_ai_suggestions import DATA_DOCUMENT_AI_SUGGESTIONS from .document_chat import DATA_DOCUMENT_CHAT from .document_history import DATA_DOCUMENT_HISTORY from .document_metadata import DATA_DOCUMENT_METADATA from .document_notes import DATA_DOCUMENT_NOTES from .document_root import DATA_DOCUMENT_ROOT from .document_share_links import DATA_DOCUMENT_SHARE_LINKS from .document_suggestions import DATA_DOCUMENT_SUGGESTIONS from .document_types import DATA_DOCUMENT_TYPES from .document_version_info import DATA_DOCUMENT_VERSION_INFO from .documents import DATA_DOCUMENTS from .documents_bulk_edit import DATA_DOCUMENTS_BULK_EDIT from .documents_search import DATA_DOCUMENTS_SEARCH from .groups import DATA_GROUPS from .mails import DATA_MAIL_ACCOUNTS, DATA_MAIL_RULES, DATA_PROCESSED_MAIL from .object_permissions import DATA_OBJECT_PERMISSIONS from .paths import DATA_PATHS from .profile import DATA_PROFILE from .remote_version import DATA_REMOTE_VERSION from .saved_views import DATA_SAVED_VIEWS from .search import DATA_SEARCH from .search_autocomplete import DATA_SEARCH_AUTOCOMPLETE from .share_link_bundles import DATA_SHARE_LINK_BUNDLES from .share_links import DATA_SHARE_LINKS from .statistics import DATA_STATISTICS from .status import DATA_STATUS from .storage_paths import DATA_STORAGE_PATHS from .tags import DATA_TAGS from .tasks import ( DATA_TASKS, DATA_TASKS_ACTIVE, DATA_TASKS_STATUS_COUNTS, DATA_TASKS_SUMMARY, ) from .token import DATA_TOKEN from .trash import DATA_TRASH from .users import DATA_USERS from .workflows import DATA_WORKFLOW_ACTIONS, DATA_WORKFLOW_TRIGGERS, DATA_WORKFLOWS def _read_schema() -> dict: filepath = Path(__file__).parent / "schema.json" with Path.open(filepath, mode="r", encoding="utf-8") as file: return json.load(file) DATA_SCHEMA = _read_schema() __all__ = ( "DATA_BULK_EDIT_OBJECTS", "DATA_CONFIG", "DATA_CORRESPONDENTS", "DATA_CUSTOM_FIELDS", "DATA_DOCUMENTS", "DATA_DOCUMENTS_BULK_EDIT", "DATA_DOCUMENTS_SEARCH", "DATA_DOCUMENT_AI_SUGGESTIONS", "DATA_DOCUMENT_CHAT", "DATA_DOCUMENT_HISTORY", "DATA_DOCUMENT_METADATA", "DATA_DOCUMENT_NOTES", "DATA_DOCUMENT_ROOT", "DATA_DOCUMENT_SHARE_LINKS", "DATA_DOCUMENT_SUGGESTIONS", "DATA_DOCUMENT_TYPES", "DATA_DOCUMENT_VERSION_INFO", "DATA_GROUPS", "DATA_MAIL_ACCOUNTS", "DATA_MAIL_RULES", "DATA_OBJECT_PERMISSIONS", "DATA_PATHS", "DATA_PROCESSED_MAIL", "DATA_PROFILE", "DATA_REMOTE_VERSION", "DATA_SAVED_VIEWS", "DATA_SCHEMA", "DATA_SEARCH", "DATA_SEARCH_AUTOCOMPLETE", "DATA_SHARE_LINKS", "DATA_SHARE_LINK_BUNDLES", "DATA_STATISTICS", "DATA_STATUS", "DATA_STORAGE_PATHS", "DATA_TAGS", "DATA_TASKS", "DATA_TASKS_ACTIVE", "DATA_TASKS_STATUS_COUNTS", "DATA_TASKS_SUMMARY", "DATA_TOKEN", "DATA_TRASH", "DATA_USERS", "DATA_WORKFLOWS", "DATA_WORKFLOW_ACTIONS", "DATA_WORKFLOW_TRIGGERS", ) tb1337-pypaperless-5d67d60/tests/data/bulk_edit_objects.py000066400000000000000000000001241524076126400234470ustar00rootroot00000000000000"""BulkEditObjects response fixtures.""" DATA_BULK_EDIT_OBJECTS = {"result": "OK"} tb1337-pypaperless-5d67d60/tests/data/config.py000066400000000000000000000010051524076126400212400ustar00rootroot00000000000000"""Config snapshot.""" DATA_CONFIG = [ { "id": 1, "user_args": None, "output_type": "pdf", "pages": None, "language": "eng", "mode": None, "skip_archive_file": None, "image_dpi": None, "unpaper_clean": None, "deskew": None, "rotate_pages": None, "rotate_pages_threshold": None, "max_image_pixels": None, "color_conversion_strategy": None, "app_title": None, "app_logo": None, } ] tb1337-pypaperless-5d67d60/tests/data/correspondents.py000066400000000000000000000040441524076126400230510ustar00rootroot00000000000000"""Correspondents snapshot.""" DATA_CORRESPONDENTS = { "count": 5, "next": None, "previous": None, "results": [ { "id": 1, "slug": "sample-correspondent", "name": "Sample Correspondent", "match": "", "matching_algorithm": 1, "is_insensitive": True, "document_count": 12, "last_correspondence": "2022-10-18", "owner": None, "user_can_change": True, }, { "id": 2, "slug": "burger-fastfood-delivery-billing", "name": "Burger FastFood Delivery Billing", "match": "", "matching_algorithm": 1, "is_insensitive": True, "document_count": 6, "last_correspondence": "2006-03-07", "owner": None, "user_can_change": True, }, { "id": 3, "slug": "example-company", "name": "Example Company", "match": "", "matching_algorithm": 6, "is_insensitive": True, "document_count": 3, "last_correspondence": "2022-03-11", "owner": 3, "user_can_change": True, }, { "id": 4, "slug": "no-illness-more-money-health-insurance", "name": "No Illness More Money Health Insurance", "match": "", "matching_algorithm": 1, "is_insensitive": True, "document_count": 6, "last_correspondence": "2000-07-18", "owner": None, "user_can_change": True, }, { "id": 5, "slug": "your-cash-is-my-cash-bank", "name": "Your Cash is my Cash Bank", "match": "Your Cash My Bank", "matching_algorithm": 2, "is_insensitive": True, "document_count": 20, "last_correspondence": "2023-08-28", "owner": None, "user_can_change": True, }, ], } tb1337-pypaperless-5d67d60/tests/data/custom_fields.py000066400000000000000000000012441524076126400226400ustar00rootroot00000000000000"""Custom fields snapshot.""" DATA_CUSTOM_FIELDS = { "count": 8, "next": None, "previous": None, "results": [ {"id": 8, "name": "Custom Link", "data_type": "documentlink"}, {"id": 7, "name": "Custom URL", "data_type": "url"}, {"id": 6, "name": "Custom Text -added-", "data_type": "string"}, {"id": 5, "name": "Custom MONEYY $$$", "data_type": "monetary"}, {"id": 4, "name": "Custom Floating", "data_type": "float"}, {"id": 3, "name": "Custom Int", "data_type": "integer"}, {"id": 2, "name": "Custom Date", "data_type": "date"}, {"id": 1, "name": "Custom Bool", "data_type": "boolean"}, ], } tb1337-pypaperless-5d67d60/tests/data/document_ai_suggestions.py000066400000000000000000000006571524076126400247300ustar00rootroot00000000000000"""Document AI suggestions snapshot.""" DATA_DOCUMENT_AI_SUGGESTIONS = { "title": "Invoice from ACME Corp", "correspondents": [1], "suggested_correspondents": ["ACME Corp"], "tags": [2, 3], "suggested_tags": ["important", "invoice"], "document_types": [1], "suggested_document_types": ["Invoice"], "storage_paths": [1], "suggested_storage_paths": ["/invoices/"], "dates": ["2024-01-15"], } tb1337-pypaperless-5d67d60/tests/data/document_chat.py000066400000000000000000000001671524076126400226200ustar00rootroot00000000000000"""Document chat snapshot.""" DATA_DOCUMENT_CHAT = { "q": "What is this document about?", "document_id": 1, } tb1337-pypaperless-5d67d60/tests/data/document_history.py000066400000000000000000000017671524076126400234110ustar00rootroot00000000000000"""Document history snapshot.""" DATA_DOCUMENT_HISTORY = [ { "id": 10, "timestamp": "2024-01-15T10:30:00Z", "action": "update", "changes": { "title": ["Old Title", "New Title"], }, "actor": { "id": 1, "username": "admin", }, }, { "id": 9, "timestamp": "2024-01-14T09:00:00Z", "action": "update", "changes": { "tags": { "type": "m2m", "operation": "add", "objects": ["invoices", "2024"], }, }, "actor": { "id": 1, "username": "admin", }, }, { "id": 1, "timestamp": "2024-01-01T08:00:00Z", "action": "create", "changes": { "id": ["None", "42"], "content": ["None", "Document content here."], }, "actor": { "id": 1, "username": "admin", }, }, ] tb1337-pypaperless-5d67d60/tests/data/document_metadata.py000066400000000000000000000047651524076126400234710ustar00rootroot00000000000000"""Document metadata snapshot.""" DATA_DOCUMENT_METADATA = { "original_checksum": "18e2352cc13379d19bd9ce329428bb99", "original_size": 190348, "original_mime_type": "application/pdf", "media_filename": "xxx.pdf", "has_archive_version": True, "original_metadata": [], "archive_checksum": "8a69542583571337dd263f8bb3cf23bd", "archive_media_filename": "0000226.pdf", "original_filename": None, "lang": "de", "archive_size": 161371, "archive_metadata": [ { "namespace": "http://ns.adobe.com/pdf/1.3/", "prefix": "pdf", "key": "Producer", "value": "pikepdf 2.16.1", }, { "namespace": "http://ns.adobe.com/xap/1.0/", "prefix": "xmp", "key": "ModifyDate", "value": "2022-02-12T11:34:40+00:00", }, { "namespace": "http://ns.adobe.com/xap/1.0/", "prefix": "xmp", "key": "CreateDate", "value": "2022-01-07T13:40:49", }, { "namespace": "http://ns.adobe.com/xap/1.0/", "prefix": "xmp", "key": "CreatorTool", "value": "ocrmypdf 12.3.2 / Tesseract OCR-PDF 4.1.1", }, { "namespace": "http://ns.adobe.com/xap/1.0/mm/", "prefix": "xmpMM", "key": "DocumentID", "value": "uuid:13371337-2342-1337-4242-133723426665", }, { "namespace": "http://purl.org/dc/elements/1.1/", "prefix": "dc", "key": "format", "value": "application/pdf", }, { "namespace": "http://purl.org/dc/elements/1.1/", "prefix": "dc", "key": "title", "value": "Octopus Energy updated", }, { "namespace": "http://www.aiim.org/pdfa/ns/id/", "prefix": "pdfaid", "key": "part", "value": "2", }, { "namespace": "http://www.aiim.org/pdfa/ns/id/", "prefix": "pdfaid", "key": "conformance", "value": "B", }, { "namespace": "http://purl.org/dc/elements/1.1/", "prefix": "dc", "key": "creator", "value": "None", }, { "namespace": "http://ns.adobe.com/xap/1.0/", "prefix": "xmp", "key": "MetadataDate", "value": "2022-02-12T11:34:40.383718+00:00", }, ], } tb1337-pypaperless-5d67d60/tests/data/document_notes.py000066400000000000000000000015441524076126400230310ustar00rootroot00000000000000"""Document notes snapshot.""" DATA_DOCUMENT_NOTES = [ { "id": 1, "note": "Sample note 1.", "created": "2023-12-21T18:08:11.481206+00:00", "user": { "id": 1, "username": "test", "first_name": "Peter", "last_name": "Patch", }, }, { "id": 2, "note": "Sample note 2.", "created": "2023-12-21T08:26:33.260968+00:00", "user": { "id": 2, "username": "test", "first_name": "Peter", "last_name": "Patch", }, }, { "id": 3, "note": "Sample note 3.", "created": "2023-12-21T08:26:31.782811+00:00", "user": { "id": 3, "username": "test", "first_name": "Peter", "last_name": "Patch", }, }, ] tb1337-pypaperless-5d67d60/tests/data/document_root.py000066400000000000000000000001361524076126400226600ustar00rootroot00000000000000"""Test fixture for DocumentRoot responses.""" DATA_DOCUMENT_ROOT = { "root_id": 1980, } tb1337-pypaperless-5d67d60/tests/data/document_share_links.py000066400000000000000000000007021524076126400241760ustar00rootroot00000000000000"""Document share links snapshot.""" DATA_DOCUMENT_SHARE_LINKS = [ { "id": 1, "created": "2023-12-11T14:06:49.096456Z", "expiration": "2023-12-18T14:06:49.064000Z", "slug": "GMIFR9WVPe7a0FAltmrAdmVsrrTzH6Z9yFi2jufhi5yCTAMWfF", }, { "id": 3, "created": "2023-12-11T14:06:55.984583Z", "expiration": None, "slug": "bDnxeQ4UmlFVUYCDrb1KBLbE4HVSW8jw3CLElcwPyAncV5eiI", }, ] tb1337-pypaperless-5d67d60/tests/data/document_suggestions.py000066400000000000000000000004511524076126400242470ustar00rootroot00000000000000"""Document suggestions snapshot.""" DATA_DOCUMENT_SUGGESTIONS = { "correspondents": [26], "tags": [ 1, 2, 3, ], "document_types": [4], "storage_paths": [ 3, 5, ], "dates": [ "2022-01-07", "2023-01-07", ], } tb1337-pypaperless-5d67d60/tests/data/document_types.py000066400000000000000000000031741524076126400230460ustar00rootroot00000000000000"""Document types snapshot.""" DATA_DOCUMENT_TYPES = { "count": 5, "next": None, "previous": None, "results": [ { "id": 1, "slug": "bescheid", "name": "Bescheid", "match": "", "matching_algorithm": 1, "is_insensitive": True, "document_count": 42, "owner": None, "user_can_change": True, }, { "id": 2, "slug": "document", "name": "Document", "match": "", "matching_algorithm": 1, "is_insensitive": True, "document_count": 218, "owner": None, "user_can_change": None, }, { "id": 3, "slug": "kontoauszug", "name": "Kontoauszug", "match": "", "matching_algorithm": 6, "is_insensitive": True, "document_count": 227, "owner": None, "user_can_change": None, }, { "id": 4, "slug": "kundigung", "name": "Kuendigung", "match": "", "matching_algorithm": 1, "is_insensitive": True, "document_count": 24, "owner": None, "user_can_change": True, }, { "id": 5, "slug": "rechnung", "name": "Rechnung", "match": "", "matching_algorithm": 6, "is_insensitive": True, "document_count": 246, "owner": None, "user_can_change": True, }, ], } tb1337-pypaperless-5d67d60/tests/data/document_version_info.py000066400000000000000000000003321524076126400243730ustar00rootroot00000000000000"""Test fixture for DocumentVersionInfo responses.""" DATA_DOCUMENT_VERSION_INFO = { "id": 1, "added": "2024-01-15T10:00:00Z", "version_label": "v1", "checksum": "abc123def456", "is_root": True, } tb1337-pypaperless-5d67d60/tests/data/documents.py000066400000000000000000000062151524076126400220040ustar00rootroot00000000000000"""Documents snapshot.""" DATA_DOCUMENTS = { "count": 2, "next": None, "previous": "", "results": [ { "id": 1, "correspondent": 1, "document_type": 2, "storage_path": None, "title": "Crazy Document", "content": "some OCRd text", "tags": [], "created": "2011-06-22", "modified": "2023-08-08T06:06:35.495972+00:00", "added": "2023-06-30T05:44:14.317925+00:00", "archive_serial_number": None, "original_file_name": "Scan_2023-06-29_113857.pdf", "archived_file_name": "2011-06-22 filename.pdf", "owner": 2, "user_can_change": True, "notes": [], "custom_fields": [], }, { "id": 2, "correspondent": 2, "document_type": 1, "storage_path": 1, "title": "Salty Document", "content": "OCRd text from document", "tags": [1], "created": "2022-01-07", "modified": "2023-12-13T16:15:02.148852+00:00", "added": "2022-02-12T11:34:50.072000+00:00", "archive_serial_number": 1, "original_file_name": None, "archived_file_name": "2022-01-07.pdf", "owner": 1, "user_can_change": True, "notes": [ { "id": 1, "note": "Sample note 1.", "created": "2023-12-21T18:08:11.481206+00:00", "document": 2, "user": { "id": 1, "username": "test", "first_name": "Peter", "last_name": "Patch", }, }, { "id": 2, "note": "Sample note 2.", "created": "2023-12-21T08:26:33.260968+00:00", "document": 2, "user": { "id": 2, "username": "test", "first_name": "Peter", "last_name": "Patch", }, }, { "id": 3, "note": "Sample note 3.", "created": "2023-12-21T08:26:31.782811+00:00", "document": 2, "user": { "id": 3, "username": "test", "first_name": "Peter", "last_name": "Patch", }, }, ], "custom_fields": [ {"value": [1094, 944], "field": 8}, {"value": "https://www.example.com", "field": 7}, {"value": "This is a text", "field": 6}, {"value": 1000.0, "field": 5}, {"value": 13.37, "field": 4}, {"value": 42, "field": 3}, {"value": "2099-12-31", "field": 2}, {"value": True, "field": 1}, ], }, ], } tb1337-pypaperless-5d67d60/tests/data/documents_bulk_edit.py000066400000000000000000000001341524076126400240200ustar00rootroot00000000000000"""Test data for `DocumentBulkEditService`.""" DATA_DOCUMENTS_BULK_EDIT = {"result": "OK"} tb1337-pypaperless-5d67d60/tests/data/documents_search.py000066400000000000000000000020631524076126400233260ustar00rootroot00000000000000"""Documents search snapshot.""" DATA_DOCUMENTS_SEARCH = { "count": 1, "next": None, "previous": None, "results": [ { "id": 1, "correspondent": 1, "document_type": 2, "storage_path": None, "title": "Crazy Document", "content": "some OCRd text", "tags": [], "created": "2011-06-22T00:00:00+00:00", "created_date": "2011-06-22", "modified": "2023-08-08T06:06:35.495972+00:00", "added": "2023-06-30T05:44:14.317925+00:00", "archive_serial_number": None, "original_file_name": "Scan_2023-06-29_113857.pdf", "archived_file_name": "2011-06-22 filename.pdf", "owner": 2, "user_can_change": True, "notes": [], "custom_fields": [], "__search_hit__": { "score": 1.0, "highlights": "some neat hint", "note_highlights": "", "rank": 0, }, }, ], } tb1337-pypaperless-5d67d60/tests/data/groups.py000066400000000000000000000027671524076126400213320ustar00rootroot00000000000000"""Groups snapshot.""" DATA_GROUPS = { "count": 1, "next": None, "previous": None, "results": [ { "id": 1, "name": "Sample Group", "permissions": [ "add_consumptiontemplate", "change_consumptiontemplate", "delete_consumptiontemplate", "view_consumptiontemplate", "view_correspondent", "add_customfield", "change_customfield", "delete_customfield", "view_customfield", "add_customfieldinstance", "change_customfieldinstance", "delete_customfieldinstance", "view_customfieldinstance", "add_document", "change_document", "delete_document", "view_document", "view_documenttype", "add_note", "change_note", "delete_note", "view_note", "add_savedview", "change_savedview", "delete_savedview", "view_savedview", "add_sharelink", "change_sharelink", "delete_sharelink", "view_sharelink", "view_tag", "add_uisettings", "change_uisettings", "delete_uisettings", "view_uisettings", ], }, ], } tb1337-pypaperless-5d67d60/tests/data/mails.py000066400000000000000000000051671524076126400211150ustar00rootroot00000000000000"""Mail snapshots.""" DATA_MAIL_ACCOUNTS = { "count": 1, "next": None, "previous": None, "results": [ { "id": 1, "name": "Test Account", "imap_server": "imap.omega.net", "imap_port": 1337, "imap_security": 2, "username": "omega-weapon", "password": "********************", "character_set": "UTF-8", "is_token": False, "owner": 1, "user_can_change": True, } ], } DATA_MAIL_RULES = { "count": 1, "next": None, "previous": None, "results": [ { "id": 1, "name": "Test", "account": 1, "folder": "INBOX", "filter_from": None, "filter_to": None, "filter_subject": None, "filter_body": None, "filter_attachment_filename_include": None, "filter_attachment_filename_exclude": None, "maximum_age": 3, "action": 3, "action_parameter": None, "assign_title_from": 1, "assign_tags": [], "assign_correspondent_from": 1, "assign_correspondent": None, "assign_document_type": None, "assign_owner_from_rule": True, "order": 1, "attachment_type": 1, "consumption_scope": 1, "owner": 1, "user_can_change": True, } ], } DATA_PROCESSED_MAIL = { "count": 3, "next": None, "previous": None, "results": [ { "id": 1, "owner": 1, "rule": 1, "folder": "INBOX", "uid": "abcdef", "subject": "Invoice #1", "received": "2025-11-16T08:18:25+01:00", "processed": "2025-11-16T11:00:17.838202+01:00", "status": "SUCCESS", "error": None, }, { "id": 2, "owner": 1, "rule": 1, "folder": "INBOX", "uid": "fedcba", "subject": "Invoice #2", "received": "2025-11-16T07:56:16+01:00", "processed": "2025-11-16T08:00:17.660439+01:00", "status": "SUCCESS", "error": None, }, { "id": 3, "owner": 1, "rule": 1, "folder": "INBOX", "uid": "123456", "subject": "Invoice #3", "received": "2025-11-16T13:54:23+01:00", "processed": "2025-11-16T14:00:17.846562+01:00", "status": "SUCCESS", "error": None, }, ], } tb1337-pypaperless-5d67d60/tests/data/object_permissions.py000066400000000000000000000003111524076126400236730ustar00rootroot00000000000000"""Object permissions snapshot.""" DATA_OBJECT_PERMISSIONS = { "view": { "users": [1, 2], "groups": [], }, "change": { "users": [], "groups": [1], }, } tb1337-pypaperless-5d67d60/tests/data/paths.py000066400000000000000000000021721524076126400211200ustar00rootroot00000000000000"""Paths snapshot.""" from tests.const import PAPERLESS_TEST_URL DATA_PATHS = { "correspondents": f"{PAPERLESS_TEST_URL}/api/correspondents/", "document_types": f"{PAPERLESS_TEST_URL}/api/document_types/", "documents": f"{PAPERLESS_TEST_URL}/api/documents/", "logs": f"{PAPERLESS_TEST_URL}/api/logs/", "tags": f"{PAPERLESS_TEST_URL}/api/tags/", "saved_views": f"{PAPERLESS_TEST_URL}/api/saved_views/", "tasks": f"{PAPERLESS_TEST_URL}/api/tasks/", "users": f"{PAPERLESS_TEST_URL}/api/users/", "groups": f"{PAPERLESS_TEST_URL}/api/groups/", "mail_accounts": f"{PAPERLESS_TEST_URL}/api/mail_accounts/", "mail_rules": f"{PAPERLESS_TEST_URL}/api/mail_rules/", "storage_paths": f"{PAPERLESS_TEST_URL}/api/storage_paths/", "config": f"{PAPERLESS_TEST_URL}/api/config/", "custom_fields": f"{PAPERLESS_TEST_URL}/api/custom_fields/", "share_links": f"{PAPERLESS_TEST_URL}/api/share_links/", "workflows": f"{PAPERLESS_TEST_URL}/api/workflows/", "workflow_actions": f"{PAPERLESS_TEST_URL}/api/workflow_actions/", "workflow_triggers": f"{PAPERLESS_TEST_URL}/api/workflow_triggers/", } tb1337-pypaperless-5d67d60/tests/data/profile.py000066400000000000000000000005011524076126400214330ustar00rootroot00000000000000"""Profile snapshot.""" DATA_PROFILE = { "email": "admin@example.com", "password": "********************", "first_name": "Admin", "last_name": "User", "auth_token": "3e9505078d32d8ad4ecea00fa0eec8e426622b52", "social_accounts": [], "has_usable_password": True, "is_mfa_enabled": False, } tb1337-pypaperless-5d67d60/tests/data/remote_version.py000066400000000000000000000001471524076126400230410ustar00rootroot00000000000000"""Remote version snapshot.""" DATA_REMOTE_VERSION = {"version": "v2.15.3", "update_available": True} tb1337-pypaperless-5d67d60/tests/data/saved_views.py000066400000000000000000000043561524076126400223260ustar00rootroot00000000000000"""Saved views snapshot.""" DATA_SAVED_VIEWS = { "count": 5, "next": None, "previous": None, "results": [ { "id": 1, "name": "New Items", "show_on_dashboard": False, "show_in_sidebar": False, "sort_field": None, "sort_reverse": False, "filter_rules": [{"rule_type": 6, "value": "1"}], "owner": 1, "user_can_change": True, }, { "id": 2, "name": "New Items Alpha", "show_on_dashboard": True, "show_in_sidebar": False, "sort_field": "title", "sort_reverse": False, "filter_rules": [ {"rule_type": 6, "value": "1"}, {"rule_type": 33, "value": "7"}, ], "owner": 1, "user_can_change": True, }, { "id": 3, "name": "New Items Omega", "show_on_dashboard": True, "show_in_sidebar": False, "sort_field": None, "sort_reverse": False, "filter_rules": [ {"rule_type": 6, "value": "1"}, {"rule_type": 35, "value": "7"}, ], "owner": 1, "user_can_change": True, }, { "id": 4, "name": "Tax", "show_on_dashboard": False, "show_in_sidebar": True, "sort_field": "created", "sort_reverse": True, "filter_rules": [{"rule_type": 6, "value": "3"}], "owner": 1, "user_can_change": True, }, { "id": 5, "name": "Todo", "show_on_dashboard": False, "show_in_sidebar": True, "sort_field": "created", "sort_reverse": True, "filter_rules": [{"rule_type": 6, "value": "4"}], "page_size": 15, "display_mode": "smallCards", "display_fields": [ "title", "created", "correspondent", "custom_field_1", "custom_field_8", ], "owner": 1, "user_can_change": True, }, ], } tb1337-pypaperless-5d67d60/tests/data/schema.json000066400000000000000000017103151524076126400215700ustar00rootroot00000000000000{ "openapi": "3.0.3", "info": { "title": "Paperless-ngx REST API", "version": "6.0.0 (10)", "description": "OpenAPI Spec for Paperless-ngx" }, "paths": { "/api/bulk_edit_objects/": { "post": { "operationId": "bulk_edit_objects", "description": "Perform a bulk edit operation on a list of objects", "externalDocs": { "description": "Further documentation", "url": "https://docs.paperless-ngx.com/api/#objects" }, "tags": [ "bulk_edit_objects" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkEditObjectsRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkEditResult" } } }, "description": "" } } } }, "/api/config/": { "get": { "operationId": "config_list", "description": "Get the application configuration", "externalDocs": { "description": "Application Configuration", "url": "https://docs.paperless-ngx.com/configuration/" }, "tags": [ "config" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ApplicationConfiguration" } } } }, "description": "" } } } }, "/api/config/{id}/": { "get": { "operationId": "config_retrieve", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this paperless application settings.", "required": true } ], "tags": [ "config" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApplicationConfiguration" } } }, "description": "" } } }, "put": { "operationId": "config_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this paperless application settings.", "required": true } ], "tags": [ "config" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApplicationConfigurationRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/ApplicationConfigurationRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/ApplicationConfigurationRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApplicationConfiguration" } } }, "description": "" } } }, "patch": { "operationId": "config_partial_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this paperless application settings.", "required": true } ], "tags": [ "config" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedApplicationConfigurationRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedApplicationConfigurationRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedApplicationConfigurationRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApplicationConfiguration" } } }, "description": "" } } }, "delete": { "operationId": "config_destroy", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this paperless application settings.", "required": true } ], "tags": [ "config" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/correspondents/": { "get": { "operationId": "correspondents_list", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "in": "query", "name": "id", "schema": { "type": "integer" } }, { "in": "query", "name": "id__in", "schema": { "type": "array", "items": { "type": "integer" } }, "description": "Multiple values may be separated by commas.", "explode": false, "style": "form" }, { "in": "query", "name": "name__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "name__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "name__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "name__istartswith", "schema": { "type": "string" } }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "correspondents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedCorrespondentList" } } }, "description": "" } } }, "post": { "operationId": "correspondents_create", "description": "Mixin to add document count to queryset, permissions-aware if needed", "tags": [ "correspondents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CorrespondentRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/CorrespondentRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/CorrespondentRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Correspondent" } } }, "description": "" } } } }, "/api/correspondents/{id}/": { "get": { "operationId": "correspondents_retrieve", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this correspondent.", "required": true } ], "tags": [ "correspondents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Correspondent" } } }, "description": "" } } }, "put": { "operationId": "correspondents_update", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this correspondent.", "required": true } ], "tags": [ "correspondents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CorrespondentRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/CorrespondentRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/CorrespondentRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Correspondent" } } }, "description": "" } } }, "patch": { "operationId": "correspondents_partial_update", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this correspondent.", "required": true } ], "tags": [ "correspondents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedCorrespondentRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedCorrespondentRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedCorrespondentRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Correspondent" } } }, "description": "" } } }, "delete": { "operationId": "correspondents_destroy", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this correspondent.", "required": true } ], "tags": [ "correspondents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/custom_fields/": { "get": { "operationId": "custom_fields_list", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "query", "name": "id", "schema": { "type": "integer" } }, { "in": "query", "name": "id__in", "schema": { "type": "array", "items": { "type": "integer" } }, "description": "Multiple values may be separated by commas.", "explode": false, "style": "form" }, { "in": "query", "name": "name__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "name__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "name__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "name__istartswith", "schema": { "type": "string" } }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "custom_fields" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedCustomFieldList" } } }, "description": "" } } }, "post": { "operationId": "custom_fields_create", "description": "Mixin to add document count to queryset, permissions-aware if needed", "tags": [ "custom_fields" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CustomFieldRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/CustomFieldRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/CustomFieldRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CustomField" } } }, "description": "" } } } }, "/api/custom_fields/{id}/": { "get": { "operationId": "custom_fields_retrieve", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this custom field.", "required": true } ], "tags": [ "custom_fields" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CustomField" } } }, "description": "" } } }, "put": { "operationId": "custom_fields_update", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this custom field.", "required": true } ], "tags": [ "custom_fields" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CustomFieldRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/CustomFieldRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/CustomFieldRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CustomField" } } }, "description": "" } } }, "patch": { "operationId": "custom_fields_partial_update", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this custom field.", "required": true } ], "tags": [ "custom_fields" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedCustomFieldRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedCustomFieldRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedCustomFieldRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CustomField" } } }, "description": "" } } }, "delete": { "operationId": "custom_fields_destroy", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this custom field.", "required": true } ], "tags": [ "custom_fields" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/document_types/": { "get": { "operationId": "document_types_list", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "in": "query", "name": "id", "schema": { "type": "integer" } }, { "in": "query", "name": "id__in", "schema": { "type": "array", "items": { "type": "integer" } }, "description": "Multiple values may be separated by commas.", "explode": false, "style": "form" }, { "in": "query", "name": "name__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "name__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "name__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "name__istartswith", "schema": { "type": "string" } }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "document_types" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDocumentTypeList" } } }, "description": "" } } }, "post": { "operationId": "document_types_create", "description": "Mixin to add document count to queryset, permissions-aware if needed", "tags": [ "document_types" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentTypeRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/DocumentTypeRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/DocumentTypeRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentType" } } }, "description": "" } } } }, "/api/document_types/{id}/": { "get": { "operationId": "document_types_retrieve", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document type.", "required": true } ], "tags": [ "document_types" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentType" } } }, "description": "" } } }, "put": { "operationId": "document_types_update", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document type.", "required": true } ], "tags": [ "document_types" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentTypeRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/DocumentTypeRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/DocumentTypeRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentType" } } }, "description": "" } } }, "patch": { "operationId": "document_types_partial_update", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document type.", "required": true } ], "tags": [ "document_types" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedDocumentTypeRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedDocumentTypeRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedDocumentTypeRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentType" } } }, "description": "" } } }, "delete": { "operationId": "document_types_destroy", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document type.", "required": true } ], "tags": [ "document_types" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/documents/": { "get": { "operationId": "documents_list", "description": "Document views including search", "parameters": [ { "in": "query", "name": "added__date__gt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "added__date__gte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "added__date__lt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "added__date__lte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "added__day", "schema": { "type": "number" } }, { "in": "query", "name": "added__gt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "added__gte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "added__lt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "added__lte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "added__month", "schema": { "type": "number" } }, { "in": "query", "name": "added__year", "schema": { "type": "number" } }, { "in": "query", "name": "archive_serial_number", "schema": { "type": "integer" } }, { "in": "query", "name": "archive_serial_number__gt", "schema": { "type": "integer" } }, { "in": "query", "name": "archive_serial_number__gte", "schema": { "type": "integer" } }, { "in": "query", "name": "archive_serial_number__isnull", "schema": { "type": "boolean" } }, { "in": "query", "name": "archive_serial_number__lt", "schema": { "type": "integer" } }, { "in": "query", "name": "archive_serial_number__lte", "schema": { "type": "integer" } }, { "in": "query", "name": "checksum__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "checksum__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "checksum__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "checksum__istartswith", "schema": { "type": "string" } }, { "in": "query", "name": "content__icontains", "schema": { "type": "string", "minLength": 1 } }, { "in": "query", "name": "content__iendswith", "schema": { "type": "string", "minLength": 1 } }, { "in": "query", "name": "content__iexact", "schema": { "type": "string", "minLength": 1 } }, { "in": "query", "name": "content__istartswith", "schema": { "type": "string", "minLength": 1 } }, { "in": "query", "name": "correspondent__id", "schema": { "type": "integer" } }, { "in": "query", "name": "correspondent__id__in", "schema": { "type": "array", "items": { "type": "integer" } }, "description": "Multiple values may be separated by commas.", "explode": false, "style": "form" }, { "in": "query", "name": "correspondent__id__none", "schema": { "type": "integer" } }, { "in": "query", "name": "correspondent__isnull", "schema": { "type": "boolean" } }, { "in": "query", "name": "correspondent__name__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "correspondent__name__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "correspondent__name__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "correspondent__name__istartswith", "schema": { "type": "string" } }, { "in": "query", "name": "created__date__gt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__date__gte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__date__lt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__date__lte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__day", "schema": { "type": "number" } }, { "in": "query", "name": "created__gt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__gte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__lt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__lte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__month", "schema": { "type": "number" } }, { "in": "query", "name": "created__year", "schema": { "type": "number" } }, { "in": "query", "name": "custom_field_query", "schema": { "type": "string", "minLength": 1 } }, { "in": "query", "name": "custom_fields__icontains", "schema": { "type": "string", "minLength": 1 } }, { "in": "query", "name": "custom_fields__id__all", "schema": { "type": "integer" } }, { "in": "query", "name": "custom_fields__id__in", "schema": { "type": "integer" } }, { "in": "query", "name": "custom_fields__id__none", "schema": { "type": "integer" } }, { "in": "query", "name": "document_type__id", "schema": { "type": "integer" } }, { "in": "query", "name": "document_type__id__in", "schema": { "type": "array", "items": { "type": "integer" } }, "description": "Multiple values may be separated by commas.", "explode": false, "style": "form" }, { "in": "query", "name": "document_type__id__none", "schema": { "type": "integer" } }, { "in": "query", "name": "document_type__isnull", "schema": { "type": "boolean" } }, { "in": "query", "name": "document_type__name__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "document_type__name__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "document_type__name__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "document_type__name__istartswith", "schema": { "type": "string" } }, { "in": "query", "name": "fields", "schema": { "type": "array", "items": { "type": "string" } } }, { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "in": "query", "name": "has_custom_fields", "schema": { "type": "boolean" }, "description": "Has custom field" }, { "in": "query", "name": "id", "schema": { "type": "integer" } }, { "in": "query", "name": "id__in", "schema": { "type": "array", "items": { "type": "integer" } }, "description": "Multiple values may be separated by commas.", "explode": false, "style": "form" }, { "in": "query", "name": "is_in_inbox", "schema": { "type": "boolean" } }, { "in": "query", "name": "is_tagged", "schema": { "type": "boolean" }, "description": "Is tagged" }, { "in": "query", "name": "mime_type", "schema": { "type": "string" } }, { "in": "query", "name": "modified__date__gt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "modified__date__gte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "modified__date__lt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "modified__date__lte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "modified__day", "schema": { "type": "number" } }, { "in": "query", "name": "modified__gt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "modified__gte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "modified__lt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "modified__lte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "modified__month", "schema": { "type": "number" } }, { "in": "query", "name": "modified__year", "schema": { "type": "number" } }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "in": "query", "name": "original_filename__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "original_filename__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "original_filename__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "original_filename__istartswith", "schema": { "type": "string" } }, { "in": "query", "name": "owner__id", "schema": { "type": "integer" } }, { "in": "query", "name": "owner__id__in", "schema": { "type": "array", "items": { "type": "integer" } }, "description": "Multiple values may be separated by commas.", "explode": false, "style": "form" }, { "in": "query", "name": "owner__id__none", "schema": { "type": "integer" } }, { "in": "query", "name": "owner__isnull", "schema": { "type": "boolean" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } }, { "in": "query", "name": "query", "schema": { "type": "string" }, "description": "Advanced Tantivy search query string" }, { "name": "search", "required": false, "in": "query", "description": "A search term.", "schema": { "type": "string" } }, { "in": "query", "name": "shared_by__id", "schema": { "type": "boolean" } }, { "in": "query", "name": "storage_path__id", "schema": { "type": "integer" } }, { "in": "query", "name": "storage_path__id__in", "schema": { "type": "array", "items": { "type": "integer" } }, "description": "Multiple values may be separated by commas.", "explode": false, "style": "form" }, { "in": "query", "name": "storage_path__id__none", "schema": { "type": "integer" } }, { "in": "query", "name": "storage_path__isnull", "schema": { "type": "boolean" } }, { "in": "query", "name": "storage_path__name__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "storage_path__name__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "storage_path__name__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "storage_path__name__istartswith", "schema": { "type": "string" } }, { "in": "query", "name": "tags__id", "schema": { "type": "integer" } }, { "in": "query", "name": "tags__id__all", "schema": { "type": "integer" } }, { "in": "query", "name": "tags__id__in", "schema": { "type": "integer" } }, { "in": "query", "name": "tags__id__none", "schema": { "type": "integer" } }, { "in": "query", "name": "tags__name__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "tags__name__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "tags__name__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "tags__name__istartswith", "schema": { "type": "string" } }, { "in": "query", "name": "text", "schema": { "type": "string" }, "description": "Simple Tantivy-backed text search query string" }, { "in": "query", "name": "title__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "title__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "title__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "title__istartswith", "schema": { "type": "string" } }, { "in": "query", "name": "title_content", "schema": { "type": "string", "minLength": 1 } }, { "in": "query", "name": "title_search", "schema": { "type": "string" }, "description": "Simple Tantivy-backed title-only search query string" } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedDocumentList" } } }, "description": "" } } } }, "/api/documents/{id}/": { "get": { "operationId": "documents_retrieve", "description": "Retrieve a single document", "parameters": [ { "in": "query", "name": "fields", "schema": { "type": "array", "items": { "type": "string" } } }, { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Document" } } }, "description": "" }, "400": { "description": "No response body" } } }, "put": { "operationId": "documents_update", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/DocumentRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/DocumentRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Document" } } }, "description": "" } } }, "patch": { "operationId": "documents_partial_update", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedDocumentRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedDocumentRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedDocumentRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Document" } } }, "description": "" } } }, "delete": { "operationId": "documents_destroy", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/documents/{id}/ai_suggestions/": { "get": { "operationId": "documents_ai_suggestions_retrieve", "description": "View AI suggestions for the document", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AISuggestions" } } }, "description": "" }, "400": { "description": "No response body" }, "403": { "description": "No response body" }, "404": { "description": "No response body" } } } }, "/api/documents/{id}/download/": { "get": { "operationId": "documents_download_retrieve", "description": "Download the document", "parameters": [ { "in": "query", "name": "follow_formatting", "schema": { "type": "boolean" }, "description": "Whether or not to use the filename on disk" }, { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true }, { "in": "query", "name": "original", "schema": { "type": "boolean" } } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "string", "format": "binary" } } }, "description": "" } } } }, "/api/documents/{id}/email/": { "post": { "operationId": "documents_email_document", "description": "Email the document to one or more recipients as an attachment.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EmailDocumentRequestRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/EmailDocumentRequestRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/EmailDocumentRequestRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "deprecated": true, "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EmailDocumentResponse" } } }, "description": "" }, "400": { "description": "No response body" }, "403": { "description": "No response body" }, "404": { "description": "No response body" }, "500": { "description": "No response body" } } } }, "/api/documents/{id}/history/": { "get": { "operationId": "documents_history_list", "description": "View the document history", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedLogEntryList" } } }, "description": "" }, "400": { "description": "No response body" }, "403": { "description": "No response body" }, "404": { "description": "No response body" } } } }, "/api/documents/{id}/metadata/": { "get": { "operationId": "documents_metadata_retrieve", "description": "View the document metadata", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Metadata" } } }, "description": "" }, "400": { "description": "No response body" }, "403": { "description": "No response body" }, "404": { "description": "No response body" } } } }, "/api/documents/{id}/notes/": { "get": { "operationId": "documents_notes_list", "description": "View, add, or delete notes for the document", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true }, { "in": "query", "name": "id", "schema": { "type": "integer" }, "description": "Note ID to delete (used only for DELETE requests)" } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Notes" } } } }, "description": "" }, "400": { "description": "No response body" }, "403": { "description": "No response body" }, "404": { "description": "No response body" } } }, "post": { "operationId": "documents_notes_create", "description": "View, add, or delete notes for the document", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true }, { "in": "query", "name": "id", "schema": { "type": "integer" }, "description": "Note ID to delete (used only for DELETE requests)" } ], "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NoteCreateRequestRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/NoteCreateRequestRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/NoteCreateRequestRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Notes" } } } }, "description": "" }, "400": { "description": "No response body" }, "403": { "description": "No response body" }, "404": { "description": "No response body" } } }, "delete": { "operationId": "documents_notes_destroy", "description": "View, add, or delete notes for the document", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true }, { "in": "query", "name": "id", "schema": { "type": "integer" }, "description": "Note ID to delete (used only for DELETE requests)" } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/Notes" } } } }, "description": "" }, "400": { "description": "No response body" }, "403": { "description": "No response body" }, "404": { "description": "No response body" } } } }, "/api/documents/{id}/preview/": { "get": { "operationId": "documents_preview_retrieve", "description": "View the document preview", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "string", "format": "binary" } } }, "description": "" } } } }, "/api/documents/{id}/root/": { "get": { "operationId": "documents_root", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentRootResponse" } } }, "description": "" } } } }, "/api/documents/{id}/share_links/": { "get": { "operationId": "document_share_links", "description": "View share links for the document", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "string" }, "required": true } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "integer" }, "created": { "type": "string", "format": "date-time" }, "expiration": { "type": "string", "format": "date-time" }, "slug": { "type": "string" } } } } } }, "description": "" }, "400": { "description": "No response body" }, "403": { "description": "No response body" }, "404": { "description": "No response body" } } } }, "/api/documents/{id}/suggestions/": { "get": { "operationId": "documents_suggestions_retrieve", "description": "View suggestions for the document", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Suggestions" } } }, "description": "" }, "400": { "description": "No response body" }, "403": { "description": "No response body" }, "404": { "description": "No response body" } } } }, "/api/documents/{id}/thumb/": { "get": { "operationId": "documents_thumb_retrieve", "description": "View the document thumbnail", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "string", "format": "binary" } } }, "description": "" } } } }, "/api/documents/{id}/update_version/": { "post": { "operationId": "documents_update_version", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true } ], "tags": [ "documents" ], "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/DocumentVersionRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "string" } } }, "description": "" } } } }, "/api/documents/{id}/versions/{version_id}/": { "patch": { "operationId": "documents_update_version_label", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true }, { "in": "path", "name": "version_id", "schema": { "type": "integer" }, "required": true } ], "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedDocumentVersionLabelRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedDocumentVersionLabelRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedDocumentVersionLabelRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateDocumentVersionLabelResult" } } }, "description": "" } } }, "delete": { "operationId": "documents_delete_version", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this document.", "required": true }, { "in": "path", "name": "version_id", "schema": { "type": "integer" }, "required": true } ], "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/documents/bulk_download/": { "post": { "operationId": "bulk_download", "description": "Download multiple documents as a ZIP archive.", "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkDownloadRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/zip": { "schema": { "type": "string", "format": "binary" } } }, "description": "" }, "403": { "description": "No response body" } } } }, "/api/documents/bulk_edit/": { "post": { "operationId": "bulk_edit", "description": "Perform a bulk edit operation on a list of documents", "externalDocs": { "description": "Further documentation", "url": "https://docs.paperless-ngx.com/api/#bulk-editing" }, "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkEditRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkEditDocumentsResult" } } }, "description": "" } } } }, "/api/documents/chat/": { "post": { "operationId": "documents_chat_create", "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChatStreamingRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/ChatStreamingRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/ChatStreamingRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ChatStreaming" } } }, "description": "" } } } }, "/api/documents/delete/": { "post": { "operationId": "documents_delete", "description": "Move selected documents to trash", "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteDocumentsRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteDocumentsResult" } } }, "description": "" } } } }, "/api/documents/edit_pdf/": { "post": { "operationId": "documents_edit_pdf", "description": "Perform PDF edit operations on a selected document", "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EditPdfDocumentsRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EditPdfDocumentsResult" } } }, "description": "" } } } }, "/api/documents/email/": { "post": { "operationId": "email_documents", "description": "Email one or more documents as attachments to one or more recipients.", "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EmailRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/EmailRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/EmailRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EmailDocumentsResponse" } } }, "description": "" }, "400": { "description": "No response body" }, "403": { "description": "No response body" }, "404": { "description": "No response body" }, "500": { "description": "No response body" } } } }, "/api/documents/merge/": { "post": { "operationId": "documents_merge", "description": "Merge selected documents into a new document", "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MergeDocumentsRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MergeDocumentsResult" } } }, "description": "" } } } }, "/api/documents/next_asn/": { "get": { "operationId": "documents_next_asn_retrieve", "description": "Get the next available Archive Serial Number (ASN) for a new document", "tags": [ "documents" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "integer" } } }, "description": "" } } } }, "/api/documents/post_document/": { "post": { "operationId": "documents_post_document_create", "description": "Upload a document via the API", "externalDocs": { "description": "Further documentation", "url": "https://docs.paperless-ngx.com/api/#file-uploads" }, "tags": [ "documents" ], "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PostDocumentRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "string" } } }, "description": "" } } } }, "/api/documents/remove_password/": { "post": { "operationId": "documents_remove_password", "description": "Remove password protection from selected PDFs", "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RemovePasswordDocumentsRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RemovePasswordDocumentsResult" } } }, "description": "" } } } }, "/api/documents/reprocess/": { "post": { "operationId": "documents_reprocess", "description": "Reprocess selected documents", "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReprocessDocumentsRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReprocessDocumentsResult" } } }, "description": "" } } } }, "/api/documents/rotate/": { "post": { "operationId": "documents_rotate", "description": "Rotate one or more documents", "tags": [ "documents" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RotateDocumentsRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RotateDocumentsResult" } } }, "description": "" } } } }, "/api/documents/selection_data/": { "post": { "operationId": "documents_selection_data_create", "description": "Get selection data for the selected documents", "tags": [ "documents" ], "requestBody": { "content": { "multipart/form-data": { "schema": { "$ref": "#/components/schemas/DocumentListRequest" } }, "application/json": { "schema": { "$ref": "#/components/schemas/DocumentListRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SelectionData" } } }, "description": "" } } } }, "/api/groups/": { "get": { "operationId": "groups_list", "parameters": [ { "in": "query", "name": "name__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "name__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "name__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "name__istartswith", "schema": { "type": "string" } }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "groups" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedGroupList" } } }, "description": "" } } }, "post": { "operationId": "groups_create", "tags": [ "groups" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GroupRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/GroupRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/GroupRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "" } } } }, "/api/groups/{id}/": { "get": { "operationId": "groups_retrieve", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this group.", "required": true } ], "tags": [ "groups" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "" } } }, "put": { "operationId": "groups_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this group.", "required": true } ], "tags": [ "groups" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GroupRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/GroupRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/GroupRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "" } } }, "patch": { "operationId": "groups_partial_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this group.", "required": true } ], "tags": [ "groups" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedGroupRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedGroupRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedGroupRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Group" } } }, "description": "" } } }, "delete": { "operationId": "groups_destroy", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this group.", "required": true } ], "tags": [ "groups" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/logs/": { "get": { "operationId": "logs_list", "description": "Logs view", "tags": [ "logs" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } }, "description": "" } } } }, "/api/logs/{id}/": { "get": { "operationId": "retrieve_log", "description": "Single log view", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "string" }, "required": true }, { "in": "query", "name": "limit", "schema": { "type": "integer" }, "description": "Return only the last N entries from the log file" } ], "tags": [ "logs" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } }, "description": "" }, "404": { "description": "No response body" } } } }, "/api/mail_accounts/": { "get": { "operationId": "mail_accounts_list", "description": "Pass a user object to serializer", "parameters": [ { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "mail_accounts" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedMailAccountList" } } }, "description": "" } } }, "post": { "operationId": "mail_accounts_create", "description": "Pass a user object to serializer", "tags": [ "mail_accounts" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailAccount" } } }, "description": "" } } } }, "/api/mail_accounts/{id}/": { "get": { "operationId": "mail_accounts_retrieve", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this mail account.", "required": true } ], "tags": [ "mail_accounts" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailAccount" } } }, "description": "" } } }, "put": { "operationId": "mail_accounts_update", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this mail account.", "required": true } ], "tags": [ "mail_accounts" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailAccount" } } }, "description": "" } } }, "patch": { "operationId": "mail_accounts_partial_update", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this mail account.", "required": true } ], "tags": [ "mail_accounts" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedMailAccountRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedMailAccountRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedMailAccountRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailAccount" } } }, "description": "" } } }, "delete": { "operationId": "mail_accounts_destroy", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this mail account.", "required": true } ], "tags": [ "mail_accounts" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/mail_accounts/{id}/process/": { "post": { "operationId": "mail_account_process", "description": "Manually process the selected mail account for new messages.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this mail account.", "required": true } ], "tags": [ "mail_accounts" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailAccountProcessResponse" } } }, "description": "" }, "404": { "description": "No response body" } } } }, "/api/mail_accounts/test/": { "post": { "operationId": "mail_account_test", "description": "Test a mail account", "tags": [ "mail_accounts" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/MailAccountRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailAccountTestResponse" } } }, "description": "" }, "400": { "content": { "application/json": { "schema": { "type": "string" } } }, "description": "" } } } }, "/api/mail_rules/": { "get": { "operationId": "mail_rules_list", "description": "Pass a user object to serializer", "parameters": [ { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "mail_rules" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedMailRuleList" } } }, "description": "" } } }, "post": { "operationId": "mail_rules_create", "description": "Pass a user object to serializer", "tags": [ "mail_rules" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailRuleRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/MailRuleRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/MailRuleRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailRule" } } }, "description": "" } } } }, "/api/mail_rules/{id}/": { "get": { "operationId": "mail_rules_retrieve", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this mail rule.", "required": true } ], "tags": [ "mail_rules" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailRule" } } }, "description": "" } } }, "put": { "operationId": "mail_rules_update", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this mail rule.", "required": true } ], "tags": [ "mail_rules" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailRuleRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/MailRuleRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/MailRuleRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailRule" } } }, "description": "" } } }, "patch": { "operationId": "mail_rules_partial_update", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this mail rule.", "required": true } ], "tags": [ "mail_rules" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedMailRuleRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedMailRuleRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedMailRuleRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MailRule" } } }, "description": "" } } }, "delete": { "operationId": "mail_rules_destroy", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this mail rule.", "required": true } ], "tags": [ "mail_rules" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/oauth/callback/": { "get": { "operationId": "oauth_callback_retrieve", "description": "Callback view for OAuth2 authentication", "tags": [ "oauth" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "description": "No response body" } } } }, "/api/processed_mail/": { "get": { "operationId": "processed_mail_list", "description": "Pass a user object to serializer", "parameters": [ { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } }, { "in": "query", "name": "rule", "schema": { "type": "integer" } }, { "in": "query", "name": "status", "schema": { "type": "string" } } ], "tags": [ "processed_mail" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedProcessedMailList" } } }, "description": "" } } } }, "/api/processed_mail/{id}/": { "get": { "operationId": "processed_mail_retrieve", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this processed mail.", "required": true } ], "tags": [ "processed_mail" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProcessedMail" } } }, "description": "" } } } }, "/api/processed_mail/bulk_delete/": { "post": { "operationId": "processed_mail_bulk_delete", "description": "Delete multiple processed mail records by ID.", "tags": [ "processed_mail" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkDeleteMailRequestRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/BulkDeleteMailRequestRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/BulkDeleteMailRequestRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkDeleteMailResponse" } } }, "description": "" }, "400": { "description": "No response body" }, "403": { "description": "No response body" } } } }, "/api/profile/": { "get": { "operationId": "profile_retrieve", "description": "User profile view, only available when logged in", "tags": [ "profile" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Profile" } } }, "description": "" } } }, "patch": { "operationId": "profile_partial_update", "description": "User profile view, only available when logged in", "tags": [ "profile" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedProfileRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedProfileRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedProfileRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Profile" } } }, "description": "" } } } }, "/api/profile/disconnect_social_account/": { "post": { "operationId": "profile_disconnect_social_account_create", "description": "Disconnects a social account provider from the user account", "tags": [ "profile" ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "id": { "type": "integer" } }, "required": [ "id" ] } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "integer" } } }, "description": "" }, "400": { "content": { "application/json": { "schema": { "type": "string" } } }, "description": "" } } } }, "/api/profile/generate_auth_token/": { "post": { "operationId": "profile_generate_auth_token_create", "description": "Generates (or re-generates) an auth token, requires a logged in user\nunlike the default DRF endpoint", "tags": [ "profile" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "string" } } }, "description": "" } } } }, "/api/profile/social_account_providers/": { "get": { "operationId": "profile_social_account_providers_retrieve", "description": "List of social account providers", "tags": [ "profile" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "object", "additionalProperties": {} } } }, "description": "" } } } }, "/api/profile/totp/": { "get": { "operationId": "profile_totp_retrieve", "description": "Generates a new TOTP secret and returns the URL and SVG", "tags": [ "profile" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "object", "additionalProperties": {} } } }, "description": "" } } }, "post": { "operationId": "profile_totp_create", "description": "Validates a TOTP code and activates the TOTP authenticator", "tags": [ "profile" ], "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "secret": { "type": "string" }, "code": { "type": "string" } }, "required": [ "secret", "code" ] } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "object", "additionalProperties": {} } } }, "description": "" } } }, "delete": { "operationId": "profile_totp_destroy", "description": "Deactivates the TOTP authenticator", "tags": [ "profile" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "boolean" } } }, "description": "" }, "404": { "content": { "application/json": { "schema": { "type": "string" } } }, "description": "" } } } }, "/api/remote_version/": { "get": { "operationId": "remote_version_retrieve", "description": "Get the current version of the Paperless-NGX server", "tags": [ "remote_version" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] }, {} ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "object", "additionalProperties": {} } } }, "description": "" } } } }, "/api/saved_views/": { "get": { "operationId": "saved_views_list", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "saved_views" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedSavedViewList" } } }, "description": "" } } }, "post": { "operationId": "saved_views_create", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "tags": [ "saved_views" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SavedViewRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/SavedViewRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/SavedViewRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SavedView" } } }, "description": "" } } } }, "/api/saved_views/{id}/": { "get": { "operationId": "saved_views_retrieve", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this saved view.", "required": true } ], "tags": [ "saved_views" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SavedView" } } }, "description": "" } } }, "put": { "operationId": "saved_views_update", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this saved view.", "required": true } ], "tags": [ "saved_views" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SavedViewRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/SavedViewRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/SavedViewRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SavedView" } } }, "description": "" } } }, "patch": { "operationId": "saved_views_partial_update", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this saved view.", "required": true } ], "tags": [ "saved_views" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedSavedViewRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedSavedViewRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedSavedViewRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SavedView" } } }, "description": "" } } }, "delete": { "operationId": "saved_views_destroy", "description": "Prefetch Django-Guardian permissions for a list before serialization, to avoid N+1 queries.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this saved view.", "required": true } ], "tags": [ "saved_views" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/search/": { "get": { "operationId": "search_retrieve", "description": "Global search", "parameters": [ { "in": "query", "name": "db_only", "schema": { "type": "boolean" }, "description": "Search only the database" }, { "in": "query", "name": "query", "schema": { "type": "string" }, "description": "Query to search for", "required": true } ], "tags": [ "search" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchResult" } } }, "description": "" } } } }, "/api/search/autocomplete/": { "get": { "operationId": "search_autocomplete_list", "description": "Get a list of all available tags", "parameters": [ { "in": "query", "name": "limit", "schema": { "type": "integer" }, "description": "Number of completions to return" }, { "in": "query", "name": "term", "schema": { "type": "string" }, "description": "Term to search for" } ], "tags": [ "search" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } }, "description": "" } } } }, "/api/share_link_bundles/": { "get": { "operationId": "share_link_bundles_list", "description": "Pass a user object to serializer", "parameters": [ { "in": "query", "name": "created__date__gt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__date__gte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__date__lt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__date__lte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__day", "schema": { "type": "number" } }, { "in": "query", "name": "created__gt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "created__gte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "created__lt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "created__lte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "created__month", "schema": { "type": "number" } }, { "in": "query", "name": "created__year", "schema": { "type": "number" } }, { "in": "query", "name": "documents", "schema": { "type": "integer" } }, { "in": "query", "name": "expiration__date__gt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "expiration__date__gte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "expiration__date__lt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "expiration__date__lte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "expiration__day", "schema": { "type": "number" } }, { "in": "query", "name": "expiration__gt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "expiration__gte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "expiration__lt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "expiration__lte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "expiration__month", "schema": { "type": "number" } }, { "in": "query", "name": "expiration__year", "schema": { "type": "number" } }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } }, { "in": "query", "name": "status", "schema": { "type": "string", "enum": [ "failed", "pending", "processing", "ready" ] }, "description": "* `pending` - Pending\n* `processing` - Processing\n* `ready` - Ready\n* `failed` - Failed" } ], "tags": [ "share_link_bundles" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedShareLinkBundleList" } } }, "description": "" } } }, "post": { "operationId": "share_link_bundles_create", "description": "Pass a user object to serializer", "tags": [ "share_link_bundles" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShareLinkBundleRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/ShareLinkBundleRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/ShareLinkBundleRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShareLinkBundle" } } }, "description": "" } } } }, "/api/share_link_bundles/{id}/": { "get": { "operationId": "share_link_bundles_retrieve", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this share link bundle.", "required": true } ], "tags": [ "share_link_bundles" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShareLinkBundle" } } }, "description": "" } } }, "put": { "operationId": "share_link_bundles_update", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this share link bundle.", "required": true } ], "tags": [ "share_link_bundles" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShareLinkBundleRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/ShareLinkBundleRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/ShareLinkBundleRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShareLinkBundle" } } }, "description": "" } } }, "patch": { "operationId": "share_link_bundles_partial_update", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this share link bundle.", "required": true } ], "tags": [ "share_link_bundles" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedShareLinkBundleRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedShareLinkBundleRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedShareLinkBundleRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShareLinkBundle" } } }, "description": "" } } }, "delete": { "operationId": "share_link_bundles_destroy", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this share link bundle.", "required": true } ], "tags": [ "share_link_bundles" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/share_link_bundles/{id}/rebuild/": { "post": { "operationId": "share_link_bundles_rebuild", "description": "Reset and re-queue a share link bundle for processing.", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this share link bundle.", "required": true } ], "tags": [ "share_link_bundles" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShareLinkBundleRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/ShareLinkBundleRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/ShareLinkBundleRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShareLinkBundle" } } }, "description": "" }, "400": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RebuildBundleError" } } }, "description": "" } } } }, "/api/share_links/": { "get": { "operationId": "share_links_list", "description": "Pass a user object to serializer", "parameters": [ { "in": "query", "name": "created__date__gt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__date__gte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__date__lt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__date__lte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "created__day", "schema": { "type": "number" } }, { "in": "query", "name": "created__gt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "created__gte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "created__lt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "created__lte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "created__month", "schema": { "type": "number" } }, { "in": "query", "name": "created__year", "schema": { "type": "number" } }, { "in": "query", "name": "expiration__date__gt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "expiration__date__gte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "expiration__date__lt", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "expiration__date__lte", "schema": { "type": "string", "format": "date" } }, { "in": "query", "name": "expiration__day", "schema": { "type": "number" } }, { "in": "query", "name": "expiration__gt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "expiration__gte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "expiration__lt", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "expiration__lte", "schema": { "type": "string", "format": "date-time" } }, { "in": "query", "name": "expiration__month", "schema": { "type": "number" } }, { "in": "query", "name": "expiration__year", "schema": { "type": "number" } }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "share_links" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedShareLinkList" } } }, "description": "" } } }, "post": { "operationId": "share_links_create", "description": "Pass a user object to serializer", "tags": [ "share_links" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShareLinkRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/ShareLinkRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/ShareLinkRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShareLink" } } }, "description": "" } } } }, "/api/share_links/{id}/": { "get": { "operationId": "share_links_retrieve", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this share link.", "required": true } ], "tags": [ "share_links" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ShareLink" } } }, "description": "" } } }, "delete": { "operationId": "share_links_destroy", "description": "Pass a user object to serializer", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this share link.", "required": true } ], "tags": [ "share_links" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/statistics/": { "get": { "operationId": "statistics_retrieve", "description": "Get statistics for the current user", "tags": [ "statistics" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "object", "additionalProperties": {} } } }, "description": "" } } } }, "/api/status/": { "get": { "operationId": "status_retrieve", "description": "Get the current system status of the Paperless-NGX server", "tags": [ "status" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SystemStatus" } } }, "description": "" } } } }, "/api/storage_paths/": { "get": { "operationId": "storage_paths_list", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "in": "query", "name": "id", "schema": { "type": "integer" } }, { "in": "query", "name": "id__in", "schema": { "type": "array", "items": { "type": "integer" } }, "description": "Multiple values may be separated by commas.", "explode": false, "style": "form" }, { "in": "query", "name": "name__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "name__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "name__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "name__istartswith", "schema": { "type": "string" } }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } }, { "in": "query", "name": "path__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "path__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "path__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "path__istartswith", "schema": { "type": "string" } } ], "tags": [ "storage_paths" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedStoragePathList" } } }, "description": "" } } }, "post": { "operationId": "storage_paths_create", "description": "Mixin to add document count to queryset, permissions-aware if needed", "tags": [ "storage_paths" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoragePathRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/StoragePathRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/StoragePathRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoragePath" } } }, "description": "" } } } }, "/api/storage_paths/{id}/": { "get": { "operationId": "storage_paths_retrieve", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this storage path.", "required": true } ], "tags": [ "storage_paths" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoragePath" } } }, "description": "" } } }, "put": { "operationId": "storage_paths_update", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this storage path.", "required": true } ], "tags": [ "storage_paths" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoragePathRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/StoragePathRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/StoragePathRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoragePath" } } }, "description": "" } } }, "patch": { "operationId": "storage_paths_partial_update", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this storage path.", "required": true } ], "tags": [ "storage_paths" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedStoragePathRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedStoragePathRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedStoragePathRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoragePath" } } }, "description": "" } } }, "delete": { "operationId": "storage_paths_destroy", "description": "When a storage path is deleted, see if documents\nusing it require a rename/move", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this storage path.", "required": true } ], "tags": [ "storage_paths" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/storage_paths/test/": { "post": { "operationId": "storage_paths_test", "description": "Test a storage path template against a document.", "tags": [ "storage_paths" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoragePathTestRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/StoragePathTestRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/StoragePathTestRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "string" } } }, "description": "" } } } }, "/api/tags/": { "get": { "operationId": "tags_list", "description": "Build a children map once to avoid per-parent queries in the serializer.", "parameters": [ { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "in": "query", "name": "id", "schema": { "type": "integer" } }, { "in": "query", "name": "id__in", "schema": { "type": "array", "items": { "type": "integer" } }, "description": "Multiple values may be separated by commas.", "explode": false, "style": "form" }, { "in": "query", "name": "is_root", "schema": { "type": "boolean" }, "description": "Is root tag" }, { "in": "query", "name": "name__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "name__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "name__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "name__istartswith", "schema": { "type": "string" } }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "tags" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedTagList" } } }, "description": "" } } }, "post": { "operationId": "tags_create", "description": "Mixin to add document count to queryset, permissions-aware if needed", "tags": [ "tags" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TagRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/TagRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/TagRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "" } } } }, "/api/tags/{id}/": { "get": { "operationId": "tags_retrieve", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "query", "name": "full_perms", "schema": { "type": "boolean" } }, { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this tag.", "required": true } ], "tags": [ "tags" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "" } } }, "put": { "operationId": "tags_update", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this tag.", "required": true } ], "tags": [ "tags" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TagRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/TagRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/TagRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "" } } }, "patch": { "operationId": "tags_partial_update", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this tag.", "required": true } ], "tags": [ "tags" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedTagRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedTagRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedTagRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Tag" } } }, "description": "" } } }, "delete": { "operationId": "tags_destroy", "description": "Mixin to add document count to queryset, permissions-aware if needed", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this tag.", "required": true } ], "tags": [ "tags" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/tasks/": { "get": { "operationId": "tasks_list", "parameters": [ { "in": "query", "name": "acknowledged", "schema": { "type": "boolean" }, "description": "Acknowledged" }, { "in": "query", "name": "date_created_after", "schema": { "type": "string", "format": "date-time" }, "description": "Created After" }, { "in": "query", "name": "date_created_before", "schema": { "type": "string", "format": "date-time" }, "description": "Created Before" }, { "in": "query", "name": "is_complete", "schema": { "type": "boolean" }, "description": "Is Complete" }, { "in": "query", "name": "name", "schema": { "type": "string" }, "description": "Name" }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "in": "query", "name": "owner", "schema": { "type": "integer" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } }, { "in": "query", "name": "result", "schema": { "type": "string" }, "description": "Result" }, { "in": "query", "name": "status", "schema": { "type": "array", "items": { "type": "string", "enum": [ "failure", "pending", "revoked", "started", "success" ] } }, "description": "Status\n\n* `pending` - Pending\n* `started` - Started\n* `success` - Success\n* `failure` - Failure\n* `revoked` - Revoked", "explode": true, "style": "form" }, { "in": "query", "name": "task_id", "schema": { "type": "string" }, "description": "Filter tasks by Celery UUID" }, { "in": "query", "name": "task_type", "schema": { "type": "array", "items": { "type": "string", "enum": [ "build_share_link", "bulk_delete", "bulk_update", "check_workflows", "consume_file", "empty_trash", "index_optimize", "llm_index", "mail_fetch", "reprocess_document", "sanity_check", "train_classifier" ] } }, "description": "Task Type\n\n* `consume_file` - Consume File\n* `train_classifier` - Train Classifier\n* `sanity_check` - Sanity Check\n* `index_optimize` - Index Optimize\n* `mail_fetch` - Mail Fetch\n* `llm_index` - LLM Index\n* `empty_trash` - Empty Trash\n* `check_workflows` - Check Workflows\n* `bulk_update` - Bulk Update\n* `reprocess_document` - Reprocess Document\n* `build_share_link` - Build Share Link\n* `bulk_delete` - Bulk Delete", "explode": true, "style": "form" }, { "in": "query", "name": "trigger_source", "schema": { "type": "array", "items": { "type": "string", "enum": [ "api_upload", "email_consume", "folder_consume", "manual", "scheduled", "system", "web_ui" ] } }, "description": "Trigger Source\n\n* `scheduled` - Scheduled\n* `web_ui` - Web UI\n* `api_upload` - API Upload\n* `folder_consume` - Folder Consume\n* `email_consume` - Email Consume\n* `system` - System\n* `manual` - Manual", "explode": true, "style": "form" } ], "tags": [ "tasks" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedTaskSerializerV10List" } } }, "description": "" } } } }, "/api/tasks/{id}/": { "get": { "operationId": "tasks_retrieve", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this Task.", "required": true } ], "tags": [ "tasks" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TaskSerializerV10" } } }, "description": "" } } } }, "/api/tasks/acknowledge/": { "post": { "operationId": "acknowledge_tasks", "description": "Acknowledge a list of tasks, or all visible unacknowledged tasks", "tags": [ "tasks" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcknowledgeTasksViewRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/AcknowledgeTasksViewRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/AcknowledgeTasksViewRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcknowledgeTasks" } } }, "description": "" } } } }, "/api/tasks/active/": { "get": { "operationId": "tasks_active_list", "description": "Currently pending and running tasks (capped at 50).", "parameters": [ { "in": "query", "name": "acknowledged", "schema": { "type": "boolean" }, "description": "Acknowledged" }, { "in": "query", "name": "date_created_after", "schema": { "type": "string", "format": "date-time" }, "description": "Created After" }, { "in": "query", "name": "date_created_before", "schema": { "type": "string", "format": "date-time" }, "description": "Created Before" }, { "in": "query", "name": "is_complete", "schema": { "type": "boolean" }, "description": "Is Complete" }, { "in": "query", "name": "name", "schema": { "type": "string" }, "description": "Name" }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "in": "query", "name": "owner", "schema": { "type": "integer" } }, { "in": "query", "name": "result", "schema": { "type": "string" }, "description": "Result" }, { "in": "query", "name": "status", "schema": { "type": "array", "items": { "type": "string", "enum": [ "failure", "pending", "revoked", "started", "success" ] } }, "description": "Status\n\n* `pending` - Pending\n* `started` - Started\n* `success` - Success\n* `failure` - Failure\n* `revoked` - Revoked", "explode": true, "style": "form" }, { "in": "query", "name": "task_type", "schema": { "type": "array", "items": { "type": "string", "enum": [ "build_share_link", "bulk_delete", "bulk_update", "check_workflows", "consume_file", "empty_trash", "index_optimize", "llm_index", "mail_fetch", "reprocess_document", "sanity_check", "train_classifier" ] } }, "description": "Task Type\n\n* `consume_file` - Consume File\n* `train_classifier` - Train Classifier\n* `sanity_check` - Sanity Check\n* `index_optimize` - Index Optimize\n* `mail_fetch` - Mail Fetch\n* `llm_index` - LLM Index\n* `empty_trash` - Empty Trash\n* `check_workflows` - Check Workflows\n* `bulk_update` - Bulk Update\n* `reprocess_document` - Reprocess Document\n* `build_share_link` - Build Share Link\n* `bulk_delete` - Bulk Delete", "explode": true, "style": "form" }, { "in": "query", "name": "trigger_source", "schema": { "type": "array", "items": { "type": "string", "enum": [ "api_upload", "email_consume", "folder_consume", "manual", "scheduled", "system", "web_ui" ] } }, "description": "Trigger Source\n\n* `scheduled` - Scheduled\n* `web_ui` - Web UI\n* `api_upload` - API Upload\n* `folder_consume` - Folder Consume\n* `email_consume` - Email Consume\n* `system` - System\n* `manual` - Manual", "explode": true, "style": "form" } ], "tags": [ "tasks" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/TaskSerializerV10" } } } }, "description": "" } } } }, "/api/tasks/run/": { "post": { "operationId": "run_task", "description": "Manually dispatch a background task. Superuser only.", "tags": [ "tasks" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RunTaskRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/RunTaskRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/RunTaskRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RunTask" } } }, "description": "" }, "400": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RunTaskError" } } }, "description": "" } } } }, "/api/tasks/status_counts/": { "get": { "operationId": "tasks_status_counts_retrieve", "description": "Aggregated task counts for task UI sections.", "tags": [ "tasks" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TaskStatusCounts" } } }, "description": "" } } } }, "/api/tasks/summary/": { "get": { "operationId": "tasks_summary_list", "description": "Aggregated task statistics per task_type over the last N days (default 30).", "parameters": [ { "in": "query", "name": "acknowledged", "schema": { "type": "boolean" }, "description": "Acknowledged" }, { "in": "query", "name": "date_created_after", "schema": { "type": "string", "format": "date-time" }, "description": "Created After" }, { "in": "query", "name": "date_created_before", "schema": { "type": "string", "format": "date-time" }, "description": "Created Before" }, { "in": "query", "name": "days", "schema": { "type": "integer", "minimum": 1, "maximum": 365, "default": 30 }, "description": "Number of days to include in aggregation (default 30, min 1, max 365)" }, { "in": "query", "name": "is_complete", "schema": { "type": "boolean" }, "description": "Is Complete" }, { "in": "query", "name": "name", "schema": { "type": "string" }, "description": "Name" }, { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "in": "query", "name": "owner", "schema": { "type": "integer" } }, { "in": "query", "name": "result", "schema": { "type": "string" }, "description": "Result" }, { "in": "query", "name": "status", "schema": { "type": "array", "items": { "type": "string", "enum": [ "failure", "pending", "revoked", "started", "success" ] } }, "description": "Status\n\n* `pending` - Pending\n* `started` - Started\n* `success` - Success\n* `failure` - Failure\n* `revoked` - Revoked", "explode": true, "style": "form" }, { "in": "query", "name": "task_type", "schema": { "type": "array", "items": { "type": "string", "enum": [ "build_share_link", "bulk_delete", "bulk_update", "check_workflows", "consume_file", "empty_trash", "index_optimize", "llm_index", "mail_fetch", "reprocess_document", "sanity_check", "train_classifier" ] } }, "description": "Task Type\n\n* `consume_file` - Consume File\n* `train_classifier` - Train Classifier\n* `sanity_check` - Sanity Check\n* `index_optimize` - Index Optimize\n* `mail_fetch` - Mail Fetch\n* `llm_index` - LLM Index\n* `empty_trash` - Empty Trash\n* `check_workflows` - Check Workflows\n* `bulk_update` - Bulk Update\n* `reprocess_document` - Reprocess Document\n* `build_share_link` - Build Share Link\n* `bulk_delete` - Bulk Delete", "explode": true, "style": "form" }, { "in": "query", "name": "trigger_source", "schema": { "type": "array", "items": { "type": "string", "enum": [ "api_upload", "email_consume", "folder_consume", "manual", "scheduled", "system", "web_ui" ] } }, "description": "Trigger Source\n\n* `scheduled` - Scheduled\n* `web_ui` - Web UI\n* `api_upload` - API Upload\n* `folder_consume` - Folder Consume\n* `email_consume` - Email Consume\n* `system` - System\n* `manual` - Manual", "explode": true, "style": "form" } ], "tags": [ "tasks" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/TaskSummary" } } } }, "description": "" } } } }, "/api/token/": { "post": { "operationId": "token_create", "tags": [ "token" ], "requestBody": { "content": { "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PaperlessAuthTokenRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PaperlessAuthTokenRequest" } }, "application/json": { "schema": { "$ref": "#/components/schemas/PaperlessAuthTokenRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaperlessAuthToken" } } }, "description": "" } } } }, "/api/trash/": { "get": { "operationId": "trash_list", "parameters": [ { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "trash" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "description": "No response body" } } }, "post": { "operationId": "trash_create", "tags": [ "trash" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TrashRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/TrashRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/TrashRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "description": "No response body" } } } }, "/api/ui_settings/": { "get": { "operationId": "ui_settings_retrieve", "tags": [ "ui_settings" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UiSettingsView" } } }, "description": "" } } }, "post": { "operationId": "ui_settings_create", "tags": [ "ui_settings" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UiSettingsViewRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/UiSettingsViewRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/UiSettingsViewRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UiSettingsView" } } }, "description": "" } } } }, "/api/users/": { "get": { "operationId": "users_list", "parameters": [ { "name": "ordering", "required": false, "in": "query", "description": "Which field to use when ordering the results.", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } }, { "in": "query", "name": "username__icontains", "schema": { "type": "string" } }, { "in": "query", "name": "username__iendswith", "schema": { "type": "string" } }, { "in": "query", "name": "username__iexact", "schema": { "type": "string" } }, { "in": "query", "name": "username__istartswith", "schema": { "type": "string" } } ], "tags": [ "users" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedUserList" } } }, "description": "" } } }, "post": { "operationId": "users_create", "tags": [ "users" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/UserRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/UserRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "" } } } }, "/api/users/{id}/": { "get": { "operationId": "users_retrieve", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this user.", "required": true } ], "tags": [ "users" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "" } } }, "put": { "operationId": "users_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this user.", "required": true } ], "tags": [ "users" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/UserRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/UserRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "" } } }, "patch": { "operationId": "users_partial_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this user.", "required": true } ], "tags": [ "users" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedUserRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedUserRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedUserRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }, "description": "" } } }, "delete": { "operationId": "users_destroy", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this user.", "required": true } ], "tags": [ "users" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/users/{id}/deactivate_totp/": { "post": { "operationId": "users_deactivate_totp_create", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this user.", "required": true } ], "tags": [ "users" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "type": "boolean" } } }, "description": "" }, "404": { "content": { "application/json": { "schema": { "type": "string" } } }, "description": "" } } } }, "/api/workflow_actions/": { "get": { "operationId": "workflow_actions_list", "parameters": [ { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "workflow_actions" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedWorkflowActionList" } } }, "description": "" } } }, "post": { "operationId": "workflow_actions_create", "tags": [ "workflow_actions" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowActionRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/WorkflowActionRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/WorkflowActionRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowAction" } } }, "description": "" } } } }, "/api/workflow_actions/{id}/": { "get": { "operationId": "workflow_actions_retrieve", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow action.", "required": true } ], "tags": [ "workflow_actions" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowAction" } } }, "description": "" } } }, "put": { "operationId": "workflow_actions_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow action.", "required": true } ], "tags": [ "workflow_actions" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowActionRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/WorkflowActionRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/WorkflowActionRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowAction" } } }, "description": "" } } }, "patch": { "operationId": "workflow_actions_partial_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow action.", "required": true } ], "tags": [ "workflow_actions" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedWorkflowActionRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedWorkflowActionRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedWorkflowActionRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowAction" } } }, "description": "" } } }, "delete": { "operationId": "workflow_actions_destroy", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow action.", "required": true } ], "tags": [ "workflow_actions" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/workflow_triggers/": { "get": { "operationId": "workflow_triggers_list", "parameters": [ { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "workflow_triggers" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedWorkflowTriggerList" } } }, "description": "" } } }, "post": { "operationId": "workflow_triggers_create", "tags": [ "workflow_triggers" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowTriggerRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/WorkflowTriggerRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/WorkflowTriggerRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowTrigger" } } }, "description": "" } } } }, "/api/workflow_triggers/{id}/": { "get": { "operationId": "workflow_triggers_retrieve", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow trigger.", "required": true } ], "tags": [ "workflow_triggers" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowTrigger" } } }, "description": "" } } }, "put": { "operationId": "workflow_triggers_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow trigger.", "required": true } ], "tags": [ "workflow_triggers" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowTriggerRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/WorkflowTriggerRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/WorkflowTriggerRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowTrigger" } } }, "description": "" } } }, "patch": { "operationId": "workflow_triggers_partial_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow trigger.", "required": true } ], "tags": [ "workflow_triggers" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedWorkflowTriggerRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedWorkflowTriggerRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedWorkflowTriggerRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowTrigger" } } }, "description": "" } } }, "delete": { "operationId": "workflow_triggers_destroy", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow trigger.", "required": true } ], "tags": [ "workflow_triggers" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } }, "/api/workflows/": { "get": { "operationId": "workflows_list", "parameters": [ { "name": "page", "required": false, "in": "query", "description": "A page number within the paginated result set.", "schema": { "type": "integer" } }, { "name": "page_size", "required": false, "in": "query", "description": "Number of results to return per page.", "schema": { "type": "integer" } } ], "tags": [ "workflows" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PaginatedWorkflowList" } } }, "description": "" } } }, "post": { "operationId": "workflows_create", "tags": [ "workflows" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/WorkflowRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/WorkflowRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "201": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workflow" } } }, "description": "" } } } }, "/api/workflows/{id}/": { "get": { "operationId": "workflows_retrieve", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow.", "required": true } ], "tags": [ "workflows" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workflow" } } }, "description": "" } } }, "put": { "operationId": "workflows_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow.", "required": true } ], "tags": [ "workflows" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkflowRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/WorkflowRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/WorkflowRequest" } } }, "required": true }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workflow" } } }, "description": "" } } }, "patch": { "operationId": "workflows_partial_update", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow.", "required": true } ], "tags": [ "workflows" ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PatchedWorkflowRequest" } }, "application/x-www-form-urlencoded": { "schema": { "$ref": "#/components/schemas/PatchedWorkflowRequest" } }, "multipart/form-data": { "schema": { "$ref": "#/components/schemas/PatchedWorkflowRequest" } } } }, "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "200": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workflow" } } }, "description": "" } } }, "delete": { "operationId": "workflows_destroy", "parameters": [ { "in": "path", "name": "id", "schema": { "type": "integer" }, "description": "A unique integer value identifying this workflow.", "required": true } ], "tags": [ "workflows" ], "security": [ { "PaperelessBasicAuthentication": [] }, { "tokenAuth": [] }, { "cookieAuth": [] } ], "responses": { "204": { "description": "No response body" } } } } }, "components": { "schemas": { "AISuggestions": { "type": "object", "properties": { "title": { "type": "string", "nullable": true }, "correspondents": { "type": "array", "items": { "type": "integer" } }, "suggested_correspondents": { "type": "array", "items": { "type": "string" } }, "tags": { "type": "array", "items": { "type": "integer" } }, "suggested_tags": { "type": "array", "items": { "type": "string" } }, "document_types": { "type": "array", "items": { "type": "integer" } }, "suggested_document_types": { "type": "array", "items": { "type": "string" } }, "storage_paths": { "type": "array", "items": { "type": "integer" } }, "suggested_storage_paths": { "type": "array", "items": { "type": "string" } }, "dates": { "type": "array", "items": { "type": "string" } } }, "required": [ "correspondents", "dates", "document_types", "storage_paths", "suggested_correspondents", "suggested_document_types", "suggested_storage_paths", "suggested_tags", "tags", "title" ] }, "AccountTypeEnum": { "enum": [ 1, 2, 3 ], "type": "integer", "description": "* `1` - IMAP\n* `2` - Gmail OAuth\n* `3` - Outlook OAuth" }, "AcknowledgeTasks": { "type": "object", "properties": { "result": { "type": "integer" } }, "required": [ "result" ] }, "AcknowledgeTasksViewRequest": { "type": "object", "properties": { "tasks": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "all": { "type": "boolean", "writeOnly": true, "default": false } } }, "Actor": { "type": "object", "properties": { "id": { "type": "integer" }, "username": { "type": "string" } }, "required": [ "id", "username" ] }, "ApplicationConfiguration": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "user_args": { "nullable": true }, "barcode_tag_mapping": { "nullable": true }, "llm_api_key": { "type": "string", "nullable": true }, "output_type": { "nullable": true, "title": "Sets the output PDF type", "oneOf": [ { "$ref": "#/components/schemas/OutputTypeEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "pages": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Do OCR from page 1 to this value" }, "language": { "type": "string", "nullable": true, "title": "Do OCR using these languages", "maxLength": 32 }, "mode": { "nullable": true, "title": "Sets the OCR mode", "oneOf": [ { "$ref": "#/components/schemas/ModeEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "archive_file_generation": { "nullable": true, "title": "Controls archive file generation", "oneOf": [ { "$ref": "#/components/schemas/ArchiveFileGenerationEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "image_dpi": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets image DPI fallback value" }, "unpaper_clean": { "nullable": true, "title": "Controls the unpaper cleaning", "oneOf": [ { "$ref": "#/components/schemas/UnpaperCleanEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "deskew": { "type": "boolean", "nullable": true, "title": "Enables deskew" }, "rotate_pages": { "type": "boolean", "nullable": true, "title": "Enables page rotation" }, "rotate_pages_threshold": { "type": "number", "format": "double", "minimum": 0.0, "nullable": true, "title": "Sets the threshold for rotation of pages" }, "max_image_pixels": { "type": "number", "format": "double", "minimum": 0.0, "nullable": true, "title": "Sets the maximum image size for decompression" }, "color_conversion_strategy": { "nullable": true, "title": "Sets the Ghostscript color conversion strategy", "oneOf": [ { "$ref": "#/components/schemas/ColorConversionStrategyEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "app_title": { "type": "string", "nullable": true, "title": "Application title", "maxLength": 48 }, "app_logo": { "type": "string", "format": "uri", "nullable": true, "title": "Application logo", "pattern": "(?:jpg|png|gif|svg)$" }, "barcodes_enabled": { "type": "boolean", "nullable": true, "title": "Enables barcode scanning" }, "barcode_enable_tiff_support": { "type": "boolean", "nullable": true, "title": "Enables barcode TIFF support" }, "barcode_string": { "type": "string", "nullable": true, "title": "Sets the barcode string", "maxLength": 32 }, "barcode_retain_split_pages": { "type": "boolean", "nullable": true, "title": "Retains split pages" }, "barcode_enable_asn": { "type": "boolean", "nullable": true, "title": "Enables ASN barcode" }, "barcode_asn_prefix": { "type": "string", "nullable": true, "title": "Sets the ASN barcode prefix", "maxLength": 32 }, "barcode_upscale": { "type": "number", "format": "double", "minimum": 1.0, "nullable": true, "title": "Sets the barcode upscale factor" }, "barcode_dpi": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the barcode DPI" }, "barcode_max_pages": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the maximum pages for barcode" }, "barcode_enable_tag": { "type": "boolean", "nullable": true, "title": "Enables tag barcode" }, "barcode_tag_split": { "type": "boolean", "nullable": true, "title": "Enables splitting on tag barcodes" }, "ai_enabled": { "type": "boolean", "nullable": true, "title": "Enables AI features" }, "llm_embedding_backend": { "nullable": true, "title": "Sets the LLM embedding backend", "oneOf": [ { "$ref": "#/components/schemas/LlmEmbeddingBackendEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "llm_embedding_model": { "type": "string", "nullable": true, "title": "Sets the LLM embedding model", "maxLength": 128 }, "llm_embedding_endpoint": { "type": "string", "nullable": true, "title": "Sets the LLM embedding endpoint, optional", "maxLength": 256 }, "llm_embedding_chunk_size": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the LLM embedding chunk size" }, "llm_context_size": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the LLM context size" }, "llm_backend": { "nullable": true, "title": "Sets the LLM backend", "oneOf": [ { "$ref": "#/components/schemas/LlmBackendEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "llm_model": { "type": "string", "nullable": true, "title": "Sets the LLM model", "maxLength": 128 }, "llm_endpoint": { "type": "string", "nullable": true, "title": "Sets the LLM endpoint, optional", "maxLength": 256 }, "llm_output_language": { "type": "string", "nullable": true, "title": "Sets the LLM output language", "maxLength": 32 }, "llm_request_timeout": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the LLM timeout in seconds" } }, "required": [ "barcode_tag_mapping", "id", "user_args" ] }, "ApplicationConfigurationRequest": { "type": "object", "properties": { "user_args": { "nullable": true }, "barcode_tag_mapping": { "nullable": true }, "llm_api_key": { "type": "string", "nullable": true, "minLength": 1 }, "output_type": { "nullable": true, "title": "Sets the output PDF type", "oneOf": [ { "$ref": "#/components/schemas/OutputTypeEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "pages": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Do OCR from page 1 to this value" }, "language": { "type": "string", "nullable": true, "title": "Do OCR using these languages", "maxLength": 32 }, "mode": { "nullable": true, "title": "Sets the OCR mode", "oneOf": [ { "$ref": "#/components/schemas/ModeEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "archive_file_generation": { "nullable": true, "title": "Controls archive file generation", "oneOf": [ { "$ref": "#/components/schemas/ArchiveFileGenerationEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "image_dpi": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets image DPI fallback value" }, "unpaper_clean": { "nullable": true, "title": "Controls the unpaper cleaning", "oneOf": [ { "$ref": "#/components/schemas/UnpaperCleanEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "deskew": { "type": "boolean", "nullable": true, "title": "Enables deskew" }, "rotate_pages": { "type": "boolean", "nullable": true, "title": "Enables page rotation" }, "rotate_pages_threshold": { "type": "number", "format": "double", "minimum": 0.0, "nullable": true, "title": "Sets the threshold for rotation of pages" }, "max_image_pixels": { "type": "number", "format": "double", "minimum": 0.0, "nullable": true, "title": "Sets the maximum image size for decompression" }, "color_conversion_strategy": { "nullable": true, "title": "Sets the Ghostscript color conversion strategy", "oneOf": [ { "$ref": "#/components/schemas/ColorConversionStrategyEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "app_title": { "type": "string", "nullable": true, "title": "Application title", "maxLength": 48 }, "app_logo": { "type": "string", "format": "binary", "nullable": true, "title": "Application logo", "pattern": "(?:jpg|png|gif|svg)$" }, "barcodes_enabled": { "type": "boolean", "nullable": true, "title": "Enables barcode scanning" }, "barcode_enable_tiff_support": { "type": "boolean", "nullable": true, "title": "Enables barcode TIFF support" }, "barcode_string": { "type": "string", "nullable": true, "title": "Sets the barcode string", "maxLength": 32 }, "barcode_retain_split_pages": { "type": "boolean", "nullable": true, "title": "Retains split pages" }, "barcode_enable_asn": { "type": "boolean", "nullable": true, "title": "Enables ASN barcode" }, "barcode_asn_prefix": { "type": "string", "nullable": true, "title": "Sets the ASN barcode prefix", "maxLength": 32 }, "barcode_upscale": { "type": "number", "format": "double", "minimum": 1.0, "nullable": true, "title": "Sets the barcode upscale factor" }, "barcode_dpi": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the barcode DPI" }, "barcode_max_pages": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the maximum pages for barcode" }, "barcode_enable_tag": { "type": "boolean", "nullable": true, "title": "Enables tag barcode" }, "barcode_tag_split": { "type": "boolean", "nullable": true, "title": "Enables splitting on tag barcodes" }, "ai_enabled": { "type": "boolean", "nullable": true, "title": "Enables AI features" }, "llm_embedding_backend": { "nullable": true, "title": "Sets the LLM embedding backend", "oneOf": [ { "$ref": "#/components/schemas/LlmEmbeddingBackendEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "llm_embedding_model": { "type": "string", "nullable": true, "title": "Sets the LLM embedding model", "maxLength": 128 }, "llm_embedding_endpoint": { "type": "string", "nullable": true, "title": "Sets the LLM embedding endpoint, optional", "maxLength": 256 }, "llm_embedding_chunk_size": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the LLM embedding chunk size" }, "llm_context_size": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the LLM context size" }, "llm_backend": { "nullable": true, "title": "Sets the LLM backend", "oneOf": [ { "$ref": "#/components/schemas/LlmBackendEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "llm_model": { "type": "string", "nullable": true, "title": "Sets the LLM model", "maxLength": 128 }, "llm_endpoint": { "type": "string", "nullable": true, "title": "Sets the LLM endpoint, optional", "maxLength": 256 }, "llm_output_language": { "type": "string", "nullable": true, "title": "Sets the LLM output language", "maxLength": 32 }, "llm_request_timeout": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the LLM timeout in seconds" } }, "required": [ "barcode_tag_mapping", "user_args" ] }, "ArchiveFileGenerationEnum": { "enum": [ "auto", "always", "never" ], "type": "string", "description": "* `auto` - auto\n* `always` - always\n* `never` - never" }, "ArchiveMetadataEntry": { "type": "object", "properties": { "namespace": { "type": "string" }, "prefix": { "type": "string" }, "key": { "type": "string" }, "value": { "type": "string" } }, "required": [ "key", "namespace", "prefix", "value" ] }, "AssignCorrespondentFromEnum": { "enum": [ 1, 2, 3, 4 ], "type": "integer", "description": "* `1` - Do not assign a correspondent\n* `2` - Use mail address\n* `3` - Use name (or mail address if not available)\n* `4` - Use correspondent selected below" }, "AssignTitleFromEnum": { "enum": [ 1, 2, 3 ], "type": "integer", "description": "* `1` - Use subject as title\n* `2` - Use attachment filename as title\n* `3` - Do not assign title from rule" }, "AttachmentTypeEnum": { "enum": [ 1, 2 ], "type": "integer", "description": "* `1` - Only process attachments.\n* `2` - Process all files, including 'inline' attachments." }, "BasicUser": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "username": { "type": "string", "description": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", "pattern": "^[\\w.@+-]+$", "maxLength": 150 }, "first_name": { "type": "string", "maxLength": 150 }, "last_name": { "type": "string", "maxLength": 150 } }, "required": [ "id", "username" ] }, "BasicUserRequest": { "type": "object", "properties": { "username": { "type": "string", "minLength": 1, "description": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", "pattern": "^[\\w.@+-]+$", "maxLength": 150 }, "first_name": { "type": "string", "maxLength": 150 }, "last_name": { "type": "string", "maxLength": 150 } }, "required": [ "username" ] }, "BlankEnum": { "enum": [ "" ] }, "BulkDeleteMailRequestRequest": { "type": "object", "properties": { "mail_ids": { "type": "array", "items": { "type": "integer" } } }, "required": [ "mail_ids" ] }, "BulkDeleteMailResponse": { "type": "object", "properties": { "result": { "type": "string" }, "deleted_mail_ids": { "type": "array", "items": { "type": "integer" } } }, "required": [ "deleted_mail_ids", "result" ] }, "BulkDownloadRequest": { "type": "object", "properties": { "documents": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "all": { "type": "boolean", "writeOnly": true, "default": false }, "filters": { "type": "object", "additionalProperties": {}, "writeOnly": true }, "content": { "allOf": [ { "$ref": "#/components/schemas/ContentEnum" } ], "default": "archive" }, "compression": { "allOf": [ { "$ref": "#/components/schemas/CompressionEnum" } ], "default": "none" }, "follow_formatting": { "type": "boolean", "default": false } } }, "BulkEditDocumentsResult": { "type": "object", "properties": { "result": { "type": "string" } }, "required": [ "result" ] }, "BulkEditObjectsRequest": { "type": "object", "properties": { "objects": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "all": { "type": "boolean", "writeOnly": true, "default": false }, "filters": { "type": "object", "additionalProperties": {}, "writeOnly": true }, "object_type": { "allOf": [ { "$ref": "#/components/schemas/ObjectTypeEnum" } ], "writeOnly": true }, "operation": { "allOf": [ { "$ref": "#/components/schemas/OperationEnum" } ], "writeOnly": true }, "owner": { "type": "integer", "nullable": true }, "permissions": { "type": "object", "additionalProperties": {}, "writeOnly": true, "title": "Set permissions" }, "merge": { "type": "boolean", "writeOnly": true, "default": false } }, "required": [ "object_type", "operation" ] }, "BulkEditRequest": { "type": "object", "properties": { "documents": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "all": { "type": "boolean", "writeOnly": true, "default": false }, "filters": { "type": "object", "additionalProperties": {}, "writeOnly": true }, "method": { "allOf": [ { "$ref": "#/components/schemas/MethodEnum" } ], "writeOnly": true }, "parameters": { "type": "object", "additionalProperties": {}, "writeOnly": true, "default": {} }, "from_webui": { "type": "boolean", "default": false } }, "required": [ "method" ] }, "BulkEditResult": { "type": "object", "properties": { "result": { "type": "string" } }, "required": [ "result" ] }, "ChatStreaming": { "type": "object", "properties": { "q": { "type": "string", "maxLength": 4000 }, "document_id": { "type": "integer", "nullable": true } }, "required": [ "q" ] }, "ChatStreamingRequest": { "type": "object", "properties": { "q": { "type": "string", "minLength": 1, "maxLength": 4000 }, "document_id": { "type": "integer", "nullable": true } }, "required": [ "q" ] }, "Classifier": { "type": "object", "properties": { "status": { "type": "string" }, "error": { "type": "string" }, "last_trained": { "type": "string", "format": "date-time" } }, "required": [ "error", "last_trained", "status" ] }, "ColorConversionStrategyEnum": { "enum": [ "LeaveColorUnchanged", "RGB", "UseDeviceIndependentColor", "Gray", "CMYK" ], "type": "string", "description": "* `LeaveColorUnchanged` - LeaveColorUnchanged\n* `RGB` - RGB\n* `UseDeviceIndependentColor` - UseDeviceIndependentColor\n* `Gray` - Gray\n* `CMYK` - CMYK" }, "CompressionEnum": { "enum": [ "none", "deflated", "bzip2", "lzma" ], "type": "string", "description": "* `none` - none\n* `deflated` - deflated\n* `bzip2` - bzip2\n* `lzma` - lzma" }, "ConsumptionScopeEnum": { "enum": [ 1, 2, 3 ], "type": "integer", "description": "* `1` - Only process attachments.\n* `2` - Process full Mail (with embedded attachments in file) as .eml\n* `3` - Process full Mail (with embedded attachments in file) as .eml + process attachments as separate documents" }, "ContentEnum": { "enum": [ "archive", "originals", "both" ], "type": "string", "description": "* `archive` - archive\n* `originals` - originals\n* `both` - both" }, "Correspondent": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "slug": { "type": "string", "readOnly": true }, "name": { "type": "string", "maxLength": 128 }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "document_count": { "type": "integer", "readOnly": true }, "last_correspondence": { "type": "string", "format": "date", "readOnly": true }, "owner": { "type": "integer", "nullable": true }, "permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "readOnly": true }, "user_can_change": { "type": "boolean", "readOnly": true } }, "required": [ "document_count", "id", "last_correspondence", "name", "permissions", "slug", "user_can_change" ] }, "CorrespondentCounts": { "type": "object", "properties": { "id": { "type": "integer" }, "document_count": { "type": "integer" } }, "required": [ "document_count", "id" ] }, "CorrespondentRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true } }, "required": [ "name" ] }, "CustomField": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "name": { "type": "string", "maxLength": 128 }, "data_type": { "$ref": "#/components/schemas/DataTypeEnum" }, "extra_data": { "nullable": true, "description": "Extra data for the custom field, such as select options" }, "document_count": { "type": "integer", "readOnly": true } }, "required": [ "data_type", "document_count", "id", "name" ] }, "CustomFieldCounts": { "type": "object", "properties": { "id": { "type": "integer" }, "document_count": { "type": "integer" } }, "required": [ "document_count", "id" ] }, "CustomFieldInstance": { "type": "object", "properties": { "value": { "oneOf": [ { "type": "string" }, { "type": "integer" }, { "type": "number", "format": "double" }, { "type": "object", "additionalProperties": {} } ], "nullable": true, "description": "Given the *incoming* primitive data, return the value for this field\nthat should be validated and transformed to a native value." }, "field": { "type": "integer" } }, "required": [ "field", "value" ] }, "CustomFieldInstanceRequest": { "type": "object", "properties": { "value": { "oneOf": [ { "type": "string" }, { "type": "integer" }, { "type": "number", "format": "double" }, { "type": "object", "additionalProperties": {} } ], "nullable": true, "description": "Given the *incoming* primitive data, return the value for this field\nthat should be validated and transformed to a native value." }, "field": { "type": "integer" } }, "required": [ "field", "value" ] }, "CustomFieldRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "data_type": { "$ref": "#/components/schemas/DataTypeEnum" }, "extra_data": { "nullable": true, "description": "Extra data for the custom field, such as select options" } }, "required": [ "data_type", "name" ] }, "DataTypeEnum": { "enum": [ "string", "url", "date", "boolean", "integer", "float", "monetary", "documentlink", "select", "longtext" ], "type": "string", "description": "* `string` - string\n* `url` - url\n* `date` - date\n* `boolean` - boolean\n* `integer` - integer\n* `float` - float\n* `monetary` - monetary\n* `documentlink` - documentlink\n* `select` - select\n* `longtext` - longtext" }, "Database": { "type": "object", "properties": { "type": { "type": "string" }, "url": { "type": "string" }, "status": { "type": "string" }, "error": { "type": "string" }, "migration_status": { "$ref": "#/components/schemas/MigrationStatus" } }, "required": [ "error", "migration_status", "status", "type", "url" ] }, "DeleteDocumentsRequest": { "type": "object", "properties": { "documents": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "all": { "type": "boolean", "writeOnly": true, "default": false }, "filters": { "type": "object", "additionalProperties": {}, "writeOnly": true } } }, "DeleteDocumentsResult": { "type": "object", "properties": { "result": { "type": "string" } }, "required": [ "result" ] }, "DisplayModeEnum": { "enum": [ "table", "smallCards", "largeCards" ], "type": "string", "description": "* `table` - Table\n* `smallCards` - Small Cards\n* `largeCards` - Large Cards" }, "Document": { "type": "object", "description": "Adds update nested feature", "properties": { "id": { "type": "integer", "readOnly": true }, "correspondent": { "type": "integer", "nullable": true }, "document_type": { "type": "integer", "nullable": true }, "storage_path": { "type": "integer", "nullable": true }, "title": { "type": "string", "maxLength": 128 }, "content": { "type": "string", "description": "The raw, text-only data of the document. This field is primarily used for searching." }, "tags": { "type": "array", "items": { "type": "integer" } }, "created": { "type": "string", "format": "date" }, "created_date": { "type": "string", "format": "date", "deprecated": true }, "modified": { "type": "string", "format": "date-time", "readOnly": true }, "added": { "type": "string", "format": "date-time", "readOnly": true }, "deleted_at": { "type": "string", "format": "date-time", "nullable": true }, "archive_serial_number": { "type": "integer", "maximum": 4294967295, "minimum": 0, "format": "int64", "nullable": true, "description": "The position of this document in your physical document archive." }, "original_file_name": { "type": "string", "nullable": true, "readOnly": true }, "archived_file_name": { "type": "string", "nullable": true, "readOnly": true }, "duplicate_documents": { "type": "array", "items": { "$ref": "#/components/schemas/DuplicateDocumentSummary" }, "readOnly": true }, "owner": { "type": "integer", "nullable": true }, "permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "readOnly": true }, "user_can_change": { "type": "boolean", "readOnly": true }, "is_shared_by_requester": { "type": "boolean", "readOnly": true }, "notes": { "type": "array", "items": { "$ref": "#/components/schemas/Notes" }, "readOnly": true }, "custom_fields": { "type": "array", "items": { "$ref": "#/components/schemas/CustomFieldInstance" } }, "page_count": { "type": "integer", "nullable": true, "readOnly": true }, "mime_type": { "type": "string", "readOnly": true }, "root_document": { "type": "integer", "readOnly": true }, "versions": { "type": "array", "items": { "$ref": "#/components/schemas/DocumentVersionInfo" }, "readOnly": true } }, "required": [ "added", "archived_file_name", "correspondent", "document_type", "duplicate_documents", "id", "is_shared_by_requester", "mime_type", "modified", "notes", "original_file_name", "page_count", "permissions", "root_document", "storage_path", "tags", "user_can_change", "versions" ] }, "DocumentListRequest": { "type": "object", "properties": { "documents": { "type": "array", "items": { "type": "integer" }, "writeOnly": true } }, "required": [ "documents" ] }, "DocumentRequest": { "type": "object", "description": "Adds update nested feature", "properties": { "correspondent": { "type": "integer", "nullable": true }, "document_type": { "type": "integer", "nullable": true }, "storage_path": { "type": "integer", "nullable": true }, "title": { "type": "string", "maxLength": 128 }, "content": { "type": "string", "description": "The raw, text-only data of the document. This field is primarily used for searching." }, "tags": { "type": "array", "items": { "type": "integer" } }, "created": { "type": "string", "format": "date" }, "created_date": { "type": "string", "format": "date", "deprecated": true }, "deleted_at": { "type": "string", "format": "date-time", "nullable": true }, "archive_serial_number": { "type": "integer", "maximum": 4294967295, "minimum": 0, "format": "int64", "nullable": true, "description": "The position of this document in your physical document archive." }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true }, "custom_fields": { "type": "array", "items": { "$ref": "#/components/schemas/CustomFieldInstanceRequest" } }, "remove_inbox_tags": { "type": "boolean", "writeOnly": true, "nullable": true, "default": false } }, "required": [ "correspondent", "document_type", "storage_path", "tags" ] }, "DocumentRootResponse": { "type": "object", "properties": { "root_id": { "type": "integer" } }, "required": [ "root_id" ] }, "DocumentType": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "slug": { "type": "string", "readOnly": true }, "name": { "type": "string", "maxLength": 128 }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "document_count": { "type": "integer", "readOnly": true }, "owner": { "type": "integer", "nullable": true }, "permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "readOnly": true }, "user_can_change": { "type": "boolean", "readOnly": true } }, "required": [ "document_count", "id", "name", "permissions", "slug", "user_can_change" ] }, "DocumentTypeCounts": { "type": "object", "properties": { "id": { "type": "integer" }, "document_count": { "type": "integer" } }, "required": [ "document_count", "id" ] }, "DocumentTypeRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true } }, "required": [ "name" ] }, "DocumentVersionInfo": { "type": "object", "properties": { "id": { "type": "integer" }, "added": { "type": "string", "format": "date-time" }, "version_label": { "type": "string", "nullable": true }, "checksum": { "type": "string", "nullable": true }, "is_root": { "type": "boolean" } }, "required": [ "added", "id", "is_root" ] }, "DocumentVersionRequest": { "type": "object", "properties": { "document": { "type": "string", "format": "binary", "writeOnly": true }, "version_label": { "type": "string", "nullable": true, "maxLength": 64 } }, "required": [ "document" ] }, "DuplicateDocumentSummary": { "type": "object", "properties": { "id": { "type": "integer" }, "title": { "type": "string" }, "deleted_at": { "type": "string", "format": "date-time", "nullable": true } }, "required": [ "deleted_at", "id", "title" ] }, "EditPdfDocumentsRequest": { "type": "object", "properties": { "documents": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "operations": { "type": "array", "items": {} }, "delete_original": { "type": "boolean", "default": false }, "update_document": { "type": "boolean", "default": false }, "include_metadata": { "type": "boolean", "default": true }, "source_mode": { "type": "string", "minLength": 1, "default": "latest_version" }, "from_webui": { "type": "boolean", "default": false } }, "required": [ "documents", "operations" ] }, "EditPdfDocumentsResult": { "type": "object", "properties": { "result": { "type": "string" } }, "required": [ "result" ] }, "EmailDocumentRequestRequest": { "type": "object", "properties": { "addresses": { "type": "string", "minLength": 1, "title": "Email addresses", "description": "Comma-separated email addresses" }, "subject": { "type": "string", "minLength": 1, "title": "Email subject" }, "message": { "type": "string", "minLength": 1, "title": "Email message" }, "use_archive_version": { "type": "boolean", "default": true, "description": "Use archive version of documents if available" } }, "required": [ "addresses", "message", "subject" ] }, "EmailDocumentResponse": { "type": "object", "properties": { "message": { "type": "string" } }, "required": [ "message" ] }, "EmailDocumentsResponse": { "type": "object", "properties": { "message": { "type": "string" } }, "required": [ "message" ] }, "EmailRequest": { "type": "object", "properties": { "documents": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "addresses": { "type": "string", "minLength": 1, "title": "Email addresses", "description": "Comma-separated email addresses" }, "subject": { "type": "string", "minLength": 1, "title": "Email subject" }, "message": { "type": "string", "minLength": 1, "title": "Email message" }, "use_archive_version": { "type": "boolean", "default": true, "description": "Use archive version of documents if available" } }, "required": [ "addresses", "documents", "message", "subject" ] }, "FileVersionEnum": { "enum": [ "archive", "original" ], "type": "string", "description": "* `archive` - Archive\n* `original` - Original" }, "Group": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "name": { "type": "string", "maxLength": 150 }, "permissions": { "type": "array", "items": { "type": "string" } } }, "required": [ "id", "name", "permissions" ] }, "GroupRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 150 }, "permissions": { "type": "array", "items": { "type": "string", "minLength": 1 } } }, "required": [ "name", "permissions" ] }, "ImapSecurityEnum": { "enum": [ 1, 2, 3 ], "type": "integer", "description": "* `1` - No encryption\n* `2` - Use SSL\n* `3` - Use STARTTLS" }, "Index": { "type": "object", "properties": { "status": { "type": "string" }, "error": { "type": "string" }, "last_modified": { "type": "string", "format": "date-time" } }, "required": [ "error", "last_modified", "status" ] }, "LlmBackendEnum": { "enum": [ "openai-like", "ollama" ], "type": "string", "description": "* `openai-like` - OpenAI-compatible\n* `ollama` - Ollama" }, "LlmEmbeddingBackendEnum": { "enum": [ "openai-like", "huggingface", "ollama" ], "type": "string", "description": "* `openai-like` - OpenAI-compatible\n* `huggingface` - Huggingface\n* `ollama` - Ollama" }, "LogEntry": { "type": "object", "properties": { "id": { "type": "integer" }, "timestamp": { "type": "string", "format": "date-time" }, "action": { "type": "string" }, "changes": { "type": "object", "additionalProperties": {} }, "actor": { "$ref": "#/components/schemas/Actor" } }, "required": [ "action", "actor", "changes", "id", "timestamp" ] }, "MailAccount": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "name": { "type": "string", "maxLength": 256 }, "imap_server": { "type": "string", "maxLength": 256 }, "imap_port": { "type": "integer", "maximum": 9223372036854775807, "minimum": 0, "format": "int64", "nullable": true, "description": "This is usually 143 for unencrypted and STARTTLS connections, and 993 for SSL connections." }, "imap_security": { "allOf": [ { "$ref": "#/components/schemas/ImapSecurityEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "username": { "type": "string", "maxLength": 256 }, "password": { "type": "string" }, "character_set": { "type": "string", "description": "The character set to use when communicating with the mail server, such as 'UTF-8' or 'US-ASCII'.", "maxLength": 256 }, "is_token": { "type": "boolean", "title": "Is token authentication" }, "owner": { "type": "integer", "nullable": true }, "user_can_change": { "type": "boolean", "readOnly": true }, "account_type": { "allOf": [ { "$ref": "#/components/schemas/AccountTypeEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "expiration": { "type": "string", "format": "date-time", "nullable": true, "description": "The expiration date of the refresh token. " } }, "required": [ "id", "imap_server", "name", "password", "user_can_change", "username" ] }, "MailAccountProcessResponse": { "type": "object", "properties": { "result": { "type": "string", "default": "OK" } } }, "MailAccountRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 256 }, "imap_server": { "type": "string", "minLength": 1, "maxLength": 256 }, "imap_port": { "type": "integer", "maximum": 9223372036854775807, "minimum": 0, "format": "int64", "nullable": true, "description": "This is usually 143 for unencrypted and STARTTLS connections, and 993 for SSL connections." }, "imap_security": { "allOf": [ { "$ref": "#/components/schemas/ImapSecurityEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "username": { "type": "string", "minLength": 1, "maxLength": 256 }, "password": { "type": "string", "minLength": 1 }, "character_set": { "type": "string", "minLength": 1, "description": "The character set to use when communicating with the mail server, such as 'UTF-8' or 'US-ASCII'.", "maxLength": 256 }, "is_token": { "type": "boolean", "title": "Is token authentication" }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true }, "account_type": { "allOf": [ { "$ref": "#/components/schemas/AccountTypeEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "expiration": { "type": "string", "format": "date-time", "nullable": true, "description": "The expiration date of the refresh token. " } }, "required": [ "imap_server", "name", "password", "username" ] }, "MailAccountTestResponse": { "type": "object", "properties": { "success": { "type": "boolean" } }, "required": [ "success" ] }, "MailRule": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "name": { "type": "string", "maxLength": 256 }, "account": { "type": "integer" }, "enabled": { "type": "boolean" }, "folder": { "type": "string", "description": "Subfolders must be separated by a delimiter, often a dot ('.') or slash ('/'), but it varies by mail server.", "maxLength": 256 }, "filter_from": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_to": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_subject": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_body": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_attachment_filename_include": { "type": "string", "nullable": true, "title": "Filter attachment filename inclusive", "description": "Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", "maxLength": 256 }, "filter_attachment_filename_exclude": { "type": "string", "nullable": true, "title": "Filter attachment filename exclusive", "description": "Do not consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", "maxLength": 256 }, "maximum_age": { "type": "integer", "maximum": 9223372036854775807, "minimum": 0, "format": "int64", "description": "Specified in days." }, "action": { "allOf": [ { "$ref": "#/components/schemas/MailRuleActionEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "action_parameter": { "type": "string", "nullable": true, "default": "" }, "assign_title_from": { "allOf": [ { "$ref": "#/components/schemas/AssignTitleFromEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "assign_tags": { "type": "array", "items": { "type": "integer", "nullable": true } }, "assign_correspondent_from": { "allOf": [ { "$ref": "#/components/schemas/AssignCorrespondentFromEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "assign_correspondent": { "type": "integer", "nullable": true }, "assign_document_type": { "type": "integer", "nullable": true }, "assign_owner_from_rule": { "type": "boolean", "title": "Assign the rule owner to documents" }, "order": { "type": "integer" }, "attachment_type": { "allOf": [ { "$ref": "#/components/schemas/AttachmentTypeEnum" } ], "description": "Inline attachments include embedded images, so it's best to combine this option with a filename filter.\n\n* `1` - Only process attachments.\n* `2` - Process all files, including 'inline' attachments.", "minimum": 0, "maximum": 9223372036854775807 }, "consumption_scope": { "allOf": [ { "$ref": "#/components/schemas/ConsumptionScopeEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "pdf_layout": { "allOf": [ { "$ref": "#/components/schemas/PdfLayoutEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "owner": { "type": "integer", "nullable": true }, "user_can_change": { "type": "boolean", "readOnly": true }, "stop_processing": { "type": "boolean", "title": "Stop processing further rules", "description": "If True, no further rules will be processed after this one if any document is queued." } }, "required": [ "account", "id", "name", "user_can_change" ] }, "MailRuleActionEnum": { "enum": [ 1, 2, 3, 4, 5 ], "type": "integer", "description": "* `1` - Delete\n* `2` - Move to specified folder\n* `3` - Mark as read, don't process read mails\n* `4` - Flag the mail, don't process flagged mails\n* `5` - Tag the mail with specified tag, don't process tagged mails" }, "MailRuleRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 256 }, "account": { "type": "integer" }, "enabled": { "type": "boolean" }, "folder": { "type": "string", "minLength": 1, "description": "Subfolders must be separated by a delimiter, often a dot ('.') or slash ('/'), but it varies by mail server.", "maxLength": 256 }, "filter_from": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_to": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_subject": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_body": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_attachment_filename_include": { "type": "string", "nullable": true, "title": "Filter attachment filename inclusive", "description": "Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", "maxLength": 256 }, "filter_attachment_filename_exclude": { "type": "string", "nullable": true, "title": "Filter attachment filename exclusive", "description": "Do not consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", "maxLength": 256 }, "maximum_age": { "type": "integer", "maximum": 9223372036854775807, "minimum": 0, "format": "int64", "description": "Specified in days." }, "action": { "allOf": [ { "$ref": "#/components/schemas/MailRuleActionEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "action_parameter": { "type": "string", "nullable": true, "minLength": 1, "default": "" }, "assign_title_from": { "allOf": [ { "$ref": "#/components/schemas/AssignTitleFromEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "assign_tags": { "type": "array", "items": { "type": "integer", "nullable": true } }, "assign_correspondent_from": { "allOf": [ { "$ref": "#/components/schemas/AssignCorrespondentFromEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "assign_correspondent": { "type": "integer", "nullable": true }, "assign_document_type": { "type": "integer", "nullable": true }, "assign_owner_from_rule": { "type": "boolean", "title": "Assign the rule owner to documents" }, "order": { "type": "integer" }, "attachment_type": { "allOf": [ { "$ref": "#/components/schemas/AttachmentTypeEnum" } ], "description": "Inline attachments include embedded images, so it's best to combine this option with a filename filter.\n\n* `1` - Only process attachments.\n* `2` - Process all files, including 'inline' attachments.", "minimum": 0, "maximum": 9223372036854775807 }, "consumption_scope": { "allOf": [ { "$ref": "#/components/schemas/ConsumptionScopeEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "pdf_layout": { "allOf": [ { "$ref": "#/components/schemas/PdfLayoutEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true }, "stop_processing": { "type": "boolean", "title": "Stop processing further rules", "description": "If True, no further rules will be processed after this one if any document is queued." } }, "required": [ "account", "name" ] }, "MatchingAlgorithm": { "enum": [ 0, 1, 2, 3, 4, 5, 6 ], "type": "integer", "description": "* `0` - None\n* `1` - Any word\n* `2` - All words\n* `3` - Exact match\n* `4` - Regular expression\n* `5` - Fuzzy word\n* `6` - Automatic" }, "MergeDocumentsRequest": { "type": "object", "properties": { "documents": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "metadata_document_id": { "type": "integer", "nullable": true }, "delete_originals": { "type": "boolean", "default": false }, "archive_fallback": { "type": "boolean", "default": false }, "source_mode": { "type": "string", "minLength": 1, "default": "latest_version" }, "from_webui": { "type": "boolean", "default": false } }, "required": [ "documents" ] }, "MergeDocumentsResult": { "type": "object", "properties": { "result": { "type": "string" } }, "required": [ "result" ] }, "Metadata": { "type": "object", "properties": { "original_checksum": { "type": "string" }, "original_size": { "type": "integer" }, "original_mime_type": { "type": "string" }, "media_filename": { "type": "string" }, "has_archive_version": { "type": "boolean" }, "original_metadata": { "type": "array", "items": { "$ref": "#/components/schemas/OriginalMetadataEntry" } }, "archive_checksum": { "type": "string", "nullable": true }, "archive_media_filename": { "type": "string", "nullable": true }, "original_filename": { "type": "string" }, "archive_size": { "type": "integer", "nullable": true }, "archive_metadata": { "type": "array", "items": { "$ref": "#/components/schemas/ArchiveMetadataEntry" }, "nullable": true }, "lang": { "type": "string" } }, "required": [ "has_archive_version", "lang", "media_filename", "original_checksum", "original_filename", "original_metadata", "original_mime_type", "original_size" ] }, "MethodEnum": { "enum": [ "set_correspondent", "set_document_type", "set_storage_path", "add_tag", "remove_tag", "modify_tags", "modify_custom_fields", "set_permissions", "delete", "reprocess", "rotate", "merge", "edit_pdf", "remove_password", "split", "delete_pages" ], "type": "string", "description": "* `set_correspondent` - set_correspondent\n* `set_document_type` - set_document_type\n* `set_storage_path` - set_storage_path\n* `add_tag` - add_tag\n* `remove_tag` - remove_tag\n* `modify_tags` - modify_tags\n* `modify_custom_fields` - modify_custom_fields\n* `set_permissions` - set_permissions\n* `delete` - delete\n* `reprocess` - reprocess\n* `rotate` - rotate\n* `merge` - merge\n* `edit_pdf` - edit_pdf\n* `remove_password` - remove_password\n* `split` - split\n* `delete_pages` - delete_pages" }, "MigrationStatus": { "type": "object", "properties": { "latest_migration": { "type": "string" }, "unapplied_migrations": { "type": "array", "items": { "type": "string" } } }, "required": [ "latest_migration", "unapplied_migrations" ] }, "ModeEnum": { "enum": [ "auto", "force", "redo", "off" ], "type": "string", "description": "* `auto` - auto\n* `force` - force\n* `redo` - redo\n* `off` - off" }, "NoteCreateRequestRequest": { "type": "object", "properties": { "note": { "type": "string", "minLength": 1 } }, "required": [ "note" ] }, "Notes": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "note": { "type": "string", "title": "Content", "description": "Note for the document" }, "created": { "type": "string", "format": "date-time" }, "user": { "allOf": [ { "$ref": "#/components/schemas/BasicUser" } ], "readOnly": true } }, "required": [ "id", "user" ] }, "NotesRequest": { "type": "object", "properties": { "note": { "type": "string", "title": "Content", "description": "Note for the document" }, "created": { "type": "string", "format": "date-time" } } }, "NullEnum": { "enum": [ null ] }, "ObjectTypeEnum": { "enum": [ "tags", "correspondents", "document_types", "storage_paths" ], "type": "string", "description": "* `tags` - tags\n* `correspondents` - correspondents\n* `document_types` - document_types\n* `storage_paths` - storage_paths" }, "OperationEnum": { "enum": [ "set_permissions", "delete" ], "type": "string", "description": "* `set_permissions` - set_permissions\n* `delete` - delete" }, "OriginalMetadataEntry": { "type": "object", "properties": { "namespace": { "type": "string" }, "prefix": { "type": "string" }, "key": { "type": "string" }, "value": { "type": "string" } }, "required": [ "key", "namespace", "prefix", "value" ] }, "OutputTypeEnum": { "enum": [ "pdf", "pdfa", "pdfa-1", "pdfa-2", "pdfa-3" ], "type": "string", "description": "* `pdf` - pdf\n* `pdfa` - pdfa\n* `pdfa-1` - pdfa-1\n* `pdfa-2` - pdfa-2\n* `pdfa-3` - pdfa-3" }, "PaginatedCorrespondentList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/Correspondent" } } } }, "PaginatedCustomFieldList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/CustomField" } } } }, "PaginatedDocumentList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/Document" } } } }, "PaginatedDocumentTypeList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/DocumentType" } } } }, "PaginatedGroupList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/Group" } } } }, "PaginatedLogEntryList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/LogEntry" } } } }, "PaginatedMailAccountList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/MailAccount" } } } }, "PaginatedMailRuleList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/MailRule" } } } }, "PaginatedProcessedMailList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/ProcessedMail" } } } }, "PaginatedSavedViewList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/SavedView" } } } }, "PaginatedShareLinkBundleList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/ShareLinkBundle" } } } }, "PaginatedShareLinkList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/ShareLink" } } } }, "PaginatedStoragePathList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/StoragePath" } } } }, "PaginatedTagList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/Tag" } } } }, "PaginatedTaskSerializerV10List": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/TaskSerializerV10" } } } }, "PaginatedUserList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/User" } } } }, "PaginatedWorkflowActionList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/WorkflowAction" } } } }, "PaginatedWorkflowList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/Workflow" } } } }, "PaginatedWorkflowTriggerList": { "type": "object", "required": [ "count", "results" ], "properties": { "count": { "type": "integer", "example": 123 }, "next": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=4" }, "previous": { "type": "string", "nullable": true, "format": "uri", "example": "http://api.example.org/accounts/?page=2" }, "results": { "type": "array", "items": { "$ref": "#/components/schemas/WorkflowTrigger" } } } }, "PaperlessAuthToken": { "type": "object", "properties": { "token": { "type": "string", "readOnly": true } }, "required": [ "token" ] }, "PaperlessAuthTokenRequest": { "type": "object", "properties": { "username": { "type": "string", "writeOnly": true, "minLength": 1 }, "password": { "type": "string", "writeOnly": true, "minLength": 1 }, "code": { "type": "string", "writeOnly": true, "minLength": 1, "title": "MFA Code" } }, "required": [ "password", "username" ] }, "PatchedApplicationConfigurationRequest": { "type": "object", "properties": { "user_args": { "nullable": true }, "barcode_tag_mapping": { "nullable": true }, "llm_api_key": { "type": "string", "nullable": true, "minLength": 1 }, "output_type": { "nullable": true, "title": "Sets the output PDF type", "oneOf": [ { "$ref": "#/components/schemas/OutputTypeEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "pages": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Do OCR from page 1 to this value" }, "language": { "type": "string", "nullable": true, "title": "Do OCR using these languages", "maxLength": 32 }, "mode": { "nullable": true, "title": "Sets the OCR mode", "oneOf": [ { "$ref": "#/components/schemas/ModeEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "archive_file_generation": { "nullable": true, "title": "Controls archive file generation", "oneOf": [ { "$ref": "#/components/schemas/ArchiveFileGenerationEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "image_dpi": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets image DPI fallback value" }, "unpaper_clean": { "nullable": true, "title": "Controls the unpaper cleaning", "oneOf": [ { "$ref": "#/components/schemas/UnpaperCleanEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "deskew": { "type": "boolean", "nullable": true, "title": "Enables deskew" }, "rotate_pages": { "type": "boolean", "nullable": true, "title": "Enables page rotation" }, "rotate_pages_threshold": { "type": "number", "format": "double", "minimum": 0.0, "nullable": true, "title": "Sets the threshold for rotation of pages" }, "max_image_pixels": { "type": "number", "format": "double", "minimum": 0.0, "nullable": true, "title": "Sets the maximum image size for decompression" }, "color_conversion_strategy": { "nullable": true, "title": "Sets the Ghostscript color conversion strategy", "oneOf": [ { "$ref": "#/components/schemas/ColorConversionStrategyEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "app_title": { "type": "string", "nullable": true, "title": "Application title", "maxLength": 48 }, "app_logo": { "type": "string", "format": "binary", "nullable": true, "title": "Application logo", "pattern": "(?:jpg|png|gif|svg)$" }, "barcodes_enabled": { "type": "boolean", "nullable": true, "title": "Enables barcode scanning" }, "barcode_enable_tiff_support": { "type": "boolean", "nullable": true, "title": "Enables barcode TIFF support" }, "barcode_string": { "type": "string", "nullable": true, "title": "Sets the barcode string", "maxLength": 32 }, "barcode_retain_split_pages": { "type": "boolean", "nullable": true, "title": "Retains split pages" }, "barcode_enable_asn": { "type": "boolean", "nullable": true, "title": "Enables ASN barcode" }, "barcode_asn_prefix": { "type": "string", "nullable": true, "title": "Sets the ASN barcode prefix", "maxLength": 32 }, "barcode_upscale": { "type": "number", "format": "double", "minimum": 1.0, "nullable": true, "title": "Sets the barcode upscale factor" }, "barcode_dpi": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the barcode DPI" }, "barcode_max_pages": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the maximum pages for barcode" }, "barcode_enable_tag": { "type": "boolean", "nullable": true, "title": "Enables tag barcode" }, "barcode_tag_split": { "type": "boolean", "nullable": true, "title": "Enables splitting on tag barcodes" }, "ai_enabled": { "type": "boolean", "nullable": true, "title": "Enables AI features" }, "llm_embedding_backend": { "nullable": true, "title": "Sets the LLM embedding backend", "oneOf": [ { "$ref": "#/components/schemas/LlmEmbeddingBackendEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "llm_embedding_model": { "type": "string", "nullable": true, "title": "Sets the LLM embedding model", "maxLength": 128 }, "llm_embedding_endpoint": { "type": "string", "nullable": true, "title": "Sets the LLM embedding endpoint, optional", "maxLength": 256 }, "llm_embedding_chunk_size": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the LLM embedding chunk size" }, "llm_context_size": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the LLM context size" }, "llm_backend": { "nullable": true, "title": "Sets the LLM backend", "oneOf": [ { "$ref": "#/components/schemas/LlmBackendEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "llm_model": { "type": "string", "nullable": true, "title": "Sets the LLM model", "maxLength": 128 }, "llm_endpoint": { "type": "string", "nullable": true, "title": "Sets the LLM endpoint, optional", "maxLength": 256 }, "llm_output_language": { "type": "string", "nullable": true, "title": "Sets the LLM output language", "maxLength": 32 }, "llm_request_timeout": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "Sets the LLM timeout in seconds" } } }, "PatchedCorrespondentRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true } } }, "PatchedCustomFieldRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "data_type": { "$ref": "#/components/schemas/DataTypeEnum" }, "extra_data": { "nullable": true, "description": "Extra data for the custom field, such as select options" } } }, "PatchedDocumentRequest": { "type": "object", "description": "Adds update nested feature", "properties": { "correspondent": { "type": "integer", "nullable": true }, "document_type": { "type": "integer", "nullable": true }, "storage_path": { "type": "integer", "nullable": true }, "title": { "type": "string", "maxLength": 128 }, "content": { "type": "string", "description": "The raw, text-only data of the document. This field is primarily used for searching." }, "tags": { "type": "array", "items": { "type": "integer" } }, "created": { "type": "string", "format": "date" }, "created_date": { "type": "string", "format": "date", "deprecated": true }, "deleted_at": { "type": "string", "format": "date-time", "nullable": true }, "archive_serial_number": { "type": "integer", "maximum": 4294967295, "minimum": 0, "format": "int64", "nullable": true, "description": "The position of this document in your physical document archive." }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true }, "custom_fields": { "type": "array", "items": { "$ref": "#/components/schemas/CustomFieldInstanceRequest" } }, "remove_inbox_tags": { "type": "boolean", "writeOnly": true, "nullable": true, "default": false } } }, "PatchedDocumentTypeRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true } } }, "PatchedDocumentVersionLabelRequest": { "type": "object", "properties": { "version_label": { "type": "string", "nullable": true, "maxLength": 64 } } }, "PatchedGroupRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 150 }, "permissions": { "type": "array", "items": { "type": "string", "minLength": 1 } } } }, "PatchedMailAccountRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 256 }, "imap_server": { "type": "string", "minLength": 1, "maxLength": 256 }, "imap_port": { "type": "integer", "maximum": 9223372036854775807, "minimum": 0, "format": "int64", "nullable": true, "description": "This is usually 143 for unencrypted and STARTTLS connections, and 993 for SSL connections." }, "imap_security": { "allOf": [ { "$ref": "#/components/schemas/ImapSecurityEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "username": { "type": "string", "minLength": 1, "maxLength": 256 }, "password": { "type": "string", "minLength": 1 }, "character_set": { "type": "string", "minLength": 1, "description": "The character set to use when communicating with the mail server, such as 'UTF-8' or 'US-ASCII'.", "maxLength": 256 }, "is_token": { "type": "boolean", "title": "Is token authentication" }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true }, "account_type": { "allOf": [ { "$ref": "#/components/schemas/AccountTypeEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "expiration": { "type": "string", "format": "date-time", "nullable": true, "description": "The expiration date of the refresh token. " } } }, "PatchedMailRuleRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 256 }, "account": { "type": "integer" }, "enabled": { "type": "boolean" }, "folder": { "type": "string", "minLength": 1, "description": "Subfolders must be separated by a delimiter, often a dot ('.') or slash ('/'), but it varies by mail server.", "maxLength": 256 }, "filter_from": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_to": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_subject": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_body": { "type": "string", "nullable": true, "maxLength": 256 }, "filter_attachment_filename_include": { "type": "string", "nullable": true, "title": "Filter attachment filename inclusive", "description": "Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", "maxLength": 256 }, "filter_attachment_filename_exclude": { "type": "string", "nullable": true, "title": "Filter attachment filename exclusive", "description": "Do not consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", "maxLength": 256 }, "maximum_age": { "type": "integer", "maximum": 9223372036854775807, "minimum": 0, "format": "int64", "description": "Specified in days." }, "action": { "allOf": [ { "$ref": "#/components/schemas/MailRuleActionEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "action_parameter": { "type": "string", "nullable": true, "minLength": 1, "default": "" }, "assign_title_from": { "allOf": [ { "$ref": "#/components/schemas/AssignTitleFromEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "assign_tags": { "type": "array", "items": { "type": "integer", "nullable": true } }, "assign_correspondent_from": { "allOf": [ { "$ref": "#/components/schemas/AssignCorrespondentFromEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "assign_correspondent": { "type": "integer", "nullable": true }, "assign_document_type": { "type": "integer", "nullable": true }, "assign_owner_from_rule": { "type": "boolean", "title": "Assign the rule owner to documents" }, "order": { "type": "integer" }, "attachment_type": { "allOf": [ { "$ref": "#/components/schemas/AttachmentTypeEnum" } ], "description": "Inline attachments include embedded images, so it's best to combine this option with a filename filter.\n\n* `1` - Only process attachments.\n* `2` - Process all files, including 'inline' attachments.", "minimum": 0, "maximum": 9223372036854775807 }, "consumption_scope": { "allOf": [ { "$ref": "#/components/schemas/ConsumptionScopeEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "pdf_layout": { "allOf": [ { "$ref": "#/components/schemas/PdfLayoutEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true }, "stop_processing": { "type": "boolean", "title": "Stop processing further rules", "description": "If True, no further rules will be processed after this one if any document is queued." } } }, "PatchedProfileRequest": { "type": "object", "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string", "minLength": 1 }, "first_name": { "type": "string", "maxLength": 150 }, "last_name": { "type": "string", "maxLength": 150 } } }, "PatchedSavedViewRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "sort_field": { "type": "string", "nullable": true, "maxLength": 128 }, "sort_reverse": { "type": "boolean" }, "filter_rules": { "type": "array", "items": { "$ref": "#/components/schemas/SavedViewFilterRuleRequest" } }, "page_size": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "View page size" }, "display_mode": { "nullable": true, "title": "View display mode", "oneOf": [ { "$ref": "#/components/schemas/DisplayModeEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "display_fields": { "nullable": true, "title": "Document display fields" }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true } } }, "PatchedShareLinkBundleRequest": { "type": "object", "properties": { "expiration_days": { "type": "integer", "minimum": 1, "writeOnly": true, "nullable": true }, "file_version": { "$ref": "#/components/schemas/FileVersionEnum" }, "document_ids": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "writeOnly": true } } }, "PatchedStoragePathRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "path": { "type": "string", "minLength": 1 }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true } } }, "PatchedTagRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "color": { "type": "string", "minLength": 1, "maxLength": 7 }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "is_inbox_tag": { "type": "boolean", "description": "Marks this tag as an inbox tag: All newly consumed documents will be tagged with inbox tags." }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true }, "parent": { "type": "integer", "nullable": true } } }, "PatchedUserRequest": { "type": "object", "properties": { "username": { "type": "string", "minLength": 1, "description": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", "pattern": "^[\\w.@+-]+$", "maxLength": 150 }, "email": { "type": "string", "format": "email", "title": "Email address", "maxLength": 254 }, "password": { "type": "string", "minLength": 1 }, "first_name": { "type": "string", "maxLength": 150 }, "last_name": { "type": "string", "maxLength": 150 }, "date_joined": { "type": "string", "format": "date-time" }, "is_staff": { "type": "boolean", "title": "Staff status", "description": "Designates whether the user can log into this admin site." }, "is_active": { "type": "boolean", "title": "Active", "description": "Designates whether this user should be treated as active. Unselect this instead of deleting accounts." }, "is_superuser": { "type": "boolean", "title": "Superuser status", "description": "Designates that this user has all permissions without explicitly assigning them." }, "groups": { "type": "array", "items": { "type": "integer" }, "description": "The groups this user belongs to. A user will get all permissions granted to each of their groups." }, "user_permissions": { "type": "array", "items": { "type": "string", "minLength": 1 } } } }, "PatchedWorkflowActionRequest": { "type": "object", "properties": { "id": { "type": "integer", "nullable": true }, "type": { "allOf": [ { "$ref": "#/components/schemas/WorkflowActionTypeEnum" } ], "title": "Workflow Action Type", "minimum": 0, "maximum": 9223372036854775807 }, "assign_title": { "type": "string", "nullable": true, "description": "Assign a document title, must be a Jinja2 template, see documentation." }, "assign_tags": { "type": "array", "items": { "type": "integer", "nullable": true } }, "assign_correspondent": { "type": "integer", "nullable": true }, "assign_document_type": { "type": "integer", "nullable": true }, "assign_storage_path": { "type": "integer", "nullable": true }, "assign_owner": { "type": "integer", "nullable": true, "title": "Assign this owner" }, "assign_view_users": { "type": "array", "items": { "type": "integer", "title": "Grant view permissions to these users" }, "title": "Grant view permissions to these users" }, "assign_view_groups": { "type": "array", "items": { "type": "integer", "title": "Grant view permissions to these groups" }, "title": "Grant view permissions to these groups" }, "assign_change_users": { "type": "array", "items": { "type": "integer", "title": "Grant change permissions to these users" }, "title": "Grant change permissions to these users" }, "assign_change_groups": { "type": "array", "items": { "type": "integer", "title": "Grant change permissions to these groups" }, "title": "Grant change permissions to these groups" }, "assign_custom_fields": { "type": "array", "items": { "type": "integer", "title": "Assign these custom fields" }, "title": "Assign these custom fields" }, "assign_custom_fields_values": { "nullable": true, "title": "Custom field values", "description": "Optional values to assign to the custom fields." }, "remove_all_tags": { "type": "boolean" }, "remove_tags": { "type": "array", "items": { "type": "integer", "title": "Remove these tag(s)" }, "title": "Remove these tag(s)" }, "remove_all_correspondents": { "type": "boolean" }, "remove_correspondents": { "type": "array", "items": { "type": "integer", "title": "Remove these correspondent(s)" }, "title": "Remove these correspondent(s)" }, "remove_all_document_types": { "type": "boolean" }, "remove_document_types": { "type": "array", "items": { "type": "integer", "title": "Remove these document type(s)" }, "title": "Remove these document type(s)" }, "remove_all_storage_paths": { "type": "boolean" }, "remove_storage_paths": { "type": "array", "items": { "type": "integer", "title": "Remove these storage path(s)" }, "title": "Remove these storage path(s)" }, "remove_custom_fields": { "type": "array", "items": { "type": "integer", "title": "Remove these custom fields" }, "title": "Remove these custom fields" }, "remove_all_custom_fields": { "type": "boolean" }, "remove_all_owners": { "type": "boolean" }, "remove_owners": { "type": "array", "items": { "type": "integer", "title": "Remove these owner(s)" }, "title": "Remove these owner(s)" }, "remove_all_permissions": { "type": "boolean" }, "remove_view_users": { "type": "array", "items": { "type": "integer", "title": "Remove view permissions for these users" }, "title": "Remove view permissions for these users" }, "remove_view_groups": { "type": "array", "items": { "type": "integer", "title": "Remove view permissions for these groups" }, "title": "Remove view permissions for these groups" }, "remove_change_users": { "type": "array", "items": { "type": "integer", "title": "Remove change permissions for these users" }, "title": "Remove change permissions for these users" }, "remove_change_groups": { "type": "array", "items": { "type": "integer", "title": "Remove change permissions for these groups" }, "title": "Remove change permissions for these groups" }, "email": { "allOf": [ { "$ref": "#/components/schemas/WorkflowActionEmailRequest" } ], "nullable": true }, "webhook": { "allOf": [ { "$ref": "#/components/schemas/WorkflowActionWebhookRequest" } ], "nullable": true }, "passwords": { "nullable": true, "description": "Passwords to try when removing PDF protection. Separate with commas or new lines." } } }, "PatchedWorkflowRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 256 }, "order": { "type": "integer" }, "enabled": { "type": "boolean" }, "triggers": { "type": "array", "items": { "$ref": "#/components/schemas/WorkflowTriggerRequest" } }, "actions": { "type": "array", "items": { "$ref": "#/components/schemas/WorkflowActionRequest" } } } }, "PatchedWorkflowTriggerRequest": { "type": "object", "properties": { "id": { "type": "integer", "nullable": true }, "sources": { "type": "array", "items": { "$ref": "#/components/schemas/SourcesEnum" }, "default": [ 1, 2, 3 ] }, "type": { "allOf": [ { "$ref": "#/components/schemas/WorkflowTriggerTypeEnum" } ], "title": "Trigger Type" }, "filter_path": { "type": "string", "nullable": true, "description": "Only consume documents with a path that matches this if specified. Wildcards specified as * are allowed. Case insensitive.", "maxLength": 256 }, "filter_filename": { "type": "string", "nullable": true, "description": "Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", "maxLength": 256 }, "filter_mailrule": { "type": "integer", "nullable": true, "title": "Filter documents from this mail rule" }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/WorkflowTriggerMatchingAlgorithmEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "match": { "type": "string", "maxLength": 256 }, "is_insensitive": { "type": "boolean" }, "filter_has_tags": { "type": "array", "items": { "type": "integer", "title": "Has these tag(s)" }, "title": "Has these tag(s)" }, "filter_has_all_tags": { "type": "array", "items": { "type": "integer", "title": "Has all of these tag(s)" }, "title": "Has all of these tag(s)" }, "filter_has_not_tags": { "type": "array", "items": { "type": "integer", "title": "Does not have these tag(s)" }, "title": "Does not have these tag(s)" }, "filter_custom_field_query": { "type": "string", "nullable": true, "description": "JSON-encoded custom field query expression." }, "filter_has_any_correspondents": { "type": "array", "items": { "type": "integer", "title": "Has one of these correspondents" }, "title": "Has one of these correspondents" }, "filter_has_not_correspondents": { "type": "array", "items": { "type": "integer", "title": "Does not have these correspondent(s)" }, "title": "Does not have these correspondent(s)" }, "filter_has_any_document_types": { "type": "array", "items": { "type": "integer", "title": "Has one of these document types" }, "title": "Has one of these document types" }, "filter_has_not_document_types": { "type": "array", "items": { "type": "integer", "title": "Does not have these document type(s)" }, "title": "Does not have these document type(s)" }, "filter_has_any_storage_paths": { "type": "array", "items": { "type": "integer", "title": "Has one of these storage paths" }, "title": "Has one of these storage paths" }, "filter_has_not_storage_paths": { "type": "array", "items": { "type": "integer", "title": "Does not have these storage path(s)" }, "title": "Does not have these storage path(s)" }, "filter_has_correspondent": { "type": "integer", "nullable": true, "title": "Has this correspondent" }, "filter_has_document_type": { "type": "integer", "nullable": true, "title": "Has this document type" }, "filter_has_storage_path": { "type": "integer", "nullable": true, "title": "Has this storage path" }, "schedule_offset_days": { "type": "integer", "maximum": 9223372036854775807, "minimum": -9223372036854775808, "format": "int64", "description": "The number of days to offset the schedule trigger by." }, "schedule_is_recurring": { "type": "boolean", "description": "If the schedule should be recurring." }, "schedule_recurring_interval_days": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "title": "Schedule recurring delay in days", "description": "The number of days between recurring schedule triggers." }, "schedule_date_field": { "allOf": [ { "$ref": "#/components/schemas/ScheduleDateFieldEnum" } ], "description": "The field to check for a schedule trigger.\n\n* `added` - Added\n* `created` - Created\n* `modified` - Modified\n* `custom_field` - Custom Field" }, "schedule_date_custom_field": { "type": "integer", "nullable": true } } }, "PdfLayoutEnum": { "enum": [ 0, 1, 2, 3, 4 ], "type": "integer", "description": "* `0` - System default\n* `1` - Text, then HTML\n* `2` - HTML, then text\n* `3` - HTML only\n* `4` - Text only" }, "PostDocumentRequest": { "type": "object", "properties": { "created": { "type": "string", "format": "date-time", "writeOnly": true, "nullable": true }, "document": { "type": "string", "format": "binary", "writeOnly": true }, "title": { "type": "string", "writeOnly": true, "minLength": 1 }, "correspondent": { "type": "integer", "writeOnly": true, "nullable": true }, "document_type": { "type": "integer", "writeOnly": true, "nullable": true }, "storage_path": { "type": "integer", "writeOnly": true, "nullable": true }, "tags": { "type": "array", "items": { "type": "integer", "writeOnly": true, "title": "Tags" }, "writeOnly": true }, "archive_serial_number": { "type": "integer", "maximum": 4294967295, "minimum": 0, "format": "int64", "writeOnly": true, "title": "ASN" }, "custom_fields": { "writeOnly": true }, "from_webui": { "type": "boolean", "writeOnly": true, "title": "Documents are from Paperless-ngx WebUI" } }, "required": [ "document" ] }, "ProcessedMail": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "owner": { "type": "integer", "nullable": true }, "rule": { "type": "integer", "readOnly": true }, "folder": { "type": "string", "readOnly": true }, "uid": { "type": "string", "readOnly": true }, "subject": { "type": "string", "readOnly": true }, "received": { "type": "string", "format": "date-time", "readOnly": true }, "processed": { "type": "string", "format": "date-time", "readOnly": true }, "status": { "type": "string", "readOnly": true }, "error": { "type": "string", "readOnly": true, "nullable": true } }, "required": [ "error", "folder", "id", "processed", "received", "rule", "status", "subject", "uid" ] }, "Profile": { "type": "object", "properties": { "email": { "type": "string", "format": "email" }, "password": { "type": "string" }, "first_name": { "type": "string", "maxLength": 150 }, "last_name": { "type": "string", "maxLength": 150 }, "auth_token": { "type": "string", "readOnly": true }, "social_accounts": { "type": "array", "items": { "$ref": "#/components/schemas/SocialAccount" }, "readOnly": true }, "has_usable_password": { "type": "boolean", "readOnly": true }, "is_mfa_enabled": { "type": "boolean", "readOnly": true } }, "required": [ "auth_token", "has_usable_password", "is_mfa_enabled", "social_accounts" ] }, "RebuildBundleError": { "type": "object", "properties": { "detail": { "type": "string" } }, "required": [ "detail" ] }, "RemovePasswordDocumentsRequest": { "type": "object", "properties": { "documents": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "password": { "type": "string", "minLength": 1 }, "update_document": { "type": "boolean", "default": false }, "delete_original": { "type": "boolean", "default": false }, "include_metadata": { "type": "boolean", "default": true }, "source_mode": { "type": "string", "minLength": 1, "default": "latest_version" }, "from_webui": { "type": "boolean", "default": false } }, "required": [ "documents", "password" ] }, "RemovePasswordDocumentsResult": { "type": "object", "properties": { "result": { "type": "string" } }, "required": [ "result" ] }, "ReprocessDocumentsRequest": { "type": "object", "properties": { "documents": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "all": { "type": "boolean", "writeOnly": true, "default": false }, "filters": { "type": "object", "additionalProperties": {}, "writeOnly": true } } }, "ReprocessDocumentsResult": { "type": "object", "properties": { "result": { "type": "string" } }, "required": [ "result" ] }, "RotateDocumentsRequest": { "type": "object", "properties": { "documents": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "all": { "type": "boolean", "writeOnly": true, "default": false }, "filters": { "type": "object", "additionalProperties": {}, "writeOnly": true }, "degrees": { "type": "integer" }, "source_mode": { "type": "string", "minLength": 1, "default": "latest_version" }, "from_webui": { "type": "boolean", "default": false } }, "required": [ "degrees" ] }, "RotateDocumentsResult": { "type": "object", "properties": { "result": { "type": "string" } }, "required": [ "result" ] }, "RuleTypeEnum": { "enum": [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49 ], "type": "integer", "description": "* `0` - title contains\n* `1` - content contains\n* `2` - ASN is\n* `3` - correspondent is\n* `4` - document type is\n* `5` - is in inbox\n* `6` - has tag\n* `7` - has any tag\n* `8` - created before\n* `9` - created after\n* `10` - created year is\n* `11` - created month is\n* `12` - created day is\n* `13` - added before\n* `14` - added after\n* `15` - modified before\n* `16` - modified after\n* `17` - does not have tag\n* `18` - does not have ASN\n* `19` - title or content contains\n* `20` - fulltext query\n* `21` - more like this\n* `22` - has tags in\n* `23` - ASN greater than\n* `24` - ASN less than\n* `25` - storage path is\n* `26` - has correspondent in\n* `27` - does not have correspondent in\n* `28` - has document type in\n* `29` - does not have document type in\n* `30` - has storage path in\n* `31` - does not have storage path in\n* `32` - owner is\n* `33` - has owner in\n* `34` - does not have owner\n* `35` - does not have owner in\n* `36` - has custom field value\n* `37` - is shared by me\n* `38` - has custom fields\n* `39` - has custom field in\n* `40` - does not have custom field in\n* `41` - does not have custom field\n* `42` - custom fields query\n* `43` - created to\n* `44` - created from\n* `45` - added to\n* `46` - added from\n* `47` - mime type is\n* `48` - simple title search\n* `49` - simple text search" }, "RunTask": { "type": "object", "properties": { "task_id": { "type": "string" } }, "required": [ "task_id" ] }, "RunTaskError": { "type": "object", "properties": { "error": { "type": "string" } }, "required": [ "error" ] }, "RunTaskRequest": { "type": "object", "properties": { "task_type": { "allOf": [ { "$ref": "#/components/schemas/TaskTypeEnum" } ], "writeOnly": true } }, "required": [ "task_type" ] }, "SanityCheck": { "type": "object", "properties": { "status": { "type": "string" }, "error": { "type": "string" }, "last_run": { "type": "string", "format": "date-time" } }, "required": [ "error", "last_run", "status" ] }, "SavedView": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "name": { "type": "string", "maxLength": 128 }, "sort_field": { "type": "string", "nullable": true, "maxLength": 128 }, "sort_reverse": { "type": "boolean" }, "filter_rules": { "type": "array", "items": { "$ref": "#/components/schemas/SavedViewFilterRule" } }, "page_size": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "View page size" }, "display_mode": { "nullable": true, "title": "View display mode", "oneOf": [ { "$ref": "#/components/schemas/DisplayModeEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "display_fields": { "nullable": true, "title": "Document display fields" }, "owner": { "type": "integer", "nullable": true }, "permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "readOnly": true }, "user_can_change": { "type": "boolean", "readOnly": true } }, "required": [ "filter_rules", "id", "name", "permissions", "user_can_change" ] }, "SavedViewFilterRule": { "type": "object", "properties": { "rule_type": { "allOf": [ { "$ref": "#/components/schemas/RuleTypeEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "value": { "type": "string", "nullable": true, "maxLength": 255 } }, "required": [ "rule_type" ] }, "SavedViewFilterRuleRequest": { "type": "object", "properties": { "rule_type": { "allOf": [ { "$ref": "#/components/schemas/RuleTypeEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "value": { "type": "string", "nullable": true, "maxLength": 255 } }, "required": [ "rule_type" ] }, "SavedViewRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "sort_field": { "type": "string", "nullable": true, "maxLength": 128 }, "sort_reverse": { "type": "boolean" }, "filter_rules": { "type": "array", "items": { "$ref": "#/components/schemas/SavedViewFilterRuleRequest" } }, "page_size": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "nullable": true, "title": "View page size" }, "display_mode": { "nullable": true, "title": "View display mode", "oneOf": [ { "$ref": "#/components/schemas/DisplayModeEnum" }, { "$ref": "#/components/schemas/BlankEnum" }, { "$ref": "#/components/schemas/NullEnum" } ] }, "display_fields": { "nullable": true, "title": "Document display fields" }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true } }, "required": [ "filter_rules", "name" ] }, "ScheduleDateFieldEnum": { "enum": [ "added", "created", "modified", "custom_field" ], "type": "string", "description": "* `added` - Added\n* `created` - Created\n* `modified` - Modified\n* `custom_field` - Custom Field" }, "SearchResult": { "type": "object", "properties": { "total": { "type": "integer" }, "documents": { "type": "array", "items": { "$ref": "#/components/schemas/Document" } }, "saved_views": { "type": "array", "items": { "$ref": "#/components/schemas/SavedView" } }, "tags": { "type": "array", "items": { "$ref": "#/components/schemas/Tag" } }, "correspondents": { "type": "array", "items": { "$ref": "#/components/schemas/Correspondent" } }, "document_types": { "type": "array", "items": { "$ref": "#/components/schemas/DocumentType" } }, "storage_paths": { "type": "array", "items": { "$ref": "#/components/schemas/StoragePath" } }, "users": { "type": "array", "items": { "$ref": "#/components/schemas/User" } }, "groups": { "type": "array", "items": { "$ref": "#/components/schemas/Group" } }, "mail_rules": { "type": "array", "items": { "$ref": "#/components/schemas/MailRule" } }, "mail_accounts": { "type": "array", "items": { "$ref": "#/components/schemas/MailAccount" } }, "workflows": { "type": "array", "items": { "$ref": "#/components/schemas/Workflow" } }, "custom_fields": { "type": "array", "items": { "$ref": "#/components/schemas/CustomField" } } }, "required": [ "correspondents", "custom_fields", "document_types", "documents", "groups", "mail_accounts", "mail_rules", "saved_views", "storage_paths", "tags", "total", "users", "workflows" ] }, "SelectionData": { "type": "object", "properties": { "selected_correspondents": { "type": "array", "items": { "$ref": "#/components/schemas/CorrespondentCounts" } }, "selected_tags": { "type": "array", "items": { "$ref": "#/components/schemas/TagCounts" } }, "selected_document_types": { "type": "array", "items": { "$ref": "#/components/schemas/DocumentTypeCounts" } }, "selected_storage_paths": { "type": "array", "items": { "$ref": "#/components/schemas/StoragePathCounts" } }, "selected_custom_fields": { "type": "array", "items": { "$ref": "#/components/schemas/CustomFieldCounts" } } }, "required": [ "selected_correspondents", "selected_custom_fields", "selected_document_types", "selected_storage_paths", "selected_tags" ] }, "ShareLink": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "created": { "type": "string", "format": "date-time", "readOnly": true }, "expiration": { "type": "string", "format": "date-time", "nullable": true }, "slug": { "type": "string", "readOnly": true, "pattern": "^[-a-zA-Z0-9_]+$" }, "document": { "type": "integer" }, "file_version": { "$ref": "#/components/schemas/FileVersionEnum" } }, "required": [ "created", "id", "slug" ] }, "ShareLinkBundle": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "created": { "type": "string", "format": "date-time", "readOnly": true }, "expiration": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, "slug": { "type": "string", "readOnly": true, "pattern": "^[-a-zA-Z0-9_]+$" }, "file_version": { "$ref": "#/components/schemas/FileVersionEnum" }, "status": { "allOf": [ { "$ref": "#/components/schemas/ShareLinkBundleStatusEnum" } ], "readOnly": true }, "size_bytes": { "type": "integer", "readOnly": true, "nullable": true, "title": "Size (bytes)" }, "last_error": { "readOnly": true, "nullable": true }, "built_at": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true }, "documents": { "type": "array", "items": { "type": "integer" }, "readOnly": true }, "document_count": { "type": "integer", "readOnly": true } }, "required": [ "built_at", "created", "document_count", "documents", "expiration", "id", "last_error", "size_bytes", "slug", "status" ] }, "ShareLinkBundleRequest": { "type": "object", "properties": { "expiration_days": { "type": "integer", "minimum": 1, "writeOnly": true, "nullable": true }, "file_version": { "$ref": "#/components/schemas/FileVersionEnum" }, "document_ids": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "writeOnly": true } }, "required": [ "document_ids" ] }, "ShareLinkBundleStatusEnum": { "enum": [ "pending", "processing", "ready", "failed" ], "type": "string", "description": "* `pending` - Pending\n* `processing` - Processing\n* `ready` - Ready\n* `failed` - Failed" }, "ShareLinkRequest": { "type": "object", "properties": { "expiration": { "type": "string", "format": "date-time", "nullable": true }, "document": { "type": "integer" }, "file_version": { "$ref": "#/components/schemas/FileVersionEnum" } } }, "SocialAccount": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "provider": { "type": "string", "maxLength": 200 }, "name": { "type": "string", "readOnly": true } }, "required": [ "id", "name", "provider" ] }, "SocialAccountRequest": { "type": "object", "properties": { "provider": { "type": "string", "minLength": 1, "maxLength": 200 } }, "required": [ "provider" ] }, "SourcesEnum": { "enum": [ 1, 2, 3, 4 ], "type": "integer", "description": "* `1` - Consume Folder\n* `2` - Api Upload\n* `3` - Mail Fetch\n* `4` - Web UI" }, "Storage": { "type": "object", "properties": { "total": { "type": "integer" }, "available": { "type": "integer" } }, "required": [ "available", "total" ] }, "StoragePath": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "slug": { "type": "string", "readOnly": true }, "name": { "type": "string", "maxLength": 128 }, "path": { "type": "string" }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "document_count": { "type": "integer", "readOnly": true }, "owner": { "type": "integer", "nullable": true }, "user_can_change": { "type": "boolean", "readOnly": true } }, "required": [ "document_count", "id", "name", "path", "slug", "user_can_change" ] }, "StoragePathCounts": { "type": "object", "properties": { "id": { "type": "integer" }, "document_count": { "type": "integer" } }, "required": [ "document_count", "id" ] }, "StoragePathRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "path": { "type": "string", "minLength": 1 }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true } }, "required": [ "name", "path" ] }, "StoragePathTestRequest": { "type": "object", "properties": { "path": { "type": "string", "writeOnly": true, "minLength": 1 }, "document": { "type": "integer", "writeOnly": true } }, "required": [ "document", "path" ] }, "Suggestions": { "type": "object", "properties": { "correspondents": { "type": "array", "items": { "type": "integer" } }, "tags": { "type": "array", "items": { "type": "integer" } }, "document_types": { "type": "array", "items": { "type": "integer" } }, "storage_paths": { "type": "array", "items": { "type": "integer" } }, "dates": { "type": "array", "items": { "type": "string" } } }, "required": [ "correspondents", "dates", "document_types", "storage_paths", "tags" ] }, "SystemStatus": { "type": "object", "properties": { "pngx_version": { "type": "string" }, "server_os": { "type": "string" }, "install_type": { "type": "string" }, "storage": { "$ref": "#/components/schemas/Storage" }, "database": { "$ref": "#/components/schemas/Database" }, "tasks": { "$ref": "#/components/schemas/Tasks" }, "index": { "$ref": "#/components/schemas/Index" }, "classifier": { "$ref": "#/components/schemas/Classifier" }, "sanity_check": { "$ref": "#/components/schemas/SanityCheck" } }, "required": [ "classifier", "database", "index", "install_type", "pngx_version", "sanity_check", "server_os", "storage", "tasks" ] }, "Tag": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "slug": { "type": "string", "readOnly": true }, "name": { "type": "string", "maxLength": 128 }, "color": { "type": "string", "maxLength": 7 }, "text_color": { "type": "string", "readOnly": true }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "is_inbox_tag": { "type": "boolean", "description": "Marks this tag as an inbox tag: All newly consumed documents will be tagged with inbox tags." }, "document_count": { "type": "integer", "readOnly": true }, "owner": { "type": "integer", "nullable": true }, "user_can_change": { "type": "boolean", "readOnly": true }, "parent": { "type": "integer", "nullable": true }, "children": { "type": "array", "items": { "type": "integer" }, "readOnly": true } }, "required": [ "children", "document_count", "id", "name", "slug", "text_color", "user_can_change" ] }, "TagCounts": { "type": "object", "properties": { "id": { "type": "integer" }, "document_count": { "type": "integer" } }, "required": [ "document_count", "id" ] }, "TagRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 128 }, "color": { "type": "string", "minLength": 1, "maxLength": 7 }, "match": { "type": "string", "maxLength": 256 }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/MatchingAlgorithm" } ], "minimum": 0, "maximum": 9223372036854775807 }, "is_insensitive": { "type": "boolean" }, "is_inbox_tag": { "type": "boolean", "description": "Marks this tag as an inbox tag: All newly consumed documents will be tagged with inbox tags." }, "owner": { "type": "integer", "nullable": true }, "set_permissions": { "type": "object", "properties": { "view": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } }, "change": { "type": "object", "properties": { "users": { "type": "array", "items": { "type": "integer" } }, "groups": { "type": "array", "items": { "type": "integer" } } } } }, "writeOnly": true }, "parent": { "type": "integer", "nullable": true } }, "required": [ "name" ] }, "TaskSerializerV10": { "type": "object", "description": "Task serializer for API v10+ using new field names.", "properties": { "id": { "type": "integer", "readOnly": true }, "task_id": { "type": "string", "readOnly": true, "description": "Celery task ID" }, "task_type": { "allOf": [ { "$ref": "#/components/schemas/TaskTypeEnum" } ], "readOnly": true, "description": "The kind of work being performed\n\n* `consume_file` - Consume File\n* `train_classifier` - Train Classifier\n* `sanity_check` - Sanity Check\n* `index_optimize` - Index Optimize\n* `mail_fetch` - Mail Fetch\n* `llm_index` - LLM Index\n* `empty_trash` - Empty Trash\n* `check_workflows` - Check Workflows\n* `bulk_update` - Bulk Update\n* `reprocess_document` - Reprocess Document\n* `build_share_link` - Build Share Link\n* `bulk_delete` - Bulk Delete" }, "task_type_display": { "type": "string", "readOnly": true }, "trigger_source": { "allOf": [ { "$ref": "#/components/schemas/TriggerSourceEnum" } ], "readOnly": true, "description": "What initiated this task\n\n* `scheduled` - Scheduled\n* `web_ui` - Web UI\n* `api_upload` - API Upload\n* `folder_consume` - Folder Consume\n* `email_consume` - Email Consume\n* `system` - System\n* `manual` - Manual" }, "trigger_source_display": { "type": "string", "readOnly": true }, "status": { "allOf": [ { "$ref": "#/components/schemas/TaskSerializerV10StatusEnum" } ], "readOnly": true }, "status_display": { "type": "string", "readOnly": true }, "date_created": { "type": "string", "format": "date-time", "readOnly": true, "title": "Created" }, "date_started": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true, "title": "Started" }, "date_done": { "type": "string", "format": "date-time", "readOnly": true, "nullable": true, "title": "Completed" }, "duration_seconds": { "type": "number", "format": "double", "readOnly": true, "nullable": true, "title": "Duration (seconds)", "description": "Elapsed time from start to completion" }, "wait_time_seconds": { "type": "number", "format": "double", "readOnly": true, "nullable": true, "title": "Wait Time (seconds)", "description": "Time from task creation to worker pickup" }, "input_data": { "readOnly": true, "description": "Structured input parameters for the task" }, "result_data": { "readOnly": true, "nullable": true, "description": "Structured result data from task execution" }, "related_document_ids": { "type": "array", "items": { "type": "integer" }, "readOnly": true }, "acknowledged": { "type": "boolean", "readOnly": true }, "owner": { "type": "integer", "readOnly": true, "nullable": true } }, "required": [ "acknowledged", "date_created", "date_done", "date_started", "duration_seconds", "id", "input_data", "owner", "related_document_ids", "result_data", "status", "status_display", "task_id", "task_type", "task_type_display", "trigger_source", "trigger_source_display", "wait_time_seconds" ] }, "TaskSerializerV10StatusEnum": { "enum": [ "pending", "started", "success", "failure", "revoked" ], "type": "string", "description": "* `pending` - Pending\n* `started` - Started\n* `success` - Success\n* `failure` - Failure\n* `revoked` - Revoked" }, "TaskStatusCounts": { "type": "object", "properties": { "all": { "type": "integer" }, "needs_attention": { "type": "integer" }, "in_progress": { "type": "integer" }, "completed": { "type": "integer" } }, "required": [ "all", "completed", "in_progress", "needs_attention" ] }, "TaskSummary": { "type": "object", "properties": { "task_type": { "type": "string" }, "total_count": { "type": "integer" }, "pending_count": { "type": "integer" }, "success_count": { "type": "integer" }, "failure_count": { "type": "integer" }, "avg_duration_seconds": { "type": "number", "format": "double", "nullable": true }, "avg_wait_time_seconds": { "type": "number", "format": "double", "nullable": true }, "last_run": { "type": "string", "format": "date-time", "nullable": true }, "last_success": { "type": "string", "format": "date-time", "nullable": true }, "last_failure": { "type": "string", "format": "date-time", "nullable": true } }, "required": [ "avg_duration_seconds", "avg_wait_time_seconds", "failure_count", "last_failure", "last_run", "last_success", "pending_count", "success_count", "task_type", "total_count" ] }, "TaskTypeEnum": { "enum": [ "consume_file", "train_classifier", "sanity_check", "index_optimize", "mail_fetch", "llm_index", "empty_trash", "check_workflows", "bulk_update", "reprocess_document", "build_share_link", "bulk_delete" ], "type": "string", "description": "* `consume_file` - Consume File\n* `train_classifier` - Train Classifier\n* `sanity_check` - Sanity Check\n* `index_optimize` - Index Optimize\n* `mail_fetch` - Mail Fetch\n* `llm_index` - LLM Index\n* `empty_trash` - Empty Trash\n* `check_workflows` - Check Workflows\n* `bulk_update` - Bulk Update\n* `reprocess_document` - Reprocess Document\n* `build_share_link` - Build Share Link\n* `bulk_delete` - Bulk Delete" }, "Tasks": { "type": "object", "properties": { "redis_url": { "type": "string" }, "redis_status": { "type": "string" }, "redis_error": { "type": "string" }, "celery_status": { "type": "string" }, "summary": { "$ref": "#/components/schemas/TasksSummaryOverview" } }, "required": [ "celery_status", "redis_error", "redis_status", "redis_url", "summary" ] }, "TasksSummaryOverview": { "type": "object", "properties": { "days": { "type": "integer" }, "total_count": { "type": "integer" }, "pending_count": { "type": "integer" }, "success_count": { "type": "integer" }, "failure_count": { "type": "integer" } }, "required": [ "days", "failure_count", "pending_count", "success_count", "total_count" ] }, "TrashActionEnum": { "enum": [ "restore", "empty" ], "type": "string", "description": "* `restore` - restore\n* `empty` - empty" }, "TrashRequest": { "type": "object", "properties": { "documents": { "type": "array", "items": { "type": "integer" }, "writeOnly": true }, "action": { "allOf": [ { "$ref": "#/components/schemas/TrashActionEnum" } ], "writeOnly": true } }, "required": [ "action" ] }, "TriggerSourceEnum": { "enum": [ "scheduled", "web_ui", "api_upload", "folder_consume", "email_consume", "system", "manual" ], "type": "string", "description": "* `scheduled` - Scheduled\n* `web_ui` - Web UI\n* `api_upload` - API Upload\n* `folder_consume` - Folder Consume\n* `email_consume` - Email Consume\n* `system` - System\n* `manual` - Manual" }, "UiSettingsView": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "settings": { "type": "object", "additionalProperties": {}, "nullable": true } }, "required": [ "id" ] }, "UiSettingsViewRequest": { "type": "object", "properties": { "settings": { "type": "object", "additionalProperties": {}, "nullable": true } } }, "UnpaperCleanEnum": { "enum": [ "clean", "clean-final", "none" ], "type": "string", "description": "* `clean` - clean\n* `clean-final` - clean-final\n* `none` - none" }, "UpdateDocumentVersionLabelResult": { "type": "object", "properties": { "id": { "type": "integer" }, "added": { "type": "string", "format": "date-time" }, "version_label": { "type": "string", "nullable": true }, "checksum": { "type": "string", "nullable": true }, "is_root": { "type": "boolean" } }, "required": [ "added", "id", "is_root" ] }, "User": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "username": { "type": "string", "description": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", "pattern": "^[\\w.@+-]+$", "maxLength": 150 }, "email": { "type": "string", "format": "email", "title": "Email address", "maxLength": 254 }, "password": { "type": "string" }, "first_name": { "type": "string", "maxLength": 150 }, "last_name": { "type": "string", "maxLength": 150 }, "date_joined": { "type": "string", "format": "date-time" }, "is_staff": { "type": "boolean", "title": "Staff status", "description": "Designates whether the user can log into this admin site." }, "is_active": { "type": "boolean", "title": "Active", "description": "Designates whether this user should be treated as active. Unselect this instead of deleting accounts." }, "is_superuser": { "type": "boolean", "title": "Superuser status", "description": "Designates that this user has all permissions without explicitly assigning them." }, "groups": { "type": "array", "items": { "type": "integer" }, "description": "The groups this user belongs to. A user will get all permissions granted to each of their groups." }, "user_permissions": { "type": "array", "items": { "type": "string" } }, "inherited_permissions": { "type": "array", "items": { "type": "string" }, "readOnly": true }, "is_mfa_enabled": { "type": "boolean", "readOnly": true } }, "required": [ "id", "inherited_permissions", "is_mfa_enabled", "username" ] }, "UserRequest": { "type": "object", "properties": { "username": { "type": "string", "minLength": 1, "description": "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", "pattern": "^[\\w.@+-]+$", "maxLength": 150 }, "email": { "type": "string", "format": "email", "title": "Email address", "maxLength": 254 }, "password": { "type": "string", "minLength": 1 }, "first_name": { "type": "string", "maxLength": 150 }, "last_name": { "type": "string", "maxLength": 150 }, "date_joined": { "type": "string", "format": "date-time" }, "is_staff": { "type": "boolean", "title": "Staff status", "description": "Designates whether the user can log into this admin site." }, "is_active": { "type": "boolean", "title": "Active", "description": "Designates whether this user should be treated as active. Unselect this instead of deleting accounts." }, "is_superuser": { "type": "boolean", "title": "Superuser status", "description": "Designates that this user has all permissions without explicitly assigning them." }, "groups": { "type": "array", "items": { "type": "integer" }, "description": "The groups this user belongs to. A user will get all permissions granted to each of their groups." }, "user_permissions": { "type": "array", "items": { "type": "string", "minLength": 1 } } }, "required": [ "username" ] }, "Workflow": { "type": "object", "properties": { "id": { "type": "integer", "readOnly": true }, "name": { "type": "string", "maxLength": 256 }, "order": { "type": "integer" }, "enabled": { "type": "boolean" }, "triggers": { "type": "array", "items": { "$ref": "#/components/schemas/WorkflowTrigger" } }, "actions": { "type": "array", "items": { "$ref": "#/components/schemas/WorkflowAction" } } }, "required": [ "actions", "id", "name", "triggers" ] }, "WorkflowAction": { "type": "object", "properties": { "id": { "type": "integer", "nullable": true }, "type": { "allOf": [ { "$ref": "#/components/schemas/WorkflowActionTypeEnum" } ], "title": "Workflow Action Type", "minimum": 0, "maximum": 9223372036854775807 }, "assign_title": { "type": "string", "nullable": true, "description": "Assign a document title, must be a Jinja2 template, see documentation." }, "assign_tags": { "type": "array", "items": { "type": "integer", "nullable": true } }, "assign_correspondent": { "type": "integer", "nullable": true }, "assign_document_type": { "type": "integer", "nullable": true }, "assign_storage_path": { "type": "integer", "nullable": true }, "assign_owner": { "type": "integer", "nullable": true, "title": "Assign this owner" }, "assign_view_users": { "type": "array", "items": { "type": "integer", "title": "Grant view permissions to these users" }, "title": "Grant view permissions to these users" }, "assign_view_groups": { "type": "array", "items": { "type": "integer", "title": "Grant view permissions to these groups" }, "title": "Grant view permissions to these groups" }, "assign_change_users": { "type": "array", "items": { "type": "integer", "title": "Grant change permissions to these users" }, "title": "Grant change permissions to these users" }, "assign_change_groups": { "type": "array", "items": { "type": "integer", "title": "Grant change permissions to these groups" }, "title": "Grant change permissions to these groups" }, "assign_custom_fields": { "type": "array", "items": { "type": "integer", "title": "Assign these custom fields" }, "title": "Assign these custom fields" }, "assign_custom_fields_values": { "nullable": true, "title": "Custom field values", "description": "Optional values to assign to the custom fields." }, "remove_all_tags": { "type": "boolean" }, "remove_tags": { "type": "array", "items": { "type": "integer", "title": "Remove these tag(s)" }, "title": "Remove these tag(s)" }, "remove_all_correspondents": { "type": "boolean" }, "remove_correspondents": { "type": "array", "items": { "type": "integer", "title": "Remove these correspondent(s)" }, "title": "Remove these correspondent(s)" }, "remove_all_document_types": { "type": "boolean" }, "remove_document_types": { "type": "array", "items": { "type": "integer", "title": "Remove these document type(s)" }, "title": "Remove these document type(s)" }, "remove_all_storage_paths": { "type": "boolean" }, "remove_storage_paths": { "type": "array", "items": { "type": "integer", "title": "Remove these storage path(s)" }, "title": "Remove these storage path(s)" }, "remove_custom_fields": { "type": "array", "items": { "type": "integer", "title": "Remove these custom fields" }, "title": "Remove these custom fields" }, "remove_all_custom_fields": { "type": "boolean" }, "remove_all_owners": { "type": "boolean" }, "remove_owners": { "type": "array", "items": { "type": "integer", "title": "Remove these owner(s)" }, "title": "Remove these owner(s)" }, "remove_all_permissions": { "type": "boolean" }, "remove_view_users": { "type": "array", "items": { "type": "integer", "title": "Remove view permissions for these users" }, "title": "Remove view permissions for these users" }, "remove_view_groups": { "type": "array", "items": { "type": "integer", "title": "Remove view permissions for these groups" }, "title": "Remove view permissions for these groups" }, "remove_change_users": { "type": "array", "items": { "type": "integer", "title": "Remove change permissions for these users" }, "title": "Remove change permissions for these users" }, "remove_change_groups": { "type": "array", "items": { "type": "integer", "title": "Remove change permissions for these groups" }, "title": "Remove change permissions for these groups" }, "email": { "allOf": [ { "$ref": "#/components/schemas/WorkflowActionEmail" } ], "nullable": true }, "webhook": { "allOf": [ { "$ref": "#/components/schemas/WorkflowActionWebhook" } ], "nullable": true }, "passwords": { "nullable": true, "description": "Passwords to try when removing PDF protection. Separate with commas or new lines." } } }, "WorkflowActionEmail": { "type": "object", "properties": { "id": { "type": "integer", "nullable": true }, "subject": { "type": "string", "title": "Email subject", "description": "The subject of the email, can include some placeholders, see documentation.", "maxLength": 256 }, "body": { "type": "string", "title": "Email body", "description": "The body (message) of the email, can include some placeholders, see documentation." }, "to": { "type": "string", "title": "Emails to", "description": "The destination email addresses, comma separated." }, "include_document": { "type": "boolean", "title": "Include document in email" } }, "required": [ "body", "subject", "to" ] }, "WorkflowActionEmailRequest": { "type": "object", "properties": { "id": { "type": "integer", "nullable": true }, "subject": { "type": "string", "minLength": 1, "title": "Email subject", "description": "The subject of the email, can include some placeholders, see documentation.", "maxLength": 256 }, "body": { "type": "string", "minLength": 1, "title": "Email body", "description": "The body (message) of the email, can include some placeholders, see documentation." }, "to": { "type": "string", "minLength": 1, "title": "Emails to", "description": "The destination email addresses, comma separated." }, "include_document": { "type": "boolean", "title": "Include document in email" } }, "required": [ "body", "subject", "to" ] }, "WorkflowActionRequest": { "type": "object", "properties": { "id": { "type": "integer", "nullable": true }, "type": { "allOf": [ { "$ref": "#/components/schemas/WorkflowActionTypeEnum" } ], "title": "Workflow Action Type", "minimum": 0, "maximum": 9223372036854775807 }, "assign_title": { "type": "string", "nullable": true, "description": "Assign a document title, must be a Jinja2 template, see documentation." }, "assign_tags": { "type": "array", "items": { "type": "integer", "nullable": true } }, "assign_correspondent": { "type": "integer", "nullable": true }, "assign_document_type": { "type": "integer", "nullable": true }, "assign_storage_path": { "type": "integer", "nullable": true }, "assign_owner": { "type": "integer", "nullable": true, "title": "Assign this owner" }, "assign_view_users": { "type": "array", "items": { "type": "integer", "title": "Grant view permissions to these users" }, "title": "Grant view permissions to these users" }, "assign_view_groups": { "type": "array", "items": { "type": "integer", "title": "Grant view permissions to these groups" }, "title": "Grant view permissions to these groups" }, "assign_change_users": { "type": "array", "items": { "type": "integer", "title": "Grant change permissions to these users" }, "title": "Grant change permissions to these users" }, "assign_change_groups": { "type": "array", "items": { "type": "integer", "title": "Grant change permissions to these groups" }, "title": "Grant change permissions to these groups" }, "assign_custom_fields": { "type": "array", "items": { "type": "integer", "title": "Assign these custom fields" }, "title": "Assign these custom fields" }, "assign_custom_fields_values": { "nullable": true, "title": "Custom field values", "description": "Optional values to assign to the custom fields." }, "remove_all_tags": { "type": "boolean" }, "remove_tags": { "type": "array", "items": { "type": "integer", "title": "Remove these tag(s)" }, "title": "Remove these tag(s)" }, "remove_all_correspondents": { "type": "boolean" }, "remove_correspondents": { "type": "array", "items": { "type": "integer", "title": "Remove these correspondent(s)" }, "title": "Remove these correspondent(s)" }, "remove_all_document_types": { "type": "boolean" }, "remove_document_types": { "type": "array", "items": { "type": "integer", "title": "Remove these document type(s)" }, "title": "Remove these document type(s)" }, "remove_all_storage_paths": { "type": "boolean" }, "remove_storage_paths": { "type": "array", "items": { "type": "integer", "title": "Remove these storage path(s)" }, "title": "Remove these storage path(s)" }, "remove_custom_fields": { "type": "array", "items": { "type": "integer", "title": "Remove these custom fields" }, "title": "Remove these custom fields" }, "remove_all_custom_fields": { "type": "boolean" }, "remove_all_owners": { "type": "boolean" }, "remove_owners": { "type": "array", "items": { "type": "integer", "title": "Remove these owner(s)" }, "title": "Remove these owner(s)" }, "remove_all_permissions": { "type": "boolean" }, "remove_view_users": { "type": "array", "items": { "type": "integer", "title": "Remove view permissions for these users" }, "title": "Remove view permissions for these users" }, "remove_view_groups": { "type": "array", "items": { "type": "integer", "title": "Remove view permissions for these groups" }, "title": "Remove view permissions for these groups" }, "remove_change_users": { "type": "array", "items": { "type": "integer", "title": "Remove change permissions for these users" }, "title": "Remove change permissions for these users" }, "remove_change_groups": { "type": "array", "items": { "type": "integer", "title": "Remove change permissions for these groups" }, "title": "Remove change permissions for these groups" }, "email": { "allOf": [ { "$ref": "#/components/schemas/WorkflowActionEmailRequest" } ], "nullable": true }, "webhook": { "allOf": [ { "$ref": "#/components/schemas/WorkflowActionWebhookRequest" } ], "nullable": true }, "passwords": { "nullable": true, "description": "Passwords to try when removing PDF protection. Separate with commas or new lines." } } }, "WorkflowActionTypeEnum": { "enum": [ 1, 2, 3, 4, 5, 6 ], "type": "integer", "description": "* `1` - Assignment\n* `2` - Removal\n* `3` - Email\n* `4` - Webhook\n* `5` - Password removal\n* `6` - Move to trash" }, "WorkflowActionWebhook": { "type": "object", "properties": { "id": { "type": "integer", "nullable": true }, "url": { "type": "string", "title": "Webhook url", "description": "The destination URL for the notification.", "maxLength": 256 }, "use_params": { "type": "boolean", "title": "Use parameters" }, "as_json": { "type": "boolean", "title": "Send as JSON" }, "params": { "nullable": true, "title": "Webhook parameters", "description": "The parameters to send with the webhook URL if body not used." }, "body": { "type": "string", "nullable": true, "title": "Webhook body", "description": "The body to send with the webhook URL if parameters not used." }, "headers": { "nullable": true, "title": "Webhook headers", "description": "The headers to send with the webhook URL." }, "include_document": { "type": "boolean", "title": "Include document in webhook" } }, "required": [ "url" ] }, "WorkflowActionWebhookRequest": { "type": "object", "properties": { "id": { "type": "integer", "nullable": true }, "url": { "type": "string", "minLength": 1, "title": "Webhook url", "description": "The destination URL for the notification.", "maxLength": 256 }, "use_params": { "type": "boolean", "title": "Use parameters" }, "as_json": { "type": "boolean", "title": "Send as JSON" }, "params": { "nullable": true, "title": "Webhook parameters", "description": "The parameters to send with the webhook URL if body not used." }, "body": { "type": "string", "nullable": true, "title": "Webhook body", "description": "The body to send with the webhook URL if parameters not used." }, "headers": { "nullable": true, "title": "Webhook headers", "description": "The headers to send with the webhook URL." }, "include_document": { "type": "boolean", "title": "Include document in webhook" } }, "required": [ "url" ] }, "WorkflowRequest": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 256 }, "order": { "type": "integer" }, "enabled": { "type": "boolean" }, "triggers": { "type": "array", "items": { "$ref": "#/components/schemas/WorkflowTriggerRequest" } }, "actions": { "type": "array", "items": { "$ref": "#/components/schemas/WorkflowActionRequest" } } }, "required": [ "actions", "name", "triggers" ] }, "WorkflowTrigger": { "type": "object", "properties": { "id": { "type": "integer", "nullable": true }, "sources": { "type": "array", "items": { "$ref": "#/components/schemas/SourcesEnum" }, "default": [ 1, 2, 3 ] }, "type": { "allOf": [ { "$ref": "#/components/schemas/WorkflowTriggerTypeEnum" } ], "title": "Trigger Type" }, "filter_path": { "type": "string", "nullable": true, "description": "Only consume documents with a path that matches this if specified. Wildcards specified as * are allowed. Case insensitive.", "maxLength": 256 }, "filter_filename": { "type": "string", "nullable": true, "description": "Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", "maxLength": 256 }, "filter_mailrule": { "type": "integer", "nullable": true, "title": "Filter documents from this mail rule" }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/WorkflowTriggerMatchingAlgorithmEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "match": { "type": "string", "maxLength": 256 }, "is_insensitive": { "type": "boolean" }, "filter_has_tags": { "type": "array", "items": { "type": "integer", "title": "Has these tag(s)" }, "title": "Has these tag(s)" }, "filter_has_all_tags": { "type": "array", "items": { "type": "integer", "title": "Has all of these tag(s)" }, "title": "Has all of these tag(s)" }, "filter_has_not_tags": { "type": "array", "items": { "type": "integer", "title": "Does not have these tag(s)" }, "title": "Does not have these tag(s)" }, "filter_custom_field_query": { "type": "string", "nullable": true, "description": "JSON-encoded custom field query expression." }, "filter_has_any_correspondents": { "type": "array", "items": { "type": "integer", "title": "Has one of these correspondents" }, "title": "Has one of these correspondents" }, "filter_has_not_correspondents": { "type": "array", "items": { "type": "integer", "title": "Does not have these correspondent(s)" }, "title": "Does not have these correspondent(s)" }, "filter_has_any_document_types": { "type": "array", "items": { "type": "integer", "title": "Has one of these document types" }, "title": "Has one of these document types" }, "filter_has_not_document_types": { "type": "array", "items": { "type": "integer", "title": "Does not have these document type(s)" }, "title": "Does not have these document type(s)" }, "filter_has_any_storage_paths": { "type": "array", "items": { "type": "integer", "title": "Has one of these storage paths" }, "title": "Has one of these storage paths" }, "filter_has_not_storage_paths": { "type": "array", "items": { "type": "integer", "title": "Does not have these storage path(s)" }, "title": "Does not have these storage path(s)" }, "filter_has_correspondent": { "type": "integer", "nullable": true, "title": "Has this correspondent" }, "filter_has_document_type": { "type": "integer", "nullable": true, "title": "Has this document type" }, "filter_has_storage_path": { "type": "integer", "nullable": true, "title": "Has this storage path" }, "schedule_offset_days": { "type": "integer", "maximum": 9223372036854775807, "minimum": -9223372036854775808, "format": "int64", "description": "The number of days to offset the schedule trigger by." }, "schedule_is_recurring": { "type": "boolean", "description": "If the schedule should be recurring." }, "schedule_recurring_interval_days": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "title": "Schedule recurring delay in days", "description": "The number of days between recurring schedule triggers." }, "schedule_date_field": { "allOf": [ { "$ref": "#/components/schemas/ScheduleDateFieldEnum" } ], "description": "The field to check for a schedule trigger.\n\n* `added` - Added\n* `created` - Created\n* `modified` - Modified\n* `custom_field` - Custom Field" }, "schedule_date_custom_field": { "type": "integer", "nullable": true } }, "required": [ "type" ] }, "WorkflowTriggerMatchingAlgorithmEnum": { "enum": [ 0, 1, 2, 3, 4, 5 ], "type": "integer", "description": "* `0` - None\n* `1` - Any word\n* `2` - All words\n* `3` - Exact match\n* `4` - Regular expression\n* `5` - Fuzzy word" }, "WorkflowTriggerRequest": { "type": "object", "properties": { "id": { "type": "integer", "nullable": true }, "sources": { "type": "array", "items": { "$ref": "#/components/schemas/SourcesEnum" }, "default": [ 1, 2, 3 ] }, "type": { "allOf": [ { "$ref": "#/components/schemas/WorkflowTriggerTypeEnum" } ], "title": "Trigger Type" }, "filter_path": { "type": "string", "nullable": true, "description": "Only consume documents with a path that matches this if specified. Wildcards specified as * are allowed. Case insensitive.", "maxLength": 256 }, "filter_filename": { "type": "string", "nullable": true, "description": "Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", "maxLength": 256 }, "filter_mailrule": { "type": "integer", "nullable": true, "title": "Filter documents from this mail rule" }, "matching_algorithm": { "allOf": [ { "$ref": "#/components/schemas/WorkflowTriggerMatchingAlgorithmEnum" } ], "minimum": 0, "maximum": 9223372036854775807 }, "match": { "type": "string", "maxLength": 256 }, "is_insensitive": { "type": "boolean" }, "filter_has_tags": { "type": "array", "items": { "type": "integer", "title": "Has these tag(s)" }, "title": "Has these tag(s)" }, "filter_has_all_tags": { "type": "array", "items": { "type": "integer", "title": "Has all of these tag(s)" }, "title": "Has all of these tag(s)" }, "filter_has_not_tags": { "type": "array", "items": { "type": "integer", "title": "Does not have these tag(s)" }, "title": "Does not have these tag(s)" }, "filter_custom_field_query": { "type": "string", "nullable": true, "description": "JSON-encoded custom field query expression." }, "filter_has_any_correspondents": { "type": "array", "items": { "type": "integer", "title": "Has one of these correspondents" }, "title": "Has one of these correspondents" }, "filter_has_not_correspondents": { "type": "array", "items": { "type": "integer", "title": "Does not have these correspondent(s)" }, "title": "Does not have these correspondent(s)" }, "filter_has_any_document_types": { "type": "array", "items": { "type": "integer", "title": "Has one of these document types" }, "title": "Has one of these document types" }, "filter_has_not_document_types": { "type": "array", "items": { "type": "integer", "title": "Does not have these document type(s)" }, "title": "Does not have these document type(s)" }, "filter_has_any_storage_paths": { "type": "array", "items": { "type": "integer", "title": "Has one of these storage paths" }, "title": "Has one of these storage paths" }, "filter_has_not_storage_paths": { "type": "array", "items": { "type": "integer", "title": "Does not have these storage path(s)" }, "title": "Does not have these storage path(s)" }, "filter_has_correspondent": { "type": "integer", "nullable": true, "title": "Has this correspondent" }, "filter_has_document_type": { "type": "integer", "nullable": true, "title": "Has this document type" }, "filter_has_storage_path": { "type": "integer", "nullable": true, "title": "Has this storage path" }, "schedule_offset_days": { "type": "integer", "maximum": 9223372036854775807, "minimum": -9223372036854775808, "format": "int64", "description": "The number of days to offset the schedule trigger by." }, "schedule_is_recurring": { "type": "boolean", "description": "If the schedule should be recurring." }, "schedule_recurring_interval_days": { "type": "integer", "maximum": 9223372036854775807, "minimum": 1, "format": "int64", "title": "Schedule recurring delay in days", "description": "The number of days between recurring schedule triggers." }, "schedule_date_field": { "allOf": [ { "$ref": "#/components/schemas/ScheduleDateFieldEnum" } ], "description": "The field to check for a schedule trigger.\n\n* `added` - Added\n* `created` - Created\n* `modified` - Modified\n* `custom_field` - Custom Field" }, "schedule_date_custom_field": { "type": "integer", "nullable": true } }, "required": [ "type" ] }, "WorkflowTriggerTypeEnum": { "enum": [ 1, 2, 3, 4 ], "type": "integer", "description": "* `1` - Consumption Started\n* `2` - Document Added\n* `3` - Document Updated\n* `4` - Scheduled" } }, "securitySchemes": { "PaperelessBasicAuthentication": { "type": "http", "scheme": "basic" }, "cookieAuth": { "type": "apiKey", "in": "cookie", "name": "sessionid" }, "tokenAuth": { "type": "apiKey", "in": "header", "name": "Authorization", "description": "Token-based authentication with required prefix \"Token\"" } } }, "externalDocs": { "description": "Paperless-ngx API Documentation", "url": "https://docs.paperless-ngx.com/api/" } } tb1337-pypaperless-5d67d60/tests/data/search.py000066400000000000000000000026101524076126400212430ustar00rootroot00000000000000"""Global search result snapshot.""" DATA_SEARCH = { "total": 2, "documents": [ { "id": 1, "correspondent": 1, "document_type": 2, "storage_path": None, "title": "Invoice 2024", "content": "some OCRd text about an invoice", "tags": [], "created": "2024-01-15", "modified": "2024-01-16T10:00:00+00:00", "added": "2024-01-16T10:00:00+00:00", "archive_serial_number": None, "original_file_name": "invoice.pdf", "archived_file_name": "2024-01-15 invoice.pdf", "owner": 1, "user_can_change": True, "notes": [], "custom_fields": [], } ], "saved_views": [], "tags": [ { "id": 1, "slug": "unpaid", "name": "Unpaid", "color": "#ff0000", "text_color": "#ffffff", "match": "", "matching_algorithm": 0, "is_insensitive": True, "is_inbox_tag": False, "document_count": 5, "owner": None, "user_can_change": True, } ], "correspondents": [], "document_types": [], "storage_paths": [], "users": [], "groups": [], "mail_rules": [], "mail_accounts": [], "workflows": [], "custom_fields": [], } tb1337-pypaperless-5d67d60/tests/data/search_autocomplete.py000066400000000000000000000001541524076126400240250ustar00rootroot00000000000000"""Search autocomplete response fixture.""" DATA_SEARCH_AUTOCOMPLETE = ["invoice", "invoices", "invoiced"] tb1337-pypaperless-5d67d60/tests/data/share_link_bundles.py000066400000000000000000000017421524076126400236360ustar00rootroot00000000000000"""Share link bundles snapshot.""" DATA_SHARE_LINK_BUNDLES = { "count": 2, "next": None, "previous": None, "results": [ { "id": 1, "created": "2024-01-15T10:00:00.000000Z", "expiration": "2024-02-15T10:00:00.000000Z", "slug": "bundle-abc123", "file_version": "archive", "status": "ready", "size_bytes": 204800, "last_error": None, "built_at": "2024-01-15T10:05:00.000000Z", "documents": [1, 2, 3], "document_count": 3, }, { "id": 2, "created": "2024-01-16T09:00:00.000000Z", "expiration": None, "slug": "bundle-xyz789", "file_version": "original", "status": "pending", "size_bytes": None, "last_error": None, "built_at": None, "documents": [4, 5], "document_count": 2, }, ], } tb1337-pypaperless-5d67d60/tests/data/share_links.py000066400000000000000000000044671524076126400223140ustar00rootroot00000000000000"""Share links snapshot.""" DATA_SHARE_LINKS = { "count": 8, "next": None, "previous": None, "results": [ { "id": 1, "created": "2023-12-11T14:06:49.096456Z", "expiration": "2023-12-18T14:06:49.064000Z", "slug": "GMIFR9WVPe7a0FAltmrAdmVsrrTzH6Z9yFi2jufhi5yCTAMWfF", "document": 1, "file_version": "original", }, { "id": 2, "created": "2023-12-11T14:06:53.583496Z", "expiration": "2024-01-10T14:06:53.558000Z", "slug": "Px2h3mrkIvExyTE8M8usrTLv3jtTb4MnLJ4eTAxcjy2FUmuDLq", "document": 2, "file_version": "original", }, { "id": 3, "created": "2023-12-11T14:06:55.984583Z", "expiration": None, "slug": "bDnxeQ4UmlFVUYCDrb1KBLbE4HVSW8jw3CLElcwPyAncV5eiI+00:00", "document": 1, "file_version": "original", }, { "id": 4, "created": "2023-12-11T14:07:01.448813Z", "expiration": "2023-12-12T14:07:01.423000Z", "slug": "HfzHhDzA03ZQg4t4TAlOuup59qgQA18Zjbb9eOE06PZ8KTjgOb", "document": 2, "file_version": "archive", }, { "id": 5, "created": "2023-12-11T14:11:50.710369Z", "expiration": None, "slug": "7PIGEZbeFv5yIrnpSVwj1QeXiJu0IZCiEWGIV4aUHQrfUQtXne", "document": 1, "file_version": "archive", }, { "id": 6, "created": "2023-12-11T14:11:50.710369Z", "expiration": None, "slug": "7PIGEZbeFv5yIrnpSVwj1QeXiJu0IZCiEWGIV4aUHQrfUQtXne", "document": 1, "file_version": "archive", }, { "id": 7, "created": "2023-12-11T14:11:50.710369Z", "expiration": None, "slug": "7PIGEZbeFv5yIrnpSVwj1QeXiJu0IZCiEWGIV4aUHQrfUQtXne", "document": 1, "file_version": "archive", }, { "id": 8, "created": "2023-12-11T14:11:50.710369Z", "expiration": None, "slug": "7PIGEZbeFv5yIrnpSVwj1QeXiJu0IZCiEWGIV4aUHQrfUQtXne", "document": 1, "file_version": "archive", }, ], } tb1337-pypaperless-5d67d60/tests/data/statistics.py000066400000000000000000000010421524076126400221660ustar00rootroot00000000000000"""Statistics snapshot.""" DATA_STATISTICS = { "documents_total": 1337, "documents_inbox": 2, "inbox_tag": 1, "inbox_tags": [1], "document_file_type_counts": [ {"mime_type": "application/pdf", "mime_type_count": 1334}, {"mime_type": "image/jpeg", "mime_type_count": 2}, {"mime_type": "message/rfc822", "mime_type_count": 1}, ], "character_count": 13371337, "tag_count": 5, "correspondent_count": 42, "document_type_count": 23, "storage_path_count": 5, "current_asn": 84, } tb1337-pypaperless-5d67d60/tests/data/status.py000066400000000000000000000017601524076126400213260ustar00rootroot00000000000000"""Status snapshot.""" DATA_STATUS = { "pngx_version": "2.6.1", "server_os": "Linux-6.6.12-linuxkit-aarch64-with-glibc2.36", "install_type": "docker", "storage": { "total": 494384795648, "available": 103324229632, }, "database": { "type": "sqlite", "url": "/usr/src/paperless/data/db.sqlite3", "status": "OK", "error": None, "migration_status": { "latest_migration": "paperless.0003_alter_applicationconfiguration_max_image_pixels", "unapplied_migrations": [], }, }, "tasks": { "redis_url": "redis://broker:6379", "redis_status": "OK", "redis_error": None, "celery_status": "OK", "index_status": "OK", "index_last_modified": "2024-03-06T07:10:55.370884+01:00", "index_error": None, "classifier_status": "OK", "classifier_last_trained": "2024-03-06T07:05:01.281804+00:00", "classifier_error": None, }, } tb1337-pypaperless-5d67d60/tests/data/storage_paths.py000066400000000000000000000037111524076126400226440ustar00rootroot00000000000000"""Storage paths snapshot.""" DATA_STORAGE_PATHS = { "count": 5, "next": None, "previous": None, "results": [ { "id": 1, "slug": "work", "name": "Work Work Work", "path": "{owner_username}/work/{correspondent}_{created}_{document_type}_{title}", "match": "", "matching_algorithm": 6, "is_insensitive": True, "document_count": 384, "owner": 3, "user_can_change": True, }, { "id": 2, "slug": "banking", "name": "Banking", "path": "{owner_username}/banking/{correspondent}_{created}_{document_type}_{title}", "match": "", "matching_algorithm": 6, "is_insensitive": True, "document_count": 303, "owner": None, "user_can_change": True, }, { "id": 3, "slug": "another-test", "name": "Another Test", "path": "Test/Path/{doc_pk}", "match": "", "matching_algorithm": 0, "is_insensitive": True, "document_count": 0, "owner": None, "user_can_change": True, }, { "id": 4, "slug": "another-test-2", "name": "Another Test 2", "path": "Test/Path/{doc_pk}", "match": "", "matching_algorithm": 0, "is_insensitive": True, "document_count": 0, "owner": None, "user_can_change": True, }, { "id": 5, "slug": "another-test-3", "name": "Another Test 3", "path": "Test/Path/{doc_pk}", "match": "", "matching_algorithm": 0, "is_insensitive": True, "document_count": 0, "owner": None, "user_can_change": True, }, ], } tb1337-pypaperless-5d67d60/tests/data/tags.py000066400000000000000000000041231524076126400207350ustar00rootroot00000000000000"""Tags snapshot.""" DATA_TAGS = { "count": 5, "next": None, "previous": None, "results": [ { "id": 1, "slug": "important", "name": "Important", "color": "#ff0000", "text_color": "#00ff00", "match": "", "matching_algorithm": 0, "is_insensitive": True, "is_inbox_tag": False, "document_count": 20, "owner": None, "user_can_change": True, }, { "id": 2, "slug": "inbox", "name": "Inbox", "color": "#ff0000", "text_color": "#00ff00", "match": "", "matching_algorithm": 1, "is_insensitive": True, "is_inbox_tag": True, "document_count": 20, "owner": None, "user_can_change": True, }, { "id": 3, "slug": "test-3", "name": "Test 3", "color": "#ff0000", "text_color": "#00ff00", "match": "", "matching_algorithm": 2, "is_insensitive": True, "is_inbox_tag": False, "document_count": 20, "owner": None, "user_can_change": True, }, { "id": 4, "slug": "test-4", "name": "Test 4", "color": "#ff0000", "text_color": "#00ff00", "match": "", "matching_algorithm": 3, "is_insensitive": True, "is_inbox_tag": False, "document_count": 20, "owner": None, "user_can_change": True, }, { "id": 5, "slug": "test-5", "name": "Test 5", "color": "#ff0000", "text_color": "#00ff00", "match": "", "matching_algorithm": 4, "is_insensitive": True, "is_inbox_tag": False, "document_count": 20, "owner": None, "user_can_change": True, }, ], } tb1337-pypaperless-5d67d60/tests/data/tasks.py000066400000000000000000000057411524076126400211330ustar00rootroot00000000000000"""Tasks snapshot.""" _TASK_1 = { "id": 1, "task_id": "11112222-aaaa-bbbb-cccc-333344445555", "task_type": "consume_file", "task_type_display": "Consume File", "trigger_source": "api_upload", "trigger_source_display": "API Upload", "status": "started", "status_display": "Started", "date_created": "2023-12-16T13:06:29.107815+00:00", "date_started": "2023-12-16T13:06:29.500000+00:00", "date_done": None, "duration_seconds": None, "wait_time_seconds": 0.39, "input_data": {"filename": "a.png"}, "result_data": None, "related_document_ids": [], "acknowledged": False, "owner": 1, } _TASK_2 = { "id": 2, "task_id": "ffffeeee-9999-8888-7777-ddddccccbbbb", "task_type": "consume_file", "task_type_display": "Consume File", "trigger_source": "folder_consume", "trigger_source_display": "Folder Consume", "status": "success", "status_display": "Success", "date_created": "2023-12-16T13:06:26.117158+00:00", "date_started": "2023-12-16T13:06:26.500000+00:00", "date_done": "2023-12-16T13:06:29.859669+00:00", "duration_seconds": 3.36, "wait_time_seconds": 0.38, "input_data": {"filename": "b.png"}, "result_data": {"document_id": 1780}, "related_document_ids": [1780], "acknowledged": False, "owner": 1, } _TASK_3 = { "id": 3, "task_id": "abcdef12-3456-7890-abcd-ef1234567890", "task_type": "sanity_check", "task_type_display": "Sanity Check", "trigger_source": "scheduled", "trigger_source_display": "Scheduled", "status": "success", "status_display": "Success", "date_created": "2023-12-16T13:04:28.175624+00:00", "date_started": "2023-12-16T13:04:28.500000+00:00", "date_done": "2023-12-16T13:04:32.318797+00:00", "duration_seconds": 3.82, "wait_time_seconds": 0.32, "input_data": {}, "result_data": None, "related_document_ids": [], "acknowledged": True, "owner": 1, } DATA_TASKS = { "count": 3, "next": None, "previous": None, "results": [_TASK_1, _TASK_2, _TASK_3], } DATA_TASKS_ACTIVE = [_TASK_1] DATA_TASKS_SUMMARY = [ { "task_type": "consume_file", "total_count": 2, "pending_count": 0, "success_count": 1, "failure_count": 0, "avg_duration_seconds": 3.36, "avg_wait_time_seconds": 0.385, "last_run": "2023-12-16T13:06:32.318797+00:00", "last_success": "2023-12-16T13:06:32.318797+00:00", "last_failure": None, }, { "task_type": "sanity_check", "total_count": 1, "pending_count": 0, "success_count": 1, "failure_count": 0, "avg_duration_seconds": 3.82, "avg_wait_time_seconds": 0.32, "last_run": "2023-12-16T13:04:32.318797+00:00", "last_success": "2023-12-16T13:04:32.318797+00:00", "last_failure": None, }, ] DATA_TASKS_STATUS_COUNTS = { "all": 3, "needs_attention": 1, "in_progress": 0, "completed": 2, } tb1337-pypaperless-5d67d60/tests/data/token.py000066400000000000000000000001621524076126400211160ustar00rootroot00000000000000"""Token snapshot.""" from tests.const import PAPERLESS_TEST_TOKEN DATA_TOKEN = {"token": PAPERLESS_TEST_TOKEN} tb1337-pypaperless-5d67d60/tests/data/trash.py000066400000000000000000000033451524076126400211250ustar00rootroot00000000000000"""Trash snapshot.""" DATA_TRASH = { "count": 2, "next": None, "previous": None, "results": [ { "id": 100, "correspondent": None, "document_type": None, "storage_path": None, "title": "Trashed Document 1", "content": "", "tags": [], "created": "2024-01-15", "modified": "2024-01-15T12:00:00+00:00", "added": "2024-01-15T12:00:00+00:00", "deleted_at": "2024-06-01T10:00:00+00:00", "archive_serial_number": None, "original_file_name": "trashed1.pdf", "archived_file_name": None, "owner": 1, "user_can_change": True, "is_shared_by_requester": False, "notes": [], "custom_fields": [], "page_count": 1, "mime_type": "application/pdf", }, { "id": 101, "correspondent": 1, "document_type": None, "storage_path": None, "title": "Trashed Document 2", "content": "some content", "tags": [1], "created": "2024-02-20", "modified": "2024-02-20T08:30:00+00:00", "added": "2024-02-20T08:30:00+00:00", "deleted_at": "2024-06-02T09:15:00+00:00", "archive_serial_number": None, "original_file_name": "trashed2.pdf", "archived_file_name": "2024-02-20 trashed2.pdf", "owner": 1, "user_can_change": True, "is_shared_by_requester": False, "notes": [], "custom_fields": [], "page_count": 2, "mime_type": "application/pdf", }, ], } tb1337-pypaperless-5d67d60/tests/data/users.py000066400000000000000000000257051524076126400211510ustar00rootroot00000000000000"""Users snapshot.""" DATA_USERS = { "count": 4, "next": None, "previous": None, "results": [ { "id": 1, "username": "admin", "email": "you@inside.me", "password": "**********", "first_name": "", "last_name": "", "date_joined": "2022-02-11T22:28:25+00:00", "is_staff": True, "is_active": False, "is_superuser": False, "groups": [], "user_permissions": [], "inherited_permissions": [], }, { "id": 2, "username": "alpha", "email": "alpha@is-not.beta", "password": "**********", "first_name": "Alpha", "last_name": "Centauri", "date_joined": "2023-06-27T15:59:01.975496+00:00", "is_staff": False, "is_active": True, "is_superuser": False, "groups": [1], "user_permissions": [], "inherited_permissions": [ "documents.delete_customfieldinstance", "documents.delete_consumptiontemplate", "documents.delete_customfield", "documents.add_uisettings", "documents.view_uisettings", "documents.view_tag", "documents.change_customfield", "documents.view_sharelink", "documents.change_note", "documents.view_correspondent", "documents.change_customfieldinstance", "documents.delete_note", "documents.add_consumptiontemplate", "documents.add_customfield", "documents.change_sharelink", "documents.view_consumptiontemplate", "documents.view_documenttype", "documents.change_document", "documents.view_customfieldinstance", "documents.add_sharelink", "documents.view_document", "documents.delete_uisettings", "documents.add_savedview", "documents.view_note", "documents.view_customfield", "documents.add_customfieldinstance", "documents.change_uisettings", "documents.change_savedview", "documents.add_document", "documents.add_note", "documents.change_consumptiontemplate", "documents.delete_sharelink", "documents.delete_savedview", "documents.view_savedview", "documents.delete_document", ], }, { "id": 3, "username": "omega", "email": "omega.weapon@ff8.net", "password": "**********", "first_name": "Omega", "last_name": "Weapon", "date_joined": "2022-02-14T13:42:54+00:00", "is_staff": True, "is_active": True, "is_superuser": True, "groups": [1], "user_permissions": [], "inherited_permissions": [ "documents.change_paperlesstask", "documents.delete_customfieldinstance", "django_celery_results.delete_chordcounter", "paperless_mail.change_processedmail", "django_celery_results.view_chordcounter", "documents.view_storagepath", "documents.delete_tag", "sessions.delete_session", "documents.view_log", "django_celery_results.change_groupresult", "documents.add_uisettings", "authtoken.delete_token", "admin.add_logentry", "django_celery_results.add_groupresult", "guardian.view_userobjectpermission", "paperless_mail.add_processedmail", "documents.change_customfieldinstance", "documents.view_correspondent", "auth.add_permission", "admin.view_logentry", "documents.delete_note", "documents.add_customfield", "auth.view_permission", "documents.add_consumptiontemplate", "guardian.delete_groupobjectpermission", "documents.change_sharelink", "documents.view_consumptiontemplate", "documents.delete_log", "guardian.delete_userobjectpermission", "paperless_mail.delete_mailrule", "sessions.change_session", "documents.add_tag", "documents.view_documenttype", "auth.view_user", "auth.add_group", "guardian.view_groupobjectpermission", "documents.change_document", "documents.change_savedviewfilterrule", "paperless_mail.add_mailrule", "documents.view_customfieldinstance", "guardian.add_userobjectpermission", "documents.add_sharelink", "auth.change_user", "documents.view_document", "django_celery_results.delete_taskresult", "documents.delete_uisettings", "documents.add_savedview", "documents.view_note", "authtoken.view_token", "authtoken.change_tokenproxy", "documents.change_storagepath", "documents.change_uisettings", "django_celery_results.add_chordcounter", "paperless_mail.change_mailrule", "documents.change_documenttype", "documents.change_savedview", "auth.delete_permission", "documents.add_storagepath", "django_celery_results.change_chordcounter", "documents.add_note", "documents.change_consumptiontemplate", "documents.delete_savedview", "guardian.change_userobjectpermission", "admin.delete_logentry", "authtoken.add_token", "documents.view_savedview", "documents.delete_paperlesstask", "sessions.view_session", "auth.delete_group", "paperless_mail.delete_mailaccount", "documents.add_savedviewfilterrule", "authtoken.delete_tokenproxy", "documents.delete_consumptiontemplate", "authtoken.view_tokenproxy", "authtoken.change_token", "documents.delete_customfield", "contenttypes.change_contenttype", "documents.change_log", "paperless_mail.view_mailrule", "documents.view_uisettings", "documents.view_tag", "documents.change_customfield", "admin.change_logentry", "documents.view_sharelink", "documents.change_note", "documents.delete_savedviewfilterrule", "auth.add_user", "django_celery_results.delete_groupresult", "documents.change_correspondent", "documents.add_documenttype", "django_celery_results.view_groupresult", "documents.add_paperlesstask", "contenttypes.add_contenttype", "auth.view_group", "guardian.change_groupobjectpermission", "django_celery_results.change_taskresult", "documents.change_tag", "paperless_mail.delete_processedmail", "paperless_mail.change_mailaccount", "documents.delete_correspondent", "paperless_mail.view_processedmail", "documents.delete_storagepath", "sessions.add_session", "paperless_mail.add_mailaccount", "django_celery_results.view_taskresult", "documents.view_customfield", "documents.view_paperlesstask", "documents.add_customfieldinstance", "django_celery_results.add_taskresult", "documents.view_savedviewfilterrule", "documents.add_log", "guardian.add_groupobjectpermission", "auth.delete_user", "auth.change_permission", "documents.add_document", "documents.delete_documenttype", "documents.delete_sharelink", "documents.add_correspondent", "authtoken.add_tokenproxy", "auth.change_group", "contenttypes.delete_contenttype", "contenttypes.view_contenttype", "paperless_mail.view_mailaccount", "documents.delete_document", ], }, { "id": 4, "username": "zg_test", "email": "", "password": "**********", "first_name": "", "last_name": "", "date_joined": "2023-06-27T15:56:39.754859+00:00", "is_staff": False, "is_active": True, "is_superuser": False, "groups": [1], "user_permissions": [], "inherited_permissions": [ "documents.delete_customfieldinstance", "documents.delete_consumptiontemplate", "documents.delete_customfield", "documents.add_uisettings", "documents.view_uisettings", "documents.view_tag", "documents.change_customfield", "documents.view_sharelink", "documents.change_note", "documents.view_correspondent", "documents.change_customfieldinstance", "documents.delete_note", "documents.add_consumptiontemplate", "documents.add_customfield", "documents.change_sharelink", "documents.view_consumptiontemplate", "documents.view_documenttype", "documents.change_document", "documents.view_customfieldinstance", "documents.add_sharelink", "documents.view_document", "documents.delete_uisettings", "documents.add_savedview", "documents.view_note", "documents.view_customfield", "documents.add_customfieldinstance", "documents.change_uisettings", "documents.change_savedview", "documents.add_document", "documents.add_note", "documents.change_consumptiontemplate", "documents.delete_sharelink", "documents.delete_savedview", "documents.view_savedview", "documents.delete_document", ], }, ], } tb1337-pypaperless-5d67d60/tests/data/workflows.py000066400000000000000000000110341524076126400220330ustar00rootroot00000000000000"""Workflow snapshot.""" DATA_WORKFLOWS = { "count": 3, "next": None, "previous": None, "results": [ { "id": 1, "name": "Importordner Template", "order": 1, "enabled": True, "triggers": [ { "id": 1, "sources": [1], "type": 1, "filter_path": None, "filter_filename": "*.pdf", "filter_mailrule": None, "matching_algorithm": 0, "match": "", "is_insensitive": None, "filter_has_tags": [], "filter_has_correspondent": None, "filter_has_document_type": None, } ], "actions": [ { "id": 1, "type": 1, "assign_title": "Some workflow title", "assign_tags": [4], "assign_correspondent": 9, "assign_document_type": 8, "assign_storage_path": 2, "assign_owner": 3, "assign_view_users": [], "assign_view_groups": [], "assign_change_users": [], "assign_change_groups": [], "assign_custom_fields": [2], } ], }, { "id": 2, "name": "API Upload Template", "order": 2, "enabled": True, "triggers": [ { "id": 2, "sources": [1, 2], "type": 1, "filter_path": "/api/*", "filter_filename": "*.pdf", "filter_mailrule": None, "matching_algorithm": 0, "match": "", "is_insensitive": None, "filter_has_tags": [], "filter_has_correspondent": None, "filter_has_document_type": None, } ], "actions": [ { "id": 2, "type": 1, "assign_title": "API", "assign_tags": [4], "assign_correspondent": 9, "assign_document_type": 12, "assign_storage_path": 2, "assign_owner": 3, "assign_view_users": [], "assign_view_groups": [], "assign_change_users": [], "assign_change_groups": [], "assign_custom_fields": [5], } ], }, { "id": 3, "name": "Email Template", "order": 3, "enabled": False, "triggers": [ { "id": 3, "sources": [3], "type": 1, "filter_path": "/mail/*", "filter_filename": "*.eml", "filter_mailrule": 1, "matching_algorithm": 0, "match": "", "is_insensitive": True, "filter_has_tags": [], "filter_has_correspondent": None, "filter_has_document_type": None, } ], "actions": [ { "id": 3, "type": 1, "assign_title": None, "assign_tags": [], "assign_correspondent": None, "assign_document_type": None, "assign_storage_path": None, "assign_owner": 2, "assign_view_users": [3, 7], "assign_view_groups": [1], "assign_change_users": [6], "assign_change_groups": [], "assign_custom_fields": [], } ], }, ], } _ACTIONS = [action for wf in DATA_WORKFLOWS["results"] for action in wf["actions"]] _TRIGGERS = [trigger for wf in DATA_WORKFLOWS["results"] for trigger in wf["triggers"]] DATA_WORKFLOW_ACTIONS = { "count": len(_ACTIONS), "next": None, "previous": None, "results": _ACTIONS, } DATA_WORKFLOW_TRIGGERS = { "count": len(_TRIGGERS), "next": None, "previous": None, "results": _TRIGGERS, } tb1337-pypaperless-5d67d60/tests/mappings.py000066400000000000000000000111411524076126400207020ustar00rootroot00000000000000"""Resource test mappings for parameterized tests.""" from dataclasses import dataclass from typing import Any from pypaperless import models from pypaperless.const import PaperlessResource from pypaperless.models import types from .data import ( DATA_CORRESPONDENTS, DATA_CUSTOM_FIELDS, DATA_DOCUMENT_TYPES, DATA_DOCUMENTS, DATA_GROUPS, DATA_MAIL_ACCOUNTS, DATA_MAIL_RULES, DATA_PROCESSED_MAIL, DATA_SAVED_VIEWS, DATA_SHARE_LINK_BUNDLES, DATA_SHARE_LINKS, DATA_STORAGE_PATHS, DATA_TAGS, DATA_USERS, DATA_WORKFLOWS, ) @dataclass class ResourceTestMapping: """Mapping for parameterized resource test cases.""" resource: str data: dict[str, Any] | list[dict[str, Any]] model_cls: type draft_cls: type | None = None draft_defaults: dict[str, Any] | None = None # field name to blank-out for DraftFieldRequiredError check; None = skip required_field: str | None = "name" # field and value used by test_update update_field: str = "name" update_value: Any = "Name Updated" CORRESPONDENT_MAP = ResourceTestMapping( PaperlessResource.CORRESPONDENTS, DATA_CORRESPONDENTS, models.Correspondent, models.CorrespondentDraft, { "name": "New Correspondent", "match": "", "matching_algorithm": types.MatchingAlgorithm.ANY, "is_insensitive": True, }, ) CUSTOM_FIELD_MAP = ResourceTestMapping( PaperlessResource.CUSTOM_FIELDS, DATA_CUSTOM_FIELDS, models.CustomField, models.CustomFieldDraft, { "name": "New Custom Field", "data_type": types.CustomFieldType.BOOLEAN, }, required_field=None, ) DOCUMENT_MAP = ResourceTestMapping( PaperlessResource.DOCUMENTS, DATA_DOCUMENTS, models.Document, models.DocumentDraft, { "document": b"...example...content...", "tags": [1, 2, 3], "correspondent": 1, "document_type": 1, "storage_path": 1, "title": "New Document", "created": None, "archive_serial_number": 1, }, update_field="title", update_value="Updated Title", ) DOCUMENT_TYPE_MAP = ResourceTestMapping( PaperlessResource.DOCUMENT_TYPES, DATA_DOCUMENT_TYPES, models.DocumentType, models.DocumentTypeDraft, { "name": "New Document Type", "match": "", "matching_algorithm": types.MatchingAlgorithm.ANY, "is_insensitive": True, }, ) GROUP_MAP = ResourceTestMapping( PaperlessResource.GROUPS, DATA_GROUPS, models.Group, ) MAIL_ACCOUNT_MAP = ResourceTestMapping( PaperlessResource.MAIL_ACCOUNTS, DATA_MAIL_ACCOUNTS, models.MailAccount, ) MAIL_RULE_MAP = ResourceTestMapping( PaperlessResource.MAIL_RULES, DATA_MAIL_RULES, models.MailRule, ) PROCESSED_MAIL_MAP = ResourceTestMapping( PaperlessResource.PROCESSED_MAIL, DATA_PROCESSED_MAIL, models.ProcessedMail, ) SAVED_VIEW_MAP = ResourceTestMapping( PaperlessResource.SAVED_VIEWS, DATA_SAVED_VIEWS, models.SavedView, ) SHARE_LINK_MAP = ResourceTestMapping( PaperlessResource.SHARE_LINKS, DATA_SHARE_LINKS, models.ShareLink, models.ShareLinkDraft, { "expiration": None, "document": 1, "file_version": types.ShareLinkFileVersion.ORIGINAL, }, required_field=None, update_field="document", update_value=2, ) SHARE_LINK_BUNDLE_MAP = ResourceTestMapping( PaperlessResource.SHARE_LINK_BUNDLES, DATA_SHARE_LINK_BUNDLES, models.ShareLinkBundle, models.ShareLinkBundleDraft, { "document_ids": [1, 2], "file_version": types.ShareLinkFileVersion.ARCHIVE, }, required_field=None, update_field="document_count", update_value=5, ) STORAGE_PATH_MAP = ResourceTestMapping( PaperlessResource.STORAGE_PATHS, DATA_STORAGE_PATHS, models.StoragePath, models.StoragePathDraft, { "name": "New Storage Path", "path": "path/to/test", "match": "", "matching_algorithm": types.MatchingAlgorithm.ANY, "is_insensitive": True, }, ) TAG_MAP = ResourceTestMapping( PaperlessResource.TAGS, DATA_TAGS, models.Tag, models.TagDraft, { "name": "New Tag", "color": "#012345", "text_color": "#987654", "is_inbox_tag": False, "match": "", "matching_algorithm": types.MatchingAlgorithm.ANY, "is_insensitive": True, }, ) USER_MAP = ResourceTestMapping( PaperlessResource.USERS, DATA_USERS, models.User, ) WORKFLOW_MAP = ResourceTestMapping( PaperlessResource.WORKFLOWS, DATA_WORKFLOWS, models.Workflow, ) tb1337-pypaperless-5d67d60/tests/ruff.toml000066400000000000000000000007641524076126400203620ustar00rootroot00000000000000# This extend our general Ruff rules specifically for tests extend = "../pyproject.toml" lint.extend-select = [ "PT", # Use @pytest.fixture without parentheses ] lint.extend-ignore = [ "S101", # As these are tests, the usage of assert could be good practise, no? "S105", # Yes, we hardcoded passwords. It will be ok this time. "S106", # Same for passwords passed as a keyword argument. "SLF001", # Tests will access private/protected members. "TC002", # pytest doesn't like this one. ] tb1337-pypaperless-5d67d60/tests/test_client.py000066400000000000000000000674311524076126400214160ustar00rootroot00000000000000"""Tests for the PaperlessClient client: init, context, requests, URL, token, Page model.""" import datetime import json import re from io import BytesIO from typing import Any import httpx import pytest from pydantic import BaseModel, Field, ValidationError from pytest_httpx import HTTPXMock from pypaperless import PaperlessClient, PaperlessSettings, generate_api_token from pypaperless.const import API_VERSION, EndpointPath from pypaperless.exceptions import ( BadJsonResponseError, DeletionError, DraftNotSupportedError, ForbiddenError, InactiveOrDeletedError, InitializationError, InvalidTokenError, JsonResponseWithError, NotFoundError, PaperlessConnectionError, PaperlessTimeoutError, UnexpectedStatusError, ) from pypaperless.models import Page from pypaperless.models.base import PaperlessModel from pypaperless.pagination import PageGenerator from pypaperless.services import mixins as service_mixins from pypaperless.services.base import ResourceService from pypaperless.transport import PaperlessTransport from pypaperless.utils import normalize_base_url, process_form_data from tests.const import ( PAPERLESS_TEST_API_VERSION, PAPERLESS_TEST_PASSWORD, PAPERLESS_TEST_TOKEN, PAPERLESS_TEST_URL, PAPERLESS_TEST_USER, PAPERLESS_TEST_VERSION, ) from .data import DATA_PATHS, DATA_TOKEN class _SentinelError(Exception): """Exception type no library code catches, used to prove an error propagates unwrapped.""" def _multipart_parts(body: str) -> dict[str, list[str]]: """Return ``{field name: [value, ...]}`` for a multipart/form-data body.""" parts: dict[str, list[str]] = {} for match in re.finditer(r'name="([^"]+)"[^\r\n]*\r\n(?:[^\r\n]+\r\n)*\r\n(.*?)\r\n--', body): parts.setdefault(match.group(1), []).append(match.group(2)) return parts async def test_init(httpx_mock: HTTPXMock, api: PaperlessClient) -> None: """Test initialization.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}{EndpointPath.INDEX}", method="GET", status_code=200, json=DATA_PATHS, ) await api.initialize() assert api.is_initialized await api.close() async def test_init_reads_version_headers(httpx_mock: HTTPXMock, api: PaperlessClient) -> None: """initialize() picks host_api_version / host_version up from the response headers.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}{EndpointPath.INDEX}", method="GET", status_code=200, json=DATA_PATHS, headers={ "x-api-version": str(PAPERLESS_TEST_API_VERSION), "x-version": PAPERLESS_TEST_VERSION, }, ) await api.initialize() assert api.host_api_version == PAPERLESS_TEST_API_VERSION assert api.host_version == PAPERLESS_TEST_VERSION assert api.runtime.api_version == PAPERLESS_TEST_API_VERSION await api.close() async def test_init_without_version_headers(httpx_mock: HTTPXMock, api: PaperlessClient) -> None: """A host that sends no version headers falls back to the compiled-in API version.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}{EndpointPath.INDEX}", method="GET", status_code=200, json=DATA_PATHS, ) await api.initialize() assert api.host_api_version == API_VERSION assert api.host_version is None await api.close() async def test_context(httpx_mock: HTTPXMock, api: PaperlessClient) -> None: """Test async context manager initializes the client.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}{EndpointPath.INDEX}", method="GET", status_code=200, json=DATA_PATHS, ) async with api: assert api.is_initialized @pytest.mark.parametrize( ("transport_exc", "expected"), [ (httpx.ConnectError("Connection refused"), PaperlessConnectionError), (httpx.ReadTimeout("Read timed out"), PaperlessTimeoutError), (httpx.RemoteProtocolError("Server disconnected"), PaperlessConnectionError), ], ids=["connect_error", "timeout", "other_transport_error"], ) async def test_init_transport_error( httpx_mock: HTTPXMock, api: PaperlessClient, transport_exc: Exception, expected: type[Exception], ) -> None: """Transport-level failures during initialize() map onto typed pypaperless errors.""" httpx_mock.add_exception(transport_exc) with pytest.raises(expected): await api.initialize() @pytest.mark.parametrize( ("response_kwargs", "expected"), [ ({"status_code": 401, "text": "any html"}, InvalidTokenError), ({"status_code": 401, "json": {"detail": "User is inactive"}}, InactiveOrDeletedError), ({"status_code": 403, "text": "any html"}, ForbiddenError), ({"status_code": 200, "text": "any html"}, InitializationError), ], ids=["wrong_token", "inactive_user", "forbidden", "non_json_body"], ) async def test_init_response_error( httpx_mock: HTTPXMock, api: PaperlessClient, response_kwargs: dict[str, Any], expected: type[Exception], ) -> None: """Unusable index responses during initialize() map onto typed pypaperless errors.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}{EndpointPath.INDEX}", method="GET", **response_kwargs, ) with pytest.raises(expected): await api.initialize() async def test_request(httpx_mock: HTTPXMock) -> None: """Test request_raw, including form data encoding.""" # use uninitialised client to bypass session setup api = PaperlessClient(PAPERLESS_TEST_URL, PAPERLESS_TEST_TOKEN) httpx_mock.add_response(url=PAPERLESS_TEST_URL, method="GET", status_code=200) res = await api._runtime.transport.request_raw("get", PAPERLESS_TEST_URL) assert res.status_code == 200 form_data = { "none_field": None, "str_field": "Hello Bytes!", "bytes_field": b"Hello String!", "tuple_field": (b"Document Content", "filename.pdf"), "int_field": 23, "float_field": 13.37, "int_list": [1, 1, 2, 3, 5, 8, 13], "dict_field": {"dict_str_field": "str", "dict_int_field": 2}, } httpx_mock.add_response(url=PAPERLESS_TEST_URL, method="POST", status_code=200) res = await api._runtime.transport.request_raw("post", PAPERLESS_TEST_URL, form=form_data) assert res.status_code == 200 body = httpx_mock.get_requests()[-1].content.decode() parts = _multipart_parts(body) # None is dropped entirely assert "none_field" not in parts # scalars are stringified assert parts["str_field"] == ["Hello Bytes!"] assert parts["int_field"] == ["23"] assert parts["float_field"] == ["13.37"] # lists become repeated values under the same name assert parts["int_list"] == ["1", "1", "2", "3", "5", "8", "13"] # dicts are flattened, the outer key disappears assert "dict_field" not in parts assert parts["dict_str_field"] == ["str"] assert parts["dict_int_field"] == ["2"] # bytes become an unnamed file, 2-tuples carry the filename assert 'name="bytes_field"; filename=' in body assert parts["bytes_field"] == ["Hello String!"] assert 'name="tuple_field"; filename="filename.pdf"' in body assert parts["tuple_field"] == ["Document Content"] await api.close() async def test_request_json(httpx_mock: HTTPXMock, api: PaperlessClient) -> None: """Test get() raises on bad content-type or non-JSON body.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}/400-json-error-payload", method="GET", status_code=400, headers={"Content-Type": "application/json"}, json={"error": "sample message"}, ) url_400 = f"{PAPERLESS_TEST_URL}/400-json-error-payload" with pytest.raises(JsonResponseWithError): await api._runtime.transport.get(url_400) httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}/200-text-error-payload", method="GET", status_code=200, headers={"Content-Type": "text/plain"}, text='{"error": "sample message"}', ) url_200_text = f"{PAPERLESS_TEST_URL}/200-text-error-payload" with pytest.raises(BadJsonResponseError): await api._runtime.transport.get(url_200_text) httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}/200-json-text-body", method="GET", status_code=200, headers={"Content-Type": "application/json"}, text="test 5 23 42 1337", ) url_200_json = f"{PAPERLESS_TEST_URL}/200-json-text-body" with pytest.raises(BadJsonResponseError): await api._runtime.transport.get(url_200_json) @pytest.mark.parametrize( ("input_url", "expected"), [ ("hostname", "https://hostname"), ("http://hostname", "http://hostname"), # only http/https are recognised as schemes; anything else is treated as a # hostname and gets the https:// prefix, producing a deliberately broken URL # rather than silently talking to an unsupported scheme ("ftp://hostname", "https://ftp://hostname"), ("hostname:80", "https://hostname:80"), ("hostname/api/api/", "https://hostname/api/api"), ("hostname/api/endpoint///", "https://hostname/api/endpoint"), ], ids=[ "scheme_less", "http_kept", "unsupported_scheme", "explicit_port", "trailing_slash", "repeated_trailing_slashes", ], ) def test_create_url(input_url: str, expected: str) -> None: """normalize_base_url handles all URL edge cases correctly.""" assert normalize_base_url(input_url) == expected @pytest.mark.parametrize( "url", [PAPERLESS_TEST_URL, PAPERLESS_TEST_URL.removeprefix("https://")], ids=["absolute_url", "scheme_less_url"], ) async def test_generate_api_token(httpx_mock: HTTPXMock, url: str) -> None: """generate_api_token() returns the token and normalizes scheme-less URLs.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}{EndpointPath.TOKEN}", method="POST", status_code=200, json=DATA_TOKEN, ) token = await generate_api_token(url, PAPERLESS_TEST_USER, PAPERLESS_TEST_PASSWORD) assert token == PAPERLESS_TEST_TOKEN body = json.loads(httpx_mock.get_requests()[-1].content) assert body == {"username": PAPERLESS_TEST_USER, "password": PAPERLESS_TEST_PASSWORD} @pytest.mark.parametrize( ("response_kwargs", "expected"), [ ({"status_code": 200, "json": {"blah": "any string"}}, BadJsonResponseError), ( {"status_code": 400, "json": {"non_field_errors": ["Unable to log in."]}}, JsonResponseWithError, ), ], ids=["token_key_missing", "login_rejected"], ) async def test_generate_api_token_response_error( httpx_mock: HTTPXMock, response_kwargs: dict[str, Any], expected: type[Exception] ) -> None: """An unusable token response raises the matching typed error.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}{EndpointPath.TOKEN}", method="POST", **response_kwargs, ) with pytest.raises(expected): await generate_api_token(PAPERLESS_TEST_URL, PAPERLESS_TEST_USER, PAPERLESS_TEST_PASSWORD) @pytest.mark.parametrize( ("transport_exc", "expected"), [ (httpx.ConnectTimeout("Connect timed out"), PaperlessTimeoutError), (httpx.ConnectError("Connection refused"), PaperlessConnectionError), # a genuinely unexpected error must surface unwrapped; a bare ValueError would # also match JSONDecodeError, which the function maps onto BadJsonResponseError (_SentinelError(), _SentinelError), ], ids=["timeout", "connect_error", "unexpected_error_propagates"], ) async def test_generate_api_token_transport_error( httpx_mock: HTTPXMock, transport_exc: Exception, expected: type[Exception] ) -> None: """Transport errors are wrapped like in PaperlessTransport; others propagate as-is.""" httpx_mock.add_exception( transport_exc, url=f"{PAPERLESS_TEST_URL}{EndpointPath.TOKEN}", method="POST", ) with pytest.raises(expected): await generate_api_token(PAPERLESS_TEST_URL, PAPERLESS_TEST_USER, PAPERLESS_TEST_PASSWORD) async def test_generate_api_token_leaves_external_client_open(httpx_mock: HTTPXMock) -> None: """A caller-supplied client is used for the request but never closed.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}{EndpointPath.TOKEN}", method="POST", status_code=200, json=DATA_TOKEN, ) external = httpx.AsyncClient() token = await generate_api_token( PAPERLESS_TEST_URL, PAPERLESS_TEST_USER, PAPERLESS_TEST_PASSWORD, client=external, ) assert token == PAPERLESS_TEST_TOKEN assert not external.is_closed await external.aclose() class _PagedTestResource(PaperlessModel): """Minimal model used to deserialize Page.results in the Page unit tests.""" id: int | None = None def _page_of( api: PaperlessClient, *, current_page: int, next_url: str | None, previous_url: str | None ) -> Page[_PagedTestResource]: """Build one page of a 100-item, 25-per-page result set.""" start = (current_page - 1) * 25 + 1 return Page.from_data( api._runtime, { "count": 100, "next": next_url, "previous": previous_url, "results": [{"id": i} for i in range(start, start + 25)], }, resource_cls=_PagedTestResource, current_page=current_page, page_size=25, ) def test_pages_object_first_page(api: PaperlessClient) -> None: """The first page reports no previous page and points at page 2.""" page = _page_of(api, current_page=1, next_url="any.url", previous_url=None) assert page.current_count == 25 assert len(page.items) == 25 assert [item.id for item in page][:3] == [1, 2, 3] for item in page: assert isinstance(item, _PagedTestResource) assert not page.has_previous_page assert page.has_next_page assert not page.is_last_page assert page.last_page == 4 assert page.next_page == 2 assert page.previous_page is None def test_pages_object_inner_page(api: PaperlessClient) -> None: """An inner page reports both neighbours with the correct page numbers.""" page = _page_of(api, current_page=3, next_url="any.url", previous_url="any.url") assert page.previous_page == 2 assert page.next_page == 4 assert page.has_previous_page assert page.has_next_page assert not page.is_last_page def test_pages_object_last_page(api: PaperlessClient) -> None: """The last page has no next page and identifies itself as last.""" page = _page_of(api, current_page=4, next_url=None, previous_url="any.url") assert page.previous_page == 3 assert page.next_page is None assert page.is_last_page assert page.last_page == 4 def test_draft_not_supported(api: PaperlessClient) -> None: """Test that CreatableService.create() raises when no draft_cls is configured.""" class TestResource(PaperlessModel): pass class TestService(ResourceService, service_mixins.CreatableService): _api_path = "any.url" _resource = "test" _resource_cls = TestResource service = TestService(api) with pytest.raises(DraftNotSupportedError): service.create() def test_draft_rejects_unknown_fields(api: PaperlessClient) -> None: """Draft models forbid unknown kwargs, so typos fail at create() time.""" with pytest.raises(ValidationError, match="Extra inputs are not permitted"): api.tags.create(tag_name="typo") # wrong keyword for 'name' # valid field names still work draft = api.tags.create(name="valid") assert draft.name == "valid" def test_validate_assignment(api: PaperlessClient) -> None: """Field assignments are validated and coerced in place.""" class AssignModel(PaperlessModel): id: int | None = None created: datetime.date | None = None tags: list[int] | None = None model = AssignModel.from_data(api.runtime, {"id": 1}) iso_string: Any = "2024-01-15" model.created = iso_string # coerced by pydantic assert model.created == datetime.date(2024, 1, 15) bad_date: Any = "not a date" with pytest.raises(ValidationError): model.created = bad_date bad_tags: Any = "3,7" with pytest.raises(ValidationError): model.tags = bad_tags def test_snapshot_lazy(api: PaperlessClient) -> None: """Snapshot derives from the raw API payload even when first read after a mutation.""" class LazyModel(PaperlessModel): id: int | None = None title: str | None = None model = LazyModel.from_data(api.runtime, {"id": 1, "title": "before"}) model.title = "after" assert model.snapshot["title"] == "before" # cached: repeated access returns the same dict instance assert model.snapshot is model.snapshot # direct construction without an API payload freezes the state eagerly direct = LazyModel(id=1, title="x") direct.title = "y" assert direct.snapshot["title"] == "x" def test_api_dump(api: PaperlessClient) -> None: """api_dump() serializes by alias, honors exclude markers and JSON-mode conversion.""" class SubModel(BaseModel): name: str class DumpModel(PaperlessModel): id: int | None = None created: datetime.date | None = None sub: SubModel | None = None notes_: list[int] | None = Field(default=None, alias="notes", exclude=True) hit_: str | None = Field(default=None, alias="__hit__") model = DumpModel.from_data( api.runtime, {"id": 1, "created": "2024-01-15", "sub": {"name": "x"}, "notes": [1], "__hit__": "y"}, ) dump = model.api_dump() assert dump == { "id": 1, "created": "2024-01-15", "sub": {"name": "x"}, "__hit__": "y", } # the snapshot uses the exact same representation assert model.snapshot == dump async def test_request_merges_custom_headers(httpx_mock: HTTPXMock) -> None: """request_raw() lets caller-supplied headers win and never mutates the caller's dict.""" api = PaperlessClient(PAPERLESS_TEST_URL, PAPERLESS_TEST_TOKEN) httpx_mock.add_response(url=PAPERLESS_TEST_URL, method="GET", status_code=200) transport = api._runtime.transport caller_headers = {"X-Custom": "value", "Accept": "application/json; version=1"} res = await transport.request_raw("get", PAPERLESS_TEST_URL, headers=caller_headers) assert res.status_code == 200 request = httpx_mock.get_requests()[-1] assert request.headers["X-Custom"] == "value" assert request.headers["Accept"] == "application/json; version=1" assert request.headers["Authorization"] == f"Token {PAPERLESS_TEST_TOKEN}" assert caller_headers == {"X-Custom": "value", "Accept": "application/json; version=1"} await api.close() async def test_request_json_400_body_not_json(httpx_mock: HTTPXMock, api: PaperlessClient) -> None: """get() raises BadJsonResponseError when a 400 body is not valid JSON.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}/400-bad-json", method="GET", status_code=400, headers={"Content-Type": "application/json"}, text="not valid json {{{}}}", ) with pytest.raises(BadJsonResponseError): await api._runtime.transport.get(f"{PAPERLESS_TEST_URL}/400-bad-json") async def test_transport_delete_raises_deletion_error( httpx_mock: HTTPXMock, api: PaperlessClient ) -> None: """transport.delete() raises DeletionError on non-2xx status.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}/api/documents/42/", method="DELETE", status_code=404, ) with pytest.raises(DeletionError): await api._runtime.transport.delete(f"{PAPERLESS_TEST_URL}/api/documents/42/") async def test_request_json_typed_status_errors( httpx_mock: HTTPXMock, api: PaperlessClient ) -> None: """get() raises NotFoundError on 404 and UnexpectedStatusError on other non-2xx codes.""" httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}/missing", method="GET", status_code=404, json={"detail": "Not found."}, ) with pytest.raises(NotFoundError): await api._runtime.transport.get(f"{PAPERLESS_TEST_URL}/missing") httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}/boom", method="GET", status_code=502, text="Bad Gateway", ) with pytest.raises(UnexpectedStatusError): await api._runtime.transport.get(f"{PAPERLESS_TEST_URL}/boom") async def test_external_client_stays_open(httpx_mock: HTTPXMock) -> None: """close() must not close a caller-supplied httpx.AsyncClient.""" external = httpx.AsyncClient() httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}{EndpointPath.INDEX}", method="GET", status_code=200, json=DATA_PATHS, ) async with PaperlessClient( PAPERLESS_TEST_URL, PAPERLESS_TEST_TOKEN, client=external ) as paperless: assert paperless.is_initialized assert not external.is_closed await external.aclose() async def test_owned_client_closed_on_close(httpx_mock: HTTPXMock) -> None: """close() closes the lazily created internal httpx client.""" transport = PaperlessTransport(PAPERLESS_TEST_URL, PAPERLESS_TEST_TOKEN) httpx_mock.add_response(url=PAPERLESS_TEST_URL, method="GET", status_code=200) await transport.request_raw("get", PAPERLESS_TEST_URL) await transport.close() assert transport._httpx_client is not None assert transport._httpx_client.is_closed def test_service_base_api_path(api: PaperlessClient) -> None: """ResourceService.api_path property returns the configured _api_path.""" class _TestService(ResourceService): _api_path = "/api/test/" svc = _TestService(api._runtime) assert svc.api_path == "/api/test/" def test_process_form_data_tuple_len1() -> None: """process_form_data wraps a 1-tuple value as a plain BytesIO (no filename).""" _data, files = process_form_data({"doc": (b"raw bytes",)}) assert len(files) == 1 name, fobj = files[0] assert name == "doc" assert isinstance(fobj, BytesIO) assert fobj.read() == b"raw bytes" def test_config_from_env(monkeypatch: pytest.MonkeyPatch) -> None: """PaperlessClient.from_env() reads PYPAPERLESS_URL / PYPAPERLESS_TOKEN from the environment.""" monkeypatch.setenv("PYPAPERLESS_URL", PAPERLESS_TEST_URL) monkeypatch.setenv("PYPAPERLESS_TOKEN", PAPERLESS_TEST_TOKEN) api = PaperlessClient.from_env() assert api.base_url == PAPERLESS_TEST_URL assert api._runtime.transport._token == PAPERLESS_TEST_TOKEN def test_config_from_env_missing_url(monkeypatch: pytest.MonkeyPatch) -> None: """PaperlessClient.from_env() raises ValidationError when PYPAPERLESS_URL is not set.""" monkeypatch.delenv("PYPAPERLESS_URL", raising=False) monkeypatch.delenv("PYPAPERLESS_TOKEN", raising=False) with pytest.raises(ValidationError): PaperlessClient.from_env() def test_settings_token_is_secret(monkeypatch: pytest.MonkeyPatch) -> None: """The token never leaks through repr/str; from_config unwraps it for the transport.""" # PaperlessSettings is a BaseSettings — a PYPAPERLESS_TOKEN in the developer's # environment would otherwise populate the deliberately anonymous config below monkeypatch.delenv("PYPAPERLESS_TOKEN", raising=False) cfg = PaperlessSettings(url=PAPERLESS_TEST_URL, token=PAPERLESS_TEST_TOKEN) assert PAPERLESS_TEST_TOKEN not in repr(cfg) assert PAPERLESS_TEST_TOKEN not in str(cfg) assert cfg.token is not None assert cfg.token.get_secret_value() == PAPERLESS_TEST_TOKEN api = PaperlessClient.from_config(cfg) assert api._runtime.transport._token == PAPERLESS_TEST_TOKEN # token=None still initializes an anonymous client cfg_anon = PaperlessSettings(url=PAPERLESS_TEST_URL) api_anon = PaperlessClient.from_config(cfg_anon) assert api_anon._runtime.transport._token is None async def test_transport_close_without_prior_request() -> None: """transport.close() must be a no-op when no httpx client was ever created.""" transport = PaperlessTransport(PAPERLESS_TEST_URL, PAPERLESS_TEST_TOKEN) assert transport._httpx_client is None await transport.close() # must not raise async def test_request_without_token(httpx_mock: HTTPXMock) -> None: """_send must omit the Authorization header when the token is None.""" transport = PaperlessTransport(PAPERLESS_TEST_URL, token=None) httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}/api/", method="GET", status_code=200, json={"count": 0}, ) res = await transport.request_raw("get", "/api/") request = httpx_mock.get_requests()[-1] assert "Authorization" not in request.headers assert res.status_code == 200 await transport.close() def test_page_items_raises_without_resource_cls(api: PaperlessClient) -> None: """Page.items raises RuntimeError when no resource_cls was supplied at construction.""" # from_data without resource_cls= leaves _resource_cls unset page = Page.from_data( api._runtime, {"count": 1, "next": None, "previous": None, "all": [1], "results": [{"id": 1}]}, ) # accessing .items runs the mapper, which raises without a resource class with pytest.raises(RuntimeError, match="resource_cls"): _ = page.items async def test_page_generator_follows_next_and_prefetches( httpx_mock: HTTPXMock, api: PaperlessClient ) -> None: """PageGenerator follows the next URL and fetches page 2 before it is asked for.""" page1 = { "count": 3, "next": f"{PAPERLESS_TEST_URL}/api/things/?page=2", "previous": None, "results": [{"id": 1}, {"id": 2}], } page2 = {"count": 3, "next": None, "previous": "x", "results": [{"id": 3}]} httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}/api/things/?page=1&page_size=150", json=page1 ) httpx_mock.add_response(url=f"{PAPERLESS_TEST_URL}/api/things/?page=2", json=page2) gen = PageGenerator(api.runtime, "/api/things/", _PagedTestResource) first = await anext(gen) assert first.current_page == 1 # the prefetch for page 2 is in flight before the consumer asks for it assert gen._prefetch is not None await gen._prefetch assert len(httpx_mock.get_requests()) == 2 pages = [first, *[page async for page in gen]] assert [page.current_page for page in pages] == [1, 2] assert pages[0].has_next_page assert pages[1].is_last_page assert pages[1].results == [{"id": 3}] async def test_page_generator_aclose_cancels_prefetch( httpx_mock: HTTPXMock, api: PaperlessClient ) -> None: """Abandoning iteration early cancels the pending prefetch via aclose().""" page1 = { "count": 2, "next": f"{PAPERLESS_TEST_URL}/api/things/?page=2", "previous": None, "results": [{"id": 1}], } httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}/api/things/?page=1&page_size=150", json=page1 ) # the prefetch may or may not fire before the cancel wins the race httpx_mock.add_response( url=f"{PAPERLESS_TEST_URL}/api/things/?page=2", json={"count": 2, "next": None, "previous": "x", "results": [{"id": 2}]}, is_optional=True, ) gen = PageGenerator(api.runtime, "/api/things/", _PagedTestResource) first = await anext(gen) assert first.current_page == 1 await gen.aclose() with pytest.raises(StopAsyncIteration): await anext(gen) def test_page_last_page_raises_without_pagination_context(api: PaperlessClient) -> None: """Page.last_page raises RuntimeError instead of ZeroDivisionError when page_size is 0.""" page = Page.from_data( api._runtime, {"count": 42, "next": "http://x/?page=2", "previous": None, "results": [{"id": 1}]}, ) with pytest.raises(RuntimeError, match="pagination context"): _ = page.last_page tb1337-pypaperless-5d67d60/tests/test_custom_fields.py000066400000000000000000000200031524076126400227600ustar00rootroot00000000000000"""Tests for custom field value types and the CustomFieldQuery DSL builder.""" import json import operator as _op import re from datetime import date from typing import Any import pytest from pytest_httpx import HTTPXMock from pypaperless import PaperlessClient from pypaperless.builders.custom_fields import ( CustomFieldQuery, _CustomFieldQueryAnd, _CustomFieldQueryNot, _CustomFieldQueryOr, ) from pypaperless.const import EndpointPath from pypaperless.models import CustomField from pypaperless.models.types import ( CustomFieldBooleanValue, CustomFieldDateValue, CustomFieldExtraData, CustomFieldIntegerValue, CustomFieldMonetaryValue, CustomFieldSelectValue, CustomFieldType, CustomFieldValue, ) from .const import PAPERLESS_TEST_URL from .data import DATA_CUSTOM_FIELDS async def test_draft_value_without_cache(paperless: PaperlessClient) -> None: """draft_value() returns a plain object when the custom field cache is empty.""" custom_field = CustomField.from_data( paperless.runtime, data={"id": 1337, "name": "Test", "data_type": CustomFieldType.INTEGER}, ) field_value = custom_field.draft_value(1337) assert type(field_value) is CustomFieldValue assert field_value.field == custom_field.id assert field_value.value == 1337 async def test_draft_value_with_cache(httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """draft_value() returns a typed value when the custom field cache is populated.""" httpx_mock.add_response( url=re.compile( r"^" + re.escape(f"{PAPERLESS_TEST_URL}{EndpointPath.CUSTOM_FIELDS}") + r"\?.*$" ), method="GET", status_code=200, json=DATA_CUSTOM_FIELDS, ) paperless.runtime.cache.custom_fields = await paperless.custom_fields.as_dict() custom_field = CustomField.from_data( runtime=paperless.runtime, data=DATA_CUSTOM_FIELDS["results"][5], ) field_value = custom_field.draft_value(1337, expected_type=CustomFieldIntegerValue) assert field_value.field == custom_field.id assert field_value.value == 1337 @pytest.mark.parametrize( "value_str", ["1900-01-02", "1900-01-02T03:04:05.133337Z"], ids=["date_string", "datetime_string"], ) def test_date_value_parses_to_date(value_str: str) -> None: """CustomFieldDateValue accepts both ISO date strings and datetime strings, returning a date.""" assert CustomFieldDateValue(value=value_str).value == date(1900, 1, 2) def test_monetary_value_parsing() -> None: """CustomFieldMonetaryValue correctly parses and formats currency/amount.""" field = CustomFieldMonetaryValue(value=None) assert field.value is None field = CustomFieldMonetaryValue(value="EUR1337.00") assert field.currency == "EUR" assert field.amount == 1337 field.amount = 123.45678 assert field.amount == 123.46 # rounded to cents field.extra_data = CustomFieldExtraData(default_currency="USD") assert field.value == "EUR123.46" field.value = "123.45" # no explicit currency assert field.currency == "USD" # falls back to default field.extra_data = CustomFieldExtraData() assert field.currency == "" field.currency = "EUR" assert field.value == "EUR123.45" field.currency = "" assert field.value == "123.45" field.value = None assert field.amount is None def test_select_value_labels() -> None: """CustomFieldSelectValue resolves labels from select_options; returns None for missing data.""" test = CustomFieldSelectValue( value="id2", extra_data={ "select_options": [ {"id": "id1", "label": "label1"}, {"id": "id2", "label": "label2"}, ] }, ) assert [option.label for option in test.labels] == ["label1", "label2"] assert test.label == "label2" test.extra_data = None assert test.label is None @pytest.mark.parametrize( ("field", "op", "value", "expected"), [ ("Status", "exact", "open", ["Status", "exact", "open"]), ("Amount", "gte", 100, ["Amount", "gte", 100]), (42, "exists", True, [42, "exists", True]), ], ids=["str_field_str_value", "str_field_int_value", "int_field"], ) def test_atom(field: Any, op: Any, value: Any, expected: Any) -> None: """Atom builds and serialises to the expected 3-element list for all field/value types.""" q = CustomFieldQuery(field, op, value) assert q.build() == expected assert json.loads(str(q)) == expected @pytest.mark.parametrize( ("combine", "expected_cls", "expected_tag", "expected_operands"), [ (_op.and_, _CustomFieldQueryAnd, "AND", [["A", "exact", 1], ["B", "exact", 2]]), (_op.or_, _CustomFieldQueryOr, "OR", [["A", "exact", 1], ["B", "exact", 2]]), ], ids=["AND", "OR"], ) def test_binary_operator( combine: Any, expected_cls: Any, expected_tag: Any, expected_operands: Any ) -> None: """& and | each produce the correct compound node with both operands.""" q1 = CustomFieldQuery("A", "exact", 1) q2 = CustomFieldQuery("B", "exact", 2) combined = combine(q1, q2) assert isinstance(combined, expected_cls) assert combined.build() == [expected_tag, expected_operands] @pytest.mark.parametrize( ("combine", "expected_cls", "expected_tag"), [ (_op.and_, _CustomFieldQueryAnd, "AND"), (_op.or_, _CustomFieldQueryOr, "OR"), ], ids=["AND", "OR"], ) def test_binary_operator_flattens(combine: Any, expected_cls: Any, expected_tag: Any) -> None: """Chaining the same operator three times flattens into a single node.""" q1 = CustomFieldQuery("X", "exact", 1) q2 = CustomFieldQuery("X", "exact", 2) q3 = CustomFieldQuery("X", "exact", 3) combined = combine(combine(q1, q2), q3) assert isinstance(combined, expected_cls) result = combined.build() assert result[0] == expected_tag assert len(result[1]) == 3 def test_not_operator() -> None: """~ produces a _CustomFieldQueryNot wrapping the operand.""" q = CustomFieldQuery("Archived", "exact", value=True) negated = ~q assert isinstance(negated, _CustomFieldQueryNot) assert negated.build() == ["NOT", ["Archived", "exact", True]] def test_combined_expression() -> None: """Complex expression (AND + NOT) builds the correct nested structure.""" q = CustomFieldQuery("Status", "exact", "open") & ~CustomFieldQuery( "Archived", "exact", value=True ) result = q.build() assert result[0] == "AND" assert result[1][1] == ["NOT", ["Archived", "exact", True]] def test_str_is_valid_json() -> None: """str() on any expression always produces valid JSON.""" q = CustomFieldQuery("A", "gte", 0) | CustomFieldQuery("B", "icontains", "foo") parsed = json.loads(str(q)) assert parsed[0] == "OR" def test_repr() -> None: """repr() returns a human-readable string with the class name and serialised expression.""" q = CustomFieldQuery("Status", "exact", "open") r = repr(q) assert r.startswith("CustomFieldQuery(") assert "Status" in r def test_date_value_accepts_date_object() -> None: """CustomFieldDateValue passes through a value that is already a date object.""" d = date(2024, 1, 15) field = CustomFieldDateValue(value=d) assert field.value == d def test_draft_value_raises_for_wrong_expected_type(api: PaperlessClient) -> None: """draft_value() must raise TypeError when the result type mismatches expected_type.""" # Build a CustomField with no cache so draft_value returns a plain CustomFieldValue. cf = CustomField.from_data(api._runtime, {"id": 99, "name": "test", "data_type": "integer"}) # Without cache the result is CustomFieldValue, not CustomFieldBooleanValue. with pytest.raises(TypeError, match="Expected CustomFieldBooleanValue"): cf.draft_value(42, CustomFieldBooleanValue) # Passing expected_type=None must not raise (the guard is skipped). result = cf.draft_value(42) assert type(result) is CustomFieldValue assert result.field == 99 assert result.value == 42 tb1337-pypaperless-5d67d60/tests/test_dispatch.py000066400000000000000000000270161524076126400217320ustar00rootroot00000000000000"""Tests for the model operation dispatcher.""" from collections.abc import Generator from typing import ClassVar import pytest from pytest_httpx import HTTPXMock from pypaperless import PaperlessClient from pypaperless.const import EndpointPath, PaperlessResource from pypaperless.dispatch import ( _MODEL_TO_PROP_NAME, DispatchableCachedProperty, dispatchable_cached_property, ) from pypaperless.exceptions import DispatchError from pypaperless.models import DocumentNote, DocumentNoteDraft from pypaperless.models.base import PaperlessModel from pypaperless.models.tasks import Task from pypaperless.services.base import PaperlessService, ResourceService from pypaperless.services.mixins import DeletableService from .const import PAPERLESS_TEST_URL from .data import DATA_CORRESPONDENTS # The helpers below cover DispatchableCachedProperty.__set_name__ edge cases via an # annotation mutation trick: declare a return annotation mypy can check, then replace # __annotations__["return"] with an unresolvable string so get_type_hints() raises at # runtime. That reaches the defensive branches without any suppression comment. def _factory_bad_type_hint(self: object) -> PaperlessService: """Provide a runtime-unresolvable return annotation.""" raise NotImplementedError # Overwrite the return annotation with an undefined name so get_type_hints raises. _factory_bad_type_hint.__annotations__["return"] = "_UndefinedTypeAtRuntime_XYZABC" def _factory_no_return_annotation(self: object) -> PaperlessService: """Remove return annotation so hints.get('return') is None.""" raise NotImplementedError # Removing the annotation makes hints.get("return") → None → not a PaperlessService. del _factory_no_return_annotation.__annotations__["return"] def _factory_base_service_return(self: object) -> PaperlessService: """Return the PaperlessService base, which has neither _resource_cls nor _draft_cls.""" raise NotImplementedError class _FakeDispatchTestModel(PaperlessModel): """Minimal PaperlessModel used only for dispatch coverage tests.""" _api_path: ClassVar[str] = "/api/dispatch-fake/" class _FakeWritableSubSvc( ResourceService, DeletableService[_FakeDispatchTestModel], ): """Writable sub-service with _resource_cls but no _draft_cls.""" _api_path = "/api/dispatch-fake/" _resource = PaperlessResource.DOCUMENTS _resource_cls = _FakeDispatchTestModel # _draft_cls is deliberately omitted so the registration loop skips the draft branch. def _bad_sub_fget(self: object) -> PaperlessService: """Property getter for bad_sub; its annotation is made unresolvable below.""" raise NotImplementedError # Overwrite return annotation with an undefined name so get_type_hints raises. _bad_sub_fget.__annotations__["return"] = "_UndefinedSubTypeAtRuntime_XYZABC" class _FakeSvcWithSubProps(PaperlessService): """Service with sub-properties exercising __set_name__ sub-discovery edge cases.""" # bad_sub: annotation is an unresolvable string → get_type_hints raises. bad_sub: property = property(_bad_sub_fget) @property def str_sub(self) -> str: """Sub-property returning str — not a PaperlessService subclass.""" return "" @property def writable_sub(self) -> _FakeWritableSubSvc: """Writable sub-service without _draft_cls.""" raise NotImplementedError def _factory_fake_with_sub_props(self: object) -> _FakeSvcWithSubProps: """Return _FakeSvcWithSubProps for __set_name__ sub-discovery edge cases.""" raise NotImplementedError @pytest.fixture(name="restore_registry") def restore_registry_fixture() -> Generator[None]: """Undo registry writes made by __set_name__ so later tests see a clean registry.""" before = dict(_MODEL_TO_PROP_NAME) try: yield finally: _MODEL_TO_PROP_NAME.clear() _MODEL_TO_PROP_NAME.update(before) class TestDispatcherInit: """Verify ModelDispatcher construction.""" def test_unknown_model_type_raises(self, api: PaperlessClient) -> None: """Dispatching a model type with no registered service raises DispatchError.""" task = Task.model_construct() with pytest.raises(DispatchError, match="No service registered"): api._dispatcher._get_service(type(task)) class TestDispatchUpdate: """Verify that update() delegates to the correct service.""" async def test_update_dispatches( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """update() on a Correspondent must call CorrespondentService.update.""" pk = DATA_CORRESPONDENTS["results"][0]["id"] # fetch the model via the service httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.CORRESPONDENTS_SINGLE}".format(pk=pk), status_code=200, json=DATA_CORRESPONDENTS["results"][0], ) corr = await paperless.correspondents(pk) # mutate and mock the PATCH corr.name = "Dispatcher Updated" httpx_mock.add_response( method="PATCH", url=f"{PAPERLESS_TEST_URL}{EndpointPath.CORRESPONDENTS_SINGLE}".format(pk=pk), status_code=200, json={**corr.snapshot, "name": "Dispatcher Updated"}, ) result = await paperless.update(corr) assert result is True assert corr.name == "Dispatcher Updated" async def test_update_no_change_returns_false( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """update() without any mutations must return False (no request sent).""" pk = DATA_CORRESPONDENTS["results"][0]["id"] httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.CORRESPONDENTS_SINGLE}".format(pk=pk), status_code=200, json=DATA_CORRESPONDENTS["results"][0], ) corr = await paperless.correspondents(pk) result = await paperless.update(corr) assert result is False class TestDispatchDelete: """Verify that delete() delegates to the correct service.""" async def test_delete_dispatches( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """delete() on a Correspondent must call CorrespondentService.delete.""" pk = DATA_CORRESPONDENTS["results"][0]["id"] httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.CORRESPONDENTS_SINGLE}".format(pk=pk), status_code=200, json=DATA_CORRESPONDENTS["results"][0], ) corr = await paperless.correspondents(pk) httpx_mock.add_response( method="DELETE", url=f"{PAPERLESS_TEST_URL}{EndpointPath.CORRESPONDENTS_SINGLE}".format(pk=pk), status_code=204, ) await paperless.delete(corr) # must not raise class TestDispatchSave: """Verify that save() delegates to the correct service.""" async def test_save_dispatches(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """save() on a CorrespondentDraft must call CorrespondentService.save.""" draft = paperless.correspondents.create( name="New via Dispatcher", match="", matching_algorithm=1, is_insensitive=True, ) httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.CORRESPONDENTS}", status_code=200, json={"id": 99, "name": "New via Dispatcher"}, ) new_id = await paperless.save(draft) assert new_id == 99 class TestDispatchableCachedPropertyBehavior: """Verify DispatchableCachedProperty descriptor behaviour and __set_name__ edge cases.""" def test_class_level_access_returns_descriptor(self) -> None: """Accessing the property on the class (obj=None) must return the descriptor itself.""" result = PaperlessClient.correspondents assert isinstance(result, dispatchable_cached_property) def test_set_name_silences_type_hints_error(self) -> None: """__set_name__ must return silently when get_type_hints raises.""" prop = DispatchableCachedProperty(_factory_bad_type_hint) prop.__set_name__(object, "bad_hint_prop") assert prop._attr_name == "bad_hint_prop" @pytest.mark.usefixtures("restore_registry") def test_set_name_skips_non_service_return(self) -> None: """__set_name__ must not touch the registry when the return type is not a service.""" prop = DispatchableCachedProperty(_factory_no_return_annotation) before = dict(_MODEL_TO_PROP_NAME) prop.__set_name__(object, "no_return_prop") assert before == _MODEL_TO_PROP_NAME @pytest.mark.usefixtures("restore_registry") def test_set_name_handles_service_without_model_cls(self) -> None: """__set_name__ must not crash when _resource_cls/_draft_cls are absent.""" prop = DispatchableCachedProperty(_factory_base_service_return) before = dict(_MODEL_TO_PROP_NAME) prop.__set_name__(object, "base_svc_prop") assert before == _MODEL_TO_PROP_NAME @pytest.mark.usefixtures("restore_registry") def test_set_name_sub_prop_discovery(self) -> None: """Sub-property discovery registers only the writable sub-service. ``bad_sub`` has an unresolvable annotation and ``str_sub`` does not return a PaperlessService, so both are skipped; ``writable_sub`` is registered under its _resource_cls even though it has no _draft_cls. """ prop = DispatchableCachedProperty(_factory_fake_with_sub_props) prop.__set_name__(object, "fake_sub_prop") assert prop._attr_name == "fake_sub_prop" assert _MODEL_TO_PROP_NAME[_FakeDispatchTestModel] == ("fake_sub_prop", "writable_sub") # the skipped sub-properties contributed no entry of their own registered = [ key for key, value in _MODEL_TO_PROP_NAME.items() if value[0] == prop._attr_name ] assert registered == [_FakeDispatchTestModel] class TestDispatchGuards: """Verify DispatchError is raised when a service lacks a required operation.""" async def test_update_raises_for_non_updatable_service(self, api: PaperlessClient) -> None: """update() must raise DispatchError when the resolved service is not UpdatableService.""" note = DocumentNote.model_construct(id=1, document=42) with pytest.raises(DispatchError, match="does not support 'update'"): await api._dispatcher.update(note) async def test_delete_raises_for_non_deletable_service( self, api: PaperlessClient, monkeypatch: pytest.MonkeyPatch ) -> None: """delete() must raise DispatchError when the resolved service is not DeletableService.""" monkeypatch.setitem(_MODEL_TO_PROP_NAME, DocumentNote, ("profile",)) note = DocumentNote.model_construct(id=1, document=42) with pytest.raises(DispatchError, match="does not support 'delete'"): await api._dispatcher.delete(note) async def test_save_raises_for_non_creatable_service( self, api: PaperlessClient, monkeypatch: pytest.MonkeyPatch ) -> None: """save() must raise DispatchError when the resolved service is not CreatableService.""" monkeypatch.setitem(_MODEL_TO_PROP_NAME, DocumentNoteDraft, ("profile",)) draft = DocumentNoteDraft.model_construct(note="x", document=42) with pytest.raises(DispatchError, match="does not support 'save'"): await api._dispatcher.save(draft) tb1337-pypaperless-5d67d60/tests/test_documents.py000066400000000000000000001440541524076126400221360ustar00rootroot00000000000000"""Tests for the Document service: CRUD, lazy fetch, files, notes, history, custom fields.""" import datetime import io import json import re from types import SimpleNamespace from typing import Any import pytest from pytest_httpx import HTTPXMock from pypaperless import PaperlessClient from pypaperless.const import EndpointPath from pypaperless.exceptions import ( AsnRequestError, DeletionError, DraftFieldRequiredError, JsonResponseWithError, PrimaryKeyRequiredError, SendEmailError, ) from pypaperless.models import ( Correspondent, CustomField, Document, DocumentAISuggestions, DocumentChat, DocumentCustomFieldList, DocumentDraft, DocumentHistory, DocumentHistoryAction, DocumentMeta, DocumentNote, DocumentNoteDraft, DocumentRoot, DocumentSuggestions, DocumentVersionInfo, DownloadedDocument, ShareLink, ) from pypaperless.models.types import ( CustomFieldBooleanValue, CustomFieldDocumentLinkValue, CustomFieldIntegerValue, CustomFieldStringValue, CustomFieldValue, DocumentMetaEntry, DocumentSearchHit, FileRetrieveMode, ) from pypaperless.services.documents.notes import DocumentNoteService from pypaperless.services.mixins.updatable import UpdatableService from .const import PAPERLESS_TEST_URL from .data import ( DATA_CUSTOM_FIELDS, DATA_DOCUMENT_AI_SUGGESTIONS, DATA_DOCUMENT_CHAT, DATA_DOCUMENT_HISTORY, DATA_DOCUMENT_METADATA, DATA_DOCUMENT_NOTES, DATA_DOCUMENT_ROOT, DATA_DOCUMENT_SHARE_LINKS, DATA_DOCUMENT_SUGGESTIONS, DATA_DOCUMENT_VERSION_INFO, DATA_DOCUMENTS, DATA_DOCUMENTS_SEARCH, ) from .mappings import DOCUMENT_MAP class TestDocuments: """Document service: full CRUD, lazy loading, files, notes, history, custom fields.""" async def test_lazy(self, paperless: PaperlessClient) -> None: """Lazy-loaded document has id set but no title fetched.""" document = await paperless.documents(42, lazy=True) assert document.id == 42 assert document.title is None async def test_create(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Draft upload validates required fields and POSTs via the correct endpoint.""" defaults = DOCUMENT_MAP.draft_defaults or {} draft = paperless.documents.create(**defaults) assert isinstance(draft, DocumentDraft) backup = draft.document draft.document = None with pytest.raises(DraftFieldRequiredError): await paperless.documents.save(draft) draft.document = backup httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_POST}", status_code=200, json="11112222-3333-4444-5555-666677778888", ) await paperless.documents.save(draft) async def test_create_date_property(self, paperless: PaperlessClient) -> None: """created_date is an alias for the created field.""" document = Document.from_data(paperless.runtime, data={**DATA_DOCUMENTS["results"][0]}) assert document.created_date == document.created async def test_update(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Updating a document PATCHes the changed field and nothing else.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=200, json=DATA_DOCUMENTS["results"][0], ) to_update = await paperless.documents(1) new_title = f"{to_update.title} Updated" to_update.title = new_title httpx_mock.add_response( method="PATCH", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=200, json={**to_update.snapshot, "title": new_title}, ) assert await paperless.documents.update(to_update) is True assert json.loads(httpx_mock.get_requests()[-1].content) == {"title": new_title} assert to_update.title == new_title async def test_delete(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """delete() returns None on 204, raises DeletionError otherwise, silent_fail suppresses.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=200, json=DATA_DOCUMENTS["results"][0], ) to_delete = await paperless.documents(1) httpx_mock.add_response( method="DELETE", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=204, ) assert await paperless.documents.delete(to_delete) is None httpx_mock.add_response( method="DELETE", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=404, ) with pytest.raises(DeletionError): await paperless.documents.delete(to_delete) httpx_mock.add_response( method="DELETE", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=404, ) assert await paperless.documents.delete(to_delete, silent_fail=True) is None async def test_meta(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """get_metadata() returns a DocumentMeta with original and archive metadata lists.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.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: assert isinstance(item, DocumentMetaEntry) assert isinstance(meta.archive_metadata, list) for item in meta.archive_metadata: assert isinstance(item, DocumentMetaEntry) async def test_files(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """get_download/preview/thumbnail each return a DownloadedDocument.""" httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_DOWNLOAD}".format(pk=1) + r"\?.*$" ), status_code=200, headers={ "Content-Type": "application/pdf", "Content-Disposition": "attachment;filename=any_filename.pdf", }, content=b"Binary data: download", ) download = await paperless.documents.download(1) assert isinstance(download, DownloadedDocument) assert download.mode == FileRetrieveMode.DOWNLOAD httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_PREVIEW}".format(pk=1) + r"\?.*$" ), status_code=200, headers={"Content-Type": "application/pdf"}, content=b"Binary data: preview", ) preview = await paperless.documents.preview(1) assert isinstance(preview, DownloadedDocument) assert preview.mode == FileRetrieveMode.PREVIEW httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_THUMBNAIL}".format(pk=1) + r"\?.*$" ), status_code=200, content=b"Binary data: 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: PaperlessClient) -> None: """get_suggestions() returns a DocumentSuggestions instance.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SUGGESTIONS}".format(pk=1), status_code=200, json=DATA_DOCUMENT_SUGGESTIONS, ) assert isinstance(await paperless.documents.suggestions(1), DocumentSuggestions) async def test_get_next_asn(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """get_next_asn() returns an int on success and raises AsnRequestError on failure.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NEXT_ASN}", status_code=200, json=1337, ) assert isinstance(await paperless.documents.get_next_asn(), int) httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NEXT_ASN}", status_code=500, ) with pytest.raises(AsnRequestError): await paperless.documents.get_next_asn() async def test_searching(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """search(), search(custom_field_query=), and more_like() return typed items.""" httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS}" + r"\?.*query.*$" ), status_code=200, json=DATA_DOCUMENTS_SEARCH, ) async for item in paperless.documents.search("1337"): assert isinstance(item, Document) assert item.has_search_hit assert isinstance(item.search_hit, DocumentSearchHit) httpx_mock.add_response( method="GET", url=re.compile( r"^" f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS}" r"\?.*custom_field_query.*$" ), status_code=200, json=DATA_DOCUMENTS_SEARCH, ) async for item in paperless.documents.search(custom_field_query="1337"): assert isinstance(item, Document) assert item.has_search_hit assert isinstance(item.search_hit, DocumentSearchHit) httpx_mock.add_response( method="GET", url=re.compile( r"^" f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS}" r"\?.*more_like_id.*$" ), status_code=200, json=DATA_DOCUMENTS_SEARCH, ) async for item in paperless.documents.more_like(1337): assert isinstance(item, Document) assert item.has_search_hit assert isinstance(item.search_hit, DocumentSearchHit) async def test_find_duplicate(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """find_duplicate() hashes the bytes and returns the first match, or None.""" content = b"%PDF-fake-content" httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS}" + r"\?.*checksum.*$" ), status_code=200, json=DATA_DOCUMENTS, ) hit = await paperless.documents.find_duplicate(content, filename="invoice.pdf") assert isinstance(hit, Document) assert hit.id == DATA_DOCUMENTS["results"][0]["id"] request = httpx_mock.get_requests()[-1] assert "checksum__iexact=" in request.url.query.decode() assert "original_filename__iexact=invoice.pdf" in request.url.query.decode() httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS}" + r"\?.*checksum.*$" ), status_code=200, json={"count": 0, "next": None, "previous": "", "results": [], "all": []}, ) miss = await paperless.documents.find_duplicate(content) assert miss is None request = httpx_mock.get_requests()[-1] assert "original_filename__iexact" not in request.url.query.decode() async def test_note_call(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Notes list returns DocumentNote instances; missing pk raises PrimaryKeyRequiredError.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=200, json=DATA_DOCUMENTS["results"][0], ) item = await paperless.documents(1) assert isinstance(item, Document) httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1), status_code=200, json=DATA_DOCUMENT_NOTES, ) results = await item.notes(force_request=True) assert isinstance(results, list) assert len(results) > 0 for note in results: assert isinstance(note, DocumentNote) assert isinstance(note.created, datetime.datetime) with pytest.raises(PrimaryKeyRequiredError): item = await paperless.documents.notes() async def test_note_create(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Note draft validates required fields and POSTs to the notes endpoint.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=200, json=DATA_DOCUMENTS["results"][0], ) item = await paperless.documents(1) draft = item.notes.create(note="Test note.") assert isinstance(draft, DocumentNoteDraft) backup = draft.note draft.note = None with pytest.raises(DraftFieldRequiredError): await item.notes.save(draft) draft.note = backup httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1), status_code=200, json=DATA_DOCUMENT_NOTES, ) result = await item.notes.save(draft) assert isinstance(result, int) # cache is updated from the POST response — no extra request needed assert item.notes_ is not None assert len(item.notes_) == len(DATA_DOCUMENT_NOTES) async def test_note_delete(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Deleting a note removes it from the embedded cache. DeletionError propagates correctly. """ httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.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}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1), status_code=200, json=DATA_DOCUMENT_NOTES, ) results = await item.notes(force_request=True) assert len(results) == len(DATA_DOCUMENT_NOTES) httpx_mock.add_response( method="DELETE", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1) + r"\?.*$" ), status_code=204, ) deleted = results[0] await item.notes.delete(deleted) # cache is updated in-place — no API request needed cached = await item.notes() assert len(cached) == len(DATA_DOCUMENT_NOTES) - 1 assert all(n.id != deleted.id for n in cached) # integer shorthand: delete by note id in document context httpx_mock.add_response( method="DELETE", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1) + r"\?.*$" ), status_code=204, ) int_target = cached[0] await item.notes.delete(int_target.id) cached = await item.notes() assert all(n.id != int_target.id for n in cached) # failed note deletion raises DeletionError httpx_mock.add_response( method="DELETE", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1) + r"\?.*$" ), status_code=404, ) with pytest.raises(DeletionError): await item.notes.delete(cached[0]) # silent_fail=True suppresses DeletionError httpx_mock.add_response( method="DELETE", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1) + r"\?.*$" ), status_code=404, ) await item.notes.delete(cached[0], silent_fail=True) async def test_notes_embedded(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Embedded notes are parsed into notes_ without a second API call.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=2), status_code=200, json=DATA_DOCUMENTS["results"][1], ) item = await paperless.documents(2) assert isinstance(item, Document) assert isinstance(item.notes_, list) assert len(item.notes_) == len(DATA_DOCUMENTS["results"][1]["notes"]) for note in item.notes_: assert isinstance(note, DocumentNote) assert isinstance(note.user, int) assert note.document == item.id # document.notes still returns the service (no regression) assert isinstance(item.notes, DocumentNoteService) # calling notes() returns cached data without an API request cached = await item.notes() assert cached == item.notes_ async def test_notes_embedded_document_backfill( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """note.document is backfilled from document.id when the API omits it.""" payload = { **DATA_DOCUMENTS["results"][1], "notes": [ {k: v for k, v in n.items() if k != "document"} for n in DATA_DOCUMENTS["results"][1]["notes"] ], } httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=2), status_code=200, json=payload, ) item = await paperless.documents(2) assert item.notes_ is not None for note in item.notes_: assert note.document == item.id async def test_notes_embedded_empty( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """notes_ is an empty list when the document has no notes.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=200, json=DATA_DOCUMENTS["results"][0], ) item = await paperless.documents(1) assert item.notes_ == [] async def test_notes_force_request( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """force_request=True bypasses the embedded cache and fetches from the API.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=2), status_code=200, json=DATA_DOCUMENTS["results"][1], ) item = await paperless.documents(2) assert item.notes_ is not None # populated from embedded payload httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=2), status_code=200, json=DATA_DOCUMENT_NOTES, ) results = await item.notes(force_request=True) assert isinstance(results, list) for note in results: assert isinstance(note, DocumentNote) async def test_note_user_int_passthrough(self, paperless: PaperlessClient) -> None: """_coerce_user returns the value unchanged when user is already an int.""" note = DocumentNote.from_data( paperless._runtime, {"id": 1, "note": "x", "user": 42, "document": 1}, ) assert note.user == 42 async def test_note_standalone_call( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Standalone notes service (no document instance) fetches from API without caching.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1), status_code=200, json=DATA_DOCUMENT_NOTES, ) results = await paperless.documents.notes(1) assert isinstance(results, list) assert len(results) == len(DATA_DOCUMENT_NOTES) for note in results: assert isinstance(note, DocumentNote) async def test_note_standalone_save( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Standalone save() posts the note and returns the new id without updating any cache.""" draft = paperless.documents.notes.create(1, note="Standalone note.") assert isinstance(draft, DocumentNoteDraft) httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1), status_code=200, json=DATA_DOCUMENT_NOTES, ) result = await paperless.documents.notes.save(draft) assert isinstance(result, int) async def test_note_save_error_payload( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """save() raises JsonResponseWithError when Paperless returns 200 with an error dict.""" draft = paperless.documents.notes.create(1, note="Doomed note.") httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1), status_code=200, json={"error": "Error saving note, check logs for more detail."}, ) with pytest.raises(JsonResponseWithError, match="Error saving note"): await paperless.documents.notes.save(draft) async def test_note_standalone_delete( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Standalone delete() removes the note without touching any cache.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1), status_code=200, json=DATA_DOCUMENT_NOTES, ) notes = await paperless.documents.notes(1) httpx_mock.add_response( method="DELETE", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1) + r"\?.*$" ), status_code=204, ) await paperless.documents.notes.delete(notes[0]) # integer shorthand with explicit pk httpx_mock.add_response( method="DELETE", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_NOTES}".format(pk=1) + r"\?.*$" ), status_code=204, ) await paperless.documents.notes.delete(notes[0].id, pk=1) async def test_note_delete_missing_pk(self, paperless: PaperlessClient) -> None: """Integer delete without document context and no pk raises PrimaryKeyRequiredError.""" with pytest.raises(PrimaryKeyRequiredError): await paperless.documents.notes.delete(2) async def test_history_call(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """History returns typed entries; direct service call and missing pk error both work.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.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=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_HISTORY}".format(pk=1) + r".*$" ), status_code=200, json=DATA_DOCUMENT_HISTORY, ) results = await item.history() assert isinstance(results, list) assert len(results) == len(DATA_DOCUMENT_HISTORY) for entry in results: assert isinstance(entry, DocumentHistory) assert entry.document == item.id assert isinstance(entry.timestamp, datetime.datetime) assert isinstance(entry.action, DocumentHistoryAction) assert isinstance(entry.changes, dict) httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_HISTORY}".format(pk=1) + r".*$" ), status_code=200, json=DATA_DOCUMENT_HISTORY, ) results_direct = await paperless.documents.history(1) assert isinstance(results_direct, list) assert len(results_direct) == len(DATA_DOCUMENT_HISTORY) with pytest.raises(PrimaryKeyRequiredError): await paperless.documents.history() async def test_share_links_call( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Share links returns ShareLink instances; missing pk raises PrimaryKeyRequiredError.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.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}{EndpointPath.DOCUMENTS_SHARE_LINKS}".format(pk=1), status_code=200, json=DATA_DOCUMENT_SHARE_LINKS, ) results = await item.share_links() assert isinstance(results, list) assert len(results) == len(DATA_DOCUMENT_SHARE_LINKS) for link in results: assert isinstance(link, ShareLink) httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SHARE_LINKS}".format(pk=1), status_code=200, json=DATA_DOCUMENT_SHARE_LINKS, ) results_direct = await paperless.documents.share_links(1) assert isinstance(results_direct, list) assert len(results_direct) == len(DATA_DOCUMENT_SHARE_LINKS) with pytest.raises(PrimaryKeyRequiredError): await paperless.documents.share_links() async def test_custom_field_list_without_cache( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Without cache, custom fields are plain CustomFieldValue instances.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=2), status_code=200, json=DATA_DOCUMENTS["results"][1], ) item = await paperless.documents(2) assert isinstance(item.custom_fields, DocumentCustomFieldList) for field in item.custom_fields: assert type(field) is CustomFieldValue async def test_custom_field_list_with_cache( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """With cache, custom fields are typed; operators += and -= update the list.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.CUSTOM_FIELDS}" + r"\?.*$"), status_code=200, json=DATA_CUSTOM_FIELDS, ) paperless.runtime.cache.custom_fields = await paperless.custom_fields.as_dict() httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=2), status_code=200, json=DATA_DOCUMENTS["results"][1], ) item = await paperless.documents(2) assert isinstance(item.custom_fields, DocumentCustomFieldList) for field in item.custom_fields: assert isinstance(field, CustomFieldValue) test_cf = CustomField.from_data( runtime=paperless.runtime, data=DATA_CUSTOM_FIELDS["results"][0], ) assert test_cf in item.custom_fields assert isinstance(item.custom_fields.get(test_cf), CustomFieldValue) assert item.custom_fields.default(test_cf) is not None assert item.custom_fields.default(-1337) is None assert isinstance( item.custom_fields.get(test_cf, CustomFieldDocumentLinkValue), CustomFieldDocumentLinkValue, ) assert isinstance( item.custom_fields.default(test_cf, CustomFieldDocumentLinkValue), CustomFieldDocumentLinkValue, ) with pytest.raises(TypeError): item.custom_fields.get(test_cf, CustomFieldBooleanValue) with pytest.raises(TypeError): item.custom_fields.default(test_cf, CustomFieldBooleanValue) item.custom_fields -= test_cf assert test_cf not in item.custom_fields item.custom_fields += test_cf.draft_value(1337) assert test_cf in item.custom_fields async def test_draft_custom_fields_as_id_list(self, paperless: PaperlessClient) -> None: """DocumentDraft serialises list[int] custom_fields as repeated form values.""" 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: PaperlessClient) -> None: """DocumentDraft serialises DocumentCustomFieldList as a JSON string.""" cf = DocumentCustomFieldList.from_data(paperless.runtime, []) cf += CustomFieldStringValue(field=6, value="hello") cf += CustomFieldIntegerValue(field=3, value=42) draft = paperless.documents.create(document=b"pdf") draft.custom_fields = cf serialized = draft.serialize() raw = serialized["form"]["custom_fields"] assert isinstance(raw, str) decoded = json.loads(raw) assert isinstance(decoded, dict) assert decoded["6"] == "hello" assert decoded["3"] == 42 async def test_draft_custom_fields_object_mapping_upload( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """A draft with a DocumentCustomFieldList can be POSTed successfully.""" cf = DocumentCustomFieldList.from_data(paperless.runtime, []) cf += CustomFieldStringValue(field=6, value="smoke") draft = paperless.documents.create(document=b"%PDF-fake", title="CF Mapping Test") draft.custom_fields = cf httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_POST}", status_code=200, json="aaaabbbb-cccc-dddd-eeee-ffffffffffff", ) task_id = await paperless.documents.save(draft) assert task_id == "aaaabbbb-cccc-dddd-eeee-ffffffffffff" request = httpx_mock.get_request(method="POST") assert request is not None body = request.content.decode(errors="replace") match = re.search(r'name="custom_fields"\r\n\r\n(\{.*?\})\r\n', body, re.DOTALL) assert match is not None, f"custom_fields not found as JSON object in body: {body!r}" decoded = json.loads(match.group(1)) assert decoded == {"6": "smoke"} async def test_email(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Email dispatch succeeds on 200 and raises SendEmailError on 400.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_EMAIL}", status_code=200, json={"message": "Email sent"}, ) await paperless.documents.email( documents=1, addresses="test@example.org", subject="Test Email", message="Test Message", ) assert json.loads(httpx_mock.get_requests()[-1].content) == { "documents": [1], "addresses": "test@example.org", "subject": "Test Email", "message": "Test Message", "use_archive_version": True, } httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_EMAIL}", status_code=400, ) with pytest.raises(SendEmailError): await paperless.documents.email( documents=[1, 2], addresses="test@example.org", subject="Test Email", message="Test Message", use_archive_version=False, ) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["documents"] == [1, 2] assert body["use_archive_version"] is False async def test_is_deleted(self, paperless: PaperlessClient) -> None: """Document.is_deleted is True when deleted_at is set, False otherwise.""" doc_alive = Document.from_data(paperless.runtime, data={**DATA_DOCUMENTS["results"][0]}) assert not doc_alive.is_deleted doc_trashed = Document.from_data( paperless.runtime, data={**DATA_DOCUMENTS["results"][0], "deleted_at": "2024-01-01T00:00:00Z"}, ) assert doc_trashed.is_deleted async def test_download_content_disposition_non_filename_part( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Download with a Content-Disposition that has a non-filename= part is handled.""" # Content-Disposition has an extra part that is NOT filename= httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_DOWNLOAD}".format(pk=1) + r"\?.*$" ), status_code=200, headers={ "Content-Type": "application/pdf", "Content-Disposition": "attachment; charset=utf-8; filename=doc.pdf", }, content=b"binary", ) download = await paperless.documents.download(1) assert isinstance(download, DownloadedDocument) assert download.disposition_filename == "doc.pdf" async def test_update_no_changes( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """update() returns False without calling the API when nothing has changed.""" # Use Correspondent which has only simple string/int fields that won't mismatch httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.CORRESPONDENTS_SINGLE}".format(pk=1), status_code=200, json={"id": 1, "name": "ACME", "slug": "acme"}, ) item = await paperless.correspondents(1) result = await paperless.correspondents.update(item) assert result is False async def test_check_permissions_field_has_permissions_no_perms_key( self, paperless: PaperlessClient ) -> None: """_check_permissions_field exits without changes when permissions not in data.""" item = Correspondent.from_data( paperless, data={ "id": 1, "name": "ACME", "permissions": {"view": {"users": []}, "change": {"users": []}}, }, ) assert item.has_permissions changed: dict = {"name": "New Name"} UpdatableService._check_permissions_field(item, changed) # permissions not in changed, so no set_permissions key added assert "set_permissions" not in changed assert "name" in changed def _validation_info(runtime: object) -> Any: """Return a stand-in carrying just the ``context`` that _enrich_from_cache reads.""" return SimpleNamespace(context={"runtime": runtime}) def test_enrich_from_cache_non_list_passthrough(api: PaperlessClient) -> None: """_enrich_from_cache returns non-list input unchanged instead of trying to enrich it.""" api.runtime.cache.custom_fields = { 1: CustomField.from_data(api.runtime, {"id": 1, "name": "cached", "data_type": "string"}) } # a populated cache is what makes the enrichment loop reachable at all, so the # non-list guard is the only thing keeping this from iterating a plain string passthrough = DocumentCustomFieldList._enrich_from_cache( "not a list", _validation_info(api.runtime) ) assert passthrough == "not a list" def test_enrich_from_cache_without_cache(api: PaperlessClient) -> None: """Without a populated cache the raw items are returned untouched.""" raw = [{"field": 1, "value": "x"}] assert DocumentCustomFieldList._enrich_from_cache(raw, _validation_info(api.runtime)) is raw def test_document_sub_service_properties_cached(api: PaperlessClient) -> None: """Accessing .history and .share_links twice returns the same cached sub-service.""" doc = Document.from_data(api._runtime, {"id": 7}) history1 = doc.history history2 = doc.history assert history1 is history2 sl1 = doc.share_links sl2 = doc.share_links assert sl1 is sl2 class TestDocumentAISuggestions: """DocumentAISuggestionsService: GET per-document sub-service.""" async def test_call_via_service( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Calling the service with a pk returns a DocumentAISuggestions instance.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_AI_SUGGESTIONS}".format(pk=1), status_code=200, json=DATA_DOCUMENT_AI_SUGGESTIONS, ) result = await paperless.documents.ai_suggestions(1) assert isinstance(result, DocumentAISuggestions) assert result.id == 1 assert result.title == DATA_DOCUMENT_AI_SUGGESTIONS["title"] assert result.correspondents == DATA_DOCUMENT_AI_SUGGESTIONS["correspondents"] assert result.suggested_tags == DATA_DOCUMENT_AI_SUGGESTIONS["suggested_tags"] async def test_call_via_document_property( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Accessing ai_suggestions via a Document instance uses the attached pk.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=200, json=DATA_DOCUMENTS["results"][0], ) httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_AI_SUGGESTIONS}".format(pk=1), status_code=200, json=DATA_DOCUMENT_AI_SUGGESTIONS, ) doc = await paperless.documents(1) result = await doc.ai_suggestions(1) assert isinstance(result, DocumentAISuggestions) async def test_no_pk_raises(self, paperless: PaperlessClient) -> None: """Calling the service without a pk and without an attached document raises.""" with pytest.raises(PrimaryKeyRequiredError): await paperless.documents.ai_suggestions() def test_property_cached(self, api: PaperlessClient) -> None: """Accessing .ai_suggestions twice returns the same service instance.""" doc = Document.from_data(api._runtime, {"id": 5}) svc1 = doc.ai_suggestions svc2 = doc.ai_suggestions assert svc1 is svc2 class TestDocumentChat: """DocumentChatService: POST collection-level service.""" async def test_call(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Calling the service POSTs the payload and returns a DocumentChat instance.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_CHAT}", status_code=200, json=DATA_DOCUMENT_CHAT, ) result = await paperless.documents.chat("What is this document about?", 1) assert isinstance(result, DocumentChat) assert result.q == DATA_DOCUMENT_CHAT["q"] assert result.document_id == DATA_DOCUMENT_CHAT["document_id"] assert json.loads(httpx_mock.get_requests()[-1].content) == { "q": "What is this document about?", "document_id": 1, } async def test_call_without_document_id( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """document_id is optional; omitting it sends only `q` in the payload.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_CHAT}", status_code=200, json={"q": "General question", "document_id": None}, ) result = await paperless.documents.chat("General question") assert isinstance(result, DocumentChat) assert result.q == "General question" assert result.document_id is None assert json.loads(httpx_mock.get_requests()[-1].content) == {"q": "General question"} class TestDocumentVersionService: """DocumentVersionService: upload / update / delete per-document sub-service.""" async def test_upload_via_service( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """upload() POSTs the file as multipart and omits version_label when not given.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_UPDATE_VERSION}".format(pk=1), status_code=200, text="OK", ) result = await paperless.documents.versions.upload(io.BytesIO(b"data"), pk=1) assert result is None request = httpx_mock.get_requests()[-1] assert request.headers["content-type"].startswith("multipart/form-data") body = request.content.decode(errors="replace") assert 'name="document"' in body assert "\r\n\r\ndata\r\n" in body assert 'name="version_label"' not in body async def test_upload_with_label_via_service( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """upload() includes version_label in multipart when provided.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_UPDATE_VERSION}".format(pk=1), status_code=200, text="OK", ) result = await paperless.documents.versions.upload( io.BytesIO(b"data"), version_label="v2", pk=1 ) assert result is None body = httpx_mock.get_requests()[-1].content.decode(errors="replace") assert re.search(r'name="version_label"\r\n\r\nv2\r\n', body) is not None async def test_update_via_service( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """update() PATCHes the version label and returns a DocumentVersionInfo.""" httpx_mock.add_response( method="PATCH", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_VERSION}".format(pk=1, version_id=1), status_code=200, json=DATA_DOCUMENT_VERSION_INFO, ) result = await paperless.documents.versions.update(1, version_label="v2", pk=1) assert isinstance(result, DocumentVersionInfo) assert result.version_label == DATA_DOCUMENT_VERSION_INFO["version_label"] assert result.is_root == DATA_DOCUMENT_VERSION_INFO["is_root"] async def test_delete_via_service( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """delete() sends DELETE and returns None.""" httpx_mock.add_response( method="DELETE", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_VERSION}".format(pk=1, version_id=1), status_code=204, ) result = await paperless.documents.versions.delete(1, pk=1) assert result is None async def test_upload_via_document_property( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """upload() works when called via a Document instance (uses attached pk).""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=200, json=DATA_DOCUMENTS["results"][0], ) httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_UPDATE_VERSION}".format(pk=1), status_code=200, text="OK", ) doc = await paperless.documents(1) result = await doc.versions.upload(io.BytesIO(b"data")) assert result is None async def test_update_via_document_property( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """update() works when called via a Document instance (uses attached pk).""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=200, json=DATA_DOCUMENTS["results"][0], ) httpx_mock.add_response( method="PATCH", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_VERSION}".format(pk=1, version_id=1), status_code=200, json=DATA_DOCUMENT_VERSION_INFO, ) doc = await paperless.documents(1) result = await doc.versions.update(1, version_label="v2") assert isinstance(result, DocumentVersionInfo) async def test_delete_via_document_property( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """delete() works when called via a Document instance (uses attached pk).""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=200, json=DATA_DOCUMENTS["results"][0], ) httpx_mock.add_response( method="DELETE", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_VERSION}".format(pk=1, version_id=1), status_code=204, ) doc = await paperless.documents(1) result = await doc.versions.delete(1) assert result is None async def test_upload_no_pk_raises(self, paperless: PaperlessClient) -> None: """upload() without pk and without attached doc raises PrimaryKeyRequiredError.""" with pytest.raises(PrimaryKeyRequiredError): await paperless.documents.versions.upload(io.BytesIO(b"data")) async def test_update_no_pk_raises(self, paperless: PaperlessClient) -> None: """update() without pk and without attached doc raises PrimaryKeyRequiredError.""" with pytest.raises(PrimaryKeyRequiredError): await paperless.documents.versions.update(1, version_label="v2") async def test_delete_no_pk_raises(self, paperless: PaperlessClient) -> None: """delete() without pk and without attached doc raises PrimaryKeyRequiredError.""" with pytest.raises(PrimaryKeyRequiredError): await paperless.documents.versions.delete(1) def test_property_cached(self, api: PaperlessClient) -> None: """Accessing .versions twice returns the same service instance.""" doc = Document.from_data(api._runtime, {"id": 5}) svc1 = doc.versions svc2 = doc.versions assert svc1 is svc2 class TestDocumentRootService: """DocumentRootService: GET per-document sub-service.""" async def test_call_via_service( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Calling the service with a pk returns a DocumentRoot instance.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_ROOT}".format(pk=1), status_code=200, json=DATA_DOCUMENT_ROOT, ) result = await paperless.documents.root(1) assert isinstance(result, DocumentRoot) assert result.root_id == DATA_DOCUMENT_ROOT["root_id"] async def test_call_via_document_property( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Accessing root via a Document instance uses the attached pk.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=1), status_code=200, json=DATA_DOCUMENTS["results"][0], ) httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_ROOT}".format(pk=1), status_code=200, json=DATA_DOCUMENT_ROOT, ) doc = await paperless.documents(1) result = await doc.root() assert isinstance(result, DocumentRoot) async def test_no_pk_raises(self, paperless: PaperlessClient) -> None: """Calling the service without a pk raises PrimaryKeyRequiredError.""" with pytest.raises(PrimaryKeyRequiredError): await paperless.documents.root() def test_property_cached(self, api: PaperlessClient) -> None: """Accessing .root twice returns the same service instance.""" doc = Document.from_data(api._runtime, {"id": 5}) svc1 = doc.root svc2 = doc.root assert svc1 is svc2 tb1337-pypaperless-5d67d60/tests/test_exceptions.py000066400000000000000000000044571524076126400223200ustar00rootroot00000000000000"""Tests for exception classes: formatting.""" import re import pytest from pypaperless.exceptions import ( JsonResponseWithError, ) @pytest.mark.parametrize( ("payload", "expected_message"), [ ( "sample string", "Paperless [error]: sample string", ), ( {"failure": "something failed"}, "Paperless [failure]: something failed", ), ( {"error": ["that", "should", "have", "been", "never", "happened"]}, "Paperless [error]: that", ), ( [{"some": [[{"weird": {"error": ["occurred"]}}]]}], "Paperless [some -> weird -> error]: occurred", ), ( {}, "Paperless [error]: unknown error", ), ( [], "Paperless [error]: unknown error", ), ( {"detail": []}, "Paperless [detail]: unknown error", ), # "error" wins over the plain first-key branch even when it is not first ( {"detail": "ignored", "error": "the real one"}, "Paperless [error]: the real one", ), # …and the generic branch picks the first key when no "error" is present ( {"detail": "the real one", "warning": "ignored"}, "Paperless [detail]: the real one", ), ], ids=[ "plain_string", "single_key_dict", "error_key_list_value", "deeply_nested", "empty_dict", "empty_list", "empty_nested_value", "error_key_takes_priority", "first_key_without_error", ], ) def test_json_response_with_error_formatting(payload: object, expected_message: str) -> None: """JsonResponseWithError must format its message correctly for every payload shape.""" with pytest.raises(JsonResponseWithError, match=rf"^{re.escape(expected_message)}$"): raise JsonResponseWithError(payload) def test_json_response_with_error_does_not_mutate_payload() -> None: """JsonResponseWithError must not consume list entries while parsing the payload.""" payload = [{"some": ["deep", "error"]}] with pytest.raises(JsonResponseWithError): raise JsonResponseWithError(payload) assert payload == [{"some": ["deep", "error"]}] tb1337-pypaperless-5d67d60/tests/test_filters.py000066400000000000000000000070251524076126400216010ustar00rootroot00000000000000"""Tests for filter TypedDicts and typed service .filter() methods.""" import re from typing import Any import pytest from pytest_httpx import HTTPXMock from pypaperless import PaperlessClient from pypaperless.const import EndpointPath from .const import PAPERLESS_TEST_URL from .data import ( DATA_CORRESPONDENTS, DATA_CUSTOM_FIELDS, DATA_DOCUMENT_TYPES, DATA_DOCUMENTS, DATA_GROUPS, DATA_PROCESSED_MAIL, DATA_SHARE_LINK_BUNDLES, DATA_SHARE_LINKS, DATA_STORAGE_PATHS, DATA_TAGS, DATA_TASKS, DATA_USERS, ) def _expected_param(value: Any) -> str: """Return the query string form Paperless receives for a filter value.""" if isinstance(value, bool): return "true" if value else "false" if isinstance(value, list): return ",".join(map(str, value)) return str(value) @pytest.mark.parametrize( ("api_key", "service_attr", "filter_kwargs", "mock_data"), [ ( "documents", "documents", {"title__icontains": "invoice", "is_tagged": True, "id__in": [1, 2]}, DATA_DOCUMENTS, ), ("correspondents", "correspondents", {"name__icontains": "acme"}, DATA_CORRESPONDENTS), ("tags", "tags", {"is_root": True}, DATA_TAGS), ("storage_paths", "storage_paths", {"path__icontains": "/invoices"}, DATA_STORAGE_PATHS), ("groups", "groups", {"name__icontains": "admin"}, DATA_GROUPS), ("users", "users", {"username__icontains": "admin"}, DATA_USERS), ( "share_link_bundles", "share_link_bundles", {"status": "ready", "documents": 1}, DATA_SHARE_LINK_BUNDLES, ), ( "custom_fields", "custom_fields", {"name__icontains": "project"}, DATA_CUSTOM_FIELDS, ), ( "document_types", "document_types", {"name__icontains": "invoice"}, DATA_DOCUMENT_TYPES, ), ( "share_links", "share_links", {"expiration__year": 2025}, DATA_SHARE_LINKS, ), ( "tasks", "tasks", {"status": "pending", "name": "consume", "result": "timeout"}, DATA_TASKS, ), ( "processed_mail", "processed_mail", {"rule": 1, "status": "FAILED"}, DATA_PROCESSED_MAIL, ), ], ids=[ "documents", "correspondents", "tags", "storage_paths", "groups", "users", "share_link_bundles", "custom_fields", "document_types", "share_links", "tasks", "processed_mail", ], ) async def test_service_filter_forwards_typed_kwargs( *, httpx_mock: HTTPXMock, paperless: PaperlessClient, api_key: str, service_attr: str, filter_kwargs: dict, mock_data: dict, ) -> None: """service.filter() forwards every typed filter kwarg into the query string.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath[api_key.upper()]}" + r"\?.*$"), status_code=200, json=mock_data, ) async with getattr(paperless, service_attr).filter(**filter_kwargs) as q: items = [item async for item in q] assert len(items) == len(mock_data["results"]) params = httpx_mock.get_requests()[-1].url.params for key, value in filter_kwargs.items(): assert params[key] == _expected_param(value) tb1337-pypaperless-5d67d60/tests/test_fixture_data.py000066400000000000000000000024141524076126400226050ustar00rootroot00000000000000"""Self-checks for the snapshot fixtures in tests/data.""" from typing import Any import pytest import tests.data as fixture_data def _paginated_fixtures() -> list[tuple[str, dict[str, Any]]]: """Return every ``(name, payload)`` fixture shaped like a DRF paginated response.""" return [ (name, obj) for name in sorted(fixture_data.__all__) if isinstance(obj := getattr(fixture_data, name), dict) and {"count", "results"} <= obj.keys() ] def test_paginated_fixtures_discovered() -> None: """The discovery must actually find fixtures, otherwise the check below is vacuous.""" assert len(_paginated_fixtures()) >= 15 @pytest.mark.parametrize( ("name", "payload"), _paginated_fixtures(), ids=[name for name, _ in _paginated_fixtures()] ) def test_paginated_fixture_count_matches_results(name: str, payload: dict[str, Any]) -> None: """A single-page fixture must report a count equal to the number of results it carries. Tests assert item counts against ``len(results)``; a fixture whose ``count`` disagrees would make ``Page.last_page`` claim pages that the fixture cannot serve. """ assert payload["next"] is None, f"{name} is not a single-page fixture" assert payload["count"] == len(payload["results"]) tb1337-pypaperless-5d67d60/tests/test_resources.py000066400000000000000000001262561524076126400221530ustar00rootroot00000000000000"""Tests for resource services: Config, Version, Profile, Statistics, Status, Tasks, Trash.""" import json import re import pytest from pytest_httpx import HTTPXMock from pypaperless import PaperlessClient from pypaperless.builders import SearchQuery from pypaperless.const import EndpointPath from pypaperless.exceptions import ( BulkEditError, BulkEditPagesError, NotFoundError, PaperlessError, TaskNotFoundError, ) from pypaperless.models import ( Config, Document, Profile, SearchResult, ShareLinkBundle, Status, Task, WorkflowAction, WorkflowTrigger, ) from pypaperless.models.mixins.securable import Permissions from pypaperless.models.tasks import TaskStatusCounts, TaskSummary from pypaperless.models.types import ( StatisticDocumentFileTypeCount, StatusDatabase, StatusStorage, StatusTasks, WorkflowActionType, WorkflowTriggerType, ) from pypaperless.services.workflows import WorkflowActionService, WorkflowTriggerService from .const import PAPERLESS_TEST_URL from .data import ( DATA_BULK_EDIT_OBJECTS, DATA_CONFIG, DATA_DOCUMENTS_BULK_EDIT, DATA_PROFILE, DATA_REMOTE_VERSION, DATA_SEARCH, DATA_SEARCH_AUTOCOMPLETE, DATA_SHARE_LINK_BUNDLES, DATA_STATISTICS, DATA_STATUS, DATA_TASKS, DATA_TASKS_ACTIVE, DATA_TASKS_STATUS_COUNTS, DATA_TASKS_SUMMARY, DATA_TRASH, DATA_WORKFLOW_ACTIONS, DATA_WORKFLOW_TRIGGERS, ) class TestConfig: """Config singleton service.""" async def test_config_call(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """config() fetches the singleton Config without requiring a pk.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.CONFIG_SINGLE}".format(pk=1), status_code=200, json=DATA_CONFIG[0], ) item = await paperless.config() assert isinstance(item, Config) class TestRemoteVersion: """Remote version lookup service.""" async def test_remote_version_call( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """remote_version() returns version string and update_available flag.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.REMOTE_VERSION}", status_code=200, json=DATA_REMOTE_VERSION, ) remote_version = await paperless.remote_version() assert isinstance(remote_version.version, str) assert isinstance(remote_version.update_available, bool) class TestProfile: """Profile service: read and partial updates.""" async def test_profile_call(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """profile() returns a Profile with expected field types.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.PROFILE}", status_code=200, json=DATA_PROFILE, ) profile = await paperless.profile() assert isinstance(profile, Profile) assert isinstance(profile.email, str) assert isinstance(profile.auth_token, str) assert isinstance(profile.has_usable_password, bool) assert isinstance(profile.is_mfa_enabled, bool) assert isinstance(profile.social_accounts, list) async def test_profile_update(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """profile.update() PATCHes the profile and returns the updated model.""" updated = {**DATA_PROFILE, "first_name": "Patched", "last_name": "User"} httpx_mock.add_response( method="PATCH", url=f"{PAPERLESS_TEST_URL}{EndpointPath.PROFILE}", status_code=200, json=updated, ) profile = await paperless.profile.update(first_name="Patched", last_name="User") assert isinstance(profile, Profile) assert profile.first_name == "Patched" assert profile.last_name == "User" body = json.loads(httpx_mock.get_requests()[-1].content) assert body == {"first_name": "Patched", "last_name": "User"} async def test_profile_update_email( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """profile.update(email=) PATCHes only the email field.""" updated = {**DATA_PROFILE, "email": "new@example.com"} httpx_mock.add_response( method="PATCH", url=f"{PAPERLESS_TEST_URL}{EndpointPath.PROFILE}", status_code=200, json=updated, ) profile = await paperless.profile.update(email="new@example.com") assert isinstance(profile, Profile) assert profile.email == "new@example.com" body = json.loads(httpx_mock.get_requests()[-1].content) assert body == {"email": "new@example.com"} async def test_profile_update_password( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """profile.update(password=) PATCHes only the password field.""" httpx_mock.add_response( method="PATCH", url=f"{PAPERLESS_TEST_URL}{EndpointPath.PROFILE}", status_code=200, json=DATA_PROFILE, ) profile = await paperless.profile.update(password="s3cr3t") assert isinstance(profile, Profile) body = json.loads(httpx_mock.get_requests()[-1].content) assert body == {"password": "s3cr3t"} class TestStatistics: """Statistics service.""" async def test_statistics_call(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """statistics() returns typed statistics with document file type counts.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.STATISTICS}", status_code=200, json=DATA_STATISTICS, ) stats = await paperless.statistics() assert isinstance(stats.character_count, int) assert isinstance(stats.document_file_type_counts, list) for item in stats.document_file_type_counts: assert isinstance(item, StatisticDocumentFileTypeCount) class TestStatus: """System status service and the has_errors aggregation.""" async def test_status_call(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """status() returns a Status with typed sub-objects.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.STATUS}", status_code=200, json=DATA_STATUS, ) status = await paperless.status() assert isinstance(status, Status) assert isinstance(status.storage, StatusStorage) assert isinstance(status.database, StatusDatabase) assert isinstance(status.tasks, StatusTasks) async def test_status_has_errors(self, paperless: PaperlessClient) -> None: """Status.has_errors is True when any component is in ERROR state.""" data = { "database": {"status": "OK"}, "tasks": { "redis_status": "OK", "celery_status": "OK", "classifier_status": "OK", }, } status = Status.from_data(paperless.runtime, data=data) assert status.has_errors is False data["database"]["status"] = "ERROR" status = Status.from_data(paperless.runtime, data=data) assert status.has_errors is True # None values are treated as no errors del data["database"]["status"] status = Status.from_data(paperless.runtime, data=data) assert status.has_errors is False def test_status_has_errors_no_tasks(self, paperless: PaperlessClient) -> None: """has_errors must work when tasks is None, skipping the per-task status extend.""" status = Status.from_data(paperless.runtime, data={"database": {"status": "OK"}}) assert status.tasks is None assert status.has_errors is False status_err = Status.from_data(paperless.runtime, data={"database": {"status": "ERROR"}}) assert status_err.has_errors is True class TestTasks: """Task service: iteration, filter, single fetch by pk and uuid.""" async def test_iter(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Iterating over tasks yields Task instances from paginated response.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.TASKS}" + r".*$"), status_code=200, json=DATA_TASKS, ) items = [item async for item in paperless.tasks] assert len(items) == len(DATA_TASKS["results"]) for item in items: assert isinstance(item, Task) async def test_filter(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """tasks.filter() is a context manager passing kwargs as query params.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.TASKS}" + r"\?.*status.*$"), status_code=200, json=DATA_TASKS, ) async with paperless.tasks.filter(status=["pending"]) as filtered: items = [item async for item in filtered] assert len(items) == len(DATA_TASKS["results"]) for item in items: assert isinstance(item, Task) # a plain list filter is sent as repeated params, unlike __in/__all assert httpx_mock.get_requests()[-1].url.params.get_list("status") == ["pending"] async def test_call_by_pk(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """tasks(pk) fetches by primary key.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.TASKS_SINGLE}".format(pk=1), status_code=200, json=DATA_TASKS["results"][0], ) item = await paperless.tasks(1) assert isinstance(item, Task) async def test_call_by_uuid(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """tasks(uuid) fetches by task UUID via paginated list filter.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.TASKS}" + r"\?task_id.*$"), status_code=200, json=DATA_TASKS, ) item = await paperless.tasks("dummy-found") assert isinstance(item, Task) async def test_call_pk_not_found( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """tasks(unknown_pk) raises NotFoundError.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.TASKS_SINGLE}".format(pk=1337), status_code=404, ) with pytest.raises(NotFoundError): await paperless.tasks(1337) async def test_call_uuid_not_found( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """tasks(unknown_uuid) raises TaskNotFoundError on empty paginated result.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.TASKS}" + r"\?task_id.*$"), status_code=200, json={"count": 0, "next": None, "previous": None, "results": []}, ) with pytest.raises(TaskNotFoundError): await paperless.tasks("dummy-not-found") async def test_active(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """tasks.active() returns an async iterator of Task instances.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.TASKS_ACTIVE}", status_code=200, json=DATA_TASKS_ACTIVE, ) items = [] async for item in paperless.tasks.active(): assert isinstance(item, Task) items.append(item) assert len(items) == len(DATA_TASKS_ACTIVE) async def test_acknowledge(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """tasks.acknowledge([...]) POSTs and returns acknowledged count.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.TASKS_ACKNOWLEDGE}", status_code=200, json={"result": 1}, ) result = await paperless.tasks.acknowledge([1]) assert result == 1 assert json.loads(httpx_mock.get_requests()[-1].content) == {"tasks": [1]} async def test_summary(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """tasks.summary() returns a list of TaskSummary instances.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.TASKS_SUMMARY}", status_code=200, json=DATA_TASKS_SUMMARY, ) result = await paperless.tasks.summary() assert isinstance(result, list) assert len(result) == len(DATA_TASKS_SUMMARY) assert all(isinstance(s, TaskSummary) for s in result) async def test_status_counts(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """tasks.status_counts() returns a single TaskStatusCounts instance.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.TASKS_STATUS_COUNTS}", status_code=200, json=DATA_TASKS_STATUS_COUNTS, ) result = await paperless.tasks.status_counts() assert isinstance(result, TaskStatusCounts) assert result.all == DATA_TASKS_STATUS_COUNTS["all"] assert result.needs_attention == DATA_TASKS_STATUS_COUNTS["needs_attention"] async def test_run(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """tasks.run(task_type) POSTs and returns the Celery UUID string.""" task_uuid = "11112222-aaaa-bbbb-cccc-333344445555" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.TASKS_RUN}", status_code=200, json={"task_id": task_uuid}, ) result = await paperless.tasks.run("sanity_check") assert isinstance(result, str) assert result == task_uuid assert json.loads(httpx_mock.get_requests()[-1].content) == {"task_type": "sanity_check"} class TestMailAccounts: """Mail account service action endpoints.""" async def test_test(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """mail_accounts.test() POSTs to the endpoint and returns JSON.""" payload = {"success": True} httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.MAIL_ACCOUNTS_TEST}", status_code=200, json=payload, ) response = await paperless.mail_accounts.test() assert response == payload async def test_process(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """mail_accounts.process(pk) POSTs to the account process endpoint.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.MAIL_ACCOUNTS_PROCESS}".format(pk=1), status_code=200, json={"result": "ok"}, ) await paperless.mail_accounts.process(1) request = httpx_mock.get_requests()[-1] assert request.url.path.endswith("/1/process/") assert json.loads(request.content) == {} class TestWorkflows: """Workflow service and its action / trigger sub-services.""" async def test_sub_services(self, paperless: PaperlessClient) -> None: """workflows.actions and workflows.triggers are the expected service types.""" assert isinstance(paperless.workflows.actions, WorkflowActionService) assert isinstance(paperless.workflows.triggers, WorkflowTriggerService) async def test_actions_iter(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Iterating workflows.actions yields WorkflowAction instances.""" httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.WORKFLOW_ACTIONS}" + r"\?.*$" ), status_code=200, json=DATA_WORKFLOW_ACTIONS, ) items = [item async for item in paperless.workflows.actions] assert len(items) == len(DATA_WORKFLOW_ACTIONS["results"]) for item in items: assert isinstance(item, WorkflowAction) assert isinstance(item.type, WorkflowActionType) async def test_actions_call(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """workflows.actions(pk) fetches a single action by primary key.""" action = DATA_WORKFLOW_ACTIONS["results"][0] httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.WORKFLOW_ACTIONS_SINGLE}".format( pk=action["id"] ), status_code=200, json=action, ) item = await paperless.workflows.actions(action["id"]) assert isinstance(item, WorkflowAction) assert item.id == action["id"] async def test_triggers_iter(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Iterating workflows.triggers yields WorkflowTrigger instances.""" httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.WORKFLOW_TRIGGERS}" + r"\?.*$" ), status_code=200, json=DATA_WORKFLOW_TRIGGERS, ) items = [item async for item in paperless.workflows.triggers] assert len(items) == len(DATA_WORKFLOW_TRIGGERS["results"]) for item in items: assert isinstance(item, WorkflowTrigger) assert isinstance(item.type, WorkflowTriggerType) async def test_triggers_call(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """workflows.triggers(pk) fetches a single trigger by primary key.""" trigger = DATA_WORKFLOW_TRIGGERS["results"][0] httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.WORKFLOW_TRIGGERS_SINGLE}".format( pk=trigger["id"] ), status_code=200, json=trigger, ) item = await paperless.workflows.triggers(trigger["id"]) assert isinstance(item, WorkflowTrigger) assert item.id == trigger["id"] class TestTrash: """Trash service: iteration, restore, and empty operations.""" async def test_iter(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """Iterating over trash yields Document instances with deleted_at set.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.TRASH}" + r"\?.*$"), status_code=200, json=DATA_TRASH, ) items = [item async for item in paperless.trash] assert len(items) == len(DATA_TRASH["results"]) for item in items: assert isinstance(item, Document) assert item.deleted_at is not None async def test_restore(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """restore() POSTs the restore action — never the destructive empty action.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.TRASH}", status_code=200, json={"result": "restored"}, ) await paperless.trash.restore([100, 101]) body = json.loads(httpx_mock.get_requests()[-1].content) assert body == {"action": "restore", "documents": [100, 101]} async def test_empty(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """empty() empties all or specific documents from the trash.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.TRASH}", status_code=200, json={"result": "emptied"}, ) await paperless.trash.empty() body = json.loads(httpx_mock.get_requests()[-1].content) assert body == {"action": "empty"} httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.TRASH}", status_code=200, json={"result": "emptied"}, ) await paperless.trash.empty([100]) body = json.loads(httpx_mock.get_requests()[-1].content) assert body == {"action": "empty", "documents": [100]} class TestSearch: """Global search service: query, db_only flag and autocomplete.""" async def test_call(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """search('query') returns a SearchResult with documents.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.SEARCH}" + r"\?.*$"), status_code=200, json=DATA_SEARCH, ) result = await paperless.search("invoice") assert isinstance(result, SearchResult) assert result.total == DATA_SEARCH["total"] assert result.documents is not None assert len(result.documents) == len(DATA_SEARCH["documents"]) params = httpx_mock.get_requests()[-1].url.params assert params["query"] == "invoice" assert "db_only" not in params async def test_call_with_db_only( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """search('query', db_only=True) passes db_only param and returns SearchResult.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.SEARCH}" + r"\?.*db_only.*$"), status_code=200, json=DATA_SEARCH, ) result = await paperless.search("invoice", db_only=True) assert isinstance(result, SearchResult) assert result.total == DATA_SEARCH["total"] params = httpx_mock.get_requests()[-1].url.params assert params["query"] == "invoice" assert params["db_only"] == "true" async def test_call_with_builder( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """search(SearchQuery(...)) converts the builder to a string automatically.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.SEARCH}" + r"\?.*$"), status_code=200, json=DATA_SEARCH, ) q = SearchQuery("invoice") & SearchQuery.field("tag", "unpaid") result = await paperless.search(q) assert isinstance(result, SearchResult) assert result.total == DATA_SEARCH["total"] assert httpx_mock.get_requests()[-1].url.params["query"] == str(q) async def test_autocomplete(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """autocomplete() returns the bare term list and omits limit when unset.""" httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.SEARCH_AUTOCOMPLETE}" + r"\?.*$" ), status_code=200, json=DATA_SEARCH_AUTOCOMPLETE, ) result = await paperless.search.autocomplete("inv") assert result == DATA_SEARCH_AUTOCOMPLETE params = httpx_mock.get_requests()[-1].url.params assert params["term"] == "inv" assert "limit" not in params async def test_autocomplete_with_limit( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """autocomplete(term, limit) forwards the limit query parameter.""" httpx_mock.add_response( method="GET", url=re.compile( r"^" + f"{PAPERLESS_TEST_URL}{EndpointPath.SEARCH_AUTOCOMPLETE}" + r"\?.*limit.*$" ), status_code=200, json=DATA_SEARCH_AUTOCOMPLETE[:1], ) result = await paperless.search.autocomplete("inv", 1) assert result == DATA_SEARCH_AUTOCOMPLETE[:1] params = httpx_mock.get_requests()[-1].url.params assert params["term"] == "inv" assert params["limit"] == "1" class TestBulkEditObjects: """BulkEditObjects service: set_permissions and delete operations.""" async def test_set_permissions(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """set_permissions() POSTs the correct payload and returns None.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.BULK_EDIT_OBJECTS}", status_code=200, json=DATA_BULK_EDIT_OBJECTS, ) perms = Permissions(view_users=[2], change_users=[1]) result = await paperless.bulk_edit_objects.set_permissions( "tags", [1, 2, 3], owner=1, permissions=perms, ) assert result is None request = httpx_mock.get_requests()[-1] body = json.loads(request.content) assert body["object_type"] == "tags" assert body["operation"] == "set_permissions" assert body["objects"] == [1, 2, 3] assert body["owner"] == 1 assert "permissions" in body async def test_set_permissions_no_optional_fields( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """set_permissions() without owner/permissions omits those keys.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.BULK_EDIT_OBJECTS}", status_code=200, json=DATA_BULK_EDIT_OBJECTS, ) await paperless.bulk_edit_objects.set_permissions("correspondents", [7]) request = httpx_mock.get_requests()[-1] body = json.loads(request.content) assert "owner" not in body assert "permissions" not in body assert body["merge"] is False async def test_delete(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """delete() POSTs the correct payload and returns None.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.BULK_EDIT_OBJECTS}", status_code=200, json=DATA_BULK_EDIT_OBJECTS, ) result = await paperless.bulk_edit_objects.delete("document_types", [10, 11]) assert result is None request = httpx_mock.get_requests()[-1] body = json.loads(request.content) assert body["object_type"] == "document_types" assert body["operation"] == "delete" assert body["objects"] == [10, 11] class TestDocumentsBulkEdit: """DocumentBulkEditService: all 16 bulk operations.""" async def test_set_correspondent( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """set_correspondent() POSTs the correct payload.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_BULK_EDIT}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.set_correspondent([1, 2], 5) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["method"] == "set_correspondent" assert body["documents"] == [1, 2] assert body["parameters"]["correspondent"] == 5 async def test_set_document_type( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """set_document_type() POSTs the correct payload.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_BULK_EDIT}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.set_document_type([3], 7) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["method"] == "set_document_type" assert body["parameters"]["document_type"] == 7 async def test_set_storage_path( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """set_storage_path() POSTs the correct payload.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_BULK_EDIT}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.set_storage_path([4, 5], 2) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["method"] == "set_storage_path" assert body["parameters"]["storage_path"] == 2 async def test_add_tag(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """add_tag() POSTs the correct payload.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_BULK_EDIT}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.add_tag([1], 10) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["method"] == "add_tag" assert body["parameters"]["tag"] == 10 async def test_remove_tag(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """remove_tag() POSTs the correct payload.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_BULK_EDIT}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.remove_tag([1], 10) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["method"] == "remove_tag" assert body["parameters"]["tag"] == 10 async def test_modify_tags(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """modify_tags() POSTs both add/remove lists.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_BULK_EDIT}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.modify_tags([1], add_tags=[3, 4], remove_tags=[5]) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["method"] == "modify_tags" assert body["parameters"]["add_tags"] == [3, 4] assert body["parameters"]["remove_tags"] == [5] async def test_modify_custom_fields( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """modify_custom_fields() POSTs dict-style custom field input.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_BULK_EDIT}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.modify_custom_fields( [1], add_custom_fields={1: "value"}, remove_custom_fields=[2] ) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["method"] == "modify_custom_fields" assert body["parameters"]["add_custom_fields"] == {"1": "value"} assert body["parameters"]["remove_custom_fields"] == [2] async def test_set_permissions(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """set_permissions() includes owner and set_permissions key in parameters.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_BULK_EDIT}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) perms = Permissions(view_users=[2], change_users=[1]) await paperless.documents.bulk_edit.set_permissions( [1, 2], owner=5, permissions=perms, merge=True ) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["method"] == "set_permissions" assert body["parameters"]["owner"] == 5 assert body["parameters"]["merge"] is True assert "set_permissions" in body["parameters"] async def test_delete(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """delete() POSTs to the dedicated delete endpoint.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_DELETE}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.delete([1, 2]) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["documents"] == [1, 2] async def test_reprocess(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """reprocess() POSTs to the dedicated reprocess endpoint.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_REPROCESS}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.reprocess([1]) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["documents"] == [1] async def test_rotate(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """rotate() POSTs degrees and source_mode.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_ROTATE}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.rotate([1, 2], 90) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["degrees"] == 90 assert body["source_mode"] == "latest_version" async def test_merge(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """merge() POSTs optional metadata_document_id when provided.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_MERGE}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.merge( [1, 2], metadata_document_id=1, delete_originals=True ) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["documents"] == [1, 2] assert body["metadata_document_id"] == 1 assert body["delete_originals"] is True async def test_edit_pdf(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """edit_pdf() wraps the single document in a list.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_EDIT_PDF}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) ops = [{"page": 1, "rotate": 90}] await paperless.documents.bulk_edit.edit_pdf(42, ops) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["documents"] == [42] assert body["operations"] == ops assert body["include_metadata"] is True async def test_remove_password(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """remove_password() POSTs password and source flags.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_REMOVE_PASSWORD}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.remove_password([5], "s3cr3t") body = json.loads(httpx_mock.get_requests()[-1].content) assert body["documents"] == [5] assert body["password"] == "s3cr3t" assert body["source_mode"] == "latest_version" async def test_set_permissions_no_owner_no_permissions( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """set_permissions() omits owner/set_permissions when not given.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_BULK_EDIT}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.set_permissions([1, 2], merge=False) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["method"] == "set_permissions" assert "owner" not in body["parameters"] assert "set_permissions" not in body["parameters"] async def test_merge_no_metadata_document_id( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """merge() must omit metadata_document_id when not provided.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_MERGE}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.merge([1, 2]) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["documents"] == [1, 2] assert "metadata_document_id" not in body async def test_error_result_raises( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """A non-OK result field (e.g. 'ERROR') raises BulkEditError on HTTP 200.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_BULK_EDIT}", status_code=200, json={"result": "ERROR"}, ) with pytest.raises(BulkEditError): await paperless.documents.bulk_edit.modify_tags([1], add_tags=[3], remove_tags=[]) async def test_split(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """split() translates page groups into edit_pdf doc indices.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_EDIT_PDF}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.split(42, [[1, 2], [3]], delete_originals=True) body = json.loads(httpx_mock.get_requests()[-1].content) assert body["documents"] == [42] assert body["operations"] == [ {"page": 1, "doc": 0}, {"page": 2, "doc": 0}, {"page": 3, "doc": 1}, ] assert body["delete_original"] is True assert body["update_document"] is False assert body["source_mode"] == "latest_version" @pytest.mark.parametrize("pages", [[], [[1], []]]) async def test_split_rejects_empty_groups( self, paperless: PaperlessClient, pages: list[list[int]] ) -> None: """split() rejects an empty group list and empty groups before any request.""" with pytest.raises(BulkEditPagesError, match="group"): await paperless.documents.bulk_edit.split(42, pages) async def test_delete_pages(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """delete_pages() looks up the page count, then keeps the complement.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=42), status_code=200, json={"id": 42, "page_count": 5}, ) httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_EDIT_PDF}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.delete_pages(42, [2, 4]) assert httpx_mock.get_requests()[-2].method == "GET" body = json.loads(httpx_mock.get_requests()[-1].content) assert body["documents"] == [42] assert body["operations"] == [{"page": 1}, {"page": 3}, {"page": 5}] assert body["update_document"] is True assert body["delete_original"] is False async def test_delete_pages_with_page_count( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """delete_pages(page_count=...) skips the document lookup.""" httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_EDIT_PDF}", status_code=200, json=DATA_DOCUMENTS_BULK_EDIT, ) await paperless.documents.bulk_edit.delete_pages(42, [1], page_count=3) # schema probe + the edit_pdf POST — no document round-trip assert len(httpx_mock.get_requests()) == 2 body = json.loads(httpx_mock.get_requests()[-1].content) assert body["operations"] == [{"page": 2}, {"page": 3}] async def test_delete_pages_without_page_count_raises( self, httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """A document record without page_count cannot be edited without an explicit count.""" httpx_mock.add_response( method="GET", url=f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS_SINGLE}".format(pk=42), status_code=200, json={"id": 42, "page_count": None}, ) with pytest.raises(BulkEditPagesError, match="no page count"): await paperless.documents.bulk_edit.delete_pages(42, [1]) async def test_delete_pages_validates_pages(self, paperless: PaperlessClient) -> None: """delete_pages() rejects empty, out-of-range and all-pages input locally.""" with pytest.raises(BulkEditPagesError, match="at least one page to remove"): await paperless.documents.bulk_edit.delete_pages(42, [], page_count=3) with pytest.raises(BulkEditPagesError, match="out of range"): await paperless.documents.bulk_edit.delete_pages(42, [0, 4], page_count=3) with pytest.raises(BulkEditPagesError, match="keep at least one page"): await paperless.documents.bulk_edit.delete_pages(42, [1, 2, 3], page_count=3) def test_bulk_edit_pages_error_bases(self) -> None: """Both bases are load-bearing: `except PaperlessError` and `except ValueError` catch it.""" assert issubclass(BulkEditPagesError, PaperlessError) assert issubclass(BulkEditPagesError, ValueError) class TestShareLinkBundleRebuild: """ShareLinkBundleService.rebuild() triggers a rebuild and returns a bundle.""" async def test_rebuild(self, httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """rebuild() POSTs to the rebuild endpoint and returns a ShareLinkBundle.""" bundle_data = DATA_SHARE_LINK_BUNDLES["results"][0] httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath.SHARE_LINK_BUNDLES_REBUILD}".format( pk=bundle_data["id"] ), status_code=200, json={**bundle_data, "status": "pending"}, ) result = await paperless.share_link_bundles.rebuild(bundle_data["id"]) assert isinstance(result, ShareLinkBundle) assert result.id == bundle_data["id"] tb1337-pypaperless-5d67d60/tests/test_search_builder.py000066400000000000000000000122721524076126400231040ustar00rootroot00000000000000"""Tests for the SearchQuery DSL builder.""" from pypaperless.builders import SearchQuery from pypaperless.builders.search import _SearchQueryAnd, _SearchQueryNot, _SearchQueryOr def test_atom_build() -> None: """A plain atom builds to its term string.""" q = SearchQuery("invoice") assert q.build() == "invoice" assert str(q) == "invoice" def test_atom_repr() -> None: """repr() contains the class name and the term.""" q = SearchQuery("invoice") assert "SearchQuery" in repr(q) assert "invoice" in repr(q) def test_field_factory() -> None: """SearchQuery.field() produces a ``field:value`` term.""" assert str(SearchQuery.field("tag", "unpaid")) == "tag:unpaid" assert str(SearchQuery.field("document_type", "invoice")) == "document_type:invoice" assert str(SearchQuery.field("correspondent", "acme")) == "correspondent:acme" def test_date_range_factory() -> None: """SearchQuery.date_range() produces a ``field:[start to end]`` term.""" q = SearchQuery.date_range("created", "2005", "2009") assert str(q) == "created:[2005 to 2009]" q2 = SearchQuery.date_range("added", "yesterday", "today") assert str(q2) == "added:[yesterday to today]" def test_and_operator() -> None: """``&`` combines two atoms into a _SearchQueryAnd.""" q = SearchQuery("invoice") & SearchQuery.field("tag", "unpaid") assert isinstance(q, _SearchQueryAnd) assert str(q) == "(invoice AND tag:unpaid)" def test_and_flatten() -> None: """Chaining ``&`` flattens into a single level (no extra parentheses).""" q = SearchQuery("a") & SearchQuery("b") & SearchQuery("c") assert isinstance(q, _SearchQueryAnd) assert str(q) == "(a AND b AND c)" def test_or_operator() -> None: """``|`` combines two atoms into a _SearchQueryOr.""" q = SearchQuery.field("tag", "inbox") | SearchQuery.field("tag", "important") assert isinstance(q, _SearchQueryOr) assert str(q) == "(tag:inbox OR tag:important)" def test_or_flatten() -> None: """Chaining ``|`` flattens into a single level (no extra parentheses).""" q = SearchQuery("a") | SearchQuery("b") | SearchQuery("c") assert isinstance(q, _SearchQueryOr) assert str(q) == "(a OR b OR c)" def test_not_operator() -> None: """``~`` wraps an atom into a _SearchQueryNot.""" q = ~SearchQuery.field("document_type", "letter") assert isinstance(q, _SearchQueryNot) assert str(q) == "NOT document_type:letter" def test_renamed_fields() -> None: """document_type and storage_path use the correct Tantivy field names.""" assert str(SearchQuery.field("document_type", "invoice")) == "document_type:invoice" assert str(SearchQuery.field("document_type_id", "5")) == "document_type_id:5" assert str(SearchQuery.field("storage_path", "archive")) == "storage_path:archive" assert str(SearchQuery.field("storage_path_id", "3")) == "storage_path_id:3" def test_notes_subfields() -> None: """JSON sub-field syntax for notes produces ``notes.note:`` and ``notes.user:`` terms.""" assert str(SearchQuery.field("notes.note", "urgent")) == "notes.note:urgent" assert str(SearchQuery.field("notes.user", "alice")) == "notes.user:alice" def test_custom_fields_subfields() -> None: """JSON sub-field syntax for custom_fields produces the correct dotted terms.""" assert str(SearchQuery.field("custom_fields.value", "42")) == "custom_fields.value:42" assert str(SearchQuery.field("custom_fields.name", "amount")) == "custom_fields.name:amount" def test_mixed_and_or_keeps_precedence_explicit() -> None: """A nested OR inside an AND stays parenthesised instead of flattening.""" q = SearchQuery("invoice") & ( SearchQuery.field("tag", "unpaid") | SearchQuery.field("tag", "overdue") ) assert isinstance(q, _SearchQueryAnd) assert str(q) == "(invoice AND (tag:unpaid OR tag:overdue))" def test_mixed_or_and_keeps_precedence_explicit() -> None: """A nested AND inside an OR stays parenthesised instead of flattening.""" q = SearchQuery.field("tag", "inbox") | ( SearchQuery("invoice") & SearchQuery.field("correspondent", "acme") ) assert isinstance(q, _SearchQueryOr) assert str(q) == "(tag:inbox OR (invoice AND correspondent:acme))" def test_not_of_compound_wraps_the_whole_group() -> None: """~ applied to a compound negates the entire group, not just its first operand.""" q = ~(SearchQuery.field("tag", "inbox") & SearchQuery.field("tag", "unpaid")) assert isinstance(q, _SearchQueryNot) assert str(q) == "NOT (tag:inbox AND tag:unpaid)" def test_and_or_not_composition() -> None: """AND, OR and NOT compose into one expression with the intended grouping.""" q = (SearchQuery("invoice") | SearchQuery("receipt")) & ~SearchQuery.field( "document_type", "letter" ) assert isinstance(q, _SearchQueryAnd) assert str(q) == "((invoice OR receipt) AND NOT document_type:letter)" def test_flatten_only_applies_to_the_same_operator() -> None: """Chaining AND across an OR operand keeps the OR as a single nested operand.""" q = SearchQuery("a") & (SearchQuery("b") | SearchQuery("c")) & SearchQuery("d") assert isinstance(q, _SearchQueryAnd) assert str(q) == "(a AND (b OR c) AND d)" tb1337-pypaperless-5d67d60/tests/test_service_mixins.py000066400000000000000000000540511524076126400231610ustar00rootroot00000000000000"""Parameterized service mixin tests: ReadOnly, ReadWrite, SecurableService.""" import asyncio import json as json_mod import re import httpx import pytest from pytest_httpx import HTTPXMock from pypaperless import PaperlessClient from pypaperless.const import EndpointPath from pypaperless.exceptions import DeletionError, DraftFieldRequiredError, NotFoundError from pypaperless.models import Page, Tag from pypaperless.models.base import PaperlessModel from pypaperless.models.mixins.data_fields import MatchingAlgorithm from pypaperless.models.types import Permissions from pypaperless.services import mixins as svc_mixins from pypaperless.services.base import ResourceService from .const import PAPERLESS_TEST_URL from .data import DATA_OBJECT_PERMISSIONS from .mappings import ( CORRESPONDENT_MAP, CUSTOM_FIELD_MAP, DOCUMENT_MAP, DOCUMENT_TYPE_MAP, GROUP_MAP, MAIL_ACCOUNT_MAP, MAIL_RULE_MAP, PROCESSED_MAIL_MAP, SAVED_VIEW_MAP, SHARE_LINK_BUNDLE_MAP, SHARE_LINK_MAP, STORAGE_PATH_MAP, TAG_MAP, USER_MAP, WORKFLOW_MAP, ResourceTestMapping, ) class _SharedServiceTests: """Shared read-only test methods inherited by TestReadOnly and TestReadWrite.""" async def test_pages( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """Test pages.""" httpx_mock.add_response( method="GET", url=re.compile( r"^" f"{PAPERLESS_TEST_URL}{EndpointPath[mapping.resource.upper()]}" r"\?.*$" ), status_code=200, json=mapping.data, ) page = await anext(aiter(getattr(paperless, mapping.resource).pages(1))) assert isinstance(page, Page) assert isinstance(page.items, list) assert len(page.items) == len(mapping.data["results"]) for item in page.items: assert isinstance(item, mapping.model_cls) async def test_iter( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """Test iter.""" httpx_mock.add_response( method="GET", url=re.compile( r"^" f"{PAPERLESS_TEST_URL}{EndpointPath[mapping.resource.upper()]}" r"\?.*$" ), status_code=200, json=mapping.data, ) items = [item async for item in getattr(paperless, mapping.resource)] assert len(items) == len(mapping.data["results"]) for item in items: assert isinstance(item, mapping.model_cls) async def test_call( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """Test call.""" httpx_mock.add_response( method="GET", url=( f"{PAPERLESS_TEST_URL}{EndpointPath[(mapping.resource + '_single').upper()]}" ).format(pk=1), status_code=200, json=mapping.data["results"][0], ) item = await getattr(paperless, mapping.resource)(1) assert isinstance(item, mapping.model_cls) # must raise as 1337 doesn't exist httpx_mock.add_response( method="GET", url=( f"{PAPERLESS_TEST_URL}{EndpointPath[(mapping.resource + '_single').upper()]}" ).format(pk=1337), status_code=404, ) with pytest.raises(NotFoundError): await getattr(paperless, mapping.resource)(1337) @pytest.mark.parametrize( "mapping", [ DOCUMENT_MAP, DOCUMENT_TYPE_MAP, CORRESPONDENT_MAP, CUSTOM_FIELD_MAP, GROUP_MAP, MAIL_ACCOUNT_MAP, MAIL_RULE_MAP, PROCESSED_MAIL_MAP, SAVED_VIEW_MAP, SHARE_LINK_BUNDLE_MAP, SHARE_LINK_MAP, STORAGE_PATH_MAP, TAG_MAP, USER_MAP, WORKFLOW_MAP, ], scope="class", ) class TestReadOnly(_SharedServiceTests): """Read-only service operations: pages, iteration, dict/list helpers, single fetch.""" async def test_as_dict( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """Test as_dict.""" httpx_mock.add_response( method="GET", url=re.compile( r"^" f"{PAPERLESS_TEST_URL}{EndpointPath[mapping.resource.upper()]}" r"\?.*$" ), status_code=200, json=mapping.data, ) items = await getattr(paperless, mapping.resource).as_dict() assert set(items) == {entry["id"] for entry in mapping.data["results"]} for pk, obj in items.items(): assert isinstance(pk, int) assert isinstance(obj, mapping.model_cls) async def test_as_list( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """Test as_list.""" httpx_mock.add_response( method="GET", url=re.compile( r"^" f"{PAPERLESS_TEST_URL}{EndpointPath[mapping.resource.upper()]}" r"\?.*$" ), status_code=200, json=mapping.data, ) items = await getattr(paperless, mapping.resource).as_list() assert len(items) == len(mapping.data["results"]) for obj in items: assert isinstance(obj, mapping.model_cls) @pytest.mark.parametrize( "mapping", [ CORRESPONDENT_MAP, CUSTOM_FIELD_MAP, DOCUMENT_TYPE_MAP, SHARE_LINK_BUNDLE_MAP, SHARE_LINK_MAP, STORAGE_PATH_MAP, TAG_MAP, ], scope="class", ) class TestReadWrite(_SharedServiceTests): """R/W service operations: filter, create, update, delete (in addition to read).""" async def test_filter( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """Test iter with filter.""" httpx_mock.add_response( method="GET", url=re.compile( r"^" f"{PAPERLESS_TEST_URL}{EndpointPath[mapping.resource.upper()]}" r"\?.*$" ), status_code=200, json=mapping.data, ) async with getattr(paperless, mapping.resource).filter( any_filter_param="1", any_filter_list__in=["1", "2"], any_filter_no_list__in="1", ) as q: items = [item async for item in q] assert len(items) == len(mapping.data["results"]) for item in items: assert isinstance(item, mapping.model_cls) params = httpx_mock.get_requests()[-1].url.params assert params["any_filter_param"] == "1" assert params["any_filter_list__in"] == "1,2" assert params["any_filter_no_list__in"] == "1" async def test_create( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """Test create.""" service = getattr(paperless, mapping.resource) 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: backup = getattr(draft, mapping.required_field) setattr(draft, mapping.required_field, None) with pytest.raises(DraftFieldRequiredError): await service.save(draft) setattr(draft, mapping.required_field, backup) # actually call the create endpoint httpx_mock.add_response( method="POST", url=f"{PAPERLESS_TEST_URL}{EndpointPath[mapping.resource.upper()]}", status_code=200, json={ "id": len(mapping.data["results"]), **draft.serialize(), }, ) new_pk = await service.save(draft) assert new_pk >= 1 async def test_update( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """Test update.""" update_field = mapping.update_field update_value = mapping.update_value pk = mapping.data["results"][0]["id"] service = getattr(paperless, mapping.resource) single_url = ( f"{PAPERLESS_TEST_URL}{EndpointPath[(mapping.resource + '_single').upper()]}" ).format(pk=pk) httpx_mock.add_response( method="GET", url=single_url, status_code=200, json=mapping.data["results"][0], ) to_update = await service(pk) setattr(to_update, update_field, update_value) httpx_mock.add_response( method="PATCH", url=single_url, status_code=200, json={**to_update.snapshot, update_field: update_value}, ) assert await service.update(to_update) is True assert json_mod.loads(httpx_mock.get_requests()[-1].content) == {update_field: update_value} # no-op update must not hit the API at all sent = len(httpx_mock.get_requests()) assert await service.update(to_update) is False assert len(httpx_mock.get_requests()) == sent # force full update setattr(to_update, update_field, update_value) httpx_mock.add_response( method="PUT", url=single_url, status_code=200, json={**to_update.snapshot, update_field: update_value}, ) expected_full = to_update.api_dump() assert await service.update(to_update, only_changed=False) is True assert json_mod.loads(httpx_mock.get_requests()[-1].content) == expected_full assert getattr(to_update, update_field) == update_value async def test_delete( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """Test delete.""" pk = mapping.data["results"][0]["id"] service = getattr(paperless, mapping.resource) httpx_mock.add_response( method="GET", url=( f"{PAPERLESS_TEST_URL}{EndpointPath[(mapping.resource + '_single').upper()]}" ).format(pk=pk), status_code=200, json=mapping.data["results"][0], ) to_delete = await service(pk) httpx_mock.add_response( method="DELETE", url=( f"{PAPERLESS_TEST_URL}{EndpointPath[(mapping.resource + '_single').upper()]}" ).format(pk=pk), status_code=204, ) await service.delete(to_delete) # failed deletion raises DeletionError httpx_mock.add_response( method="DELETE", url=( f"{PAPERLESS_TEST_URL}{EndpointPath[(mapping.resource + '_single').upper()]}" ).format(pk=pk), status_code=404, ) with pytest.raises(DeletionError): await service.delete(to_delete) # silent_fail=True suppresses DeletionError httpx_mock.add_response( method="DELETE", url=( f"{PAPERLESS_TEST_URL}{EndpointPath[(mapping.resource + '_single').upper()]}" ).format(pk=pk), status_code=404, ) await service.delete(to_delete, silent_fail=True) @pytest.mark.parametrize( "mapping", [ CORRESPONDENT_MAP, DOCUMENT_MAP, DOCUMENT_TYPE_MAP, STORAGE_PATH_MAP, TAG_MAP, ], scope="class", ) class TestSecurableService: """SecurableService: request_permissions flag, with_permissions() context manager.""" async def test_permissions( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """Test permissions.""" getattr(paperless, mapping.resource).request_permissions = True assert getattr(paperless, mapping.resource).request_permissions # single object with permissions httpx_mock.add_response( method="GET", url=re.compile( r"^" + ( f"{PAPERLESS_TEST_URL}{EndpointPath[(mapping.resource + '_single').upper()]}" ).format(pk=1) + r"\?.*$" ), status_code=200, json={**mapping.data["results"][0], "permissions": DATA_OBJECT_PERMISSIONS}, ) item = await getattr(paperless, mapping.resource)(1) assert httpx_mock.get_requests()[-1].url.params["full_perms"] == "true" assert item.has_permissions assert isinstance(item.permissions, Permissions) # iterator with permissions httpx_mock.add_response( method="GET", url=re.compile( r"^" f"{PAPERLESS_TEST_URL}{EndpointPath[mapping.resource.upper()]}" r"\?.*$" ), status_code=200, json={ **mapping.data, "results": [ {**item, "permissions": DATA_OBJECT_PERMISSIONS} for item in mapping.data["results"] ], }, ) items = [item async for item in getattr(paperless, mapping.resource)] assert httpx_mock.get_requests()[-1].url.params["full_perms"] == "true" assert len(items) == len(mapping.data["results"]) for item in items: assert isinstance(item, mapping.model_cls) assert item.has_permissions assert isinstance(item.permissions, Permissions) async def test_permission_change( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """Test permission mutation is serialised in the PATCH request.""" pk = mapping.data["results"][0]["id"] getattr(paperless, mapping.resource).request_permissions = True httpx_mock.add_response( method="GET", url=re.compile( r"^" + ( f"{PAPERLESS_TEST_URL}{EndpointPath[(mapping.resource + '_single').upper()]}" ).format(pk=pk) + r"\?.*$" ), status_code=200, json={**mapping.data["results"][0], "permissions": DATA_OBJECT_PERMISSIONS}, ) item = await getattr(paperless, mapping.resource)(pk) item.permissions.view.users.append(23) def _lookup_set_permissions(request: httpx.Request) -> httpx.Response: assert request.url json_data = json_mod.loads(request.content) assert "set_permissions" in json_data return httpx.Response(status_code=200, json=item.snapshot) httpx_mock.add_callback( _lookup_set_permissions, url=re.compile( r"^" + re.escape( ( f"{PAPERLESS_TEST_URL}" f"{EndpointPath[(mapping.resource + '_single').upper()]}" ).format(pk=pk) ) + r"\?.*$" ), method="PATCH", ) await getattr(paperless, mapping.resource).update(item) async def test_with_permissions_context_manager( self, httpx_mock: HTTPXMock, paperless: PaperlessClient, mapping: ResourceTestMapping ) -> None: """with_permissions() enables the flag task-locally and resets it on exit.""" service = getattr(paperless, mapping.resource) assert not service.request_permissions httpx_mock.add_response( method="GET", url=re.compile( r"^" + ( f"{PAPERLESS_TEST_URL}{EndpointPath[(mapping.resource + '_single').upper()]}" ).format(pk=1) + r"\?.*$" ), status_code=200, json={**mapping.data["results"][0], "permissions": DATA_OBJECT_PERMISSIONS}, ) async with service.with_permissions() as scoped: assert scoped.request_permissions item = await scoped(1) assert item.has_permissions assert isinstance(item.permissions, Permissions) assert not service.request_permissions def test_permissions_from_existing_instance() -> None: """Permissions._accept_flat passes through non-dict input (e.g. an existing instance).""" # Call _accept_flat directly with a non-dict value — it must return it unchanged non_dict_input = [1, 2, 3] result = Permissions._accept_flat(non_dict_input) assert result is non_dict_input async def test_iterable_filter_base_method(paperless: PaperlessClient) -> None: """IterableService.filter() applies filters for the context duration and clears them after.""" class _MinimalModel(PaperlessModel): id: int | None = None class _MinimalService(ResourceService, svc_mixins.IterableService[_MinimalModel]): _api_path = EndpointPath.CORRESPONDENTS _resource = "correspondents" _resource_cls = _MinimalModel svc = _MinimalService(paperless) async with svc.filter(title__icontains="test") as ctx: assert ctx is svc generator = ctx.pages() assert generator.params["title__icontains"] == "test" generator = svc.pages() assert "title__icontains" not in generator.params async def test_filter_contexts_are_isolated( httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Overlapping filter() contexts on the same service must not clobber each other.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" f"{PAPERLESS_TEST_URL}{EndpointPath.CORRESPONDENTS}" r"\?.*$"), status_code=200, json={"count": 0, "next": None, "previous": None, "results": []}, is_reusable=True, ) async with paperless.correspondents.filter(name__icontains="acme") as ctx_a: async with paperless.correspondents.filter(name__icontains="globex") as ctx_b: await ctx_b.as_list() # ctx_b has exited — ctx_a must still carry its own filters await ctx_a.as_list() requests = httpx_mock.get_requests() assert requests[-2].url.params["name__icontains"] == "globex" assert requests[-1].url.params["name__icontains"] == "acme" async def test_filter_joins_in_and_all_lists( httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """List values for __in and __all filters are sent comma-separated, not repeated.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" f"{PAPERLESS_TEST_URL}{EndpointPath.DOCUMENTS}" r"\?.*$"), status_code=200, json={"count": 0, "next": None, "previous": None, "results": []}, ) async with paperless.documents.filter( id__in=[1, 2], tags__id__all=[3, 7], ) as filtered: await filtered.as_list() request = httpx_mock.get_requests()[-1] assert request.url.params["id__in"] == "1,2" assert request.url.params["tags__id__all"] == "3,7" async def test_filter_contexts_isolated_across_tasks( httpx_mock: HTTPXMock, paperless: PaperlessClient ) -> None: """Concurrent tasks filtering the same service must each keep their own filters.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" f"{PAPERLESS_TEST_URL}{EndpointPath.CORRESPONDENTS}" r"\?.*$"), status_code=200, json={"count": 0, "next": None, "previous": None, "results": []}, is_reusable=True, ) async def fetch(name: str) -> None: async with paperless.correspondents.filter(name__icontains=name) as filtered: await asyncio.sleep(0) await filtered.as_list() await asyncio.gather(fetch("acme"), fetch("globex")) sent = {req.url.params["name__icontains"] for req in httpx_mock.get_requests()[-2:]} assert sent == {"acme", "globex"} async def test_processed_mail_filter(httpx_mock: HTTPXMock, paperless: PaperlessClient) -> None: """ProcessedMailService.filter() forwards its typed keys into the query string.""" httpx_mock.add_response( method="GET", url=re.compile(r"^" f"{PAPERLESS_TEST_URL}{EndpointPath.PROCESSED_MAIL}" r"\?.*$"), status_code=200, json=PROCESSED_MAIL_MAP.data, ) async with paperless.processed_mail.filter(rule=1, status="FAILED") as filtered: items = await filtered.as_list() assert len(items) == len(PROCESSED_MAIL_MAP.data["results"]) params = httpx_mock.get_requests()[-1].url.params assert params["rule"] == "1" assert params["status"] == "FAILED" def test_tag_with_nested_children(api: PaperlessClient) -> None: """Tag._validate_children builds nested Tag instances from raw dict children.""" tag_data = { "id": 1, "slug": "parent", "name": "Parent Tag", "color": "#000000", "text_color": "#ffffff", "matching_algorithm": 2, "children": [ { "id": 2, "slug": "child", "name": "Child Tag", "color": "#000000", "text_color": "#ffffff", "matching_algorithm": 1, "children": [ { "id": 3, "slug": "grandchild", "name": "Grandchild Tag", "color": "#000000", "text_color": "#ffffff", "matching_algorithm": 6, } ], } ], } tag = Tag.from_data(api._runtime, 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 tb1337-pypaperless-5d67d60/tests/test_types.py000066400000000000000000000122611524076126400212730ustar00rootroot00000000000000"""Tests for enum types: UNKNOWN fallback values.""" import importlib import inspect import pkgutil from enum import Enum import pytest from pydantic import TypeAdapter, ValidationError import pypaperless from pypaperless.models.saved_views import _DisplayFieldValue from pypaperless.models.types import ( SavedViewCustomFieldDisplay, SavedViewDisplayField, ) _NEVER_STR = "!never_existing_type!" _NEVER_INT = 99952342 def _unknown_fallback_enums() -> list[type[Enum]]: """Collect every library enum that maps unrecognised values onto UNKNOWN. Discovered rather than hand-listed so a new enum cannot slip past this test. """ found: dict[str, type[Enum]] = {} for module_info in pkgutil.walk_packages(pypaperless.__path__, f"{pypaperless.__name__}."): module = importlib.import_module(module_info.name) for obj in vars(module).values(): if ( inspect.isclass(obj) and issubclass(obj, Enum) and obj.__module__.startswith(pypaperless.__name__) and "UNKNOWN" in obj.__members__ and "_missing_" in obj.__dict__ ): found[f"{obj.__module__}.{obj.__qualname__}"] = obj return [found[key] for key in sorted(found)] def test_unknown_fallback_enum_discovery() -> None: """The discovery helper must find enums across all model subpackages.""" names = {enum_cls.__name__ for enum_cls in _unknown_fallback_enums()} assert { "ImapSecurity", "OcrMode", "PaperlessResource", "ShareLinkBundleStatus", "TaskTriggerSource", "WorkflowTriggerType", } <= names @pytest.mark.parametrize( "enum_cls", _unknown_fallback_enums(), ids=lambda enum_cls: enum_cls.__name__, ) @pytest.mark.parametrize("bad_value", [_NEVER_STR, _NEVER_INT], ids=["str", "int"]) def test_enum_unknown_fallback(enum_cls: type[Enum], bad_value: object) -> None: """Every custom enum must return UNKNOWN for unrecognised values instead of raising.""" assert enum_cls(bad_value) == enum_cls.UNKNOWN @pytest.mark.parametrize( ("raw", "expected_pk"), [ ("custom_field_1", 1), ("custom_field_8", 8), ("custom_field_100", 100), ], ids=["pk_1", "pk_8", "pk_100"], ) def test_saved_view_custom_field_display_valid(raw: str, expected_pk: int) -> None: """SavedViewCustomFieldDisplay accepts valid custom_field_ strings and exposes the PK.""" obj = SavedViewCustomFieldDisplay(raw) assert obj == raw assert isinstance(obj, str) assert obj.pk == expected_pk @pytest.mark.parametrize( "bad", ["custom_field_", "custom_field_foo", "title", "", "custom_field_1_extra"], ids=["no_digits", "alpha_pk", "known_field", "empty", "trailing_garbage"], ) def test_saved_view_custom_field_display_invalid(bad: str) -> None: """SavedViewCustomFieldDisplay raises ValueError for strings that are not custom_field_.""" with pytest.raises(ValueError, match="Not a custom field display value"): SavedViewCustomFieldDisplay(bad) _ta: TypeAdapter[SavedViewDisplayField | SavedViewCustomFieldDisplay] = TypeAdapter( _DisplayFieldValue ) @pytest.mark.parametrize( ("raw", "expected_type", "expected_value"), [ ("title", SavedViewDisplayField, SavedViewDisplayField.TITLE), ("created", SavedViewDisplayField, SavedViewDisplayField.CREATED), ("correspondent", SavedViewDisplayField, SavedViewDisplayField.CORRESPONDENT), ("custom_field_1", SavedViewCustomFieldDisplay, "custom_field_1"), ("custom_field_8", SavedViewCustomFieldDisplay, "custom_field_8"), ], ids=["title", "created", "correspondent", "cf_1", "cf_8"], ) def test_display_field_value_coercion( raw: str, expected_type: type, expected_value: object ) -> None: """SavedViewDisplayField coerces strings to DisplayField or CustomFieldDisplay.""" result = _ta.validate_python(raw) assert isinstance(result, expected_type) assert result == expected_value def test_display_field_value_invalid() -> None: """SavedViewDisplayField raises ValidationError for unrecognised strings.""" with pytest.raises(ValidationError): _ta.validate_python("unknown_field_xyz") def test_display_field_value_passthrough_enum() -> None: """SavedViewDisplayField accepts an already-coerced DisplayField without re-wrapping.""" result = _ta.validate_python(SavedViewDisplayField.TITLE) assert result is SavedViewDisplayField.TITLE def test_display_field_value_passthrough_custom() -> None: """SavedViewCustomFieldDisplay accepts an already-coerced custom field without re-wrapping.""" cfd = SavedViewCustomFieldDisplay("custom_field_42") result = _ta.validate_python(cfd) assert result is cfd def test_display_field_value_serialises_to_str() -> None: """SavedViewCustomFieldDisplay round-trips through JSON as a plain string.""" result = _ta.validate_python("custom_field_8") assert _ta.dump_json(result) == b'"custom_field_8"' def test_display_field_value_non_string_raises() -> None: """SavedViewDisplayField raises ValidationError for non-string inputs.""" with pytest.raises(ValidationError): _ta.validate_python(42) tb1337-pypaperless-5d67d60/uv.lock000066400000000000000000005132751524076126400166730ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.15'", "python_full_version < '3.15'", ] [[package]] name = "annotated-types" version = "0.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] name = "anyio" version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] name = "ast-serialize" version = "0.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, ] [[package]] name = "certifi" version = "2026.7.22" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] name = "click" version = "8.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] name = "codespell" version = "2.4.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "covdefaults" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/ee/9a6f2611f72e4c5657ae5542a510cf4164d2c673687c0ea73bb1cbd85b4d/covdefaults-2.3.0.tar.gz", hash = "sha256:4e99f679f12d792bc62e5510fa3eb59546ed47bd569e36e4fddc4081c9c3ebf7", size = 4835, upload-time = "2023-03-05T16:43:34.779Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/76/4c/823bc951445aa97e5a1b7e337690db3abf85212c8d138e170922e7916ac8/covdefaults-2.3.0-py2.py3-none-any.whl", hash = "sha256:2832961f6ffcfe4b57c338bc3418a3526f495c26fb9c54565409c5532f7c41be", size = 5144, upload-time = "2023-03-05T16:43:33.245Z" }, ] [[package]] name = "coverage" version = "7.15.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, ] [[package]] name = "deepmerge" version = "2.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2a/78/6e9e20106224083cfb817d2d3c26e80e72258d617b616721a169b87081e0/deepmerge-2.1.0.tar.gz", hash = "sha256:07ca7a7b8935df596c512fa8161877c0487ac61f691c07766e7d71d2b23bdd2f", size = 21449, upload-time = "2026-06-22T05:46:07.669Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/51/25/2a75b47cb057b1e164c604fb81ab690a6cdb5e2260ce651194eae90f64a3/deepmerge-2.1.0-py3-none-any.whl", hash = "sha256:8f148339a91d680a75ecb74ade235d9e759a93df373a0b04e9d31c8666cfeb75", size = 14345, upload-time = "2026-06-22T05:46:06.742Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "httpcore" version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] name = "httpx" version = "0.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, { name = "httpcore" }, { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "idna" version = "3.18" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "librt" version = "0.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] [[package]] name = "markdown" version = "3.10.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "mypy" version = "2.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "packaging" version = "26.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] name = "pathspec" version = "1.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "prek" version = "0.4.14" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cf/51/135dc6ba2c021ce32b40700c8c337db72d802893e15291f4b3056076582f/prek-0.4.14.tar.gz", hash = "sha256:f6d0952e31ffd6e508660749dd51b8d8de96e955ed12c40e411f3224f502fed2", size = 537668, upload-time = "2026-08-17T04:27:55.031Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9d/75/727724174d419cab6e11e0c22c3fbcdd083312b0a0202dfa6befdb7f1bcb/prek-0.4.14-py3-none-linux_armv6l.whl", hash = "sha256:cf7fe2e07c99948ca3326fbd4254054cf4caa71a69cf9032be09c3938544e1e3", size = 5878406, upload-time = "2026-08-17T04:27:30.135Z" }, { url = "https://files.pythonhosted.org/packages/34/b6/82dfb41347342b4c53c1ddd27cfa81d3e1cf4ad0d5d76493c51e8da58dc9/prek-0.4.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6c8043aa5555c2ada561f4c69429e5117f370ad92f7d708340613b0965e725bd", size = 6216669, upload-time = "2026-08-17T04:27:31.795Z" }, { url = "https://files.pythonhosted.org/packages/5b/68/ade0ba8b8c0044a3f7ca1a5cd7c97bc10656398c67466b04235f0f0b7418/prek-0.4.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:355570f0d8366a56817e55f44edf736ffc9ff2323894e21cd739ab519d343541", size = 5728085, upload-time = "2026-08-17T04:27:33.322Z" }, { url = "https://files.pythonhosted.org/packages/a0/cc/8ddb67fb000d1ad4c95288e6c7e27989d3c172cd88893e3a6f9cdd7199fb/prek-0.4.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:334f48a2b19e63c5ab741e601cb6088caaa11d1b75146900dc298b055452d551", size = 6043650, upload-time = "2026-08-17T04:27:34.699Z" }, { url = "https://files.pythonhosted.org/packages/5b/49/2780489798147fa1e81fa017af384a40229802cf36680cf72c74478d143c/prek-0.4.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5fbb17270df2dcb3c1aa6edfaa68f850d7968862413e58b1572e41c981f644f9", size = 5786094, upload-time = "2026-08-17T04:27:36.282Z" }, { url = "https://files.pythonhosted.org/packages/6a/2a/27a4cdfa767b46663eb32a2b5f78ebaaeefc6e5caa87a21587216a504e31/prek-0.4.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ec46be0b1b45943a0746fdf69feeede274b0230bad32b11e2489bb600846081", size = 6239874, upload-time = "2026-08-17T04:27:37.984Z" }, { url = "https://files.pythonhosted.org/packages/e0/ca/5ae58d95bc9dbad75ea13aea83fe0acfaca37d027c6bd7f9f1d1d97f3666/prek-0.4.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a21eea6ee996dbba351c1af8c6bd1959a61c37949757c0b97aef3edde82c126a", size = 6968009, upload-time = "2026-08-17T04:27:39.48Z" }, { url = "https://files.pythonhosted.org/packages/bb/1c/c6a7800406f987559fc8e7c2cfd257a5a68096e806e953f143b488e00e4f/prek-0.4.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34169cb1c8dfbe4b9cb7af164f5c1c3dff68929c61ca1e1de28d378b32720836", size = 6443370, upload-time = "2026-08-17T04:27:41.181Z" }, { url = "https://files.pythonhosted.org/packages/92/38/4bf84223216ce2d31b500691d1644a9f35d6e468cb8ecb9a7dfbc66bc82c/prek-0.4.14-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e39772ebf579f957fdbbc66ef6a7f7433bf603128cd60d5c804c70caac5f69c0", size = 6056449, upload-time = "2026-08-17T04:27:42.722Z" }, { url = "https://files.pythonhosted.org/packages/75/0a/f1980039ab7bf8342c4aa4da2a4cafe40e6a840f456d5bbf86ba024fcdca/prek-0.4.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a79e5940354a789e1311172a960c5daa75e3f643a47ee2be85b7d2ca1d7d980a", size = 5839941, upload-time = "2026-08-17T04:27:44.306Z" }, { url = "https://files.pythonhosted.org/packages/f1/e2/67f067dcb912e157bc7f4cd2aeb422a0de65e7744c675d7f439676f6c832/prek-0.4.14-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:60b6539c1b28804807849173a6b49d467f6f36ca98bbb4536f5e34114d79942a", size = 5762723, upload-time = "2026-08-17T04:27:45.737Z" }, { url = "https://files.pythonhosted.org/packages/84/39/6669fff5c3336a2b79cf853c86b95547cf9be720a2ee7e4ae5eaa6cb46a1/prek-0.4.14-py3-none-musllinux_1_1_i686.whl", hash = "sha256:24cfabd9e5b8c5546ecaef562841a41a255a9dec1b7ae7761ce5a7a024ab9dd9", size = 6090040, upload-time = "2026-08-17T04:27:47.185Z" }, { url = "https://files.pythonhosted.org/packages/2a/23/c23210be4c89795a854e76146c2465aaffc3d85ea83df61c1d4d5bb41bca/prek-0.4.14-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:1f08d368da45439f949885bb0b637bc7dfe6910140d5285fa8bae058697ddd06", size = 6570273, upload-time = "2026-08-17T04:27:48.673Z" }, { url = "https://files.pythonhosted.org/packages/00/9c/4f10728ed36295347e84f01bb3e4c9078c9e337edc66215f5e2066b30551/prek-0.4.14-py3-none-win32.whl", hash = "sha256:5bfb30808ce2099c67d2a2d4cd68dc031e3fec1eeb61d73ef51a8f7c04d021cb", size = 5586715, upload-time = "2026-08-17T04:27:50.471Z" }, { url = "https://files.pythonhosted.org/packages/4f/a7/d080a157d4e92927f7618d62d6a23979ef64a10c76a040cb8a8a194c50d7/prek-0.4.14-py3-none-win_amd64.whl", hash = "sha256:29364012d5704475d1092eb8a96ea30b163279096ad5e0c80a620fffa79bc639", size = 5969946, upload-time = "2026-08-17T04:27:51.971Z" }, { url = "https://files.pythonhosted.org/packages/37/e1/6fc64bb82e7270f61707e00b6d3154a4592ae0b2d3bd308173aa7aabe0a1/prek-0.4.14-py3-none-win_arm64.whl", hash = "sha256:ff588c02e10c8d05150763607671a22d5585c0ff7036c884d3489c2726eb215c", size = 5729906, upload-time = "2026-08-17T04:27:53.52Z" }, ] [[package]] name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] [[package]] name = "pydantic-settings" version = "2.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pymdown-extensions" version = "11.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, ] [[package]] name = "pypaperless" version = "0.0.0" source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "pydantic" }, { name = "pydantic-settings" }, ] [package.dev-dependencies] dev = [ { name = "codespell" }, { name = "covdefaults" }, { name = "coverage" }, { name = "mypy" }, { name = "prek" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-httpx" }, { name = "ruff" }, { name = "yamllint" }, ] docs = [ { name = "zensical" }, ] [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "pydantic", specifier = ">=2.13.4" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, ] [package.metadata.requires-dev] dev = [ { name = "codespell", specifier = ">=2.4.3" }, { name = "covdefaults", specifier = ">=2.3.0" }, { name = "coverage", specifier = ">=7.15.4" }, { name = "mypy", specifier = ">=2.3.1" }, { name = "prek", specifier = ">=0.4.14" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-httpx", specifier = ">=0.36.2" }, { name = "ruff", specifier = ">=0.16.3" }, { name = "yamllint", specifier = ">=1.38.0" }, ] docs = [{ name = "zensical", specifier = ">=0.0.55" }] [[package]] name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] name = "pytest-httpx" version = "0.36.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4e/42/f53c58570e80d503ade9dd42ce57f2915d14bcbe25f6308138143950d1d6/pytest_httpx-0.36.2.tar.gz", hash = "sha256:05a56527484f7f4e8c856419ea379b8dc359c36801c4992fdb330f294c690356", size = 57683, upload-time = "2026-04-09T13:57:19.837Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/55/1fa65f8e4fceb19dd6daa867c162ad845d547f6058cd92b4b02384a44777/pytest_httpx-0.36.2-py3-none-any.whl", hash = "sha256:d42ebd5679442dc7bfb0c48e0767b6562e9bc4534d805127b0084171886a5e22", size = 20315, upload-time = "2026-04-09T13:57:18.587Z" }, ] [[package]] name = "python-dotenv" version = "1.2.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "ruff" version = "0.16.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, ] [[package]] name = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "typing-extensions" version = "4.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "typing-inspection" version = "0.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] [[package]] name = "yamllint" version = "1.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pathspec" }, { name = "pyyaml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/a0/8fc2d68e132cf918f18273fdc8a1b8432b60d75ac12fdae4b0ef5c9d2e8d/yamllint-1.38.0.tar.gz", hash = "sha256:09e5f29531daab93366bb061e76019d5e91691ef0a40328f04c927387d1d364d", size = 142446, upload-time = "2026-01-13T07:47:53.276Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/05/92/aed08e68de6e6a3d7c2328ce7388072cd6affc26e2917197430b646aed02/yamllint-1.38.0-py3-none-any.whl", hash = "sha256:fc394a5b3be980a4062607b8fdddc0843f4fa394152b6da21722f5d59013c220", size = 68940, upload-time = "2026-01-13T07:47:51.343Z" }, ] [[package]] name = "zensical" version = "0.0.55" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "deepmerge" }, { name = "jinja2" }, { name = "markdown" }, { name = "pygments" }, { name = "pymdown-extensions" }, { name = "pyyaml" }, { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e5/b3/bc08f7aee32ee0338c6338c37c3508b4f7df27ab0dffe857ea8eb2cb50f7/zensical-0.0.55.tar.gz", hash = "sha256:adc4b5c963385ffa22b18b999c7cfb74689670170276fb1370fe70f78314b431", size = 3992602, upload-time = "2026-08-16T10:19:52.418Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/49/5f/6d43ae25b6be241fd5056be8555de16c21a35b7d18321f4e204ebe1b0181/zensical-0.0.55-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b53d213126402fec053b59e68122198a92caec6e53ac9673e7b2c27008acccb7", size = 12893366, upload-time = "2026-08-16T10:19:23.907Z" }, { url = "https://files.pythonhosted.org/packages/1f/71/df7e7a82e003da68dee0d410c510a32df67990bd4949e8e09c9260a95064/zensical-0.0.55-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbe0bdafab130bd5a9444adccf3330e056388eedb82681ffa51c6c476264688b", size = 12778731, upload-time = "2026-08-16T10:19:26.42Z" }, { url = "https://files.pythonhosted.org/packages/e9/f0/c399907c672759f27e3d8031e3cdb1ff3c297f9df086d82648f573b8067e/zensical-0.0.55-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a699bc5711ddc51a7362b5875d88bcdb9eea018dd76b241920dfeff9fd14abd", size = 13225804, upload-time = "2026-08-16T10:19:28.932Z" }, { url = "https://files.pythonhosted.org/packages/e4/e5/0d50213904ef85d5412e6bcd92fe4bd88399e57bf06a28f9cba9a30a558f/zensical-0.0.55-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e5f8a734d3b1d964de4e81f257f7ddb69c79b23178df6713d56eea97e9ddbdf", size = 13159319, upload-time = "2026-08-16T10:19:31.318Z" }, { url = "https://files.pythonhosted.org/packages/66/f3/de59ba35711c9dc4a1eda435a0058245c42f72639a2399f9590c361daa7a/zensical-0.0.55-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f83fd50c61c42a9a37510843b1de07652f8ec5df8cc245382cfc2e592c7d6f83", size = 13536310, upload-time = "2026-08-16T10:19:34.026Z" }, { url = "https://files.pythonhosted.org/packages/56/35/1e14d07c97bdb901bf4fd72e6fba52a65e1ff46d0a385b2fceeee2f65227/zensical-0.0.55-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7a970fe0f2512261d60f64ebcc9b9e2dbce4a96f520a4dac21a980881b9284d", size = 13192098, upload-time = "2026-08-16T10:19:36.247Z" }, { url = "https://files.pythonhosted.org/packages/4f/a2/7de6478a39b23d6dbc8cd862390562d9a91641d9ca4552ed6958259e3ca0/zensical-0.0.55-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7b9b6c27080d3976ef7e2b9ac211a4f6f0a446e104a01114889a0992f571da56", size = 13403866, upload-time = "2026-08-16T10:19:38.491Z" }, { url = "https://files.pythonhosted.org/packages/73/2e/bbf25e9f3378dd2d6209dbea427cf9047cdc99533a94152c26fd33f2bb72/zensical-0.0.55-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:588b14322d55652d83de551c7fcd8a377c65753a4f393a88b359fd92738efe1e", size = 13432059, upload-time = "2026-08-16T10:19:40.931Z" }, { url = "https://files.pythonhosted.org/packages/75/5c/0400328cba6712cfce2bc12039a4184e951e4cbe1d35b12dad69b0e678d4/zensical-0.0.55-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:79e9ce4195f8d7b0a7d364f9adc1a98bdb4c5f213985edd6fa75d7e3359e8616", size = 13587117, upload-time = "2026-08-16T10:19:43.168Z" }, { url = "https://files.pythonhosted.org/packages/84/fc/be21acae7d5b4ceb26f48b7dcda2ab65f2ad2fb865030aeff51b1a000d97/zensical-0.0.55-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bc8d2f8a343311f668c96aced77ca679144d62dc0f191dadbd9d7bc84e980295", size = 13535419, upload-time = "2026-08-16T10:19:45.584Z" }, { url = "https://files.pythonhosted.org/packages/c8/37/000e3a9cec7aad0087a353e913fd973dbe355ce3b6efdc1cce2d31764f2c/zensical-0.0.55-cp310-abi3-win32.whl", hash = "sha256:c63d6d925b69e5e115a96e6981e2c24194abda3757e65fb5ff843329f1b8bdfb", size = 12448614, upload-time = "2026-08-16T10:19:47.843Z" }, { url = "https://files.pythonhosted.org/packages/8a/ab/f660dbc74b7acb5547c6920068516e5570965b64ae757f30847df0ab51db/zensical-0.0.55-cp310-abi3-win_amd64.whl", hash = "sha256:cbe65f11dab3f019fc047ddbf147b6f6ac8f6e2a83232c8be6d65fa7c50ee7df", size = 12712414, upload-time = "2026-08-16T10:19:50.053Z" }, ] tb1337-pypaperless-5d67d60/wrangler.jsonc000066400000000000000000000004151524076126400202310ustar00rootroot00000000000000{ "$schema": "node_modules/wrangler/config-schema.json", "name": "pypaperless-docs", "compatibility_date": "2026-05-21", "observability": { "enabled": true }, "assets": { "directory": "site" }, "compatibility_flags": [ "nodejs_compat" ] } tb1337-pypaperless-5d67d60/zensical.toml000066400000000000000000000111521524076126400200570ustar00rootroot00000000000000[project] dev_addr = "localhost:8001" site_name = "pypaperless" site_description = "Little async API client for paperless(-ngx)." site_author = "Tobias Schulz" site_url = "https://pypaperless.docs.tbsch.de" copyright = "Copyright © 2024 - 2026 Tobias Schulz" repo_url = "https://github.com/tb1337/pypaperless" repo_name = "tb1337/pypaperless" edit_uri = "blob/main/docs/" extra_css = ["stylesheets/extra.css"] extra_javascript = ["javascripts/counter.js"] nav = [ "index.md", { "Getting started" = "quickstart.md" }, { "Session management" = "session.md" }, { "Concepts" = [ { "Documents" = "concepts/documents.md" }, { "Custom Fields" = "concepts/custom_fields.md" }, { "Permissions" = "concepts/permissions.md" }, { "Custom Field Query" = "concepts/custom_field_query.md" }, { "Search Query" = "concepts/search_query.md" }, ]}, { "Resources" = [ { "Overview" = "resources.md" }, { "Bulk Edit Objects" = "resources/bulk_edit_objects.md" }, { "Config" = "resources/config.md" }, { "Correspondents" = "resources/correspondents.md" }, { "Custom Fields" = "resources/custom_fields.md" }, { "Document Types" = "resources/document_types.md" }, { "Documents" = "resources/documents.md" }, { "Groups" = "resources/groups.md" }, { "Mail Accounts" = "resources/mail_accounts.md" }, { "Mail Rules" = "resources/mail_rules.md" }, { "Processed Mail" = "resources/processed_mail.md" }, { "Profile" = "resources/profile.md" }, { "Remote Version" = "resources/remote_version.md" }, { "Saved Views" = "resources/saved_views.md" }, { "Search" = "resources/search.md" }, { "Share Links" = "resources/share_links.md" }, { "Share Link Bundles" = "resources/share_link_bundles.md" }, { "Statistics" = "resources/statistics.md" }, { "Status" = "resources/status.md" }, { "Storage Paths" = "resources/storage_paths.md" }, { "Tags" = "resources/tags.md" }, { "Tasks" = "resources/tasks.md" }, { "Trash" = "resources/trash.md" }, { "Users" = "resources/users.md" }, { "Workflows" = "resources/workflows.md" }, ]}, { "Reference" = "exceptions.md" }, { "Migrating v5 → v6" = "migrating-v5-to-v6.md" }, ] # ---------------------------------------------------------------------------- # Theme # ---------------------------------------------------------------------------- [project.theme] language = "en" features = [ "announce.dismiss", "content.code.annotate", "content.code.copy", "content.code.select", "content.footnote.tooltips", "navigation.footer", "navigation.instant", "navigation.instant.prefetch", "navigation.path", "navigation.top", "navigation.tracking", "search.highlight", ] [project.theme.icon] logo = "lucide/code" [project.theme.font] text = "Roboto" code = "Roboto Mono" [[project.theme.palette]] media = "(prefers-color-scheme)" toggle.icon = "lucide/sun-moon" toggle.name = "Switch to light mode" [[project.theme.palette]] media = "(prefers-color-scheme: light)" scheme = "default" toggle.icon = "lucide/sun" toggle.name = "Switch to dark mode" [[project.theme.palette]] media = "(prefers-color-scheme: dark)" scheme = "slate" toggle.icon = "lucide/moon" toggle.name = "Switch to system preference" # ---------------------------------------------------------------------------- # Social links # ---------------------------------------------------------------------------- [[project.extra.social]] icon = "fontawesome/brands/github" link = "https://github.com/tb1337/pypaperless" [[project.extra.social]] icon = "lucide/globe" link = "https://tbsch.de" # ---------------------------------------------------------------------------- # Markdown extensions # ---------------------------------------------------------------------------- [project.markdown_extensions.attr_list] [project.markdown_extensions.md_in_html] [project.markdown_extensions.def_list] [project.markdown_extensions.admonition] [project.markdown_extensions.pymdownx.inlinehilite] [project.markdown_extensions.pymdownx.tilde] [project.markdown_extensions.pymdownx.snippets] [project.markdown_extensions.pymdownx.details] [project.markdown_extensions.pymdownx.highlight] anchor_linenums = true [project.markdown_extensions.pymdownx.superfences] custom_fences = [ { name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" } ] [project.markdown_extensions.pymdownx.tabbed] alternate_style = true [project.markdown_extensions.pymdownx.emoji] emoji_index = "zensical.extensions.emoji.twemoji" emoji_generator = "zensical.extensions.emoji.to_svg"