├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .gitattributes ├── .github ├── labels.yml ├── release-drafter.yml ├── renovate.json └── workflows │ ├── labels.yml │ ├── linting.yml │ ├── lock.yml │ ├── pr-labels.yml │ ├── release-drafter.yml │ ├── release.yml │ ├── stale.yml │ ├── tests.yml │ └── typing.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode └── launch.json ├── .yamllint ├── Dockerfile.dev ├── LICENSE.md ├── README.md ├── docs └── usage.md ├── pypaperless ├── __init__.py ├── api.py ├── const.py ├── exceptions.py ├── helpers.py ├── models │ ├── __init__.py │ ├── base.py │ ├── classifiers.py │ ├── common.py │ ├── config.py │ ├── custom_fields.py │ ├── documents.py │ ├── generators │ │ ├── __init__.py │ │ └── page.py │ ├── mails.py │ ├── mixins │ │ ├── __init__.py │ │ ├── helpers │ │ │ ├── __init__.py │ │ │ ├── callable.py │ │ │ ├── draftable.py │ │ │ ├── iterable.py │ │ │ └── securable.py │ │ └── models │ │ │ ├── __init__.py │ │ │ ├── creatable.py │ │ │ ├── data_fields.py │ │ │ ├── deletable.py │ │ │ ├── securable.py │ │ │ └── updatable.py │ ├── pages.py │ ├── permissions.py │ ├── remote_version.py │ ├── saved_views.py │ ├── share_links.py │ ├── statistics.py │ ├── status.py │ ├── tasks.py │ ├── utils │ │ └── __init__.py │ └── workflows.py └── py.typed ├── pyproject.toml ├── script ├── bootstrap └── setup ├── tests ├── __init__.py ├── conftest.py ├── const.py ├── data │ ├── __init__.py │ ├── api-schema_v2.15.0.json │ ├── v0_0_0.py │ ├── v1_17_0.py │ ├── v1_8_0.py │ ├── v2_0_0.py │ ├── v2_15_0.py │ ├── v2_3_0.py │ └── v2_6_0.py ├── ruff.toml ├── test_common.py ├── test_models_matrix.py └── test_models_specific.py └── uv.lock /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pypaperless Developer", 3 | "context": "..", 4 | "dockerFile": "../Dockerfile.dev", 5 | "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && script/setup", 6 | "postStartCommand": "script/bootstrap", 7 | "containerEnv": { 8 | "DEVCONTAINER": "true" 9 | }, 10 | "features": {}, 11 | "runArgs": [ 12 | "-e", 13 | "GIT_EDITOR=code --wait", 14 | "--security-opt", 15 | "label=disable" 16 | ], 17 | "customizations": { 18 | "vscode": { 19 | "extensions": [ 20 | "charliermarsh.ruff", 21 | "ms-python.pylint", 22 | "ms-python.vscode-pylance", 23 | "redhat.vscode-yaml", 24 | "esbenp.prettier-vscode", 25 | "visualstudioexptteam.vscodeintellicode", 26 | "GitHub.vscode-pull-request-github", 27 | "ryanluker.vscode-coverage-gutters" 28 | ], 29 | "settings": { 30 | "coverage-gutters.customizable.context-menu": true, 31 | "coverage-gutters.customizable.status-bar-toggler-watchCoverageAndVisibleEditors-enabled": true, 32 | "coverage-gutters.showGutterCoverage": true, 33 | "coverage-gutters.showLineCoverage": true, 34 | "coverage-gutters.xmlname": "coverage.xml", 35 | "editor.formatOnPaste": false, 36 | "editor.formatOnSave": true, 37 | "editor.formatOnType": true, 38 | "files.trimTrailingWhitespace": true, 39 | "python.defaultInterpreterPath": "/home/vscode/.local/dev-venv/bin/python", 40 | "python.pythonPath": "/home/vscode/.local/dev-venv/bin/python", 41 | "python.terminal.activateEnvInCurrentTerminal": true, 42 | "python.testing.pytestArgs": [ 43 | "--no-cov", 44 | "--cov-report=term", 45 | "--cov-report=xml" 46 | ], 47 | "pylint.importStrategy": "fromEnvironment", 48 | "[python]": { 49 | "editor.defaultFormatter": "charliermarsh.ruff" 50 | }, 51 | "terminal.integrated.profiles.linux": { 52 | "zsh": { 53 | "path": "/usr/bin/zsh" 54 | } 55 | }, 56 | "terminal.integrated.defaultProfile.linux": "zsh" 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_size = 2 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.py whitespace=error 3 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: "auto" 3 | color: bfdadc 4 | description: "Marks a PR or issue that has been created automatically." 5 | - name: "breaking-change" 6 | color: ee0701 7 | description: "A breaking change for existing users." 8 | - name: "bugfix" 9 | color: ee0701 10 | description: "Inconsistencies or issues which will cause a problem for users or implementers." 11 | - name: "documentation" 12 | color: 0052cc 13 | description: "Solely about the documentation of the project." 14 | - name: "enhancement" 15 | color: 1d76db 16 | description: "Enhancement of the code, not introducing new features." 17 | - name: "refactor" 18 | color: 1d76db 19 | description: "Improvement of existing code, not introducing new features." 20 | - name: "performance" 21 | color: 1d76db 22 | description: "Improving performance, not introducing new features." 23 | - name: "new-feature" 24 | color: 0e8a16 25 | description: "New features or options." 26 | - name: "maintenance" 27 | color: 2af79e 28 | description: "Generic maintenance tasks." 29 | - name: "ci" 30 | color: 1d76db 31 | description: "Work that improves the continuous integration." 32 | - name: "dependencies" 33 | color: 1d76db 34 | description: "Upgrade or downgrade of project dependencies." 35 | 36 | - name: "in-progress" 37 | color: fbca04 38 | description: "Issue is currently being resolved by a developer." 39 | - name: "stale" 40 | color: fef2c0 41 | description: "There has not been activity on this issue or PR for quite some time." 42 | - name: "no-stale" 43 | color: fef2c0 44 | description: "This issue or PR is exempted from the stale bot." 45 | 46 | - name: "security" 47 | color: ee0701 48 | description: "Marks a security issue that needs to be resolved asap." 49 | - name: "incomplete" 50 | color: fef2c0 51 | description: "Marks a PR or issue that is missing information." 52 | - name: "invalid" 53 | color: fef2c0 54 | description: "Marks a PR or issue that is missing information." 55 | 56 | - name: "beginner-friendly" 57 | color: 0e8a16 58 | description: "Good first issue for people wanting to contribute to the project." 59 | - name: "help-wanted" 60 | color: 0e8a16 61 | description: "We need some extra helping hands or expertise in order to resolve this." 62 | 63 | - name: "priority-critical" 64 | color: ee0701 65 | description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." 66 | - name: "priority-high" 67 | color: b60205 68 | description: "After critical issues are fixed, these should be dealt with before any further issues." 69 | - name: "priority-medium" 70 | color: 0e8a16 71 | description: "This issue may be useful, and needs some attention." 72 | - name: "priority-low" 73 | color: e4ea8a 74 | description: "Nice addition, maybe... someday..." 75 | 76 | - name: "major" 77 | color: b60205 78 | description: "This PR causes a major version bump in the version number." 79 | - name: "minor" 80 | color: 0e8a16 81 | description: "This PR causes a minor version bump in the version number." 82 | - name: "skip-changelog" 83 | color: bfdadc 84 | description: "This PR causes no version bump and won't be displayed in the changelog." 85 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name-template: "v$RESOLVED_VERSION 🌈" 3 | tag-template: "v$RESOLVED_VERSION" 4 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 5 | 6 | exclude-labels: 7 | - "skip-changelog" 8 | 9 | categories: 10 | - title: "🚨 Breaking changes" 11 | labels: 12 | - "breaking-change" 13 | - title: "✨ New features" 14 | labels: 15 | - "new-feature" 16 | - title: "🐛 Bug fixes" 17 | labels: 18 | - "bugfix" 19 | - title: "🚀 Enhancements" 20 | labels: 21 | - "enhancement" 22 | - "refactor" 23 | - "performance" 24 | - title: "🧰 Maintenance" 25 | labels: 26 | - "maintenance" 27 | - "ci" 28 | - title: "📚 Documentation" 29 | labels: 30 | - "documentation" 31 | - title: "⬆️ Dependency updates" 32 | labels: 33 | - "dependencies" 34 | 35 | version-resolver: 36 | major: 37 | labels: 38 | - "major" 39 | - "breaking-change" 40 | minor: 41 | labels: 42 | - "minor" 43 | - "new-feature" 44 | patch: 45 | labels: 46 | - "bugfix" 47 | - "ci" 48 | - "dependencies" 49 | - "enhancement" 50 | - "performance" 51 | - "refactor" 52 | default: patch 53 | 54 | template: | 55 | ## Changes 56 | 57 | $CHANGES 58 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "schedule": ["* 2 1 * *"], 4 | "rebaseWhen": "behind-base-branch", 5 | "dependencyDashboard": true, 6 | "labels": ["auto", "no-stale"], 7 | "lockFileMaintenance": { 8 | "enabled": true, 9 | "automerge": true 10 | }, 11 | "commitMessagePrefix": "⬆️", 12 | "packageRules": [ 13 | { 14 | "matchManagers": ["pip_requirements"], 15 | "enabled": false 16 | }, 17 | { 18 | "matchUpdateTypes": ["lockFileMaintenance"], 19 | "addLabels": ["skip-changelog"] 20 | }, 21 | { 22 | "matchManagers": ["pep621"], 23 | "addLabels": ["dependencies", "python"] 24 | }, 25 | { 26 | "matchManagers": ["pep621"], 27 | "matchDepTypes": ["dev"], 28 | "rangeStrategy": "pin" 29 | }, 30 | { 31 | "matchManagers": ["pep621"], 32 | "matchUpdateTypes": ["minor", "patch"], 33 | "automerge": true 34 | }, 35 | { 36 | "matchManagers": ["github-actions"], 37 | "addLabels": ["ci", "github_actions", "skip-changelog"], 38 | "rangeStrategy": "pin" 39 | }, 40 | { 41 | "matchManagers": ["github-actions"], 42 | "matchUpdateTypes": ["minor", "patch"], 43 | "automerge": true 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Sync labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - .github/labels.yml 11 | workflow_dispatch: 12 | 13 | jobs: 14 | labels: 15 | name: 🏷 Sync labels 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: ⤵️ Check out code from GitHub 19 | uses: actions/checkout@v4.2.2 20 | - name: 🚀 Run Label Syncer 21 | uses: micnncim/action-label-syncer@v1.3.0 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Linting 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - "**.py" 11 | - "pyproject.toml" 12 | - "uv.lock" 13 | - "pypaperless/**" 14 | - "tests/**" 15 | pull_request: 16 | paths: 17 | - "**.py" 18 | - "pyproject.toml" 19 | - "uv.lock" 20 | - "pypaperless/**" 21 | - "tests/**" 22 | workflow_dispatch: 23 | 24 | env: 25 | DEFAULT_PYTHON: "3.12" 26 | 27 | jobs: 28 | codespell: 29 | name: codespell 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: ⤵️ Check out code from GitHub 33 | uses: actions/checkout@v4.2.2 34 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 35 | id: python 36 | uses: actions/setup-python@v5.5.0 37 | with: 38 | python-version: ${{ env.DEFAULT_PYTHON }} 39 | - name: 🏗 Set up uv 40 | run: | 41 | pipx install uv 42 | uv venv 43 | - name: 🏗 Install Python dependencies 44 | run: uv sync --group dev 45 | - name: 🚀 Check code for common misspellings 46 | run: uv run pre-commit run codespell --all-files 47 | 48 | ruff: 49 | name: ruff 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: ⤵️ Check out code from GitHub 53 | uses: actions/checkout@v4.2.2 54 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 55 | id: python 56 | uses: actions/setup-python@v5.5.0 57 | with: 58 | python-version: ${{ env.DEFAULT_PYTHON }} 59 | - name: 🏗 Set up uv 60 | run: | 61 | pipx install uv 62 | uv venv 63 | - name: 🏗 Install Python dependencies 64 | run: uv sync --group dev 65 | - name: 🚀 Run ruff linter 66 | run: uv run ruff check --output-format=github . 67 | - name: 🚀 Run ruff formatter 68 | run: uv run ruff format --check . 69 | 70 | pre-commit-hooks: 71 | name: pre-commit-hooks 72 | runs-on: ubuntu-latest 73 | steps: 74 | - name: ⤵️ Check out code from GitHub 75 | uses: actions/checkout@v4.2.2 76 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 77 | id: python 78 | uses: actions/setup-python@v5.5.0 79 | with: 80 | python-version: ${{ env.DEFAULT_PYTHON }} 81 | - name: 🏗 Set up uv 82 | run: | 83 | pipx install uv 84 | uv venv 85 | - name: 🏗 Install Python dependencies 86 | run: uv sync --group dev 87 | - name: 🚀 Check Python AST 88 | run: uv run pre-commit run check-ast --all-files 89 | - name: 🚀 Check for case conflicts 90 | run: uv run pre-commit run check-case-conflict --all-files 91 | - name: 🚀 Check docstring is first 92 | run: uv run pre-commit run check-docstring-first --all-files 93 | # - name: 🚀 Check that executables have shebangs 94 | # run: uv run pre-commit run check-executables-have-shebangs --all-files 95 | - name: 🚀 Check JSON files 96 | run: uv run pre-commit run check-json --all-files 97 | - name: 🚀 Check for merge conflicts 98 | run: uv run pre-commit run check-merge-conflict --all-files 99 | - name: 🚀 Check for broken symlinks 100 | run: uv run pre-commit run check-symlinks --all-files 101 | - name: 🚀 Check TOML files 102 | run: uv run pre-commit run check-toml --all-files 103 | - name: 🚀 Check YAML files 104 | run: uv run pre-commit run check-yaml --all-files 105 | - name: 🚀 Detect Private Keys 106 | run: uv run pre-commit run detect-private-key --all-files 107 | - name: 🚀 Check End of Files 108 | run: uv run pre-commit run end-of-file-fixer --all-files 109 | - name: 🚀 Trim Trailing Whitespace 110 | run: uv run pre-commit run trailing-whitespace --all-files 111 | 112 | pylint: 113 | name: pylint 114 | runs-on: ubuntu-latest 115 | steps: 116 | - name: ⤵️ Check out code from GitHub 117 | uses: actions/checkout@v4.2.2 118 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 119 | id: python 120 | uses: actions/setup-python@v5.5.0 121 | with: 122 | python-version: ${{ env.DEFAULT_PYTHON }} 123 | - name: 🏗 Set up uv 124 | run: | 125 | pipx install uv 126 | uv venv 127 | - name: 🏗 Install Python dependencies 128 | run: uv sync --group dev 129 | - name: 🚀 Run pylint 130 | run: uv run pre-commit run pylint --all-files 131 | 132 | yamllint: 133 | name: yamllint 134 | runs-on: ubuntu-latest 135 | steps: 136 | - name: ⤵️ Check out code from GitHub 137 | uses: actions/checkout@v4.2.2 138 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 139 | id: python 140 | uses: actions/setup-python@v5.5.0 141 | with: 142 | python-version: ${{ env.DEFAULT_PYTHON }} 143 | - name: 🏗 Set up uv 144 | run: | 145 | pipx install uv 146 | uv venv 147 | - name: 🏗 Install Python dependencies 148 | run: uv sync --group dev 149 | - name: 🚀 Run yamllint 150 | run: uv run yamllint . 151 | -------------------------------------------------------------------------------- /.github/workflows/lock.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Lock 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | schedule: 7 | - cron: "0 5 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | lock: 12 | name: 🔒 Lock closed issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: dessant/lock-threads@v5.0.1 16 | with: 17 | github-token: ${{ github.token }} 18 | issue-inactive-days: "30" 19 | issue-lock-reason: "" 20 | pr-inactive-days: "1" 21 | pr-lock-reason: "" 22 | -------------------------------------------------------------------------------- /.github/workflows/pr-labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: PR Labels 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | pull_request_target: 7 | types: 8 | - opened 9 | - synchronize 10 | - labeled 11 | - unlabeled 12 | workflow_call: 13 | 14 | jobs: 15 | pr_labels: 16 | name: Verify 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 🏷 Verify PR has a valid label 20 | uses: jesusvasquez333/verify-pr-label-action@v1.4.0 21 | with: 22 | pull-request-number: "${{ github.event.pull_request.number }}" 23 | github-token: "${{ secrets.GITHUB_TOKEN }}" 24 | valid-labels: >- 25 | breaking-change, bugfix, 26 | ci, 27 | dependencies, documentation, 28 | enhancement, 29 | maintenance, 30 | new-feature, 31 | performance, 32 | refactor 33 | disable-reviews: true 34 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release Drafter 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | update_release_draft: 13 | name: 📝 Draft release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: 🚀 Run Release Drafter 17 | uses: release-drafter/release-drafter@v6.1.0 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | release: 7 | types: 8 | - published 9 | 10 | env: 11 | DEFAULT_PYTHON: "3.12" 12 | 13 | jobs: 14 | release: 15 | name: Releasing to PyPi 16 | runs-on: ubuntu-latest 17 | environment: 18 | name: release 19 | url: https://pypi.org/p/pypaperless 20 | permissions: 21 | contents: write 22 | id-token: write # important for trusted publishing (pypi) 23 | steps: 24 | - name: ⤵️ Check out code from GitHub 25 | uses: actions/checkout@v4.2.2 26 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 27 | id: python 28 | uses: actions/setup-python@v5.5.0 29 | with: 30 | python-version: ${{ env.DEFAULT_PYTHON }} 31 | - name: 🏗 Set up uv 32 | run: | 33 | pipx install uv 34 | uv venv 35 | - name: 🏗 Install Python dependencies 36 | run: uv sync --group dev 37 | - name: 🏗 Set package version 38 | run: | 39 | version="${{ github.event.release.tag_name }}" 40 | version="${version,,}" 41 | version="${version#v}" 42 | sed -i "s/^version = .*/version = \"${version}\"/" pyproject.toml 43 | - name: 🏗 Build package 44 | run: uv build 45 | - name: 🚀 Publish to PyPi 46 | uses: pypa/gh-action-pypi-publish@v1.12.4 47 | with: 48 | verbose: true 49 | print-hash: true 50 | - name: ✍️ Sign published artifacts 51 | uses: sigstore/gh-action-sigstore-python@v3.0.0 52 | with: 53 | inputs: ./dist/*.tar.gz ./dist/*.whl 54 | release-signing-artifacts: true 55 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Stale 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | schedule: 7 | - cron: "0 3 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | stale: 12 | name: 🧹 Clean up stale issues and PRs 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: 🚀 Run stale 16 | uses: actions/stale@v9.1.0 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | days-before-stale: 30 20 | days-before-close: 7 21 | remove-stale-when-updated: true 22 | stale-issue-label: "stale" 23 | exempt-issue-labels: "no-stale,help-wanted" 24 | stale-issue-message: > 25 | There hasn't been any activity on this issue recently, so we have to 26 | clean up some inactive issues. 27 | 28 | Please make sure to update to the latest version and 29 | check if that solves the issue. Let us know if that works for you 30 | by leaving a comment 👍 31 | 32 | This issue has now been marked as stale and will be closed if no 33 | further activity occurs. Thank you. 34 | stale-pr-label: "stale" 35 | exempt-pr-labels: "no-stale" 36 | stale-pr-message: > 37 | There hasn't been any activity on this pull request recently. This 38 | pull request has been automatically marked as stale because of that 39 | and will be closed if no further activity occurs within 7 days. 40 | 41 | Thank you for your contribution! 42 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Testing 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - "**.py" 11 | - "pyproject.toml" 12 | - "uv.lock" 13 | - "pypaperless/**" 14 | - "tests/**" 15 | pull_request: 16 | paths: 17 | - "**.py" 18 | - "pyproject.toml" 19 | - "uv.lock" 20 | - "pypaperless/**" 21 | - "tests/**" 22 | workflow_dispatch: 23 | 24 | env: 25 | DEFAULT_PYTHON: "3.12" 26 | 27 | jobs: 28 | pytest: 29 | name: Python ${{ matrix.python }} 30 | runs-on: ubuntu-latest 31 | strategy: 32 | matrix: 33 | python: ["3.12", "3.13"] 34 | steps: 35 | - name: ⤵️ Check out code from GitHub 36 | uses: actions/checkout@v4.2.2 37 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 38 | id: python 39 | uses: actions/setup-python@v5.5.0 40 | with: 41 | python-version: ${{ env.DEFAULT_PYTHON }} 42 | - name: 🏗 Set up uv 43 | run: | 44 | pipx install uv 45 | uv venv 46 | - name: 🏗 Install Python dependencies 47 | run: uv sync --group dev 48 | - name: 🚀 Run pytest 49 | run: uv run pytest -v --cov-report xml:coverage.xml --cov pypaperless tests 50 | - name: ⬆️ Upload coverage artifact 51 | uses: actions/upload-artifact@v4.6.2 52 | with: 53 | name: coverage-${{ matrix.python }} 54 | path: coverage.xml 55 | 56 | coverage: 57 | runs-on: ubuntu-latest 58 | needs: pytest 59 | steps: 60 | - name: ⤵️ Check out code from GitHub 61 | uses: actions/checkout@v4.2.2 62 | with: 63 | fetch-depth: 0 64 | - name: ⬇️ Download coverage data 65 | uses: actions/download-artifact@v4.3.0 66 | - name: 🚀 Upload coverage report 67 | uses: codecov/codecov-action@v5.4.3 68 | with: 69 | token: ${{ secrets.CODECOV_TOKEN }} 70 | fail_ci_if_error: true 71 | -------------------------------------------------------------------------------- /.github/workflows/typing.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Typing 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - "**.py" 11 | - "pyproject.toml" 12 | - "uv.lock" 13 | - "pypaperless/**" 14 | - "tests/**" 15 | pull_request: 16 | paths: 17 | - "**.py" 18 | - "pyproject.toml" 19 | - "uv.lock" 20 | - "pypaperless/**" 21 | - "tests/**" 22 | workflow_dispatch: 23 | 24 | env: 25 | DEFAULT_PYTHON: "3.12" 26 | 27 | jobs: 28 | mypy: 29 | name: mypy 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: ⤵️ Check out code from GitHub 33 | uses: actions/checkout@v4.2.2 34 | - name: 🏗 Set up Python ${{ env.DEFAULT_PYTHON }} 35 | id: python 36 | uses: actions/setup-python@v5.5.0 37 | with: 38 | python-version: ${{ env.DEFAULT_PYTHON }} 39 | - name: 🏗 Set up uv 40 | run: | 41 | pipx install uv 42 | uv venv 43 | - name: 🏗 Install Python dependencies 44 | run: uv sync --group dev 45 | - name: 🚀 Run mypy 46 | run: uv run mypy pypaperless tests 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # OSX useful to ignore 7 | *.DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | # C extensions 31 | *.so 32 | 33 | # Distribution / packaging 34 | .Python 35 | env/ 36 | build/ 37 | develop-eggs/ 38 | dist/ 39 | downloads/ 40 | eggs/ 41 | .eggs/ 42 | lib/ 43 | lib64/ 44 | parts/ 45 | sdist/ 46 | var/ 47 | *.egg-info/ 48 | .installed.cfg 49 | *.egg 50 | 51 | # PyInstaller 52 | # Usually these files are written by a python script from a template 53 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 54 | *.manifest 55 | *.spec 56 | 57 | # Installer logs 58 | pip-log.txt 59 | pip-delete-this-directory.txt 60 | 61 | # Unit test / coverage reports 62 | htmlcov/ 63 | .tox/ 64 | .coverage 65 | .coverage.* 66 | .cache 67 | nosetests.xml 68 | coverage.xml 69 | *,cover 70 | .hypothesis/ 71 | .pytest_cache/ 72 | 73 | # Translations 74 | *.mo 75 | *.pot 76 | 77 | # Django stuff: 78 | *.log 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # mypy 92 | .mypy_cache/ 93 | 94 | # ruff 95 | .ruff_cache 96 | 97 | # Visual Studio Code 98 | .vscode 99 | 100 | # IntelliJ Idea family of suites 101 | .idea 102 | *.iml 103 | 104 | ## File-based project format: 105 | *.ipr 106 | *.iws 107 | 108 | ## mpeltonen/sbt-idea plugin 109 | .idea_modules/ 110 | 111 | # PyBuilder 112 | target/ 113 | 114 | # Cookiecutter 115 | output/ 116 | python_boilerplate/ 117 | 118 | # Node 119 | node_modules/ 120 | 121 | # Deepcode AI 122 | .dccache 123 | 124 | # run 125 | run/ 126 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: ruff-check 6 | name: 🐶 Ruff Linter 7 | language: system 8 | types: [python] 9 | entry: uv run ruff check --fix 10 | require_serial: true 11 | stages: [pre-commit, pre-push, manual] 12 | - id: ruff-format 13 | name: 🐶 Ruff Formatter 14 | language: system 15 | types: [python] 16 | entry: uv run ruff format 17 | require_serial: true 18 | stages: [pre-commit, pre-push, manual] 19 | - id: check-ast 20 | name: 🐍 Check Python AST 21 | language: system 22 | types: [python] 23 | entry: uv run check-ast 24 | - id: check-case-conflict 25 | name: 🔠 Check for case conflicts 26 | language: system 27 | entry: uv run check-case-conflict 28 | - id: check-docstring-first 29 | name: ℹ️ Check docstring is first 30 | language: system 31 | types: [python] 32 | entry: uv run check-docstring-first 33 | # - id: check-executables-have-shebangs 34 | # name: 🧐 Check that executables have shebangs 35 | # language: system 36 | # types: [text, executable] 37 | # entry: uv run check-executables-have-shebangs 38 | # stages: [commit, push, manual] 39 | - id: check-json 40 | name: { Check JSON files 41 | language: system 42 | types: [json] 43 | entry: uv run check-json 44 | - id: check-merge-conflict 45 | name: 💥 Check for merge conflicts 46 | language: system 47 | types: [text] 48 | entry: uv run check-merge-conflict 49 | - id: check-symlinks 50 | name: 🔗 Check for broken symlinks 51 | language: system 52 | types: [symlink] 53 | entry: uv run check-symlinks 54 | - id: check-toml 55 | name: ✅ Check TOML files 56 | language: system 57 | types: [toml] 58 | entry: uv run check-toml 59 | - id: check-xml 60 | name: ✅ Check XML files 61 | entry: uv run check-xml 62 | language: system 63 | types: [xml] 64 | - id: check-yaml 65 | name: ✅ Check YAML files 66 | language: system 67 | types: [yaml] 68 | entry: uv run check-yaml 69 | - id: codespell 70 | name: ✅ Check code for common misspellings 71 | language: system 72 | types: [text] 73 | exclude: ^(uv\.lock|requirements\.txt|requirements_dev.txt)$ 74 | entry: uv run codespell 75 | - id: detect-private-key 76 | name: 🕵️ Detect Private Keys 77 | language: system 78 | types: [text] 79 | entry: uv run detect-private-key 80 | - id: end-of-file-fixer 81 | name: ⮐ Fix End of Files 82 | language: system 83 | types: [text] 84 | entry: uv run end-of-file-fixer 85 | stages: [pre-commit, pre-push, manual] 86 | - id: mypy 87 | name: 🆎 Static type checking using mypy 88 | language: system 89 | types: [python] 90 | entry: uv run mypy 91 | require_serial: true 92 | - id: no-commit-to-branch 93 | name: 🛑 Don't commit to main branch 94 | language: system 95 | entry: uv run no-commit-to-branch 96 | pass_filenames: false 97 | always_run: true 98 | args: 99 | - --branch=main 100 | - id: pylint 101 | name: 🌟 Starring code with pylint 102 | language: system 103 | types: [python] 104 | entry: uv run pylint 105 | - id: pytest 106 | name: 🧪 Running tests and test coverage with pytest 107 | language: system 108 | types: [python] 109 | entry: uv run pytest 110 | pass_filenames: false 111 | - id: trailing-whitespace 112 | name: ✄ Trim Trailing Whitespace 113 | language: system 114 | types: [text] 115 | entry: uv run trailing-whitespace-fixer 116 | stages: [pre-commit, pre-push, manual] 117 | - id: yamllint 118 | name: 🎗 Check YAML files with yamllint 119 | language: system 120 | types: [yaml] 121 | entry: uv run yamllint 122 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python: pypaperless debug", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "program": "run/debug.py", 9 | "console": "integratedTerminal", 10 | "justMyCode": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | ignore: 3 | - .venv 4 | rules: 5 | braces: 6 | level: error 7 | min-spaces-inside: 0 8 | max-spaces-inside: 1 9 | min-spaces-inside-empty: -1 10 | max-spaces-inside-empty: -1 11 | brackets: 12 | level: error 13 | min-spaces-inside: 0 14 | max-spaces-inside: 0 15 | min-spaces-inside-empty: -1 16 | max-spaces-inside-empty: -1 17 | colons: 18 | level: error 19 | max-spaces-before: 0 20 | max-spaces-after: 1 21 | commas: 22 | level: error 23 | max-spaces-before: 0 24 | min-spaces-after: 1 25 | max-spaces-after: 1 26 | comments: 27 | level: error 28 | require-starting-space: true 29 | min-spaces-from-content: 1 30 | comments-indentation: 31 | level: error 32 | document-end: 33 | level: error 34 | present: false 35 | document-start: 36 | level: error 37 | present: true 38 | empty-lines: 39 | level: error 40 | max: 1 41 | max-start: 0 42 | max-end: 1 43 | hyphens: 44 | level: error 45 | max-spaces-after: 1 46 | indentation: 47 | level: error 48 | spaces: 2 49 | indent-sequences: true 50 | check-multi-line-strings: false 51 | key-duplicates: 52 | level: error 53 | line-length: 54 | level: warning 55 | max: 120 56 | allow-non-breakable-words: true 57 | allow-non-breakable-inline-mappings: true 58 | new-line-at-end-of-file: 59 | level: error 60 | new-lines: 61 | level: error 62 | type: unix 63 | trailing-spaces: 64 | level: error 65 | truthy: 66 | level: error 67 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:1-3.12 2 | 3 | SHELL ["/bin/bash", "-o", "pipefail", "-c"] 4 | 5 | # Uninstall pre-installed formatting and linting tools 6 | # They would conflict with our pinned versions 7 | RUN \ 8 | pipx uninstall pydocstyle \ 9 | && pipx uninstall pycodestyle \ 10 | && pipx uninstall mypy \ 11 | && pipx uninstall pylint 12 | 13 | RUN \ 14 | curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ 15 | && apt-get update \ 16 | && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 17 | # Additional library needed by some tests and accordingly by VScode Tests Discovery 18 | bluez \ 19 | ffmpeg \ 20 | libudev-dev \ 21 | libavformat-dev \ 22 | libavcodec-dev \ 23 | libavdevice-dev \ 24 | libavutil-dev \ 25 | libgammu-dev \ 26 | libswscale-dev \ 27 | libswresample-dev \ 28 | libavfilter-dev \ 29 | libpcap-dev \ 30 | libturbojpeg0 \ 31 | libyaml-dev \ 32 | libxml2 \ 33 | git \ 34 | cmake \ 35 | && apt-get clean \ 36 | && rm -rf /var/lib/apt/lists/* 37 | 38 | # Install uv 39 | RUN pip3 install uv 40 | 41 | USER vscode 42 | ENV VIRTUAL_ENV="/home/vscode/.local/dev-venv" 43 | RUN uv venv $VIRTUAL_ENV 44 | ENV PATH="$VIRTUAL_ENV/bin:$PATH" 45 | # force use the venv 46 | ENV UV_PROJECT_ENVIRONMENT="$VIRTUAL_ENV" 47 | 48 | WORKDIR /workspaces 49 | 50 | # Set the default shell to bash instead of sh 51 | ENV SHELL=/bin/bash 52 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2022-2024 tb1337 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyPaperless 2 | 3 | [![GitHub Release][release-badge]][release-url] 4 | [![Python Version][python-badge]][python-url] 5 | [![GitHub License][license-badge]][license-url] 6 | 7 | [![Tests][tests-badge]][tests-url] 8 | [![Codecov][codecov-badge]][codecov-url] 9 | [![Linting][linting-badge]][linting-url] 10 | [![Typing][typing-badge]][typing-url] 11 | 12 | Little asynchronous client for Paperless-ngx, written in Python. You should at least use Python `>=3.12`. 13 | 14 | **v4 upgrade warning** 15 | 16 | * We dropped support for Python `<=3.11`. 17 | * Major changes in various classes occurred. Consider using Paperless-ngx `>=2.15.0`. 18 | * Support for Paperless-ngx `<2.15.0` will end after 2025/07. 19 | 20 | ## Features 21 | 22 | - Depends on aiohttp, works in async environments. 23 | - Token authentication only. **No credentials anymore.** 24 | - Request single resource items. 25 | - Iterate over all resource items or request them page by page. 26 | - Create, update and delete resource items. 27 | - Almost feature complete. 28 | - _PyPaperless_ is designed to transport data only. Your code must organize it. 29 | 30 | Find out more about Paperless-ngx here: 31 | 32 | - Project: https://docs.paperless-ngx.com 33 | - API Docs: https://docs.paperless-ngx.com/api/ 34 | - Source Code: https://github.com/paperless-ngx/paperless-ngx 35 | 36 | ## Installation 37 | 38 | ```bash 39 | pip install pypaperless 40 | ``` 41 | 42 | ## Documentation 43 | 44 | Please check out the **[docs][docs-url]** for detailed instructions and examples. 45 | 46 | ## Authors & contributors 47 | 48 | _PyPaperless_ is written by [Tobias Schulz][contributors-tbsch]. Its his first Python project. Feedback appreciated. 49 | 50 | Check out all [contributors here][contributors-url]. 51 | 52 | [codecov-badge]: https://codecov.io/gh/tb1337/paperless-api/graph/badge.svg?token=IMXRBK3HRE 53 | [codecov-url]: https://app.codecov.io/gh/tb1337/paperless-api/tree/main 54 | [contributors-tbsch]: https://tbsch.de 55 | [contributors-url]: https://github.com/tb1337/paperless-api/graphs/contributors 56 | [docs-url]: https://github.com/tb1337/paperless-api/blob/main/docs/usage.md 57 | [license-badge]: https://img.shields.io/github/license/tb1337/paperless-api 58 | [license-url]: /LICENSE.md 59 | [python-badge]: https://img.shields.io/pypi/pyversions/pypaperless 60 | [python-url]: https://pypi.org/project/pypaperless/ 61 | [tests-badge]: https://github.com/tb1337/paperless-api/actions/workflows/tests.yml/badge.svg 62 | [tests-url]: https://github.com/tb1337/paperless-api/actions 63 | [release-badge]: https://img.shields.io/github/v/release/tb1337/paperless-api 64 | [release-url]: https://github.com/tb1337/paperless-api/releases 65 | [linting-badge]: https://github.com/tb1337/paperless-api/actions/workflows/linting.yml/badge.svg 66 | [linting-url]: https://github.com/tb1337/paperless-api/actions 67 | [typing-badge]: https://github.com/tb1337/paperless-api/actions/workflows/typing.yml/badge.svg 68 | [typing-url]: https://github.com/tb1337/paperless-api/actions 69 | -------------------------------------------------------------------------------- /pypaperless/__init__.py: -------------------------------------------------------------------------------- 1 | """PyPaperless.""" 2 | 3 | from .api import Paperless 4 | 5 | __all__ = ("Paperless",) 6 | -------------------------------------------------------------------------------- /pypaperless/api.py: -------------------------------------------------------------------------------- 1 | """PyPaperless.""" 2 | 3 | import logging 4 | from collections.abc import AsyncGenerator 5 | from contextlib import asynccontextmanager 6 | from io import BytesIO 7 | from json.decoder import JSONDecodeError 8 | from typing import Any, cast 9 | 10 | import aiohttp 11 | import aiohttp.web_exceptions 12 | from yarl import URL 13 | 14 | from . import helpers 15 | from .const import API_PATH, API_VERSION, PaperlessResource 16 | from .exceptions import ( 17 | BadJsonResponseError, 18 | InitializationError, 19 | JsonResponseWithError, 20 | PaperlessConnectionError, 21 | PaperlessForbiddenError, 22 | PaperlessInactiveOrDeletedError, 23 | PaperlessInvalidTokenError, 24 | ) 25 | from .models.base import HelperBase 26 | from .models.common import PaperlessCache 27 | 28 | 29 | class Paperless: 30 | """Retrieves and manipulates data from and to Paperless via REST.""" 31 | 32 | _helpers_map: set[tuple[str, type[HelperBase]]] = { 33 | (PaperlessResource.CONFIG, helpers.ConfigHelper), 34 | (PaperlessResource.CORRESPONDENTS, helpers.CorrespondentHelper), 35 | (PaperlessResource.CUSTOM_FIELDS, helpers.CustomFieldHelper), 36 | (PaperlessResource.DOCUMENTS, helpers.DocumentHelper), 37 | (PaperlessResource.DOCUMENT_TYPES, helpers.DocumentTypeHelper), 38 | (PaperlessResource.GROUPS, helpers.GroupHelper), 39 | (PaperlessResource.MAIL_ACCOUNTS, helpers.MailAccountHelper), 40 | (PaperlessResource.MAIL_RULES, helpers.MailRuleHelper), 41 | (PaperlessResource.SAVED_VIEWS, helpers.SavedViewHelper), 42 | (PaperlessResource.SHARE_LINKS, helpers.ShareLinkHelper), 43 | (PaperlessResource.STATISTICS, helpers.StatisticHelper), 44 | (PaperlessResource.REMOTE_VERSION, helpers.RemoteVersionHelper), 45 | (PaperlessResource.STATUS, helpers.StatusHelper), 46 | (PaperlessResource.STORAGE_PATHS, helpers.StoragePathHelper), 47 | (PaperlessResource.TAGS, helpers.TagHelper), 48 | (PaperlessResource.TASKS, helpers.TaskHelper), 49 | (PaperlessResource.USERS, helpers.UserHelper), 50 | (PaperlessResource.WORKFLOWS, helpers.WorkflowHelper), 51 | } 52 | 53 | config: helpers.ConfigHelper 54 | correspondents: helpers.CorrespondentHelper 55 | custom_fields: helpers.CustomFieldHelper 56 | documents: helpers.DocumentHelper 57 | document_types: helpers.DocumentTypeHelper 58 | groups: helpers.GroupHelper 59 | mail_accounts: helpers.MailAccountHelper 60 | mail_rules: helpers.MailRuleHelper 61 | saved_views: helpers.SavedViewHelper 62 | share_links: helpers.ShareLinkHelper 63 | statistics: helpers.StatisticHelper 64 | remote_version: helpers.RemoteVersionHelper 65 | status: helpers.StatusHelper 66 | storage_paths: helpers.StoragePathHelper 67 | tags: helpers.TagHelper 68 | tasks: helpers.TaskHelper 69 | users: helpers.UserHelper 70 | workflows: helpers.WorkflowHelper 71 | 72 | async def __aenter__(self) -> "Paperless": 73 | """Return context manager.""" 74 | await self.initialize() 75 | return self 76 | 77 | async def __aexit__(self, *_: object) -> None: 78 | """Exit context manager.""" 79 | await self.close() 80 | 81 | def __init__( 82 | self, 83 | url: str | URL, 84 | token: str, 85 | *, 86 | session: aiohttp.ClientSession | None = None, 87 | request_args: dict[str, Any] | None = None, 88 | ) -> None: 89 | """Initialize a `Paperless` instance. 90 | 91 | You have to permit either a session, or an url / token pair. 92 | 93 | `url`: A hostname or IP-address as string, or yarl.URL object. 94 | `token`: An api token created in Paperless Django settings, or via the helper function. 95 | `session`: A custom `PaperlessSession` object, if existing. 96 | `request_args` are passed to each request method call as additional kwargs, 97 | ssl stuff for example. You should read the aiohttp docs to learn more about it. 98 | """ 99 | self._base_url = self._create_base_url(url) 100 | self._cache = PaperlessCache() 101 | self._initialized = False 102 | self._local_resources: set[PaperlessResource] = set() 103 | self._remote_resources: set[PaperlessResource] = set() 104 | self._request_args = request_args or {} 105 | self._session = session 106 | self._token = token 107 | self._version: str | None = None 108 | 109 | self.logger = logging.getLogger(f"{__package__}") 110 | 111 | @property 112 | def base_url(self) -> str: 113 | """Return the base url of the Paperless api endpoint.""" 114 | return str(self._base_url) 115 | 116 | @property 117 | def cache(self) -> PaperlessCache: 118 | """Return the cache object.""" 119 | return self._cache 120 | 121 | @property 122 | def is_initialized(self) -> bool: 123 | """Return `True` if connection is initialized.""" 124 | return self._initialized 125 | 126 | @property 127 | def host_version(self) -> str | None: 128 | """Return the version of the Paperless host.""" 129 | return self._version 130 | 131 | @property 132 | def local_resources(self) -> set[PaperlessResource]: 133 | """Return a set of locally available resources.""" 134 | return self._local_resources 135 | 136 | @property 137 | def remote_resources(self) -> set[PaperlessResource]: 138 | """Return a set of available resources of the Paperless host.""" 139 | return self._remote_resources 140 | 141 | @staticmethod 142 | def _create_base_url(url: str | URL) -> URL: 143 | """Create URL from string or URL and prepare for further use.""" 144 | # reverse compatibility, fall back to https 145 | if isinstance(url, str) and "://" not in url: 146 | url = f"https://{url}".rstrip("/") 147 | url = URL(url) 148 | 149 | # scheme check. fall back to https 150 | if url.scheme not in ("https", "http"): 151 | url = URL(url).with_scheme("https") 152 | 153 | return url 154 | 155 | @staticmethod 156 | def _process_form(data: dict[str, Any]) -> aiohttp.FormData: 157 | """Process form data and create a `aiohttp.FormData` object. 158 | 159 | Every field item gets converted to a string-like object. 160 | """ 161 | form = aiohttp.FormData(quote_fields=False) 162 | 163 | def _add_form_value(name: str | None, value: Any) -> Any: 164 | if value is None: 165 | return 166 | params = {} 167 | if isinstance(value, dict): 168 | for dict_key, dict_value in value.items(): 169 | _add_form_value(dict_key, dict_value) 170 | return 171 | if isinstance(value, list | set): 172 | for list_value in value: 173 | _add_form_value(name, list_value) 174 | return 175 | if isinstance(value, tuple): 176 | if len(value) == 2: 177 | params["filename"] = f"{value[1]}" 178 | value = value[0] 179 | if name is not None: 180 | form.add_field( 181 | name, BytesIO(value) if isinstance(value, bytes) else f"{value}", **params 182 | ) 183 | 184 | _add_form_value(None, data) 185 | return form 186 | 187 | @staticmethod 188 | async def generate_api_token( 189 | url: str, 190 | username: str, 191 | password: str, 192 | session: aiohttp.ClientSession | None = None, 193 | ) -> str: 194 | """Request Paperless to generate an api token for the given credentials. 195 | 196 | Warning: the request is plain and insecure. Don't use this in production 197 | environments or businesses. 198 | 199 | Warning: error handling is low for this method, as it is just a helper. 200 | 201 | Example: 202 | ------- 203 | ```python 204 | token = Paperless.generate_api_token("example.com:8000", "api_user", "secret_password") 205 | 206 | paperless = Paperless("example.com:8000", token) 207 | # do something 208 | ``` 209 | 210 | """ 211 | external_session = session is not None 212 | session = session or aiohttp.ClientSession() 213 | try: 214 | url = url.rstrip("/") 215 | json = { 216 | "username": username, 217 | "password": password, 218 | } 219 | res = await session.request("post", f"{url}{API_PATH['token']}", json=json) 220 | data = await res.json() 221 | res.raise_for_status() 222 | return str(data["token"]) 223 | except (JSONDecodeError, KeyError) as exc: 224 | message = "Token is missing in response." 225 | raise BadJsonResponseError(message) from exc 226 | except aiohttp.ClientResponseError as exc: 227 | raise JsonResponseWithError(payload={"error": data}) from exc 228 | finally: 229 | if not external_session: 230 | await session.close() 231 | 232 | async def close(self) -> None: 233 | """Clean up connection.""" 234 | if self._session: 235 | await self._session.close() 236 | self.logger.info("Closed.") 237 | 238 | async def initialize(self) -> None: 239 | """Initialize the connection to DRF and fetch the endpoints.""" 240 | 241 | async def _init_with_openapi_response() -> bool: 242 | """Connect to paperless and request the openapi schema.""" 243 | try: 244 | async with self.request("get", API_PATH["api_schema"]) as res: 245 | res.raise_for_status() 246 | except aiohttp.ClientError: 247 | return False 248 | 249 | self._version = res.headers.get("x-version", None) 250 | return True 251 | 252 | async def _init_with_legacy_response() -> dict[str, str]: 253 | """Connect to paperless and request the entity dictionary (DRF).""" 254 | async with self.request("get", API_PATH["index"]) as res: 255 | try: 256 | res.raise_for_status() 257 | payload = await res.json() 258 | except (aiohttp.ClientResponseError, ValueError) as exc: 259 | raise InitializationError from exc 260 | 261 | self._version = res.headers.get("x-version", None) 262 | return cast("dict[str, str]", payload) 263 | 264 | if await _init_with_openapi_response(): 265 | self.logger.debug("OpenAPI spec detected.") 266 | self._remote_resources = { 267 | res 268 | for res in PaperlessResource 269 | if res 270 | not in { 271 | PaperlessResource.UNKNOWN, 272 | PaperlessResource.CONSUMPTION_TEMPLATES, 273 | } 274 | } 275 | else: 276 | payload = await _init_with_legacy_response() 277 | self._remote_resources = set(map(PaperlessResource, payload)) 278 | 279 | # initialize helpers 280 | for attribute, helper in self._helpers_map: 281 | setattr(self, f"{attribute}", helper(self)) 282 | 283 | unused = self._remote_resources.difference(self._local_resources) 284 | missing = self._local_resources.difference(self._remote_resources) 285 | 286 | if len(unused) > 0: 287 | self.logger.debug("Unused features: %s", ", ".join(unused)) 288 | 289 | if len(missing) > 0: 290 | self.logger.warning( 291 | "Outdated version detected. Consider pulling the latest version of Paperless-ngx." 292 | ) 293 | self.logger.warning("Support for Paperless-ngx AsyncGenerator[aiohttp.ClientResponse, None]: 309 | """Send a request to the Paperless api and return the `aiohttp.ClientResponse`. 310 | 311 | This method provides a little interface for utilizing `aiohttp.FormData`. 312 | 313 | `method`: A http method: get, post, patch, put, delete, head, options 314 | `path`: A path to the endpoint or a string url. 315 | `json`: A dict containing the json data. 316 | `data`: A dict containing the data to send in the request body. 317 | `form`: A dict with form data, which gets converted to `aiohttp.FormData` 318 | and replaces `data`. 319 | `params`: A dict with query parameters. 320 | `kwargs`: Optional attributes for the `aiohttp.ClientSession.request` method. 321 | """ 322 | if self._session is None: 323 | self._session = aiohttp.ClientSession() 324 | 325 | # add headers 326 | headers = { 327 | "Accept": f"application/json; version={API_VERSION}", 328 | "Authorization": f"Token {self._token}", 329 | } 330 | 331 | # Merge with any user-defined headers (optional) 332 | if "headers" in kwargs: 333 | kwargs["headers"].update(headers) 334 | else: 335 | kwargs["headers"] = headers 336 | 337 | # add request args 338 | kwargs.update(self._request_args) 339 | 340 | # overwrite data with a form, when there is a form payload 341 | if isinstance(form, dict): 342 | data = self._process_form(form) 343 | 344 | # add base path 345 | url = f"{self._base_url}{path}" if not path.startswith("http") else path 346 | 347 | try: 348 | res = await self._session.request( 349 | method=method, 350 | url=url, 351 | json=json, 352 | data=data, 353 | params=params, 354 | **kwargs, 355 | ) 356 | self.logger.debug("%s (%d): %s", method.upper(), res.status, res.url) 357 | except aiohttp.ClientConnectionError as err: 358 | raise PaperlessConnectionError from err 359 | 360 | # error handling for 401 and 403 codes 361 | if res.status == 401: 362 | try: 363 | error_data = await res.json() 364 | detail = error_data.get("detail", "") 365 | except JSONDecodeError: 366 | detail = "" 367 | 368 | if "inactive" in detail.lower() or "deleted" in detail.lower(): 369 | raise PaperlessInactiveOrDeletedError(res) 370 | 371 | raise PaperlessInvalidTokenError(res) 372 | 373 | if res.status == 403: 374 | raise PaperlessForbiddenError(res) 375 | 376 | yield res 377 | 378 | async def request_json( 379 | self, 380 | method: str, 381 | endpoint: str, 382 | **kwargs: Any, 383 | ) -> Any: 384 | """Make a request to the api and parse response json to dict.""" 385 | async with self.request(method, endpoint, **kwargs) as res: 386 | if res.content_type != "application/json": 387 | raise BadJsonResponseError(res) 388 | 389 | try: 390 | payload = await res.json() 391 | except ValueError: 392 | raise BadJsonResponseError(res) from None 393 | 394 | if res.status == 400: 395 | raise JsonResponseWithError(payload) 396 | 397 | res.raise_for_status() 398 | 399 | return payload 400 | -------------------------------------------------------------------------------- /pypaperless/const.py: -------------------------------------------------------------------------------- 1 | """PyPaperless constants.""" 2 | 3 | from __future__ import annotations 4 | 5 | from enum import StrEnum 6 | 7 | API_VERSION = 7 8 | 9 | CONFIG = "config" 10 | CONSUMPTION_TEMPLATES = "consumption_templates" 11 | CORRESPONDENTS = "correspondents" 12 | CUSTOM_FIELDS = "custom_fields" 13 | DOCUMENTS = "documents" 14 | DOCUMENT_TYPES = "document_types" 15 | GROUPS = "groups" 16 | LOGS = "logs" 17 | MAIL_ACCOUNTS = "mail_accounts" 18 | MAIL_RULES = "mail_rules" 19 | SAVED_VIEWS = "saved_views" 20 | SHARE_LINKS = "share_links" 21 | STATISTICS = "statistics" 22 | REMOTE_VERSION = "remote_version" 23 | STATUS = "status" 24 | STORAGE_PATHS = "storage_paths" 25 | TAGS = "tags" 26 | TASKS = "tasks" 27 | USERS = "users" 28 | WORKFLOW_ACTIONS = "workflow_actions" 29 | WORKFLOWS = "workflows" 30 | WORKFLOW_TRIGGERS = "workflow_triggers" 31 | UNKNOWN = "unknown" 32 | 33 | API_PATH = { 34 | "api_schema": "/api/schema/", 35 | "index": "/api/", 36 | "token": "/api/token/", 37 | f"{CONFIG}": f"/api/{CONFIG}/", 38 | f"{CONFIG}_single": f"/api/{CONFIG}/{{pk}}/", 39 | f"{CORRESPONDENTS}": f"/api/{CORRESPONDENTS}/", 40 | f"{CORRESPONDENTS}_single": f"/api/{CORRESPONDENTS}/{{pk}}/", 41 | f"{CUSTOM_FIELDS}": f"/api/{CUSTOM_FIELDS}/", 42 | f"{CUSTOM_FIELDS}_single": f"/api/{CUSTOM_FIELDS}/{{pk}}/", 43 | f"{DOCUMENTS}": f"/api/{DOCUMENTS}/", 44 | f"{DOCUMENTS}_download": f"/api/{DOCUMENTS}/{{pk}}/download/", 45 | f"{DOCUMENTS}_meta": f"/api/{DOCUMENTS}/{{pk}}/metadata/", 46 | f"{DOCUMENTS}_next_asn": f"/api/{DOCUMENTS}/next_asn/", 47 | f"{DOCUMENTS}_notes": f"/api/{DOCUMENTS}/{{pk}}/notes/", 48 | f"{DOCUMENTS}_preview": f"/api/{DOCUMENTS}/{{pk}}/preview/", 49 | f"{DOCUMENTS}_thumbnail": f"/api/{DOCUMENTS}/{{pk}}/thumb/", 50 | f"{DOCUMENTS}_post": f"/api/{DOCUMENTS}/post_document/", 51 | f"{DOCUMENTS}_single": f"/api/{DOCUMENTS}/{{pk}}/", 52 | f"{DOCUMENTS}_suggestions": f"/api/{DOCUMENTS}/{{pk}}/suggestions/", 53 | f"{DOCUMENT_TYPES}": f"/api/{DOCUMENT_TYPES}/", 54 | f"{DOCUMENT_TYPES}_single": f"/api/{DOCUMENT_TYPES}/{{pk}}/", 55 | f"{GROUPS}": f"/api/{GROUPS}/", 56 | f"{GROUPS}_single": f"/api/{GROUPS}/{{pk}}/", 57 | f"{MAIL_ACCOUNTS}": f"/api/{MAIL_ACCOUNTS}/", 58 | f"{MAIL_ACCOUNTS}_single": f"/api/{MAIL_ACCOUNTS}/{{pk}}/", 59 | f"{MAIL_RULES}": f"/api/{MAIL_RULES}/", 60 | f"{MAIL_RULES}_single": f"/api/{MAIL_RULES}/{{pk}}/", 61 | f"{SAVED_VIEWS}": f"/api/{SAVED_VIEWS}/", 62 | f"{SAVED_VIEWS}_single": f"/api/{SAVED_VIEWS}/{{pk}}/", 63 | f"{SHARE_LINKS}": f"/api/{SHARE_LINKS}/", 64 | f"{SHARE_LINKS}_single": f"/api/{SHARE_LINKS}/{{pk}}/", 65 | f"{STATISTICS}": f"/api/{STATISTICS}/", 66 | f"{REMOTE_VERSION}": f"/api/{REMOTE_VERSION}/", 67 | f"{STATUS}": f"/api/{STATUS}/", 68 | f"{STORAGE_PATHS}": f"/api/{STORAGE_PATHS}/", 69 | f"{STORAGE_PATHS}_single": f"/api/{STORAGE_PATHS}/{{pk}}/", 70 | f"{TAGS}": f"/api/{TAGS}/", 71 | f"{TAGS}_single": f"/api/{TAGS}/{{pk}}/", 72 | f"{TASKS}": f"/api/{TASKS}/", 73 | f"{TASKS}_single": f"/api/{TASKS}/{{pk}}/", 74 | f"{USERS}": f"/api/{USERS}/", 75 | f"{USERS}_single": f"/api/{USERS}/{{pk}}/", 76 | f"{WORKFLOWS}": f"/api/{WORKFLOWS}/", 77 | f"{WORKFLOWS}_single": f"/api/{WORKFLOWS}/{{pk}}/", 78 | f"{WORKFLOW_ACTIONS}": f"/api/{WORKFLOW_ACTIONS}/", 79 | f"{WORKFLOW_ACTIONS}_single": f"/api/{WORKFLOW_ACTIONS}/{{pk}}/", 80 | f"{WORKFLOW_TRIGGERS}": f"/api/{WORKFLOW_TRIGGERS}/", 81 | f"{WORKFLOW_TRIGGERS}_single": f"/api/{WORKFLOW_TRIGGERS}/{{pk}}/", 82 | } 83 | 84 | 85 | class PaperlessResource(StrEnum): 86 | """Represent paths of api endpoints.""" 87 | 88 | CONFIG = CONFIG 89 | CONSUMPTION_TEMPLATES = CONSUMPTION_TEMPLATES 90 | CORRESPONDENTS = CORRESPONDENTS 91 | CUSTOM_FIELDS = CUSTOM_FIELDS 92 | DOCUMENTS = DOCUMENTS 93 | DOCUMENT_TYPES = DOCUMENT_TYPES 94 | GROUPS = GROUPS 95 | LOGS = LOGS 96 | MAIL_ACCOUNTS = MAIL_ACCOUNTS 97 | MAIL_RULES = MAIL_RULES 98 | SAVED_VIEWS = SAVED_VIEWS 99 | SHARE_LINKS = SHARE_LINKS 100 | STATISTICS = STATISTICS 101 | REMOTE_VERSION = REMOTE_VERSION 102 | STATUS = STATUS 103 | STORAGE_PATHS = STORAGE_PATHS 104 | TAGS = TAGS 105 | TASKS = TASKS 106 | USERS = USERS 107 | WORKFLOWS = WORKFLOWS 108 | WORKFLOW_ACTIONS = WORKFLOW_ACTIONS 109 | WORKFLOW_TRIGGERS = WORKFLOW_TRIGGERS 110 | UNKNOWN = UNKNOWN 111 | 112 | @classmethod 113 | def _missing_(cls: type[PaperlessResource], *_: object) -> PaperlessResource: 114 | """Set default member on unknown value.""" 115 | return cls.UNKNOWN 116 | -------------------------------------------------------------------------------- /pypaperless/exceptions.py: -------------------------------------------------------------------------------- 1 | """PyPaperless exceptions.""" 2 | 3 | from typing import Any 4 | 5 | 6 | class PaperlessError(Exception): 7 | """Base exception for PyPaperless.""" 8 | 9 | 10 | # Sessions and requests 11 | 12 | 13 | class InitializationError(PaperlessError): 14 | """Raise when initializing a `Paperless` instance without valid url or token.""" 15 | 16 | 17 | class PaperlessConnectionError(InitializationError, PaperlessError): 18 | """Raise when connection to Paperless is not possible.""" 19 | 20 | 21 | class PaperlessAuthError(InitializationError, PaperlessError): 22 | """Raise when response is 401 code.""" 23 | 24 | 25 | class PaperlessInvalidTokenError(PaperlessAuthError): 26 | """Raise when response is 401 due invalid access token.""" 27 | 28 | 29 | class PaperlessInactiveOrDeletedError(PaperlessAuthError): 30 | """Raise when response is 401 code due user is inactive or deleted.""" 31 | 32 | 33 | class PaperlessForbiddenError(InitializationError, PaperlessError): 34 | """Raise when response is 403 code.""" 35 | 36 | 37 | class BadJsonResponseError(PaperlessError): 38 | """Raise when response is no valid json.""" 39 | 40 | 41 | class JsonResponseWithError(PaperlessError): 42 | """Raise when Paperless accepted the request, but responded with an error payload.""" 43 | 44 | def __init__(self, payload: Any) -> None: 45 | """Initialize a `JsonResponseWithError` instance.""" 46 | 47 | def _parse_payload(payload: Any, key: list[str] | None = None) -> tuple[list[str], str]: 48 | """Parse first suitable error from payload.""" 49 | if key is None: 50 | key = [] 51 | 52 | if isinstance(payload, list): 53 | return _parse_payload(payload.pop(0), key) 54 | if isinstance(payload, dict): 55 | if "error" in payload: 56 | key.append("error") 57 | return _parse_payload(payload["error"], key) 58 | 59 | new_key = next(iter(payload)) 60 | key.append(new_key) 61 | 62 | return _parse_payload(payload[new_key], key) 63 | 64 | return key, payload 65 | 66 | key, message = _parse_payload(payload) 67 | 68 | if len(key) == 0: 69 | key.append("error") 70 | key_chain = " -> ".join(key) 71 | 72 | super().__init__(f"Paperless [{key_chain}]: {message}") 73 | 74 | 75 | # Models 76 | 77 | 78 | class AsnRequestError(PaperlessError): 79 | """Raise when getting an error during requesting the next asn.""" 80 | 81 | 82 | class DraftFieldRequiredError(PaperlessError): 83 | """Raise when trying to save models with missing required fields.""" 84 | 85 | 86 | class DraftNotSupportedError(PaperlessError): 87 | """Raise when trying to draft unsupported models.""" 88 | 89 | 90 | class ItemNotFoundError(PaperlessError): 91 | """Raise when trying to access non-existing items in PaperlessModelData classes.""" 92 | 93 | 94 | class PrimaryKeyRequiredError(PaperlessError): 95 | """Raise when trying to access model data without supplying a pk.""" 96 | 97 | 98 | # Tasks 99 | 100 | 101 | class TaskNotFoundError(PaperlessError): 102 | """Raise when trying to access a task by non-existing uuid.""" 103 | 104 | def __init__(self, task_id: str) -> None: 105 | """Initialize a `TaskNotFound` instance.""" 106 | super().__init__(f"Task with UUID {task_id} not found.") 107 | -------------------------------------------------------------------------------- /pypaperless/helpers.py: -------------------------------------------------------------------------------- 1 | """PyPaperless helpers.""" 2 | 3 | # pylint: disable=unused-import 4 | 5 | from .models.base import HelperBase # noqa: F401 6 | from .models.classifiers import ( # noqa: F401 7 | CorrespondentHelper, 8 | DocumentTypeHelper, 9 | StoragePathHelper, 10 | TagHelper, 11 | ) 12 | from .models.config import ConfigHelper # noqa: F401 13 | from .models.custom_fields import CustomFieldHelper # noqa: F401 14 | from .models.documents import DocumentHelper, DocumentMetaHelper, DocumentNoteHelper # noqa: F401 15 | from .models.mails import MailAccountHelper, MailRuleHelper # noqa: F401 16 | from .models.permissions import GroupHelper, UserHelper # noqa: F401 17 | from .models.remote_version import RemoteVersionHelper # noqa: F401 18 | from .models.saved_views import SavedViewHelper # noqa: F401 19 | from .models.share_links import ShareLinkHelper # noqa: F401 20 | from .models.statistics import StatisticHelper # noqa: F401 21 | from .models.status import StatusHelper # noqa: F401 22 | from .models.tasks import TaskHelper # noqa: F401 23 | from .models.workflows import WorkflowHelper # noqa: F401 24 | -------------------------------------------------------------------------------- /pypaperless/models/__init__.py: -------------------------------------------------------------------------------- 1 | """PyPaperless models.""" 2 | 3 | from .classifiers import ( 4 | Correspondent, 5 | CorrespondentDraft, 6 | DocumentType, 7 | DocumentTypeDraft, 8 | StoragePath, 9 | StoragePathDraft, 10 | Tag, 11 | TagDraft, 12 | ) 13 | from .config import Config 14 | from .custom_fields import CustomField, CustomFieldDraft 15 | from .documents import ( 16 | Document, 17 | DocumentDraft, 18 | DocumentMeta, 19 | DocumentNote, 20 | DocumentNoteDraft, 21 | ) 22 | from .mails import MailAccount, MailRule 23 | from .pages import Page 24 | from .permissions import Group, User 25 | from .remote_version import RemoteVersion 26 | from .saved_views import SavedView 27 | from .share_links import ShareLink, ShareLinkDraft 28 | from .statistics import Statistic 29 | from .status import Status 30 | from .tasks import Task 31 | from .workflows import Workflow, WorkflowAction, WorkflowTrigger 32 | 33 | __all__ = ( 34 | "Config", 35 | "Correspondent", 36 | "CorrespondentDraft", 37 | "CustomField", 38 | "CustomFieldDraft", 39 | "Document", 40 | "DocumentDraft", 41 | "DocumentMeta", 42 | "DocumentNote", 43 | "DocumentNoteDraft", 44 | "DocumentType", 45 | "DocumentTypeDraft", 46 | "Group", 47 | "MailAccount", 48 | "MailRule", 49 | "Page", 50 | "RemoteVersion", 51 | "SavedView", 52 | "ShareLink", 53 | "ShareLinkDraft", 54 | "Statistic", 55 | "Status", 56 | "StoragePath", 57 | "StoragePathDraft", 58 | "Tag", 59 | "TagDraft", 60 | "Task", 61 | "User", 62 | "Workflow", 63 | "WorkflowAction", 64 | "WorkflowTrigger", 65 | ) 66 | -------------------------------------------------------------------------------- /pypaperless/models/base.py: -------------------------------------------------------------------------------- 1 | """Provide base classes.""" 2 | 3 | from abc import ABC, abstractmethod 4 | from dataclasses import Field, dataclass, fields 5 | from typing import TYPE_CHECKING, Any, Generic, Protocol, Self, TypeVar, final 6 | 7 | from pypaperless.const import API_PATH, PaperlessResource 8 | from pypaperless.models.utils import dict_value_to_object 9 | 10 | if TYPE_CHECKING: 11 | from pypaperless import Paperless 12 | 13 | 14 | ResourceT = TypeVar("ResourceT", bound="PaperlessModel") 15 | 16 | 17 | class PaperlessBase: 18 | """Superclass for all classes in PyPaperless.""" 19 | 20 | _api_path = API_PATH["index"] 21 | 22 | def __init__(self, api: "Paperless") -> None: 23 | """Initialize a `PaperlessBase` instance.""" 24 | self._api = api 25 | 26 | 27 | class HelperProtocol(Protocol, Generic[ResourceT]): 28 | """Protocol for any `HelperBase` instances and its ancestors.""" 29 | 30 | _api: "Paperless" 31 | _api_path: str 32 | _resource: PaperlessResource 33 | _resource_cls: type[ResourceT] 34 | 35 | 36 | class HelperBase(PaperlessBase, Generic[ResourceT]): 37 | """Base class for all helpers in PyPaperless.""" 38 | 39 | _resource: PaperlessResource 40 | _resource_public: bool = True 41 | 42 | def __init__(self, api: "Paperless") -> None: 43 | """Initialize a `HelperBase` instance.""" 44 | super().__init__(api) 45 | 46 | if self._resource_public: 47 | self._api.local_resources.add(self._resource) 48 | 49 | @property 50 | def is_available(self) -> bool: 51 | """Return if the attached endpoint is available, or not.""" 52 | return self._resource in self._api.remote_resources 53 | 54 | 55 | @dataclass(init=False) 56 | class PaperlessModelProtocol(Protocol): 57 | """Protocol for any `PaperlessBase` instances and its ancestors.""" 58 | 59 | _api: "Paperless" 60 | _api_path: str 61 | _data: dict[str, Any] 62 | _fetched: bool 63 | _params: dict[str, Any] 64 | 65 | # fmt: off 66 | def _get_dataclass_fields(self) -> list[Field]: ... 67 | def _set_dataclass_fields(self) -> None: ... 68 | # fmt: on 69 | 70 | 71 | @dataclass(init=False) 72 | class PaperlessModel(PaperlessBase): 73 | """Base class for all models in PyPaperless.""" 74 | 75 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 76 | """Initialize a `PaperlessModel` instance.""" 77 | super().__init__(api) 78 | 79 | self._data = {} 80 | self._data.update(data) 81 | self._fetched = False 82 | self._params: dict[str, Any] = {} 83 | 84 | @final 85 | @classmethod 86 | def create_with_data( 87 | cls, 88 | api: "Paperless", 89 | data: dict[str, Any], 90 | *, 91 | fetched: bool = False, 92 | ) -> Self: 93 | """Return a new instance of `cls` from `data`. 94 | 95 | Primarily used by class factories to create new model instances. 96 | 97 | Example: `document = Document.create_with_data(...)` 98 | """ 99 | item = cls(api, data=data) 100 | 101 | item._fetched = fetched 102 | if fetched: 103 | item._set_dataclass_fields() 104 | return item 105 | 106 | @final 107 | def _get_dataclass_fields(self) -> list[Field]: 108 | """Get the dataclass fields.""" 109 | return [ 110 | field 111 | for field in fields(self) 112 | if (not field.name.startswith("_") or field.name == "__search_hit__") 113 | ] 114 | 115 | @final 116 | def _set_dataclass_fields(self) -> None: 117 | """Set the dataclass fields from `self._data`.""" 118 | for field in self._get_dataclass_fields(): 119 | value = dict_value_to_object( 120 | f"{self.__class__.__name__}.{field.name}", 121 | self._data.get(field.name), 122 | field.type, 123 | field.default, 124 | self._api, 125 | ) 126 | setattr(self, field.name, value) 127 | 128 | @property 129 | def is_fetched(self) -> bool: 130 | """Return whether the `model data` is fetched or not.""" 131 | return self._fetched 132 | 133 | async def load(self) -> None: 134 | """Get `model data` from DRF.""" 135 | data = await self._api.request_json("get", self._api_path, params=self._params) 136 | 137 | self._data.update(data) 138 | self._set_dataclass_fields() 139 | self._fetched = True 140 | 141 | 142 | class PaperlessModelData(ABC): 143 | """Base class for all custom data types in PyPaperless.""" 144 | 145 | @classmethod 146 | @abstractmethod 147 | def unserialize(cls, api: "Paperless", data: Any) -> Self: 148 | """Return a new instance of `cls` from `data`.""" 149 | 150 | @abstractmethod 151 | def serialize(self) -> Any: 152 | """Serialize the class data.""" 153 | -------------------------------------------------------------------------------- /pypaperless/models/classifiers.py: -------------------------------------------------------------------------------- 1 | """Provide `Correspondent`, `DocumentType`, `StoragePath` and `Tag` related models and helpers.""" 2 | 3 | import datetime 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from pypaperless.const import API_PATH, PaperlessResource 8 | 9 | from .base import HelperBase, PaperlessModel 10 | from .mixins import helpers, models 11 | 12 | if TYPE_CHECKING: 13 | from pypaperless import Paperless 14 | 15 | 16 | @dataclass(init=False) 17 | class Correspondent( 18 | PaperlessModel, 19 | models.MatchingFieldsMixin, 20 | models.SecurableMixin, 21 | models.UpdatableMixin, 22 | models.DeletableMixin, 23 | ): 24 | """Represent a Paperless `Correspondent`.""" 25 | 26 | _api_path = API_PATH["correspondents_single"] 27 | 28 | id: int | None = None 29 | slug: str | None = None 30 | name: str | None = None 31 | document_count: int | None = None 32 | last_correspondence: datetime.datetime | None = None 33 | 34 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 35 | """Initialize a `Correspondent` instance.""" 36 | super().__init__(api, data) 37 | 38 | self._api_path = self._api_path.format(pk=data.get("id")) 39 | 40 | 41 | @dataclass(init=False) 42 | class CorrespondentDraft( 43 | PaperlessModel, 44 | models.MatchingFieldsMixin, 45 | models.SecurableDraftMixin, 46 | models.CreatableMixin, 47 | ): 48 | """Represent a new `Correspondent`, which is not yet stored in Paperless.""" 49 | 50 | _api_path = API_PATH["correspondents"] 51 | 52 | _create_required_fields = { 53 | "name", 54 | "match", 55 | "matching_algorithm", 56 | "is_insensitive", 57 | } 58 | 59 | name: str | None = None 60 | 61 | 62 | @dataclass(init=False) 63 | class DocumentType( 64 | PaperlessModel, 65 | models.MatchingFieldsMixin, 66 | models.SecurableMixin, 67 | models.UpdatableMixin, 68 | models.DeletableMixin, 69 | ): 70 | """Represent a Paperless `DocumentType`.""" 71 | 72 | _api_path = API_PATH["document_types_single"] 73 | 74 | id: int | None = None 75 | slug: str | None = None 76 | name: str | None = None 77 | document_count: int | None = None 78 | 79 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 80 | """Initialize a `DocumentType` instance.""" 81 | super().__init__(api, data) 82 | 83 | self._api_path = self._api_path.format(pk=data.get("id")) 84 | 85 | 86 | @dataclass(init=False) 87 | class DocumentTypeDraft( 88 | PaperlessModel, 89 | models.MatchingFieldsMixin, 90 | models.SecurableDraftMixin, 91 | models.CreatableMixin, 92 | ): 93 | """Represent a new `DocumentType`, which is not yet stored in Paperless.""" 94 | 95 | _api_path = API_PATH["document_types"] 96 | 97 | _create_required_fields = { 98 | "name", 99 | "match", 100 | "matching_algorithm", 101 | "is_insensitive", 102 | } 103 | 104 | name: str | None = None 105 | owner: int | None = None 106 | 107 | 108 | @dataclass(init=False) 109 | class StoragePath( 110 | PaperlessModel, 111 | models.MatchingFieldsMixin, 112 | models.SecurableMixin, 113 | models.UpdatableMixin, 114 | models.DeletableMixin, 115 | ): 116 | """Represent a Paperless `StoragePath`.""" 117 | 118 | _api_path = API_PATH["storage_paths_single"] 119 | 120 | id: int | None = None 121 | slug: str | None = None 122 | name: str | None = None 123 | path: str | None = None 124 | document_count: int | None = None 125 | 126 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 127 | """Initialize a `StoragePath` instance.""" 128 | super().__init__(api, data) 129 | 130 | self._api_path = self._api_path.format(pk=data.get("id")) 131 | 132 | 133 | @dataclass(init=False) 134 | class StoragePathDraft( 135 | PaperlessModel, 136 | models.MatchingFieldsMixin, 137 | models.SecurableDraftMixin, 138 | models.CreatableMixin, 139 | ): 140 | """Represent a new `StoragePath`, which is not yet stored in Paperless.""" 141 | 142 | _api_path = API_PATH["storage_paths"] 143 | 144 | _create_required_fields = { 145 | "name", 146 | "path", 147 | "match", 148 | "matching_algorithm", 149 | "is_insensitive", 150 | } 151 | 152 | name: str | None = None 153 | path: str | None = None 154 | owner: int | None = None 155 | 156 | 157 | @dataclass(init=False) 158 | class Tag( 159 | PaperlessModel, 160 | models.MatchingFieldsMixin, 161 | models.SecurableMixin, 162 | models.UpdatableMixin, 163 | models.DeletableMixin, 164 | ): 165 | """Represent a Paperless `Tag`.""" 166 | 167 | _api_path = API_PATH["tags_single"] 168 | 169 | id: int | None = None 170 | slug: str | None = None 171 | name: str | None = None 172 | color: str | None = None 173 | text_color: str | None = None 174 | is_inbox_tag: bool | None = None 175 | document_count: int | None = None 176 | 177 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 178 | """Initialize a `Tag` instance.""" 179 | super().__init__(api, data) 180 | 181 | self._api_path = self._api_path.format(pk=data.get("id")) 182 | 183 | 184 | @dataclass(init=False) 185 | class TagDraft( 186 | PaperlessModel, 187 | models.MatchingFieldsMixin, 188 | models.SecurableDraftMixin, 189 | models.CreatableMixin, 190 | ): 191 | """Represent a new `Tag`, which is not yet stored in Paperless.""" 192 | 193 | _api_path = API_PATH["tags"] 194 | 195 | _create_required_fields = { 196 | "name", 197 | "color", 198 | "is_inbox_tag", 199 | "match", 200 | "matching_algorithm", 201 | "is_insensitive", 202 | } 203 | 204 | name: str | None = None 205 | color: str | None = None 206 | text_color: str | None = None 207 | is_inbox_tag: bool | None = None 208 | owner: int | None = None 209 | 210 | 211 | class CorrespondentHelper( 212 | HelperBase[Correspondent], 213 | helpers.SecurableMixin, 214 | helpers.CallableMixin[Correspondent], 215 | helpers.DraftableMixin[CorrespondentDraft], 216 | helpers.IterableMixin[Correspondent], 217 | ): 218 | """Represent a factory for Paperless `Correspondent` models.""" 219 | 220 | _api_path = API_PATH["correspondents"] 221 | _resource = PaperlessResource.CORRESPONDENTS 222 | 223 | _draft_cls = CorrespondentDraft 224 | _resource_cls = Correspondent 225 | 226 | 227 | class DocumentTypeHelper( 228 | HelperBase[DocumentType], 229 | helpers.SecurableMixin, 230 | helpers.CallableMixin[DocumentType], 231 | helpers.DraftableMixin[DocumentTypeDraft], 232 | helpers.IterableMixin[DocumentType], 233 | ): 234 | """Represent a factory for Paperless `DocumentType` models.""" 235 | 236 | _api_path = API_PATH["document_types"] 237 | _resource = PaperlessResource.DOCUMENT_TYPES 238 | 239 | _draft_cls = DocumentTypeDraft 240 | _resource_cls = DocumentType 241 | 242 | 243 | class StoragePathHelper( 244 | HelperBase[StoragePath], 245 | helpers.SecurableMixin, 246 | helpers.CallableMixin[StoragePath], 247 | helpers.DraftableMixin[StoragePathDraft], 248 | helpers.IterableMixin[StoragePath], 249 | ): 250 | """Represent a factory for Paperless `StoragePath` models.""" 251 | 252 | _api_path = API_PATH["storage_paths"] 253 | _resource = PaperlessResource.STORAGE_PATHS 254 | 255 | _draft_cls = StoragePathDraft 256 | _resource_cls = StoragePath 257 | 258 | 259 | class TagHelper( 260 | HelperBase[Tag], 261 | helpers.SecurableMixin, 262 | helpers.CallableMixin[Tag], 263 | helpers.DraftableMixin[TagDraft], 264 | helpers.IterableMixin[Tag], 265 | ): 266 | """Represent a factory for Paperless `Tag` models.""" 267 | 268 | _api_path = API_PATH["tags"] 269 | _resource = PaperlessResource.TAGS 270 | 271 | _draft_cls = TagDraft 272 | _resource_cls = Tag 273 | -------------------------------------------------------------------------------- /pypaperless/models/common.py: -------------------------------------------------------------------------------- 1 | """PyPaperless common types.""" 2 | 3 | import contextlib 4 | import datetime 5 | from dataclasses import dataclass, field 6 | from enum import Enum, StrEnum 7 | from typing import TYPE_CHECKING, Any, TypedDict 8 | 9 | if TYPE_CHECKING: 10 | from pypaperless import Paperless 11 | 12 | from .classifiers import Correspondent, DocumentType, StoragePath, Tag 13 | from .custom_fields import CustomField 14 | 15 | 16 | # custom_fields 17 | class CustomFieldExtraDataSelectOptions(TypedDict): 18 | """Represent the `extra_data.select_options` field of a `CustomField`.""" 19 | 20 | id: str | None 21 | label: str | None 22 | 23 | 24 | class CustomFieldExtraData(TypedDict): 25 | """Represent the `extra_data` field of a `CustomField`.""" 26 | 27 | default_currency: str | None 28 | select_options: list[CustomFieldExtraDataSelectOptions | None] 29 | 30 | 31 | class CustomFieldType(Enum): 32 | """Represent a subtype of `CustomField`.""" 33 | 34 | STRING = "string" 35 | URL = "url" 36 | DATE = "date" 37 | BOOLEAN = "boolean" 38 | INTEGER = "integer" 39 | FLOAT = "float" 40 | MONETARY = "monetary" 41 | DOCUMENT_LINK = "documentlink" 42 | SELECT = "select" 43 | UNKNOWN = "unknown" 44 | 45 | @classmethod 46 | def _missing_(cls: type, *_: object) -> "CustomFieldType": 47 | """Set default member on unknown value.""" 48 | return CustomFieldType.UNKNOWN 49 | 50 | 51 | # documents 52 | @dataclass(kw_only=True) 53 | class CustomFieldValue: 54 | """Represent a subtype of `CustomField`.""" 55 | 56 | field: int | None = None 57 | value: Any | None = None 58 | name: str | None = None 59 | data_type: CustomFieldType | None = None 60 | extra_data: CustomFieldExtraData | None = None 61 | 62 | 63 | @dataclass(kw_only=True) 64 | class CustomFieldBooleanValue(CustomFieldValue): 65 | """Represent a boolean `CustomFieldValue`.""" 66 | 67 | value: bool | None = None 68 | 69 | 70 | @dataclass(kw_only=True) 71 | class CustomFieldDateValue(CustomFieldValue): 72 | """Represent a date `CustomFieldValue`.""" 73 | 74 | value: datetime.datetime | str | None = None 75 | 76 | def __post_init__(self) -> None: 77 | """Convert the value to a datetime.""" 78 | if isinstance(self.value, str): 79 | with contextlib.suppress(ValueError): 80 | self.value = datetime.datetime.fromisoformat(self.value) 81 | 82 | 83 | @dataclass(kw_only=True) 84 | class CustomFieldDocumentLinkValue(CustomFieldValue): 85 | """Represent a document link `CustomFieldValue`.""" 86 | 87 | value: list[int] | None = None 88 | 89 | 90 | @dataclass(kw_only=True) 91 | class CustomFieldFloatValue(CustomFieldValue): 92 | """Represent a float `CustomFieldValue`.""" 93 | 94 | value: float | None = None 95 | 96 | 97 | @dataclass(kw_only=True) 98 | class CustomFieldIntegerValue(CustomFieldValue): 99 | """Represent an integer `CustomFieldValue`.""" 100 | 101 | value: int | None = None 102 | 103 | 104 | @dataclass(kw_only=True) 105 | class CustomFieldSelectValue(CustomFieldValue): 106 | """Represent a select `CustomFieldValue`.""" 107 | 108 | value: int | None = None 109 | 110 | @property 111 | def labels(self) -> list[CustomFieldExtraDataSelectOptions | None]: 112 | """Return the list of labels of the `CustomField`.""" 113 | if not self.extra_data: 114 | return [] 115 | return self.extra_data["select_options"] 116 | 117 | @property 118 | def label(self) -> str | None: 119 | """Return the label for `value` or fall back to `None`.""" 120 | for opt in self.labels: 121 | if opt and opt["id"] == self.value: 122 | return opt["label"] 123 | return None 124 | 125 | 126 | @dataclass(kw_only=True) 127 | class CustomFieldStringValue(CustomFieldValue): 128 | """Represent a string `CustomFieldValue`.""" 129 | 130 | value: str | None = None 131 | 132 | 133 | # documents 134 | @dataclass(kw_only=True) 135 | class DocumentMetadataType: 136 | """Represent a subtype of `DocumentMeta`.""" 137 | 138 | namespace: str | None = None 139 | prefix: str | None = None 140 | key: str | None = None 141 | value: str | None = None 142 | 143 | 144 | # documents 145 | @dataclass(kw_only=True) 146 | class DocumentSearchHitType: 147 | """Represent a subtype of `Document`.""" 148 | 149 | score: float | None = None 150 | highlights: str | None = None 151 | note_highlights: str | None = None 152 | rank: int | None = None 153 | 154 | 155 | # api 156 | @dataclass(kw_only=True) 157 | class MasterDataInstance: 158 | """Represent a `MasterDataInstance`.""" 159 | 160 | api: "Paperless" 161 | is_initialized: bool = False 162 | 163 | correspondents: list["Correspondent"] = field(default_factory=list) 164 | custom_fields: list["CustomField"] = field(default_factory=list) 165 | document_types: list["DocumentType"] = field(default_factory=list) 166 | storage_paths: list["StoragePath"] = field(default_factory=list) 167 | tags: list["Tag"] = field(default_factory=list) 168 | 169 | 170 | # mixins/models/data_fields, used for classifiers 171 | class MatchingAlgorithmType(Enum): 172 | """Represent a subtype of `Correspondent`, `DocumentType`, `StoragePath` and `Tag`.""" 173 | 174 | NONE = 0 175 | ANY = 1 176 | ALL = 2 177 | LITERAL = 3 178 | REGEX = 4 179 | FUZZY = 5 180 | AUTO = 6 181 | UNKNOWN = -1 182 | 183 | @classmethod 184 | def _missing_(cls: type, *_: object) -> "MatchingAlgorithmType": 185 | """Set default member on unknown value.""" 186 | return MatchingAlgorithmType.UNKNOWN 187 | 188 | 189 | # api 190 | @dataclass(kw_only=True) 191 | class PaperlessCache: 192 | """Represent a Paperless cache object.""" 193 | 194 | custom_fields: dict[int, "CustomField"] | None = None 195 | 196 | 197 | # mixins/models/securable 198 | @dataclass(kw_only=True) 199 | class PermissionSetType: 200 | """Represent a Paperless permission set.""" 201 | 202 | users: list[int] = field(default_factory=list) 203 | groups: list[int] = field(default_factory=list) 204 | 205 | 206 | # mixins/models/securable 207 | @dataclass(kw_only=True) 208 | class PermissionTableType: 209 | """Represent a Paperless permissions type.""" 210 | 211 | view: PermissionSetType = field(default_factory=PermissionSetType) 212 | change: PermissionSetType = field(default_factory=PermissionSetType) 213 | 214 | 215 | # documents 216 | class RetrieveFileMode(StrEnum): 217 | """Represent a subtype of `DownloadedDocument`.""" 218 | 219 | DOWNLOAD = "download" 220 | PREVIEW = "preview" 221 | THUMBNAIL = "thumb" 222 | 223 | 224 | # saved_views 225 | @dataclass(kw_only=True) 226 | class SavedViewFilterRuleType: 227 | """Represent a subtype of `SavedView`.""" 228 | 229 | rule_type: int | None = None 230 | value: str | None = None 231 | 232 | 233 | # share_links 234 | class ShareLinkFileVersionType(Enum): 235 | """Represent a subtype of `ShareLink`.""" 236 | 237 | ARCHIVE = "archive" 238 | ORIGINAL = "original" 239 | UNKNOWN = "unknown" 240 | 241 | @classmethod 242 | def _missing_(cls: type, *_: object) -> "ShareLinkFileVersionType": 243 | """Set default member on unknown value.""" 244 | return ShareLinkFileVersionType.UNKNOWN 245 | 246 | 247 | # statistics 248 | @dataclass(kw_only=True) 249 | class StatisticDocumentFileTypeCount: 250 | """Represent a Paperless statistics file type count.""" 251 | 252 | mime_type: str | None = None 253 | mime_type_count: int | None = None 254 | 255 | 256 | # status 257 | class StatusType(Enum): 258 | """Represent a subtype of `Status`.""" 259 | 260 | OK = "OK" 261 | ERROR = "ERROR" 262 | WARNING = "WARNING" 263 | UNKNOWN = "UNKNOWN" 264 | 265 | @classmethod 266 | def _missing_(cls: type, *_: object) -> "StatusType": 267 | """Set default member on unknown value.""" 268 | return StatusType.UNKNOWN 269 | 270 | 271 | # status 272 | @dataclass(kw_only=True) 273 | class StatusDatabaseMigrationStatusType: 274 | """Represent a subtype of `StatusDatabaseType`.""" 275 | 276 | latest_migration: str | None = None 277 | unapplied_migrations: list[str] = field(default_factory=list) 278 | 279 | 280 | # status 281 | @dataclass(kw_only=True) 282 | class StatusDatabaseType: 283 | """Represent a subtype of `Status`.""" 284 | 285 | type: str | None = None 286 | url: str | None = None 287 | status: StatusType | None = None 288 | error: str | None = None 289 | migration_status: StatusDatabaseMigrationStatusType | None = None 290 | 291 | 292 | # status 293 | @dataclass(kw_only=True) 294 | class StatusStorageType: 295 | """Represent a subtype of `Status`.""" 296 | 297 | total: int | None = None 298 | available: int | None = None 299 | 300 | 301 | # status 302 | @dataclass(kw_only=True) 303 | class StatusTasksType: 304 | """Represent a subtype of `Status`.""" 305 | 306 | redis_url: str | None = None 307 | redis_status: StatusType | None = None 308 | redis_error: str | None = None 309 | celery_status: StatusType | None = None 310 | celery_url: str | None = None 311 | celery_error: str | None = None 312 | index_status: StatusType | None = None 313 | index_last_modified: datetime.datetime | None = None 314 | index_error: str | None = None 315 | classifier_status: StatusType | None = None 316 | classifier_last_trained: datetime.datetime | None = None 317 | classifier_error: str | None = None 318 | sanity_check_status: StatusType | None = None 319 | sanity_check_last_run: datetime.datetime | None = None 320 | sanity_check_error: str | None = None 321 | 322 | 323 | # tasks 324 | class TaskStatusType(Enum): 325 | """Represent a subtype of `Task`.""" 326 | 327 | PENDING = "PENDING" 328 | STARTED = "STARTED" 329 | SUCCESS = "SUCCESS" 330 | FAILURE = "FAILURE" 331 | UNKNOWN = "UNKNOWN" 332 | 333 | @classmethod 334 | def _missing_(cls: type, *_: object) -> "TaskStatusType": 335 | """Set default member on unknown value.""" 336 | return TaskStatusType.UNKNOWN 337 | 338 | 339 | # workflows 340 | class WorkflowActionType(Enum): 341 | """Represent a subtype of `Workflow`.""" 342 | 343 | ASSIGNMENT = 1 344 | UNKNOWN = -1 345 | 346 | @classmethod 347 | def _missing_(cls: type, *_: object) -> "WorkflowActionType": 348 | """Set default member on unknown value.""" 349 | return WorkflowActionType.UNKNOWN 350 | 351 | 352 | # workflows 353 | class WorkflowTriggerType(Enum): 354 | """Represent a subtype of `Workflow`.""" 355 | 356 | CONSUMPTION = 1 357 | DOCUMENT_ADDED = 2 358 | DOCUMENT_UPDATED = 3 359 | UNKNOWN = -1 360 | 361 | @classmethod 362 | def _missing_(cls: type, *_: object) -> "WorkflowTriggerType": 363 | """Set default member on unknown value.""" 364 | return WorkflowTriggerType.UNKNOWN 365 | 366 | 367 | # workflows 368 | class WorkflowTriggerSourceType(Enum): 369 | """Represent a subtype of `Workflow`.""" 370 | 371 | CONSUME_FOLDER = 1 372 | API_UPLOAD = 2 373 | MAIL_FETCH = 3 374 | UNKNOWN = -1 375 | 376 | @classmethod 377 | def _missing_(cls: type, *_: object) -> "WorkflowTriggerSourceType": 378 | """Set default member on unknown value.""" 379 | return WorkflowTriggerSourceType.UNKNOWN 380 | -------------------------------------------------------------------------------- /pypaperless/models/config.py: -------------------------------------------------------------------------------- 1 | """Provide `Config` related models and helpers.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from pypaperless.const import API_PATH, PaperlessResource 7 | 8 | from .base import HelperBase, PaperlessModel 9 | from .mixins import helpers 10 | 11 | if TYPE_CHECKING: 12 | from pypaperless import Paperless 13 | 14 | 15 | @dataclass(init=False) 16 | class Config( 17 | PaperlessModel, 18 | ): 19 | """Represent a Paperless `Config`.""" 20 | 21 | _api_path = API_PATH["config_single"] 22 | 23 | id: int | None = None 24 | user_args: str | None = None 25 | output_type: str | None = None 26 | pages: int | None = None 27 | language: str | None = None 28 | mode: str | None = None 29 | skip_archive_file: str | None = None 30 | image_dpi: int | None = None 31 | unpaper_clean: str | None = None 32 | deskew: bool | None = None 33 | rotate_pages: bool | None = None 34 | rotate_pages_threshold: float | None = None 35 | max_image_pixels: float | None = None 36 | color_conversion_strategy: str | None = None 37 | app_title: str | None = None 38 | app_logo: str | None = None 39 | 40 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 41 | """Initialize a `Config` instance.""" 42 | super().__init__(api, data) 43 | 44 | self._api_path = self._api_path.format(pk=data.get("id")) 45 | 46 | 47 | class ConfigHelper( 48 | HelperBase[Config], 49 | helpers.CallableMixin[Config], 50 | ): 51 | """Represent a factory for Paperless `Config` models.""" 52 | 53 | _api_path = API_PATH["config"] 54 | _resource = PaperlessResource.CONFIG 55 | 56 | _resource_cls = Config 57 | -------------------------------------------------------------------------------- /pypaperless/models/custom_fields.py: -------------------------------------------------------------------------------- 1 | """Provide `CustomField` related models and helpers.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from pypaperless.const import API_PATH, PaperlessResource 7 | 8 | from .base import HelperBase, PaperlessModel 9 | from .common import CustomFieldExtraData, CustomFieldType 10 | from .mixins import helpers, models 11 | 12 | if TYPE_CHECKING: 13 | from pypaperless import Paperless 14 | 15 | 16 | @dataclass(init=False) 17 | class CustomField( 18 | PaperlessModel, 19 | models.UpdatableMixin, 20 | models.DeletableMixin, 21 | ): 22 | """Represent a Paperless `CustomField`.""" 23 | 24 | _api_path = API_PATH["custom_fields_single"] 25 | 26 | id: int 27 | name: str | None = None 28 | data_type: CustomFieldType | None = None 29 | extra_data: CustomFieldExtraData | None = None 30 | document_count: int | None = None 31 | 32 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 33 | """Initialize a `Document` instance.""" 34 | super().__init__(api, data) 35 | 36 | self._api_path = self._api_path.format(pk=data.get("id")) 37 | 38 | 39 | @dataclass(init=False) 40 | class CustomFieldDraft( 41 | PaperlessModel, 42 | models.CreatableMixin, 43 | ): 44 | """Represent a new Paperless `CustomField`, which is not stored in Paperless.""" 45 | 46 | _api_path = API_PATH["custom_fields"] 47 | 48 | _create_required_fields = {"name", "data_type"} 49 | 50 | name: str | None = None 51 | data_type: CustomFieldType | None = None 52 | extra_data: CustomFieldExtraData | None = None 53 | 54 | 55 | class CustomFieldHelper( 56 | HelperBase[CustomField], 57 | helpers.CallableMixin[CustomField], 58 | helpers.DraftableMixin[CustomFieldDraft], 59 | helpers.IterableMixin[CustomField], 60 | ): 61 | """Represent a factory for Paperless `CustomField` models.""" 62 | 63 | _api_path = API_PATH["custom_fields"] 64 | _resource = PaperlessResource.CUSTOM_FIELDS 65 | 66 | _draft_cls = CustomFieldDraft 67 | _resource_cls = CustomField 68 | -------------------------------------------------------------------------------- /pypaperless/models/generators/__init__.py: -------------------------------------------------------------------------------- 1 | """PyPaperless generators.""" 2 | 3 | from .page import PageGenerator 4 | 5 | __all__ = ("PageGenerator",) 6 | -------------------------------------------------------------------------------- /pypaperless/models/generators/page.py: -------------------------------------------------------------------------------- 1 | """Provide the PageGenerator class.""" 2 | 3 | from collections.abc import AsyncIterator 4 | from copy import deepcopy 5 | from typing import TYPE_CHECKING, Any, Self 6 | 7 | from pypaperless.models.base import PaperlessBase 8 | from pypaperless.models.pages import Page 9 | 10 | if TYPE_CHECKING: 11 | from pypaperless import Paperless 12 | 13 | 14 | class PageGenerator(PaperlessBase, AsyncIterator): 15 | """Iterator for DRF paginated endpoints. 16 | 17 | `api`: An instance of :class:`Paperless`. 18 | `url`: A url returning DRF page contents. 19 | `resource`: A target resource model type for mapping results with. 20 | `params`: Optional dict of query string parameters. 21 | """ 22 | 23 | _page: Page | None 24 | 25 | def __aiter__(self) -> Self: 26 | """Return self as iterator.""" 27 | return self 28 | 29 | async def __anext__(self) -> Page: 30 | """Return next item from the current batch.""" 31 | if self._page is not None and self._page.is_last_page: 32 | raise StopAsyncIteration 33 | 34 | res = await self._api.request_json("get", self._url, params=self.params) 35 | data = { 36 | **res, 37 | "_api_path": self._url, 38 | "current_page": self.params["page"], 39 | "page_size": self.params["page_size"], 40 | } 41 | self._page = Page.create_with_data(self._api, data, fetched=True) 42 | # dirty attach the resource to the data class 43 | self._page._resource_cls = self._resource_cls # noqa: SLF001 44 | 45 | # rise page by one to request next page on next iteration 46 | self.params["page"] += 1 47 | 48 | # we do not reach this point without a self._page object, so: ignore type error 49 | return self._page 50 | 51 | def __init__( 52 | self, 53 | api: "Paperless", 54 | url: str, 55 | resource_cls: type, 56 | params: dict[str, Any] | None = None, 57 | ) -> None: 58 | """Initialize `PageGenerator` class instance.""" 59 | super().__init__(api) 60 | 61 | self._page = None 62 | self._resource_cls = resource_cls 63 | self._url = url 64 | 65 | self.params = deepcopy(params) if params else {} 66 | self.params.setdefault("page", 1) 67 | self.params.setdefault("page_size", 150) 68 | -------------------------------------------------------------------------------- /pypaperless/models/mails.py: -------------------------------------------------------------------------------- 1 | """Provide `MailRule` related models and helpers.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from pypaperless.const import API_PATH, PaperlessResource 7 | 8 | from .base import HelperBase, PaperlessModel 9 | from .mixins import helpers, models 10 | 11 | if TYPE_CHECKING: 12 | from pypaperless import Paperless 13 | 14 | 15 | @dataclass(init=False) 16 | class MailAccount( 17 | PaperlessModel, 18 | models.SecurableMixin, 19 | ): 20 | """Represent a Paperless `MailAccount`.""" 21 | 22 | _api_path = API_PATH["mail_accounts_single"] 23 | 24 | id: int | None = None 25 | name: str | None = None 26 | imap_server: str | None = None 27 | imap_port: int | None = None 28 | imap_security: int | None = None 29 | username: str | None = None 30 | # exclude that from the dataclass 31 | # password: str | None = None # noqa: ERA001 32 | character_set: str | None = None 33 | is_token: bool | None = None 34 | 35 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 36 | """Initialize a `MailAccount` instance.""" 37 | super().__init__(api, data) 38 | 39 | self._api_path = self._api_path.format(pk=data.get("id")) 40 | 41 | 42 | @dataclass(init=False) 43 | class MailRule( 44 | PaperlessModel, 45 | models.SecurableMixin, 46 | ): 47 | """Represent a Paperless `MailRule`.""" 48 | 49 | _api_path = API_PATH["mail_rules_single"] 50 | 51 | id: int | None = None 52 | name: str | None = None 53 | account: int | None = None 54 | folder: str | None = None 55 | filter_from: str | None = None 56 | filter_to: str | None = None 57 | filter_subject: str | None = None 58 | filter_body: str | None = None 59 | filter_attachment_filename_include: str | None = None 60 | filter_attachment_filename_exclude: str | None = None 61 | maximum_age: int | None = None 62 | action: int | None = None 63 | action_parameter: str | None = None 64 | assign_title_from: int | None = None 65 | assign_tags: list[int] | None = None 66 | assign_correspondent_from: int | None = None 67 | assign_correspondent: int | None = None 68 | assign_document_type: int | None = None 69 | assign_owner_from_rule: bool | None = None 70 | order: int | None = None 71 | attachment_type: int | None = None 72 | consumption_scope: int | None = None 73 | 74 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 75 | """Initialize a `MailRule` instance.""" 76 | super().__init__(api, data) 77 | 78 | self._api_path = self._api_path.format(pk=data.get("id")) 79 | 80 | 81 | class MailAccountHelper( 82 | HelperBase[MailAccount], 83 | helpers.CallableMixin[MailAccount], 84 | helpers.IterableMixin[MailAccount], 85 | helpers.SecurableMixin, 86 | ): 87 | """Represent a factory for Paperless `MailAccount` models.""" 88 | 89 | _api_path = API_PATH["mail_accounts"] 90 | _resource = PaperlessResource.MAIL_ACCOUNTS 91 | 92 | _resource_cls = MailAccount 93 | 94 | 95 | class MailRuleHelper( 96 | HelperBase[MailRule], 97 | helpers.CallableMixin[MailRule], 98 | helpers.IterableMixin[MailRule], 99 | helpers.SecurableMixin, 100 | ): 101 | """Represent a factory for Paperless `MailRule` models.""" 102 | 103 | _api_path = API_PATH["mail_rules"] 104 | _resource = PaperlessResource.MAIL_RULES 105 | 106 | _resource_cls = MailRule 107 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | """Mixins for PyPaperless models.""" 2 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | """Mixins for PyPaperless helpers.""" 2 | 3 | from .callable import CallableMixin 4 | from .draftable import DraftableMixin 5 | from .iterable import IterableMixin 6 | from .securable import SecurableMixin 7 | 8 | __all__ = ( 9 | "CallableMixin", 10 | "DraftableMixin", 11 | "IterableMixin", 12 | "SecurableMixin", 13 | ) 14 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/helpers/callable.py: -------------------------------------------------------------------------------- 1 | """CallableMixin for PyPaperless helpers.""" 2 | 3 | from pypaperless.models.base import HelperProtocol, ResourceT 4 | 5 | 6 | class CallableMixin(HelperProtocol[ResourceT]): 7 | """Provide methods for calling a specific resource item.""" 8 | 9 | async def __call__( 10 | self, 11 | pk: int, 12 | *, 13 | lazy: bool = False, 14 | ) -> ResourceT: 15 | """Request exactly one resource item. 16 | 17 | Example: 18 | ------- 19 | ```python 20 | # request a document 21 | document = await paperless.documents(42) 22 | 23 | # initialize a model and request it later 24 | document = await paperless.documents(42, lazy=True) 25 | ``` 26 | 27 | """ 28 | data = { 29 | "id": pk, 30 | } 31 | item = self._resource_cls.create_with_data(self._api, data) 32 | 33 | # set requesting full permissions 34 | if getattr(self, "_request_full_perms", False): 35 | item._params.update({"full_perms": "true"}) # noqa: SLF001 36 | 37 | if not lazy: 38 | await item.load() 39 | return item 40 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/helpers/draftable.py: -------------------------------------------------------------------------------- 1 | """DraftableMixin for PyPaperless helpers.""" 2 | 3 | from typing import Any 4 | 5 | from pypaperless.exceptions import DraftNotSupportedError 6 | from pypaperless.models.base import HelperProtocol, ResourceT 7 | 8 | 9 | class DraftableMixin(HelperProtocol[ResourceT]): 10 | """Provide the `draft` method for PyPaperless helpers.""" 11 | 12 | _draft_cls: type[ResourceT] 13 | 14 | def draft(self, **kwargs: Any) -> ResourceT: 15 | """Return a fresh and empty `PaperlessModel` instance. 16 | 17 | Example: 18 | ------- 19 | ```python 20 | draft = paperless.documents.draft(document=bytes(...), title="New Document") 21 | # do something 22 | ``` 23 | 24 | """ 25 | if not hasattr(self, "_draft_cls"): 26 | message = "Helper class has no _draft_cls attribute." 27 | raise DraftNotSupportedError(message) 28 | kwargs.update({"id": -1}) 29 | 30 | return self._draft_cls.create_with_data(self._api, data=kwargs, fetched=True) 31 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/helpers/iterable.py: -------------------------------------------------------------------------------- 1 | """IterableMixin for PyPaperless helpers.""" 2 | 3 | from collections.abc import AsyncGenerator, AsyncIterator 4 | from contextlib import asynccontextmanager 5 | from typing import TYPE_CHECKING, Any, Self 6 | 7 | from pypaperless.models.base import HelperProtocol, ResourceT 8 | from pypaperless.models.generators import PageGenerator 9 | 10 | if TYPE_CHECKING: 11 | from pypaperless.models import Page 12 | 13 | 14 | class IterableMixin(HelperProtocol[ResourceT]): 15 | """Provide methods for iterating over resource items.""" 16 | 17 | _aiter_filters: dict[str, str | int] | None 18 | 19 | async def __aiter__(self) -> AsyncIterator[ResourceT]: 20 | """Iterate over resource items. 21 | 22 | Example: 23 | ------- 24 | ```python 25 | async for item in paperless.documents: 26 | # do something 27 | ``` 28 | 29 | """ 30 | async for page in self.pages(): 31 | for item in page: 32 | yield item 33 | 34 | @asynccontextmanager 35 | async def reduce( 36 | self: Self, 37 | **kwargs: str | int, 38 | ) -> AsyncGenerator[Self, None]: 39 | """Provide context for iterating over resource items with query parameters. 40 | 41 | `kwargs`: Insert any Paperless api supported filter keywords here. 42 | You can provide `page` and `page_size` parameters, as well. 43 | 44 | Example: 45 | ------- 46 | ```python 47 | filters = { 48 | "page_size": 1337, 49 | "title__icontains": "2023", 50 | } 51 | 52 | async with paperless.documents.reduce(**filters): 53 | # iterate over resource items ... 54 | async for item in paperless.documents: 55 | ... 56 | 57 | # ... or iterate pages as-is 58 | async for page in paperless.documents.pages(): 59 | ... 60 | ``` 61 | 62 | """ 63 | self._aiter_filters = kwargs 64 | yield self 65 | self._aiter_filters = None 66 | 67 | async def all(self) -> list[int]: 68 | """Return a list of all resource item primary keys. 69 | 70 | When used within a `reduce` context, returns a list of filtered primary keys. 71 | """ 72 | page = await anext(self.pages(page=1)) 73 | return page.all 74 | 75 | async def as_dict(self) -> dict[int, ResourceT]: 76 | """Shortcut for returning a primary key/object dict of all resource items. 77 | 78 | When used within a `reduce` context, data is filtered. 79 | """ 80 | return {item.id: item async for item in self} # type: ignore[attr-defined] 81 | 82 | async def as_list(self) -> list[ResourceT]: 83 | """Shortcut for returning a list of all resource items. 84 | 85 | When used within a `reduce` context, data is filtered. 86 | """ 87 | return [item async for item in self] 88 | 89 | def pages( 90 | self, 91 | page: int = 1, 92 | page_size: int = 150, 93 | ) -> "AsyncIterator[Page[ResourceT]]": 94 | """Iterate over resource pages. 95 | 96 | `page`: A page number to start with. 97 | `page_size`: The page size for each requested batch. 98 | 99 | Example: 100 | ------- 101 | ```python 102 | async for item in paperless.documents.pages(): 103 | # do something 104 | ``` 105 | 106 | """ 107 | params: dict[str, Any] = getattr(self, "_aiter_filters", None) or {} 108 | 109 | for param, value in params.items(): 110 | if param.endswith("__in"): 111 | try: 112 | value.extend([]) # throw AttributeError if not a list 113 | params[param] = ",".join(map(str, value)) 114 | except AttributeError: 115 | # value is not a list, don't modify 116 | continue 117 | 118 | params.setdefault("page", page) 119 | params.setdefault("page_size", page_size) 120 | 121 | # set requesting full permissions 122 | if getattr(self, "_request_full_perms", False): 123 | params.update({"full_perms": "true"}) 124 | 125 | return PageGenerator(self._api, self._api_path, self._resource_cls, params=params) 126 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/helpers/securable.py: -------------------------------------------------------------------------------- 1 | """SecurableMixin for PyPaperless helpers.""" 2 | 3 | 4 | class SecurableMixin: 5 | """Provide the `request_full_permissions` property for PyPaperless helpers.""" 6 | 7 | _request_full_perms: bool = False 8 | 9 | @property 10 | def request_permissions(self) -> bool: 11 | """Return whether the helper requests items with the `permissions` table, or not. 12 | 13 | Documentation: https://docs.paperless-ngx.com/api/#permissions 14 | """ 15 | return self._request_full_perms 16 | 17 | @request_permissions.setter 18 | def request_permissions(self, value: bool) -> None: 19 | """Set whether the helper requests items with the `permissions` table, or not. 20 | 21 | Documentation: https://docs.paperless-ngx.com/api/#permissions 22 | """ 23 | self._request_full_perms = value 24 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Mixins for PyPaperless models.""" 2 | 3 | from .creatable import CreatableMixin 4 | from .data_fields import MatchingFieldsMixin 5 | from .deletable import DeletableMixin 6 | from .securable import SecurableDraftMixin, SecurableMixin 7 | from .updatable import UpdatableMixin 8 | 9 | __all__ = ( 10 | "CreatableMixin", 11 | "DeletableMixin", 12 | "MatchingFieldsMixin", 13 | "SecurableDraftMixin", 14 | "SecurableMixin", 15 | "UpdatableMixin", 16 | ) 17 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/models/creatable.py: -------------------------------------------------------------------------------- 1 | """CreatableMixin for PyPaperless models.""" 2 | 3 | from typing import Any, cast 4 | 5 | from pypaperless.exceptions import DraftFieldRequiredError 6 | from pypaperless.models.base import PaperlessModelProtocol 7 | from pypaperless.models.utils import object_to_dict_value 8 | 9 | 10 | class CreatableMixin(PaperlessModelProtocol): 11 | """Provide the `save` method for PyPaperless models.""" 12 | 13 | _create_required_fields: set[str] 14 | 15 | async def save(self) -> int | str | tuple[int, int]: 16 | """Create a new `resource item` in Paperless. 17 | 18 | Return the created item `id`, or a `task_id` in case of documents. 19 | 20 | Example: 21 | ------- 22 | ```python 23 | draft = paperless.documents.draft(document=bytes(...)) 24 | draft.title = "Add a title" 25 | 26 | # request Paperless to store the new item 27 | draft.save() 28 | ``` 29 | 30 | """ 31 | self.validate() 32 | kwdict = self._serialize() 33 | res = await self._api.request_json("post", self._api_path, **kwdict) 34 | 35 | if type(self).__name__ == "DocumentNoteDraft": 36 | return ( 37 | cast("int", max(item.get("id") for item in res)), 38 | cast("int", kwdict["json"]["document"]), 39 | ) 40 | if isinstance(res, dict): 41 | return int(res["id"]) 42 | return str(res) 43 | 44 | def _serialize(self) -> dict[str, Any]: 45 | """Serialize.""" 46 | data = { 47 | "json": { 48 | field.name: object_to_dict_value(getattr(self, field.name)) 49 | for field in self._get_dataclass_fields() 50 | }, 51 | } 52 | # check for empty permissions as they will raise if None 53 | if "set_permissions" in data["json"] and data["json"]["set_permissions"] is None: 54 | del data["json"]["set_permissions"] 55 | 56 | return data 57 | 58 | def validate(self) -> None: 59 | """Check required fields before persisting the item to Paperless.""" 60 | missing = [field for field in self._create_required_fields if getattr(self, field) is None] 61 | 62 | if len(missing) == 0: 63 | return 64 | 65 | message = f"Missing fields for saving a `{type(self).__name__}`: {', '.join(missing)}." 66 | raise DraftFieldRequiredError(message) 67 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/models/data_fields.py: -------------------------------------------------------------------------------- 1 | """PermissionFieldsMixin for PyPaperless models.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | from pypaperless.models.common import MatchingAlgorithmType 6 | 7 | 8 | @dataclass 9 | class MatchingFieldsMixin: 10 | """Provide shared matching fields for PyPaperless models.""" 11 | 12 | match: str | None = None 13 | matching_algorithm: MatchingAlgorithmType | None = None 14 | is_insensitive: bool | None = None 15 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/models/deletable.py: -------------------------------------------------------------------------------- 1 | """DeletableMixin for PyPaperless models.""" 2 | 3 | from pypaperless.models.base import PaperlessModelProtocol 4 | 5 | 6 | class DeletableMixin(PaperlessModelProtocol): 7 | """Provide the `delete` method for PyPaperless models.""" 8 | 9 | async def delete(self) -> bool: 10 | """Delete a `resource item` from DRF. There is no point of return. 11 | 12 | Return `True` when deletion was successful, `False` otherwise. 13 | 14 | Example: 15 | ------- 16 | ```python 17 | # request a document 18 | document = await paperless.documents(42) 19 | 20 | if await document.delete(): 21 | print("Successfully deleted the document!") 22 | ``` 23 | 24 | """ 25 | async with self._api.request("delete", self._api_path) as res: 26 | return res.status == 204 27 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/models/securable.py: -------------------------------------------------------------------------------- 1 | """SecurableMixin for PyPaperless models.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | from pypaperless.models.common import PermissionTableType 6 | 7 | 8 | @dataclass(kw_only=True) 9 | class SecurableMixin: 10 | """Provide permission fields for PyPaperless models.""" 11 | 12 | owner: int | None = None 13 | user_can_change: bool | None = None 14 | permissions: PermissionTableType | None = None 15 | 16 | @property 17 | def has_permissions(self) -> bool: 18 | """Return if the model data includes the permission field.""" 19 | return self.permissions is not None 20 | 21 | 22 | @dataclass(kw_only=True) 23 | class SecurableDraftMixin: 24 | """Provide permission fields for PyPaperless draft models.""" 25 | 26 | owner: int | None = None 27 | set_permissions: PermissionTableType | None = None 28 | -------------------------------------------------------------------------------- /pypaperless/models/mixins/models/updatable.py: -------------------------------------------------------------------------------- 1 | """UpdatableMixin for PyPaperless models.""" 2 | 3 | from copy import deepcopy 4 | from typing import Any 5 | 6 | from pypaperless.models.base import PaperlessModelProtocol 7 | from pypaperless.models.utils import object_to_dict_value 8 | 9 | from .securable import SecurableMixin 10 | 11 | 12 | class UpdatableMixin(PaperlessModelProtocol): 13 | """Provide the `update` method for PyPaperless models.""" 14 | 15 | _data: dict[str, Any] 16 | 17 | async def update(self, *, only_changed: bool = True) -> bool: 18 | """Send actually changed `model data` to DRF. 19 | 20 | Return `True` when any attribute was updated, `False` otherwise. 21 | 22 | Example: 23 | ------- 24 | ```python 25 | # request a document 26 | document = await paperless.documents(42) 27 | 28 | document.title = "New Title" 29 | if await document.update(): 30 | print("Successfully updated a field!") 31 | ``` 32 | 33 | """ 34 | updated = False 35 | 36 | if only_changed: 37 | updated = await self._patch_fields() 38 | else: 39 | updated = await self._put_fields() 40 | 41 | self._set_dataclass_fields() 42 | return updated 43 | 44 | def _check_permissions_field(self, data: dict) -> None: 45 | """Check.""" 46 | if SecurableMixin not in type(self).__bases__: 47 | return 48 | if not self.has_permissions: # type: ignore[attr-defined] 49 | return 50 | if "permissions" in data: 51 | data["set_permissions"] = deepcopy(data["permissions"]) 52 | del data["permissions"] 53 | 54 | async def _patch_fields(self) -> bool: 55 | """Use the http `PATCH` method for updating only changed fields.""" 56 | changed = {} 57 | for field in self._get_dataclass_fields(): 58 | new_value = object_to_dict_value(getattr(self, field.name)) 59 | 60 | if field.name in self._data and new_value != self._data[field.name]: 61 | changed[field.name] = new_value 62 | 63 | if len(changed) == 0: 64 | return False 65 | 66 | self._check_permissions_field(changed) 67 | 68 | self._data = await self._api.request_json( 69 | "patch", 70 | self._api_path, 71 | json=changed, 72 | params=self._params, 73 | ) 74 | return True 75 | 76 | async def _put_fields(self) -> bool: 77 | """Use the http `PUT` method to replace all fields.""" 78 | data = { 79 | field.name: object_to_dict_value(getattr(self, field.name)) 80 | for field in self._get_dataclass_fields() 81 | } 82 | 83 | self._check_permissions_field(data) 84 | 85 | self._data = await self._api.request_json( 86 | "put", 87 | self._api_path, 88 | json=data, 89 | params=self._params, 90 | ) 91 | return True 92 | -------------------------------------------------------------------------------- /pypaperless/models/pages.py: -------------------------------------------------------------------------------- 1 | """Provide the `Paginated` class.""" 2 | 3 | import math 4 | from collections.abc import Iterator 5 | from dataclasses import dataclass, field 6 | from typing import Any, Generic 7 | 8 | from pypaperless.const import API_PATH 9 | from pypaperless.models.base import ResourceT 10 | 11 | from .base import PaperlessModel 12 | 13 | 14 | @dataclass(init=False) 15 | class Page( 16 | PaperlessModel, 17 | Generic[ResourceT], 18 | ): 19 | """Represent a Paperless DRF `Paginated`.""" 20 | 21 | _api_path = API_PATH["index"] 22 | _resource_cls: type[ResourceT] 23 | 24 | # our fields 25 | current_page: int 26 | page_size: int 27 | 28 | # DRF fields 29 | count: int 30 | next: str | None = None 31 | previous: str | None = None 32 | all: list[int] = field(default_factory=list) 33 | results: list[dict[str, Any]] = field(default_factory=list) 34 | 35 | def __iter__(self) -> Iterator[ResourceT]: 36 | """Return iter of `.items`.""" 37 | return iter(self.items) 38 | 39 | @property 40 | def current_count(self) -> int: 41 | """Return the item count of the current page.""" 42 | return len(self.results) 43 | 44 | @property 45 | def has_next_page(self) -> bool: 46 | """Return whether there is a next page or not.""" 47 | return self.next_page is not None 48 | 49 | @property 50 | def has_previous_page(self) -> bool: 51 | """Return whether there is a previous page or not.""" 52 | return self.previous_page is not None 53 | 54 | @property 55 | def items(self) -> list[ResourceT]: 56 | """Return the results list field with mapped PyPaperless `models`. 57 | 58 | Example: 59 | ------- 60 | ```python 61 | async for page in paperless.documents.pages(): 62 | assert isinstance(page.results.pop(), Document) # fails, it is a dict 63 | assert isinstance(page.items.pop(), Document) # ok 64 | ``` 65 | 66 | """ 67 | 68 | def mapper(data: dict[str, Any]) -> ResourceT: 69 | return self._resource_cls.create_with_data(self._api, data, fetched=True) 70 | 71 | return list(map(mapper, self._data["results"])) 72 | 73 | @property 74 | def is_last_page(self) -> bool: 75 | """Return whether we are on the last page or not.""" 76 | return not self.has_next_page 77 | 78 | @property 79 | def last_page(self) -> int: 80 | """Return the last page number.""" 81 | return math.ceil(self.count / self.page_size) 82 | 83 | @property 84 | def next_page(self) -> int | None: 85 | """Return the next page number if a next page exists.""" 86 | if self.next is None: 87 | return None 88 | return self.current_page + 1 89 | 90 | @property 91 | def previous_page(self) -> int | None: 92 | """Return the previous page number if a previous page exists.""" 93 | if self.previous is None: 94 | return None 95 | return self.current_page - 1 96 | -------------------------------------------------------------------------------- /pypaperless/models/permissions.py: -------------------------------------------------------------------------------- 1 | """Provide `User` and 'Group' related models and helpers.""" 2 | 3 | import datetime 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from pypaperless.const import API_PATH, PaperlessResource 8 | 9 | from .base import HelperBase, PaperlessModel 10 | from .mixins import helpers 11 | 12 | if TYPE_CHECKING: 13 | from pypaperless import Paperless 14 | 15 | 16 | @dataclass(init=False) 17 | class Group(PaperlessModel): 18 | """Represent a Paperless `Group`.""" 19 | 20 | _api_path = API_PATH["groups_single"] 21 | 22 | id: int 23 | name: str | None = None 24 | permissions: list[str] | None = None 25 | 26 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 27 | """Initialize a `Group` instance.""" 28 | super().__init__(api, data) 29 | 30 | self._api_path = self._api_path.format(pk=data.get("id")) 31 | 32 | 33 | @dataclass(init=False) 34 | class User(PaperlessModel): 35 | """Represent a Paperless `User`.""" 36 | 37 | _api_path = API_PATH["users_single"] 38 | 39 | id: int 40 | username: str | None = None 41 | # exclude that from the dataclass 42 | # password: str | None = None # noqa: ERA001 43 | email: str | None = None 44 | first_name: str | None = None 45 | last_name: str | None = None 46 | date_joined: datetime.datetime | None = None 47 | is_staff: bool | None = None 48 | is_active: bool | None = None 49 | is_superuser: bool | None = None 50 | groups: list[int] | None = None 51 | user_permissions: list[str] | None = None 52 | inherited_permissions: list[str] | None = None 53 | 54 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 55 | """Initialize a `User` instance.""" 56 | super().__init__(api, data) 57 | 58 | self._api_path = self._api_path.format(pk=data.get("id")) 59 | 60 | 61 | class GroupHelper( 62 | HelperBase[Group], 63 | helpers.CallableMixin[Group], 64 | helpers.IterableMixin[Group], 65 | ): 66 | """Represent a factory for Paperless `Group` models.""" 67 | 68 | _api_path = API_PATH["groups"] 69 | _resource = PaperlessResource.GROUPS 70 | 71 | _resource_cls = Group 72 | 73 | 74 | class UserHelper( 75 | HelperBase[User], 76 | helpers.CallableMixin[User], 77 | helpers.IterableMixin[User], 78 | ): 79 | """Represent a factory for Paperless `User` models.""" 80 | 81 | _api_path = API_PATH["users"] 82 | _resource = PaperlessResource.USERS 83 | 84 | _resource_cls = User 85 | -------------------------------------------------------------------------------- /pypaperless/models/remote_version.py: -------------------------------------------------------------------------------- 1 | """Provide `Remote Version` related models and helpers.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | from pypaperless.const import API_PATH, PaperlessResource 6 | 7 | from .base import HelperBase, PaperlessModel 8 | 9 | 10 | @dataclass(init=False) 11 | class RemoteVersion( 12 | PaperlessModel, 13 | ): 14 | """Represent Paperless `Remote Version`.""" 15 | 16 | _api_path = API_PATH["remote_version"] 17 | 18 | version: str | None = None 19 | update_available: bool | None = None 20 | 21 | 22 | class RemoteVersionHelper(HelperBase[RemoteVersion]): 23 | """Represent a factory for Paperless `Remote Version` models.""" 24 | 25 | _api_path = API_PATH["remote_version"] 26 | _resource = PaperlessResource.REMOTE_VERSION 27 | 28 | _resource_cls = RemoteVersion 29 | 30 | async def __call__(self) -> RemoteVersion: 31 | """Request the `Remote Version` model data.""" 32 | res = await self._api.request_json("get", self._api_path) 33 | return self._resource_cls.create_with_data(self._api, res, fetched=True) 34 | -------------------------------------------------------------------------------- /pypaperless/models/saved_views.py: -------------------------------------------------------------------------------- 1 | """Provide `SavedView` related models and helpers.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from pypaperless.const import API_PATH, PaperlessResource 7 | 8 | from .base import HelperBase, PaperlessModel 9 | from .common import SavedViewFilterRuleType 10 | from .mixins import helpers, models 11 | 12 | if TYPE_CHECKING: 13 | from pypaperless import Paperless 14 | 15 | 16 | @dataclass(init=False) 17 | class SavedView( 18 | PaperlessModel, 19 | models.SecurableMixin, 20 | ): 21 | """Represent a Paperless `SavedView`.""" 22 | 23 | _api_path = API_PATH["saved_views_single"] 24 | 25 | id: int | None = None 26 | name: str | None = None 27 | show_on_dashboard: bool | None = None 28 | show_in_sidebar: bool | None = None 29 | sort_field: str | None = None 30 | sort_reverse: bool | None = None 31 | filter_rules: list[SavedViewFilterRuleType] | None = None 32 | 33 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 34 | """Initialize a `SavedView` instance.""" 35 | super().__init__(api, data) 36 | 37 | self._api_path = self._api_path.format(pk=data.get("id")) 38 | 39 | 40 | class SavedViewHelper( 41 | HelperBase[SavedView], 42 | helpers.CallableMixin[SavedView], 43 | helpers.IterableMixin[SavedView], 44 | helpers.SecurableMixin, 45 | ): 46 | """Represent a factory for Paperless `SavedView` models.""" 47 | 48 | _api_path = API_PATH["saved_views"] 49 | _resource = PaperlessResource.SAVED_VIEWS 50 | 51 | _resource_cls = SavedView 52 | -------------------------------------------------------------------------------- /pypaperless/models/share_links.py: -------------------------------------------------------------------------------- 1 | """Provide `ShareLink` related models and helpers.""" 2 | 3 | import datetime 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from pypaperless.const import API_PATH, PaperlessResource 8 | 9 | from .base import HelperBase, PaperlessModel 10 | from .common import ShareLinkFileVersionType 11 | from .mixins import helpers, models 12 | 13 | if TYPE_CHECKING: 14 | from pypaperless import Paperless 15 | 16 | 17 | @dataclass(init=False) 18 | class ShareLink( 19 | PaperlessModel, 20 | models.DeletableMixin, 21 | models.UpdatableMixin, 22 | ): 23 | """Represent a Paperless `ShareLink`.""" 24 | 25 | _api_path = API_PATH["share_links_single"] 26 | 27 | id: int 28 | created: datetime.datetime | None = None 29 | expiration: datetime.datetime | None = None 30 | slug: str | None = None 31 | document: int | None = None 32 | file_version: ShareLinkFileVersionType | None = None 33 | 34 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 35 | """Initialize a `ShareLink` instance.""" 36 | super().__init__(api, data) 37 | 38 | self._api_path = self._api_path.format(pk=data.get("id")) 39 | 40 | 41 | @dataclass(init=False) 42 | class ShareLinkDraft( 43 | PaperlessModel, 44 | models.CreatableMixin, 45 | ): 46 | """Represent a new Paperless `ShareLink`, which is not stored in Paperless.""" 47 | 48 | _api_path = API_PATH["share_links"] 49 | 50 | _create_required_fields = {"document", "file_version"} 51 | 52 | expiration: datetime.datetime | None = None 53 | document: int | None = None 54 | file_version: ShareLinkFileVersionType | None = None 55 | 56 | 57 | class ShareLinkHelper( 58 | HelperBase[ShareLink], 59 | helpers.CallableMixin[ShareLink], 60 | helpers.DraftableMixin[ShareLinkDraft], 61 | helpers.IterableMixin[ShareLink], 62 | ): 63 | """Represent a factory for Paperless `ShareLink` models.""" 64 | 65 | _api_path = API_PATH["share_links"] 66 | _resource = PaperlessResource.SHARE_LINKS 67 | 68 | _draft_cls = ShareLinkDraft 69 | _resource_cls = ShareLink 70 | -------------------------------------------------------------------------------- /pypaperless/models/statistics.py: -------------------------------------------------------------------------------- 1 | """Provide `Statistics` related models and helpers.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | from pypaperless.const import API_PATH, PaperlessResource 6 | 7 | from .base import HelperBase, PaperlessModel 8 | from .common import StatisticDocumentFileTypeCount 9 | 10 | 11 | @dataclass(init=False) 12 | class Statistic( 13 | PaperlessModel, 14 | ): 15 | """Represent Paperless `Statistic`.""" 16 | 17 | _api_path = API_PATH["statistics"] 18 | 19 | documents_total: int | None = None 20 | documents_inbox: int | None = None 21 | inbox_tag: int | None = None 22 | inbox_tags: list[int] | None = None 23 | document_file_type_counts: list[StatisticDocumentFileTypeCount] | None = None 24 | character_count: int | None = None 25 | tag_count: int | None = None 26 | correspondent_count: int | None = None 27 | document_type_count: int | None = None 28 | storage_path_count: int | None = None 29 | current_asn: int | None = None 30 | 31 | 32 | class StatisticHelper(HelperBase[Statistic]): 33 | """Represent a factory for Paperless `Statistic` models.""" 34 | 35 | _api_path = API_PATH["statistics"] 36 | _resource = PaperlessResource.STATISTICS 37 | 38 | _resource_cls = Statistic 39 | 40 | async def __call__(self) -> Statistic: 41 | """Request the `Statistic` model data.""" 42 | res = await self._api.request_json("get", self._api_path) 43 | return self._resource_cls.create_with_data(self._api, res, fetched=True) 44 | -------------------------------------------------------------------------------- /pypaperless/models/status.py: -------------------------------------------------------------------------------- 1 | """Provide `Status` related models and helpers.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import cast 5 | 6 | from pypaperless.const import API_PATH, PaperlessResource 7 | from pypaperless.models.common import ( 8 | StatusDatabaseType, 9 | StatusStorageType, 10 | StatusTasksType, 11 | StatusType, 12 | ) 13 | 14 | from .base import HelperBase, PaperlessModel 15 | 16 | 17 | @dataclass(init=False) 18 | class Status(PaperlessModel): 19 | """Represent a Paperless `Status`.""" 20 | 21 | _api_path = API_PATH["status"] 22 | 23 | pngx_version: str | None = None 24 | server_os: str | None = None 25 | install_type: str | None = None 26 | storage: StatusStorageType | None = None 27 | database: StatusDatabaseType | None = None 28 | tasks: StatusTasksType | None = None 29 | 30 | @property 31 | def has_errors(self) -> bool: 32 | """Return whether any status flag is `ERROR`.""" 33 | statuses: list[StatusType] = [ 34 | self.database.status if self.database and self.database.status else StatusType.OK, 35 | *[ 36 | cast("StatusType", getattr(self.tasks, status, StatusType.OK)) 37 | for status in ( 38 | "redis_status", 39 | "celery_status", 40 | "classifier_status", 41 | ) 42 | if self.tasks 43 | ], 44 | ] 45 | 46 | return any(st == StatusType.ERROR for st in statuses) 47 | 48 | 49 | class StatusHelper(HelperBase[Status]): 50 | """Represent a factory for the Paperless `Status` model.""" 51 | 52 | _api_path = API_PATH["status"] 53 | _resource = PaperlessResource.STATUS 54 | _resource_public = False 55 | 56 | _resource_cls = Status 57 | 58 | async def __call__(self) -> Status: 59 | """Request the `Status` model data.""" 60 | res = await self._api.request_json("get", self._api_path) 61 | return self._resource_cls.create_with_data(self._api, res, fetched=True) 62 | -------------------------------------------------------------------------------- /pypaperless/models/tasks.py: -------------------------------------------------------------------------------- 1 | """Provide `Task` related models and helpers.""" 2 | 3 | from collections.abc import AsyncIterator 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from pypaperless.const import API_PATH, PaperlessResource 8 | from pypaperless.exceptions import TaskNotFoundError 9 | 10 | from .base import HelperBase, PaperlessModel 11 | from .common import TaskStatusType 12 | 13 | if TYPE_CHECKING: 14 | from pypaperless import Paperless 15 | 16 | 17 | @dataclass(init=False) 18 | class Task( 19 | PaperlessModel, 20 | ): 21 | """Represent a Paperless `Task`.""" 22 | 23 | _api_path = API_PATH["tasks_single"] 24 | 25 | id: int | None = None 26 | task_id: str | None = None 27 | task_file_name: str | None = None 28 | date_created: str | None = None 29 | date_done: str | None = None 30 | type: str | None = None 31 | status: TaskStatusType | None = None 32 | result: str | None = None 33 | acknowledged: bool | None = None 34 | related_document: int | None = None 35 | 36 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 37 | """Initialize a `Task` instance.""" 38 | super().__init__(api, data) 39 | 40 | self._api_path = self._api_path.format(pk=data.get("id")) 41 | 42 | 43 | class TaskHelper( 44 | HelperBase[Task], 45 | ): 46 | """Represent a factory for Paperless `Task` models.""" 47 | 48 | _api_path = API_PATH["tasks"] 49 | _resource = PaperlessResource.TASKS 50 | 51 | _resource_cls = Task 52 | 53 | async def __aiter__(self) -> AsyncIterator[Task]: 54 | """Iterate over task items. 55 | 56 | Example: 57 | ------- 58 | ```python 59 | async for task in paperless.tasks: 60 | # do something 61 | ``` 62 | 63 | """ 64 | res = await self._api.request_json("get", self._api_path) 65 | for data in res: 66 | yield self._resource_cls.create_with_data(self._api, data, fetched=True) 67 | 68 | async def __call__(self, task_id: int | str) -> Task: 69 | """Request exactly one task by id. 70 | 71 | If task_id is `str`: interpret it as a task uuid. 72 | If task_id is `int`: interpret it as a primary key. 73 | 74 | Example: 75 | ------- 76 | ```python 77 | task = await paperless.tasks("uuid-string") 78 | task = await paperless.tasks(1337) 79 | ``` 80 | 81 | """ 82 | if isinstance(task_id, str): 83 | params = { 84 | "task_id": task_id, 85 | } 86 | res = await self._api.request_json("get", self._api_path, params=params) 87 | try: 88 | item = self._resource_cls.create_with_data(self._api, res.pop(), fetched=True) 89 | except IndexError as exc: 90 | raise TaskNotFoundError(task_id) from exc 91 | else: 92 | data = { 93 | "id": task_id, 94 | } 95 | item = self._resource_cls.create_with_data(self._api, data) 96 | await item.load() 97 | 98 | return item 99 | -------------------------------------------------------------------------------- /pypaperless/models/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """Utils for pypaperless models. 2 | 3 | Since there are common use-cases in transforming dicts to dataclass et vice-versa, 4 | we borrowed some snippets from aiohue instead of re-inventing the wheel. 5 | 6 | pypaperless is meant to be the api library for a Home Assistant integration, 7 | so it should be okay I think. 8 | 9 | https://github.com/home-assistant-libs/aiohue/ 10 | 11 | Thanks for the excellent work, guys! 12 | """ 13 | 14 | # mypy: ignore-errors 15 | # pylint: disable=all 16 | 17 | import logging 18 | from dataclasses import MISSING, asdict, fields, is_dataclass 19 | from datetime import date, datetime 20 | from enum import Enum 21 | from types import NoneType, UnionType 22 | from typing import TYPE_CHECKING, Any, Union, get_args, get_origin, get_type_hints 23 | 24 | import pypaperless.models.base as paperless_base 25 | 26 | if TYPE_CHECKING: 27 | from pypaperless import Paperless 28 | 29 | 30 | def _str_to_datetime(datetimestr: str) -> datetime: 31 | """Parse datetime from string.""" 32 | return datetime.fromisoformat(datetimestr.replace("Z", "+00:00")) 33 | 34 | 35 | def _str_to_date(datestr: str) -> date: 36 | """Parse date from string.""" 37 | return date.fromisoformat(datestr) 38 | 39 | 40 | def _dateobj_to_str(value: date | datetime) -> str: 41 | """Parse string from date objects.""" 42 | return value.isoformat() 43 | 44 | 45 | def _is_typeddict(cls: type) -> bool: 46 | """Check whether a type is a `TypedDict` or not.""" 47 | return ( 48 | isinstance(cls, type) 49 | and issubclass(cls, dict) 50 | and hasattr(cls, "__annotations__") 51 | and getattr(cls, "__total__", None) is not None 52 | ) 53 | 54 | 55 | def object_to_dict_value(value: Any) -> Any: 56 | """Convert object values to their correspondending json values.""" 57 | if isinstance(value, dict): 58 | return {k: object_to_dict_value(v) for k, v in value.items()} 59 | if isinstance(value, list): 60 | return [object_to_dict_value(item) for item in value] 61 | if isinstance(value, Enum): 62 | return value.value 63 | if isinstance(value, (date, datetime)): 64 | return _dateobj_to_str(value) 65 | if isinstance(value, paperless_base.PaperlessModelData): 66 | return value.serialize() 67 | if is_dataclass(value): 68 | return object_to_dict_value(asdict(value)) 69 | 70 | return value 71 | 72 | 73 | def dict_value_to_object( # noqa: C901, PLR0915 74 | name: str, 75 | value: Any, 76 | value_type: Any, 77 | default: Any = MISSING, 78 | _api: "Paperless | None" = None, 79 | ) -> Any: 80 | """Try to parse a value from raw (json) data and type annotations. 81 | 82 | Since there are common use-cases in transforming dicts to dataclass et vice-versa, 83 | we borrowed some snippets from aiohue instead of re-inventing the wheel. 84 | 85 | pypaperless is meant to be the api library for a Home Assistant integration, 86 | so it should be okay I think. 87 | 88 | https://github.com/home-assistant-libs/aiohue/ 89 | """ 90 | # pypaperless addition 91 | try: 92 | is_paperless_model = _api is not None and issubclass( 93 | value_type, 94 | paperless_base.PaperlessModel, 95 | ) 96 | except TypeError: 97 | # happens if value_type is not a class 98 | is_paperless_model = False 99 | 100 | try: 101 | is_paperless_data = _api is not None and issubclass( 102 | value_type, 103 | paperless_base.PaperlessModelData, 104 | ) 105 | except TypeError: 106 | # happens if value_type is not a class 107 | is_paperless_data = False 108 | 109 | # ruff: noqa: PLR0911, PLR0912 110 | if isinstance(value_type, str): 111 | # this shouldn't happen, but just in case 112 | value_type = get_type_hints(value_type, globals(), locals()) 113 | 114 | if is_paperless_data: 115 | # create class instance if its custom data 116 | return value_type.unserialize(api=_api, data=value) 117 | 118 | if isinstance(value, dict): 119 | # always prefer classes that have a from_dict 120 | if hasattr(value_type, "from_dict"): 121 | return value_type.from_dict(value) 122 | # pypaperless addition for typeddicts 123 | if _is_typeddict(value_type): 124 | return value 125 | 126 | if value is None and not isinstance(default, type(MISSING)): 127 | return default 128 | if value is None and value_type is NoneType: 129 | return None 130 | if is_dataclass(value_type) and isinstance(value, dict): 131 | if is_paperless_model: 132 | return value_type.create_with_data(api=_api, data=value, fetched=True) 133 | return value_type( 134 | **{ 135 | field.name: dict_value_to_object( 136 | f"{value_type.__name__}.{field.name}", 137 | value.get(field.name), 138 | field.type, 139 | field.default, 140 | _api, 141 | ) 142 | for field in fields(value_type) 143 | } 144 | ) 145 | # get origin value type and inspect one-by-one 146 | origin: Any = get_origin(value_type) 147 | if origin in (list, tuple, set) and isinstance(value, list | tuple | set): 148 | return origin( 149 | dict_value_to_object(name, subvalue, get_args(value_type)[0], _api=_api) 150 | for subvalue in value 151 | if subvalue is not None 152 | ) 153 | 154 | # handle dictionary where we should inspect all values 155 | if origin is dict: 156 | subkey_type = get_args(value_type)[0] 157 | subvalue_type = get_args(value_type)[1] 158 | return { 159 | dict_value_to_object(subkey, subkey, subkey_type, _api=_api): dict_value_to_object( 160 | f"{subkey}.value", subvalue, subvalue_type, _api=_api 161 | ) 162 | for subkey, subvalue in value.items() 163 | } 164 | # handle Union type 165 | if origin is Union or origin is UnionType: 166 | # try all possible types 167 | sub_value_types = get_args(value_type) 168 | for sub_arg_type in sub_value_types: 169 | if value is NoneType and sub_arg_type is NoneType: 170 | return value 171 | if value == {} and sub_arg_type is NoneType: 172 | # handle case where optional value is received as empty dict from api 173 | return None 174 | # try them all until one succeeds 175 | try: 176 | return dict_value_to_object(name, value, sub_arg_type, _api=_api) 177 | except (KeyError, TypeError, ValueError): 178 | pass 179 | # if we get to this point, all possibilities failed 180 | # find out if we should raise or log this 181 | err = ( 182 | f"Value {value} of type {type(value)} is invalid for {name}, " 183 | f"expected value of type {value_type}" 184 | ) 185 | if NoneType not in sub_value_types: 186 | # raise exception, we have no idea how to handle this value 187 | raise TypeError(err) 188 | # failed to parse the (sub) value but None allowed, log only 189 | logging.getLogger(__name__).warning(err) 190 | return None 191 | if origin is type: 192 | return get_type_hints(value, globals(), locals()) 193 | # handle Any as value type (which is basically unprocessable) 194 | if value_type is Any: 195 | return value 196 | # raise if value is None and the value is required according to annotations 197 | if value is None and value_type is not NoneType: 198 | message = f"`{name}` of type `{value_type}` is required." 199 | raise KeyError(message) 200 | 201 | try: 202 | if issubclass(value_type, Enum): 203 | return value_type(value) 204 | if issubclass(value_type, datetime): 205 | return _str_to_datetime(value) 206 | if issubclass(value_type, date): 207 | return _str_to_date(value) 208 | except TypeError: 209 | # happens if value_type is not a class 210 | pass 211 | 212 | # common type conversions (e.g. int as string) 213 | if value_type is float and isinstance(value, int): 214 | return float(value) 215 | if value_type is int and isinstance(value, str) and value.isnumeric(): 216 | return int(value) 217 | 218 | # If we reach this point, we could not match the value with the type and we raise 219 | if not isinstance(value, value_type): 220 | message = f"Value {value} of type {type(value)} is invalid for {name}, \ 221 | expected value of type {value_type}" 222 | raise TypeError(message) 223 | 224 | return value 225 | -------------------------------------------------------------------------------- /pypaperless/models/workflows.py: -------------------------------------------------------------------------------- 1 | """Provide `Workflow` related models and helpers.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from pypaperless.const import API_PATH, PaperlessResource 7 | 8 | from .base import HelperBase, PaperlessModel 9 | from .common import WorkflowActionType, WorkflowTriggerSourceType, WorkflowTriggerType 10 | from .mixins import helpers, models 11 | 12 | if TYPE_CHECKING: 13 | from pypaperless import Paperless 14 | 15 | 16 | @dataclass(init=False) 17 | class WorkflowAction(PaperlessModel): 18 | """Represent a Paperless `WorkflowAction`.""" 19 | 20 | _api_path = API_PATH["workflow_actions_single"] 21 | 22 | id: int | None = None 23 | type: WorkflowActionType | None = None 24 | assign_title: str | None = None 25 | assign_tags: list[int] | None = None 26 | assign_correspondent: int | None = None 27 | assign_document_type: int | None = None 28 | assign_storage_path: int | None = None 29 | assign_view_users: list[int] | None = None 30 | assign_view_groups: list[int] | None = None 31 | assign_change_users: list[int] | None = None 32 | assign_change_groups: list[int] | None = None 33 | assign_custom_fields: list[int] | None = None 34 | 35 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 36 | """Initialize a `Workflow` instance.""" 37 | super().__init__(api, data) 38 | 39 | self._api_path = self._api_path.format(pk=data.get("id")) 40 | 41 | 42 | @dataclass(init=False) 43 | class WorkflowTrigger( 44 | PaperlessModel, 45 | models.MatchingFieldsMixin, 46 | ): 47 | """Represent a Paperless `WorkflowTrigger`.""" 48 | 49 | _api_path = API_PATH["workflow_triggers_single"] 50 | 51 | id: int | None = None 52 | sources: list[WorkflowTriggerSourceType] | None = None 53 | type: WorkflowTriggerType | None = None 54 | filter_path: str | None = None 55 | filter_filename: str | None = None 56 | filter_mailrule: int | None = None 57 | filter_has_tags: list[int] | None = None 58 | filter_has_correspondent: int | None = None 59 | filter_has_document_type: int | None = None 60 | 61 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 62 | """Initialize a `Workflow` instance.""" 63 | super().__init__(api, data) 64 | 65 | self._api_path = self._api_path.format(pk=data.get("id")) 66 | 67 | 68 | @dataclass(init=False) 69 | class Workflow(PaperlessModel): 70 | """Represent a Paperless `Workflow`.""" 71 | 72 | _api_path = API_PATH["workflows_single"] 73 | 74 | id: int | None = None 75 | name: str | None = None 76 | order: int | None = None 77 | enabled: bool | None = None 78 | actions: list[WorkflowAction] | None = None 79 | triggers: list[WorkflowTrigger] | None = None 80 | 81 | def __init__(self, api: "Paperless", data: dict[str, Any]) -> None: 82 | """Initialize a `Workflow` instance.""" 83 | super().__init__(api, data) 84 | 85 | self._api_path = self._api_path.format(pk=data.get("id")) 86 | 87 | 88 | class WorkflowActionHelper( 89 | HelperBase[WorkflowAction], 90 | helpers.CallableMixin[WorkflowAction], 91 | helpers.IterableMixin[WorkflowAction], 92 | ): 93 | """Represent a factory for Paperless `WorkflowAction` models.""" 94 | 95 | _api_path = API_PATH["workflow_actions"] 96 | _resource = PaperlessResource.WORKFLOW_ACTIONS 97 | 98 | _resource_cls = WorkflowAction 99 | 100 | 101 | class WorkflowTriggerHelper( 102 | HelperBase[WorkflowTrigger], 103 | helpers.CallableMixin[WorkflowTrigger], 104 | helpers.IterableMixin[WorkflowTrigger], 105 | ): 106 | """Represent a factory for Paperless `WorkflowTrigger` models.""" 107 | 108 | _api_path = API_PATH["workflow_triggers"] 109 | _resource = PaperlessResource.WORKFLOW_TRIGGERS 110 | 111 | _resource_cls = WorkflowTrigger 112 | 113 | 114 | class WorkflowHelper( 115 | HelperBase[Workflow], 116 | helpers.CallableMixin[Workflow], 117 | helpers.IterableMixin[Workflow], 118 | ): 119 | """Represent a factory for Paperless `Workflow` models.""" 120 | 121 | _api_path = API_PATH["workflows"] 122 | _resource = PaperlessResource.WORKFLOWS 123 | 124 | _resource_cls = Workflow 125 | 126 | def __init__(self, api: "Paperless") -> None: 127 | """Initialize a `WorkflowHelper` instance.""" 128 | super().__init__(api) 129 | 130 | self._actions = WorkflowActionHelper(api) 131 | self._triggers = WorkflowTriggerHelper(api) 132 | 133 | @property 134 | def actions(self) -> WorkflowActionHelper: 135 | """Return the attached `WorkflowActionHelper` instance. 136 | 137 | Example: 138 | ------- 139 | ```python 140 | wf_action = await paperless.workflows.actions(5) 141 | ``` 142 | 143 | """ 144 | return self._actions 145 | 146 | @property 147 | def triggers(self) -> WorkflowTriggerHelper: 148 | """Return the attached `WorkflowTriggerHelper` instance. 149 | 150 | Example: 151 | ------- 152 | ```python 153 | wf_trigger = await paperless.workflows.triggers(23) 154 | ``` 155 | 156 | """ 157 | return self._triggers 158 | -------------------------------------------------------------------------------- /pypaperless/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tb1337/paperless-api/3e784c4fb6c6b74fdf5bf09a7ebe04b73b7da03e/pypaperless/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "pypaperless" 7 | version = "0.0.0" 8 | license = {text = "MIT"} 9 | description = "Little api client for paperless(-ngx)." 10 | readme = "README.md" 11 | authors = [ 12 | {name = "Tobias Schulz", email = "public.dev@tbsch.de"} 13 | ] 14 | keywords = ["library", "async", "api-client", "python3", "paperless-ngx"] 15 | classifiers = [ 16 | "Development Status :: 5 - Production/Stable", 17 | "Framework :: AsyncIO", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Topic :: Software Development :: Libraries :: Python Modules" 23 | ] 24 | requires-python = ">=3.12.0" 25 | dependencies = [ 26 | "aiohttp>=3.11.16", 27 | "yarl>=1.20.0", 28 | ] 29 | 30 | [dependency-groups] 31 | dev = [ 32 | "aioresponses>=0.7.7", 33 | "codespell>=2.3.0", 34 | "covdefaults>=2.3.0", 35 | "coverage>=7.6.10", 36 | "mypy>=1.14.1", 37 | "pre-commit>=4.0.1", 38 | "pre-commit-hooks>=5.0.0", 39 | "pylint>=3.3.3", 40 | "pytest>=8.3.4", 41 | "pytest-aiohttp>=1.0.5", 42 | "pytest-asyncio>=0.25.1", 43 | "pytest-cov>=6.0.0", 44 | "ruff>=0.8.5", 45 | "yamllint>=1.35.1", 46 | ] 47 | 48 | [project.urls] 49 | "Homepage" = "https://github.com/tb1337/paperless-api" 50 | "Source Code" = "https://github.com/tb1337/paperless-api" 51 | "Bug Reports" = "https://github.com/tb1337/paperless-api/issues" 52 | "Coverage" = "https://codecov.io/gh/tb1337/paperless-api" 53 | 54 | [tool.coverage.run] 55 | plugins = ["covdefaults"] 56 | source = ["pypaperless"] 57 | 58 | [tool.coverage.report] 59 | fail_under = 95 60 | show_missing = true 61 | 62 | [tool.mypy] 63 | platform = "linux" 64 | python_version = "3.12" 65 | 66 | follow_imports = "normal" 67 | ignore_missing_imports = true 68 | 69 | check_untyped_defs = true 70 | disallow_any_generics = false 71 | disallow_incomplete_defs = true 72 | disallow_subclassing_any = true 73 | disallow_untyped_calls = true 74 | disallow_untyped_decorators = true 75 | disallow_untyped_defs = true 76 | no_implicit_optional = true 77 | show_error_codes = true 78 | warn_incomplete_stub = true 79 | warn_no_return = true 80 | warn_redundant_casts = true 81 | warn_return_any = true 82 | warn_unreachable = true 83 | warn_unused_configs = true 84 | warn_unused_ignores = true 85 | 86 | [tool.pylint.MASTER] 87 | ignore = [ 88 | "tests/", 89 | ] 90 | 91 | [tool.pylint.BASIC] 92 | good-names = [ 93 | "_", 94 | "ex", 95 | "fp", 96 | "i", 97 | "id", 98 | "j", 99 | "k", 100 | "on", 101 | "Run", 102 | "T", 103 | "wv", 104 | ] 105 | 106 | [tool.pylint."MESSAGES CONTROL"] 107 | disable = [ 108 | "duplicate-code", 109 | "no-name-in-module", # currently throws 110 | "too-few-public-methods", 111 | "too-many-ancestors", 112 | "too-many-arguments", 113 | "too-many-instance-attributes", 114 | "too-many-public-methods", 115 | ] 116 | 117 | [tool.pylint.SIMILARITIES] 118 | ignore-imports = true 119 | 120 | [tool.pylint.FORMAT] 121 | max-line-length = 100 122 | 123 | [tool.pylint.DESIGN] 124 | max-attributes = 20 125 | 126 | [tool.pytest.ini_options] 127 | addopts = "--cov --cov-report=term --cov-report=xml" 128 | asyncio_mode = "auto" 129 | asyncio_default_fixture_loop_scope = "session" 130 | 131 | [tool.ruff] 132 | line-length = 100 133 | target-version = "py312" 134 | 135 | [tool.ruff.lint] 136 | ignore = [ 137 | "ANN401", # Opinioated warning on disallowing dynamically typed expressions 138 | "D203", # Conflicts with other rules 139 | "D213", # Conflicts with other rules 140 | "D417", # False positives in some occasions 141 | "PLR2004", # Just annoying, not really useful 142 | "RUF012", # Just annoying 143 | 144 | # Conflicts with the Ruff formatter 145 | "COM812", 146 | "ISC001", 147 | ] 148 | select = ["ALL"] 149 | 150 | [tool.ruff.lint.flake8-pytest-style] 151 | fixture-parentheses = false 152 | mark-parentheses = false 153 | 154 | [tool.ruff.lint.isort] 155 | known-first-party = ["pypaperless"] 156 | 157 | [tool.ruff.lint.mccabe] 158 | max-complexity = 25 159 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Resolve all dependencies that the application requires to run. 3 | 4 | # Stop on errors 5 | set -e 6 | 7 | cd "$(dirname "$0")/.." 8 | 9 | uv venv $VIRTUAL_ENV 10 | 11 | echo "Installing development dependencies..." 12 | uv sync --group dev 13 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Setups the repository. 3 | 4 | # Stop on errors 5 | set -e 6 | 7 | cd "$(dirname "$0")/.." 8 | 9 | # Add default vscode settings if not existing 10 | SETTINGS_FILE=./.vscode/settings.json 11 | SETTINGS_TEMPLATE_FILE=./.vscode/settings.default.json 12 | if [ ! -f "$SETTINGS_FILE" ]; then 13 | echo "Copy $SETTINGS_TEMPLATE_FILE to $SETTINGS_FILE." 14 | cp "$SETTINGS_TEMPLATE_FILE" "$SETTINGS_FILE" 15 | fi 16 | 17 | if [ ! -n "$VIRTUAL_ENV" ]; then 18 | if [ -x "$(command -v uv)" ]; then 19 | uv venv .venv 20 | else 21 | python3 -m venv .venv 22 | fi 23 | source .venv/bin/activate 24 | fi 25 | 26 | if ! [ -x "$(command -v uv)" ]; then 27 | python3 -m pip install uv 28 | fi 29 | 30 | script/bootstrap 31 | 32 | pre-commit install 33 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for pypaperless.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Any 5 | 6 | from pypaperless import helpers, models 7 | from pypaperless.const import PaperlessResource 8 | from pypaperless.models import common 9 | 10 | # mypy: ignore-errors 11 | 12 | 13 | @dataclass 14 | class ResourceTestMapping: 15 | """Mapping for test cases.""" 16 | 17 | resource: str 18 | helper_cls: type 19 | model_cls: type 20 | draft_cls: type | None = None 21 | draft_defaults: dict[str, Any] | None = None 22 | 23 | 24 | CONFIG_MAP = ResourceTestMapping( 25 | PaperlessResource.CONFIG, 26 | helpers.ConfigHelper, 27 | models.Config, 28 | ) 29 | 30 | CORRESPONDENT_MAP = ResourceTestMapping( 31 | PaperlessResource.CORRESPONDENTS, 32 | helpers.CorrespondentHelper, 33 | models.Correspondent, 34 | models.CorrespondentDraft, 35 | { 36 | "name": "New Correspondent", 37 | "match": "", 38 | "matching_algorithm": common.MatchingAlgorithmType.ANY, 39 | "is_insensitive": True, 40 | }, 41 | ) 42 | 43 | CUSTOM_FIELD_MAP = ResourceTestMapping( 44 | PaperlessResource.CUSTOM_FIELDS, 45 | helpers.CustomFieldHelper, 46 | models.CustomField, 47 | models.CustomFieldDraft, 48 | { 49 | "name": "New Custom Field", 50 | "data_type": common.CustomFieldType.BOOLEAN, 51 | }, 52 | ) 53 | 54 | DOCUMENT_MAP = ResourceTestMapping( 55 | PaperlessResource.DOCUMENTS, 56 | helpers.DocumentHelper, 57 | models.Document, 58 | models.DocumentDraft, 59 | { 60 | "document": b"...example...content...", 61 | "tags": [1, 2, 3], 62 | "correspondent": 1, 63 | "document_type": 1, 64 | "storage_path": 1, 65 | "title": "New Document", 66 | "created": None, 67 | "archive_serial_number": 1, 68 | }, 69 | ) 70 | 71 | DOCUMENT_TYPE_MAP = ResourceTestMapping( 72 | PaperlessResource.DOCUMENT_TYPES, 73 | helpers.DocumentTypeHelper, 74 | models.DocumentType, 75 | models.DocumentTypeDraft, 76 | { 77 | "name": "New Document Type", 78 | "match": "", 79 | "matching_algorithm": common.MatchingAlgorithmType.ANY, 80 | "is_insensitive": True, 81 | }, 82 | ) 83 | 84 | GROUP_MAP = ResourceTestMapping( 85 | PaperlessResource.GROUPS, 86 | helpers.GroupHelper, 87 | models.Group, 88 | ) 89 | 90 | MAIL_ACCOUNT_MAP = ResourceTestMapping( 91 | PaperlessResource.MAIL_ACCOUNTS, 92 | helpers.MailAccountHelper, 93 | models.MailAccount, 94 | ) 95 | 96 | MAIL_RULE_MAP = ResourceTestMapping( 97 | PaperlessResource.MAIL_RULES, 98 | helpers.MailRuleHelper, 99 | models.MailRule, 100 | ) 101 | 102 | SAVED_VIEW_MAP = ResourceTestMapping( 103 | PaperlessResource.SAVED_VIEWS, 104 | helpers.SavedViewHelper, 105 | models.SavedView, 106 | ) 107 | 108 | SHARE_LINK_MAP = ResourceTestMapping( 109 | PaperlessResource.SHARE_LINKS, 110 | helpers.ShareLinkHelper, 111 | models.ShareLink, 112 | models.ShareLinkDraft, 113 | { 114 | "expiration": None, 115 | "document": 1, 116 | "file_version": common.ShareLinkFileVersionType.ORIGINAL, 117 | }, 118 | ) 119 | 120 | STATUS_MAP = ResourceTestMapping( 121 | PaperlessResource.STATUS, 122 | helpers.StatusHelper, 123 | models.Status, 124 | ) 125 | 126 | STORAGE_PATH_MAP = ResourceTestMapping( 127 | PaperlessResource.STORAGE_PATHS, 128 | helpers.StoragePathHelper, 129 | models.StoragePath, 130 | models.StoragePathDraft, 131 | { 132 | "name": "New Storage Path", 133 | "path": "path/to/test", 134 | "match": "", 135 | "matching_algorithm": common.MatchingAlgorithmType.ANY, 136 | "is_insensitive": True, 137 | }, 138 | ) 139 | 140 | TAG_MAP = ResourceTestMapping( 141 | PaperlessResource.TAGS, 142 | helpers.TagHelper, 143 | models.Tag, 144 | models.TagDraft, 145 | { 146 | "name": "New Tag", 147 | "color": "#012345", 148 | "text_color": "#987654", 149 | "is_inbox_tag": False, 150 | "match": "", 151 | "matching_algorithm": common.MatchingAlgorithmType.ANY, 152 | "is_insensitive": True, 153 | }, 154 | ) 155 | 156 | TASK_MAP = ResourceTestMapping( 157 | PaperlessResource.TASKS, 158 | helpers.TaskHelper, 159 | models.Task, 160 | ) 161 | 162 | USER_MAP = ResourceTestMapping( 163 | PaperlessResource.USERS, 164 | helpers.UserHelper, 165 | models.User, 166 | ) 167 | 168 | WORKFLOW_MAP = ResourceTestMapping( 169 | PaperlessResource.WORKFLOWS, 170 | helpers.WorkflowHelper, 171 | models.Workflow, 172 | ) 173 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Setup pytest.""" 2 | 3 | from collections.abc import AsyncGenerator, Generator 4 | from typing import Any 5 | 6 | import pytest 7 | from aioresponses import aioresponses 8 | 9 | from pypaperless import Paperless 10 | from pypaperless.const import API_PATH 11 | 12 | from .const import PAPERLESS_TEST_REQ_ARGS, PAPERLESS_TEST_TOKEN, PAPERLESS_TEST_URL 13 | from .data import PATCHWORK 14 | 15 | # mypy: ignore-errors 16 | 17 | 18 | @pytest.fixture(name="resp") 19 | def aioresponses_fixture() -> Generator[aioresponses, None, None]: 20 | """Return aioresponses fixture.""" 21 | with aioresponses() as m: 22 | yield m 23 | 24 | 25 | @pytest.fixture(name="api_latest") 26 | async def api_version_latest_fixture( 27 | api_215: Paperless, 28 | ) -> AsyncGenerator[Paperless, Any]: 29 | """Return a Paperless object with latest version.""" 30 | return api_215 31 | 32 | 33 | @pytest.fixture(name="api") 34 | def api_obj_fixture() -> Paperless: 35 | """Return Paperless.""" 36 | return Paperless( 37 | PAPERLESS_TEST_URL, 38 | PAPERLESS_TEST_TOKEN, 39 | request_args=PAPERLESS_TEST_REQ_ARGS, 40 | ) 41 | 42 | 43 | @pytest.fixture(name="api_00") 44 | async def api_version_00_fixture( 45 | resp: aioresponses, 46 | api: Paperless, 47 | ) -> AsyncGenerator[Paperless, Any]: 48 | """Return a basic Paperless object.""" 49 | resp.get( 50 | f"{PAPERLESS_TEST_URL}{API_PATH['api_schema']}", 51 | status=500, 52 | headers={"X-Version": "0.0.0"}, 53 | payload=PATCHWORK["paths_v0_0_0"], 54 | ) 55 | resp.get( 56 | f"{PAPERLESS_TEST_URL}{API_PATH['index']}", 57 | status=200, 58 | headers={"X-Version": "0.0.0"}, 59 | payload=PATCHWORK["paths_v0_0_0"], 60 | ) 61 | async with api: 62 | yield api 63 | 64 | 65 | @pytest.fixture(name="api_18") 66 | async def api_version_18_fixture( 67 | resp: aioresponses, 68 | api: Paperless, 69 | ) -> AsyncGenerator[Paperless, Any]: 70 | """Return a Paperless object with given version.""" 71 | resp.get( 72 | f"{PAPERLESS_TEST_URL}{API_PATH['index']}", 73 | status=200, 74 | headers={"X-Version": "1.8.0"}, 75 | payload=PATCHWORK["paths_v1_8_0"], 76 | ) 77 | async with api: 78 | yield api 79 | 80 | 81 | @pytest.fixture(name="api_117") 82 | async def api_version_117_fixture( 83 | resp: aioresponses, 84 | api: Paperless, 85 | ) -> AsyncGenerator[Paperless, Any]: 86 | """Return a Paperless object with given version.""" 87 | resp.get( 88 | f"{PAPERLESS_TEST_URL}{API_PATH['index']}", 89 | status=200, 90 | headers={"X-Version": "1.17.0"}, 91 | payload=PATCHWORK["paths_v1_17_0"], 92 | ) 93 | async with api: 94 | yield api 95 | 96 | 97 | @pytest.fixture(name="api_20") 98 | async def api_version_20_fixture( 99 | resp: aioresponses, 100 | api: Paperless, 101 | ) -> AsyncGenerator[Paperless, Any]: 102 | """Return a Paperless object with given version.""" 103 | resp.get( 104 | f"{PAPERLESS_TEST_URL}{API_PATH['index']}", 105 | status=200, 106 | headers={"X-Version": "2.0.0"}, 107 | payload=PATCHWORK["paths_v2_0_0"], 108 | ) 109 | async with api: 110 | yield api 111 | 112 | 113 | @pytest.fixture(name="api_23") 114 | async def api_version_23_fixture( 115 | resp: aioresponses, 116 | api: Paperless, 117 | ) -> AsyncGenerator[Paperless, Any]: 118 | """Return a Paperless object with given version.""" 119 | resp.get( 120 | f"{PAPERLESS_TEST_URL}{API_PATH['index']}", 121 | status=200, 122 | headers={"X-Version": "2.3.0"}, 123 | payload=PATCHWORK["paths_v2_3_0"], 124 | ) 125 | async with api: 126 | yield api 127 | 128 | 129 | @pytest.fixture(name="api_26") 130 | async def api_version_26_fixture( 131 | resp: aioresponses, 132 | api: Paperless, 133 | ) -> AsyncGenerator[Paperless, Any]: 134 | """Return a Paperless object with given version.""" 135 | resp.get( 136 | f"{PAPERLESS_TEST_URL}{API_PATH['index']}", 137 | status=200, 138 | headers={"X-Version": "2.6.0"}, 139 | payload=PATCHWORK["paths"], 140 | ) 141 | async with api: 142 | yield api 143 | 144 | 145 | @pytest.fixture(name="api_215") 146 | async def api_version_215_fixture( 147 | resp: aioresponses, 148 | api: Paperless, 149 | ) -> AsyncGenerator[Paperless, Any]: 150 | """Return a Paperless object with given version.""" 151 | resp.get( 152 | f"{PAPERLESS_TEST_URL}{API_PATH['api_schema']}", 153 | status=200, 154 | headers={"X-Version": "2.15.0"}, 155 | payload=PATCHWORK["schema"], 156 | ) 157 | async with api: 158 | yield api 159 | -------------------------------------------------------------------------------- /tests/const.py: -------------------------------------------------------------------------------- 1 | """Test constants.""" 2 | 3 | PAPERLESS_TEST_URL = "https://paperless.not-existing.internal" 4 | PAPERLESS_TEST_TOKEN = "abcdef0123456789" 5 | PAPERLESS_TEST_USER = "test-user" 6 | PAPERLESS_TEST_PASSWORD = "not-so-secret-password" 7 | PAPERLESS_TEST_REQ_ARGS = {"ssl": False} 8 | -------------------------------------------------------------------------------- /tests/data/__init__.py: -------------------------------------------------------------------------------- 1 | """Raw data constants.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | from .v0_0_0 import ( 7 | V0_0_0_CORRESPONDENTS, 8 | V0_0_0_DOCUMENT_SUGGESTIONS, 9 | V0_0_0_DOCUMENT_TYPES, 10 | V0_0_0_DOCUMENTS, 11 | V0_0_0_DOCUMENTS_METADATA, 12 | V0_0_0_DOCUMENTS_SEARCH, 13 | V0_0_0_GROUPS, 14 | V0_0_0_MAIL_ACCOUNTS, 15 | V0_0_0_MAIL_RULES, 16 | V0_0_0_OBJECT_PERMISSIONS, 17 | V0_0_0_PATHS, 18 | V0_0_0_REMOTE_VERSION, 19 | V0_0_0_SAVED_VIEWS, 20 | V0_0_0_TAGS, 21 | V0_0_0_TASKS, 22 | V0_0_0_TOKEN, 23 | V0_0_0_USERS, 24 | ) 25 | from .v1_8_0 import V1_8_0_PATHS, V1_8_0_STORAGE_PATHS 26 | from .v1_17_0 import V1_17_0_DOCUMENT_NOTES 27 | from .v2_0_0 import ( 28 | V2_0_0_CONFIG, 29 | V2_0_0_CUSTOM_FIELDS, 30 | V2_0_0_PATHS, 31 | V2_0_0_SHARE_LINKS, 32 | ) 33 | from .v2_3_0 import ( 34 | V2_3_0_PATHS, 35 | V2_3_0_WORKFLOW_ACTIONS, 36 | V2_3_0_WORKFLOW_TRIGGERS, 37 | V2_3_0_WORKFLOWS, 38 | ) 39 | from .v2_6_0 import V2_6_0_STATUS 40 | from .v2_15_0 import V2_15_0_STATISTICS 41 | 42 | # mypy: ignore-errors 43 | 44 | 45 | def _read_schema(filename: str) -> dict: 46 | filepath = Path(f"tests/data/api-schema_{filename}.json") 47 | with Path.open(filepath, mode="r", encoding="utf-8") as file: 48 | return json.load(file) 49 | 50 | 51 | _schema_v2_15_0 = _read_schema("v2.15.0") 52 | 53 | PATCHWORK = { 54 | # 0.0.0 55 | "paths": V0_0_0_PATHS | V1_8_0_PATHS | V2_0_0_PATHS | V2_3_0_PATHS, 56 | "paths_v0_0_0": V0_0_0_PATHS, 57 | "paths_v1_8_0": V1_8_0_PATHS, 58 | "paths_v2_0_0": V2_0_0_PATHS, 59 | "paths_v2_3_0": V2_3_0_PATHS, 60 | "correspondents": V0_0_0_CORRESPONDENTS, 61 | "documents": V0_0_0_DOCUMENTS, 62 | "documents_metadata": V0_0_0_DOCUMENTS_METADATA, 63 | "documents_search": V0_0_0_DOCUMENTS_SEARCH, 64 | "documents_suggestions": V0_0_0_DOCUMENT_SUGGESTIONS, 65 | "document_types": V0_0_0_DOCUMENT_TYPES, 66 | "groups": V0_0_0_GROUPS, 67 | "mail_accounts": V0_0_0_MAIL_ACCOUNTS, 68 | "mail_rules": V0_0_0_MAIL_RULES, 69 | "object_permissions": V0_0_0_OBJECT_PERMISSIONS, 70 | "saved_views": V0_0_0_SAVED_VIEWS, 71 | "tags": V0_0_0_TAGS, 72 | "tasks": V0_0_0_TASKS, 73 | "token": V0_0_0_TOKEN, 74 | "users": V0_0_0_USERS, 75 | "remote_version": V0_0_0_REMOTE_VERSION, 76 | # 1.8.0 77 | "storage_paths": V1_8_0_STORAGE_PATHS, 78 | # 1.17.0 79 | "document_notes": V1_17_0_DOCUMENT_NOTES, 80 | # 2.0.0 81 | "config": V2_0_0_CONFIG, 82 | "custom_fields": V2_0_0_CUSTOM_FIELDS, 83 | "share_links": V2_0_0_SHARE_LINKS, 84 | # 2.3.0 85 | "workflows": V2_3_0_WORKFLOWS, 86 | "workflow_actions": V2_3_0_WORKFLOW_ACTIONS, 87 | "workflow_triggers": V2_3_0_WORKFLOW_TRIGGERS, 88 | # 2.6.0 89 | "status": V2_6_0_STATUS, 90 | # 2.15.0 91 | "schema": _schema_v2_15_0, 92 | "statistics": V2_15_0_STATISTICS, 93 | } 94 | 95 | __all__ = ("PATCHWORK",) 96 | -------------------------------------------------------------------------------- /tests/data/v1_17_0.py: -------------------------------------------------------------------------------- 1 | """Raw data constants for Paperless versions >= 1.17.0.""" 2 | 3 | V1_17_0_DOCUMENT_NOTES = [ 4 | { 5 | "id": 1, 6 | "note": "Sample note 1.", 7 | "created": "2023-12-21T18:08:11.481206+00:00", 8 | "user": { 9 | "id": 1, 10 | "username": "test", 11 | "first_name": "Peter", 12 | "last_name": "Patch", 13 | }, 14 | }, 15 | { 16 | "id": 2, 17 | "note": "Sample note 2.", 18 | "created": "2023-12-21T08:26:33.260968+00:00", 19 | "user": { 20 | "id": 2, 21 | "username": "test", 22 | "first_name": "Peter", 23 | "last_name": "Patch", 24 | }, 25 | }, 26 | { 27 | "id": 3, 28 | "note": "Sample note 3.", 29 | "created": "2023-12-21T08:26:31.782811+00:00", 30 | "user": { 31 | "id": 3, 32 | "username": "test", 33 | "first_name": "Peter", 34 | "last_name": "Patch", 35 | }, 36 | }, 37 | ] 38 | -------------------------------------------------------------------------------- /tests/data/v1_8_0.py: -------------------------------------------------------------------------------- 1 | """Raw data constants for Paperless versions >= 1.8.0.""" 2 | 3 | from tests.const import PAPERLESS_TEST_URL 4 | 5 | V1_8_0_PATHS = { 6 | "storage_paths": f"{PAPERLESS_TEST_URL}/api/storage_paths/", 7 | } 8 | 9 | V1_8_0_STORAGE_PATHS = { 10 | "count": 3, 11 | "next": None, 12 | "previous": None, 13 | "all": [1, 2, 3, 4, 5], 14 | "results": [ 15 | { 16 | "id": 1, 17 | "slug": "work", 18 | "name": "Work Work Work", 19 | "path": "{owner_username}/work/{correspondent}_{created}_{document_type}_{title}", 20 | "match": "", 21 | "matching_algorithm": 6, 22 | "is_insensitive": True, 23 | "document_count": 384, 24 | "owner": 3, 25 | "user_can_change": True, 26 | }, 27 | { 28 | "id": 2, 29 | "slug": "banking", 30 | "name": "Banking", 31 | "path": "{owner_username}/banking/{correspondent}_{created}_{document_type}_{title}", 32 | "match": "", 33 | "matching_algorithm": 6, 34 | "is_insensitive": True, 35 | "document_count": 303, 36 | "owner": None, 37 | "user_can_change": True, 38 | }, 39 | { 40 | "id": 3, 41 | "slug": "another-test", 42 | "name": "Another Test", 43 | "path": "Test/Path/{doc_pk}", 44 | "match": "", 45 | "matching_algorithm": 0, 46 | "is_insensitive": True, 47 | "document_count": 0, 48 | "owner": None, 49 | "user_can_change": True, 50 | }, 51 | { 52 | "id": 4, 53 | "slug": "another-test-2", 54 | "name": "Another Test 2", 55 | "path": "Test/Path/{doc_pk}", 56 | "match": "", 57 | "matching_algorithm": 0, 58 | "is_insensitive": True, 59 | "document_count": 0, 60 | "owner": None, 61 | "user_can_change": True, 62 | }, 63 | { 64 | "id": 5, 65 | "slug": "another-test-3", 66 | "name": "Another Test 3", 67 | "path": "Test/Path/{doc_pk}", 68 | "match": "", 69 | "matching_algorithm": 0, 70 | "is_insensitive": True, 71 | "document_count": 0, 72 | "owner": None, 73 | "user_can_change": True, 74 | }, 75 | ], 76 | } 77 | -------------------------------------------------------------------------------- /tests/data/v2_0_0.py: -------------------------------------------------------------------------------- 1 | """Raw data constants for Paperless versions >= 2.0.0.""" 2 | 3 | from tests.const import PAPERLESS_TEST_URL 4 | 5 | V2_0_0_PATHS = { 6 | "config": f"{PAPERLESS_TEST_URL}/api/config/", 7 | "custom_fields": f"{PAPERLESS_TEST_URL}/api/custom_fields/", 8 | "share_links": f"{PAPERLESS_TEST_URL}/api/share_links/", 9 | } 10 | 11 | V2_0_0_CONFIG = [ 12 | { 13 | "id": 1, 14 | "user_args": None, 15 | "output_type": "pdf", 16 | "pages": None, 17 | "language": "eng", 18 | "mode": None, 19 | "skip_archive_file": None, 20 | "image_dpi": None, 21 | "unpaper_clean": None, 22 | "deskew": None, 23 | "rotate_pages": None, 24 | "rotate_pages_threshold": None, 25 | "max_image_pixels": None, 26 | "color_conversion_strategy": None, 27 | "app_title": None, 28 | "app_logo": None, 29 | } 30 | ] 31 | 32 | V2_0_0_CUSTOM_FIELDS = { 33 | "count": 8, 34 | "next": None, 35 | "previous": None, 36 | "all": [8, 7, 6, 5, 4, 3, 2, 1], 37 | "results": [ 38 | {"id": 8, "name": "Custom Link", "data_type": "documentlink"}, 39 | {"id": 7, "name": "Custom URL", "data_type": "url"}, 40 | {"id": 6, "name": "Custom Text -added-", "data_type": "string"}, 41 | {"id": 5, "name": "Custom MONEYY $$$", "data_type": "monetary"}, 42 | {"id": 4, "name": "Custom Floating", "data_type": "float"}, 43 | {"id": 3, "name": "Custom Int", "data_type": "integer"}, 44 | {"id": 2, "name": "Custom Date", "data_type": "date"}, 45 | {"id": 1, "name": "Custom Bool", "data_type": "boolean"}, 46 | ], 47 | } 48 | 49 | V2_0_0_SHARE_LINKS = { 50 | "count": 5, 51 | "next": None, 52 | "previous": None, 53 | "all": [1, 2, 3, 4, 5, 6, 7, 8], 54 | "results": [ 55 | { 56 | "id": 1, 57 | "created": "2023-12-11T14:06:49.096456+00:00", 58 | "expiration": "2023-12-18T14:06:49.064000+00:00", 59 | "slug": "GMIFR9WVPe7a0FAltmrAdmVsrrTzH6Z9yFi2jufhi5yCTAMWfF", 60 | "document": 1, 61 | "file_version": "original", 62 | }, 63 | { 64 | "id": 2, 65 | "created": "2023-12-11T14:06:53.583496+00:00", 66 | "expiration": "2024-01-10T14:06:53.558000+00:00", 67 | "slug": "Px2h3mrkIvExyTE8M8usrTLv3jtTb4MnLJ4eTAxcjy2FUmuDLq", 68 | "document": 2, 69 | "file_version": "original", 70 | }, 71 | { 72 | "id": 3, 73 | "created": "2023-12-11T14:06:55.984583+00:00", 74 | "expiration": None, 75 | "slug": "bDnxeQ4UmlFVUYCDrb1KBLbE4HVSW8jw3CLElcwPyAncV5eiI+00:00", 76 | "document": 1, 77 | "file_version": "original", 78 | }, 79 | { 80 | "id": 4, 81 | "created": "2023-12-11T14:07:01.448813+00:00", 82 | "expiration": "2023-12-12T14:07:01.423000+00:00", 83 | "slug": "HfzHhDzA03ZQg4t4TAlOuup59qgQA18Zjbb9eOE06PZ8KTjgOb", 84 | "document": 2, 85 | "file_version": "archive", 86 | }, 87 | { 88 | "id": 5, 89 | "created": "2023-12-11T14:11:50.710369+00:00", 90 | "expiration": None, 91 | "slug": "7PIGEZbeFv5yIrnpSVwj1QeXiJu0IZCiEWGIV4aUHQrfUQtXne", 92 | "document": 1, 93 | "file_version": "archive", 94 | }, 95 | { 96 | "id": 6, 97 | "created": "2023-12-11T14:11:50.710369+00:00", 98 | "expiration": None, 99 | "slug": "7PIGEZbeFv5yIrnpSVwj1QeXiJu0IZCiEWGIV4aUHQrfUQtXne", 100 | "document": 1, 101 | "file_version": "archive", 102 | }, 103 | { 104 | "id": 7, 105 | "created": "2023-12-11T14:11:50.710369+00:00", 106 | "expiration": None, 107 | "slug": "7PIGEZbeFv5yIrnpSVwj1QeXiJu0IZCiEWGIV4aUHQrfUQtXne", 108 | "document": 1, 109 | "file_version": "archive", 110 | }, 111 | { 112 | "id": 8, 113 | "created": "2023-12-11T14:11:50.710369+00:00", 114 | "expiration": None, 115 | "slug": "7PIGEZbeFv5yIrnpSVwj1QeXiJu0IZCiEWGIV4aUHQrfUQtXne", 116 | "document": 1, 117 | "file_version": "archive", 118 | }, 119 | ], 120 | } 121 | -------------------------------------------------------------------------------- /tests/data/v2_15_0.py: -------------------------------------------------------------------------------- 1 | """Raw data constants for Paperless versions >= 2.15.0.""" 2 | 3 | # mypy: ignore-errors 4 | 5 | V2_15_0_STATISTICS = { 6 | "documents_total": 1337, 7 | "documents_inbox": 2, 8 | "inbox_tag": 1, 9 | "inbox_tags": [1], 10 | "document_file_type_counts": [ 11 | {"mime_type": "application/pdf", "mime_type_count": 1334}, 12 | {"mime_type": "image/jpeg", "mime_type_count": 2}, 13 | {"mime_type": "message/rfc822", "mime_type_count": 1}, 14 | ], 15 | "character_count": 13371337, 16 | "tag_count": 5, 17 | "correspondent_count": 42, 18 | "document_type_count": 23, 19 | "storage_path_count": 5, 20 | "current_asn": 84, 21 | } 22 | -------------------------------------------------------------------------------- /tests/data/v2_3_0.py: -------------------------------------------------------------------------------- 1 | """Raw data constants for Paperless versions >= 2.3.0.""" 2 | 3 | from tests.const import PAPERLESS_TEST_URL 4 | 5 | # mypy: ignore-errors 6 | 7 | V2_3_0_PATHS = { 8 | "workflows": f"{PAPERLESS_TEST_URL}/api/workflows/", 9 | "workflow_actions": f"{PAPERLESS_TEST_URL}/api/workflow_actions/", 10 | "workflow_triggers": f"{PAPERLESS_TEST_URL}/api/workflow_triggers/", 11 | } 12 | 13 | V2_3_0_WORKFLOWS = { 14 | "count": 3, 15 | "next": None, 16 | "previous": None, 17 | "all": [1, 2, 3], 18 | "results": [ 19 | { 20 | "id": 1, 21 | "name": "Importordner Template", 22 | "order": 1, 23 | "enabled": True, 24 | "triggers": [ 25 | { 26 | "id": 1, 27 | "sources": [1], 28 | "type": 1, 29 | "filter_path": None, 30 | "filter_filename": "*.pdf", 31 | "filter_mailrule": None, 32 | "matching_algorithm": 0, 33 | "match": "", 34 | "is_insensitive": None, 35 | "filter_has_tags": [], 36 | "filter_has_correspondent": None, 37 | "filter_has_document_type": None, 38 | } 39 | ], 40 | "actions": [ 41 | { 42 | "id": 1, 43 | "type": 1, 44 | "assign_title": "Some workflow title", 45 | "assign_tags": [4], 46 | "assign_correspondent": 9, 47 | "assign_document_type": 8, 48 | "assign_storage_path": 2, 49 | "assign_owner": 3, 50 | "assign_view_users": [], 51 | "assign_view_groups": [], 52 | "assign_change_users": [], 53 | "assign_change_groups": [], 54 | "assign_custom_fields": [2], 55 | } 56 | ], 57 | }, 58 | { 59 | "id": 2, 60 | "name": "API Upload Template", 61 | "order": 2, 62 | "enabled": True, 63 | "triggers": [ 64 | { 65 | "id": 2, 66 | "sources": [1, 2], 67 | "type": 1, 68 | "filter_path": "/api/*", 69 | "filter_filename": "*.pdf", 70 | "filter_mailrule": None, 71 | "matching_algorithm": 0, 72 | "match": "", 73 | "is_insensitive": None, 74 | "filter_has_tags": [], 75 | "filter_has_correspondent": None, 76 | "filter_has_document_type": None, 77 | } 78 | ], 79 | "actions": [ 80 | { 81 | "id": 2, 82 | "type": 1, 83 | "assign_title": "API", 84 | "assign_tags": [4], 85 | "assign_correspondent": 9, 86 | "assign_document_type": 12, 87 | "assign_storage_path": 2, 88 | "assign_owner": 3, 89 | "assign_view_users": [], 90 | "assign_view_groups": [], 91 | "assign_change_users": [], 92 | "assign_change_groups": [], 93 | "assign_custom_fields": [5], 94 | } 95 | ], 96 | }, 97 | { 98 | "id": 3, 99 | "name": "Email Template", 100 | "order": 3, 101 | "enabled": False, 102 | "triggers": [ 103 | { 104 | "id": 3, 105 | "sources": [3], 106 | "type": 1, 107 | "filter_path": "/mail/*", 108 | "filter_filename": "*.eml", 109 | "filter_mailrule": 1, 110 | "matching_algorithm": 0, 111 | "match": "", 112 | "is_insensitive": True, 113 | "filter_has_tags": [], 114 | "filter_has_correspondent": None, 115 | "filter_has_document_type": None, 116 | } 117 | ], 118 | "actions": [ 119 | { 120 | "id": 3, 121 | "type": 1, 122 | "assign_title": None, 123 | "assign_tags": [], 124 | "assign_correspondent": None, 125 | "assign_document_type": None, 126 | "assign_storage_path": None, 127 | "assign_owner": 2, 128 | "assign_view_users": [3, 7], 129 | "assign_view_groups": [1], 130 | "assign_change_users": [6], 131 | "assign_change_groups": [], 132 | "assign_custom_fields": [], 133 | } 134 | ], 135 | }, 136 | ], 137 | } 138 | 139 | V2_3_0_WORKFLOW_ACTIONS = { 140 | "count": 0, 141 | "next": None, 142 | "previous": None, 143 | "all": [], 144 | "results": [], 145 | } 146 | for wf in V2_3_0_WORKFLOWS["results"]: 147 | V2_3_0_WORKFLOW_ACTIONS["count"] += 1 148 | for act in wf["actions"]: 149 | V2_3_0_WORKFLOW_ACTIONS["all"].append(act["id"]) 150 | V2_3_0_WORKFLOW_ACTIONS["results"].append(act) 151 | 152 | V2_3_0_WORKFLOW_TRIGGERS = { 153 | "count": 0, 154 | "next": None, 155 | "previous": None, 156 | "all": [], 157 | "results": [], 158 | } 159 | for wf in V2_3_0_WORKFLOWS["results"]: 160 | V2_3_0_WORKFLOW_TRIGGERS["count"] += 1 161 | for act in wf["triggers"]: 162 | V2_3_0_WORKFLOW_TRIGGERS["all"].append(act["id"]) 163 | V2_3_0_WORKFLOW_TRIGGERS["results"].append(act) 164 | -------------------------------------------------------------------------------- /tests/data/v2_6_0.py: -------------------------------------------------------------------------------- 1 | """Raw data constants for Paperless versions >= 2.6.0.""" 2 | 3 | # mypy: ignore-errors 4 | 5 | V2_6_0_STATUS = { 6 | "pngx_version": "2.6.1", 7 | "server_os": "Linux-6.6.12-linuxkit-aarch64-with-glibc2.36", 8 | "install_type": "docker", 9 | "storage": { 10 | "total": 494384795648, 11 | "available": 103324229632, 12 | }, 13 | "database": { 14 | "type": "sqlite", 15 | "url": "/usr/src/paperless/data/db.sqlite3", 16 | "status": "OK", 17 | "error": None, 18 | "migration_status": { 19 | "latest_migration": "paperless.0003_alter_applicationconfiguration_max_image_pixels", 20 | "unapplied_migrations": [], 21 | }, 22 | }, 23 | "tasks": { 24 | "redis_url": "redis://broker:6379", 25 | "redis_status": "OK", 26 | "redis_error": None, 27 | "celery_status": "OK", 28 | "index_status": "OK", 29 | "index_last_modified": "2024-03-06T07:10:55.370884+01:00", 30 | "index_error": None, 31 | "classifier_status": "OK", 32 | "classifier_last_trained": "2024-03-06T07:05:01.281804+00:00", 33 | "classifier_error": None, 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /tests/ruff.toml: -------------------------------------------------------------------------------- 1 | # This extend our general Ruff rules specifically for tests 2 | extend = "../pyproject.toml" 3 | 4 | lint.extend-select = [ 5 | "PT", # Use @pytest.fixture without parentheses 6 | ] 7 | 8 | lint.extend-ignore = [ 9 | "S101", # As these are tests, the usage of assert could be good practise, no? 10 | "S105", # Yes, we hardcoded passwords. It will be ok this time. 11 | "SLF001", # Tests will access private/protected members. 12 | "TCH002", # pytest doesn't like this one. 13 | ] 14 | -------------------------------------------------------------------------------- /tests/test_models_matrix.py: -------------------------------------------------------------------------------- 1 | """Paperless basic tests.""" 2 | 3 | import re 4 | from typing import Any 5 | 6 | import aiohttp 7 | import pytest 8 | from aioresponses import CallbackResult, aioresponses 9 | 10 | from pypaperless import Paperless 11 | from pypaperless.const import API_PATH 12 | from pypaperless.exceptions import DraftFieldRequiredError 13 | from pypaperless.models import Page 14 | from pypaperless.models.common import PermissionTableType 15 | 16 | from . import ( 17 | CORRESPONDENT_MAP, 18 | CUSTOM_FIELD_MAP, 19 | DOCUMENT_MAP, 20 | DOCUMENT_TYPE_MAP, 21 | GROUP_MAP, 22 | MAIL_ACCOUNT_MAP, 23 | MAIL_RULE_MAP, 24 | SAVED_VIEW_MAP, 25 | SHARE_LINK_MAP, 26 | STORAGE_PATH_MAP, 27 | TAG_MAP, 28 | USER_MAP, 29 | WORKFLOW_MAP, 30 | ResourceTestMapping, 31 | ) 32 | from .const import PAPERLESS_TEST_URL 33 | from .data import PATCHWORK 34 | 35 | # mypy: ignore-errors 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "mapping", 40 | [ 41 | DOCUMENT_MAP, 42 | DOCUMENT_TYPE_MAP, 43 | CORRESPONDENT_MAP, 44 | CUSTOM_FIELD_MAP, 45 | GROUP_MAP, 46 | MAIL_ACCOUNT_MAP, 47 | MAIL_RULE_MAP, 48 | SAVED_VIEW_MAP, 49 | SHARE_LINK_MAP, 50 | STORAGE_PATH_MAP, 51 | TAG_MAP, 52 | USER_MAP, 53 | WORKFLOW_MAP, 54 | ], 55 | scope="class", 56 | ) 57 | # test models/classifiers.py 58 | # test models/custom_fields.py 59 | # test models/mails.py 60 | # test models/permissions.py 61 | # test models/saved_views.py 62 | # test models/share_links.py 63 | class TestReadOnly: 64 | """Read only resources test cases.""" 65 | 66 | async def test_pages( 67 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 68 | ) -> None: 69 | """Test pages.""" 70 | resp.get( 71 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource]}" + r"\?.*$"), 72 | status=200, 73 | payload=PATCHWORK[mapping.resource], 74 | ) 75 | page = await anext(aiter(getattr(api_latest, mapping.resource).pages(1))) 76 | assert isinstance(page, Page) 77 | assert isinstance(page.items, list) 78 | for item in page.items: 79 | assert isinstance(item, mapping.model_cls) 80 | 81 | async def test_as_dict( 82 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 83 | ) -> None: 84 | """Test as_dict.""" 85 | resp.get( 86 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource]}" + r"\?.*$"), 87 | status=200, 88 | payload=PATCHWORK[mapping.resource], 89 | ) 90 | items = await getattr(api_latest, mapping.resource).as_dict() 91 | for pk, obj in items.items(): 92 | assert isinstance(pk, int) 93 | assert isinstance(obj, mapping.model_cls) 94 | 95 | async def test_as_list( 96 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 97 | ) -> None: 98 | """Test as_dict.""" 99 | resp.get( 100 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource]}" + r"\?.*$"), 101 | status=200, 102 | payload=PATCHWORK[mapping.resource], 103 | ) 104 | items = await getattr(api_latest, mapping.resource).as_list() 105 | for obj in items: 106 | assert isinstance(obj, mapping.model_cls) 107 | 108 | async def test_iter( 109 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 110 | ) -> None: 111 | """Test iter.""" 112 | resp.get( 113 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource]}" + r"\?.*$"), 114 | status=200, 115 | payload=PATCHWORK[mapping.resource], 116 | ) 117 | async for item in getattr(api_latest, mapping.resource): 118 | assert isinstance(item, mapping.model_cls) 119 | 120 | async def test_all( 121 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 122 | ) -> None: 123 | """Test all.""" 124 | resp.get( 125 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource]}" + r"\?.*$"), 126 | status=200, 127 | payload=PATCHWORK[mapping.resource], 128 | ) 129 | items = await getattr(api_latest, mapping.resource).all() 130 | assert isinstance(items, list) 131 | for item in items: 132 | assert isinstance(item, int) 133 | 134 | async def test_call( 135 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 136 | ) -> None: 137 | """Test call.""" 138 | resp.get( 139 | f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1), 140 | status=200, 141 | payload=PATCHWORK[mapping.resource]["results"][0], 142 | ) 143 | item = await getattr(api_latest, mapping.resource)(1) 144 | assert item 145 | assert isinstance(item, mapping.model_cls) 146 | # must raise as 1337 doesn't exist 147 | resp.get( 148 | f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1337), 149 | status=404, 150 | ) 151 | with pytest.raises(aiohttp.ClientResponseError): 152 | await getattr(api_latest, mapping.resource)(1337) 153 | 154 | 155 | @pytest.mark.parametrize( 156 | "mapping", 157 | [ 158 | CORRESPONDENT_MAP, 159 | CUSTOM_FIELD_MAP, 160 | DOCUMENT_TYPE_MAP, 161 | SHARE_LINK_MAP, 162 | STORAGE_PATH_MAP, 163 | TAG_MAP, 164 | ], 165 | scope="class", 166 | ) 167 | # test models/classifiers.py 168 | # test models/custom_fields.py 169 | # test models/share_links.py 170 | class TestReadWrite: 171 | """R/W models test cases.""" 172 | 173 | async def test_pages( 174 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 175 | ) -> None: 176 | """Test pages.""" 177 | resp.get( 178 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource]}" + r"\?.*$"), 179 | status=200, 180 | payload=PATCHWORK[mapping.resource], 181 | ) 182 | page = await anext(aiter(getattr(api_latest, mapping.resource).pages(1))) 183 | assert isinstance(page, Page) 184 | assert isinstance(page.items, list) 185 | for item in page.items: 186 | assert isinstance(item, mapping.model_cls) 187 | 188 | async def test_iter( 189 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 190 | ) -> None: 191 | """Test iter.""" 192 | resp.get( 193 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource]}" + r"\?.*$"), 194 | status=200, 195 | payload=PATCHWORK[mapping.resource], 196 | ) 197 | async for item in getattr(api_latest, mapping.resource): 198 | assert isinstance(item, mapping.model_cls) 199 | 200 | async def test_all( 201 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 202 | ) -> None: 203 | """Test all.""" 204 | resp.get( 205 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource]}" + r"\?.*$"), 206 | status=200, 207 | payload=PATCHWORK[mapping.resource], 208 | ) 209 | items = await getattr(api_latest, mapping.resource).all() 210 | assert isinstance(items, list) 211 | for item in items: 212 | assert isinstance(item, int) 213 | 214 | async def test_reduce( 215 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 216 | ) -> None: 217 | """Test iter with reduce.""" 218 | resp.get( 219 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource]}" + r"\?.*$"), 220 | status=200, 221 | payload=PATCHWORK[mapping.resource], 222 | ) 223 | async with getattr(api_latest, mapping.resource).reduce( 224 | any_filter_param="1", 225 | any_filter_list__in=["1", "2"], 226 | any_filter_no_list__in="1", 227 | ) as q: 228 | async for item in q: 229 | assert isinstance(item, mapping.model_cls) 230 | 231 | async def test_call( 232 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 233 | ) -> None: 234 | """Test call.""" 235 | resp.get( 236 | f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1), 237 | status=200, 238 | payload=PATCHWORK[mapping.resource]["results"][0], 239 | ) 240 | item = await getattr(api_latest, mapping.resource)(1) 241 | assert item 242 | assert isinstance(item, mapping.model_cls) 243 | # must raise as 1337 doesn't exist 244 | resp.get( 245 | f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1337), 246 | status=404, 247 | ) 248 | with pytest.raises(aiohttp.ClientResponseError): 249 | await getattr(api_latest, mapping.resource)(1337) 250 | 251 | async def test_create( 252 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 253 | ) -> None: 254 | """Test create.""" 255 | draft = getattr(api_latest, mapping.resource).draft(**mapping.draft_defaults) 256 | assert isinstance(draft, mapping.draft_cls) 257 | # test empty draft fields 258 | if mapping.model_cls not in ( 259 | SHARE_LINK_MAP.model_cls, 260 | CUSTOM_FIELD_MAP.model_cls, 261 | ): 262 | backup = draft.name 263 | draft.name = None 264 | with pytest.raises(DraftFieldRequiredError): 265 | await draft.save() 266 | draft.name = backup 267 | # actually call the create endpoint 268 | resp.post( 269 | f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource]}", 270 | status=200, 271 | payload={ 272 | "id": len(PATCHWORK[mapping.resource]["results"]), 273 | **draft._serialize(), # pylint: disable=protected-access 274 | }, 275 | ) 276 | new_pk = await draft.save() 277 | assert new_pk >= 1 278 | 279 | async def test_udpate( 280 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 281 | ) -> None: 282 | """Test update.""" 283 | update_field = "name" 284 | update_value = "Name Updated" 285 | if mapping.model_cls is SHARE_LINK_MAP.model_cls: 286 | update_field = "document" 287 | update_value = 2 288 | # go on 289 | resp.get( 290 | f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1), 291 | status=200, 292 | payload=PATCHWORK[mapping.resource]["results"][0], 293 | ) 294 | to_update = await getattr(api_latest, mapping.resource)(1) 295 | setattr(to_update, update_field, update_value) 296 | # actually call the update endpoint 297 | resp.patch( 298 | f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1), 299 | status=200, 300 | payload={ 301 | **to_update._data, # pylint: disable=protected-access 302 | update_field: update_value, 303 | }, 304 | ) 305 | await to_update.update() 306 | assert getattr(to_update, update_field) == update_value 307 | # no updates 308 | assert not await to_update.update() 309 | # force update 310 | setattr(to_update, update_field, update_value) 311 | resp.put( 312 | f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1), 313 | status=200, 314 | payload={ 315 | **to_update._data, # pylint: disable=protected-access 316 | update_field: update_value, 317 | }, 318 | ) 319 | await to_update.update(only_changed=False) 320 | assert getattr(to_update, update_field) == update_value 321 | 322 | async def test_delete( 323 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 324 | ) -> None: 325 | """Test delete.""" 326 | resp.get( 327 | f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1), 328 | status=200, 329 | payload=PATCHWORK[mapping.resource]["results"][0], 330 | ) 331 | to_delete = await getattr(api_latest, mapping.resource)(1) 332 | resp.delete( 333 | f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1), 334 | status=204, # Paperless-ngx responds with 204 on deletion 335 | ) 336 | assert await to_delete.delete() 337 | # test deletion failed 338 | resp.delete( 339 | f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1), 340 | status=404, # we send another status code 341 | ) 342 | assert not await to_delete.delete() 343 | 344 | 345 | @pytest.mark.parametrize( 346 | "mapping", 347 | [ 348 | CORRESPONDENT_MAP, 349 | DOCUMENT_MAP, 350 | DOCUMENT_TYPE_MAP, 351 | STORAGE_PATH_MAP, 352 | TAG_MAP, 353 | ], 354 | scope="class", 355 | ) 356 | # test models/classifiers.py 357 | class TestSecurableMixin: 358 | """SecurableMixin test cases.""" 359 | 360 | async def test_permissions( 361 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 362 | ) -> None: 363 | """Test permissions.""" 364 | getattr(api_latest, mapping.resource).request_permissions = True 365 | assert getattr(api_latest, mapping.resource).request_permissions 366 | # request single object 367 | resp.get( 368 | re.compile( 369 | r"^" 370 | + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1) 371 | + r"\?.*$" 372 | ), 373 | status=200, 374 | payload={ 375 | **PATCHWORK[mapping.resource]["results"][0], 376 | "permissions": PATCHWORK["object_permissions"], 377 | }, 378 | ) 379 | item = await getattr(api_latest, mapping.resource)(1) 380 | assert item.has_permissions 381 | assert isinstance(item.permissions, PermissionTableType) 382 | # request by iterator 383 | resp.get( 384 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource]}" + r"\?.*$"), 385 | status=200, 386 | payload={ 387 | **PATCHWORK[mapping.resource], 388 | "results": [ 389 | {**item, "permissions": PATCHWORK["object_permissions"]} 390 | for item in PATCHWORK[mapping.resource]["results"] 391 | ], 392 | }, 393 | ) 394 | async for item in getattr(api_latest, mapping.resource): 395 | assert isinstance(item, mapping.model_cls) 396 | assert item.has_permissions 397 | assert isinstance(item.permissions, PermissionTableType) 398 | 399 | async def test_permission_change( 400 | self, resp: aioresponses, api_latest: Paperless, mapping: ResourceTestMapping 401 | ) -> None: 402 | """Test permission changes.""" 403 | getattr(api_latest, mapping.resource).request_permissions = True 404 | assert getattr(api_latest, mapping.resource).request_permissions 405 | resp.get( 406 | re.compile( 407 | r"^" 408 | + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1) 409 | + r"\?.*$" 410 | ), 411 | status=200, 412 | payload={ 413 | **PATCHWORK[mapping.resource]["results"][0], 414 | "permissions": PATCHWORK["object_permissions"], 415 | }, 416 | ) 417 | item = await getattr(api_latest, mapping.resource)(1) 418 | item.permissions.view.users.append(23) 419 | 420 | def _lookup_set_permissions( # pylint: disable=unused-argument 421 | url: str, 422 | json: dict[str, Any], 423 | **kwargs: Any, # noqa: ARG001 424 | ) -> CallbackResult: 425 | assert url 426 | assert "set_permissions" in json 427 | return CallbackResult( 428 | status=200, 429 | payload=item._data, # pylint: disable=protected-access 430 | ) 431 | 432 | resp.patch( 433 | re.compile( 434 | r"^" 435 | + f"{PAPERLESS_TEST_URL}{API_PATH[mapping.resource + '_single']}".format(pk=1) 436 | + r"\?.*$" 437 | ), 438 | callback=_lookup_set_permissions, 439 | ) 440 | await item.update() 441 | -------------------------------------------------------------------------------- /tests/test_models_specific.py: -------------------------------------------------------------------------------- 1 | """Paperless basic tests.""" 2 | 3 | import datetime 4 | import re 5 | 6 | import aiohttp 7 | import pytest 8 | from aioresponses import aioresponses 9 | 10 | from pypaperless import Paperless 11 | from pypaperless.const import API_PATH 12 | from pypaperless.exceptions import ( 13 | AsnRequestError, 14 | DraftFieldRequiredError, 15 | PrimaryKeyRequiredError, 16 | TaskNotFoundError, 17 | ) 18 | from pypaperless.models import ( 19 | Config, 20 | CustomField, 21 | Document, 22 | DocumentDraft, 23 | DocumentMeta, 24 | DocumentNote, 25 | DocumentNoteDraft, 26 | Status, 27 | Task, 28 | ) 29 | from pypaperless.models.common import ( 30 | CustomFieldValue, 31 | DocumentMetadataType, 32 | DocumentSearchHitType, 33 | RetrieveFileMode, 34 | StatisticDocumentFileTypeCount, 35 | StatusDatabaseType, 36 | StatusStorageType, 37 | StatusTasksType, 38 | ) 39 | from pypaperless.models.documents import ( 40 | DocumentCustomFieldList, 41 | DocumentSuggestions, 42 | DownloadedDocument, 43 | ) 44 | from pypaperless.models.workflows import WorkflowActionHelper, WorkflowTriggerHelper 45 | 46 | from . import DOCUMENT_MAP 47 | from .const import PAPERLESS_TEST_URL 48 | from .data import PATCHWORK 49 | 50 | # mypy: ignore-errors 51 | 52 | 53 | # test models/config.py 54 | class TestModelConfig: 55 | """Config test cases.""" 56 | 57 | async def test_call(self, resp: aioresponses, api_latest: Paperless) -> None: 58 | """Test call.""" 59 | resp.get( 60 | f"{PAPERLESS_TEST_URL}{API_PATH['config_single']}".format(pk=1), 61 | status=200, 62 | payload=PATCHWORK["config"][0], 63 | ) 64 | item = await api_latest.config(1) 65 | assert item 66 | assert isinstance(item, Config) 67 | # must raise as 1337 doesn't exist 68 | resp.get( 69 | f"{PAPERLESS_TEST_URL}{API_PATH['config_single']}".format(pk=1337), 70 | status=404, 71 | ) 72 | with pytest.raises(aiohttp.ClientResponseError): 73 | await api_latest.config(1337) 74 | 75 | 76 | # test models/documents.py 77 | class TestModelDocuments: 78 | """Documents test cases.""" 79 | 80 | async def test_lazy(self, resp: aioresponses, api_latest: Paperless) -> None: 81 | """Test laziness.""" 82 | document = Document(api_latest, data={"id": 1}) 83 | assert not document.is_fetched 84 | 85 | resp.get( 86 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 87 | status=200, 88 | payload=PATCHWORK["documents"]["results"][0], 89 | ) 90 | await document.load() 91 | assert document.is_fetched 92 | 93 | async def test_create(self, resp: aioresponses, api_latest: Paperless) -> None: 94 | """Test create.""" 95 | defaults = DOCUMENT_MAP.draft_defaults or {} 96 | draft = api_latest.documents.draft(**defaults) 97 | assert isinstance(draft, DocumentDraft) 98 | backup = draft.document 99 | draft.document = None 100 | with pytest.raises(DraftFieldRequiredError): 101 | await draft.save() 102 | draft.document = backup 103 | # actually call the create endpoint 104 | resp.post( 105 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_post']}", 106 | status=200, 107 | payload="11112222-3333-4444-5555-666677778888", 108 | ) 109 | await draft.save() 110 | 111 | async def test_udpate(self, resp: aioresponses, api_latest: Paperless) -> None: 112 | """Test update.""" 113 | resp.get( 114 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 115 | status=200, 116 | payload=PATCHWORK["documents"]["results"][0], 117 | ) 118 | to_update = await api_latest.documents(1) 119 | new_title = f"{to_update.title} Updated" 120 | to_update.title = new_title 121 | # actually call the update endpoint 122 | resp.patch( 123 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 124 | status=200, 125 | payload={ 126 | **to_update._data, # pylint: disable=protected-access 127 | "title": new_title, 128 | }, 129 | ) 130 | await to_update.update() 131 | assert to_update.title == new_title 132 | 133 | async def test_delete(self, resp: aioresponses, api_latest: Paperless) -> None: 134 | """Test delete.""" 135 | resp.get( 136 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 137 | status=200, 138 | payload=PATCHWORK["documents"]["results"][0], 139 | ) 140 | to_delete = await api_latest.documents(1) 141 | resp.delete( 142 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 143 | status=204, # Paperless-ngx responds with 204 on deletion 144 | ) 145 | assert await to_delete.delete() 146 | # test deletion failed 147 | resp.delete( 148 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 149 | status=404, # we send another status code 150 | ) 151 | assert not await to_delete.delete() 152 | 153 | async def test_meta(self, resp: aioresponses, api_latest: Paperless) -> None: 154 | """Test meta.""" 155 | resp.get( 156 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 157 | status=200, 158 | payload=PATCHWORK["documents"]["results"][0], 159 | ) 160 | document = await api_latest.documents(1) 161 | resp.get( 162 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_meta']}".format(pk=1), 163 | status=200, 164 | payload=PATCHWORK["documents_metadata"], 165 | ) 166 | meta = await document.get_metadata() 167 | assert isinstance(meta, DocumentMeta) 168 | assert isinstance(meta.original_metadata, list) 169 | for item in meta.original_metadata: 170 | assert isinstance(item, DocumentMetadataType) 171 | assert isinstance(meta.archive_metadata, list) 172 | for item in meta.archive_metadata: 173 | assert isinstance(item, DocumentMetadataType) 174 | 175 | async def test_files(self, resp: aioresponses, api_latest: Paperless) -> None: 176 | """Test files.""" 177 | resp.get( 178 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 179 | status=200, 180 | payload=PATCHWORK["documents"]["results"][0], 181 | ) 182 | document = await api_latest.documents(1) 183 | resp.get( 184 | re.compile( 185 | r"^" 186 | + f"{PAPERLESS_TEST_URL}{API_PATH['documents_download']}".format(pk=1) 187 | + r"\?.*$" 188 | ), 189 | status=200, 190 | headers={ 191 | "Content-Type": "application/pdf", 192 | "Content-Disposition": "attachment;filename=any_filename.pdf", 193 | }, 194 | body=b"Binary data: download", 195 | ) 196 | download = await document.get_download() 197 | assert isinstance(download, DownloadedDocument) 198 | assert download.mode == RetrieveFileMode.DOWNLOAD 199 | resp.get( 200 | re.compile( 201 | r"^" 202 | + f"{PAPERLESS_TEST_URL}{API_PATH['documents_preview']}".format(pk=1) 203 | + r"\?.*$" 204 | ), 205 | status=200, 206 | headers={ 207 | "Content-Type": "application/pdf", 208 | }, 209 | body=b"Binary data: preview", 210 | ) 211 | preview = await document.get_preview() 212 | assert isinstance(preview, DownloadedDocument) 213 | assert preview.mode == RetrieveFileMode.PREVIEW 214 | resp.get( 215 | re.compile( 216 | r"^" 217 | + f"{PAPERLESS_TEST_URL}{API_PATH['documents_thumbnail']}".format(pk=1) 218 | + r"\?.*$" 219 | ), 220 | status=200, 221 | body=b"Binary data: thumbnail", 222 | ) 223 | thumbnail = await document.get_thumbnail() 224 | assert isinstance(thumbnail, DownloadedDocument) 225 | assert thumbnail.mode == RetrieveFileMode.THUMBNAIL 226 | 227 | async def test_suggestions(self, resp: aioresponses, api_latest: Paperless) -> None: 228 | """Test suggestions.""" 229 | resp.get( 230 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 231 | status=200, 232 | payload=PATCHWORK["documents"]["results"][0], 233 | ) 234 | document = await api_latest.documents(1) 235 | resp.get( 236 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_suggestions']}".format(pk=1), 237 | status=200, 238 | payload=PATCHWORK["documents_suggestions"], 239 | ) 240 | suggestions = await document.get_suggestions() 241 | assert isinstance(suggestions, DocumentSuggestions) 242 | 243 | async def test_get_next_an(self, resp: aioresponses, api_latest: Paperless) -> None: 244 | """Test get next asn.""" 245 | resp.get( 246 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_next_asn']}", 247 | status=200, 248 | payload=1337, 249 | ) 250 | asn = await api_latest.documents.get_next_asn() 251 | assert isinstance(asn, int) 252 | resp.get( 253 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_next_asn']}", 254 | status=500, 255 | ) 256 | with pytest.raises(AsnRequestError): 257 | await api_latest.documents.get_next_asn() 258 | 259 | async def test_searching(self, resp: aioresponses, api_latest: Paperless) -> None: 260 | """Test searching.""" 261 | # search 262 | resp.get( 263 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH['documents']}" + r"\?.*query.*$"), 264 | status=200, 265 | payload=PATCHWORK["documents_search"], 266 | ) 267 | async for item in api_latest.documents.search("1337"): 268 | assert isinstance(item, Document) 269 | assert item.has_search_hit 270 | assert isinstance(item.search_hit, DocumentSearchHitType) 271 | # more_like 272 | resp.get( 273 | re.compile( 274 | r"^" + f"{PAPERLESS_TEST_URL}{API_PATH['documents']}" + r"\?.*more_like_id.*$" 275 | ), 276 | status=200, 277 | payload=PATCHWORK["documents_search"], 278 | ) 279 | async for item in api_latest.documents.more_like(1337): 280 | assert isinstance(item, Document) 281 | assert item.has_search_hit 282 | assert isinstance(item.search_hit, DocumentSearchHitType) 283 | 284 | async def test_note_call(self, resp: aioresponses, api_latest: Paperless) -> None: 285 | """Test call.""" 286 | resp.get( 287 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 288 | status=200, 289 | payload=PATCHWORK["documents"]["results"][0], 290 | ) 291 | item = await api_latest.documents(1) 292 | assert isinstance(item, Document) 293 | resp.get( 294 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_notes']}".format(pk=1), 295 | status=200, 296 | payload=PATCHWORK["document_notes"], 297 | ) 298 | results = await item.notes() 299 | assert isinstance(results, list) 300 | assert len(results) > 0 301 | for note in results: 302 | assert isinstance(note, DocumentNote) 303 | assert isinstance(note.created, datetime.datetime) 304 | with pytest.raises(PrimaryKeyRequiredError): 305 | item = await api_latest.documents.notes() 306 | 307 | async def test_note_create(self, resp: aioresponses, api_latest: Paperless) -> None: 308 | """Test create.""" 309 | resp.get( 310 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 311 | status=200, 312 | payload=PATCHWORK["documents"]["results"][0], 313 | ) 314 | item = await api_latest.documents(1) 315 | draft = item.notes.draft(note="Test note.") 316 | assert isinstance(draft, DocumentNoteDraft) 317 | backup = draft.note 318 | draft.note = None 319 | with pytest.raises(DraftFieldRequiredError): 320 | await draft.save() 321 | draft.note = backup 322 | # actually call the create endpoint 323 | resp.post( 324 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_notes']}".format(pk=1), 325 | status=200, 326 | payload=PATCHWORK["document_notes"], 327 | ) 328 | result = await draft.save() 329 | assert isinstance(result, tuple) 330 | 331 | async def test_note_delete(self, resp: aioresponses, api_latest: Paperless) -> None: 332 | """Test delete.""" 333 | resp.get( 334 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=1), 335 | status=200, 336 | payload=PATCHWORK["documents"]["results"][0], 337 | ) 338 | item = await api_latest.documents(1) 339 | resp.get( 340 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_notes']}".format(pk=1), 341 | status=200, 342 | payload=PATCHWORK["document_notes"], 343 | ) 344 | results = await item.notes() 345 | resp.delete( 346 | re.compile( 347 | r"^" + f"{PAPERLESS_TEST_URL}{API_PATH['documents_notes']}".format(pk=1) + r"\?.*$" 348 | ), 349 | status=204, # Paperless-ngx responds with 204 on deletion 350 | ) 351 | deletion = await results.pop().delete() 352 | assert deletion 353 | 354 | async def test_custom_fields(self, resp: aioresponses, api_latest: Paperless) -> None: 355 | """Test custom fields.""" 356 | # set custom fields cache 357 | resp.get( 358 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH['custom_fields']}" + r"\?.*$"), 359 | status=200, 360 | payload=PATCHWORK["custom_fields"], 361 | ) 362 | api_latest.cache.custom_fields = await api_latest.custom_fields.as_dict() 363 | 364 | # request document 365 | resp.get( 366 | f"{PAPERLESS_TEST_URL}{API_PATH['documents_single']}".format(pk=2), 367 | status=200, 368 | payload=PATCHWORK["documents"]["results"][1], 369 | ) 370 | item = await api_latest.documents(2) 371 | assert isinstance(item.custom_fields, DocumentCustomFieldList) 372 | 373 | # test if custom field is in document custom field values 374 | test_cf = CustomField.create_with_data( 375 | api=api_latest, 376 | data=PATCHWORK["custom_fields"]["results"][0], 377 | fetched=True, 378 | ) 379 | assert test_cf in item.custom_fields 380 | assert isinstance(item.custom_fields.get(test_cf), CustomFieldValue) 381 | assert item.custom_fields.default(test_cf) is not None 382 | assert item.custom_fields.default(-1337) is None 383 | 384 | 385 | # test models/remote_version.py 386 | class TestModelVersion: 387 | """Version test cases.""" 388 | 389 | async def test_call(self, resp: aioresponses, api_latest: Paperless) -> None: 390 | """Test call.""" 391 | resp.get( 392 | f"{PAPERLESS_TEST_URL}{API_PATH['remote_version']}", 393 | status=200, 394 | payload=PATCHWORK["remote_version"], 395 | ) 396 | remote_version = await api_latest.remote_version() 397 | assert remote_version 398 | assert isinstance(remote_version.version, str) 399 | assert isinstance(remote_version.update_available, bool) 400 | 401 | 402 | # test models/statistics.py 403 | class TestModelStatistics: 404 | """Statistics test cases.""" 405 | 406 | async def test_call(self, resp: aioresponses, api_latest: Paperless) -> None: 407 | """Test call.""" 408 | resp.get( 409 | f"{PAPERLESS_TEST_URL}{API_PATH['statistics']}", 410 | status=200, 411 | payload=PATCHWORK["statistics"], 412 | ) 413 | stats = await api_latest.statistics() 414 | assert stats 415 | assert isinstance(stats.character_count, int) 416 | assert isinstance(stats.document_file_type_counts, list) 417 | for item in stats.document_file_type_counts: 418 | assert isinstance(item, StatisticDocumentFileTypeCount) 419 | 420 | 421 | # test models/status.py 422 | class TestModelStatus: 423 | """Status test cases.""" 424 | 425 | async def test_call(self, resp: aioresponses, api_latest: Paperless) -> None: 426 | """Test call.""" 427 | resp.get( 428 | f"{PAPERLESS_TEST_URL}{API_PATH['status']}", 429 | status=200, 430 | payload=PATCHWORK["status"], 431 | ) 432 | status = await api_latest.status() 433 | assert status 434 | assert isinstance(status, Status) 435 | assert isinstance(status.storage, StatusStorageType) 436 | assert isinstance(status.database, StatusDatabaseType) 437 | assert isinstance(status.tasks, StatusTasksType) 438 | 439 | async def test_has_errors(self, api_latest: Paperless) -> None: 440 | """Test has errors.""" 441 | data = { 442 | "database": { 443 | "status": "OK", 444 | }, 445 | "tasks": { 446 | "redis_status": "OK", 447 | "celery_status": "OK", 448 | "classifier_status": "OK", 449 | }, 450 | } 451 | 452 | # everything fine as we initialized Status with OK values only 453 | status = Status.create_with_data(api_latest, data=data, fetched=True) 454 | assert status.has_errors is False 455 | 456 | # lets set something to ERROR 457 | data["database"]["status"] = "ERROR" 458 | status = Status.create_with_data(api_latest, data=data, fetched=True) 459 | assert status.has_errors is True 460 | 461 | # assume any status value is None; None values are treated as no errors 462 | del data["database"]["status"] 463 | status = Status.create_with_data(api_latest, data=data, fetched=True) 464 | assert status.has_errors is False 465 | 466 | 467 | # test models/tasks.py 468 | class TestModelTasks: 469 | """Tasks test cases.""" 470 | 471 | async def test_iter(self, resp: aioresponses, api_latest: Paperless) -> None: 472 | """Test iter.""" 473 | resp.get( 474 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH['tasks']}" + r".*$"), 475 | status=200, 476 | payload=PATCHWORK["tasks"], 477 | ) 478 | async for item in api_latest.tasks: 479 | assert isinstance(item, Task) 480 | 481 | async def test_call(self, resp: aioresponses, api_latest: Paperless) -> None: 482 | """Test call.""" 483 | # by pk 484 | resp.get( 485 | f"{PAPERLESS_TEST_URL}{API_PATH['tasks_single']}".format(pk=1), 486 | status=200, 487 | payload=PATCHWORK["tasks"][0], 488 | ) 489 | item = await api_latest.tasks(1) 490 | assert item 491 | assert isinstance(item, Task) 492 | # by uuid 493 | resp.get( 494 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH['tasks']}" + r"\?task_id.*$"), 495 | status=200, 496 | payload=PATCHWORK["tasks"], 497 | ) 498 | item = await api_latest.tasks("dummy-found") 499 | assert item 500 | assert isinstance(item, Task) 501 | # must raise as pk doesn't exist 502 | resp.get( 503 | f"{PAPERLESS_TEST_URL}{API_PATH['tasks_single']}".format(pk=1337), 504 | status=404, 505 | ) 506 | with pytest.raises(aiohttp.ClientResponseError): 507 | await api_latest.tasks(1337) 508 | # must raise as task_id doesn't exist 509 | resp.get( 510 | re.compile(r"^" + f"{PAPERLESS_TEST_URL}{API_PATH['tasks']}" + r"\?task_id.*$"), 511 | status=200, 512 | payload=[], 513 | ) 514 | with pytest.raises(TaskNotFoundError): 515 | await api_latest.tasks("dummy-not-found") 516 | 517 | 518 | # test models/workflows.py 519 | class TestModelWorkflows: 520 | """Tasks test cases.""" 521 | 522 | async def test_helpers(self, api_latest: Paperless) -> None: 523 | """Test helpers.""" 524 | assert isinstance(api_latest.workflows.actions, WorkflowActionHelper) 525 | assert isinstance(api_latest.workflows.triggers, WorkflowTriggerHelper) 526 | --------------------------------------------------------------------------------