diff --git a/.codecov.yml b/.codecov.yml index b4037d507..e6b4d0347 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,6 +1,7 @@ +# https://docs.codecov.com/docs/codecovyml-reference#codecov codecov: require_ci_to_pass: true - # https://docs.codecov.com/docs/components +# https://docs.codecov.com/docs/components component_management: individual_components: - component_id: backend @@ -9,35 +10,70 @@ component_management: - component_id: frontend paths: - src-ui/** +# https://docs.codecov.com/docs/flags#step-2-flag-management-in-yaml +# https://docs.codecov.com/docs/carryforward-flags flags: - backend: + # Backend Python versions + backend-python-3.10: paths: - src/** carryforward: true - frontend: + backend-python-3.11: + paths: + - src/** + carryforward: true + backend-python-3.12: + paths: + - src/** + carryforward: true + # Frontend (shards merge into single flag) + frontend-node-24.x: paths: - src-ui/** carryforward: true -# https://docs.codecov.com/docs/pull-request-comments comment: layout: "header, diff, components, flags, files" - # https://docs.codecov.com/docs/javascript-bundle-analysis require_bundle_changes: true bundle_change_threshold: "50Kb" coverage: + # https://docs.codecov.com/docs/commit-status status: project: - default: + backend: + flags: + - backend-python-3.10 + - backend-python-3.11 + - backend-python-3.12 + paths: + - src/** # https://docs.codecov.com/docs/commit-status#threshold threshold: 1% + removed_code_behavior: adjust_base + frontend: + flags: + - frontend-node-24.x + paths: + - src-ui/** + threshold: 1% + removed_code_behavior: adjust_base patch: - default: - # For the changed lines only, target 100% covered, but - # allow as low as 75% + backend: + flags: + - backend-python-3.10 + - backend-python-3.11 + - backend-python-3.12 + paths: + - src/** + target: 100% + threshold: 25% + frontend: + flags: + - frontend-node-24.x + paths: + - src-ui/** target: 100% threshold: 25% # https://docs.codecov.com/docs/javascript-bundle-analysis bundle_analysis: - # Fail if the bundle size increases by more than 1MB warning_threshold: "1MB" status: true diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 89c8a96ea..2b8169f24 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -44,6 +44,7 @@ include-labels: - 'notable' exclude-labels: - 'skip-changelog' +filter-by-commitish: true category-template: '### $TITLE' change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))' change-title-escapes: '\<*_&#@' diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index 25d19f73a..98c10396c 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -88,13 +88,13 @@ jobs: if: always() uses: codecov/codecov-action@v5 with: - flags: backend,backend-python-${{ matrix.python-version }} + flags: backend-python-${{ matrix.python-version }} files: junit.xml report_type: test_results - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: - flags: backend,backend-python-${{ matrix.python-version }} + flags: backend-python-${{ matrix.python-version }} files: coverage.xml report_type: coverage - name: Stop containers diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5793ecfa1..2fd465fdd 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -35,7 +35,7 @@ jobs: contents: read packages: write outputs: - can-push: ${{ steps.check-push.outputs.can-push }} + should-push: ${{ steps.check-push.outputs.should-push }} push-external: ${{ steps.check-push.outputs.push-external }} repository: ${{ steps.repo.outputs.name }} ref-name: ${{ steps.ref.outputs.name }} @@ -59,16 +59,28 @@ jobs: env: REF_NAME: ${{ steps.ref.outputs.name }} run: | - # can-push: Can we push to GHCR? - # True for: pushes, or PRs from the same repo (not forks) - can_push=${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} - echo "can-push=${can_push}" - echo "can-push=${can_push}" >> $GITHUB_OUTPUT + # should-push: Should we push to GHCR? + # True for: + # 1. Pushes (tags/dev/beta) - filtered via the workflow triggers + # 2. Internal PRs where the branch name starts with 'feature-' - filtered here when a PR is synced + + should_push="false" + + if [[ "${{ github.event_name }}" == "push" ]]; then + should_push="true" + elif [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then + if [[ "${REF_NAME}" == feature-* || "${REF_NAME}" == fix-* ]]; then + should_push="true" + fi + fi + + echo "should-push=${should_push}" + echo "should-push=${should_push}" >> $GITHUB_OUTPUT # push-external: Should we also push to Docker Hub and Quay.io? # Only for main repo on dev/beta branches or version tags push_external="false" - if [[ "${can_push}" == "true" && "${{ github.repository_owner }}" == "paperless-ngx" ]]; then + if [[ "${should_push}" == "true" && "${{ github.repository_owner }}" == "paperless-ngx" ]]; then case "${REF_NAME}" in dev|beta) push_external="true" @@ -98,6 +110,12 @@ jobs: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Maximize space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf /usr/local/share/boost + sudo rm -rf "$AGENT_TOOLSDIRECTORY" - name: Docker metadata id: docker-meta uses: docker/metadata-action@v5.10.0 @@ -119,20 +137,20 @@ jobs: labels: ${{ steps.docker-meta.outputs.labels }} build-args: | PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }},push-by-digest=true,name-canonical=true,push=${{ steps.check-push.outputs.can-push }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }},push-by-digest=true,name-canonical=true,push=${{ steps.check-push.outputs.should-push }} cache-from: | type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:${{ steps.ref.outputs.cache-ref }}-${{ matrix.arch }} type=registry,ref=${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}/cache/app:dev-${{ matrix.arch }} - cache-to: ${{ steps.check-push.outputs.can-push == 'true' && format('type=registry,mode=max,ref={0}/{1}/cache/app:{2}-{3}', env.REGISTRY, steps.repo.outputs.name, steps.ref.outputs.cache-ref, matrix.arch) || '' }} + cache-to: ${{ steps.check-push.outputs.should-push == 'true' && format('type=registry,mode=max,ref={0}/{1}/cache/app:{2}-{3}', env.REGISTRY, steps.repo.outputs.name, steps.ref.outputs.cache-ref, matrix.arch) || '' }} - name: Export digest - if: steps.check-push.outputs.can-push == 'true' + if: steps.check-push.outputs.should-push == 'true' run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" echo "digest=${digest}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest - if: steps.check-push.outputs.can-push == 'true' + if: steps.check-push.outputs.should-push == 'true' uses: actions/upload-artifact@v6.0.0 with: name: digests-${{ matrix.arch }} @@ -143,7 +161,7 @@ jobs: name: Merge and Push Manifest runs-on: ubuntu-24.04 needs: build-arch - if: needs.build-arch.outputs.can-push == 'true' + if: needs.build-arch.outputs.should-push == 'true' permissions: contents: read packages: write diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml index 907d36abf..d800fe827 100644 --- a/.github/workflows/ci-frontend.yml +++ b/.github/workflows/ci-frontend.yml @@ -109,13 +109,13 @@ jobs: if: always() uses: codecov/codecov-action@v5 with: - flags: frontend,frontend-node-${{ matrix.node-version }} + flags: frontend-node-${{ matrix.node-version }} directory: src-ui/ report_type: test_results - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: - flags: frontend,frontend-node-${{ matrix.node-version }} + flags: frontend-node-${{ matrix.node-version }} directory: src-ui/coverage/ e2e-tests: name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})" diff --git a/Dockerfile b/Dockerfile index 5262ea124..ba0592509 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ RUN set -eux \ # Purpose: Installs s6-overlay and rootfs # Comments: # - Don't leave anything extra in here either -FROM ghcr.io/astral-sh/uv:0.9.15-python3.12-trixie-slim AS s6-overlay-base +FROM ghcr.io/astral-sh/uv:0.9.26-python3.12-trixie-slim AS s6-overlay-base WORKDIR /usr/src/s6 @@ -196,7 +196,11 @@ RUN set -eux \ && apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \ && echo "Installing Python requirements" \ && uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt \ - && uv pip install --no-cache --system --no-python-downloads --python-preference system --requirements requirements.txt \ + && uv pip install --no-cache --system --no-python-downloads --python-preference system \ + --index https://pypi.org/simple \ + --index https://download.pytorch.org/whl/cpu \ + --index-strategy unsafe-best-match \ + --requirements requirements.txt \ && echo "Installing NLTK data" \ && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \ && python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \ diff --git a/docker/install_management_commands.sh b/docker/install_management_commands.sh index 17dae68a2..f7a175e9e 100755 --- a/docker/install_management_commands.sh +++ b/docker/install_management_commands.sh @@ -4,13 +4,13 @@ set -eu -for command in decrypt_documents \ - document_archiver \ +for command in document_archiver \ document_exporter \ document_importer \ mail_fetcher \ document_create_classifier \ document_index \ + document_llmindex \ document_renamer \ document_retagger \ document_thumbnails \ diff --git a/docker/rootfs/usr/local/bin/decrypt_documents b/docker/rootfs/usr/local/bin/document_llmindex similarity index 65% rename from docker/rootfs/usr/local/bin/decrypt_documents rename to docker/rootfs/usr/local/bin/document_llmindex index 4da1549ee..8e51245e1 100755 --- a/docker/rootfs/usr/local/bin/decrypt_documents +++ b/docker/rootfs/usr/local/bin/document_llmindex @@ -6,9 +6,9 @@ set -e cd "${PAPERLESS_SRC_DIR}" if [[ $(id -u) == 0 ]]; then - s6-setuidgid paperless python3 manage.py decrypt_documents "$@" + s6-setuidgid paperless python3 manage.py document_llmindex "$@" elif [[ $(id -un) == "paperless" ]]; then - python3 manage.py decrypt_documents "$@" + python3 manage.py document_llmindex "$@" else echo "Unknown user." fi diff --git a/docs/administration.md b/docs/administration.md index ddf51bf9a..2fb70a806 100644 --- a/docs/administration.md +++ b/docs/administration.md @@ -580,36 +580,6 @@ document. documents, such as encrypted PDF documents. The archiver will skip over these documents each time it sees them. -### Managing encryption {#encryption} - -!!! warning - - Encryption was removed in [paperless-ng 0.9](changelog.md#paperless-ng-090) - because it did not really provide any additional security, the passphrase - was stored in a configuration file on the same system as the documents. - Furthermore, the entire text content of the documents is stored plain in - the database, even if your documents are encrypted. Filenames are not - encrypted as well. Finally, the web server provides transparent access to - your encrypted documents. - - Consider running paperless on an encrypted filesystem instead, which - will then at least provide security against physical hardware theft. - -#### Enabling encryption - -Enabling encryption is no longer supported. - -#### Disabling encryption - -Basic usage to disable encryption of your document store: - -(Note: If `PAPERLESS_PASSPHRASE` isn't set already, you need to specify -it here) - -``` -decrypt_documents [--passphrase SECR3TP4SSPHRA$E] -``` - ### Detecting duplicates {#fuzzy_duplicate} Paperless already catches and prevents upload of exactly matching documents, diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md index de1068864..89e076167 100644 --- a/docs/advanced_usage.md +++ b/docs/advanced_usage.md @@ -501,7 +501,7 @@ The `datetime` filter formats a datetime string or datetime object using Python' See the [strftime format code documentation](https://docs.python.org/3.13/library/datetime.html#strftime-and-strptime-format-codes) for the possible codes and their meanings. -##### Date Localization +##### Date Localization {#date-localization} The `localize_date` filter formats a date or datetime object into a localized string using Babel internationalization. This takes into account the provided locale for translation. Since this must be used on a date or datetime object, @@ -851,8 +851,8 @@ followed by the even pages. It's important that the scan files get consumed in the correct order, and one at a time. You therefore need to make sure that Paperless is running while you upload the files into -the directory; and if you're using [polling](configuration.md#polling), make sure that -`CONSUMER_POLLING` is set to a value lower than it takes for the second scan to appear, +the directory; and if you're using polling, make sure that +`CONSUMER_POLLING_INTERVAL` is set to a value lower than it takes for the second scan to appear, like 5-10 or even lower. Another thing that might happen is that you start a double sided scan, but then forget diff --git a/docs/changelog.md b/docs/changelog.md index 189c74ce1..f222a7305 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,9 +1,60 @@ # Changelog +## paperless-ngx 2.20.5 + +### Bug Fixes + +- Fix: ensure horizontal scroll for long tag names in list, wrap tags without parent [@shamoon](https://github.com/shamoon) ([#11811](https://github.com/paperless-ngx/paperless-ngx/pull/11811)) +- Fix: use explicit order field for workflow actions [@shamoon](https://github.com/shamoon) [@stumpylog](https://github.com/stumpylog) ([#11781](https://github.com/paperless-ngx/paperless-ngx/pull/11781)) + +### All App Changes + +
+2 changes + +- Fix: ensure horizontal scroll for long tag names in list, wrap tags without parent [@shamoon](https://github.com/shamoon) ([#11811](https://github.com/paperless-ngx/paperless-ngx/pull/11811)) +- Fix: use explicit order field for workflow actions [@shamoon](https://github.com/shamoon) [@stumpylog](https://github.com/stumpylog) ([#11781](https://github.com/paperless-ngx/paperless-ngx/pull/11781)) +
+ +## paperless-ngx 2.20.4 + +### Security + +- Resolve [GHSA-28cf-xvcf-hw6m](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-28cf-xvcf-hw6m) + +### Bug Fixes + +- Fix: propagate metadata override created value [@shamoon](https://github.com/shamoon) ([#11659](https://github.com/paperless-ngx/paperless-ngx/pull/11659)) +- Fix: support ordering by storage path name [@shamoon](https://github.com/shamoon) ([#11661](https://github.com/paperless-ngx/paperless-ngx/pull/11661)) +- Fix: validate cf integer values within PostgreSQL range [@shamoon](https://github.com/shamoon) ([#11666](https://github.com/paperless-ngx/paperless-ngx/pull/11666)) +- Fixhancement: add error handling and retry when opening index [@shamoon](https://github.com/shamoon) ([#11731](https://github.com/paperless-ngx/paperless-ngx/pull/11731)) +- Fix: fix recurring workflow to respect latest run time [@shamoon](https://github.com/shamoon) ([#11735](https://github.com/paperless-ngx/paperless-ngx/pull/11735)) + +### All App Changes + +
+5 changes + +- Fix: propagate metadata override created value [@shamoon](https://github.com/shamoon) ([#11659](https://github.com/paperless-ngx/paperless-ngx/pull/11659)) +- Fix: support ordering by storage path name [@shamoon](https://github.com/shamoon) ([#11661](https://github.com/paperless-ngx/paperless-ngx/pull/11661)) +- Fix: validate cf integer values within PostgreSQL range [@shamoon](https://github.com/shamoon) ([#11666](https://github.com/paperless-ngx/paperless-ngx/pull/11666)) +- Fixhancement: add error handling and retry when opening index [@shamoon](https://github.com/shamoon) ([#11731](https://github.com/paperless-ngx/paperless-ngx/pull/11731)) +- Fix: fix recurring workflow to respect latest run time [@shamoon](https://github.com/shamoon) ([#11735](https://github.com/paperless-ngx/paperless-ngx/pull/11735)) +
+ ## paperless-ngx 2.20.3 +### Security + +- Resolve [GHSA-7cq3-mhxq-w946](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-7cq3-mhxq-w946) + ## paperless-ngx 2.20.2 +### Security + +- Resolve [GHSA-6653-vcx4-69mc](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-6653-vcx4-69mc) +- Resolve [GHSA-24x5-wp64-9fcc](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-24x5-wp64-9fcc) + ### Features / Enhancements - Tweakhancement: dim inactive users in users-groups list [@shamoon](https://github.com/shamoon) ([#11537](https://github.com/paperless-ngx/paperless-ngx/pull/11537)) diff --git a/docs/configuration.md b/docs/configuration.md index e1f6f6d4c..cc829342d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -170,11 +170,18 @@ Available options are `postgresql` and `mariadb`. !!! note - A small pool is typically sufficient — for example, a size of 4. - Make sure your PostgreSQL server's max_connections setting is large enough to handle: - ```(Paperless workers + Celery workers) × pool size + safety margin``` - For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4: - (4 + 2) × 4 + 10 = 34 connections required. + A pool of 8-10 connections per worker is typically sufficient. + If you encounter error messages such as `couldn't get a connection` + or database connection timeouts, you probably need to increase the pool size. + + !!! warning + Make sure your PostgreSQL `max_connections` setting is large enough to handle the connection pools: + `(NB_PAPERLESS_WORKERS + NB_CELERY_WORKERS) × POOL_SIZE + SAFETY_MARGIN`. For example, with + 4 Paperless workers and 2 Celery workers, and a pool size of 8:``(4 + 2) × 8 + 10 = 58`, + so `max_connections = 60` (or even more) is appropriate. + + This assumes only Paperless-ngx connects to your PostgreSQL instance. If you have other applications, + you should increase `max_connections` accordingly. #### [`PAPERLESS_DB_READ_CACHE_ENABLED=`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED} @@ -1168,21 +1175,45 @@ don't exist yet. #### [`PAPERLESS_CONSUMER_IGNORE_PATTERNS=`](#PAPERLESS_CONSUMER_IGNORE_PATTERNS) {#PAPERLESS_CONSUMER_IGNORE_PATTERNS} -: By default, paperless ignores certain files and folders in the -consumption directory, such as system files created by the Mac OS -or hidden folders some tools use to store data. +: Additional regex patterns for files to ignore in the consumption directory. Patterns are matched against filenames only (not full paths) +using Python's `re.match()`, which anchors at the start of the filename. - This can be adjusted by configuring a custom json array with - patterns to exclude. + See the [watchfiles documentation](https://watchfiles.helpmanual.io/api/filters/#watchfiles.BaseFilter.ignore_entity_patterns) - For example, `.DS_STORE/*` will ignore any files found in a folder - named `.DS_STORE`, including `.DS_STORE/bar.pdf` and `foo/.DS_STORE/bar.pdf` + This setting is for additional patterns beyond the built-in defaults. Common system files and directories are already ignored automatically. + The patterns will be compiled via Python's standard `re` module. - A pattern like `._*` will ignore anything starting with `._`, including: - `._foo.pdf` and `._bar/foo.pdf` + Example custom patterns: - Defaults to - `[".DS_Store", ".DS_STORE", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*", "Thumbs.db"]`. + ```json + ["^temp_", "\\.bak$", "^~"] + ``` + + This would ignore: + + - Files starting with `temp_` (e.g., `temp_scan.pdf`) + - Files ending with `.bak` (e.g., `document.pdf.bak`) + - Files starting with `~` (e.g., `~$document.docx`) + + Defaults to `[]` (empty list, uses only built-in defaults). + + The default ignores are `[.DS_Store, .DS_STORE, ._*, desktop.ini, Thumbs.db]` and cannot be overridden. + +#### [`PAPERLESS_CONSUMER_IGNORE_DIRS=`](#PAPERLESS_CONSUMER_IGNORE_DIRS) {#PAPERLESS_CONSUMER_IGNORE_DIRS} + +: Additional directory names to ignore in the consumption directory. Directories matching these names (and all their contents) will be skipped. + + This setting is for additional directories beyond the built-in defaults. Matching is done by directory name only, not full path. + + Example: + + ```json + ["temp", "incoming", ".hidden"] + ``` + + Defaults to `[]` (empty list, uses only built-in defaults). + + The default ignores are `[.stfolder, .stversions, .localized, @eaDir, .Spotlight-V100, .Trashes, __MACOSX]` and cannot be overridden. #### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER} @@ -1281,48 +1312,24 @@ within your documents. Defaults to false. -### Polling {#polling} +#### [`PAPERLESS_CONSUMER_POLLING_INTERVAL=`](#PAPERLESS_CONSUMER_POLLING_INTERVAL) {#PAPERLESS_CONSUMER_POLLING_INTERVAL} -#### [`PAPERLESS_CONSUMER_POLLING=`](#PAPERLESS_CONSUMER_POLLING) {#PAPERLESS_CONSUMER_POLLING} +: Configures how the consumer detects new files in the consumption directory. -: If paperless won't find documents added to your consume folder, it -might not be able to automatically detect filesystem changes. In -that case, specify a polling interval in seconds here, which will -then cause paperless to periodically check your consumption -directory for changes. This will also disable listening for file -system changes with `inotify`. + When set to `0` (default), paperless uses native filesystem notifications for efficient, immediate detection of new files. - Defaults to 0, which disables polling and uses filesystem - notifications. + When set to a positive number, paperless polls the consumption directory at that interval in seconds. Use polling for network filesystems (NFS, SMB/CIFS) where native notifications may not work reliably. -#### [`PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=`](#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT) {#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT} + Defaults to 0. -: If consumer polling is enabled, sets the maximum number of times -paperless will check for a file to remain unmodified. If a file's -modification time and size are identical for two consecutive checks, it -will be consumed. +#### [`PAPERLESS_CONSUMER_STABILITY_DELAY=`](#PAPERLESS_CONSUMER_STABILITY_DELAY) {#PAPERLESS_CONSUMER_STABILITY_DELAY} - Defaults to 5. +: Sets the time in seconds that a file must remain unchanged (same size and modification time) before paperless will begin consuming it. -#### [`PAPERLESS_CONSUMER_POLLING_DELAY=`](#PAPERLESS_CONSUMER_POLLING_DELAY) {#PAPERLESS_CONSUMER_POLLING_DELAY} + Increase this value if you experience issues with files being consumed before they are fully written, particularly on slower network storage or + with certain scanner quirks -: If consumer polling is enabled, sets the delay in seconds between -each check (above) paperless will do while waiting for a file to -remain unmodified. - - Defaults to 5. - -### iNotify {#inotify} - -#### [`PAPERLESS_CONSUMER_INOTIFY_DELAY=`](#PAPERLESS_CONSUMER_INOTIFY_DELAY) {#PAPERLESS_CONSUMER_INOTIFY_DELAY} - -: Sets the time in seconds the consumer will wait for additional -events from inotify before the consumer will consider a file ready -and begin consumption. Certain scanners or network setups may -generate multiple events for a single file, leading to multiple -consumers working on the same file. Configure this to prevent that. - - Defaults to 0.5 seconds. + Defaults to 5.0 seconds. ## Workflow webhooks @@ -1824,3 +1831,67 @@ password. All of these options come from their similarly-named [Django settings] : The endpoint to use for the remote OCR engine. This is required for Azure AI. Defaults to None. + +## AI {#ai} + +#### [`PAPERLESS_AI_ENABLED=`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED} + +: Enables the AI features in Paperless. This includes the AI-based +suggestions. This setting is required to be set to true in order to use the AI features. + + Defaults to false. + +#### [`PAPERLESS_AI_LLM_EMBEDDING_BACKEND=`](#PAPERLESS_AI_LLM_EMBEDDING_BACKEND) {#PAPERLESS_AI_LLM_EMBEDDING_BACKEND} + +: The embedding backend to use for RAG. This can be either "openai" or "huggingface". + + Defaults to None. + +#### [`PAPERLESS_AI_LLM_EMBEDDING_MODEL=`](#PAPERLESS_AI_LLM_EMBEDDING_MODEL) {#PAPERLESS_AI_LLM_EMBEDDING_MODEL} + +: The model to use for the embedding backend for RAG. This can be set to any of the embedding models supported by the current embedding backend. If not supplied, defaults to "text-embedding-3-small" for OpenAI and "sentence-transformers/all-MiniLM-L6-v2" for Huggingface. + + Defaults to None. + +#### [`PAPERLESS_AI_LLM_BACKEND=`](#PAPERLESS_AI_LLM_BACKEND) {#PAPERLESS_AI_LLM_BACKEND} + +: The AI backend to use. This can be either "openai" or "ollama". If set to "ollama", the AI +features will be run locally on your machine. If set to "openai", the AI features will be run +using the OpenAI API. This setting is required to be set to use the AI features. + + Defaults to None. + + !!! note + + The OpenAI API is a paid service. You will need to set up an OpenAI account and + will be charged for usage incurred by Paperless-ngx features and your document data + will (of course) be sent to the OpenAI API. Paperless-ngx does not endorse the use of the + OpenAI API in any way. + + Refer to the OpenAI terms of service, and use at your own risk. + +#### [`PAPERLESS_AI_LLM_MODEL=`](#PAPERLESS_AI_LLM_MODEL) {#PAPERLESS_AI_LLM_MODEL} + +: The model to use for the AI backend, i.e. "gpt-3.5-turbo", "gpt-4" or any of the models supported by the +current backend. If not supplied, defaults to "gpt-3.5-turbo" for OpenAI and "llama3.1" for Ollama. + + Defaults to None. + +#### [`PAPERLESS_AI_LLM_API_KEY=`](#PAPERLESS_AI_LLM_API_KEY) {#PAPERLESS_AI_LLM_API_KEY} + +: The API key to use for the AI backend. This is required for the OpenAI backend (optional for others). + + Defaults to None. + +#### [`PAPERLESS_AI_LLM_ENDPOINT=`](#PAPERLESS_AI_LLM_ENDPOINT) {#PAPERLESS_AI_LLM_ENDPOINT} + +: The endpoint / url to use for the AI backend. This is required for the Ollama backend (optional for others). + + Defaults to None. + +#### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON} + +: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if +AI is enabled and the LLM embedding backend is set. + + Defaults to `10 2 * * *`, once per day. diff --git a/docs/index.md b/docs/index.md index c84cd0ce4..1d72f8f6c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -31,6 +31,7 @@ physical documents into a searchable online archive so you can keep, well, _less - _New!_ Supports remote OCR with Azure AI (opt-in). - Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals. - Uses machine-learning to automatically add tags, correspondents and document types to your documents. +- **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default). - Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more. - Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents. - **Beautiful, modern web application** that features: diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 000000000..1c934e6df --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,25 @@ +# v3 Migration Guide + +## Consumer Settings Changes + +The v3 consumer command uses a [different library](https://watchfiles.helpmanual.io/) to unify +the watching for new files in the consume directory. For the user, this removes several configuration options related to delays and retries +and replaces with a single unified setting. It also adjusts how the consumer ignore filtering happens, replaced `fnmatch` with `regex` and +separating the directory ignore from the file ignore. + +### Summary + +| Old Setting | New Setting | Notes | +| ------------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `CONSUMER_POLLING` | [`CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL) | Renamed for clarity | +| `CONSUMER_INOTIFY_DELAY` | [`CONSUMER_STABILITY_DELAY`](configuration.md#PAPERLESS_CONSUMER_STABILITY_DELAY) | Unified for all modes | +| `CONSUMER_POLLING_DELAY` | _Removed_ | Use `CONSUMER_STABILITY_DELAY` | +| `CONSUMER_POLLING_RETRY_COUNT` | _Removed_ | Automatic with stability tracking | +| `CONSUMER_IGNORE_PATTERNS` | [`CONSUMER_IGNORE_PATTERNS`](configuration.md#PAPERLESS_CONSUMER_IGNORE_PATTERNS) | **Now regex, not fnmatch**; user patterns are added to (not replacing) default ones | +| _New_ | [`CONSUMER_IGNORE_DIRS`](configuration.md#PAPERLESS_CONSUMER_IGNORE_DIRS) | Additional directories to ignore; user entries are added to (not replacing) defaults | + +## Encryption Support + +Document and thumbnail encryption is no longer supported. This was previously deprecated in [paperless-ng 0.9.3](https://github.com/paperless-ngx/paperless-ngx/blob/dev/docs/changelog.md#paperless-ng-093) + +Users must decrypt their document using the `decrypt_documents` command before upgrading. diff --git a/docs/setup.md b/docs/setup.md index 3e7ac1be3..f0381f076 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -124,8 +124,7 @@ account. The script essentially automatically performs the steps described in [D system notifications with `inotify`. When storing the consumption directory on such a file system, paperless will not pick up new files with the default configuration. You will need to use - [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING), which will disable inotify. See - [here](configuration.md#polling). + [`PAPERLESS_CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL), which will disable inotify. 5. Run `docker compose pull`. This will pull the image from the GitHub container registry by default but you can change the image to pull from Docker Hub by changing the `image` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e20751875..94e12307e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -46,9 +46,9 @@ run: If you notice that the consumer will only pickup files in the consumption directory at startup, but won't find any other files added later, you will need to enable filesystem polling with the configuration -option [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING). +option [`PAPERLESS_CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL). -This will disable listening to filesystem changes with inotify and +This will disable automatic listening for filesystem changes and paperless will manually check the consumption directory for changes instead. @@ -234,47 +234,9 @@ FileNotFoundError: [Errno 2] No such file or directory: '/tmp/ocrmypdf.io.yhk3zb This probably indicates paperless tried to consume the same file twice. This can happen for a number of reasons, depending on how documents are -placed into the consume folder. If paperless is using inotify (the -default) to check for documents, try adjusting the -[inotify configuration](configuration.md#inotify). If polling is enabled, try adjusting the -[polling configuration](configuration.md#polling). - -## Consumer fails waiting for file to remain unmodified. - -You might find messages like these in your log files: - -``` -[ERROR] [paperless.management.consumer] Timeout while waiting on file /usr/src/paperless/src/../consume/SCN_0001.pdf to remain unmodified. -``` - -This indicates paperless timed out while waiting for the file to be -completely written to the consume folder. Adjusting -[polling configuration](configuration.md#polling) values should resolve the issue. - -!!! note - - The user will need to manually move the file out of the consume folder - and back in, for the initial failing file to be consumed. - -## Consumer fails reporting "OS reports file as busy still". - -You might find messages like these in your log files: - -``` -[WARNING] [paperless.management.consumer] Not consuming file /usr/src/paperless/src/../consume/SCN_0001.pdf: OS reports file as busy still -``` - -This indicates paperless was unable to open the file, as the OS reported -the file as still being in use. To prevent a crash, paperless did not -try to consume the file. If paperless is using inotify (the default) to -check for documents, try adjusting the -[inotify configuration](configuration.md#inotify). If polling is enabled, try adjusting the -[polling configuration](configuration.md#polling). - -!!! note - - The user will need to manually move the file out of the consume folder - and back in, for the initial failing file to be consumed. +placed into the consume folder, such as how a scanner may modify a file multiple times as it scans. +Try adjusting the +[file stability delay](configuration.md#PAPERLESS_CONSUMER_STABILITY_DELAY) to a larger value. ## Log reports "Creating PaperlessTask failed". diff --git a/docs/usage.md b/docs/usage.md index a307db3cd..7da83a3e1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -278,6 +278,28 @@ Once setup, navigating to the email settings page in Paperless-ngx will allow yo You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads) for details. +## Document Suggestions + +Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user. + +## AI Features + +Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration. + +!!! warning + + Remember that Paperless-ngx will send document content to the AI provider you have configured, so consider the privacy implications of using these features, especially if using a remote model (e.g. OpenAI), instead of the default local model. + +The AI features work by creating an embedding of the text content and metadata of documents, which is then used for various tasks such as similarity search and question answering. This uses the FAISS vector store. + +### AI-Enhanced Suggestions + +If enabled, Paperless-ngx can use an AI LLM model to suggest document titles, dates, tags, correspondents and document types for documents. This feature will always be "opt-in" and does not disable the existing classifier-based suggestion system. Currently, both remote (via the OpenAI API) and local (via Ollama) models are supported, see [configuration](configuration.md#ai) for details. + +### Document Chat + +Paperless-ngx can use an AI LLM model to answer questions about a document or across multiple documents. Again, this feature works best when RAG is enabled. The chat feature is available in the upper app toolbar and will switch between chatting across multiple documents or a single document based on the current view. + ## Sharing documents from Paperless-ngx Paperless-ngx supports sharing documents with other users by assigning them [permissions](#object-permissions) @@ -543,7 +565,7 @@ This allows for complex logic to be used to generate the title, including [logic and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11). The template is provided as a string. -Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#Date-Localization) in the title. +Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#date-localization) in the title. The available inputs differ depending on the type of workflow trigger. This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been @@ -575,6 +597,7 @@ The following placeholders are only available for "added" or "updated" triggers - `{{created_day}}`: created day - `{{created_time}}`: created time in HH:MM format - `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set. +- `{{doc_id}}`: Document ID ##### Examples diff --git a/mkdocs.yml b/mkdocs.yml index 05826f25f..69a15193a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,8 +69,9 @@ nav: - development.md - 'FAQs': faq.md - troubleshooting.md + - 'Migration to v3': migration.md - changelog.md -copyright: Copyright © 2016 - 2023 Daniel Quinn, Jonas Winkler, and the Paperless-ngx team +copyright: Copyright © 2016 - 2026 Daniel Quinn, Jonas Winkler, and the Paperless-ngx team extra: social: - icon: fontawesome/brands/github diff --git a/paperless.conf.example b/paperless.conf.example index 1ba21f41d..424f6cce9 100644 --- a/paperless.conf.example +++ b/paperless.conf.example @@ -55,10 +55,10 @@ #PAPERLESS_TASK_WORKERS=1 #PAPERLESS_THREADS_PER_WORKER=1 #PAPERLESS_TIME_ZONE=UTC -#PAPERLESS_CONSUMER_POLLING=10 +#PAPERLESS_CONSUMER_POLLING_INTERVAL=10 #PAPERLESS_CONSUMER_DELETE_DUPLICATES=false #PAPERLESS_CONSUMER_RECURSIVE=false -#PAPERLESS_CONSUMER_IGNORE_PATTERNS=[".DS_STORE/*", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini"] +#PAPERLESS_CONSUMER_IGNORE_PATTERNS=[] # Defaults are built in; add filename regexes, e.g. ["^\\.DS_Store$", "^desktop\\.ini$"] #PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS=false #PAPERLESS_CONSUMER_ENABLE_BARCODES=false #PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT diff --git a/pyproject.toml b/pyproject.toml index fb47e55f1..097e2c19b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "paperless-ngx" -version = "2.20.3" +version = "2.20.5" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" readme = "README.md" requires-python = ">=3.10" @@ -27,8 +27,8 @@ dependencies = [ # WARNING: django does not use semver. # Only patch versions are guaranteed to not introduce breaking changes. "django~=5.2.5", - "django-allauth[mfa,socialaccount]~=65.12.1", - "django-auditlog~=3.3.0", + "django-allauth[mfa,socialaccount]~=65.13.1", + "django-auditlog~=3.4.1", "django-cachalot~=2.8.0", "django-celery-results~=2.6.0", "django-compression-middleware~=0.5.0", @@ -44,16 +44,23 @@ dependencies = [ "drf-spectacular~=0.28", "drf-spectacular-sidecar~=2025.10.1", "drf-writable-nested~=0.7.1", + "faiss-cpu>=1.10", "filelock~=3.20.0", "flower~=2.0.1", - "gotenberg-client~=0.12.0", + "gotenberg-client~=0.13.1", "httpx-oauth~=0.16", "imap-tools~=1.11.0", - "inotifyrecursive~=0.3", "jinja2~=3.1.5", "langdetect~=1.0.9", + "llama-index-core>=0.14.12", + "llama-index-embeddings-huggingface>=0.6.1", + "llama-index-embeddings-openai>=0.5.1", + "llama-index-llms-ollama>=0.9.1", + "llama-index-llms-openai>=0.6.13", + "llama-index-vector-stores-faiss>=0.5.2", "nltk~=3.9.1", - "ocrmypdf~=16.12.0", + "ocrmypdf~=16.13.0", + "openai>=1.76", "pathvalidate~=3.3.1", "pdf2image~=1.17.0", "python-dateutil~=2.9.0", @@ -66,10 +73,12 @@ dependencies = [ "redis[hiredis]~=5.2.1", "regex>=2025.9.18", "scikit-learn~=1.7.0", + "sentence-transformers>=4.1", "setproctitle~=1.3.4", "tika-client~=0.10.0", + "torch~=2.9.1", "tqdm~=4.67.1", - "watchdog~=6.0", + "watchfiles>=1.1.1", "whitenoise~=6.9", "whoosh-reloaded>=2.7.5", "zxing-cpp~=2.3.0", @@ -82,7 +91,7 @@ optional-dependencies.postgres = [ "psycopg[c,pool]==3.2.12", # Direct dependency for proper resolution of the pre-built wheels "psycopg-c==3.2.12", - "psycopg-pool==3.2.7", + "psycopg-pool==3.3", ] optional-dependencies.webserver = [ "granian[uvloop]~=2.5.1", @@ -117,7 +126,7 @@ testing = [ ] lint = [ - "pre-commit~=4.4.0", + "pre-commit~=4.5.1", "pre-commit-uv~=4.2.0", "ruff~=0.14.0", ] @@ -160,6 +169,15 @@ zxing-cpp = [ { url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" }, ] +torch = [ + { index = "pytorch-cpu" }, +] + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" +explicit = true + [tool.ruff] target-version = "py310" line-length = 88 @@ -255,6 +273,7 @@ testpaths = [ "src/paperless_tika/tests", "src/paperless_text/tests/", "src/paperless_remote/tests/", + "src/paperless_ai/tests", ] addopts = [ "--pythonwarnings=all", diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index a27f4f72e..5cab6203c 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -297,11 +297,11 @@ src/app/components/app-frame/app-frame.component.html - 84 + 87 src/app/components/app-frame/app-frame.component.html - 86 + 89 src/app/components/dashboard/dashboard.component.html @@ -316,11 +316,11 @@ src/app/components/app-frame/app-frame.component.html - 91 + 94 src/app/components/app-frame/app-frame.component.html - 93 + 96 src/app/components/document-list/document-list.component.ts @@ -359,15 +359,15 @@ src/app/components/app-frame/app-frame.component.html - 51 + 54 src/app/components/app-frame/app-frame.component.html - 255 + 258 src/app/components/app-frame/app-frame.component.html - 257 + 260 @@ -385,7 +385,7 @@ src/app/components/document-detail/document-detail.component.html - 119 + 109 @@ -530,18 +530,18 @@ Discard src/app/components/admin/config/config.component.html - 53 + 57 src/app/components/document-detail/document-detail.component.html - 380 + 396 Save src/app/components/admin/config/config.component.html - 56 + 60 src/app/components/admin/settings/settings.component.html @@ -593,7 +593,7 @@ src/app/components/document-detail/document-detail.component.html - 373 + 389 src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html @@ -612,42 +612,42 @@ Error retrieving config src/app/components/admin/config/config.component.ts - 103 + 105 Invalid JSON src/app/components/admin/config/config.component.ts - 129 + 131 Configuration updated src/app/components/admin/config/config.component.ts - 173 + 175 An error occurred updating configuration src/app/components/admin/config/config.component.ts - 178 + 180 File successfully updated src/app/components/admin/config/config.component.ts - 200 + 202 An error occurred uploading file src/app/components/admin/config/config.component.ts - 205 + 207 @@ -658,11 +658,11 @@ src/app/components/app-frame/app-frame.component.html - 290 + 293 src/app/components/app-frame/app-frame.component.html - 293 + 296 @@ -761,7 +761,7 @@ src/app/components/document-detail/document-detail.component.html - 393 + 409 src/app/components/document-list/document-list.component.html @@ -1032,11 +1032,11 @@ src/app/components/app-frame/app-frame.component.html - 215 + 218 src/app/components/app-frame/app-frame.component.html - 217 + 220 src/app/components/manage/saved-views/saved-views.component.html @@ -1234,7 +1234,7 @@ src/app/components/document-detail/document-detail.component.html - 349 + 365 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -1601,7 +1601,7 @@ src/app/components/app-frame/app-frame.component.ts - 180 + 182 @@ -1612,11 +1612,11 @@ src/app/components/app-frame/app-frame.component.html - 278 + 281 src/app/components/app-frame/app-frame.component.html - 280 + 283 @@ -2028,11 +2028,11 @@ src/app/components/app-frame/app-frame.component.html - 238 + 241 src/app/components/app-frame/app-frame.component.html - 241 + 244 @@ -2397,11 +2397,11 @@ src/app/components/app-frame/app-frame.component.html - 269 + 272 src/app/components/app-frame/app-frame.component.html - 271 + 274 @@ -2607,11 +2607,11 @@ src/app/components/document-detail/document-detail.component.ts - 1029 + 1098 src/app/components/document-detail/document-detail.component.ts - 1394 + 1463 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -2713,83 +2713,83 @@ Logged in as src/app/components/app-frame/app-frame.component.html - 43 + 46 My Profile src/app/components/app-frame/app-frame.component.html - 47 + 50 Logout src/app/components/app-frame/app-frame.component.html - 54 + 57 Documentation src/app/components/app-frame/app-frame.component.html - 59 - - - src/app/components/app-frame/app-frame.component.html - 299 + 62 src/app/components/app-frame/app-frame.component.html 302 + + src/app/components/app-frame/app-frame.component.html + 305 + Saved views src/app/components/app-frame/app-frame.component.html - 101 + 104 src/app/components/app-frame/app-frame.component.html - 106 + 109 Open documents src/app/components/app-frame/app-frame.component.html - 141 + 144 Close all src/app/components/app-frame/app-frame.component.html - 161 + 164 src/app/components/app-frame/app-frame.component.html - 163 + 166 Manage src/app/components/app-frame/app-frame.component.html - 172 + 175 Correspondents src/app/components/app-frame/app-frame.component.html - 178 + 181 src/app/components/app-frame/app-frame.component.html - 180 + 183 src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html @@ -2800,11 +2800,11 @@ Tags src/app/components/app-frame/app-frame.component.html - 185 + 188 src/app/components/app-frame/app-frame.component.html - 188 + 191 src/app/components/common/input/tags/tags.component.ts @@ -2835,11 +2835,11 @@ Document Types src/app/components/app-frame/app-frame.component.html - 194 + 197 src/app/components/app-frame/app-frame.component.html - 196 + 199 src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html @@ -2850,11 +2850,11 @@ Storage Paths src/app/components/app-frame/app-frame.component.html - 201 + 204 src/app/components/app-frame/app-frame.component.html - 203 + 206 src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html @@ -2865,11 +2865,11 @@ Custom Fields src/app/components/app-frame/app-frame.component.html - 208 + 211 src/app/components/app-frame/app-frame.component.html - 210 + 213 src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html @@ -2884,11 +2884,11 @@ Workflows src/app/components/app-frame/app-frame.component.html - 224 + 227 src/app/components/app-frame/app-frame.component.html - 226 + 229 src/app/components/manage/workflows/workflows.component.html @@ -2899,92 +2899,92 @@ Mail src/app/components/app-frame/app-frame.component.html - 231 + 234 src/app/components/app-frame/app-frame.component.html - 234 + 237 Administration src/app/components/app-frame/app-frame.component.html - 249 + 252 Configuration src/app/components/app-frame/app-frame.component.html - 262 + 265 src/app/components/app-frame/app-frame.component.html - 264 + 267 GitHub src/app/components/app-frame/app-frame.component.html - 309 + 312 is available. src/app/components/app-frame/app-frame.component.html - 318,319 + 321,322 Click to view. src/app/components/app-frame/app-frame.component.html - 319 + 322 Paperless-ngx can automatically check for updates src/app/components/app-frame/app-frame.component.html - 323 + 326 How does this work? src/app/components/app-frame/app-frame.component.html - 330,332 + 333,335 Update available src/app/components/app-frame/app-frame.component.html - 343 + 346 Sidebar views updated src/app/components/app-frame/app-frame.component.ts - 264 + 270 Error updating sidebar views src/app/components/app-frame/app-frame.component.ts - 267 + 273 An error occurred while saving update checking settings. src/app/components/app-frame/app-frame.component.ts - 288 + 294 @@ -3186,6 +3186,20 @@ 20 + + Ask a question about this document... + + src/app/components/chat/chat/chat.component.ts + 37 + + + + Ask a question about a document... + + src/app/components/chat/chat/chat.component.ts + 38 + + Clear @@ -3223,7 +3237,7 @@ src/app/components/document-detail/document-detail.component.ts - 982 + 1051 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -3328,7 +3342,7 @@ src/app/components/document-detail/document-detail.component.ts - 1445 + 1514 @@ -3339,7 +3353,7 @@ src/app/components/document-detail/document-detail.component.ts - 1446 + 1515 @@ -3350,7 +3364,7 @@ src/app/components/document-detail/document-detail.component.ts - 1447 + 1516 @@ -3474,7 +3488,7 @@ src/app/components/document-detail/document-detail.component.html - 113 + 103 src/app/guards/dirty-saved-view.guard.ts @@ -4247,6 +4261,10 @@ src/app/components/common/system-status-dialog/system-status-dialog.component.html 264 + + src/app/components/common/system-status-dialog/system-status-dialog.component.html + 302 + src/app/components/common/toast/toast.component.html 30 @@ -4440,7 +4458,7 @@ src/app/components/document-detail/document-detail.component.html - 315 + 331 @@ -4551,7 +4569,7 @@ src/app/components/document-detail/document-detail.component.html - 98 + 88 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -5626,7 +5644,7 @@ Show password src/app/components/common/input/password/password.component.html - 6 + 12 @@ -5709,6 +5727,13 @@ 55 + + Suggestion: + + src/app/components/common/input/text/text.component.html + 20 + + Read more @@ -5986,7 +6011,7 @@ src/app/components/common/system-status-dialog/system-status-dialog.component.html - 284 + 321 src/app/components/manage/mail/mail.component.html @@ -6271,7 +6296,7 @@ src/app/components/document-detail/document-detail.component.html - 94 + 84 @@ -6302,6 +6327,42 @@ 159 + + Suggest + + src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html + 8 + + + + Show suggestions + + src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html + 17 + + + + No novel suggestions + + src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html + 24 + + + + + + src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html + 30 + + + src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html + 36 + + + src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html + 42 + + Environment @@ -6458,6 +6519,10 @@ src/app/components/common/system-status-dialog/system-status-dialog.component.html 245 + + src/app/components/common/system-status-dialog/system-status-dialog.component.html + 293 + Last Updated @@ -6493,6 +6558,10 @@ src/app/components/common/system-status-dialog/system-status-dialog.component.html 252 + + src/app/components/common/system-status-dialog/system-status-dialog.component.html + 300 + WebSocket Connection @@ -6508,6 +6577,13 @@ 261 + + AI Index + + src/app/components/common/system-status-dialog/system-status-dialog.component.html + 270 + + Copy Raw Error @@ -6867,7 +6943,7 @@ src/app/components/document-detail/document-detail.component.ts - 1393 + 1462 @@ -6881,28 +6957,28 @@ Send src/app/components/document-detail/document-detail.component.html - 90 + 80 Previous src/app/components/document-detail/document-detail.component.html - 116 + 106 Details src/app/components/document-detail/document-detail.component.html - 129 + 145 Title src/app/components/document-detail/document-detail.component.html - 132 + 148 src/app/components/document-list/document-list.component.html @@ -6925,21 +7001,21 @@ Archive serial number src/app/components/document-detail/document-detail.component.html - 133 + 149 Date created src/app/components/document-detail/document-detail.component.html - 134 + 150 Correspondent src/app/components/document-detail/document-detail.component.html - 136 + 152 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -6966,7 +7042,7 @@ Document type src/app/components/document-detail/document-detail.component.html - 138 + 154 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -6993,7 +7069,7 @@ Storage path src/app/components/document-detail/document-detail.component.html - 140 + 156 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -7016,7 +7092,7 @@ Default src/app/components/document-detail/document-detail.component.html - 141 + 157 src/app/components/manage/saved-views/saved-views.component.html @@ -7027,14 +7103,14 @@ Content src/app/components/document-detail/document-detail.component.html - 245 + 261 Metadata src/app/components/document-detail/document-detail.component.html - 254 + 270 src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts @@ -7045,175 +7121,175 @@ Date modified src/app/components/document-detail/document-detail.component.html - 261 + 277 Date added src/app/components/document-detail/document-detail.component.html - 265 + 281 Media filename src/app/components/document-detail/document-detail.component.html - 269 + 285 Original filename src/app/components/document-detail/document-detail.component.html - 273 + 289 Original MD5 checksum src/app/components/document-detail/document-detail.component.html - 277 + 293 Original file size src/app/components/document-detail/document-detail.component.html - 281 + 297 Original mime type src/app/components/document-detail/document-detail.component.html - 285 + 301 Archive MD5 checksum src/app/components/document-detail/document-detail.component.html - 290 + 306 Archive file size src/app/components/document-detail/document-detail.component.html - 296 + 312 Original document metadata src/app/components/document-detail/document-detail.component.html - 305 + 321 Archived document metadata src/app/components/document-detail/document-detail.component.html - 308 + 324 Notes src/app/components/document-detail/document-detail.component.html - 327,330 + 343,346 History src/app/components/document-detail/document-detail.component.html - 338 + 354 Save & next src/app/components/document-detail/document-detail.component.html - 375 + 391 Save & close src/app/components/document-detail/document-detail.component.html - 378 + 394 Document loading... src/app/components/document-detail/document-detail.component.html - 388 + 404 Enter Password src/app/components/document-detail/document-detail.component.html - 442 + 458 An error occurred loading content: src/app/components/document-detail/document-detail.component.ts - 417,419 + 430,432 Document changes detected src/app/components/document-detail/document-detail.component.ts - 451 + 464 The version of this document in your browser session appears older than the existing version. src/app/components/document-detail/document-detail.component.ts - 452 + 465 Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document. src/app/components/document-detail/document-detail.component.ts - 453 + 466 Ok src/app/components/document-detail/document-detail.component.ts - 455 + 468 Next document src/app/components/document-detail/document-detail.component.ts - 581 + 594 Previous document src/app/components/document-detail/document-detail.component.ts - 591 + 604 Close document src/app/components/document-detail/document-detail.component.ts - 599 + 612 src/app/services/open-documents.service.ts @@ -7224,67 +7300,67 @@ Save document src/app/components/document-detail/document-detail.component.ts - 606 + 619 Save and close / next src/app/components/document-detail/document-detail.component.ts - 615 + 628 Error retrieving metadata src/app/components/document-detail/document-detail.component.ts - 670 + 683 Error retrieving suggestions. src/app/components/document-detail/document-detail.component.ts - 699 + 731 Document "" saved successfully. src/app/components/document-detail/document-detail.component.ts - 871 + 940 src/app/components/document-detail/document-detail.component.ts - 895 + 964 Error saving document "" src/app/components/document-detail/document-detail.component.ts - 901 + 970 Error saving document src/app/components/document-detail/document-detail.component.ts - 951 + 1020 Do you really want to move the document "" to the trash? src/app/components/document-detail/document-detail.component.ts - 983 + 1052 Documents can be restored prior to permanent deletion. src/app/components/document-detail/document-detail.component.ts - 984 + 1053 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7295,7 +7371,7 @@ Move to trash src/app/components/document-detail/document-detail.component.ts - 986 + 1055 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7306,14 +7382,14 @@ Error deleting document src/app/components/document-detail/document-detail.component.ts - 1005 + 1074 Reprocess confirm src/app/components/document-detail/document-detail.component.ts - 1025 + 1094 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -7324,102 +7400,102 @@ This operation will permanently recreate the archive file for this document. src/app/components/document-detail/document-detail.component.ts - 1026 + 1095 The archive file will be re-generated with the current settings. src/app/components/document-detail/document-detail.component.ts - 1027 + 1096 Reprocess operation for "" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content. src/app/components/document-detail/document-detail.component.ts - 1037 + 1106 Error executing operation src/app/components/document-detail/document-detail.component.ts - 1048 + 1117 Error downloading document src/app/components/document-detail/document-detail.component.ts - 1097 + 1166 Page Fit src/app/components/document-detail/document-detail.component.ts - 1174 + 1243 PDF edit operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1412 + 1481 Error executing PDF edit operation src/app/components/document-detail/document-detail.component.ts - 1424 + 1493 Please enter the current password before attempting to remove it. src/app/components/document-detail/document-detail.component.ts - 1435 + 1504 Password removal operation for "" will begin in the background. src/app/components/document-detail/document-detail.component.ts - 1467 + 1536 Error executing password removal operation src/app/components/document-detail/document-detail.component.ts - 1481 + 1550 Print failed. src/app/components/document-detail/document-detail.component.ts - 1518 + 1587 Error loading document for printing. src/app/components/document-detail/document-detail.component.ts - 1530 + 1599 An error occurred loading tiff: src/app/components/document-detail/document-detail.component.ts - 1595 + 1664 src/app/components/document-detail/document-detail.component.ts - 1599 + 1668 @@ -8224,7 +8300,7 @@ src/app/data/paperless-config.ts - 91 + 104 @@ -9622,196 +9698,259 @@ General Settings src/app/data/paperless-config.ts - 50 + 51 OCR Settings src/app/data/paperless-config.ts - 51 + 52 Barcode Settings src/app/data/paperless-config.ts - 52 + 53 + + + + AI Settings + + src/app/data/paperless-config.ts + 54 Output Type src/app/data/paperless-config.ts - 76 + 89 Language src/app/data/paperless-config.ts - 84 + 97 Mode src/app/data/paperless-config.ts - 98 + 111 Skip Archive File src/app/data/paperless-config.ts - 106 + 119 Image DPI src/app/data/paperless-config.ts - 114 + 127 Clean src/app/data/paperless-config.ts - 121 + 134 Deskew src/app/data/paperless-config.ts - 129 + 142 Rotate Pages src/app/data/paperless-config.ts - 136 + 149 Rotate Pages Threshold src/app/data/paperless-config.ts - 143 + 156 Max Image Pixels src/app/data/paperless-config.ts - 150 + 163 Color Conversion Strategy src/app/data/paperless-config.ts - 157 + 170 OCR Arguments src/app/data/paperless-config.ts - 165 + 178 Application Logo src/app/data/paperless-config.ts - 172 + 185 Application Title src/app/data/paperless-config.ts - 179 + 192 Enable Barcodes src/app/data/paperless-config.ts - 186 + 199 Enable TIFF Support src/app/data/paperless-config.ts - 193 + 206 Barcode String src/app/data/paperless-config.ts - 200 + 213 Retain Split Pages src/app/data/paperless-config.ts - 207 + 220 Enable ASN src/app/data/paperless-config.ts - 214 + 227 ASN Prefix src/app/data/paperless-config.ts - 221 + 234 Upscale src/app/data/paperless-config.ts - 228 + 241 DPI src/app/data/paperless-config.ts - 235 + 248 Max Pages src/app/data/paperless-config.ts - 242 + 255 Enable Tag Detection src/app/data/paperless-config.ts - 249 + 262 Tag Mapping src/app/data/paperless-config.ts - 256 + 269 + + + + AI Enabled + + src/app/data/paperless-config.ts + 276 + + + + Consider privacy implications when enabling AI features, especially if using a remote model. + + src/app/data/paperless-config.ts + 280 + + + + LLM Embedding Backend + + src/app/data/paperless-config.ts + 284 + + + + LLM Embedding Model + + src/app/data/paperless-config.ts + 292 + + + + LLM Backend + + src/app/data/paperless-config.ts + 299 + + + + LLM Model + + src/app/data/paperless-config.ts + 307 + + + + LLM API Key + + src/app/data/paperless-config.ts + 314 + + + + LLM Endpoint + + src/app/data/paperless-config.ts + 321 diff --git a/src-ui/package.json b/src-ui/package.json index 7b5954b53..0536c0489 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -1,6 +1,6 @@ { "name": "paperless-ngx-ui", - "version": "2.20.3", + "version": "2.20.5", "scripts": { "preinstall": "npx only-allow pnpm", "ng": "ng", diff --git a/src-ui/src/app/components/admin/config/config.component.html b/src-ui/src/app/components/admin/config/config.component.html index e1d7340a6..1d38a5d32 100644 --- a/src-ui/src/app/components/admin/config/config.component.html +++ b/src-ui/src/app/components/admin/config/config.component.html @@ -35,8 +35,12 @@ @case (ConfigOptionType.String) { } @case (ConfigOptionType.JSON) { } @case (ConfigOptionType.File) { } + @case (ConfigOptionType.Password) { } } + @if (option.note) { +
{{option.note}}
+ } diff --git a/src-ui/src/app/components/admin/config/config.component.ts b/src-ui/src/app/components/admin/config/config.component.ts index eee617310..b0dcba57b 100644 --- a/src-ui/src/app/components/admin/config/config.component.ts +++ b/src-ui/src/app/components/admin/config/config.component.ts @@ -29,6 +29,7 @@ import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' import { FileComponent } from '../../common/input/file/file.component' import { NumberComponent } from '../../common/input/number/number.component' +import { PasswordComponent } from '../../common/input/password/password.component' import { SelectComponent } from '../../common/input/select/select.component' import { SwitchComponent } from '../../common/input/switch/switch.component' import { TextComponent } from '../../common/input/text/text.component' @@ -46,6 +47,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading TextComponent, NumberComponent, FileComponent, + PasswordComponent, AsyncPipe, NgbNavModule, FormsModule, diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts index cc5c96640..650d6d8ea 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts @@ -91,6 +91,9 @@ const status: SystemStatus = { sanity_check_status: SystemStatusItemStatus.ERROR, sanity_check_last_run: new Date().toISOString(), sanity_check_error: 'Error running sanity check.', + llmindex_status: SystemStatusItemStatus.DISABLED, + llmindex_last_modified: new Date().toISOString(), + llmindex_error: null, }, } diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index 673eaf03b..62a2e16cc 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -30,6 +30,9 @@
    + @if (aiEnabled) { + + } diff --git a/src-ui/src/app/components/chat/chat/chat.component.scss b/src-ui/src/app/components/chat/chat/chat.component.scss new file mode 100644 index 000000000..4b00cce1b --- /dev/null +++ b/src-ui/src/app/components/chat/chat/chat.component.scss @@ -0,0 +1,37 @@ +.dropdown-menu { + width: var(--pngx-toast-max-width); +} + +.chat-messages { + max-height: 350px; + overflow-y: auto; +} + +.dropdown-toggle::after { + display: none; +} + +.dropdown-item { + white-space: initial; +} + +@media screen and (max-width: 400px) { + :host ::ng-deep .dropdown-menu-end { + right: -3rem; + } +} + +.blinking-cursor { + font-weight: bold; + font-size: 1.2em; + animation: blink 1s step-end infinite; +} + +@keyframes blink { + from, to { + opacity: 0; + } + 50% { + opacity: 1; + } +} diff --git a/src-ui/src/app/components/chat/chat/chat.component.spec.ts b/src-ui/src/app/components/chat/chat/chat.component.spec.ts new file mode 100644 index 000000000..0ccb04a99 --- /dev/null +++ b/src-ui/src/app/components/chat/chat/chat.component.spec.ts @@ -0,0 +1,132 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { provideHttpClientTesting } from '@angular/common/http/testing' +import { ElementRef } from '@angular/core' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { NavigationEnd, Router } from '@angular/router' +import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { Subject } from 'rxjs' +import { ChatService } from 'src/app/services/chat.service' +import { ChatComponent } from './chat.component' + +describe('ChatComponent', () => { + let component: ChatComponent + let fixture: ComponentFixture + let chatService: ChatService + let router: Router + let routerEvents$: Subject + let mockStream$: Subject + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [NgxBootstrapIconsModule.pick(allIcons), ChatComponent], + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }).compileComponents() + + fixture = TestBed.createComponent(ChatComponent) + router = TestBed.inject(Router) + routerEvents$ = new Subject() + jest + .spyOn(router, 'events', 'get') + .mockReturnValue(routerEvents$.asObservable()) + chatService = TestBed.inject(ChatService) + mockStream$ = new Subject() + jest + .spyOn(chatService, 'streamChat') + .mockReturnValue(mockStream$.asObservable()) + component = fixture.componentInstance + + jest.useFakeTimers() + + fixture.detectChanges() + + component.scrollAnchor.nativeElement.scrollIntoView = jest.fn() + }) + + it('should update documentId on initialization', () => { + jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123') + component.ngOnInit() + expect(component.documentId).toBe(123) + }) + + it('should update documentId on navigation', () => { + component.ngOnInit() + routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456')) + expect(component.documentId).toBe(456) + }) + + it('should return correct placeholder based on documentId', () => { + component.documentId = 123 + expect(component.placeholder).toBe('Ask a question about this document...') + component.documentId = undefined + expect(component.placeholder).toBe('Ask a question about a document...') + }) + + it('should send a message and handle streaming response', () => { + component.input = 'Hello' + component.sendMessage() + + expect(component.messages.length).toBe(2) + expect(component.messages[0].content).toBe('Hello') + expect(component.loading).toBe(true) + + mockStream$.next('Hi') + expect(component.messages[1].content).toBe('H') + mockStream$.next('Hi there') + // advance time to process the typewriter effect + jest.advanceTimersByTime(1000) + expect(component.messages[1].content).toBe('Hi there') + + mockStream$.complete() + expect(component.loading).toBe(false) + expect(component.messages[1].isStreaming).toBe(false) + }) + + it('should handle errors during streaming', () => { + component.input = 'Hello' + component.sendMessage() + + mockStream$.error('Error') + expect(component.messages[1].content).toContain( + '⚠️ Error receiving response.' + ) + expect(component.loading).toBe(false) + }) + + it('should enqueue typewriter chunks correctly', () => { + const message = { content: '', role: 'assistant', isStreaming: true } + component.enqueueTypewriter(null, message as any) // coverage for null + component.enqueueTypewriter('Hello', message as any) + expect(component['typewriterBuffer'].length).toBe(4) + }) + + it('should scroll to bottom after sending a message', () => { + const scrollSpy = jest.spyOn( + ChatComponent.prototype as any, + 'scrollToBottom' + ) + component.input = 'Test' + component.sendMessage() + expect(scrollSpy).toHaveBeenCalled() + }) + + it('should focus chat input when dropdown is opened', () => { + const focus = jest.fn() + component.chatInput = { + nativeElement: { focus: focus }, + } as unknown as ElementRef + + component.onOpenChange(true) + jest.advanceTimersByTime(15) + expect(focus).toHaveBeenCalled() + }) + + it('should send message on Enter key press', () => { + jest.spyOn(component, 'sendMessage') + const event = new KeyboardEvent('keydown', { key: 'Enter' }) + component.searchInputKeyDown(event) + expect(component.sendMessage).toHaveBeenCalled() + }) +}) diff --git a/src-ui/src/app/components/chat/chat/chat.component.ts b/src-ui/src/app/components/chat/chat/chat.component.ts new file mode 100644 index 000000000..50d27e0b1 --- /dev/null +++ b/src-ui/src/app/components/chat/chat/chat.component.ts @@ -0,0 +1,140 @@ +import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { NavigationEnd, Router } from '@angular/router' +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { filter, map } from 'rxjs' +import { ChatMessage, ChatService } from 'src/app/services/chat.service' + +@Component({ + selector: 'pngx-chat', + imports: [ + FormsModule, + ReactiveFormsModule, + NgxBootstrapIconsModule, + NgbDropdownModule, + ], + templateUrl: './chat.component.html', + styleUrl: './chat.component.scss', +}) +export class ChatComponent implements OnInit { + public messages: ChatMessage[] = [] + public loading = false + public input: string = '' + public documentId!: number + + private chatService: ChatService = inject(ChatService) + private router: Router = inject(Router) + + @ViewChild('scrollAnchor') scrollAnchor!: ElementRef + @ViewChild('chatInput') chatInput!: ElementRef + + private typewriterBuffer: string[] = [] + private typewriterActive = false + + public get placeholder(): string { + return this.documentId + ? $localize`Ask a question about this document...` + : $localize`Ask a question about a document...` + } + + ngOnInit(): void { + this.updateDocumentId(this.router.url) + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + map((event) => (event as NavigationEnd).url) + ) + .subscribe((url) => { + this.updateDocumentId(url) + }) + } + + private updateDocumentId(url: string): void { + const docIdRe = url.match(/^\/documents\/(\d+)/) + this.documentId = docIdRe ? +docIdRe[1] : undefined + } + + sendMessage(): void { + if (!this.input.trim()) return + + const userMessage: ChatMessage = { role: 'user', content: this.input } + this.messages.push(userMessage) + this.scrollToBottom() + + const assistantMessage: ChatMessage = { + role: 'assistant', + content: '', + isStreaming: true, + } + this.messages.push(assistantMessage) + this.loading = true + + let lastPartialLength = 0 + + this.chatService.streamChat(this.documentId, this.input).subscribe({ + next: (chunk) => { + const delta = chunk.substring(lastPartialLength) + lastPartialLength = chunk.length + this.enqueueTypewriter(delta, assistantMessage) + }, + error: () => { + assistantMessage.content += '\n\n⚠️ Error receiving response.' + assistantMessage.isStreaming = false + this.loading = false + }, + complete: () => { + assistantMessage.isStreaming = false + this.loading = false + this.scrollToBottom() + }, + }) + + this.input = '' + } + + enqueueTypewriter(chunk: string, message: ChatMessage): void { + if (!chunk) return + + this.typewriterBuffer.push(...chunk.split('')) + + if (!this.typewriterActive) { + this.typewriterActive = true + this.playTypewriter(message) + } + } + + playTypewriter(message: ChatMessage): void { + if (this.typewriterBuffer.length === 0) { + this.typewriterActive = false + return + } + + const nextChar = this.typewriterBuffer.shift() + message.content += nextChar + this.scrollToBottom() + + setTimeout(() => this.playTypewriter(message), 10) // 10ms per character + } + + private scrollToBottom(): void { + setTimeout(() => { + this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' }) + }, 50) + } + + public onOpenChange(open: boolean): void { + if (open) { + setTimeout(() => { + this.chatInput.nativeElement.focus() + }, 10) + } + } + + public searchInputKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter') { + event.preventDefault() + this.sendMessage() + } + } +} diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html index f58cfeeb9..f06f37dd0 100644 --- a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html +++ b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html @@ -1,7 +1,7 @@ -
    -
    diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts index aa52592b1..fafc9e876 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts @@ -252,7 +252,7 @@ describe('WorkflowEditDialogComponent', () => { expect(component.object.actions.length).toEqual(2) }) - it('should update order and remove ids from actions on drag n drop', () => { + it('should update order on drag n drop', () => { const action1 = workflow.actions[0] const action2 = workflow.actions[1] component.object = workflow @@ -261,8 +261,6 @@ describe('WorkflowEditDialogComponent', () => { WorkflowAction[] >) expect(component.object.actions).toEqual([action2, action1]) - expect(action1.id).toBeNull() - expect(action2.id).toBeNull() }) it('should not include auto matching in algorithms', () => { diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts index 1fcc84c5d..b7d07e3e6 100644 --- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts +++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts @@ -1288,11 +1288,6 @@ export class WorkflowEditDialogComponent const actionField = this.actionFields.at(event.previousIndex) this.actionFields.removeAt(event.previousIndex) this.actionFields.insert(event.currentIndex, actionField) - // removing id will effectively re-create the actions in this order - this.object.actions.forEach((a) => (a.id = null)) - this.actionFields.controls.forEach((c) => - c.get('id').setValue(null, { emitEvent: false }) - ) } save(): void { diff --git a/src-ui/src/app/components/common/input/password/password.component.html b/src-ui/src/app/components/common/input/password/password.component.html index 9daa4be5f..b5d88f79c 100644 --- a/src-ui/src/app/components/common/input/password/password.component.html +++ b/src-ui/src/app/components/common/input/password/password.component.html @@ -1,17 +1,24 @@ -
    - -
    - - @if (showReveal) { - +
    +
    +
    + @if (title) { + + } +
    +
    +
    + + @if (showReveal) { + + } +
    +
    + {{error}} +
    + @if (hint) { + }
    -
    - {{error}} -
    - @if (hint) { - - }
    diff --git a/src-ui/src/app/components/common/input/tags/tags.component.html b/src-ui/src/app/components/common/input/tags/tags.component.html index f04863f40..960245984 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.html +++ b/src-ui/src/app/components/common/input/tags/tags.component.html @@ -28,7 +28,7 @@ -
    +
    @if (item.id && tags) { @if (getTag(item.id)?.parent) { diff --git a/src-ui/src/app/components/common/input/tags/tags.component.scss b/src-ui/src/app/components/common/input/tags/tags.component.scss index 52292d5cb..2f06247bd 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.scss +++ b/src-ui/src/app/components/common/input/tags/tags.component.scss @@ -22,8 +22,8 @@ } // Dropdown hierarchy reveal for ng-select options -::ng-deep .ng-dropdown-panel .ng-option { - overflow-x: scroll; +:host ::ng-deep .ng-dropdown-panel .ng-option { + overflow-x: auto !important; .tag-option-row { font-size: 1rem; @@ -41,12 +41,12 @@ } } -::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal, -::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal { +:host ::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal, +:host ::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal { max-width: 1000px; } ::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator, -::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator { +:host ::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator { background: transparent; } diff --git a/src-ui/src/app/components/common/input/text/text.component.html b/src-ui/src/app/components/common/input/text/text.component.html index dcc43f72c..5bf4dd93a 100644 --- a/src-ui/src/app/components/common/input/text/text.component.html +++ b/src-ui/src/app/components/common/input/text/text.component.html @@ -15,6 +15,12 @@ @if (hint) { } + @if (getSuggestion()?.length > 0) { + + Suggestion:  + {{getSuggestion()}}  + + }
    {{error}}
    diff --git a/src-ui/src/app/components/common/input/text/text.component.spec.ts b/src-ui/src/app/components/common/input/text/text.component.spec.ts index c5662b341..539c1eb6b 100644 --- a/src-ui/src/app/components/common/input/text/text.component.spec.ts +++ b/src-ui/src/app/components/common/input/text/text.component.spec.ts @@ -26,10 +26,20 @@ describe('TextComponent', () => { it('should support use of input field', () => { expect(component.value).toBeUndefined() - // TODO: why doesn't this work? - // input.value = 'foo' - // input.dispatchEvent(new Event('change')) - // fixture.detectChanges() - // expect(component.value).toEqual('foo') + input.value = 'foo' + input.dispatchEvent(new Event('input')) + fixture.detectChanges() + expect(component.value).toBe('foo') + }) + + it('should support suggestion', () => { + component.value = 'foo' + component.suggestion = 'foo' + expect(component.getSuggestion()).toBe('') + component.value = 'bar' + expect(component.getSuggestion()).toBe('foo') + component.applySuggestion() + fixture.detectChanges() + expect(component.value).toBe('foo') }) }) diff --git a/src-ui/src/app/components/common/input/text/text.component.ts b/src-ui/src/app/components/common/input/text/text.component.ts index 283a8eb71..22b1fed4a 100644 --- a/src-ui/src/app/components/common/input/text/text.component.ts +++ b/src-ui/src/app/components/common/input/text/text.component.ts @@ -4,6 +4,7 @@ import { NG_VALUE_ACCESSOR, ReactiveFormsModule, } from '@angular/forms' +import { RouterLink } from '@angular/router' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { AbstractInputComponent } from '../abstract-input' @@ -18,7 +19,12 @@ import { AbstractInputComponent } from '../abstract-input' selector: 'pngx-input-text', templateUrl: './text.component.html', styleUrls: ['./text.component.scss'], - imports: [FormsModule, ReactiveFormsModule, NgxBootstrapIconsModule], + imports: [ + FormsModule, + ReactiveFormsModule, + NgxBootstrapIconsModule, + RouterLink, + ], }) export class TextComponent extends AbstractInputComponent { @Input() @@ -27,7 +33,19 @@ export class TextComponent extends AbstractInputComponent { @Input() placeholder: string = '' + @Input() + suggestion: string = '' + constructor() { super() } + + getSuggestion() { + return this.value !== this.suggestion ? this.suggestion : '' + } + + applySuggestion() { + this.value = this.suggestion + this.onChange(this.value) + } } diff --git a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html new file mode 100644 index 000000000..af207c05c --- /dev/null +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.html @@ -0,0 +1,49 @@ +
    + + + @if (aiEnabled) { +
    + + +
    +
    + @if (!suggestions?.suggested_tags && !suggestions?.suggested_document_types && !suggestions?.suggested_correspondents) { +
    + No novel suggestions +
    + } + @if (suggestions?.suggested_tags.length > 0) { + Tags + @for (tag of suggestions.suggested_tags; track tag) { + + } + } + @if (suggestions?.suggested_document_types.length > 0) { +
    Document Types
    + @for (type of suggestions.suggested_document_types; track type) { + + } + } + @if (suggestions?.suggested_correspondents.length > 0) { +
    Correspondents
    + @for (correspondent of suggestions.suggested_correspondents; track correspondent) { + + } + } +
    +
    +
    + } +
    diff --git a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.scss b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.scss new file mode 100644 index 000000000..19aa1dc7d --- /dev/null +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.scss @@ -0,0 +1,3 @@ +.suggestions-dropdown { + min-width: 250px; +} diff --git a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.spec.ts b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.spec.ts new file mode 100644 index 000000000..801a56af3 --- /dev/null +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.spec.ts @@ -0,0 +1,51 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' +import { SuggestionsDropdownComponent } from './suggestions-dropdown.component' + +describe('SuggestionsDropdownComponent', () => { + let component: SuggestionsDropdownComponent + let fixture: ComponentFixture + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + NgbDropdownModule, + NgxBootstrapIconsModule.pick(allIcons), + SuggestionsDropdownComponent, + ], + providers: [], + }) + fixture = TestBed.createComponent(SuggestionsDropdownComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should calculate totalSuggestions', () => { + component.suggestions = { + suggested_correspondents: ['John Doe'], + suggested_tags: ['Tag1', 'Tag2'], + suggested_document_types: ['Type1'], + } + expect(component.totalSuggestions).toBe(4) + }) + + it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => { + jest.spyOn(component.getSuggestions, 'emit') + component.suggestions = null + component.clickSuggest() + expect(component.getSuggestions.emit).toHaveBeenCalled() + }) + + it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => { + component.aiEnabled = true + fixture.detectChanges() + component.suggestions = { + suggested_correspondents: [], + suggested_tags: [], + suggested_document_types: [], + } + component.clickSuggest() + expect(component.dropdown.open).toBeTruthy() + }) +}) diff --git a/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.ts b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.ts new file mode 100644 index 000000000..b165f0a5e --- /dev/null +++ b/src-ui/src/app/components/common/suggestions-dropdown/suggestions-dropdown.component.ts @@ -0,0 +1,64 @@ +import { + Component, + EventEmitter, + Input, + Output, + ViewChild, +} from '@angular/core' +import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { DocumentSuggestions } from 'src/app/data/document-suggestions' +import { pngxPopperOptions } from 'src/app/utils/popper-options' + +@Component({ + selector: 'pngx-suggestions-dropdown', + imports: [NgbDropdownModule, NgxBootstrapIconsModule], + templateUrl: './suggestions-dropdown.component.html', + styleUrl: './suggestions-dropdown.component.scss', +}) +export class SuggestionsDropdownComponent { + public popperOptions = pngxPopperOptions + + @ViewChild('dropdown') dropdown: NgbDropdown + + @Input() + suggestions: DocumentSuggestions = null + + @Input() + aiEnabled: boolean = false + + @Input() + loading: boolean = false + + @Input() + disabled: boolean = false + + @Output() + getSuggestions: EventEmitter = + new EventEmitter() + + @Output() + addTag: EventEmitter = new EventEmitter() + + @Output() + addDocumentType: EventEmitter = new EventEmitter() + + @Output() + addCorrespondent: EventEmitter = new EventEmitter() + + public clickSuggest(): void { + if (!this.suggestions) { + this.getSuggestions.emit(this) + } else { + this.dropdown?.toggle() + } + } + + get totalSuggestions(): number { + return ( + this.suggestions?.suggested_correspondents?.length + + this.suggestions?.suggested_tags?.length + + this.suggestions?.suggested_document_types?.length || 0 + ) + } +} diff --git a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html index 99fddbf2c..c34f984b2 100644 --- a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html +++ b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html @@ -266,6 +266,43 @@ } + @if (aiEnabled) { +
    AI Index
    +
    + + @if (currentUserIsSuperUser) { + @if (isRunning(PaperlessTaskName.LLMIndexUpdate)) { +
    + } @else { + + } + } +
    + + @if (status.tasks.llmindex_status === 'OK') { +
    Last Run:
    {{status.tasks.llmindex_last_modified | customDate:'medium'}} + } @else { +
    Error:
    {{status.tasks.llmindex_error}} + } +
    + }
    diff --git a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts index 1785459f4..0fd331b10 100644 --- a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts @@ -68,6 +68,9 @@ const status: SystemStatus = { sanity_check_status: SystemStatusItemStatus.OK, sanity_check_last_run: new Date().toISOString(), sanity_check_error: null, + llmindex_status: SystemStatusItemStatus.OK, + llmindex_last_modified: new Date().toISOString(), + llmindex_error: null, }, } diff --git a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts index f88d56ff6..d53bb74bf 100644 --- a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts +++ b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts @@ -13,9 +13,11 @@ import { SystemStatus, SystemStatusItemStatus, } from 'src/app/data/system-status' +import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { FileSizePipe } from 'src/app/pipes/file-size.pipe' import { PermissionsService } from 'src/app/services/permissions.service' +import { SettingsService } from 'src/app/services/settings.service' import { SystemStatusService } from 'src/app/services/system-status.service' import { TasksService } from 'src/app/services/tasks.service' import { ToastService } from 'src/app/services/toast.service' @@ -44,6 +46,7 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy { private toastService = inject(ToastService) private permissionsService = inject(PermissionsService) private websocketStatusService = inject(WebsocketStatusService) + private settingsService = inject(SettingsService) public SystemStatusItemStatus = SystemStatusItemStatus public PaperlessTaskName = PaperlessTaskName @@ -60,6 +63,10 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy { return this.permissionsService.isSuperUser() } + get aiEnabled(): boolean { + return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) + } + public ngOnInit() { this.versionMismatch = environment.production && diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index f8a942ba3..44304c942 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -74,16 +74,6 @@
    - - - -
    + +
    + + +
    + +
    + + +
    +
    +
    @@ -129,7 +145,7 @@ Details
    - + @@ -139,7 +155,7 @@ (createNew)="createDocumentType($event)" [hideAddButton]="createDisabled(DataType.DocumentType)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"> - + @for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
    @switch (getCustomFieldFromInstance(fieldInstance)?.data_type) { @@ -361,14 +377,14 @@
    -
    +
    -
    +
    @if (hasNext()) { diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index b1b3650c6..198e7a7a4 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -157,6 +157,16 @@ describe('DocumentDetailComponent', () => { { provide: TagService, useValue: { + getCachedMany: (ids: number[]) => + of( + ids.map((id) => ({ + id, + name: `Tag${id}`, + is_inbox_tag: true, + color: '#ff0000', + text_color: '#000000', + })) + ), listAll: () => of({ count: 3, @@ -383,8 +393,32 @@ describe('DocumentDetailComponent', () => { currentUserCan = true }) - it('should support creating document type', () => { + it('should support creating tag, remove from suggestions', () => { initNormally() + component.suggestions = { + suggested_tags: ['Tag1', 'NewTag12'], + } + let openModal: NgbModalRef + modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) + const modalSpy = jest.spyOn(modalService, 'open') + component.createTag('NewTag12') + expect(modalSpy).toHaveBeenCalled() + openModal.componentInstance.succeeded.next({ + id: 12, + name: 'NewTag12', + is_inbox_tag: true, + color: '#ff0000', + text_color: '#000000', + }) + expect(component.tagsInput.value).toContain(12) + expect(component.suggestions.suggested_tags).not.toContain('NewTag12') + }) + + it('should support creating document type, remove from suggestions', () => { + initNormally() + component.suggestions = { + suggested_document_types: ['DocumentType1', 'NewDocType2'], + } let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') @@ -392,10 +426,16 @@ describe('DocumentDetailComponent', () => { expect(modalSpy).toHaveBeenCalled() openModal.componentInstance.succeeded.next({ id: 12, name: 'NewDocType12' }) expect(component.documentForm.get('document_type').value).toEqual(12) + expect(component.suggestions.suggested_document_types).not.toContain( + 'NewDocType2' + ) }) - it('should support creating correspondent', () => { + it('should support creating correspondent, remove from suggestions', () => { initNormally() + component.suggestions = { + suggested_correspondents: ['Correspondent1', 'NewCorrrespondent12'], + } let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) const modalSpy = jest.spyOn(modalService, 'open') @@ -406,6 +446,9 @@ describe('DocumentDetailComponent', () => { name: 'NewCorrrespondent12', }) expect(component.documentForm.get('correspondent').value).toEqual(12) + expect(component.suggestions.suggested_correspondents).not.toContain( + 'NewCorrrespondent12' + ) }) it('should support creating storage path', () => { @@ -996,7 +1039,7 @@ describe('DocumentDetailComponent', () => { expect(component.document.custom_fields).toHaveLength(initialLength - 1) expect(component.customFieldFormFields).toHaveLength(initialLength - 1) expect( - fixture.debugElement.query(By.css('form')).nativeElement.textContent + fixture.debugElement.query(By.css('form ul')).nativeElement.textContent ).not.toContain('Field 1') const patchSpy = jest.spyOn(documentService, 'patch') component.save(true) @@ -1087,10 +1130,22 @@ describe('DocumentDetailComponent', () => { it('should get suggestions', () => { const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions') - suggestionsSpy.mockReturnValue(of({ tags: [42, 43] })) + suggestionsSpy.mockReturnValue( + of({ + tags: [42, 43], + suggested_tags: [], + suggested_document_types: [], + suggested_correspondents: [], + }) + ) initNormally() expect(suggestionsSpy).toHaveBeenCalled() - expect(component.suggestions).toEqual({ tags: [42, 43] }) + expect(component.suggestions).toEqual({ + tags: [42, 43], + suggested_tags: [], + suggested_document_types: [], + suggested_correspondents: [], + }) }) it('should show error if needed for get suggestions', () => { diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 165cf0cef..a78ef4b9f 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -31,6 +31,7 @@ import { map, switchMap, takeUntil, + tap, } from 'rxjs/operators' import { Correspondent } from 'src/app/data/correspondent' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' @@ -76,6 +77,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service import { DocumentService } from 'src/app/services/rest/document.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service' +import { TagService } from 'src/app/services/rest/tag.service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' @@ -89,6 +91,7 @@ import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspo import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component' import { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' +import { TagEditDialogComponent } from '../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { EmailDocumentDialogComponent } from '../common/email-document-dialog/email-document-dialog.component' import { CheckComponent } from '../common/input/check/check.component' import { DateComponent } from '../common/input/date/date.component' @@ -107,6 +110,7 @@ import { PdfEditorEditMode, } from '../common/pdf-editor/pdf-editor.component' import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component' +import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component' import { DocumentHistoryComponent } from '../document-history/document-history.component' import { DocumentNotesComponent } from '../document-notes/document-notes.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' @@ -163,6 +167,7 @@ export enum ZoomSetting { NumberComponent, MonetaryComponent, UrlComponent, + SuggestionsDropdownComponent, CustomDatePipe, FileSizePipe, IfPermissionsDirective, @@ -185,6 +190,7 @@ export class DocumentDetailComponent { private documentsService = inject(DocumentService) private route = inject(ActivatedRoute) + private tagService = inject(TagService) private correspondentService = inject(CorrespondentService) private documentTypeService = inject(DocumentTypeService) private router = inject(Router) @@ -207,6 +213,8 @@ export class DocumentDetailComponent @ViewChild('inputTitle') titleInput: TextComponent + @ViewChild('tagsInput') tagsInput: TagsComponent + expandOriginalMetadata = false expandArchivedMetadata = false @@ -218,6 +226,7 @@ export class DocumentDetailComponent document: Document metadata: DocumentMetadata suggestions: DocumentSuggestions + suggestionsLoading: boolean = false users: User[] title: string @@ -277,10 +286,10 @@ export class DocumentDetailComponent if ( element && element.nativeElement.offsetParent !== null && - this.nav?.activeId == 4 + this.nav?.activeId == DocumentDetailNavIDs.Preview ) { // its visible - setTimeout(() => this.nav?.select(1)) + setTimeout(() => this.nav?.select(DocumentDetailNavIDs.Details)) } } @@ -299,6 +308,10 @@ export class DocumentDetailComponent return this.deviceDetectorService.isMobile() } + get aiEnabled(): boolean { + return this.settings.get(SETTINGS_KEYS.AI_ENABLED) + } + get archiveContentRenderType(): ContentRenderType { return this.document?.archived_file_name ? this.getRenderType('application/pdf') @@ -683,25 +696,12 @@ export class DocumentDetailComponent PermissionType.Document ) ) { - this.documentsService - .getSuggestions(doc.id) - .pipe( - first(), - takeUntil(this.unsubscribeNotifier), - takeUntil(this.docChangeNotifier) - ) - .subscribe({ - next: (result) => { - this.suggestions = result - }, - error: (error) => { - this.suggestions = null - this.toastService.showError( - $localize`Error retrieving suggestions.`, - error - ) - }, - }) + this.tagService.getCachedMany(doc.tags).subscribe((tags) => { + // only show suggestions if document has inbox tags + if (tags.some((tag) => tag.is_inbox_tag)) { + this.getSuggestions() + } + }) } this.title = this.documentTitlePipe.transform(doc.title) this.prepareForm(doc) @@ -711,6 +711,63 @@ export class DocumentDetailComponent return this.documentForm.get('custom_fields') as FormArray } + getSuggestions() { + this.suggestionsLoading = true + this.documentsService + .getSuggestions(this.documentId) + .pipe( + first(), + takeUntil(this.unsubscribeNotifier), + takeUntil(this.docChangeNotifier) + ) + .subscribe({ + next: (result) => { + this.suggestions = result + this.suggestionsLoading = false + }, + error: (error) => { + this.suggestions = null + this.suggestionsLoading = false + this.toastService.showError( + $localize`Error retrieving suggestions.`, + error + ) + }, + }) + } + + createTag(newName: string) { + var modal = this.modalService.open(TagEditDialogComponent, { + backdrop: 'static', + }) + modal.componentInstance.dialogMode = EditDialogMode.CREATE + if (newName) modal.componentInstance.object = { name: newName } + modal.componentInstance.succeeded + .pipe( + tap((newTag: Tag) => { + // remove from suggestions if present + if (this.suggestions) { + this.suggestions = { + ...this.suggestions, + suggested_tags: this.suggestions.suggested_tags.filter( + (tag) => tag !== newTag.name + ), + } + } + }), + switchMap((newTag: Tag) => { + return this.tagService + .listAll() + .pipe(map((tags) => ({ newTag, tags }))) + }), + takeUntil(this.unsubscribeNotifier) + ) + .subscribe(({ newTag, tags }) => { + this.tagsInput.tags = tags.results + this.tagsInput.addTag(newTag.id) + }) + } + createDocumentType(newName: string) { var modal = this.modalService.open(DocumentTypeEditDialogComponent, { backdrop: 'static', @@ -730,6 +787,12 @@ export class DocumentDetailComponent this.documentTypes = documentTypes.results this.documentForm.get('document_type').setValue(newDocumentType.id) this.documentForm.get('document_type').markAsDirty() + if (this.suggestions) { + this.suggestions.suggested_document_types = + this.suggestions.suggested_document_types.filter( + (dt) => dt !== newName + ) + } }) } @@ -754,6 +817,12 @@ export class DocumentDetailComponent this.correspondents = correspondents.results this.documentForm.get('correspondent').setValue(newCorrespondent.id) this.documentForm.get('correspondent').markAsDirty() + if (this.suggestions) { + this.suggestions.suggested_correspondents = + this.suggestions.suggested_correspondents.filter( + (c) => c !== newName + ) + } }) } diff --git a/src-ui/src/app/data/document-suggestions.ts b/src-ui/src/app/data/document-suggestions.ts index 85f9f9d22..447c4402b 100644 --- a/src-ui/src/app/data/document-suggestions.ts +++ b/src-ui/src/app/data/document-suggestions.ts @@ -1,11 +1,17 @@ export interface DocumentSuggestions { + title?: string + tags?: number[] + suggested_tags?: string[] correspondents?: number[] + suggested_correspondents?: string[] document_types?: number[] + suggested_document_types?: string[] storage_paths?: number[] + suggested_storage_paths?: string[] dates?: string[] // ISO-formatted date string e.g. 2022-11-03 } diff --git a/src-ui/src/app/data/paperless-config.ts b/src-ui/src/app/data/paperless-config.ts index 3afca66ff..fd151002d 100644 --- a/src-ui/src/app/data/paperless-config.ts +++ b/src-ui/src/app/data/paperless-config.ts @@ -44,12 +44,24 @@ export enum ConfigOptionType { Boolean = 'boolean', JSON = 'json', File = 'file', + Password = 'password', } export const ConfigCategory = { General: $localize`General Settings`, OCR: $localize`OCR Settings`, Barcode: $localize`Barcode Settings`, + AI: $localize`AI Settings`, +} + +export const LLMEmbeddingBackendConfig = { + OPENAI: 'openai', + HUGGINGFACE: 'huggingface', +} + +export const LLMBackendConfig = { + OPENAI: 'openai', + OLLAMA: 'ollama', } export interface ConfigOption { @@ -59,6 +71,7 @@ export interface ConfigOption { choices?: Array<{ id: string; name: string }> config_key?: string category: string + note?: string } function mapToItems(enumObj: Object): Array<{ id: string; name: string }> { @@ -258,6 +271,58 @@ export const PaperlessConfigOptions: ConfigOption[] = [ config_key: 'PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING', category: ConfigCategory.Barcode, }, + { + key: 'ai_enabled', + title: $localize`AI Enabled`, + type: ConfigOptionType.Boolean, + config_key: 'PAPERLESS_AI_ENABLED', + category: ConfigCategory.AI, + note: $localize`Consider privacy implications when enabling AI features, especially if using a remote model.`, + }, + { + key: 'llm_embedding_backend', + title: $localize`LLM Embedding Backend`, + type: ConfigOptionType.Select, + choices: mapToItems(LLMEmbeddingBackendConfig), + config_key: 'PAPERLESS_AI_LLM_EMBEDDING_BACKEND', + category: ConfigCategory.AI, + }, + { + key: 'llm_embedding_model', + title: $localize`LLM Embedding Model`, + type: ConfigOptionType.String, + config_key: 'PAPERLESS_AI_LLM_EMBEDDING_MODEL', + category: ConfigCategory.AI, + }, + { + key: 'llm_backend', + title: $localize`LLM Backend`, + type: ConfigOptionType.Select, + choices: mapToItems(LLMBackendConfig), + config_key: 'PAPERLESS_AI_LLM_BACKEND', + category: ConfigCategory.AI, + }, + { + key: 'llm_model', + title: $localize`LLM Model`, + type: ConfigOptionType.String, + config_key: 'PAPERLESS_AI_LLM_MODEL', + category: ConfigCategory.AI, + }, + { + key: 'llm_api_key', + title: $localize`LLM API Key`, + type: ConfigOptionType.Password, + config_key: 'PAPERLESS_AI_LLM_API_KEY', + category: ConfigCategory.AI, + }, + { + key: 'llm_endpoint', + title: $localize`LLM Endpoint`, + type: ConfigOptionType.String, + config_key: 'PAPERLESS_AI_LLM_ENDPOINT', + category: ConfigCategory.AI, + }, ] export interface PaperlessConfig extends ObjectWithId { @@ -287,4 +352,11 @@ export interface PaperlessConfig extends ObjectWithId { barcode_max_pages: number barcode_enable_tag: boolean barcode_tag_mapping: object + ai_enabled: boolean + llm_embedding_backend: string + llm_embedding_model: string + llm_backend: string + llm_model: string + llm_api_key: string + llm_endpoint: string } diff --git a/src-ui/src/app/data/paperless-task.ts b/src-ui/src/app/data/paperless-task.ts index 1bec277eb..b30af7cdd 100644 --- a/src-ui/src/app/data/paperless-task.ts +++ b/src-ui/src/app/data/paperless-task.ts @@ -11,6 +11,7 @@ export enum PaperlessTaskName { TrainClassifier = 'train_classifier', SanityCheck = 'check_sanity', IndexOptimize = 'index_optimize', + LLMIndexUpdate = 'llmindex_update', } export enum PaperlessTaskStatus { diff --git a/src-ui/src/app/data/system-status.ts b/src-ui/src/app/data/system-status.ts index 334dc54f8..7dcbffa20 100644 --- a/src-ui/src/app/data/system-status.ts +++ b/src-ui/src/app/data/system-status.ts @@ -7,6 +7,7 @@ export enum SystemStatusItemStatus { OK = 'OK', ERROR = 'ERROR', WARNING = 'WARNING', + DISABLED = 'DISABLED', } export interface SystemStatus { @@ -43,6 +44,9 @@ export interface SystemStatus { sanity_check_status: SystemStatusItemStatus sanity_check_last_run: string // ISO date string sanity_check_error: string + llmindex_status: SystemStatusItemStatus + llmindex_last_modified: string // ISO date string + llmindex_error: string } websocket_connected?: SystemStatusItemStatus // added client-side } diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts index 6ace74810..e797fe9b3 100644 --- a/src-ui/src/app/data/ui-settings.ts +++ b/src-ui/src/app/data/ui-settings.ts @@ -76,6 +76,7 @@ export const SETTINGS_KEYS = { GMAIL_OAUTH_URL: 'gmail_oauth_url', OUTLOOK_OAUTH_URL: 'outlook_oauth_url', EMAIL_ENABLED: 'email_enabled', + AI_ENABLED: 'ai_enabled', } export const SETTINGS: UiSetting[] = [ @@ -289,4 +290,9 @@ export const SETTINGS: UiSetting[] = [ type: 'string', default: 'page-width', // ZoomSetting from 'document-detail.component' }, + { + key: SETTINGS_KEYS.AI_ENABLED, + type: 'boolean', + default: false, + }, ] diff --git a/src-ui/src/app/interceptors/csrf.interceptor.ts b/src-ui/src/app/interceptors/csrf.interceptor.ts index 2f590c5eb..03a2fa7b3 100644 --- a/src-ui/src/app/interceptors/csrf.interceptor.ts +++ b/src-ui/src/app/interceptors/csrf.interceptor.ts @@ -4,15 +4,15 @@ import { HttpInterceptor, HttpRequest, } from '@angular/common/http' -import { Injectable, inject } from '@angular/core' +import { inject, Injectable } from '@angular/core' import { Meta } from '@angular/platform-browser' import { CookieService } from 'ngx-cookie-service' import { Observable } from 'rxjs' @Injectable() export class CsrfInterceptor implements HttpInterceptor { - private cookieService = inject(CookieService) - private meta = inject(Meta) + private cookieService: CookieService = inject(CookieService) + private meta: Meta = inject(Meta) intercept( request: HttpRequest, diff --git a/src-ui/src/app/services/chat.service.spec.ts b/src-ui/src/app/services/chat.service.spec.ts new file mode 100644 index 000000000..b8ca957cb --- /dev/null +++ b/src-ui/src/app/services/chat.service.spec.ts @@ -0,0 +1,58 @@ +import { + HttpEventType, + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http' +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import { environment } from 'src/environments/environment' +import { ChatService } from './chat.service' + +describe('ChatService', () => { + let service: ChatService + let httpMock: HttpTestingController + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + ChatService, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }) + service = TestBed.inject(ChatService) + httpMock = TestBed.inject(HttpTestingController) + }) + + afterEach(() => { + httpMock.verify() + }) + + it('should stream chat messages', (done) => { + const documentId = 1 + const prompt = 'Hello, world!' + const mockResponse = 'Partial response text' + const apiUrl = `${environment.apiBaseUrl}documents/chat/` + + service.streamChat(documentId, prompt).subscribe((chunk) => { + expect(chunk).toBe(mockResponse) + done() + }) + + const req = httpMock.expectOne(apiUrl) + expect(req.request.method).toBe('POST') + expect(req.request.body).toEqual({ + document_id: documentId, + q: prompt, + }) + + req.event({ + type: HttpEventType.DownloadProgress, + partialText: mockResponse, + } as any) + }) +}) diff --git a/src-ui/src/app/services/chat.service.ts b/src-ui/src/app/services/chat.service.ts new file mode 100644 index 000000000..9ddfb8330 --- /dev/null +++ b/src-ui/src/app/services/chat.service.ts @@ -0,0 +1,46 @@ +import { + HttpClient, + HttpDownloadProgressEvent, + HttpEventType, +} from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import { filter, map, Observable } from 'rxjs' +import { environment } from 'src/environments/environment' + +export interface ChatMessage { + role: 'user' | 'assistant' + content: string + isStreaming?: boolean +} + +@Injectable({ + providedIn: 'root', +}) +export class ChatService { + private http: HttpClient = inject(HttpClient) + + streamChat(documentId: number, prompt: string): Observable { + return this.http + .post( + `${environment.apiBaseUrl}documents/chat/`, + { + document_id: documentId, + q: prompt, + }, + { + observe: 'events', + reportProgress: true, + responseType: 'text', + withCredentials: true, + } + ) + .pipe( + map((event) => { + if (event.type === HttpEventType.DownloadProgress) { + return (event as HttpDownloadProgressEvent).partialText! + } + }), + filter((chunk) => !!chunk) + ) + } +} diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index c8bb844e9..9ebf29d16 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -6,7 +6,7 @@ export const environment = { apiVersion: '9', // match src/paperless/settings.py appTitle: 'Paperless-ngx', tag: 'prod', - version: '2.20.3', + version: '2.20.5', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', diff --git a/src-ui/src/main.ts b/src-ui/src/main.ts index 7f1a39fbe..b85d8ff35 100644 --- a/src-ui/src/main.ts +++ b/src-ui/src/main.ts @@ -10,6 +10,7 @@ import { DatePipe, registerLocaleData } from '@angular/common' import { HTTP_INTERCEPTORS, provideHttpClient, + withFetch, withInterceptorsFromDi, } from '@angular/common/http' import { FormsModule, ReactiveFormsModule } from '@angular/forms' @@ -49,6 +50,7 @@ import { caretDown, caretUp, chatLeftText, + chatSquareDots, check, check2All, checkAll, @@ -124,6 +126,7 @@ import { sliders2Vertical, sortAlphaDown, sortAlphaUpAlt, + stars, tag, tagFill, tags, @@ -266,6 +269,7 @@ const icons = { caretDown, caretUp, chatLeftText, + chatSquareDots, check, check2All, checkAll, @@ -341,6 +345,7 @@ const icons = { sliders2Vertical, sortAlphaDown, sortAlphaUpAlt, + stars, tagFill, tag, tags, @@ -407,6 +412,6 @@ bootstrapApplication(AppComponent, { CorrespondentNamePipe, DocumentTypeNamePipe, StoragePathNamePipe, - provideHttpClient(withInterceptorsFromDi()), + provideHttpClient(withInterceptorsFromDi(), withFetch()), ], }).catch((err) => console.error(err)) diff --git a/src-ui/src/theme.scss b/src-ui/src/theme.scss index eacc3b4e7..6ff5f4a09 100644 --- a/src-ui/src/theme.scss +++ b/src-ui/src/theme.scss @@ -73,6 +73,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml, None: + """ + Invalidate the LLM suggestions cache for a specific document and backend. + """ + doc_key = get_suggestion_cache_key(document_id) + data: SuggestionCacheData = cache.get(doc_key) + + if data: + cache.delete(doc_key) + + def get_metadata_cache_key(document_id: int) -> str: """ Returns the basic key for a document's metadata diff --git a/src/documents/checks.py b/src/documents/checks.py index 8f8fbf4f9..b6e9e90fc 100644 --- a/src/documents/checks.py +++ b/src/documents/checks.py @@ -1,60 +1,12 @@ -import textwrap - from django.conf import settings from django.core.checks import Error from django.core.checks import Warning from django.core.checks import register -from django.core.exceptions import FieldError -from django.db.utils import OperationalError -from django.db.utils import ProgrammingError from documents.signals import document_consumer_declaration from documents.templating.utils import convert_format_str_to_template_format -@register() -def changed_password_check(app_configs, **kwargs): - from documents.models import Document - from paperless.db import GnuPG - - try: - encrypted_doc = ( - Document.objects.filter( - storage_type=Document.STORAGE_TYPE_GPG, - ) - .only("pk", "storage_type") - .first() - ) - except (OperationalError, ProgrammingError, FieldError): - return [] # No documents table yet - - if encrypted_doc: - if not settings.PASSPHRASE: - return [ - Error( - "The database contains encrypted documents but no password is set.", - ), - ] - - if not GnuPG.decrypted(encrypted_doc.source_file): - return [ - Error( - textwrap.dedent( - """ - The current password doesn't match the password of the - existing documents. - - If you intend to change your password, you must first export - all of the old documents, start fresh with the new password - and then re-import them." - """, - ), - ), - ] - - return [] - - @register() def parser_check(app_configs, **kwargs): parsers = [] diff --git a/src/documents/conditionals.py b/src/documents/conditionals.py index 47d9bfe4b..b93cabf62 100644 --- a/src/documents/conditionals.py +++ b/src/documents/conditionals.py @@ -128,7 +128,7 @@ def thumbnail_last_modified(request, pk: int) -> datetime | None: Cache should be (slightly?) faster than filesystem """ try: - doc = Document.objects.only("storage_type").get(pk=pk) + doc = Document.objects.only("pk").get(pk=pk) if not doc.thumbnail_path.exists(): return None doc_key = get_thumbnail_modified_key(pk) diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 2c1cf025b..4c8c4dd28 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -497,7 +497,6 @@ class ConsumerPlugin( create_source_path_directory(document.source_path) self._write( - document.storage_type, self.unmodified_original if self.unmodified_original is not None else self.working_copy, @@ -505,7 +504,6 @@ class ConsumerPlugin( ) self._write( - document.storage_type, thumbnail, document.thumbnail_path, ) @@ -517,7 +515,6 @@ class ConsumerPlugin( ) create_source_path_directory(document.archive_path) self._write( - document.storage_type, archive_path, document.archive_path, ) @@ -637,8 +634,6 @@ class ConsumerPlugin( ) self.log.debug(f"Creation date from st_mtime: {create_date}") - storage_type = Document.STORAGE_TYPE_UNENCRYPTED - if self.metadata.filename: title = Path(self.metadata.filename).stem else: @@ -665,7 +660,6 @@ class ConsumerPlugin( checksum=hashlib.md5(file_for_checksum.read_bytes()).hexdigest(), created=create_date, modified=create_date, - storage_type=storage_type, page_count=page_count, original_filename=self.filename, ) @@ -736,7 +730,7 @@ class ConsumerPlugin( } CustomFieldInstance.objects.create(**args) # adds to document - def _write(self, storage_type, source, target): + def _write(self, source, target): with ( Path(source).open("rb") as read_file, Path(target).open("wb") as write_file, diff --git a/src/documents/file_handling.py b/src/documents/file_handling.py index 48cd57311..39831016d 100644 --- a/src/documents/file_handling.py +++ b/src/documents/file_handling.py @@ -126,7 +126,6 @@ def generate_filename( doc: Document, *, counter=0, - append_gpg=True, archive_filename=False, ) -> Path: base_path: Path | None = None @@ -170,8 +169,4 @@ def generate_filename( final_filename = f"{doc.pk:07}{counter_str}{filetype_str}" full_path = Path(final_filename) - # Add GPG extension if needed - if append_gpg and doc.storage_type == doc.STORAGE_TYPE_GPG: - full_path = full_path.with_suffix(full_path.suffix + ".gpg") - return full_path diff --git a/src/documents/management/commands/decrypt_documents.py b/src/documents/management/commands/decrypt_documents.py deleted file mode 100644 index 793cac4bb..000000000 --- a/src/documents/management/commands/decrypt_documents.py +++ /dev/null @@ -1,93 +0,0 @@ -from pathlib import Path - -from django.conf import settings -from django.core.management.base import BaseCommand -from django.core.management.base import CommandError - -from documents.models import Document -from paperless.db import GnuPG - - -class Command(BaseCommand): - help = ( - "This is how you migrate your stored documents from an encrypted " - "state to an unencrypted one (or vice-versa)" - ) - - def add_arguments(self, parser) -> None: - parser.add_argument( - "--passphrase", - help=( - "If PAPERLESS_PASSPHRASE isn't set already, you need to specify it here" - ), - ) - - def handle(self, *args, **options) -> None: - try: - self.stdout.write( - self.style.WARNING( - "\n\n" - "WARNING: This script is going to work directly on your " - "document originals, so\n" - "WARNING: you probably shouldn't run " - "this unless you've got a recent backup\n" - "WARNING: handy. It " - "*should* work without a hitch, but be safe and backup your\n" - "WARNING: stuff first.\n\n" - "Hit Ctrl+C to exit now, or Enter to " - "continue.\n\n", - ), - ) - _ = input() - except KeyboardInterrupt: - return - - passphrase = options["passphrase"] or settings.PASSPHRASE - if not passphrase: - raise CommandError( - "Passphrase not defined. Please set it with --passphrase or " - "by declaring it in your environment or your config.", - ) - - self.__gpg_to_unencrypted(passphrase) - - def __gpg_to_unencrypted(self, passphrase: str) -> None: - encrypted_files = Document.objects.filter( - storage_type=Document.STORAGE_TYPE_GPG, - ) - - for document in encrypted_files: - self.stdout.write(f"Decrypting {document}") - - old_paths = [document.source_path, document.thumbnail_path] - - with document.source_file as file_handle: - raw_document = GnuPG.decrypted(file_handle, passphrase) - with document.thumbnail_file as file_handle: - raw_thumb = GnuPG.decrypted(file_handle, passphrase) - - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED - - ext: str = Path(document.filename).suffix - - if not ext == ".gpg": - raise CommandError( - f"Abort: encrypted file {document.source_path} does not " - f"end with .gpg", - ) - - document.filename = Path(document.filename).stem - - with document.source_path.open("wb") as f: - f.write(raw_document) - - with document.thumbnail_path.open("wb") as f: - f.write(raw_thumb) - - Document.objects.filter(id=document.id).update( - storage_type=document.storage_type, - filename=document.filename, - ) - - for path in old_paths: - path.unlink() diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py index 97027e02d..e57569129 100644 --- a/src/documents/management/commands/document_consumer.py +++ b/src/documents/management/commands/document_consumer.py @@ -1,135 +1,343 @@ +""" +Document consumer management command. + +Watches a consumption directory for new documents and queues them for processing. +Uses watchfiles for efficient file system monitoring with support for both +native OS notifications and polling fallback. +""" + +from __future__ import annotations + import logging -import os -from concurrent.futures import ThreadPoolExecutor -from fnmatch import filter +from dataclasses import dataclass from pathlib import Path -from pathlib import PurePath from threading import Event from time import monotonic -from time import sleep +from typing import TYPE_CHECKING from typing import Final from django import db from django.conf import settings from django.core.management.base import BaseCommand from django.core.management.base import CommandError -from watchdog.events import FileSystemEventHandler -from watchdog.observers.polling import PollingObserver +from watchfiles import Change +from watchfiles import DefaultFilter +from watchfiles import watch from documents.data_models import ConsumableDocument from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentSource from documents.models import Tag -from documents.parsers import is_file_ext_supported +from documents.parsers import get_supported_file_extensions from documents.tasks import consume_file -try: - from inotifyrecursive import INotify - from inotifyrecursive import flags -except ImportError: # pragma: no cover - INotify = flags = None +if TYPE_CHECKING: + from collections.abc import Iterator + logger = logging.getLogger("paperless.management.consumer") -def _tags_from_path(filepath: Path) -> list[int]: +@dataclass +class TrackedFile: + """Represents a file being tracked for stability.""" + + path: Path + last_event_time: float + last_mtime: float | None = None + last_size: int | None = None + + def update_stats(self) -> bool: + """ + Update file stats. Returns True if file exists and stats were updated. + """ + try: + stat = self.path.stat() + self.last_mtime = stat.st_mtime + self.last_size = stat.st_size + return True + except OSError: + return False + + def is_unchanged(self) -> bool: + """ + Check if file stats match the previously recorded values. + Returns False if file doesn't exist or stats changed. + """ + try: + stat = self.path.stat() + return stat.st_mtime == self.last_mtime and stat.st_size == self.last_size + except OSError: + return False + + +class FileStabilityTracker: """ - Walk up the directory tree from filepath to CONSUMPTION_DIR + Tracks file events and determines when files are stable for consumption. + + A file is considered stable when: + 1. No new events have been received for it within the stability delay + 2. Its size and modification time haven't changed + 3. It still exists as a regular file + + This handles various edge cases: + - Network copies that write in chunks + - Scanners that open/close files multiple times + - Temporary files that get renamed + - Files that are deleted before becoming stable + """ + + def __init__(self, stability_delay: float = 1.0) -> None: + """ + Initialize the tracker. + + Args: + stability_delay: Time in seconds a file must remain unchanged + before being considered stable. + """ + self.stability_delay = stability_delay + self._tracked: dict[Path, TrackedFile] = {} + + def track(self, path: Path, change: Change) -> None: + """ + Register a file event. + + Args: + path: The file path that changed. + change: The type of change (added, modified, deleted). + """ + path = path.resolve() + + match change: + case Change.deleted: + self._tracked.pop(path, None) + logger.debug(f"Stopped tracking deleted file: {path}") + case Change.added | Change.modified: + current_time = monotonic() + if path in self._tracked: + tracked = self._tracked[path] + tracked.last_event_time = current_time + tracked.update_stats() + logger.debug(f"Updated tracking for: {path}") + else: + tracked = TrackedFile(path=path, last_event_time=current_time) + if tracked.update_stats(): + self._tracked[path] = tracked + logger.debug(f"Started tracking: {path}") + else: + logger.debug(f"Could not stat file, not tracking: {path}") + + def get_stable_files(self) -> Iterator[Path]: + """ + Yield files that have been stable for the configured delay. + + Files are removed from tracking once yielded or determined to be invalid. + """ + current_time = monotonic() + to_remove: list[Path] = [] + to_yield: list[Path] = [] + + for path, tracked in self._tracked.items(): + time_since_event = current_time - tracked.last_event_time + + if time_since_event < self.stability_delay: + continue + + # File has waited long enough, verify it's unchanged + if not tracked.is_unchanged(): + # Stats changed or file gone - update and wait again + if tracked.update_stats(): + tracked.last_event_time = current_time + logger.debug(f"File changed during stability check: {path}") + else: + # File no longer exists, remove from tracking + to_remove.append(path) + logger.debug(f"File disappeared during stability check: {path}") + continue + + # File is stable, we can return it + to_yield.append(path) + logger.info(f"File is stable: {path}") + + # Remove files that are no longer valid + for path in to_remove: + self._tracked.pop(path, None) + + # Remove and yield stable files + for path in to_yield: + self._tracked.pop(path, None) + yield path + + def has_pending_files(self) -> bool: + """Check if there are files waiting for stability check.""" + return len(self._tracked) > 0 + + @property + def pending_count(self) -> int: + """Number of files being tracked.""" + return len(self._tracked) + + +class ConsumerFilter(DefaultFilter): + """ + Filter for watchfiles that accepts only supported document types + and ignores system files/directories. + + Extends DefaultFilter leveraging its built-in filtering: + - `ignore_dirs`: Directory names to ignore (and all their contents) + - `ignore_entity_patterns`: Regex patterns matched against filename/dirname only + + We add custom logic for file extension filtering (only accept supported + document types), which the library doesn't provide. + """ + + # Regex patterns for files to always ignore (matched against filename only) + # These are passed to DefaultFilter.ignore_entity_patterns + DEFAULT_IGNORE_PATTERNS: Final[tuple[str, ...]] = ( + r"^\.DS_Store$", + r"^\.DS_STORE$", + r"^\._.*", + r"^desktop\.ini$", + r"^Thumbs\.db$", + ) + + # Directories to always ignore (passed to DefaultFilter.ignore_dirs) + # These are matched by directory name, not full path + DEFAULT_IGNORE_DIRS: Final[tuple[str, ...]] = ( + ".stfolder", # Syncthing + ".stversions", # Syncthing + ".localized", # macOS + "@eaDir", # Synology NAS + ".Spotlight-V100", # macOS + ".Trashes", # macOS + "__MACOSX", # macOS archive artifacts + ) + + def __init__( + self, + *, + supported_extensions: frozenset[str] | None = None, + ignore_patterns: list[str] | None = None, + ignore_dirs: list[str] | None = None, + ) -> None: + """ + Initialize the consumer filter. + + Args: + supported_extensions: Set of file extensions to accept (e.g., {".pdf", ".png"}). + If None, uses get_supported_file_extensions(). + ignore_patterns: Additional regex patterns to ignore (matched against filename). + ignore_dirs: Additional directory names to ignore (merged with defaults). + """ + # Get supported extensions + if supported_extensions is None: + supported_extensions = frozenset(get_supported_file_extensions()) + self._supported_extensions = supported_extensions + + # Combine default and user patterns + all_patterns: list[str] = list(self.DEFAULT_IGNORE_PATTERNS) + if ignore_patterns: + all_patterns.extend(ignore_patterns) + + # Combine default and user ignore_dirs + all_ignore_dirs: list[str] = list(self.DEFAULT_IGNORE_DIRS) + if ignore_dirs: + all_ignore_dirs.extend(ignore_dirs) + + # Let DefaultFilter handle all the pattern and directory filtering + super().__init__( + ignore_dirs=tuple(all_ignore_dirs), + ignore_entity_patterns=tuple(all_patterns), + ignore_paths=(), + ) + + def __call__(self, change: Change, path: str) -> bool: + """ + Filter function for watchfiles. + + Returns True if the path should be watched, False to ignore. + + The parent DefaultFilter handles: + - Hidden files/directories (starting with .) + - Directories in ignore_dirs + - Files/directories matching ignore_entity_patterns + + We additionally filter files by extension. + """ + # Let parent filter handle directory ignoring and pattern matching + if not super().__call__(change, path): + return False + + path_obj = Path(path) + + # For directories, parent filter already handled everything + if path_obj.is_dir(): + return True + + # For files, check extension + return self._has_supported_extension(path_obj) + + def _has_supported_extension(self, path: Path) -> bool: + """Check if the file has a supported extension.""" + suffix = path.suffix.lower() + return suffix in self._supported_extensions + + +def _tags_from_path(filepath: Path, consumption_dir: Path) -> list[int]: + """ + Walk up the directory tree from filepath to consumption_dir and get or create Tag IDs for every directory. - Returns set of Tag models + Returns list of Tag primary keys. """ db.close_old_connections() - tag_ids = set() - path_parts = filepath.relative_to(settings.CONSUMPTION_DIR).parent.parts + tag_ids: set[int] = set() + path_parts = filepath.relative_to(consumption_dir).parent.parts + for part in path_parts: - tag_ids.add( - Tag.objects.get_or_create(name__iexact=part, defaults={"name": part})[0].pk, + tag, _ = Tag.objects.get_or_create( + name__iexact=part, + defaults={"name": part}, ) + tag_ids.add(tag.pk) return list(tag_ids) -def _is_ignored(filepath: Path) -> bool: +def _consume_file( + filepath: Path, + consumption_dir: Path, + *, + subdirs_as_tags: bool, +) -> None: """ - Checks if the given file should be ignored, based on configured - patterns. + Queue a file for consumption. - Returns True if the file is ignored, False otherwise + Args: + filepath: Path to the file to consume. + consumption_dir: Base consumption directory. + subdirs_as_tags: Whether to create tags from subdirectory names. """ - # Trim out the consume directory, leaving only filename and it's - # path relative to the consume directory - filepath_relative = PurePath(filepath).relative_to(settings.CONSUMPTION_DIR) - - # March through the components of the path, including directories and the filename - # looking for anything matching - # foo/bar/baz/file.pdf -> (foo, bar, baz, file.pdf) - parts = [] - for part in filepath_relative.parts: - # If the part is not the name (ie, it's a dir) - # Need to append the trailing slash or fnmatch doesn't match - # fnmatch("dir", "dir/*") == False - # fnmatch("dir/", "dir/*") == True - if part != filepath_relative.name: - part = part + "/" - parts.append(part) - - for pattern in settings.CONSUMER_IGNORE_PATTERNS: - if len(filter(parts, pattern)): - return True - - return False - - -def _consume(filepath: Path) -> None: - # Check permissions early + # Verify file still exists and is accessible try: - filepath.stat() - except (PermissionError, OSError): - logger.warning(f"Not consuming file {filepath}: Permission denied.") + if not filepath.is_file(): + logger.debug(f"Not consuming {filepath}: not a file or doesn't exist") + return + except OSError as e: + logger.warning(f"Not consuming {filepath}: {e}") return - if filepath.is_dir() or _is_ignored(filepath): - return - - if not filepath.is_file(): - logger.debug(f"Not consuming file {filepath}: File has moved.") - return - - if not is_file_ext_supported(filepath.suffix): - logger.warning(f"Not consuming file {filepath}: Unknown file extension.") - return - - # Total wait time: up to 500ms - os_error_retry_count: Final[int] = 50 - os_error_retry_wait: Final[float] = 0.01 - - read_try_count = 0 - file_open_ok = False - os_error_str = None - - while (read_try_count < os_error_retry_count) and not file_open_ok: + # Get tags from path if configured + tag_ids: list[int] | None = None + if subdirs_as_tags: try: - with filepath.open("rb"): - file_open_ok = True - except OSError as e: - read_try_count += 1 - os_error_str = str(e) - sleep(os_error_retry_wait) + tag_ids = _tags_from_path(filepath, consumption_dir) + except Exception: + logger.exception(f"Error creating tags from path for {filepath}") - if read_try_count >= os_error_retry_count: - logger.warning(f"Not consuming file {filepath}: OS reports {os_error_str}") - return - - tag_ids = None + # Queue for consumption try: - if settings.CONSUMER_SUBDIRS_AS_TAGS: - tag_ids = _tags_from_path(filepath) - except Exception: - logger.exception("Error creating tags from path") - - try: - logger.info(f"Adding {filepath} to the task queue.") + logger.info(f"Adding {filepath} to the task queue") consume_file.delay( ConsumableDocument( source=DocumentSource.ConsumeFolder, @@ -138,228 +346,209 @@ def _consume(filepath: Path) -> None: DocumentMetadataOverrides(tag_ids=tag_ids), ) except Exception: - # Catch all so that the consumer won't crash. - # This is also what the test case is listening for to check for - # errors. - logger.exception("Error while consuming document") - - -def _consume_wait_unmodified(file: Path) -> None: - """ - Waits for the given file to appear unmodified based on file size - and modification time. Will wait a configured number of seconds - and retry a configured number of times before either consuming or - giving up - """ - if _is_ignored(file): - return - - logger.debug(f"Waiting for file {file} to remain unmodified") - mtime = -1 - size = -1 - current_try = 0 - while current_try < settings.CONSUMER_POLLING_RETRY_COUNT: - try: - stat_data = file.stat() - new_mtime = stat_data.st_mtime - new_size = stat_data.st_size - except FileNotFoundError: - logger.debug( - f"File {file} moved while waiting for it to remain unmodified.", - ) - return - if new_mtime == mtime and new_size == size: - _consume(file) - return - mtime = new_mtime - size = new_size - sleep(settings.CONSUMER_POLLING_DELAY) - current_try += 1 - - logger.error(f"Timeout while waiting on file {file} to remain unmodified.") - - -class Handler(FileSystemEventHandler): - def __init__(self, pool: ThreadPoolExecutor) -> None: - super().__init__() - self._pool = pool - - def on_created(self, event): - self._pool.submit(_consume_wait_unmodified, Path(event.src_path)) - - def on_moved(self, event): - self._pool.submit(_consume_wait_unmodified, Path(event.dest_path)) + logger.exception(f"Error while queuing document {filepath}") class Command(BaseCommand): """ - On every iteration of an infinite loop, consume what we can from the - consumption directory. + Watch a consumption directory and queue new documents for processing. + + Uses watchfiles for efficient file system monitoring. Supports both + native OS notifications (inotify on Linux, FSEvents on macOS) and + polling for network filesystems. """ - # This is here primarily for the tests and is irrelevant in production. - stop_flag = Event() - # Also only for testing, configures in one place the timeout used before checking - # the stop flag - testing_timeout_s: Final[float] = 0.5 - testing_timeout_ms: Final[float] = testing_timeout_s * 1000.0 + help = "Watch the consumption directory for new documents" - def add_arguments(self, parser): + # For testing - allows tests to stop the consumer + stop_flag: Event = Event() + + # Testing timeout in seconds + testing_timeout_s: Final[float] = 0.5 + + def add_arguments(self, parser) -> None: parser.add_argument( "directory", - default=settings.CONSUMPTION_DIR, + default=None, nargs="?", - help="The consumption directory.", + help="The consumption directory (defaults to CONSUMPTION_DIR setting)", + ) + parser.add_argument( + "--oneshot", + action="store_true", + help="Process existing files and exit without watching", ) - parser.add_argument("--oneshot", action="store_true", help="Run only once.") - - # Only use during unit testing, will configure a timeout - # Leaving it unset or false and the consumer will exit when it - # receives SIGINT parser.add_argument( "--testing", action="store_true", - help="Flag used only for unit testing", + help="Enable testing mode with shorter timeouts", default=False, ) - def handle(self, *args, **options): - directory = options["directory"] - recursive = settings.CONSUMER_RECURSIVE - + def handle(self, *args, **options) -> None: + # Resolve consumption directory + directory = options.get("directory") if not directory: - raise CommandError("CONSUMPTION_DIR does not appear to be set.") + directory = getattr(settings, "CONSUMPTION_DIR", None) + if not directory: + raise CommandError("CONSUMPTION_DIR is not configured") directory = Path(directory).resolve() - if not directory.is_dir(): - raise CommandError(f"Consumption directory {directory} does not exist") + if not directory.exists(): + raise CommandError(f"Consumption directory does not exist: {directory}") - # Consumer will need this + if not directory.is_dir(): + raise CommandError(f"Consumption path is not a directory: {directory}") + + # Ensure scratch directory exists settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True) - if recursive: - for dirpath, _, filenames in os.walk(directory): - for filename in filenames: - filepath = Path(dirpath) / filename - _consume(filepath) - else: - for filepath in directory.iterdir(): - _consume(filepath) + # Get settings + recursive: bool = settings.CONSUMER_RECURSIVE + subdirs_as_tags: bool = settings.CONSUMER_SUBDIRS_AS_TAGS + polling_interval: float = settings.CONSUMER_POLLING_INTERVAL + stability_delay: float = settings.CONSUMER_STABILITY_DELAY + ignore_patterns: list[str] = settings.CONSUMER_IGNORE_PATTERNS + ignore_dirs: list[str] = settings.CONSUMER_IGNORE_DIRS + is_testing: bool = options.get("testing", False) + is_oneshot: bool = options.get("oneshot", False) - if options["oneshot"]: + # Create filter + consumer_filter = ConsumerFilter( + ignore_patterns=ignore_patterns, + ignore_dirs=ignore_dirs, + ) + + # Process existing files + self._process_existing_files( + directory=directory, + recursive=recursive, + subdirs_as_tags=subdirs_as_tags, + consumer_filter=consumer_filter, + ) + + if is_oneshot: + logger.info("Oneshot mode: processed existing files, exiting") return - if settings.CONSUMER_POLLING == 0 and INotify: - self.handle_inotify(directory, recursive, is_testing=options["testing"]) + # Start watching + self._watch_directory( + directory=directory, + recursive=recursive, + subdirs_as_tags=subdirs_as_tags, + consumer_filter=consumer_filter, + polling_interval=polling_interval, + stability_delay=stability_delay, + is_testing=is_testing, + ) + + logger.debug("Consumer exiting") + + def _process_existing_files( + self, + *, + directory: Path, + recursive: bool, + subdirs_as_tags: bool, + consumer_filter: ConsumerFilter, + ) -> None: + """Process any existing files in the consumption directory.""" + logger.info(f"Processing existing files in {directory}") + + glob_pattern = "**/*" if recursive else "*" + + for filepath in directory.glob(glob_pattern): + # Use filter to check if file should be processed + if not filepath.is_file(): + continue + + if not consumer_filter(Change.added, str(filepath)): + continue + + _consume_file( + filepath=filepath, + consumption_dir=directory, + subdirs_as_tags=subdirs_as_tags, + ) + + def _watch_directory( + self, + *, + directory: Path, + recursive: bool, + subdirs_as_tags: bool, + consumer_filter: ConsumerFilter, + polling_interval: float, + stability_delay: float, + is_testing: bool, + ) -> None: + """Watch directory for changes and process stable files.""" + use_polling = polling_interval > 0 + poll_delay_ms = int(polling_interval * 1000) if use_polling else 0 + + if use_polling: + logger.info( + f"Watching {directory} using polling (interval: {polling_interval}s)", + ) else: - if INotify is None and settings.CONSUMER_POLLING == 0: # pragma: no cover - logger.warning("Using polling as INotify import failed") - self.handle_polling(directory, recursive, is_testing=options["testing"]) + logger.info(f"Watching {directory} using native file system events") - logger.debug("Consumer exiting.") + # Create stability tracker + tracker = FileStabilityTracker(stability_delay=stability_delay) - def handle_polling(self, directory, recursive, *, is_testing: bool): - logger.info(f"Polling directory for changes: {directory}") + # Calculate timeouts + stability_timeout_ms = int(stability_delay * 1000) + testing_timeout_ms = int(self.testing_timeout_s * 1000) - timeout = None - if is_testing: - timeout = self.testing_timeout_s - logger.debug(f"Configuring timeout to {timeout}s") + # Start with no timeout (wait indefinitely for first event) + # unless in testing mode + timeout_ms = testing_timeout_ms if is_testing else 0 - polling_interval = settings.CONSUMER_POLLING - if polling_interval == 0: # pragma: no cover - # Only happens if INotify failed to import - logger.warning("Using polling of 10s, consider setting this") - polling_interval = 10 + self.stop_flag.clear() - with ThreadPoolExecutor(max_workers=4) as pool: - observer = PollingObserver(timeout=polling_interval) - observer.schedule(Handler(pool), directory, recursive=recursive) - observer.start() + while not self.stop_flag.is_set(): try: - while observer.is_alive(): - observer.join(timeout) - if self.stop_flag.is_set(): - observer.stop() - except KeyboardInterrupt: - observer.stop() - observer.join() - - def handle_inotify(self, directory, recursive, *, is_testing: bool): - logger.info(f"Using inotify to watch directory for changes: {directory}") - - timeout_ms = None - if is_testing: - timeout_ms = self.testing_timeout_ms - logger.debug(f"Configuring timeout to {timeout_ms}ms") - - inotify = INotify() - inotify_flags = flags.CLOSE_WRITE | flags.MOVED_TO | flags.MODIFY - if recursive: - inotify.add_watch_recursive(directory, inotify_flags) - else: - inotify.add_watch(directory, inotify_flags) - - inotify_debounce_secs: Final[float] = settings.CONSUMER_INOTIFY_DELAY - inotify_debounce_ms: Final[int] = inotify_debounce_secs * 1000 - - finished = False - - notified_files = {} - - try: - while not finished: - try: - for event in inotify.read(timeout=timeout_ms): - path = inotify.get_path(event.wd) if recursive else directory - filepath = Path(path) / event.name - if flags.MODIFY in flags.from_mask(event.mask): - notified_files.pop(filepath, None) - else: - notified_files[filepath] = monotonic() - - # Check the files against the timeout - still_waiting = {} - # last_event_time is time of the last inotify event for this file - for filepath, last_event_time in notified_files.items(): - # Current time - last time over the configured timeout - waited_long_enough = ( - monotonic() - last_event_time - ) > inotify_debounce_secs - - # Also make sure the file exists still, some scanners might write a - # temporary file first - try: - file_still_exists = filepath.exists() and filepath.is_file() - except (PermissionError, OSError): # pragma: no cover - # If we can't check, let it fail in the _consume function - file_still_exists = True + for changes in watch( + directory, + watch_filter=consumer_filter, + rust_timeout=timeout_ms, + yield_on_timeout=True, + force_polling=use_polling, + poll_delay_ms=poll_delay_ms, + recursive=recursive, + stop_event=self.stop_flag, + ): + # Process each change + for change_type, path in changes: + path = Path(path).resolve() + if not path.is_file(): continue + logger.debug(f"Event: {change_type.name} for {path}") + tracker.track(path, change_type) - if waited_long_enough and file_still_exists: - _consume(filepath) - elif file_still_exists: - still_waiting[filepath] = last_event_time + # Check for stable files + for stable_path in tracker.get_stable_files(): + _consume_file( + filepath=stable_path, + consumption_dir=directory, + subdirs_as_tags=subdirs_as_tags, + ) - # These files are still waiting to hit the timeout - notified_files = still_waiting + # Exit watch loop to reconfigure timeout + break - # If files are waiting, need to exit read() to check them - # Otherwise, go back to infinite sleep time, but only if not testing - if len(notified_files) > 0: - timeout_ms = inotify_debounce_ms - elif is_testing: - timeout_ms = self.testing_timeout_ms - else: - timeout_ms = None + # Determine next timeout + if tracker.has_pending_files(): + # Check pending files at stability interval + timeout_ms = stability_timeout_ms + elif is_testing: + # In testing, use short timeout to check stop flag + timeout_ms = testing_timeout_ms + else: # pragma: nocover + # No pending files, wait indefinitely + timeout_ms = 0 - if self.stop_flag.is_set(): - logger.debug("Finishing because event is set") - finished = True - - except KeyboardInterrupt: - logger.info("Received SIGINT, stopping inotify") - finished = True - finally: - inotify.close() + except KeyboardInterrupt: # pragma: nocover + logger.info("Received interrupt, stopping consumer") + self.stop_flag.set() diff --git a/src/documents/management/commands/document_exporter.py b/src/documents/management/commands/document_exporter.py index 88daeddf5..77b3b6416 100644 --- a/src/documents/management/commands/document_exporter.py +++ b/src/documents/management/commands/document_exporter.py @@ -3,7 +3,6 @@ import json import os import shutil import tempfile -import time from pathlib import Path from typing import TYPE_CHECKING @@ -56,7 +55,6 @@ from documents.settings import EXPORTER_FILE_NAME from documents.settings import EXPORTER_THUMBNAIL_NAME from documents.utils import copy_file_with_basic_stats from paperless import version -from paperless.db import GnuPG from paperless.models import ApplicationConfiguration from paperless_mail.models import MailAccount from paperless_mail.models import MailRule @@ -316,20 +314,17 @@ class Command(CryptMixin, BaseCommand): total=len(document_manifest), disable=self.no_progress_bar, ): - # 3.1. store files unencrypted - document_dict["fields"]["storage_type"] = Document.STORAGE_TYPE_UNENCRYPTED - document = document_map[document_dict["pk"]] - # 3.2. generate a unique filename + # 3.1. generate a unique filename base_name = self.generate_base_name(document) - # 3.3. write filenames into manifest + # 3.2. write filenames into manifest original_target, thumbnail_target, archive_target = ( self.generate_document_targets(document, base_name, document_dict) ) - # 3.4. write files to target folder + # 3.3. write files to target folder if not self.data_only: self.copy_document_files( document, @@ -423,7 +418,6 @@ class Command(CryptMixin, BaseCommand): base_name = generate_filename( document, counter=filename_counter, - append_gpg=False, ) else: base_name = document.get_public_filename(counter=filename_counter) @@ -482,46 +476,24 @@ class Command(CryptMixin, BaseCommand): If the document is encrypted, the files are decrypted before copying them to the target location. """ - if document.storage_type == Document.STORAGE_TYPE_GPG: - t = int(time.mktime(document.created.timetuple())) + self.check_and_copy( + document.source_path, + document.checksum, + original_target, + ) - original_target.parent.mkdir(parents=True, exist_ok=True) - with document.source_file as out_file: - original_target.write_bytes(GnuPG.decrypted(out_file)) - os.utime(original_target, times=(t, t)) + if thumbnail_target: + self.check_and_copy(document.thumbnail_path, None, thumbnail_target) - if thumbnail_target: - thumbnail_target.parent.mkdir(parents=True, exist_ok=True) - with document.thumbnail_file as out_file: - thumbnail_target.write_bytes(GnuPG.decrypted(out_file)) - os.utime(thumbnail_target, times=(t, t)) - - if archive_target: - archive_target.parent.mkdir(parents=True, exist_ok=True) - if TYPE_CHECKING: - assert isinstance(document.archive_path, Path) - with document.archive_path as out_file: - archive_target.write_bytes(GnuPG.decrypted(out_file)) - os.utime(archive_target, times=(t, t)) - else: + if archive_target: + if TYPE_CHECKING: + assert isinstance(document.archive_path, Path) self.check_and_copy( - document.source_path, - document.checksum, - original_target, + document.archive_path, + document.archive_checksum, + archive_target, ) - if thumbnail_target: - self.check_and_copy(document.thumbnail_path, None, thumbnail_target) - - if archive_target: - if TYPE_CHECKING: - assert isinstance(document.archive_path, Path) - self.check_and_copy( - document.archive_path, - document.archive_checksum, - archive_target, - ) - def check_and_write_json( self, content: list[dict] | dict, diff --git a/src/documents/management/commands/document_importer.py b/src/documents/management/commands/document_importer.py index 3e614c6a6..ba3d793b3 100644 --- a/src/documents/management/commands/document_importer.py +++ b/src/documents/management/commands/document_importer.py @@ -383,8 +383,6 @@ class Command(CryptMixin, BaseCommand): else: archive_path = None - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED - with FileLock(settings.MEDIA_LOCK): if Path(document.source_path).is_file(): raise FileExistsError(document.source_path) diff --git a/src/documents/management/commands/document_llmindex.py b/src/documents/management/commands/document_llmindex.py new file mode 100644 index 000000000..d2df02ed9 --- /dev/null +++ b/src/documents/management/commands/document_llmindex.py @@ -0,0 +1,22 @@ +from django.core.management import BaseCommand +from django.db import transaction + +from documents.management.commands.mixins import ProgressBarMixin +from documents.tasks import llmindex_index + + +class Command(ProgressBarMixin, BaseCommand): + help = "Manages the LLM-based vector index for Paperless." + + def add_arguments(self, parser): + parser.add_argument("command", choices=["rebuild", "update"]) + self.add_argument_progress_bar_mixin(parser) + + def handle(self, *args, **options): + self.handle_progress_bar_mixin(**options) + with transaction.atomic(): + llmindex_index( + progress_bar_disable=self.no_progress_bar, + rebuild=options["command"] == "rebuild", + scheduled=False, + ) diff --git a/src/documents/migrations/0001_initial.py b/src/documents/migrations/0001_initial.py index 89c9e29df..d68a5934b 100644 --- a/src/documents/migrations/0001_initial.py +++ b/src/documents/migrations/0001_initial.py @@ -1,5 +1,13 @@ -# Generated by Django 1.9 on 2015-12-20 19:10 +# Generated by Django 5.2.7 on 2026-01-15 22:08 +import datetime + +import django.core.validators +import django.db.models.deletion +import django.db.models.functions.comparison +import django.db.models.functions.text +import django.utils.timezone +import multiselectfield.db.fields from django.conf import settings from django.db import migrations from django.db import models @@ -8,9 +16,554 @@ from django.db import models class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] operations = [ + migrations.CreateModel( + name="WorkflowActionEmail", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "subject", + models.CharField( + help_text="The subject of the email, can include some placeholders, see documentation.", + max_length=256, + verbose_name="email subject", + ), + ), + ( + "body", + models.TextField( + help_text="The body (message) of the email, can include some placeholders, see documentation.", + verbose_name="email body", + ), + ), + ( + "to", + models.TextField( + help_text="The destination email addresses, comma separated.", + verbose_name="emails to", + ), + ), + ( + "include_document", + models.BooleanField( + default=False, + verbose_name="include document in email", + ), + ), + ], + ), + migrations.CreateModel( + name="WorkflowActionWebhook", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "url", + models.CharField( + help_text="The destination URL for the notification.", + max_length=256, + verbose_name="webhook url", + ), + ), + ( + "use_params", + models.BooleanField(default=True, verbose_name="use parameters"), + ), + ( + "as_json", + models.BooleanField(default=False, verbose_name="send as JSON"), + ), + ( + "params", + models.JSONField( + blank=True, + help_text="The parameters to send with the webhook URL if body not used.", + null=True, + verbose_name="webhook parameters", + ), + ), + ( + "body", + models.TextField( + blank=True, + help_text="The body to send with the webhook URL if parameters not used.", + null=True, + verbose_name="webhook body", + ), + ), + ( + "headers", + models.JSONField( + blank=True, + help_text="The headers to send with the webhook URL.", + null=True, + verbose_name="webhook headers", + ), + ), + ( + "include_document", + models.BooleanField( + default=False, + verbose_name="include document in webhook", + ), + ), + ], + ), + migrations.CreateModel( + name="Correspondent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=128, verbose_name="name")), + ( + "match", + models.CharField(blank=True, max_length=256, verbose_name="match"), + ), + ( + "matching_algorithm", + models.PositiveIntegerField( + choices=[ + (0, "None"), + (1, "Any word"), + (2, "All words"), + (3, "Exact match"), + (4, "Regular expression"), + (5, "Fuzzy word"), + (6, "Automatic"), + ], + default=1, + verbose_name="matching algorithm", + ), + ), + ( + "is_insensitive", + models.BooleanField(default=True, verbose_name="is insensitive"), + ), + ( + "owner", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "verbose_name": "correspondent", + "verbose_name_plural": "correspondents", + "ordering": ("name",), + "abstract": False, + }, + ), + migrations.CreateModel( + name="CustomField", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + models.DateTimeField( + db_index=True, + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ("name", models.CharField(max_length=128)), + ( + "data_type", + models.CharField( + choices=[ + ("string", "String"), + ("url", "URL"), + ("date", "Date"), + ("boolean", "Boolean"), + ("integer", "Integer"), + ("float", "Float"), + ("monetary", "Monetary"), + ("documentlink", "Document Link"), + ("select", "Select"), + ("longtext", "Long Text"), + ], + editable=False, + max_length=50, + verbose_name="data type", + ), + ), + ( + "extra_data", + models.JSONField( + blank=True, + help_text="Extra data for the custom field, such as select options", + null=True, + verbose_name="extra data", + ), + ), + ], + options={ + "verbose_name": "custom field", + "verbose_name_plural": "custom fields", + "ordering": ("created",), + "constraints": [ + models.UniqueConstraint( + fields=("name",), + name="documents_customfield_unique_name", + ), + ], + }, + ), + migrations.CreateModel( + name="DocumentType", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=128, verbose_name="name")), + ( + "match", + models.CharField(blank=True, max_length=256, verbose_name="match"), + ), + ( + "matching_algorithm", + models.PositiveIntegerField( + choices=[ + (0, "None"), + (1, "Any word"), + (2, "All words"), + (3, "Exact match"), + (4, "Regular expression"), + (5, "Fuzzy word"), + (6, "Automatic"), + ], + default=1, + verbose_name="matching algorithm", + ), + ), + ( + "is_insensitive", + models.BooleanField(default=True, verbose_name="is insensitive"), + ), + ( + "owner", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "verbose_name": "document type", + "verbose_name_plural": "document types", + "ordering": ("name",), + "abstract": False, + }, + ), + migrations.CreateModel( + name="StoragePath", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=128, verbose_name="name")), + ( + "match", + models.CharField(blank=True, max_length=256, verbose_name="match"), + ), + ( + "matching_algorithm", + models.PositiveIntegerField( + choices=[ + (0, "None"), + (1, "Any word"), + (2, "All words"), + (3, "Exact match"), + (4, "Regular expression"), + (5, "Fuzzy word"), + (6, "Automatic"), + ], + default=1, + verbose_name="matching algorithm", + ), + ), + ( + "is_insensitive", + models.BooleanField(default=True, verbose_name="is insensitive"), + ), + ("path", models.TextField(verbose_name="path")), + ( + "owner", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "verbose_name": "storage path", + "verbose_name_plural": "storage paths", + "ordering": ("name",), + "abstract": False, + }, + ), + migrations.CreateModel( + name="Tag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "tn_ancestors_pks", + models.TextField( + blank=True, + default="", + editable=False, + verbose_name="Ancestors pks", + ), + ), + ( + "tn_ancestors_count", + models.PositiveIntegerField( + default=0, + editable=False, + verbose_name="Ancestors count", + ), + ), + ( + "tn_children_pks", + models.TextField( + blank=True, + default="", + editable=False, + verbose_name="Children pks", + ), + ), + ( + "tn_children_count", + models.PositiveIntegerField( + default=0, + editable=False, + verbose_name="Children count", + ), + ), + ( + "tn_depth", + models.PositiveIntegerField( + default=0, + editable=False, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(10), + ], + verbose_name="Depth", + ), + ), + ( + "tn_descendants_pks", + models.TextField( + blank=True, + default="", + editable=False, + verbose_name="Descendants pks", + ), + ), + ( + "tn_descendants_count", + models.PositiveIntegerField( + default=0, + editable=False, + verbose_name="Descendants count", + ), + ), + ( + "tn_index", + models.PositiveIntegerField( + default=0, + editable=False, + verbose_name="Index", + ), + ), + ( + "tn_level", + models.PositiveIntegerField( + default=1, + editable=False, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(10), + ], + verbose_name="Level", + ), + ), + ( + "tn_priority", + models.PositiveIntegerField( + default=0, + validators=[ + django.core.validators.MinValueValidator(0), + django.core.validators.MaxValueValidator(9999999999), + ], + verbose_name="Priority", + ), + ), + ( + "tn_order", + models.PositiveIntegerField( + default=0, + editable=False, + verbose_name="Order", + ), + ), + ( + "tn_siblings_pks", + models.TextField( + blank=True, + default="", + editable=False, + verbose_name="Siblings pks", + ), + ), + ( + "tn_siblings_count", + models.PositiveIntegerField( + default=0, + editable=False, + verbose_name="Siblings count", + ), + ), + ("name", models.CharField(max_length=128, verbose_name="name")), + ( + "match", + models.CharField(blank=True, max_length=256, verbose_name="match"), + ), + ( + "matching_algorithm", + models.PositiveIntegerField( + choices=[ + (0, "None"), + (1, "Any word"), + (2, "All words"), + (3, "Exact match"), + (4, "Regular expression"), + (5, "Fuzzy word"), + (6, "Automatic"), + ], + default=1, + verbose_name="matching algorithm", + ), + ), + ( + "is_insensitive", + models.BooleanField(default=True, verbose_name="is insensitive"), + ), + ( + "color", + models.CharField( + default="#a6cee3", + max_length=7, + verbose_name="color", + ), + ), + ( + "is_inbox_tag", + models.BooleanField( + default=False, + help_text="Marks this tag as an inbox tag: All newly consumed documents will be tagged with inbox tags.", + verbose_name="is inbox tag", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ( + "tn_parent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="tn_children", + to="documents.tag", + verbose_name="Parent", + ), + ), + ], + options={ + "verbose_name": "tag", + "verbose_name_plural": "tags", + "ordering": ("name",), + "abstract": False, + }, + ), migrations.CreateModel( name="Document", fields=[ @@ -23,18 +576,1397 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("sender", models.CharField(blank=True, db_index=True, max_length=128)), - ("title", models.CharField(blank=True, db_index=True, max_length=128)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("restored_at", models.DateTimeField(blank=True, null=True)), + ("transaction_id", models.UUIDField(blank=True, null=True)), + ( + "title", + models.CharField( + blank=True, + db_index=True, + max_length=128, + verbose_name="title", + ), + ), ( "content", models.TextField( - db_index=( - "mysql" not in settings.DATABASES["default"]["ENGINE"] + blank=True, + help_text="The raw, text-only data of the document. This field is primarily used for searching.", + verbose_name="content", + ), + ), + ( + "mime_type", + models.CharField( + editable=False, + max_length=256, + verbose_name="mime type", + ), + ), + ( + "checksum", + models.CharField( + editable=False, + help_text="The checksum of the original document.", + max_length=32, + unique=True, + verbose_name="checksum", + ), + ), + ( + "archive_checksum", + models.CharField( + blank=True, + editable=False, + help_text="The checksum of the archived document.", + max_length=32, + null=True, + verbose_name="archive checksum", + ), + ), + ( + "page_count", + models.PositiveIntegerField( + help_text="The number of pages of the document.", + null=True, + validators=[django.core.validators.MinValueValidator(1)], + verbose_name="page count", + ), + ), + ( + "created", + models.DateField( + db_index=True, + default=datetime.date.today, + verbose_name="created", + ), + ), + ( + "modified", + models.DateTimeField( + auto_now=True, + db_index=True, + verbose_name="modified", + ), + ), + ( + "storage_type", + models.CharField( + choices=[ + ("unencrypted", "Unencrypted"), + ("gpg", "Encrypted with GNU Privacy Guard"), + ], + default="unencrypted", + editable=False, + max_length=11, + verbose_name="storage type", + ), + ), + ( + "added", + models.DateTimeField( + db_index=True, + default=django.utils.timezone.now, + editable=False, + verbose_name="added", + ), + ), + ( + "filename", + models.FilePathField( + default=None, + editable=False, + help_text="Current filename in storage", + max_length=1024, + null=True, + unique=True, + verbose_name="filename", + ), + ), + ( + "archive_filename", + models.FilePathField( + default=None, + editable=False, + help_text="Current archive filename in storage", + max_length=1024, + null=True, + unique=True, + verbose_name="archive filename", + ), + ), + ( + "original_filename", + models.CharField( + default=None, + editable=False, + help_text="The original name of the file when it was uploaded", + max_length=1024, + null=True, + verbose_name="original filename", + ), + ), + ( + "archive_serial_number", + models.PositiveIntegerField( + blank=True, + db_index=True, + help_text="The position of this document in your physical document archive.", + null=True, + unique=True, + validators=[ + django.core.validators.MaxValueValidator(4294967295), + django.core.validators.MinValueValidator(0), + ], + verbose_name="archive serial number", + ), + ), + ( + "correspondent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="documents", + to="documents.correspondent", + verbose_name="correspondent", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ( + "document_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="documents", + to="documents.documenttype", + verbose_name="document type", + ), + ), + ( + "storage_path", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="documents", + to="documents.storagepath", + verbose_name="storage path", + ), + ), + ( + "tags", + models.ManyToManyField( + blank=True, + related_name="documents", + to="documents.tag", + verbose_name="tags", + ), + ), + ], + options={ + "verbose_name": "document", + "verbose_name_plural": "documents", + "ordering": ("-created",), + }, + ), + migrations.CreateModel( + name="CustomFieldInstance", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("restored_at", models.DateTimeField(blank=True, null=True)), + ("transaction_id", models.UUIDField(blank=True, null=True)), + ( + "created", + models.DateTimeField( + db_index=True, + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ("value_text", models.CharField(max_length=128, null=True)), + ("value_bool", models.BooleanField(null=True)), + ("value_url", models.URLField(null=True)), + ("value_date", models.DateField(null=True)), + ("value_int", models.IntegerField(null=True)), + ("value_float", models.FloatField(null=True)), + ("value_monetary", models.CharField(max_length=128, null=True)), + ( + "value_monetary_amount", + models.GeneratedField( + db_persist=True, + expression=models.Case( + models.When( + then=django.db.models.functions.comparison.Cast( + django.db.models.functions.text.Substr( + "value_monetary", + 1, + ), + output_field=models.DecimalField( + decimal_places=2, + max_digits=65, + ), + ), + value_monetary__regex="^\\d+", + ), + default=django.db.models.functions.comparison.Cast( + django.db.models.functions.text.Substr( + "value_monetary", + 4, + ), + output_field=models.DecimalField( + decimal_places=2, + max_digits=65, + ), + ), + output_field=models.DecimalField( + decimal_places=2, + max_digits=65, + ), + ), + output_field=models.DecimalField( + decimal_places=2, + max_digits=65, ), ), ), - ("created", models.DateTimeField(auto_now_add=True)), - ("modified", models.DateTimeField(auto_now=True)), + ("value_document_ids", models.JSONField(null=True)), + ("value_select", models.CharField(max_length=16, null=True)), + ("value_long_text", models.TextField(null=True)), + ( + "field", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="fields", + to="documents.customfield", + ), + ), + ( + "document", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + related_name="custom_fields", + to="documents.document", + ), + ), + ], + options={ + "verbose_name": "custom field instance", + "verbose_name_plural": "custom field instances", + "ordering": ("created",), + }, + ), + migrations.CreateModel( + name="Note", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("restored_at", models.DateTimeField(blank=True, null=True)), + ("transaction_id", models.UUIDField(blank=True, null=True)), + ( + "note", + models.TextField( + blank=True, + help_text="Note for the document", + verbose_name="content", + ), + ), + ( + "created", + models.DateTimeField( + db_index=True, + default=django.utils.timezone.now, + verbose_name="created", + ), + ), + ( + "document", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="notes", + to="documents.document", + verbose_name="document", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="notes", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ], + options={ + "verbose_name": "note", + "verbose_name_plural": "notes", + "ordering": ("created",), + }, + ), + migrations.CreateModel( + name="PaperlessTask", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "task_id", + models.CharField( + help_text="Celery ID for the Task that was run", + max_length=255, + unique=True, + verbose_name="Task ID", + ), + ), + ( + "acknowledged", + models.BooleanField( + default=False, + help_text="If the task is acknowledged via the frontend or API", + verbose_name="Acknowledged", + ), + ), + ( + "task_file_name", + models.CharField( + help_text="Name of the file which the Task was run for", + max_length=255, + null=True, + verbose_name="Task Filename", + ), + ), + ( + "task_name", + models.CharField( + choices=[ + ("consume_file", "Consume File"), + ("train_classifier", "Train Classifier"), + ("check_sanity", "Check Sanity"), + ("index_optimize", "Index Optimize"), + ("llmindex_update", "LLM Index Update"), + ], + help_text="Name of the task that was run", + max_length=255, + null=True, + verbose_name="Task Name", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("FAILURE", "FAILURE"), + ("PENDING", "PENDING"), + ("RECEIVED", "RECEIVED"), + ("RETRY", "RETRY"), + ("REVOKED", "REVOKED"), + ("STARTED", "STARTED"), + ("SUCCESS", "SUCCESS"), + ], + default="PENDING", + help_text="Current state of the task being run", + max_length=30, + verbose_name="Task State", + ), + ), + ( + "date_created", + models.DateTimeField( + default=django.utils.timezone.now, + help_text="Datetime field when the task result was created in UTC", + null=True, + verbose_name="Created DateTime", + ), + ), + ( + "date_started", + models.DateTimeField( + default=None, + help_text="Datetime field when the task was started in UTC", + null=True, + verbose_name="Started DateTime", + ), + ), + ( + "date_done", + models.DateTimeField( + default=None, + help_text="Datetime field when the task was completed in UTC", + null=True, + verbose_name="Completed DateTime", + ), + ), + ( + "result", + models.TextField( + default=None, + help_text="The data returned by the task", + null=True, + verbose_name="Result Data", + ), + ), + ( + "type", + models.CharField( + choices=[ + ("auto_task", "Auto Task"), + ("scheduled_task", "Scheduled Task"), + ("manual_task", "Manual Task"), + ], + default="auto_task", + help_text="The type of task that was run", + max_length=30, + verbose_name="Task Type", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="SavedView", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=128, verbose_name="name")), + ( + "show_on_dashboard", + models.BooleanField(verbose_name="show on dashboard"), + ), + ( + "show_in_sidebar", + models.BooleanField(verbose_name="show in sidebar"), + ), + ( + "sort_field", + models.CharField( + blank=True, + max_length=128, + null=True, + verbose_name="sort field", + ), + ), + ( + "sort_reverse", + models.BooleanField(default=False, verbose_name="sort reverse"), + ), + ( + "page_size", + models.PositiveIntegerField( + blank=True, + null=True, + validators=[django.core.validators.MinValueValidator(1)], + verbose_name="View page size", + ), + ), + ( + "display_mode", + models.CharField( + blank=True, + choices=[ + ("table", "Table"), + ("smallCards", "Small Cards"), + ("largeCards", "Large Cards"), + ], + max_length=128, + null=True, + verbose_name="View display mode", + ), + ), + ( + "display_fields", + models.JSONField( + blank=True, + null=True, + verbose_name="Document display fields", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "verbose_name": "saved view", + "verbose_name_plural": "saved views", + "ordering": ("name",), + }, + ), + migrations.CreateModel( + name="SavedViewFilterRule", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "rule_type", + models.PositiveIntegerField( + choices=[ + (0, "title contains"), + (1, "content contains"), + (2, "ASN is"), + (3, "correspondent is"), + (4, "document type is"), + (5, "is in inbox"), + (6, "has tag"), + (7, "has any tag"), + (8, "created before"), + (9, "created after"), + (10, "created year is"), + (11, "created month is"), + (12, "created day is"), + (13, "added before"), + (14, "added after"), + (15, "modified before"), + (16, "modified after"), + (17, "does not have tag"), + (18, "does not have ASN"), + (19, "title or content contains"), + (20, "fulltext query"), + (21, "more like this"), + (22, "has tags in"), + (23, "ASN greater than"), + (24, "ASN less than"), + (25, "storage path is"), + (26, "has correspondent in"), + (27, "does not have correspondent in"), + (28, "has document type in"), + (29, "does not have document type in"), + (30, "has storage path in"), + (31, "does not have storage path in"), + (32, "owner is"), + (33, "has owner in"), + (34, "does not have owner"), + (35, "does not have owner in"), + (36, "has custom field value"), + (37, "is shared by me"), + (38, "has custom fields"), + (39, "has custom field in"), + (40, "does not have custom field in"), + (41, "does not have custom field"), + (42, "custom fields query"), + (43, "created to"), + (44, "created from"), + (45, "added to"), + (46, "added from"), + (47, "mime type is"), + ], + verbose_name="rule type", + ), + ), + ( + "value", + models.CharField( + blank=True, + max_length=255, + null=True, + verbose_name="value", + ), + ), + ( + "saved_view", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="filter_rules", + to="documents.savedview", + verbose_name="saved view", + ), + ), + ], + options={ + "verbose_name": "filter rule", + "verbose_name_plural": "filter rules", + }, + ), + migrations.CreateModel( + name="ShareLink", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("restored_at", models.DateTimeField(blank=True, null=True)), + ("transaction_id", models.UUIDField(blank=True, null=True)), + ( + "created", + models.DateTimeField( + blank=True, + db_index=True, + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "expiration", + models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="expiration", + ), + ), + ( + "slug", + models.SlugField( + blank=True, + editable=False, + unique=True, + verbose_name="slug", + ), + ), + ( + "file_version", + models.CharField( + choices=[("archive", "Archive"), ("original", "Original")], + default="archive", + max_length=50, + ), + ), + ( + "document", + models.ForeignKey( + blank=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="share_links", + to="documents.document", + verbose_name="document", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="share_links", + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "verbose_name": "share link", + "verbose_name_plural": "share links", + "ordering": ("created",), + }, + ), + migrations.CreateModel( + name="UiSettings", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("settings", models.JSONField(null=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="ui_settings", + to=settings.AUTH_USER_MODEL, + ), + ), ], ), + migrations.CreateModel( + name="WorkflowAction", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.PositiveIntegerField( + choices=[ + (1, "Assignment"), + (2, "Removal"), + (3, "Email"), + (4, "Webhook"), + ], + default=1, + verbose_name="Workflow Action Type", + ), + ), + ( + "assign_title", + models.TextField( + blank=True, + help_text="Assign a document title, must be a Jinja2 template, see documentation.", + null=True, + verbose_name="assign title", + ), + ), + ( + "assign_custom_fields_values", + models.JSONField( + blank=True, + default=dict, + help_text="Optional values to assign to the custom fields.", + null=True, + verbose_name="custom field values", + ), + ), + ( + "remove_all_tags", + models.BooleanField(default=False, verbose_name="remove all tags"), + ), + ( + "remove_all_document_types", + models.BooleanField( + default=False, + verbose_name="remove all document types", + ), + ), + ( + "remove_all_correspondents", + models.BooleanField( + default=False, + verbose_name="remove all correspondents", + ), + ), + ( + "remove_all_storage_paths", + models.BooleanField( + default=False, + verbose_name="remove all storage paths", + ), + ), + ( + "remove_all_owners", + models.BooleanField( + default=False, + verbose_name="remove all owners", + ), + ), + ( + "remove_all_permissions", + models.BooleanField( + default=False, + verbose_name="remove all permissions", + ), + ), + ( + "remove_all_custom_fields", + models.BooleanField( + default=False, + verbose_name="remove all custom fields", + ), + ), + ( + "assign_change_groups", + models.ManyToManyField( + blank=True, + related_name="+", + to="auth.group", + verbose_name="grant change permissions to these groups", + ), + ), + ( + "assign_change_users", + models.ManyToManyField( + blank=True, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="grant change permissions to these users", + ), + ), + ( + "assign_correspondent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="documents.correspondent", + verbose_name="assign this correspondent", + ), + ), + ( + "assign_custom_fields", + models.ManyToManyField( + blank=True, + related_name="+", + to="documents.customfield", + verbose_name="assign these custom fields", + ), + ), + ( + "assign_document_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="documents.documenttype", + verbose_name="assign this document type", + ), + ), + ( + "assign_owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="assign this owner", + ), + ), + ( + "assign_storage_path", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="documents.storagepath", + verbose_name="assign this storage path", + ), + ), + ( + "assign_tags", + models.ManyToManyField( + blank=True, + related_name="+", + to="documents.tag", + verbose_name="assign this tag", + ), + ), + ( + "assign_view_groups", + models.ManyToManyField( + blank=True, + related_name="+", + to="auth.group", + verbose_name="grant view permissions to these groups", + ), + ), + ( + "assign_view_users", + models.ManyToManyField( + blank=True, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="grant view permissions to these users", + ), + ), + ( + "remove_change_groups", + models.ManyToManyField( + blank=True, + related_name="+", + to="auth.group", + verbose_name="remove change permissions for these groups", + ), + ), + ( + "remove_change_users", + models.ManyToManyField( + blank=True, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="remove change permissions for these users", + ), + ), + ( + "remove_correspondents", + models.ManyToManyField( + blank=True, + related_name="+", + to="documents.correspondent", + verbose_name="remove these correspondent(s)", + ), + ), + ( + "remove_custom_fields", + models.ManyToManyField( + blank=True, + related_name="+", + to="documents.customfield", + verbose_name="remove these custom fields", + ), + ), + ( + "remove_document_types", + models.ManyToManyField( + blank=True, + related_name="+", + to="documents.documenttype", + verbose_name="remove these document type(s)", + ), + ), + ( + "remove_owners", + models.ManyToManyField( + blank=True, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="remove these owner(s)", + ), + ), + ( + "remove_storage_paths", + models.ManyToManyField( + blank=True, + related_name="+", + to="documents.storagepath", + verbose_name="remove these storage path(s)", + ), + ), + ( + "remove_tags", + models.ManyToManyField( + blank=True, + related_name="+", + to="documents.tag", + verbose_name="remove these tag(s)", + ), + ), + ( + "remove_view_groups", + models.ManyToManyField( + blank=True, + related_name="+", + to="auth.group", + verbose_name="remove view permissions for these groups", + ), + ), + ( + "remove_view_users", + models.ManyToManyField( + blank=True, + related_name="+", + to=settings.AUTH_USER_MODEL, + verbose_name="remove view permissions for these users", + ), + ), + ( + "email", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="action", + to="documents.workflowactionemail", + verbose_name="email", + ), + ), + ( + "webhook", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="action", + to="documents.workflowactionwebhook", + verbose_name="webhook", + ), + ), + ], + options={ + "verbose_name": "workflow action", + "verbose_name_plural": "workflow actions", + }, + ), + migrations.CreateModel( + name="Workflow", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField(max_length=256, unique=True, verbose_name="name"), + ), + ("order", models.IntegerField(default=0, verbose_name="order")), + ("enabled", models.BooleanField(default=True, verbose_name="enabled")), + ( + "actions", + models.ManyToManyField( + related_name="workflows", + to="documents.workflowaction", + verbose_name="actions", + ), + ), + ], + ), + migrations.CreateModel( + name="WorkflowRun", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("restored_at", models.DateTimeField(blank=True, null=True)), + ("transaction_id", models.UUIDField(blank=True, null=True)), + ( + "type", + models.PositiveIntegerField( + choices=[ + (1, "Consumption Started"), + (2, "Document Added"), + (3, "Document Updated"), + (4, "Scheduled"), + ], + null=True, + verbose_name="workflow trigger type", + ), + ), + ( + "run_at", + models.DateTimeField( + db_index=True, + default=django.utils.timezone.now, + verbose_name="date run", + ), + ), + ( + "document", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="workflow_runs", + to="documents.document", + verbose_name="document", + ), + ), + ( + "workflow", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="runs", + to="documents.workflow", + verbose_name="workflow", + ), + ), + ], + options={ + "verbose_name": "workflow run", + "verbose_name_plural": "workflow runs", + }, + ), + migrations.CreateModel( + name="WorkflowTrigger", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.PositiveIntegerField( + choices=[ + (1, "Consumption Started"), + (2, "Document Added"), + (3, "Document Updated"), + (4, "Scheduled"), + ], + default=1, + verbose_name="Workflow Trigger Type", + ), + ), + ( + "sources", + multiselectfield.db.fields.MultiSelectField( + choices=[ + (1, "Consume Folder"), + (2, "Api Upload"), + (3, "Mail Fetch"), + (4, "Web UI"), + ], + default="1,2,3,4", + max_length=7, + ), + ), + ( + "filter_path", + models.CharField( + blank=True, + help_text="Only consume documents with a path that matches this if specified. Wildcards specified as * are allowed. Case insensitive.", + max_length=256, + null=True, + verbose_name="filter path", + ), + ), + ( + "filter_filename", + models.CharField( + blank=True, + help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", + max_length=256, + null=True, + verbose_name="filter filename", + ), + ), + ( + "match", + models.CharField(blank=True, max_length=256, verbose_name="match"), + ), + ( + "matching_algorithm", + models.PositiveIntegerField( + choices=[ + (0, "None"), + (1, "Any word"), + (2, "All words"), + (3, "Exact match"), + (4, "Regular expression"), + (5, "Fuzzy word"), + ], + default=0, + verbose_name="matching algorithm", + ), + ), + ( + "is_insensitive", + models.BooleanField(default=True, verbose_name="is insensitive"), + ), + ( + "filter_custom_field_query", + models.TextField( + blank=True, + help_text="JSON-encoded custom field query expression.", + null=True, + verbose_name="filter custom field query", + ), + ), + ( + "schedule_offset_days", + models.IntegerField( + default=0, + help_text="The number of days to offset the schedule trigger by.", + verbose_name="schedule offset days", + ), + ), + ( + "schedule_is_recurring", + models.BooleanField( + default=False, + help_text="If the schedule should be recurring.", + verbose_name="schedule is recurring", + ), + ), + ( + "schedule_recurring_interval_days", + models.PositiveIntegerField( + default=1, + help_text="The number of days between recurring schedule triggers.", + validators=[django.core.validators.MinValueValidator(1)], + verbose_name="schedule recurring delay in days", + ), + ), + ( + "schedule_date_field", + models.CharField( + choices=[ + ("added", "Added"), + ("created", "Created"), + ("modified", "Modified"), + ("custom_field", "Custom Field"), + ], + default="added", + help_text="The field to check for a schedule trigger.", + max_length=20, + verbose_name="schedule date field", + ), + ), + ( + "filter_has_all_tags", + models.ManyToManyField( + blank=True, + related_name="workflowtriggers_has_all", + to="documents.tag", + verbose_name="has all of these tag(s)", + ), + ), + ( + "filter_has_correspondent", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="documents.correspondent", + verbose_name="has this correspondent", + ), + ), + ( + "filter_has_document_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="documents.documenttype", + verbose_name="has this document type", + ), + ), + ( + "filter_has_not_correspondents", + models.ManyToManyField( + blank=True, + related_name="workflowtriggers_has_not_correspondent", + to="documents.correspondent", + verbose_name="does not have these correspondent(s)", + ), + ), + ( + "filter_has_not_document_types", + models.ManyToManyField( + blank=True, + related_name="workflowtriggers_has_not_document_type", + to="documents.documenttype", + verbose_name="does not have these document type(s)", + ), + ), + ( + "filter_has_not_storage_paths", + models.ManyToManyField( + blank=True, + related_name="workflowtriggers_has_not_storage_path", + to="documents.storagepath", + verbose_name="does not have these storage path(s)", + ), + ), + ( + "filter_has_not_tags", + models.ManyToManyField( + blank=True, + related_name="workflowtriggers_has_not", + to="documents.tag", + verbose_name="does not have these tag(s)", + ), + ), + ( + "filter_has_storage_path", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="documents.storagepath", + verbose_name="has this storage path", + ), + ), + ( + "filter_has_tags", + models.ManyToManyField( + blank=True, + to="documents.tag", + verbose_name="has these tag(s)", + ), + ), + ], + options={ + "verbose_name": "workflow trigger", + "verbose_name_plural": "workflow triggers", + }, + ), ] diff --git a/src/documents/migrations/0002_auto_20151226_1316.py b/src/documents/migrations/0002_auto_20151226_1316.py deleted file mode 100644 index ffd240902..000000000 --- a/src/documents/migrations/0002_auto_20151226_1316.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 1.9 on 2015-12-26 13:16 - -import django.utils.timezone -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0001_initial"), - ] - - operations = [ - migrations.AlterModelOptions( - name="document", - options={"ordering": ("sender", "title")}, - ), - migrations.AlterField( - model_name="document", - name="created", - field=models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - ), - ), - ] diff --git a/src/documents/migrations/1033_alter_documenttype_options_alter_tag_options_and_more.py b/src/documents/migrations/0002_initial.py similarity index 61% rename from src/documents/migrations/1033_alter_documenttype_options_alter_tag_options_and_more.py rename to src/documents/migrations/0002_initial.py index 1368ac641..ba0e93da8 100644 --- a/src/documents/migrations/1033_alter_documenttype_options_alter_tag_options_and_more.py +++ b/src/documents/migrations/0002_initial.py @@ -1,50 +1,49 @@ -# Generated by Django 4.1.5 on 2023-03-04 22:33 +# Generated by Django 5.2.9 on 2026-01-20 18:46 +import django.db.models.deletion from django.db import migrations from django.db import models class Migration(migrations.Migration): + initial = True + dependencies = [ - ("documents", "1032_alter_correspondent_matching_algorithm_and_more"), + ("documents", "0001_initial"), + ("paperless_mail", "0001_initial"), ] operations = [ - migrations.AlterModelOptions( - name="documenttype", - options={ - "ordering": ("name",), - "verbose_name": "document type", - "verbose_name_plural": "document types", - }, + migrations.AddField( + model_name="workflowtrigger", + name="filter_mailrule", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="paperless_mail.mailrule", + verbose_name="filter documents from this mail rule", + ), ), - migrations.AlterModelOptions( - name="tag", - options={ - "ordering": ("name",), - "verbose_name": "tag", - "verbose_name_plural": "tags", - }, + migrations.AddField( + model_name="workflowtrigger", + name="schedule_date_custom_field", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="documents.customfield", + verbose_name="schedule date custom field", + ), ), - migrations.AlterField( - model_name="correspondent", - name="name", - field=models.CharField(max_length=128, verbose_name="name"), - ), - migrations.AlterField( - model_name="documenttype", - name="name", - field=models.CharField(max_length=128, verbose_name="name"), - ), - migrations.AlterField( - model_name="storagepath", - name="name", - field=models.CharField(max_length=128, verbose_name="name"), - ), - migrations.AlterField( - model_name="tag", - name="name", - field=models.CharField(max_length=128, verbose_name="name"), + migrations.AddField( + model_name="workflow", + name="triggers", + field=models.ManyToManyField( + related_name="workflows", + to="documents.workflowtrigger", + verbose_name="triggers", + ), ), migrations.AddConstraint( model_name="correspondent", @@ -61,6 +60,13 @@ class Migration(migrations.Migration): name="documents_correspondent_name_uniq", ), ), + migrations.AddConstraint( + model_name="customfieldinstance", + constraint=models.UniqueConstraint( + fields=("document", "field"), + name="documents_customfieldinstance_unique_document_field", + ), + ), migrations.AddConstraint( model_name="documenttype", constraint=models.UniqueConstraint( diff --git a/src/documents/migrations/0003_sender.py b/src/documents/migrations/0003_sender.py deleted file mode 100644 index dd194afdb..000000000 --- a/src/documents/migrations/0003_sender.py +++ /dev/null @@ -1,70 +0,0 @@ -# Generated by Django 1.9 on 2016-01-11 12:21 - -import django.db.models.deletion -from django.db import migrations -from django.db import models -from django.template.defaultfilters import slugify - -DOCUMENT_SENDER_MAP = {} - - -def move_sender_strings_to_sender_model(apps, schema_editor): - sender_model = apps.get_model("documents", "Sender") - document_model = apps.get_model("documents", "Document") - - # Create the sender and log the relationship with the document - for document in document_model.objects.all(): - if document.sender: - ( - DOCUMENT_SENDER_MAP[document.pk], - _, - ) = sender_model.objects.get_or_create( - name=document.sender, - defaults={"slug": slugify(document.sender)}, - ) - - -def realign_senders(apps, schema_editor): - document_model = apps.get_model("documents", "Document") - for pk, sender in DOCUMENT_SENDER_MAP.items(): - document_model.objects.filter(pk=pk).update(sender=sender) - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0002_auto_20151226_1316"), - ] - - operations = [ - migrations.CreateModel( - name="Sender", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=128, unique=True)), - ("slug", models.SlugField()), - ], - ), - migrations.RunPython(move_sender_strings_to_sender_model), - migrations.RemoveField( - model_name="document", - name="sender", - ), - migrations.AddField( - model_name="document", - name="sender", - field=models.ForeignKey( - blank=True, - on_delete=django.db.models.deletion.CASCADE, - to="documents.Sender", - ), - ), - migrations.RunPython(realign_senders), - ] diff --git a/src/paperless_mail/migrations/0004_mailrule_order.py b/src/documents/migrations/0003_workflowaction_order.py similarity index 51% rename from src/paperless_mail/migrations/0004_mailrule_order.py rename to src/documents/migrations/0003_workflowaction_order.py index 4ffc0a6e5..82bc49ba7 100644 --- a/src/paperless_mail/migrations/0004_mailrule_order.py +++ b/src/documents/migrations/0003_workflowaction_order.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1.3 on 2020-11-21 21:51 +# Generated by Django 5.2.9 on 2026-01-20 20:06 from django.db import migrations from django.db import models @@ -6,13 +6,13 @@ from django.db import models class Migration(migrations.Migration): dependencies = [ - ("paperless_mail", "0003_auto_20201118_1940"), + ("documents", "0002_initial"), ] operations = [ migrations.AddField( - model_name="mailrule", + model_name="workflowaction", name="order", - field=models.IntegerField(default=0), + field=models.PositiveIntegerField(default=0, verbose_name="order"), ), ] diff --git a/src/documents/migrations/0004_auto_20160114_1844.py b/src/documents/migrations/0004_auto_20160114_1844.py deleted file mode 100644 index 97bda420e..000000000 --- a/src/documents/migrations/0004_auto_20160114_1844.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 1.9 on 2016-01-14 18:44 - -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0003_sender"), - ] - - operations = [ - migrations.AlterField( - model_name="document", - name="sender", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="documents", - to="documents.Sender", - ), - ), - ] diff --git a/src/documents/migrations/0004_auto_20160114_1844_squashed_0011_auto_20160303_1929.py b/src/documents/migrations/0004_auto_20160114_1844_squashed_0011_auto_20160303_1929.py deleted file mode 100644 index 8d86cbbc1..000000000 --- a/src/documents/migrations/0004_auto_20160114_1844_squashed_0011_auto_20160303_1929.py +++ /dev/null @@ -1,178 +0,0 @@ -# Generated by Django 4.2.13 on 2024-06-28 17:52 - -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - replaces = [ - ("documents", "0004_auto_20160114_1844"), - ("documents", "0005_auto_20160123_0313"), - ("documents", "0006_auto_20160123_0430"), - ("documents", "0007_auto_20160126_2114"), - ("documents", "0008_document_file_type"), - ("documents", "0009_auto_20160214_0040"), - ("documents", "0010_log"), - ("documents", "0011_auto_20160303_1929"), - ] - - dependencies = [ - ("documents", "0003_sender"), - ] - - operations = [ - migrations.AlterField( - model_name="document", - name="sender", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="documents", - to="documents.sender", - ), - ), - migrations.AlterModelOptions( - name="sender", - options={"ordering": ("name",)}, - ), - migrations.CreateModel( - name="Tag", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=128, unique=True)), - ("slug", models.SlugField(blank=True)), - ( - "colour", - models.PositiveIntegerField( - choices=[ - (1, "#a6cee3"), - (2, "#1f78b4"), - (3, "#b2df8a"), - (4, "#33a02c"), - (5, "#fb9a99"), - (6, "#e31a1c"), - (7, "#fdbf6f"), - (8, "#ff7f00"), - (9, "#cab2d6"), - (10, "#6a3d9a"), - (11, "#b15928"), - (12, "#000000"), - (13, "#cccccc"), - ], - default=1, - ), - ), - ("match", models.CharField(blank=True, max_length=256)), - ( - "matching_algorithm", - models.PositiveIntegerField( - choices=[ - (1, "Any"), - (2, "All"), - (3, "Literal"), - (4, "Regular Expression"), - ], - default=1, - help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. If you don\'t know what a regex is, you probably don\'t want this option.', - ), - ), - ], - options={ - "abstract": False, - }, - ), - migrations.AlterField( - model_name="sender", - name="slug", - field=models.SlugField(blank=True), - ), - migrations.AddField( - model_name="document", - name="file_type", - field=models.CharField( - choices=[ - ("pdf", "PDF"), - ("png", "PNG"), - ("jpg", "JPG"), - ("gif", "GIF"), - ("tiff", "TIFF"), - ], - default="pdf", - editable=False, - max_length=4, - ), - preserve_default=False, - ), - migrations.AddField( - model_name="document", - name="tags", - field=models.ManyToManyField( - blank=True, - related_name="documents", - to="documents.tag", - ), - ), - migrations.CreateModel( - name="Log", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("group", models.UUIDField(blank=True)), - ("message", models.TextField()), - ( - "level", - models.PositiveIntegerField( - choices=[ - (10, "Debugging"), - (20, "Informational"), - (30, "Warning"), - (40, "Error"), - (50, "Critical"), - ], - default=20, - ), - ), - ( - "component", - models.PositiveIntegerField( - choices=[(1, "Consumer"), (2, "Mail Fetcher")], - ), - ), - ("created", models.DateTimeField(auto_now_add=True)), - ("modified", models.DateTimeField(auto_now=True)), - ], - options={ - "ordering": ("-modified",), - }, - ), - migrations.RenameModel( - old_name="Sender", - new_name="Correspondent", - ), - migrations.AlterModelOptions( - name="document", - options={"ordering": ("correspondent", "title")}, - ), - migrations.RenameField( - model_name="document", - old_name="sender", - new_name="correspondent", - ), - ] diff --git a/src/paperless_mail/migrations/0011_remove_mailrule_assign_tag.py b/src/documents/migrations/0004_remove_document_storage_type.py similarity index 50% rename from src/paperless_mail/migrations/0011_remove_mailrule_assign_tag.py rename to src/documents/migrations/0004_remove_document_storage_type.py index 16cec8710..e138d5d78 100644 --- a/src/paperless_mail/migrations/0011_remove_mailrule_assign_tag.py +++ b/src/documents/migrations/0004_remove_document_storage_type.py @@ -1,16 +1,16 @@ -# Generated by Django 3.2.12 on 2022-03-11 15:18 +# Generated by Django 5.2.9 on 2026-01-24 23:05 from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ("paperless_mail", "0010_auto_20220311_1602"), + ("documents", "0003_workflowaction_order"), ] operations = [ migrations.RemoveField( - model_name="mailrule", - name="assign_tag", + model_name="document", + name="storage_type", ), ] diff --git a/src/documents/migrations/0005_auto_20160123_0313.py b/src/documents/migrations/0005_auto_20160123_0313.py deleted file mode 100644 index b0ccc5825..000000000 --- a/src/documents/migrations/0005_auto_20160123_0313.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 1.9 on 2016-01-23 03:13 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0004_auto_20160114_1844"), - ] - - operations = [ - migrations.AlterModelOptions( - name="sender", - options={"ordering": ("name",)}, - ), - ] diff --git a/src/documents/migrations/0006_auto_20160123_0430.py b/src/documents/migrations/0006_auto_20160123_0430.py deleted file mode 100644 index 315b9646f..000000000 --- a/src/documents/migrations/0006_auto_20160123_0430.py +++ /dev/null @@ -1,64 +0,0 @@ -# Generated by Django 1.9 on 2016-01-23 04:30 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0005_auto_20160123_0313"), - ] - - operations = [ - migrations.CreateModel( - name="Tag", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=128, unique=True)), - ("slug", models.SlugField(blank=True)), - ( - "colour", - models.PositiveIntegerField( - choices=[ - (1, "#a6cee3"), - (2, "#1f78b4"), - (3, "#b2df8a"), - (4, "#33a02c"), - (5, "#fb9a99"), - (6, "#e31a1c"), - (7, "#fdbf6f"), - (8, "#ff7f00"), - (9, "#cab2d6"), - (10, "#6a3d9a"), - (11, "#ffff99"), - (12, "#b15928"), - (13, "#000000"), - (14, "#cccccc"), - ], - default=1, - ), - ), - ], - options={ - "abstract": False, - }, - ), - migrations.AlterField( - model_name="sender", - name="slug", - field=models.SlugField(blank=True), - ), - migrations.AddField( - model_name="document", - name="tags", - field=models.ManyToManyField(related_name="documents", to="documents.Tag"), - ), - ] diff --git a/src/documents/migrations/0007_auto_20160126_2114.py b/src/documents/migrations/0007_auto_20160126_2114.py deleted file mode 100644 index 04ccc0589..000000000 --- a/src/documents/migrations/0007_auto_20160126_2114.py +++ /dev/null @@ -1,55 +0,0 @@ -# Generated by Django 1.9 on 2016-01-26 21:14 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0006_auto_20160123_0430"), - ] - - operations = [ - migrations.AddField( - model_name="tag", - name="match", - field=models.CharField(blank=True, max_length=256), - ), - migrations.AddField( - model_name="tag", - name="matching_algorithm", - field=models.PositiveIntegerField( - blank=True, - choices=[ - (1, "Any"), - (2, "All"), - (3, "Literal"), - (4, "Regular Expression"), - ], - help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. If you don\'t know what a regex is, you probably don\'t want this option.', - null=True, - ), - ), - migrations.AlterField( - model_name="tag", - name="colour", - field=models.PositiveIntegerField( - choices=[ - (1, "#a6cee3"), - (2, "#1f78b4"), - (3, "#b2df8a"), - (4, "#33a02c"), - (5, "#fb9a99"), - (6, "#e31a1c"), - (7, "#fdbf6f"), - (8, "#ff7f00"), - (9, "#cab2d6"), - (10, "#6a3d9a"), - (11, "#b15928"), - (12, "#000000"), - (13, "#cccccc"), - ], - default=1, - ), - ), - ] diff --git a/src/documents/migrations/0008_document_file_type.py b/src/documents/migrations/0008_document_file_type.py deleted file mode 100644 index 7787f8622..000000000 --- a/src/documents/migrations/0008_document_file_type.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 1.9 on 2016-01-29 22:58 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0007_auto_20160126_2114"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="file_type", - field=models.CharField( - choices=[ - ("pdf", "PDF"), - ("png", "PNG"), - ("jpg", "JPG"), - ("gif", "GIF"), - ("tiff", "TIFF"), - ], - default="pdf", - editable=False, - max_length=4, - ), - preserve_default=False, - ), - migrations.AlterField( - model_name="document", - name="tags", - field=models.ManyToManyField( - blank=True, - related_name="documents", - to="documents.Tag", - ), - ), - ] diff --git a/src/documents/migrations/0009_auto_20160214_0040.py b/src/documents/migrations/0009_auto_20160214_0040.py deleted file mode 100644 index 5c95e1035..000000000 --- a/src/documents/migrations/0009_auto_20160214_0040.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 1.9 on 2016-02-14 00:40 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0008_document_file_type"), - ] - - operations = [ - migrations.AlterField( - model_name="tag", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any"), - (2, "All"), - (3, "Literal"), - (4, "Regular Expression"), - ], - default=1, - help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. If you don\'t know what a regex is, you probably don\'t want this option.', - ), - ), - ] diff --git a/src/documents/migrations/0010_log.py b/src/documents/migrations/0010_log.py deleted file mode 100644 index 8f015cab0..000000000 --- a/src/documents/migrations/0010_log.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated by Django 1.9 on 2016-02-27 17:54 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0009_auto_20160214_0040"), - ] - - operations = [ - migrations.CreateModel( - name="Log", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("group", models.UUIDField(blank=True)), - ("message", models.TextField()), - ( - "level", - models.PositiveIntegerField( - choices=[ - (10, "Debugging"), - (20, "Informational"), - (30, "Warning"), - (40, "Error"), - (50, "Critical"), - ], - default=20, - ), - ), - ( - "component", - models.PositiveIntegerField( - choices=[(1, "Consumer"), (2, "Mail Fetcher")], - ), - ), - ("created", models.DateTimeField(auto_now_add=True)), - ("modified", models.DateTimeField(auto_now=True)), - ], - options={ - "ordering": ("-modified",), - }, - ), - ] diff --git a/src/documents/migrations/0011_auto_20160303_1929.py b/src/documents/migrations/0011_auto_20160303_1929.py deleted file mode 100644 index 4c4fcd3ad..000000000 --- a/src/documents/migrations/0011_auto_20160303_1929.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 1.9.2 on 2016-03-03 19:29 - -from django.db import migrations - - -class Migration(migrations.Migration): - atomic = False - dependencies = [ - ("documents", "0010_log"), - ] - - operations = [ - migrations.RenameModel( - old_name="Sender", - new_name="Correspondent", - ), - migrations.AlterModelOptions( - name="document", - options={"ordering": ("correspondent", "title")}, - ), - migrations.RenameField( - model_name="document", - old_name="sender", - new_name="correspondent", - ), - ] diff --git a/src/documents/migrations/0012_auto_20160305_0040.py b/src/documents/migrations/0012_auto_20160305_0040.py deleted file mode 100644 index 097661137..000000000 --- a/src/documents/migrations/0012_auto_20160305_0040.py +++ /dev/null @@ -1,128 +0,0 @@ -# Generated by Django 1.9.2 on 2016-03-05 00:40 - -import os -import re -import shutil -import subprocess -import tempfile -from pathlib import Path - -import gnupg -from django.conf import settings -from django.db import migrations -from django.utils.termcolors import colorize as colourise # Spelling hurts me - - -class GnuPG: - """ - A handy singleton to use when handling encrypted files. - """ - - gpg = gnupg.GPG(gnupghome=settings.GNUPG_HOME) - - @classmethod - def decrypted(cls, file_handle): - return cls.gpg.decrypt_file(file_handle, passphrase=settings.PASSPHRASE).data - - @classmethod - def encrypted(cls, file_handle): - return cls.gpg.encrypt_file( - file_handle, - recipients=None, - passphrase=settings.PASSPHRASE, - symmetric=True, - ).data - - -def move_documents_and_create_thumbnails(apps, schema_editor): - (Path(settings.MEDIA_ROOT) / "documents" / "originals").mkdir( - parents=True, - exist_ok=True, - ) - (Path(settings.MEDIA_ROOT) / "documents" / "thumbnails").mkdir( - parents=True, - exist_ok=True, - ) - - documents: list[str] = os.listdir(Path(settings.MEDIA_ROOT) / "documents") # noqa: PTH208 - - if set(documents) == {"originals", "thumbnails"}: - return - - print( - colourise( - "\n\n" - " This is a one-time only migration to generate thumbnails for all of your\n" - " documents so that future UIs will have something to work with. If you have\n" - " a lot of documents though, this may take a while, so a coffee break may be\n" - " in order." - "\n", - opts=("bold",), - ), - ) - - Path(settings.SCRATCH_DIR).mkdir(parents=True, exist_ok=True) - - for f in sorted(documents): - if not f.endswith("gpg"): - continue - - print( - " {} {} {}".format( - colourise("*", fg="green"), - colourise("Generating a thumbnail for", fg="white"), - colourise(f, fg="cyan"), - ), - ) - - thumb_temp: str = tempfile.mkdtemp(prefix="paperless", dir=settings.SCRATCH_DIR) - orig_temp: str = tempfile.mkdtemp(prefix="paperless", dir=settings.SCRATCH_DIR) - - orig_source: Path = Path(settings.MEDIA_ROOT) / "documents" / f - orig_target: Path = Path(orig_temp) / f.replace(".gpg", "") - - with orig_source.open("rb") as encrypted, orig_target.open("wb") as unencrypted: - unencrypted.write(GnuPG.decrypted(encrypted)) - - subprocess.Popen( - ( - settings.CONVERT_BINARY, - "-scale", - "500x5000", - "-alpha", - "remove", - orig_target, - Path(thumb_temp) / "convert-%04d.png", - ), - ).wait() - - thumb_source: Path = Path(thumb_temp) / "convert-0000.png" - thumb_target: Path = ( - Path(settings.MEDIA_ROOT) - / "documents" - / "thumbnails" - / re.sub(r"(\d+)\.\w+(\.gpg)", "\\1.png\\2", f) - ) - with ( - thumb_source.open("rb") as unencrypted, - thumb_target.open("wb") as encrypted, - ): - encrypted.write(GnuPG.encrypted(unencrypted)) - - shutil.rmtree(thumb_temp) - shutil.rmtree(orig_temp) - - shutil.move( - Path(settings.MEDIA_ROOT) / "documents" / f, - Path(settings.MEDIA_ROOT) / "documents" / "originals" / f, - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0011_auto_20160303_1929"), - ] - - operations = [ - migrations.RunPython(move_documents_and_create_thumbnails), - ] diff --git a/src/documents/migrations/0013_auto_20160325_2111.py b/src/documents/migrations/0013_auto_20160325_2111.py deleted file mode 100644 index 1d3f8b07d..000000000 --- a/src/documents/migrations/0013_auto_20160325_2111.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 1.9.4 on 2016-03-25 21:11 - -import django.utils.timezone -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0012_auto_20160305_0040"), - ] - - operations = [ - migrations.AddField( - model_name="correspondent", - name="match", - field=models.CharField(blank=True, max_length=256), - ), - migrations.AddField( - model_name="correspondent", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any"), - (2, "All"), - (3, "Literal"), - (4, "Regular Expression"), - ], - default=1, - help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. If you don\'t know what a regex is, you probably don\'t want this option.', - ), - ), - migrations.AlterField( - model_name="document", - name="created", - field=models.DateTimeField(default=django.utils.timezone.now), - ), - migrations.RemoveField( - model_name="log", - name="component", - ), - ] diff --git a/src/documents/migrations/0014_document_checksum.py b/src/documents/migrations/0014_document_checksum.py deleted file mode 100644 index de256ced7..000000000 --- a/src/documents/migrations/0014_document_checksum.py +++ /dev/null @@ -1,182 +0,0 @@ -# Generated by Django 1.9.4 on 2016-03-28 19:09 - -import hashlib -from pathlib import Path - -import django.utils.timezone -import gnupg -from django.conf import settings -from django.db import migrations -from django.db import models -from django.template.defaultfilters import slugify -from django.utils.termcolors import colorize as colourise # Spelling hurts me - - -class GnuPG: - """ - A handy singleton to use when handling encrypted files. - """ - - gpg = gnupg.GPG(gnupghome=settings.GNUPG_HOME) - - @classmethod - def decrypted(cls, file_handle): - return cls.gpg.decrypt_file(file_handle, passphrase=settings.PASSPHRASE).data - - @classmethod - def encrypted(cls, file_handle): - return cls.gpg.encrypt_file( - file_handle, - recipients=None, - passphrase=settings.PASSPHRASE, - symmetric=True, - ).data - - -class Document: - """ - Django's migrations restrict access to model methods, so this is a snapshot - of the methods that existed at the time this migration was written, since - we need to make use of a lot of these shortcuts here. - """ - - def __init__(self, doc): - self.pk = doc.pk - self.correspondent = doc.correspondent - self.title = doc.title - self.file_type = doc.file_type - self.tags = doc.tags - self.created = doc.created - - def __str__(self): - created = self.created.strftime("%Y%m%d%H%M%S") - if self.correspondent and self.title: - return f"{created}: {self.correspondent} - {self.title}" - if self.correspondent or self.title: - return f"{created}: {self.correspondent or self.title}" - return str(created) - - @property - def source_path(self): - return ( - Path(settings.MEDIA_ROOT) - / "documents" - / "originals" - / f"{self.pk:07}.{self.file_type}.gpg" - ) - - @property - def source_file(self): - return self.source_path.open("rb") - - @property - def file_name(self): - return slugify(str(self)) + "." + self.file_type - - -def set_checksums(apps, schema_editor): - document_model = apps.get_model("documents", "Document") - - if not document_model.objects.all().exists(): - return - - print( - colourise( - "\n\n" - " This is a one-time only migration to generate checksums for all\n" - " of your existing documents. If you have a lot of documents\n" - " though, this may take a while, so a coffee break may be in\n" - " order." - "\n", - opts=("bold",), - ), - ) - - sums = {} - for d in document_model.objects.all(): - document = Document(d) - - print( - " {} {} {}".format( - colourise("*", fg="green"), - colourise("Generating a checksum for", fg="white"), - colourise(document.file_name, fg="cyan"), - ), - ) - - with document.source_file as encrypted: - checksum = hashlib.md5(GnuPG.decrypted(encrypted)).hexdigest() - - if checksum in sums: - error = "\n{line}{p1}\n\n{doc1}\n{doc2}\n\n{p2}\n\n{code}\n\n{p3}{line}".format( - p1=colourise( - "It appears that you have two identical documents in your collection and \nPaperless no longer supports this (see issue #97). The documents in question\nare:", - fg="yellow", - ), - p2=colourise( - "To fix this problem, you'll have to remove one of them from the database, a task\nmost easily done by running the following command in the same\ndirectory as manage.py:", - fg="yellow", - ), - p3=colourise( - "When that's finished, re-run the migrate, and provided that there aren't any\nother duplicates, you should be good to go.", - fg="yellow", - ), - doc1=colourise( - f" * {sums[checksum][1]} (id: {sums[checksum][0]})", - fg="red", - ), - doc2=colourise( - f" * {document.file_name} (id: {document.pk})", - fg="red", - ), - code=colourise( - f" $ echo 'DELETE FROM documents_document WHERE id = {document.pk};' | ./manage.py dbshell", - fg="green", - ), - line=colourise("\n{}\n".format("=" * 80), fg="white", opts=("bold",)), - ) - raise RuntimeError(error) - sums[checksum] = (document.pk, document.file_name) - - document_model.objects.filter(pk=document.pk).update(checksum=checksum) - - -def do_nothing(apps, schema_editor): - pass - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0013_auto_20160325_2111"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="checksum", - field=models.CharField( - default="-", - db_index=True, - editable=False, - max_length=32, - help_text="The checksum of the original document (before it " - "was encrypted). We use this to prevent duplicate " - "document imports.", - ), - preserve_default=False, - ), - migrations.RunPython(set_checksums, do_nothing), - migrations.AlterField( - model_name="document", - name="created", - field=models.DateTimeField( - db_index=True, - default=django.utils.timezone.now, - ), - ), - migrations.AlterField( - model_name="document", - name="modified", - field=models.DateTimeField(auto_now=True, db_index=True), - ), - ] diff --git a/src/documents/migrations/0015_add_insensitive_to_match.py b/src/documents/migrations/0015_add_insensitive_to_match.py deleted file mode 100644 index 0761cc929..000000000 --- a/src/documents/migrations/0015_add_insensitive_to_match.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 1.10.2 on 2016-10-05 21:38 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0014_document_checksum"), - ] - - operations = [ - migrations.AlterField( - model_name="document", - name="checksum", - field=models.CharField( - editable=False, - help_text="The checksum of the original document (before it was encrypted). We use this to prevent duplicate document imports.", - max_length=32, - unique=True, - ), - ), - migrations.AddField( - model_name="correspondent", - name="is_insensitive", - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name="tag", - name="is_insensitive", - field=models.BooleanField(default=True), - ), - ] diff --git a/src/documents/migrations/0015_add_insensitive_to_match_squashed_0018_auto_20170715_1712.py b/src/documents/migrations/0015_add_insensitive_to_match_squashed_0018_auto_20170715_1712.py deleted file mode 100644 index bfa897601..000000000 --- a/src/documents/migrations/0015_add_insensitive_to_match_squashed_0018_auto_20170715_1712.py +++ /dev/null @@ -1,92 +0,0 @@ -# Generated by Django 4.2.13 on 2024-06-28 17:57 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - replaces = [ - ("documents", "0015_add_insensitive_to_match"), - ("documents", "0016_auto_20170325_1558"), - ("documents", "0017_auto_20170512_0507"), - ("documents", "0018_auto_20170715_1712"), - ] - - dependencies = [ - ("documents", "0014_document_checksum"), - ] - - operations = [ - migrations.AlterField( - model_name="document", - name="checksum", - field=models.CharField( - editable=False, - help_text="The checksum of the original document (before it was encrypted). We use this to prevent duplicate document imports.", - max_length=32, - unique=True, - ), - ), - migrations.AddField( - model_name="correspondent", - name="is_insensitive", - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name="tag", - name="is_insensitive", - field=models.BooleanField(default=True), - ), - migrations.AlterField( - model_name="document", - name="content", - field=models.TextField( - blank=True, - db_index=("mysql" not in settings.DATABASES["default"]["ENGINE"]), - help_text="The raw, text-only data of the document. This field is primarily used for searching.", - ), - ), - migrations.AlterField( - model_name="correspondent", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any"), - (2, "All"), - (3, "Literal"), - (4, "Regular Expression"), - (5, "Fuzzy Match"), - ], - default=1, - help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. (If you don\'t know what a regex is, you probably don\'t want this option.) Finally, a "fuzzy match" looks for words or phrases that are mostly—but not exactly—the same, which can be useful for matching against documents containing imperfections that foil accurate OCR.', - ), - ), - migrations.AlterField( - model_name="tag", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any"), - (2, "All"), - (3, "Literal"), - (4, "Regular Expression"), - (5, "Fuzzy Match"), - ], - default=1, - help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. (If you don\'t know what a regex is, you probably don\'t want this option.) Finally, a "fuzzy match" looks for words or phrases that are mostly—but not exactly—the same, which can be useful for matching against documents containing imperfections that foil accurate OCR.', - ), - ), - migrations.AlterField( - model_name="document", - name="correspondent", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="documents", - to="documents.correspondent", - ), - ), - ] diff --git a/src/documents/migrations/0016_auto_20170325_1558.py b/src/documents/migrations/0016_auto_20170325_1558.py deleted file mode 100644 index 743097d0c..000000000 --- a/src/documents/migrations/0016_auto_20170325_1558.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 1.10.5 on 2017-03-25 15:58 - -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0015_add_insensitive_to_match"), - ] - - operations = [ - migrations.AlterField( - model_name="document", - name="content", - field=models.TextField( - blank=True, - db_index=("mysql" not in settings.DATABASES["default"]["ENGINE"]), - help_text="The raw, text-only data of the document. This field is primarily used for searching.", - ), - ), - ] diff --git a/src/documents/migrations/0017_auto_20170512_0507.py b/src/documents/migrations/0017_auto_20170512_0507.py deleted file mode 100644 index b9477a06c..000000000 --- a/src/documents/migrations/0017_auto_20170512_0507.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 1.10.5 on 2017-05-12 05:07 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0016_auto_20170325_1558"), - ] - - operations = [ - migrations.AlterField( - model_name="correspondent", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any"), - (2, "All"), - (3, "Literal"), - (4, "Regular Expression"), - (5, "Fuzzy Match"), - ], - default=1, - help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. (If you don\'t know what a regex is, you probably don\'t want this option.) Finally, a "fuzzy match" looks for words or phrases that are mostly—but not exactly—the same, which can be useful for matching against documents containing imperfections that foil accurate OCR.', - ), - ), - migrations.AlterField( - model_name="tag", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any"), - (2, "All"), - (3, "Literal"), - (4, "Regular Expression"), - (5, "Fuzzy Match"), - ], - default=1, - help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. (If you don\'t know what a regex is, you probably don\'t want this option.) Finally, a "fuzzy match" looks for words or phrases that are mostly—but not exactly—the same, which can be useful for matching against documents containing imperfections that foil accurate OCR.', - ), - ), - ] diff --git a/src/documents/migrations/0018_auto_20170715_1712.py b/src/documents/migrations/0018_auto_20170715_1712.py deleted file mode 100644 index 838d79ddc..000000000 --- a/src/documents/migrations/0018_auto_20170715_1712.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 1.10.5 on 2017-07-15 17:12 - -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0017_auto_20170512_0507"), - ] - - operations = [ - migrations.AlterField( - model_name="document", - name="correspondent", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="documents", - to="documents.Correspondent", - ), - ), - ] diff --git a/src/documents/migrations/0019_add_consumer_user.py b/src/documents/migrations/0019_add_consumer_user.py deleted file mode 100644 index b38d88538..000000000 --- a/src/documents/migrations/0019_add_consumer_user.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 1.10.5 on 2017-07-15 17:12 - -from django.contrib.auth.models import User -from django.db import migrations - - -def forwards_func(apps, schema_editor): - User.objects.create(username="consumer") - - -def reverse_func(apps, schema_editor): - User.objects.get(username="consumer").delete() - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0018_auto_20170715_1712"), - ] - - operations = [ - migrations.RunPython(forwards_func, reverse_func), - ] diff --git a/src/documents/migrations/0020_document_added.py b/src/documents/migrations/0020_document_added.py deleted file mode 100644 index 34042eedf..000000000 --- a/src/documents/migrations/0020_document_added.py +++ /dev/null @@ -1,29 +0,0 @@ -import django.utils.timezone -from django.db import migrations -from django.db import models - - -def set_added_time_to_created_time(apps, schema_editor): - Document = apps.get_model("documents", "Document") - for doc in Document.objects.all(): - doc.added = doc.created - doc.save() - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0019_add_consumer_user"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="added", - field=models.DateTimeField( - db_index=True, - default=django.utils.timezone.now, - editable=False, - ), - ), - migrations.RunPython(set_added_time_to_created_time), - ] diff --git a/src/documents/migrations/0021_document_storage_type.py b/src/documents/migrations/0021_document_storage_type.py deleted file mode 100644 index b35fe75ed..000000000 --- a/src/documents/migrations/0021_document_storage_type.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 1.11.10 on 2018-02-04 13:07 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0020_document_added"), - ] - - operations = [ - # Add the field with the default GPG-encrypted value - migrations.AddField( - model_name="document", - name="storage_type", - field=models.CharField( - choices=[ - ("unencrypted", "Unencrypted"), - ("gpg", "Encrypted with GNU Privacy Guard"), - ], - default="gpg", - editable=False, - max_length=11, - ), - ), - # Now that the field is added, change the default to unencrypted - migrations.AlterField( - model_name="document", - name="storage_type", - field=models.CharField( - choices=[ - ("unencrypted", "Unencrypted"), - ("gpg", "Encrypted with GNU Privacy Guard"), - ], - default="unencrypted", - editable=False, - max_length=11, - ), - ), - ] diff --git a/src/documents/migrations/0022_auto_20181007_1420.py b/src/documents/migrations/0022_auto_20181007_1420.py deleted file mode 100644 index 02dfa6d2b..000000000 --- a/src/documents/migrations/0022_auto_20181007_1420.py +++ /dev/null @@ -1,61 +0,0 @@ -# Generated by Django 2.0.8 on 2018-10-07 14:20 - -from django.db import migrations -from django.db import models -from django.utils.text import slugify - - -def re_slug_all_the_things(apps, schema_editor): - """ - Rewrite all slug values to make sure they're actually slugs before we brand - them as uneditable. - """ - - Tag = apps.get_model("documents", "Tag") - Correspondent = apps.get_model("documents", "Correspondent") - - for klass in (Tag, Correspondent): - for instance in klass.objects.all(): - klass.objects.filter(pk=instance.pk).update(slug=slugify(instance.slug)) - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0021_document_storage_type"), - ] - - operations = [ - migrations.AlterModelOptions( - name="tag", - options={"ordering": ("name",)}, - ), - migrations.AlterField( - model_name="correspondent", - name="slug", - field=models.SlugField(blank=True, editable=False), - ), - migrations.AlterField( - model_name="document", - name="file_type", - field=models.CharField( - choices=[ - ("pdf", "PDF"), - ("png", "PNG"), - ("jpg", "JPG"), - ("gif", "GIF"), - ("tiff", "TIFF"), - ("txt", "TXT"), - ("csv", "CSV"), - ("md", "MD"), - ], - editable=False, - max_length=4, - ), - ), - migrations.AlterField( - model_name="tag", - name="slug", - field=models.SlugField(blank=True, editable=False), - ), - migrations.RunPython(re_slug_all_the_things, migrations.RunPython.noop), - ] diff --git a/src/documents/migrations/0023_document_current_filename.py b/src/documents/migrations/0023_document_current_filename.py deleted file mode 100644 index 5f52e1c89..000000000 --- a/src/documents/migrations/0023_document_current_filename.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 2.0.10 on 2019-04-26 18:57 - -from django.db import migrations -from django.db import models - - -def set_filename(apps, schema_editor): - Document = apps.get_model("documents", "Document") - for doc in Document.objects.all(): - file_name = f"{doc.pk:07}.{doc.file_type}" - if doc.storage_type == "gpg": - file_name += ".gpg" - - # Set filename - doc.filename = file_name - - # Save document - doc.save() - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0022_auto_20181007_1420"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="filename", - field=models.FilePathField( - default=None, - null=True, - editable=False, - help_text="Current filename in storage", - max_length=256, - ), - ), - migrations.RunPython(set_filename), - ] diff --git a/src/documents/migrations/1000_update_paperless_all.py b/src/documents/migrations/1000_update_paperless_all.py deleted file mode 100644 index ae0d217f6..000000000 --- a/src/documents/migrations/1000_update_paperless_all.py +++ /dev/null @@ -1,147 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-07 12:35 -import uuid - -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -def logs_set_default_group(apps, schema_editor): - Log = apps.get_model("documents", "Log") - for log in Log.objects.all(): - if log.group is None: - log.group = uuid.uuid4() - log.save() - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "0023_document_current_filename"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="archive_serial_number", - field=models.IntegerField( - blank=True, - db_index=True, - help_text="The position of this document in your physical document archive.", - null=True, - unique=True, - ), - ), - migrations.AddField( - model_name="tag", - name="is_inbox_tag", - field=models.BooleanField( - default=False, - help_text="Marks this tag as an inbox tag: All newly consumed documents will be tagged with inbox tags.", - ), - ), - migrations.CreateModel( - name="DocumentType", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=128, unique=True)), - ("slug", models.SlugField(blank=True, editable=False)), - ("match", models.CharField(blank=True, max_length=256)), - ( - "matching_algorithm", - models.PositiveIntegerField( - choices=[ - (1, "Any"), - (2, "All"), - (3, "Literal"), - (4, "Regular Expression"), - (5, "Fuzzy Match"), - (6, "Automatic Classification"), - ], - default=1, - help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. (If you don\'t know what a regex is, you probably don\'t want this option.) Finally, a "fuzzy match" looks for words or phrases that are mostly—but not exactly—the same, which can be useful for matching against documents containing imperfections that foil accurate OCR.', - ), - ), - ("is_insensitive", models.BooleanField(default=True)), - ], - options={ - "abstract": False, - "ordering": ("name",), - }, - ), - migrations.AddField( - model_name="document", - name="document_type", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="documents", - to="documents.documenttype", - ), - ), - migrations.AlterField( - model_name="correspondent", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any"), - (2, "All"), - (3, "Literal"), - (4, "Regular Expression"), - (5, "Fuzzy Match"), - (6, "Automatic Classification"), - ], - default=1, - help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. (If you don\'t know what a regex is, you probably don\'t want this option.) Finally, a "fuzzy match" looks for words or phrases that are mostly—but not exactly—the same, which can be useful for matching against documents containing imperfections that foil accurate OCR.', - ), - ), - migrations.AlterField( - model_name="tag", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any"), - (2, "All"), - (3, "Literal"), - (4, "Regular Expression"), - (5, "Fuzzy Match"), - (6, "Automatic Classification"), - ], - default=1, - help_text='Which algorithm you want to use when matching text to the OCR\'d PDF. Here, "any" looks for any occurrence of any word provided in the PDF, while "all" requires that every word provided appear in the PDF, albeit not in the order provided. A "literal" match means that the text you enter must appear in the PDF exactly as you\'ve entered it, and "regular expression" uses a regex to match the PDF. (If you don\'t know what a regex is, you probably don\'t want this option.) Finally, a "fuzzy match" looks for words or phrases that are mostly—but not exactly—the same, which can be useful for matching against documents containing imperfections that foil accurate OCR.', - ), - ), - migrations.AlterField( - model_name="document", - name="content", - field=models.TextField( - blank=True, - help_text="The raw, text-only data of the document. This field is primarily used for searching.", - ), - ), - migrations.AlterModelOptions( - name="log", - options={"ordering": ("-created",)}, - ), - migrations.RemoveField( - model_name="log", - name="modified", - ), - migrations.AlterField( - model_name="log", - name="group", - field=models.UUIDField(blank=True, null=True), - ), - migrations.RunPython( - code=django.db.migrations.operations.special.RunPython.noop, - reverse_code=logs_set_default_group, - ), - ] diff --git a/src/documents/migrations/1001_auto_20201109_1636.py b/src/documents/migrations/1001_auto_20201109_1636.py deleted file mode 100644 index 7477a118c..000000000 --- a/src/documents/migrations/1001_auto_20201109_1636.py +++ /dev/null @@ -1,13 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-09 16:36 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1000_update_paperless_all"), - ] - - operations = [ - migrations.RunPython(migrations.RunPython.noop, migrations.RunPython.noop), - ] diff --git a/src/documents/migrations/1002_auto_20201111_1105.py b/src/documents/migrations/1002_auto_20201111_1105.py deleted file mode 100644 index 1835c4ca9..000000000 --- a/src/documents/migrations/1002_auto_20201111_1105.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-11 11:05 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1001_auto_20201109_1636"), - ] - - operations = [ - migrations.AlterField( - model_name="document", - name="filename", - field=models.FilePathField( - default=None, - editable=False, - help_text="Current filename in storage", - max_length=1024, - null=True, - ), - ), - ] diff --git a/src/documents/migrations/1003_mime_types.py b/src/documents/migrations/1003_mime_types.py deleted file mode 100644 index 4c7ddb492..000000000 --- a/src/documents/migrations/1003_mime_types.py +++ /dev/null @@ -1,92 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-20 11:21 -from pathlib import Path - -import magic -from django.conf import settings -from django.db import migrations -from django.db import models - -from paperless.db import GnuPG - -STORAGE_TYPE_UNENCRYPTED = "unencrypted" -STORAGE_TYPE_GPG = "gpg" - - -def source_path(self) -> Path: - if self.filename: - fname: str = str(self.filename) - else: - fname = f"{self.pk:07}.{self.file_type}" - if self.storage_type == STORAGE_TYPE_GPG: - fname += ".gpg" - - return Path(settings.ORIGINALS_DIR) / fname - - -def add_mime_types(apps, schema_editor): - Document = apps.get_model("documents", "Document") - documents = Document.objects.all() - - for d in documents: - with Path(source_path(d)).open("rb") as f: - if d.storage_type == STORAGE_TYPE_GPG: - data = GnuPG.decrypted(f) - else: - data = f.read(1024) - - d.mime_type = magic.from_buffer(data, mime=True) - d.save() - - -def add_file_extensions(apps, schema_editor): - Document = apps.get_model("documents", "Document") - documents = Document.objects.all() - - for d in documents: - d.file_type = Path(d.filename).suffix.lstrip(".") - d.save() - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1002_auto_20201111_1105"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="mime_type", - field=models.CharField(default="-", editable=False, max_length=256), - preserve_default=False, - ), - migrations.RunPython(add_mime_types, migrations.RunPython.noop), - # This operation is here so that we can revert the entire migration: - # By allowing this field to be blank and null, we can revert the - # remove operation further down and the database won't complain about - # NOT NULL violations. - migrations.AlterField( - model_name="document", - name="file_type", - field=models.CharField( - choices=[ - ("pdf", "PDF"), - ("png", "PNG"), - ("jpg", "JPG"), - ("gif", "GIF"), - ("tiff", "TIFF"), - ("txt", "TXT"), - ("csv", "CSV"), - ("md", "MD"), - ], - editable=False, - max_length=4, - null=True, - blank=True, - ), - ), - migrations.RunPython(migrations.RunPython.noop, add_file_extensions), - migrations.RemoveField( - model_name="document", - name="file_type", - ), - ] diff --git a/src/documents/migrations/1004_sanity_check_schedule.py b/src/documents/migrations/1004_sanity_check_schedule.py deleted file mode 100644 index 018cf2492..000000000 --- a/src/documents/migrations/1004_sanity_check_schedule.py +++ /dev/null @@ -1,12 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-25 14:53 - -from django.db import migrations -from django.db.migrations import RunPython - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1003_mime_types"), - ] - - operations = [RunPython(migrations.RunPython.noop, migrations.RunPython.noop)] diff --git a/src/documents/migrations/1005_checksums.py b/src/documents/migrations/1005_checksums.py deleted file mode 100644 index 4637e06ce..000000000 --- a/src/documents/migrations/1005_checksums.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-29 00:48 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1004_sanity_check_schedule"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="archive_checksum", - field=models.CharField( - blank=True, - editable=False, - help_text="The checksum of the archived document.", - max_length=32, - null=True, - ), - ), - migrations.AlterField( - model_name="document", - name="checksum", - field=models.CharField( - editable=False, - help_text="The checksum of the original document.", - max_length=32, - unique=True, - ), - ), - ] diff --git a/src/documents/migrations/1006_auto_20201208_2209.py b/src/documents/migrations/1006_auto_20201208_2209.py deleted file mode 100644 index 425f0a768..000000000 --- a/src/documents/migrations/1006_auto_20201208_2209.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 3.1.4 on 2020-12-08 22:09 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1005_checksums"), - ] - - operations = [ - migrations.RemoveField( - model_name="correspondent", - name="slug", - ), - migrations.RemoveField( - model_name="documenttype", - name="slug", - ), - migrations.RemoveField( - model_name="tag", - name="slug", - ), - ] diff --git a/src/documents/migrations/1006_auto_20201208_2209_squashed_1011_auto_20210101_2340.py b/src/documents/migrations/1006_auto_20201208_2209_squashed_1011_auto_20210101_2340.py deleted file mode 100644 index aa8cb6deb..000000000 --- a/src/documents/migrations/1006_auto_20201208_2209_squashed_1011_auto_20210101_2340.py +++ /dev/null @@ -1,485 +0,0 @@ -# Generated by Django 4.2.13 on 2024-06-28 18:01 - -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - replaces = [ - ("documents", "1006_auto_20201208_2209"), - ("documents", "1007_savedview_savedviewfilterrule"), - ("documents", "1008_auto_20201216_1736"), - ("documents", "1009_auto_20201216_2005"), - ("documents", "1010_auto_20210101_2159"), - ("documents", "1011_auto_20210101_2340"), - ] - - dependencies = [ - ("documents", "1005_checksums"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveField( - model_name="correspondent", - name="slug", - ), - migrations.RemoveField( - model_name="documenttype", - name="slug", - ), - migrations.RemoveField( - model_name="tag", - name="slug", - ), - migrations.CreateModel( - name="SavedView", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=128, verbose_name="name")), - ( - "show_on_dashboard", - models.BooleanField(verbose_name="show on dashboard"), - ), - ( - "show_in_sidebar", - models.BooleanField(verbose_name="show in sidebar"), - ), - ( - "sort_field", - models.CharField(max_length=128, verbose_name="sort field"), - ), - ( - "sort_reverse", - models.BooleanField(default=False, verbose_name="sort reverse"), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - verbose_name="user", - ), - ), - ], - options={ - "ordering": ("name",), - "verbose_name": "saved view", - "verbose_name_plural": "saved views", - }, - ), - migrations.AlterModelOptions( - name="correspondent", - options={ - "ordering": ("name",), - "verbose_name": "correspondent", - "verbose_name_plural": "correspondents", - }, - ), - migrations.AlterModelOptions( - name="document", - options={ - "ordering": ("-created",), - "verbose_name": "document", - "verbose_name_plural": "documents", - }, - ), - migrations.AlterModelOptions( - name="documenttype", - options={ - "verbose_name": "document type", - "verbose_name_plural": "document types", - }, - ), - migrations.AlterModelOptions( - name="log", - options={ - "ordering": ("-created",), - "verbose_name": "log", - "verbose_name_plural": "logs", - }, - ), - migrations.AlterModelOptions( - name="tag", - options={"verbose_name": "tag", "verbose_name_plural": "tags"}, - ), - migrations.AlterField( - model_name="correspondent", - name="is_insensitive", - field=models.BooleanField(default=True, verbose_name="is insensitive"), - ), - migrations.AlterField( - model_name="correspondent", - name="match", - field=models.CharField(blank=True, max_length=256, verbose_name="match"), - ), - migrations.AlterField( - model_name="correspondent", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="correspondent", - name="name", - field=models.CharField(max_length=128, unique=True, verbose_name="name"), - ), - migrations.AlterField( - model_name="document", - name="added", - field=models.DateTimeField( - db_index=True, - default=django.utils.timezone.now, - editable=False, - verbose_name="added", - ), - ), - migrations.AlterField( - model_name="document", - name="archive_checksum", - field=models.CharField( - blank=True, - editable=False, - help_text="The checksum of the archived document.", - max_length=32, - null=True, - verbose_name="archive checksum", - ), - ), - migrations.AlterField( - model_name="document", - name="archive_serial_number", - field=models.IntegerField( - blank=True, - db_index=True, - help_text="The position of this document in your physical document archive.", - null=True, - unique=True, - verbose_name="archive serial number", - ), - ), - migrations.AlterField( - model_name="document", - name="checksum", - field=models.CharField( - editable=False, - help_text="The checksum of the original document.", - max_length=32, - unique=True, - verbose_name="checksum", - ), - ), - migrations.AlterField( - model_name="document", - name="content", - field=models.TextField( - blank=True, - help_text="The raw, text-only data of the document. This field is primarily used for searching.", - verbose_name="content", - ), - ), - migrations.AlterField( - model_name="document", - name="correspondent", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="documents", - to="documents.correspondent", - verbose_name="correspondent", - ), - ), - migrations.AlterField( - model_name="document", - name="created", - field=models.DateTimeField( - db_index=True, - default=django.utils.timezone.now, - verbose_name="created", - ), - ), - migrations.AlterField( - model_name="document", - name="document_type", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="documents", - to="documents.documenttype", - verbose_name="document type", - ), - ), - migrations.AlterField( - model_name="document", - name="filename", - field=models.FilePathField( - default=None, - editable=False, - help_text="Current filename in storage", - max_length=1024, - null=True, - verbose_name="filename", - ), - ), - migrations.AlterField( - model_name="document", - name="mime_type", - field=models.CharField( - editable=False, - max_length=256, - verbose_name="mime type", - ), - ), - migrations.AlterField( - model_name="document", - name="modified", - field=models.DateTimeField( - auto_now=True, - db_index=True, - verbose_name="modified", - ), - ), - migrations.AlterField( - model_name="document", - name="storage_type", - field=models.CharField( - choices=[ - ("unencrypted", "Unencrypted"), - ("gpg", "Encrypted with GNU Privacy Guard"), - ], - default="unencrypted", - editable=False, - max_length=11, - verbose_name="storage type", - ), - ), - migrations.AlterField( - model_name="document", - name="tags", - field=models.ManyToManyField( - blank=True, - related_name="documents", - to="documents.tag", - verbose_name="tags", - ), - ), - migrations.AlterField( - model_name="document", - name="title", - field=models.CharField( - blank=True, - db_index=True, - max_length=128, - verbose_name="title", - ), - ), - migrations.AlterField( - model_name="documenttype", - name="is_insensitive", - field=models.BooleanField(default=True, verbose_name="is insensitive"), - ), - migrations.AlterField( - model_name="documenttype", - name="match", - field=models.CharField(blank=True, max_length=256, verbose_name="match"), - ), - migrations.AlterField( - model_name="documenttype", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="documenttype", - name="name", - field=models.CharField(max_length=128, unique=True, verbose_name="name"), - ), - migrations.AlterField( - model_name="log", - name="created", - field=models.DateTimeField(auto_now_add=True, verbose_name="created"), - ), - migrations.AlterField( - model_name="log", - name="group", - field=models.UUIDField(blank=True, null=True, verbose_name="group"), - ), - migrations.AlterField( - model_name="log", - name="level", - field=models.PositiveIntegerField( - choices=[ - (10, "debug"), - (20, "information"), - (30, "warning"), - (40, "error"), - (50, "critical"), - ], - default=20, - verbose_name="level", - ), - ), - migrations.AlterField( - model_name="log", - name="message", - field=models.TextField(verbose_name="message"), - ), - migrations.CreateModel( - name="SavedViewFilterRule", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "rule_type", - models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - ], - verbose_name="rule type", - ), - ), - ( - "value", - models.CharField( - blank=True, - max_length=128, - null=True, - verbose_name="value", - ), - ), - ( - "saved_view", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="filter_rules", - to="documents.savedview", - verbose_name="saved view", - ), - ), - ], - options={ - "verbose_name": "filter rule", - "verbose_name_plural": "filter rules", - }, - ), - migrations.AlterField( - model_name="tag", - name="colour", - field=models.PositiveIntegerField( - choices=[ - (1, "#a6cee3"), - (2, "#1f78b4"), - (3, "#b2df8a"), - (4, "#33a02c"), - (5, "#fb9a99"), - (6, "#e31a1c"), - (7, "#fdbf6f"), - (8, "#ff7f00"), - (9, "#cab2d6"), - (10, "#6a3d9a"), - (11, "#b15928"), - (12, "#000000"), - (13, "#cccccc"), - ], - default=1, - verbose_name="color", - ), - ), - migrations.AlterField( - model_name="tag", - name="is_inbox_tag", - field=models.BooleanField( - default=False, - help_text="Marks this tag as an inbox tag: All newly consumed documents will be tagged with inbox tags.", - verbose_name="is inbox tag", - ), - ), - migrations.AlterField( - model_name="tag", - name="is_insensitive", - field=models.BooleanField(default=True, verbose_name="is insensitive"), - ), - migrations.AlterField( - model_name="tag", - name="match", - field=models.CharField(blank=True, max_length=256, verbose_name="match"), - ), - migrations.AlterField( - model_name="tag", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="tag", - name="name", - field=models.CharField(max_length=128, unique=True, verbose_name="name"), - ), - ] diff --git a/src/documents/migrations/1007_savedview_savedviewfilterrule.py b/src/documents/migrations/1007_savedview_savedviewfilterrule.py deleted file mode 100644 index 64564c6af..000000000 --- a/src/documents/migrations/1007_savedview_savedviewfilterrule.py +++ /dev/null @@ -1,90 +0,0 @@ -# Generated by Django 3.1.4 on 2020-12-12 14:41 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("documents", "1006_auto_20201208_2209"), - ] - - operations = [ - migrations.CreateModel( - name="SavedView", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=128)), - ("show_on_dashboard", models.BooleanField()), - ("show_in_sidebar", models.BooleanField()), - ("sort_field", models.CharField(max_length=128)), - ("sort_reverse", models.BooleanField(default=False)), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.CreateModel( - name="SavedViewFilterRule", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "rule_type", - models.PositiveIntegerField( - choices=[ - (0, "Title contains"), - (1, "Content contains"), - (2, "ASN is"), - (3, "Correspondent is"), - (4, "Document type is"), - (5, "Is in inbox"), - (6, "Has tag"), - (7, "Has any tag"), - (8, "Created before"), - (9, "Created after"), - (10, "Created year is"), - (11, "Created month is"), - (12, "Created day is"), - (13, "Added before"), - (14, "Added after"), - (15, "Modified before"), - (16, "Modified after"), - (17, "Does not have tag"), - ], - ), - ), - ("value", models.CharField(max_length=128)), - ( - "saved_view", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="filter_rules", - to="documents.savedview", - ), - ), - ], - ), - ] diff --git a/src/documents/migrations/1008_auto_20201216_1736.py b/src/documents/migrations/1008_auto_20201216_1736.py deleted file mode 100644 index 76f0343b1..000000000 --- a/src/documents/migrations/1008_auto_20201216_1736.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 3.1.4 on 2020-12-16 17:36 - -import django.db.models.functions.text -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1007_savedview_savedviewfilterrule"), - ] - - operations = [ - migrations.AlterModelOptions( - name="correspondent", - options={"ordering": (django.db.models.functions.text.Lower("name"),)}, - ), - migrations.AlterModelOptions( - name="document", - options={"ordering": ("-created",)}, - ), - migrations.AlterModelOptions( - name="documenttype", - options={"ordering": (django.db.models.functions.text.Lower("name"),)}, - ), - migrations.AlterModelOptions( - name="savedview", - options={"ordering": (django.db.models.functions.text.Lower("name"),)}, - ), - migrations.AlterModelOptions( - name="tag", - options={"ordering": (django.db.models.functions.text.Lower("name"),)}, - ), - ] diff --git a/src/documents/migrations/1009_auto_20201216_2005.py b/src/documents/migrations/1009_auto_20201216_2005.py deleted file mode 100644 index 37bae8881..000000000 --- a/src/documents/migrations/1009_auto_20201216_2005.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 3.1.4 on 2020-12-16 20:05 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1008_auto_20201216_1736"), - ] - - operations = [ - migrations.AlterModelOptions( - name="correspondent", - options={"ordering": ("name",)}, - ), - migrations.AlterModelOptions( - name="documenttype", - options={"ordering": ("name",)}, - ), - migrations.AlterModelOptions( - name="savedview", - options={"ordering": ("name",)}, - ), - migrations.AlterModelOptions( - name="tag", - options={"ordering": ("name",)}, - ), - ] diff --git a/src/documents/migrations/1010_auto_20210101_2159.py b/src/documents/migrations/1010_auto_20210101_2159.py deleted file mode 100644 index 0c6a42d30..000000000 --- a/src/documents/migrations/1010_auto_20210101_2159.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.1.4 on 2021-01-01 21:59 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1009_auto_20201216_2005"), - ] - - operations = [ - migrations.AlterField( - model_name="savedviewfilterrule", - name="value", - field=models.CharField(blank=True, max_length=128, null=True), - ), - ] diff --git a/src/documents/migrations/1011_auto_20210101_2340.py b/src/documents/migrations/1011_auto_20210101_2340.py deleted file mode 100644 index dea107715..000000000 --- a/src/documents/migrations/1011_auto_20210101_2340.py +++ /dev/null @@ -1,454 +0,0 @@ -# Generated by Django 3.1.4 on 2021-01-01 23:40 - -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("documents", "1010_auto_20210101_2159"), - ] - - operations = [ - migrations.AlterModelOptions( - name="correspondent", - options={ - "ordering": ("name",), - "verbose_name": "correspondent", - "verbose_name_plural": "correspondents", - }, - ), - migrations.AlterModelOptions( - name="document", - options={ - "ordering": ("-created",), - "verbose_name": "document", - "verbose_name_plural": "documents", - }, - ), - migrations.AlterModelOptions( - name="documenttype", - options={ - "verbose_name": "document type", - "verbose_name_plural": "document types", - }, - ), - migrations.AlterModelOptions( - name="log", - options={ - "ordering": ("-created",), - "verbose_name": "log", - "verbose_name_plural": "logs", - }, - ), - migrations.AlterModelOptions( - name="savedview", - options={ - "ordering": ("name",), - "verbose_name": "saved view", - "verbose_name_plural": "saved views", - }, - ), - migrations.AlterModelOptions( - name="savedviewfilterrule", - options={ - "verbose_name": "filter rule", - "verbose_name_plural": "filter rules", - }, - ), - migrations.AlterModelOptions( - name="tag", - options={"verbose_name": "tag", "verbose_name_plural": "tags"}, - ), - migrations.AlterField( - model_name="correspondent", - name="is_insensitive", - field=models.BooleanField(default=True, verbose_name="is insensitive"), - ), - migrations.AlterField( - model_name="correspondent", - name="match", - field=models.CharField(blank=True, max_length=256, verbose_name="match"), - ), - migrations.AlterField( - model_name="correspondent", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="correspondent", - name="name", - field=models.CharField(max_length=128, unique=True, verbose_name="name"), - ), - migrations.AlterField( - model_name="document", - name="added", - field=models.DateTimeField( - db_index=True, - default=django.utils.timezone.now, - editable=False, - verbose_name="added", - ), - ), - migrations.AlterField( - model_name="document", - name="archive_checksum", - field=models.CharField( - blank=True, - editable=False, - help_text="The checksum of the archived document.", - max_length=32, - null=True, - verbose_name="archive checksum", - ), - ), - migrations.AlterField( - model_name="document", - name="archive_serial_number", - field=models.IntegerField( - blank=True, - db_index=True, - help_text="The position of this document in your physical document archive.", - null=True, - unique=True, - verbose_name="archive serial number", - ), - ), - migrations.AlterField( - model_name="document", - name="checksum", - field=models.CharField( - editable=False, - help_text="The checksum of the original document.", - max_length=32, - unique=True, - verbose_name="checksum", - ), - ), - migrations.AlterField( - model_name="document", - name="content", - field=models.TextField( - blank=True, - help_text="The raw, text-only data of the document. This field is primarily used for searching.", - verbose_name="content", - ), - ), - migrations.AlterField( - model_name="document", - name="correspondent", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="documents", - to="documents.correspondent", - verbose_name="correspondent", - ), - ), - migrations.AlterField( - model_name="document", - name="created", - field=models.DateTimeField( - db_index=True, - default=django.utils.timezone.now, - verbose_name="created", - ), - ), - migrations.AlterField( - model_name="document", - name="document_type", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="documents", - to="documents.documenttype", - verbose_name="document type", - ), - ), - migrations.AlterField( - model_name="document", - name="filename", - field=models.FilePathField( - default=None, - editable=False, - help_text="Current filename in storage", - max_length=1024, - null=True, - verbose_name="filename", - ), - ), - migrations.AlterField( - model_name="document", - name="mime_type", - field=models.CharField( - editable=False, - max_length=256, - verbose_name="mime type", - ), - ), - migrations.AlterField( - model_name="document", - name="modified", - field=models.DateTimeField( - auto_now=True, - db_index=True, - verbose_name="modified", - ), - ), - migrations.AlterField( - model_name="document", - name="storage_type", - field=models.CharField( - choices=[ - ("unencrypted", "Unencrypted"), - ("gpg", "Encrypted with GNU Privacy Guard"), - ], - default="unencrypted", - editable=False, - max_length=11, - verbose_name="storage type", - ), - ), - migrations.AlterField( - model_name="document", - name="tags", - field=models.ManyToManyField( - blank=True, - related_name="documents", - to="documents.Tag", - verbose_name="tags", - ), - ), - migrations.AlterField( - model_name="document", - name="title", - field=models.CharField( - blank=True, - db_index=True, - max_length=128, - verbose_name="title", - ), - ), - migrations.AlterField( - model_name="documenttype", - name="is_insensitive", - field=models.BooleanField(default=True, verbose_name="is insensitive"), - ), - migrations.AlterField( - model_name="documenttype", - name="match", - field=models.CharField(blank=True, max_length=256, verbose_name="match"), - ), - migrations.AlterField( - model_name="documenttype", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="documenttype", - name="name", - field=models.CharField(max_length=128, unique=True, verbose_name="name"), - ), - migrations.AlterField( - model_name="log", - name="created", - field=models.DateTimeField(auto_now_add=True, verbose_name="created"), - ), - migrations.AlterField( - model_name="log", - name="group", - field=models.UUIDField(blank=True, null=True, verbose_name="group"), - ), - migrations.AlterField( - model_name="log", - name="level", - field=models.PositiveIntegerField( - choices=[ - (10, "debug"), - (20, "information"), - (30, "warning"), - (40, "error"), - (50, "critical"), - ], - default=20, - verbose_name="level", - ), - ), - migrations.AlterField( - model_name="log", - name="message", - field=models.TextField(verbose_name="message"), - ), - migrations.AlterField( - model_name="savedview", - name="name", - field=models.CharField(max_length=128, verbose_name="name"), - ), - migrations.AlterField( - model_name="savedview", - name="show_in_sidebar", - field=models.BooleanField(verbose_name="show in sidebar"), - ), - migrations.AlterField( - model_name="savedview", - name="show_on_dashboard", - field=models.BooleanField(verbose_name="show on dashboard"), - ), - migrations.AlterField( - model_name="savedview", - name="sort_field", - field=models.CharField(max_length=128, verbose_name="sort field"), - ), - migrations.AlterField( - model_name="savedview", - name="sort_reverse", - field=models.BooleanField(default=False, verbose_name="sort reverse"), - ), - migrations.AlterField( - model_name="savedview", - name="user", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - verbose_name="user", - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - ], - verbose_name="rule type", - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="saved_view", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="filter_rules", - to="documents.savedview", - verbose_name="saved view", - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="value", - field=models.CharField( - blank=True, - max_length=128, - null=True, - verbose_name="value", - ), - ), - migrations.AlterField( - model_name="tag", - name="colour", - field=models.PositiveIntegerField( - choices=[ - (1, "#a6cee3"), - (2, "#1f78b4"), - (3, "#b2df8a"), - (4, "#33a02c"), - (5, "#fb9a99"), - (6, "#e31a1c"), - (7, "#fdbf6f"), - (8, "#ff7f00"), - (9, "#cab2d6"), - (10, "#6a3d9a"), - (11, "#b15928"), - (12, "#000000"), - (13, "#cccccc"), - ], - default=1, - verbose_name="color", - ), - ), - migrations.AlterField( - model_name="tag", - name="is_inbox_tag", - field=models.BooleanField( - default=False, - help_text="Marks this tag as an inbox tag: All newly consumed documents will be tagged with inbox tags.", - verbose_name="is inbox tag", - ), - ), - migrations.AlterField( - model_name="tag", - name="is_insensitive", - field=models.BooleanField(default=True, verbose_name="is insensitive"), - ), - migrations.AlterField( - model_name="tag", - name="match", - field=models.CharField(blank=True, max_length=256, verbose_name="match"), - ), - migrations.AlterField( - model_name="tag", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="tag", - name="name", - field=models.CharField(max_length=128, unique=True, verbose_name="name"), - ), - ] diff --git a/src/documents/migrations/1012_fix_archive_files.py b/src/documents/migrations/1012_fix_archive_files.py deleted file mode 100644 index a97fa7a80..000000000 --- a/src/documents/migrations/1012_fix_archive_files.py +++ /dev/null @@ -1,367 +0,0 @@ -# Generated by Django 3.1.6 on 2021-02-07 22:26 -import datetime -import hashlib -import logging -import os -import shutil -from collections import defaultdict -from pathlib import Path -from time import sleep - -import pathvalidate -from django.conf import settings -from django.db import migrations -from django.db import models -from django.template.defaultfilters import slugify - -logger = logging.getLogger("paperless.migrations") - - -############################################################################### -# This is code copied straight paperless before the change. -############################################################################### -class defaultdictNoStr(defaultdict): - def __str__(self): # pragma: no cover - raise ValueError("Don't use {tags} directly.") - - -def many_to_dictionary(field): # pragma: no cover - # Converts ManyToManyField to dictionary by assuming, that field - # entries contain an _ or - which will be used as a delimiter - mydictionary = dict() - - for index, t in enumerate(field.all()): - # Populate tag names by index - mydictionary[index] = slugify(t.name) - - # Find delimiter - delimiter = t.name.find("_") - - if delimiter == -1: - delimiter = t.name.find("-") - - if delimiter == -1: - continue - - key = t.name[:delimiter] - value = t.name[delimiter + 1 :] - - mydictionary[slugify(key)] = slugify(value) - - return mydictionary - - -def archive_name_from_filename(filename: Path) -> Path: - return Path(filename.stem + ".pdf") - - -def archive_path_old(doc) -> Path: - if doc.filename: - fname = archive_name_from_filename(Path(doc.filename)) - else: - fname = Path(f"{doc.pk:07}.pdf") - - return settings.ARCHIVE_DIR / fname - - -STORAGE_TYPE_GPG = "gpg" - - -def archive_path_new(doc) -> Path | None: - if doc.archive_filename is not None: - return settings.ARCHIVE_DIR / doc.archive_filename - else: - return None - - -def source_path(doc) -> Path: - if doc.filename: - fname = doc.filename - else: - fname = f"{doc.pk:07}{doc.file_type}" - if doc.storage_type == STORAGE_TYPE_GPG: - fname = Path(str(fname) + ".gpg") # pragma: no cover - - return settings.ORIGINALS_DIR / fname - - -def generate_unique_filename(doc, *, archive_filename=False): - if archive_filename: - old_filename = doc.archive_filename - root = settings.ARCHIVE_DIR - else: - old_filename = doc.filename - root = settings.ORIGINALS_DIR - - counter = 0 - - while True: - new_filename = generate_filename( - doc, - counter=counter, - archive_filename=archive_filename, - ) - if new_filename == old_filename: - # still the same as before. - return new_filename - - if (root / new_filename).exists(): - counter += 1 - else: - return new_filename - - -def generate_filename(doc, *, counter=0, append_gpg=True, archive_filename=False): - path = "" - - try: - if settings.FILENAME_FORMAT is not None: - tags = defaultdictNoStr(lambda: slugify(None), many_to_dictionary(doc.tags)) - - tag_list = pathvalidate.sanitize_filename( - ",".join(sorted([tag.name for tag in doc.tags.all()])), - replacement_text="-", - ) - - if doc.correspondent: - correspondent = pathvalidate.sanitize_filename( - doc.correspondent.name, - replacement_text="-", - ) - else: - correspondent = "none" - - if doc.document_type: - document_type = pathvalidate.sanitize_filename( - doc.document_type.name, - replacement_text="-", - ) - else: - document_type = "none" - - path = settings.FILENAME_FORMAT.format( - title=pathvalidate.sanitize_filename(doc.title, replacement_text="-"), - correspondent=correspondent, - document_type=document_type, - created=datetime.date.isoformat(doc.created), - created_year=doc.created.year if doc.created else "none", - created_month=f"{doc.created.month:02}" if doc.created else "none", - created_day=f"{doc.created.day:02}" if doc.created else "none", - added=datetime.date.isoformat(doc.added), - added_year=doc.added.year if doc.added else "none", - added_month=f"{doc.added.month:02}" if doc.added else "none", - added_day=f"{doc.added.day:02}" if doc.added else "none", - tags=tags, - tag_list=tag_list, - ).strip() - - path = path.strip(os.sep) - - except (ValueError, KeyError, IndexError): - logger.warning( - f"Invalid PAPERLESS_FILENAME_FORMAT: " - f"{settings.FILENAME_FORMAT}, falling back to default", - ) - - counter_str = f"_{counter:02}" if counter else "" - - filetype_str = ".pdf" if archive_filename else doc.file_type - - if len(path) > 0: - filename = f"{path}{counter_str}{filetype_str}" - else: - filename = f"{doc.pk:07}{counter_str}{filetype_str}" - - # Append .gpg for encrypted files - if append_gpg and doc.storage_type == STORAGE_TYPE_GPG: - filename += ".gpg" - - return filename - - -############################################################################### -# This code performs bidirection archive file transformation. -############################################################################### - - -def parse_wrapper(parser, path, mime_type, file_name): - # this is here so that I can mock this out for testing. - parser.parse(path, mime_type, file_name) - - -def create_archive_version(doc, retry_count=3): - from documents.parsers import DocumentParser - from documents.parsers import ParseError - from documents.parsers import get_parser_class_for_mime_type - - logger.info(f"Regenerating archive document for document ID:{doc.id}") - parser_class = get_parser_class_for_mime_type(doc.mime_type) - for try_num in range(retry_count): - parser: DocumentParser = parser_class(None, None) - try: - parse_wrapper( - parser, - source_path(doc), - doc.mime_type, - Path(doc.filename).name, - ) - doc.content = parser.get_text() - - if parser.get_archive_path() and Path(parser.get_archive_path()).is_file(): - doc.archive_filename = generate_unique_filename( - doc, - archive_filename=True, - ) - with Path(parser.get_archive_path()).open("rb") as f: - doc.archive_checksum = hashlib.md5(f.read()).hexdigest() - archive_path_new(doc).parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(parser.get_archive_path(), archive_path_new(doc)) - else: - doc.archive_checksum = None - logger.error( - f"Parser did not return an archive document for document " - f"ID:{doc.id}. Removing archive document.", - ) - doc.save() - return - except ParseError: - if try_num + 1 == retry_count: - logger.exception( - f"Unable to regenerate archive document for ID:{doc.id}. You " - f"need to invoke the document_archiver management command " - f"manually for that document.", - ) - doc.archive_checksum = None - doc.save() - return - else: - # This is mostly here for the tika parser in docker - # environments. The servers for parsing need to come up first, - # and the docker setup doesn't ensure that tika is running - # before attempting migrations. - logger.error("Parse error, will try again in 5 seconds...") - sleep(5) - finally: - parser.cleanup() - - -def move_old_to_new_locations(apps, schema_editor): - Document = apps.get_model("documents", "Document") - - affected_document_ids = set() - - old_archive_path_to_id = {} - - # check for documents that have incorrect archive versions - for doc in Document.objects.filter(archive_checksum__isnull=False): - old_path = archive_path_old(doc) - - if old_path in old_archive_path_to_id: - affected_document_ids.add(doc.id) - affected_document_ids.add(old_archive_path_to_id[old_path]) - else: - old_archive_path_to_id[old_path] = doc.id - - # check that archive files of all unaffected documents are in place - for doc in Document.objects.filter(archive_checksum__isnull=False): - old_path = archive_path_old(doc) - if doc.id not in affected_document_ids and not old_path.is_file(): - raise ValueError( - f"Archived document ID:{doc.id} does not exist at: {old_path}", - ) - - # check that we can regenerate affected archive versions - for doc_id in affected_document_ids: - from documents.parsers import get_parser_class_for_mime_type - - doc = Document.objects.get(id=doc_id) - parser_class = get_parser_class_for_mime_type(doc.mime_type) - if not parser_class: - raise ValueError( - f"Document ID:{doc.id} has an invalid archived document, " - f"but no parsers are available. Cannot migrate.", - ) - - for doc in Document.objects.filter(archive_checksum__isnull=False): - if doc.id in affected_document_ids: - old_path = archive_path_old(doc) - # remove affected archive versions - if old_path.is_file(): - logger.debug(f"Removing {old_path}") - old_path.unlink() - else: - # Set archive path for unaffected files - doc.archive_filename = archive_name_from_filename(Path(doc.filename)) - Document.objects.filter(id=doc.id).update( - archive_filename=doc.archive_filename, - ) - - # regenerate archive documents - for doc_id in affected_document_ids: - doc = Document.objects.get(id=doc_id) - create_archive_version(doc) - - -def move_new_to_old_locations(apps, schema_editor): - Document = apps.get_model("documents", "Document") - - old_archive_paths = set() - - for doc in Document.objects.filter(archive_checksum__isnull=False): - new_archive_path = archive_path_new(doc) - old_archive_path = archive_path_old(doc) - if old_archive_path in old_archive_paths: - raise ValueError( - f"Cannot migrate: Archive file name {old_archive_path} of " - f"document {doc.filename} would clash with another archive " - f"filename.", - ) - old_archive_paths.add(old_archive_path) - if new_archive_path != old_archive_path and old_archive_path.is_file(): - raise ValueError( - f"Cannot migrate: Cannot move {new_archive_path} to " - f"{old_archive_path}: file already exists.", - ) - - for doc in Document.objects.filter(archive_checksum__isnull=False): - new_archive_path = archive_path_new(doc) - old_archive_path = archive_path_old(doc) - if new_archive_path != old_archive_path: - logger.debug(f"Moving {new_archive_path} to {old_archive_path}") - shutil.move(new_archive_path, old_archive_path) - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1011_auto_20210101_2340"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="archive_filename", - field=models.FilePathField( - default=None, - editable=False, - help_text="Current archive filename in storage", - max_length=1024, - null=True, - unique=True, - verbose_name="archive filename", - ), - ), - migrations.AlterField( - model_name="document", - name="filename", - field=models.FilePathField( - default=None, - editable=False, - help_text="Current filename in storage", - max_length=1024, - null=True, - unique=True, - verbose_name="filename", - ), - ), - migrations.RunPython(move_old_to_new_locations, move_new_to_old_locations), - ] diff --git a/src/documents/migrations/1013_migrate_tag_colour.py b/src/documents/migrations/1013_migrate_tag_colour.py deleted file mode 100644 index 6cae10898..000000000 --- a/src/documents/migrations/1013_migrate_tag_colour.py +++ /dev/null @@ -1,74 +0,0 @@ -# Generated by Django 3.1.4 on 2020-12-02 21:43 - -from django.db import migrations -from django.db import models - -COLOURS_OLD = { - 1: "#a6cee3", - 2: "#1f78b4", - 3: "#b2df8a", - 4: "#33a02c", - 5: "#fb9a99", - 6: "#e31a1c", - 7: "#fdbf6f", - 8: "#ff7f00", - 9: "#cab2d6", - 10: "#6a3d9a", - 11: "#b15928", - 12: "#000000", - 13: "#cccccc", -} - - -def forward(apps, schema_editor): - Tag = apps.get_model("documents", "Tag") - - for tag in Tag.objects.all(): - colour_old_id = tag.colour_old - rgb = COLOURS_OLD[colour_old_id] - tag.color = rgb - tag.save() - - -def reverse(apps, schema_editor): - Tag = apps.get_model("documents", "Tag") - - def _get_colour_id(rdb): - for idx, rdbx in COLOURS_OLD.items(): - if rdbx == rdb: - return idx - # Return colour 1 if we can't match anything - return 1 - - for tag in Tag.objects.all(): - colour_id = _get_colour_id(tag.color) - tag.colour_old = colour_id - tag.save() - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1012_fix_archive_files"), - ] - - operations = [ - migrations.RenameField( - model_name="tag", - old_name="colour", - new_name="colour_old", - ), - migrations.AddField( - model_name="tag", - name="color", - field=models.CharField( - default="#a6cee3", - max_length=7, - verbose_name="color", - ), - ), - migrations.RunPython(forward, reverse), - migrations.RemoveField( - model_name="tag", - name="colour_old", - ), - ] diff --git a/src/documents/migrations/1014_auto_20210228_1614.py b/src/documents/migrations/1014_auto_20210228_1614.py deleted file mode 100644 index 5785bcb53..000000000 --- a/src/documents/migrations/1014_auto_20210228_1614.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 3.1.7 on 2021-02-28 15:14 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1013_migrate_tag_colour"), - ] - - operations = [ - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - ], - verbose_name="rule type", - ), - ), - ] diff --git a/src/documents/migrations/1015_remove_null_characters.py b/src/documents/migrations/1015_remove_null_characters.py deleted file mode 100644 index 9872b3a75..000000000 --- a/src/documents/migrations/1015_remove_null_characters.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 3.1.7 on 2021-04-04 18:28 -import logging - -from django.db import migrations - -logger = logging.getLogger("paperless.migrations") - - -def remove_null_characters(apps, schema_editor): - Document = apps.get_model("documents", "Document") - - for doc in Document.objects.all(): - content: str = doc.content - if "\0" in content: - logger.info(f"Removing null characters from document {doc}...") - doc.content = content.replace("\0", " ") - doc.save() - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1014_auto_20210228_1614"), - ] - - operations = [ - migrations.RunPython(remove_null_characters, migrations.RunPython.noop), - ] diff --git a/src/documents/migrations/1016_auto_20210317_1351.py b/src/documents/migrations/1016_auto_20210317_1351.py deleted file mode 100644 index 67147fd4c..000000000 --- a/src/documents/migrations/1016_auto_20210317_1351.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 3.1.7 on 2021-03-17 12:51 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1015_remove_null_characters"), - ] - - operations = [ - migrations.AlterField( - model_name="savedview", - name="sort_field", - field=models.CharField( - blank=True, - max_length=128, - null=True, - verbose_name="sort field", - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - ], - verbose_name="rule type", - ), - ), - ] diff --git a/src/documents/migrations/1016_auto_20210317_1351_squashed_1020_merge_20220518_1839.py b/src/documents/migrations/1016_auto_20210317_1351_squashed_1020_merge_20220518_1839.py deleted file mode 100644 index a7f92e931..000000000 --- a/src/documents/migrations/1016_auto_20210317_1351_squashed_1020_merge_20220518_1839.py +++ /dev/null @@ -1,190 +0,0 @@ -# Generated by Django 4.2.13 on 2024-06-28 18:09 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - replaces = [ - ("documents", "1016_auto_20210317_1351"), - ("documents", "1017_alter_savedviewfilterrule_rule_type"), - ("documents", "1018_alter_savedviewfilterrule_value"), - ("documents", "1019_uisettings"), - ("documents", "1019_storagepath_document_storage_path"), - ("documents", "1020_merge_20220518_1839"), - ] - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("documents", "1015_remove_null_characters"), - ] - - operations = [ - migrations.AlterField( - model_name="savedview", - name="sort_field", - field=models.CharField( - blank=True, - max_length=128, - null=True, - verbose_name="sort field", - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - ], - verbose_name="rule type", - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - ], - verbose_name="rule type", - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="value", - field=models.CharField( - blank=True, - max_length=255, - null=True, - verbose_name="value", - ), - ), - migrations.CreateModel( - name="UiSettings", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("settings", models.JSONField(null=True)), - ( - "user", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="ui_settings", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.CreateModel( - name="StoragePath", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "name", - models.CharField(max_length=128, unique=True, verbose_name="name"), - ), - ( - "match", - models.CharField(blank=True, max_length=256, verbose_name="match"), - ), - ( - "matching_algorithm", - models.PositiveIntegerField( - choices=[ - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - ( - "is_insensitive", - models.BooleanField(default=True, verbose_name="is insensitive"), - ), - ("path", models.CharField(max_length=512, verbose_name="path")), - ], - options={ - "verbose_name": "storage path", - "verbose_name_plural": "storage paths", - "ordering": ("name",), - }, - ), - migrations.AddField( - model_name="document", - name="storage_path", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="documents", - to="documents.storagepath", - verbose_name="storage path", - ), - ), - ] diff --git a/src/documents/migrations/1017_alter_savedviewfilterrule_rule_type.py b/src/documents/migrations/1017_alter_savedviewfilterrule_rule_type.py deleted file mode 100644 index ab18f1bc1..000000000 --- a/src/documents/migrations/1017_alter_savedviewfilterrule_rule_type.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 3.2.12 on 2022-03-17 11:59 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1016_auto_20210317_1351"), - ] - - operations = [ - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - ], - verbose_name="rule type", - ), - ), - ] diff --git a/src/documents/migrations/1018_alter_savedviewfilterrule_value.py b/src/documents/migrations/1018_alter_savedviewfilterrule_value.py deleted file mode 100644 index 95ef4861d..000000000 --- a/src/documents/migrations/1018_alter_savedviewfilterrule_value.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.0.3 on 2022-04-01 22:50 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1017_alter_savedviewfilterrule_rule_type"), - ] - - operations = [ - migrations.AlterField( - model_name="savedviewfilterrule", - name="value", - field=models.CharField( - blank=True, - max_length=255, - null=True, - verbose_name="value", - ), - ), - ] diff --git a/src/documents/migrations/1019_storagepath_document_storage_path.py b/src/documents/migrations/1019_storagepath_document_storage_path.py deleted file mode 100644 index b09941bf5..000000000 --- a/src/documents/migrations/1019_storagepath_document_storage_path.py +++ /dev/null @@ -1,73 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-02 15:56 - -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1018_alter_savedviewfilterrule_value"), - ] - - operations = [ - migrations.CreateModel( - name="StoragePath", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "name", - models.CharField(max_length=128, unique=True, verbose_name="name"), - ), - ( - "match", - models.CharField(blank=True, max_length=256, verbose_name="match"), - ), - ( - "matching_algorithm", - models.PositiveIntegerField( - choices=[ - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - ( - "is_insensitive", - models.BooleanField(default=True, verbose_name="is insensitive"), - ), - ("path", models.CharField(max_length=512, verbose_name="path")), - ], - options={ - "verbose_name": "storage path", - "verbose_name_plural": "storage paths", - "ordering": ("name",), - }, - ), - migrations.AddField( - model_name="document", - name="storage_path", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="documents", - to="documents.storagepath", - verbose_name="storage path", - ), - ), - ] diff --git a/src/documents/migrations/1019_uisettings.py b/src/documents/migrations/1019_uisettings.py deleted file mode 100644 index e84138077..000000000 --- a/src/documents/migrations/1019_uisettings.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-07 05:10 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("documents", "1018_alter_savedviewfilterrule_value"), - ] - - operations = [ - migrations.CreateModel( - name="UiSettings", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("settings", models.JSONField(null=True)), - ( - "user", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="ui_settings", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - ] diff --git a/src/documents/migrations/1020_merge_20220518_1839.py b/src/documents/migrations/1020_merge_20220518_1839.py deleted file mode 100644 index a766aaa20..000000000 --- a/src/documents/migrations/1020_merge_20220518_1839.py +++ /dev/null @@ -1,12 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-18 18:39 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1019_storagepath_document_storage_path"), - ("documents", "1019_uisettings"), - ] - - operations = [] diff --git a/src/documents/migrations/1021_webp_thumbnail_conversion.py b/src/documents/migrations/1021_webp_thumbnail_conversion.py deleted file mode 100644 index 50b12b156..000000000 --- a/src/documents/migrations/1021_webp_thumbnail_conversion.py +++ /dev/null @@ -1,104 +0,0 @@ -# Generated by Django 4.0.5 on 2022-06-11 15:40 -import logging -import multiprocessing.pool -import shutil -import tempfile -import time -from pathlib import Path - -from django.conf import settings -from django.db import migrations - -from documents.parsers import run_convert - -logger = logging.getLogger("paperless.migrations") - - -def _do_convert(work_package): - existing_thumbnail, converted_thumbnail = work_package - try: - logger.info(f"Converting thumbnail: {existing_thumbnail}") - - # Run actual conversion - run_convert( - density=300, - scale="500x5000>", - alpha="remove", - strip=True, - trim=False, - auto_orient=True, - input_file=f"{existing_thumbnail}[0]", - output_file=str(converted_thumbnail), - ) - - # Copy newly created thumbnail to thumbnail directory - shutil.copy(converted_thumbnail, existing_thumbnail.parent) - - # Remove the PNG version - existing_thumbnail.unlink() - - logger.info( - "Conversion to WebP completed, " - f"replaced {existing_thumbnail.name} with {converted_thumbnail.name}", - ) - - except Exception as e: - logger.error(f"Error converting thumbnail (existing file unchanged): {e}") - - -def _convert_thumbnails_to_webp(apps, schema_editor): - start = time.time() - - with tempfile.TemporaryDirectory() as tempdir: - work_packages = [] - - for file in Path(settings.THUMBNAIL_DIR).glob("*.png"): - existing_thumbnail = file.resolve() - - # Change the existing filename suffix from png to webp - converted_thumbnail_name = existing_thumbnail.with_suffix( - ".webp", - ).name - - # Create the expected output filename in the tempdir - converted_thumbnail = ( - Path(tempdir) / Path(converted_thumbnail_name) - ).resolve() - - # Package up the necessary info - work_packages.append( - (existing_thumbnail, converted_thumbnail), - ) - - if work_packages: - logger.info( - "\n\n" - " This is a one-time only migration to convert thumbnails for all of your\n" - " documents into WebP format. If you have a lot of documents though, \n" - " this may take a while, so a coffee break may be in order." - "\n", - ) - - with multiprocessing.pool.Pool( - processes=min(multiprocessing.cpu_count(), 4), - maxtasksperchild=4, - ) as pool: - pool.map(_do_convert, work_packages) - - end = time.time() - duration = end - start - - logger.info(f"Conversion completed in {duration:.3f}s") - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1020_merge_20220518_1839"), - ] - - operations = [ - migrations.RunPython( - code=_convert_thumbnails_to_webp, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/src/documents/migrations/1022_paperlesstask.py b/src/documents/migrations/1022_paperlesstask.py deleted file mode 100644 index c7b3f7744..000000000 --- a/src/documents/migrations/1022_paperlesstask.py +++ /dev/null @@ -1,52 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-23 07:14 - -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1021_webp_thumbnail_conversion"), - ] - - operations = [ - migrations.CreateModel( - name="PaperlessTask", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("task_id", models.CharField(max_length=128)), - ("name", models.CharField(max_length=256, null=True)), - ( - "created", - models.DateTimeField(auto_now=True, verbose_name="created"), - ), - ( - "started", - models.DateTimeField(null=True, verbose_name="started"), - ), - ("acknowledged", models.BooleanField(default=False)), - ( - "attempted_task", - models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="attempted_task", - # This is a dummy field, 1026 will fix up the column - # This manual change is required, as django doesn't django doesn't really support - # removing an app which has migration deps like this - to="documents.document", - ), - ), - ], - ), - ] diff --git a/src/documents/migrations/1022_paperlesstask_squashed_1036_alter_savedviewfilterrule_rule_type.py b/src/documents/migrations/1022_paperlesstask_squashed_1036_alter_savedviewfilterrule_rule_type.py deleted file mode 100644 index dc73a51a2..000000000 --- a/src/documents/migrations/1022_paperlesstask_squashed_1036_alter_savedviewfilterrule_rule_type.py +++ /dev/null @@ -1,668 +0,0 @@ -# Generated by Django 4.2.13 on 2024-06-28 18:10 - -import django.core.validators -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - replaces = [ - ("documents", "1022_paperlesstask"), - ("documents", "1023_add_comments"), - ("documents", "1024_document_original_filename"), - ("documents", "1025_alter_savedviewfilterrule_rule_type"), - ("documents", "1026_transition_to_celery"), - ("documents", "1027_remove_paperlesstask_attempted_task_and_more"), - ("documents", "1028_remove_paperlesstask_task_args_and_more"), - ("documents", "1029_alter_document_archive_serial_number"), - ("documents", "1030_alter_paperlesstask_task_file_name"), - ("documents", "1031_remove_savedview_user_correspondent_owner_and_more"), - ("documents", "1032_alter_correspondent_matching_algorithm_and_more"), - ("documents", "1033_alter_documenttype_options_alter_tag_options_and_more"), - ("documents", "1034_alter_savedviewfilterrule_rule_type"), - ("documents", "1035_rename_comment_note"), - ("documents", "1036_alter_savedviewfilterrule_rule_type"), - ] - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("django_celery_results", "0011_taskresult_periodic_task_name"), - ("documents", "1021_webp_thumbnail_conversion"), - ] - - operations = [ - migrations.CreateModel( - name="Comment", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "comment", - models.TextField( - blank=True, - help_text="Comment for the document", - verbose_name="content", - ), - ), - ( - "created", - models.DateTimeField( - db_index=True, - default=django.utils.timezone.now, - verbose_name="created", - ), - ), - ( - "document", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="documents", - to="documents.document", - verbose_name="document", - ), - ), - ( - "user", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="users", - to=settings.AUTH_USER_MODEL, - verbose_name="user", - ), - ), - ], - options={ - "verbose_name": "comment", - "verbose_name_plural": "comments", - "ordering": ("created",), - }, - ), - migrations.AddField( - model_name="document", - name="original_filename", - field=models.CharField( - default=None, - editable=False, - help_text="The original name of the file when it was uploaded", - max_length=1024, - null=True, - verbose_name="original filename", - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - (23, "ASN greater than"), - (24, "ASN less than"), - (25, "storage path is"), - ], - verbose_name="rule type", - ), - ), - migrations.CreateModel( - name="PaperlessTask", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("task_id", models.CharField(max_length=128)), - ("acknowledged", models.BooleanField(default=False)), - ( - "attempted_task", - models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="attempted_task", - to="django_celery_results.taskresult", - ), - ), - ], - ), - migrations.RunSQL( - sql="DROP TABLE IF EXISTS django_q_ormq", - reverse_sql="", - ), - migrations.RunSQL( - sql="DROP TABLE IF EXISTS django_q_schedule", - reverse_sql="", - ), - migrations.RunSQL( - sql="DROP TABLE IF EXISTS django_q_task", - reverse_sql="", - ), - migrations.RemoveField( - model_name="paperlesstask", - name="attempted_task", - ), - migrations.AddField( - model_name="paperlesstask", - name="date_created", - field=models.DateTimeField( - default=django.utils.timezone.now, - help_text="Datetime field when the task result was created in UTC", - null=True, - verbose_name="Created DateTime", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="date_done", - field=models.DateTimeField( - default=None, - help_text="Datetime field when the task was completed in UTC", - null=True, - verbose_name="Completed DateTime", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="date_started", - field=models.DateTimeField( - default=None, - help_text="Datetime field when the task was started in UTC", - null=True, - verbose_name="Started DateTime", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="result", - field=models.TextField( - default=None, - help_text="The data returned by the task", - null=True, - verbose_name="Result Data", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="status", - field=models.CharField( - choices=[ - ("FAILURE", "FAILURE"), - ("PENDING", "PENDING"), - ("RECEIVED", "RECEIVED"), - ("RETRY", "RETRY"), - ("REVOKED", "REVOKED"), - ("STARTED", "STARTED"), - ("SUCCESS", "SUCCESS"), - ], - default="PENDING", - help_text="Current state of the task being run", - max_length=30, - verbose_name="Task State", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="task_name", - field=models.CharField( - help_text="Name of the Task which was run", - max_length=255, - null=True, - verbose_name="Task Name", - ), - ), - migrations.AlterField( - model_name="paperlesstask", - name="acknowledged", - field=models.BooleanField( - default=False, - help_text="If the task is acknowledged via the frontend or API", - verbose_name="Acknowledged", - ), - ), - migrations.AlterField( - model_name="paperlesstask", - name="task_id", - field=models.CharField( - help_text="Celery ID for the Task that was run", - max_length=255, - unique=True, - verbose_name="Task ID", - ), - ), - migrations.AlterField( - model_name="document", - name="archive_serial_number", - field=models.PositiveIntegerField( - blank=True, - db_index=True, - help_text="The position of this document in your physical document archive.", - null=True, - unique=True, - validators=[ - django.core.validators.MaxValueValidator(4294967295), - django.core.validators.MinValueValidator(0), - ], - verbose_name="archive serial number", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="task_file_name", - field=models.CharField( - help_text="Name of the file which the Task was run for", - max_length=255, - null=True, - verbose_name="Task Filename", - ), - ), - migrations.RenameField( - model_name="savedview", - old_name="user", - new_name="owner", - ), - migrations.AlterField( - model_name="savedview", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="correspondent", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="document", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="documenttype", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="storagepath", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="tag", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AlterField( - model_name="correspondent", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (0, "None"), - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="documenttype", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (0, "None"), - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="storagepath", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (0, "None"), - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="tag", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (0, "None"), - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterModelOptions( - name="documenttype", - options={ - "ordering": ("name",), - "verbose_name": "document type", - "verbose_name_plural": "document types", - }, - ), - migrations.AlterModelOptions( - name="tag", - options={ - "ordering": ("name",), - "verbose_name": "tag", - "verbose_name_plural": "tags", - }, - ), - migrations.AlterField( - model_name="correspondent", - name="name", - field=models.CharField(max_length=128, verbose_name="name"), - ), - migrations.AlterField( - model_name="documenttype", - name="name", - field=models.CharField(max_length=128, verbose_name="name"), - ), - migrations.AlterField( - model_name="storagepath", - name="name", - field=models.CharField(max_length=128, verbose_name="name"), - ), - migrations.AlterField( - model_name="tag", - name="name", - field=models.CharField(max_length=128, verbose_name="name"), - ), - migrations.AddConstraint( - model_name="correspondent", - constraint=models.UniqueConstraint( - fields=("name", "owner"), - name="documents_correspondent_unique_name_owner", - ), - ), - migrations.AddConstraint( - model_name="correspondent", - constraint=models.UniqueConstraint( - condition=models.Q(("owner__isnull", True)), - fields=("name",), - name="documents_correspondent_name_uniq", - ), - ), - migrations.AddConstraint( - model_name="documenttype", - constraint=models.UniqueConstraint( - fields=("name", "owner"), - name="documents_documenttype_unique_name_owner", - ), - ), - migrations.AddConstraint( - model_name="documenttype", - constraint=models.UniqueConstraint( - condition=models.Q(("owner__isnull", True)), - fields=("name",), - name="documents_documenttype_name_uniq", - ), - ), - migrations.AddConstraint( - model_name="storagepath", - constraint=models.UniqueConstraint( - fields=("name", "owner"), - name="documents_storagepath_unique_name_owner", - ), - ), - migrations.AddConstraint( - model_name="storagepath", - constraint=models.UniqueConstraint( - condition=models.Q(("owner__isnull", True)), - fields=("name",), - name="documents_storagepath_name_uniq", - ), - ), - migrations.AddConstraint( - model_name="tag", - constraint=models.UniqueConstraint( - fields=("name", "owner"), - name="documents_tag_unique_name_owner", - ), - ), - migrations.AddConstraint( - model_name="tag", - constraint=models.UniqueConstraint( - condition=models.Q(("owner__isnull", True)), - fields=("name",), - name="documents_tag_name_uniq", - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - (23, "ASN greater than"), - (24, "ASN less than"), - (25, "storage path is"), - (26, "has correspondent in"), - (27, "does not have correspondent in"), - (28, "has document type in"), - (29, "does not have document type in"), - (30, "has storage path in"), - (31, "does not have storage path in"), - ], - verbose_name="rule type", - ), - ), - migrations.RenameModel( - old_name="Comment", - new_name="Note", - ), - migrations.RenameField( - model_name="note", - old_name="comment", - new_name="note", - ), - migrations.AlterModelOptions( - name="note", - options={ - "ordering": ("created",), - "verbose_name": "note", - "verbose_name_plural": "notes", - }, - ), - migrations.AlterField( - model_name="note", - name="document", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="notes", - to="documents.document", - verbose_name="document", - ), - ), - migrations.AlterField( - model_name="note", - name="note", - field=models.TextField( - blank=True, - help_text="Note for the document", - verbose_name="content", - ), - ), - migrations.AlterField( - model_name="note", - name="user", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="notes", - to=settings.AUTH_USER_MODEL, - verbose_name="user", - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - (23, "ASN greater than"), - (24, "ASN less than"), - (25, "storage path is"), - (26, "has correspondent in"), - (27, "does not have correspondent in"), - (28, "has document type in"), - (29, "does not have document type in"), - (30, "has storage path in"), - (31, "does not have storage path in"), - (32, "owner is"), - (33, "has owner in"), - (34, "does not have owner"), - (35, "does not have owner in"), - ], - verbose_name="rule type", - ), - ), - ] diff --git a/src/documents/migrations/1023_add_comments.py b/src/documents/migrations/1023_add_comments.py deleted file mode 100644 index 0b26739bc..000000000 --- a/src/documents/migrations/1023_add_comments.py +++ /dev/null @@ -1,70 +0,0 @@ -import django.utils.timezone -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1022_paperlesstask"), - ] - - operations = [ - migrations.CreateModel( - name="Comment", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "comment", - models.TextField( - blank=True, - help_text="Comment for the document", - verbose_name="content", - ), - ), - ( - "created", - models.DateTimeField( - db_index=True, - default=django.utils.timezone.now, - verbose_name="created", - ), - ), - ( - "document", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="documents", - to="documents.document", - verbose_name="document", - ), - ), - ( - "user", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="users", - to=settings.AUTH_USER_MODEL, - verbose_name="user", - ), - ), - ], - options={ - "verbose_name": "comment", - "verbose_name_plural": "comments", - "ordering": ("created",), - }, - ), - ] diff --git a/src/documents/migrations/1024_document_original_filename.py b/src/documents/migrations/1024_document_original_filename.py deleted file mode 100644 index 05be7269e..000000000 --- a/src/documents/migrations/1024_document_original_filename.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 4.0.6 on 2022-07-25 06:34 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1023_add_comments"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="original_filename", - field=models.CharField( - default=None, - editable=False, - help_text="The original name of the file when it was uploaded", - max_length=1024, - null=True, - verbose_name="original filename", - ), - ), - ] diff --git a/src/documents/migrations/1025_alter_savedviewfilterrule_rule_type.py b/src/documents/migrations/1025_alter_savedviewfilterrule_rule_type.py deleted file mode 100644 index a2deb9579..000000000 --- a/src/documents/migrations/1025_alter_savedviewfilterrule_rule_type.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 4.0.5 on 2022-08-26 16:49 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1024_document_original_filename"), - ] - - operations = [ - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - (23, "ASN greater than"), - (24, "ASN less than"), - (25, "storage path is"), - ], - verbose_name="rule type", - ), - ), - ] diff --git a/src/documents/migrations/1026_transition_to_celery.py b/src/documents/migrations/1026_transition_to_celery.py deleted file mode 100644 index 227188d22..000000000 --- a/src/documents/migrations/1026_transition_to_celery.py +++ /dev/null @@ -1,60 +0,0 @@ -# Generated by Django 4.1.1 on 2022-09-27 19:31 - -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("django_celery_results", "0011_taskresult_periodic_task_name"), - ("documents", "1025_alter_savedviewfilterrule_rule_type"), - ] - - operations = [ - migrations.RemoveField( - model_name="paperlesstask", - name="created", - ), - migrations.RemoveField( - model_name="paperlesstask", - name="name", - ), - migrations.RemoveField( - model_name="paperlesstask", - name="started", - ), - # Remove the field from the model - migrations.RemoveField( - model_name="paperlesstask", - name="attempted_task", - ), - # Add the field back, pointing to the correct model - # This resolves a problem where the temporary change in 1022 - # results in a type mismatch - migrations.AddField( - model_name="paperlesstask", - name="attempted_task", - field=models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="attempted_task", - to="django_celery_results.taskresult", - ), - ), - # Drop the django-q tables entirely - # Must be done last or there could be references here - migrations.RunSQL( - "DROP TABLE IF EXISTS django_q_ormq", - reverse_sql=migrations.RunSQL.noop, - ), - migrations.RunSQL( - "DROP TABLE IF EXISTS django_q_schedule", - reverse_sql=migrations.RunSQL.noop, - ), - migrations.RunSQL( - "DROP TABLE IF EXISTS django_q_task", - reverse_sql=migrations.RunSQL.noop, - ), - ] diff --git a/src/documents/migrations/1027_remove_paperlesstask_attempted_task_and_more.py b/src/documents/migrations/1027_remove_paperlesstask_attempted_task_and_more.py deleted file mode 100644 index c169c3096..000000000 --- a/src/documents/migrations/1027_remove_paperlesstask_attempted_task_and_more.py +++ /dev/null @@ -1,134 +0,0 @@ -# Generated by Django 4.1.2 on 2022-10-17 16:31 - -import django.utils.timezone -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1026_transition_to_celery"), - ] - - operations = [ - migrations.RemoveField( - model_name="paperlesstask", - name="attempted_task", - ), - migrations.AddField( - model_name="paperlesstask", - name="date_created", - field=models.DateTimeField( - default=django.utils.timezone.now, - help_text="Datetime field when the task result was created in UTC", - null=True, - verbose_name="Created DateTime", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="date_done", - field=models.DateTimeField( - default=None, - help_text="Datetime field when the task was completed in UTC", - null=True, - verbose_name="Completed DateTime", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="date_started", - field=models.DateTimeField( - default=None, - help_text="Datetime field when the task was started in UTC", - null=True, - verbose_name="Started DateTime", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="result", - field=models.TextField( - default=None, - help_text="The data returned by the task", - null=True, - verbose_name="Result Data", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="status", - field=models.CharField( - choices=[ - ("FAILURE", "FAILURE"), - ("PENDING", "PENDING"), - ("RECEIVED", "RECEIVED"), - ("RETRY", "RETRY"), - ("REVOKED", "REVOKED"), - ("STARTED", "STARTED"), - ("SUCCESS", "SUCCESS"), - ], - default="PENDING", - help_text="Current state of the task being run", - max_length=30, - verbose_name="Task State", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="task_args", - field=models.JSONField( - help_text="JSON representation of the positional arguments used with the task", - null=True, - verbose_name="Task Positional Arguments", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="task_file_name", - field=models.CharField( - help_text="Name of the file which the Task was run for", - max_length=255, - null=True, - verbose_name="Task Name", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="task_kwargs", - field=models.JSONField( - help_text="JSON representation of the named arguments used with the task", - null=True, - verbose_name="Task Named Arguments", - ), - ), - migrations.AddField( - model_name="paperlesstask", - name="task_name", - field=models.CharField( - help_text="Name of the Task which was run", - max_length=255, - null=True, - verbose_name="Task Name", - ), - ), - migrations.AlterField( - model_name="paperlesstask", - name="acknowledged", - field=models.BooleanField( - default=False, - help_text="If the task is acknowledged via the frontend or API", - verbose_name="Acknowledged", - ), - ), - migrations.AlterField( - model_name="paperlesstask", - name="task_id", - field=models.CharField( - help_text="Celery ID for the Task that was run", - max_length=255, - unique=True, - verbose_name="Task ID", - ), - ), - ] diff --git a/src/documents/migrations/1028_remove_paperlesstask_task_args_and_more.py b/src/documents/migrations/1028_remove_paperlesstask_task_args_and_more.py deleted file mode 100644 index 6e03c124b..000000000 --- a/src/documents/migrations/1028_remove_paperlesstask_task_args_and_more.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.1.3 on 2022-11-22 17:50 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1027_remove_paperlesstask_attempted_task_and_more"), - ] - - operations = [ - migrations.RemoveField( - model_name="paperlesstask", - name="task_args", - ), - migrations.RemoveField( - model_name="paperlesstask", - name="task_kwargs", - ), - ] diff --git a/src/documents/migrations/1029_alter_document_archive_serial_number.py b/src/documents/migrations/1029_alter_document_archive_serial_number.py deleted file mode 100644 index 57848b2dc..000000000 --- a/src/documents/migrations/1029_alter_document_archive_serial_number.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 4.1.4 on 2023-01-24 17:56 - -import django.core.validators -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1028_remove_paperlesstask_task_args_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="document", - name="archive_serial_number", - field=models.PositiveIntegerField( - blank=True, - db_index=True, - help_text="The position of this document in your physical document archive.", - null=True, - unique=True, - validators=[ - django.core.validators.MaxValueValidator(4294967295), - django.core.validators.MinValueValidator(0), - ], - verbose_name="archive serial number", - ), - ), - ] diff --git a/src/documents/migrations/1030_alter_paperlesstask_task_file_name.py b/src/documents/migrations/1030_alter_paperlesstask_task_file_name.py deleted file mode 100644 index 37e918bee..000000000 --- a/src/documents/migrations/1030_alter_paperlesstask_task_file_name.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.1.5 on 2023-02-03 21:53 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1029_alter_document_archive_serial_number"), - ] - - operations = [ - migrations.AlterField( - model_name="paperlesstask", - name="task_file_name", - field=models.CharField( - help_text="Name of the file which the Task was run for", - max_length=255, - null=True, - verbose_name="Task Filename", - ), - ), - ] diff --git a/src/documents/migrations/1031_remove_savedview_user_correspondent_owner_and_more.py b/src/documents/migrations/1031_remove_savedview_user_correspondent_owner_and_more.py deleted file mode 100644 index 56e4355ef..000000000 --- a/src/documents/migrations/1031_remove_savedview_user_correspondent_owner_and_more.py +++ /dev/null @@ -1,87 +0,0 @@ -# Generated by Django 4.1.4 on 2022-02-03 04:24 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("documents", "1030_alter_paperlesstask_task_file_name"), - ] - - operations = [ - migrations.RenameField( - model_name="savedview", - old_name="user", - new_name="owner", - ), - migrations.AlterField( - model_name="savedview", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="correspondent", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="document", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="documenttype", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="storagepath", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="tag", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - ] diff --git a/src/documents/migrations/1032_alter_correspondent_matching_algorithm_and_more.py b/src/documents/migrations/1032_alter_correspondent_matching_algorithm_and_more.py deleted file mode 100644 index 3d1c5658a..000000000 --- a/src/documents/migrations/1032_alter_correspondent_matching_algorithm_and_more.py +++ /dev/null @@ -1,81 +0,0 @@ -# Generated by Django 4.1.7 on 2023-02-22 00:45 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1031_remove_savedview_user_correspondent_owner_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="correspondent", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (0, "None"), - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="documenttype", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (0, "None"), - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="storagepath", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (0, "None"), - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - migrations.AlterField( - model_name="tag", - name="matching_algorithm", - field=models.PositiveIntegerField( - choices=[ - (0, "None"), - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - (6, "Automatic"), - ], - default=1, - verbose_name="matching algorithm", - ), - ), - ] diff --git a/src/documents/migrations/1034_alter_savedviewfilterrule_rule_type.py b/src/documents/migrations/1034_alter_savedviewfilterrule_rule_type.py deleted file mode 100644 index b648ac839..000000000 --- a/src/documents/migrations/1034_alter_savedviewfilterrule_rule_type.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 4.1.5 on 2023-03-15 07:10 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1033_alter_documenttype_options_alter_tag_options_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - (23, "ASN greater than"), - (24, "ASN less than"), - (25, "storage path is"), - (26, "has correspondent in"), - (27, "does not have correspondent in"), - (28, "has document type in"), - (29, "does not have document type in"), - (30, "has storage path in"), - (31, "does not have storage path in"), - ], - verbose_name="rule type", - ), - ), - ] diff --git a/src/documents/migrations/1035_rename_comment_note.py b/src/documents/migrations/1035_rename_comment_note.py deleted file mode 100644 index 9f9aaca94..000000000 --- a/src/documents/migrations/1035_rename_comment_note.py +++ /dev/null @@ -1,62 +0,0 @@ -# Generated by Django 4.1.5 on 2023-03-17 22:15 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("documents", "1034_alter_savedviewfilterrule_rule_type"), - ] - - operations = [ - migrations.RenameModel( - old_name="Comment", - new_name="Note", - ), - migrations.RenameField(model_name="note", old_name="comment", new_name="note"), - migrations.AlterModelOptions( - name="note", - options={ - "ordering": ("created",), - "verbose_name": "note", - "verbose_name_plural": "notes", - }, - ), - migrations.AlterField( - model_name="note", - name="document", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="notes", - to="documents.document", - verbose_name="document", - ), - ), - migrations.AlterField( - model_name="note", - name="note", - field=models.TextField( - blank=True, - help_text="Note for the document", - verbose_name="content", - ), - ), - migrations.AlterField( - model_name="note", - name="user", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="notes", - to=settings.AUTH_USER_MODEL, - verbose_name="user", - ), - ), - ] diff --git a/src/documents/migrations/1036_alter_savedviewfilterrule_rule_type.py b/src/documents/migrations/1036_alter_savedviewfilterrule_rule_type.py deleted file mode 100644 index e65586ad8..000000000 --- a/src/documents/migrations/1036_alter_savedviewfilterrule_rule_type.py +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by Django 4.1.7 on 2023-05-04 04:11 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1035_rename_comment_note"), - ] - - operations = [ - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - (23, "ASN greater than"), - (24, "ASN less than"), - (25, "storage path is"), - (26, "has correspondent in"), - (27, "does not have correspondent in"), - (28, "has document type in"), - (29, "does not have document type in"), - (30, "has storage path in"), - (31, "does not have storage path in"), - (32, "owner is"), - (33, "has owner in"), - (34, "does not have owner"), - (35, "does not have owner in"), - ], - verbose_name="rule type", - ), - ), - ] diff --git a/src/documents/migrations/1037_webp_encrypted_thumbnail_conversion.py b/src/documents/migrations/1037_webp_encrypted_thumbnail_conversion.py deleted file mode 100644 index 13996132f..000000000 --- a/src/documents/migrations/1037_webp_encrypted_thumbnail_conversion.py +++ /dev/null @@ -1,164 +0,0 @@ -# Generated by Django 4.1.9 on 2023-06-29 19:29 -import logging -import multiprocessing.pool -import shutil -import tempfile -import time -from pathlib import Path - -import gnupg -from django.conf import settings -from django.db import migrations - -from documents.parsers import run_convert - -logger = logging.getLogger("paperless.migrations") - - -def _do_convert(work_package) -> None: - ( - existing_encrypted_thumbnail, - converted_encrypted_thumbnail, - passphrase, - ) = work_package - - try: - gpg = gnupg.GPG(gnupghome=settings.GNUPG_HOME) - - logger.info(f"Decrypting thumbnail: {existing_encrypted_thumbnail}") - - # Decrypt png - decrypted_thumbnail = existing_encrypted_thumbnail.with_suffix("").resolve() - - with existing_encrypted_thumbnail.open("rb") as existing_encrypted_file: - raw_thumb = gpg.decrypt_file( - existing_encrypted_file, - passphrase=passphrase, - always_trust=True, - ).data - with Path(decrypted_thumbnail).open("wb") as decrypted_file: - decrypted_file.write(raw_thumb) - - converted_decrypted_thumbnail = Path( - str(converted_encrypted_thumbnail).replace("webp.gpg", "webp"), - ).resolve() - - logger.info(f"Converting decrypted thumbnail: {decrypted_thumbnail}") - - # Convert to webp - run_convert( - density=300, - scale="500x5000>", - alpha="remove", - strip=True, - trim=False, - auto_orient=True, - input_file=f"{decrypted_thumbnail}[0]", - output_file=str(converted_decrypted_thumbnail), - ) - - logger.info( - f"Encrypting converted thumbnail: {converted_decrypted_thumbnail}", - ) - - # Encrypt webp - with Path(converted_decrypted_thumbnail).open("rb") as converted_decrypted_file: - encrypted = gpg.encrypt_file( - fileobj_or_path=converted_decrypted_file, - recipients=None, - passphrase=passphrase, - symmetric=True, - always_trust=True, - ).data - - with Path(converted_encrypted_thumbnail).open( - "wb", - ) as converted_encrypted_file: - converted_encrypted_file.write(encrypted) - - # Copy newly created thumbnail to thumbnail directory - shutil.copy(converted_encrypted_thumbnail, existing_encrypted_thumbnail.parent) - - # Remove the existing encrypted PNG version - existing_encrypted_thumbnail.unlink() - - # Remove the decrypted PNG version - decrypted_thumbnail.unlink() - - # Remove the decrypted WebP version - converted_decrypted_thumbnail.unlink() - - logger.info( - "Conversion to WebP completed, " - f"replaced {existing_encrypted_thumbnail.name} with {converted_encrypted_thumbnail.name}", - ) - - except Exception as e: - logger.error(f"Error converting thumbnail (existing file unchanged): {e}") - - -def _convert_encrypted_thumbnails_to_webp(apps, schema_editor) -> None: - start: float = time.time() - - with tempfile.TemporaryDirectory() as tempdir: - work_packages = [] - - if len(list(Path(settings.THUMBNAIL_DIR).glob("*.png.gpg"))) > 0: - passphrase = settings.PASSPHRASE - - if not passphrase: - raise Exception( - "Passphrase not defined, encrypted thumbnails cannot be migrated" - "without this", - ) - - for file in Path(settings.THUMBNAIL_DIR).glob("*.png.gpg"): - existing_thumbnail: Path = file.resolve() - - # Change the existing filename suffix from png to webp - converted_thumbnail_name: str = Path( - str(existing_thumbnail).replace(".png.gpg", ".webp.gpg"), - ).name - - # Create the expected output filename in the tempdir - converted_thumbnail: Path = ( - Path(tempdir) / Path(converted_thumbnail_name) - ).resolve() - - # Package up the necessary info - work_packages.append( - (existing_thumbnail, converted_thumbnail, passphrase), - ) - - if work_packages: - logger.info( - "\n\n" - " This is a one-time only migration to convert thumbnails for all of your\n" - " *encrypted* documents into WebP format. If you have a lot of encrypted documents, \n" - " this may take a while, so a coffee break may be in order." - "\n", - ) - - with multiprocessing.pool.Pool( - processes=min(multiprocessing.cpu_count(), 4), - maxtasksperchild=4, - ) as pool: - pool.map(_do_convert, work_packages) - - end: float = time.time() - duration: float = end - start - - logger.info(f"Conversion completed in {duration:.3f}s") - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1036_alter_savedviewfilterrule_rule_type"), - ] - - operations = [ - migrations.RunPython( - code=_convert_encrypted_thumbnails_to_webp, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/src/documents/migrations/1038_sharelink.py b/src/documents/migrations/1038_sharelink.py deleted file mode 100644 index fa2860b6f..000000000 --- a/src/documents/migrations/1038_sharelink.py +++ /dev/null @@ -1,126 +0,0 @@ -# Generated by Django 4.1.10 on 2023-08-14 14:51 - -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.contrib.auth.management import create_permissions -from django.contrib.auth.models import Group -from django.contrib.auth.models import Permission -from django.contrib.auth.models import User -from django.db import migrations -from django.db import models -from django.db.models import Q - - -def add_sharelink_permissions(apps, schema_editor): - # create permissions without waiting for post_migrate signal - for app_config in apps.get_app_configs(): - app_config.models_module = True - create_permissions(app_config, apps=apps, verbosity=0) - app_config.models_module = None - - add_permission = Permission.objects.get(codename="add_document") - sharelink_permissions = Permission.objects.filter(codename__contains="sharelink") - - for user in User.objects.filter(Q(user_permissions=add_permission)).distinct(): - user.user_permissions.add(*sharelink_permissions) - - for group in Group.objects.filter(Q(permissions=add_permission)).distinct(): - group.permissions.add(*sharelink_permissions) - - -def remove_sharelink_permissions(apps, schema_editor): - sharelink_permissions = Permission.objects.filter(codename__contains="sharelink") - - for user in User.objects.all(): - user.user_permissions.remove(*sharelink_permissions) - - for group in Group.objects.all(): - group.permissions.remove(*sharelink_permissions) - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("documents", "1037_webp_encrypted_thumbnail_conversion"), - ] - - operations = [ - migrations.CreateModel( - name="ShareLink", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "created", - models.DateTimeField( - blank=True, - db_index=True, - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ( - "expiration", - models.DateTimeField( - blank=True, - db_index=True, - null=True, - verbose_name="expiration", - ), - ), - ( - "slug", - models.SlugField( - blank=True, - editable=False, - unique=True, - verbose_name="slug", - ), - ), - ( - "file_version", - models.CharField( - choices=[("archive", "Archive"), ("original", "Original")], - default="archive", - max_length=50, - ), - ), - ( - "document", - models.ForeignKey( - blank=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="share_links", - to="documents.document", - verbose_name="document", - ), - ), - ( - "owner", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="share_links", - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - ], - options={ - "verbose_name": "share link", - "verbose_name_plural": "share links", - "ordering": ("created",), - }, - ), - migrations.RunPython(add_sharelink_permissions, remove_sharelink_permissions), - ] diff --git a/src/documents/migrations/1039_consumptiontemplate.py b/src/documents/migrations/1039_consumptiontemplate.py deleted file mode 100644 index cf8b9fd91..000000000 --- a/src/documents/migrations/1039_consumptiontemplate.py +++ /dev/null @@ -1,219 +0,0 @@ -# Generated by Django 4.1.11 on 2023-09-16 18:04 - -import django.db.models.deletion -import multiselectfield.db.fields -from django.conf import settings -from django.contrib.auth.management import create_permissions -from django.contrib.auth.models import Group -from django.contrib.auth.models import Permission -from django.contrib.auth.models import User -from django.db import migrations -from django.db import models -from django.db.models import Q - - -def add_consumptiontemplate_permissions(apps, schema_editor): - # create permissions without waiting for post_migrate signal - for app_config in apps.get_app_configs(): - app_config.models_module = True - create_permissions(app_config, apps=apps, verbosity=0) - app_config.models_module = None - - add_permission = Permission.objects.get(codename="add_document") - consumptiontemplate_permissions = Permission.objects.filter( - codename__contains="consumptiontemplate", - ) - - for user in User.objects.filter(Q(user_permissions=add_permission)).distinct(): - user.user_permissions.add(*consumptiontemplate_permissions) - - for group in Group.objects.filter(Q(permissions=add_permission)).distinct(): - group.permissions.add(*consumptiontemplate_permissions) - - -def remove_consumptiontemplate_permissions(apps, schema_editor): - consumptiontemplate_permissions = Permission.objects.filter( - codename__contains="consumptiontemplate", - ) - - for user in User.objects.all(): - user.user_permissions.remove(*consumptiontemplate_permissions) - - for group in Group.objects.all(): - group.permissions.remove(*consumptiontemplate_permissions) - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("auth", "0012_alter_user_first_name_max_length"), - ("documents", "1038_sharelink"), - ("paperless_mail", "0021_alter_mailaccount_password"), - ] - - operations = [ - migrations.CreateModel( - name="ConsumptionTemplate", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "name", - models.CharField(max_length=256, unique=True, verbose_name="name"), - ), - ("order", models.IntegerField(default=0, verbose_name="order")), - ( - "sources", - multiselectfield.db.fields.MultiSelectField( - choices=[ - (1, "Consume Folder"), - (2, "Api Upload"), - (3, "Mail Fetch"), - ], - default="1,2,3", - max_length=3, - ), - ), - ( - "filter_path", - models.CharField( - blank=True, - help_text="Only consume documents with a path that matches this if specified. Wildcards specified as * are allowed. Case insensitive.", - max_length=256, - null=True, - verbose_name="filter path", - ), - ), - ( - "filter_filename", - models.CharField( - blank=True, - help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", - max_length=256, - null=True, - verbose_name="filter filename", - ), - ), - ( - "filter_mailrule", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="paperless_mail.mailrule", - verbose_name="filter documents from this mail rule", - ), - ), - ( - "assign_change_groups", - models.ManyToManyField( - blank=True, - related_name="+", - to="auth.group", - verbose_name="grant change permissions to these groups", - ), - ), - ( - "assign_change_users", - models.ManyToManyField( - blank=True, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="grant change permissions to these users", - ), - ), - ( - "assign_correspondent", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.correspondent", - verbose_name="assign this correspondent", - ), - ), - ( - "assign_document_type", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.documenttype", - verbose_name="assign this document type", - ), - ), - ( - "assign_owner", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="assign this owner", - ), - ), - ( - "assign_storage_path", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.storagepath", - verbose_name="assign this storage path", - ), - ), - ( - "assign_tags", - models.ManyToManyField( - blank=True, - to="documents.tag", - verbose_name="assign this tag", - ), - ), - ( - "assign_title", - models.CharField( - blank=True, - help_text="Assign a document title, can include some placeholders, see documentation.", - max_length=256, - null=True, - verbose_name="assign title", - ), - ), - ( - "assign_view_groups", - models.ManyToManyField( - blank=True, - related_name="+", - to="auth.group", - verbose_name="grant view permissions to these groups", - ), - ), - ( - "assign_view_users", - models.ManyToManyField( - blank=True, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="grant view permissions to these users", - ), - ), - ], - options={ - "verbose_name": "consumption template", - "verbose_name_plural": "consumption templates", - }, - ), - migrations.RunPython( - add_consumptiontemplate_permissions, - remove_consumptiontemplate_permissions, - ), - ] diff --git a/src/documents/migrations/1040_customfield_customfieldinstance_and_more.py b/src/documents/migrations/1040_customfield_customfieldinstance_and_more.py deleted file mode 100644 index ecd715a57..000000000 --- a/src/documents/migrations/1040_customfield_customfieldinstance_and_more.py +++ /dev/null @@ -1,171 +0,0 @@ -# Generated by Django 4.2.6 on 2023-11-02 17:38 - -import django.db.models.deletion -import django.utils.timezone -from django.contrib.auth.management import create_permissions -from django.contrib.auth.models import Group -from django.contrib.auth.models import Permission -from django.contrib.auth.models import User -from django.db import migrations -from django.db import models -from django.db.models import Q - - -def add_customfield_permissions(apps, schema_editor): - # create permissions without waiting for post_migrate signal - for app_config in apps.get_app_configs(): - app_config.models_module = True - create_permissions(app_config, apps=apps, verbosity=0) - app_config.models_module = None - - add_permission = Permission.objects.get(codename="add_document") - customfield_permissions = Permission.objects.filter( - codename__contains="customfield", - ) - - for user in User.objects.filter(Q(user_permissions=add_permission)).distinct(): - user.user_permissions.add(*customfield_permissions) - - for group in Group.objects.filter(Q(permissions=add_permission)).distinct(): - group.permissions.add(*customfield_permissions) - - -def remove_customfield_permissions(apps, schema_editor): - customfield_permissions = Permission.objects.filter( - codename__contains="customfield", - ) - - for user in User.objects.all(): - user.user_permissions.remove(*customfield_permissions) - - for group in Group.objects.all(): - group.permissions.remove(*customfield_permissions) - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1039_consumptiontemplate"), - ] - - operations = [ - migrations.CreateModel( - name="CustomField", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "created", - models.DateTimeField( - db_index=True, - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ("name", models.CharField(max_length=128)), - ( - "data_type", - models.CharField( - choices=[ - ("string", "String"), - ("url", "URL"), - ("date", "Date"), - ("boolean", "Boolean"), - ("integer", "Integer"), - ("float", "Float"), - ("monetary", "Monetary"), - ], - editable=False, - max_length=50, - verbose_name="data type", - ), - ), - ], - options={ - "verbose_name": "custom field", - "verbose_name_plural": "custom fields", - "ordering": ("created",), - }, - ), - migrations.CreateModel( - name="CustomFieldInstance", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "created", - models.DateTimeField( - db_index=True, - default=django.utils.timezone.now, - editable=False, - verbose_name="created", - ), - ), - ("value_text", models.CharField(max_length=128, null=True)), - ("value_bool", models.BooleanField(null=True)), - ("value_url", models.URLField(null=True)), - ("value_date", models.DateField(null=True)), - ("value_int", models.IntegerField(null=True)), - ("value_float", models.FloatField(null=True)), - ( - "value_monetary", - models.DecimalField(decimal_places=2, max_digits=12, null=True), - ), - ( - "document", - models.ForeignKey( - editable=False, - on_delete=django.db.models.deletion.CASCADE, - related_name="custom_fields", - to="documents.document", - ), - ), - ( - "field", - models.ForeignKey( - editable=False, - on_delete=django.db.models.deletion.CASCADE, - related_name="fields", - to="documents.customfield", - ), - ), - ], - options={ - "verbose_name": "custom field instance", - "verbose_name_plural": "custom field instances", - "ordering": ("created",), - }, - ), - migrations.AddConstraint( - model_name="customfield", - constraint=models.UniqueConstraint( - fields=("name",), - name="documents_customfield_unique_name", - ), - ), - migrations.AddConstraint( - model_name="customfieldinstance", - constraint=models.UniqueConstraint( - fields=("document", "field"), - name="documents_customfieldinstance_unique_document_field", - ), - ), - migrations.RunPython( - add_customfield_permissions, - remove_customfield_permissions, - ), - ] diff --git a/src/documents/migrations/1041_alter_consumptiontemplate_sources.py b/src/documents/migrations/1041_alter_consumptiontemplate_sources.py deleted file mode 100644 index c96dc53cf..000000000 --- a/src/documents/migrations/1041_alter_consumptiontemplate_sources.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.7 on 2023-11-30 14:29 - -import multiselectfield.db.fields -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1040_customfield_customfieldinstance_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="consumptiontemplate", - name="sources", - field=multiselectfield.db.fields.MultiSelectField( - choices=[(1, "Consume Folder"), (2, "Api Upload"), (3, "Mail Fetch")], - default="1,2,3", - max_length=5, - ), - ), - ] diff --git a/src/documents/migrations/1042_consumptiontemplate_assign_custom_fields_and_more.py b/src/documents/migrations/1042_consumptiontemplate_assign_custom_fields_and_more.py deleted file mode 100644 index ffd0dbefa..000000000 --- a/src/documents/migrations/1042_consumptiontemplate_assign_custom_fields_and_more.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 4.2.7 on 2023-12-04 04:03 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1041_alter_consumptiontemplate_sources"), - ] - - operations = [ - migrations.AddField( - model_name="consumptiontemplate", - name="assign_custom_fields", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.customfield", - verbose_name="assign these custom fields", - ), - ), - migrations.AddField( - model_name="customfieldinstance", - name="value_document_ids", - field=models.JSONField(null=True), - ), - migrations.AlterField( - model_name="customfield", - name="data_type", - field=models.CharField( - choices=[ - ("string", "String"), - ("url", "URL"), - ("date", "Date"), - ("boolean", "Boolean"), - ("integer", "Integer"), - ("float", "Float"), - ("monetary", "Monetary"), - ("documentlink", "Document Link"), - ], - editable=False, - max_length=50, - verbose_name="data type", - ), - ), - ] diff --git a/src/documents/migrations/1043_alter_savedviewfilterrule_rule_type.py b/src/documents/migrations/1043_alter_savedviewfilterrule_rule_type.py deleted file mode 100644 index bd62673df..000000000 --- a/src/documents/migrations/1043_alter_savedviewfilterrule_rule_type.py +++ /dev/null @@ -1,60 +0,0 @@ -# Generated by Django 4.2.7 on 2023-12-09 18:13 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1042_consumptiontemplate_assign_custom_fields_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - (23, "ASN greater than"), - (24, "ASN less than"), - (25, "storage path is"), - (26, "has correspondent in"), - (27, "does not have correspondent in"), - (28, "has document type in"), - (29, "does not have document type in"), - (30, "has storage path in"), - (31, "does not have storage path in"), - (32, "owner is"), - (33, "has owner in"), - (34, "does not have owner"), - (35, "does not have owner in"), - (36, "has custom field value"), - (37, "is shared by me"), - ], - verbose_name="rule type", - ), - ), - ] diff --git a/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py b/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py deleted file mode 100644 index 2cdd631bb..000000000 --- a/src/documents/migrations/1044_workflow_workflowaction_workflowtrigger_and_more.py +++ /dev/null @@ -1,524 +0,0 @@ -# Generated by Django 4.2.7 on 2023-12-23 22:51 - -import django.db.models.deletion -import multiselectfield.db.fields -from django.conf import settings -from django.contrib.auth.management import create_permissions -from django.db import migrations -from django.db import models -from django.db import transaction -from django.db.models import Q - - -def add_workflow_permissions(apps, schema_editor): - app_name = "auth" - User = apps.get_model(app_label=app_name, model_name="User") - Group = apps.get_model(app_label=app_name, model_name="Group") - Permission = apps.get_model(app_label=app_name, model_name="Permission") - # create permissions without waiting for post_migrate signal - for app_config in apps.get_app_configs(): - app_config.models_module = True - create_permissions(app_config, apps=apps, verbosity=0) - app_config.models_module = None - - add_permission = Permission.objects.get(codename="add_document") - workflow_permissions = Permission.objects.filter( - codename__contains="workflow", - ) - - for user in User.objects.filter(Q(user_permissions=add_permission)).distinct(): - user.user_permissions.add(*workflow_permissions) - - for group in Group.objects.filter(Q(permissions=add_permission)).distinct(): - group.permissions.add(*workflow_permissions) - - -def remove_workflow_permissions(apps, schema_editor): - app_name = "auth" - User = apps.get_model(app_label=app_name, model_name="User") - Group = apps.get_model(app_label=app_name, model_name="Group") - Permission = apps.get_model(app_label=app_name, model_name="Permission") - workflow_permissions = Permission.objects.filter( - codename__contains="workflow", - ) - - for user in User.objects.all(): - user.user_permissions.remove(*workflow_permissions) - - for group in Group.objects.all(): - group.permissions.remove(*workflow_permissions) - - -def migrate_consumption_templates(apps, schema_editor): - """ - Migrate consumption templates to workflows. At this point ConsumptionTemplate still exists - but objects are not returned as their true model so we have to manually do that - """ - app_name = "documents" - - ConsumptionTemplate = apps.get_model( - app_label=app_name, - model_name="ConsumptionTemplate", - ) - Workflow = apps.get_model(app_label=app_name, model_name="Workflow") - WorkflowAction = apps.get_model(app_label=app_name, model_name="WorkflowAction") - WorkflowTrigger = apps.get_model(app_label=app_name, model_name="WorkflowTrigger") - DocumentType = apps.get_model(app_label=app_name, model_name="DocumentType") - Correspondent = apps.get_model(app_label=app_name, model_name="Correspondent") - StoragePath = apps.get_model(app_label=app_name, model_name="StoragePath") - Tag = apps.get_model(app_label=app_name, model_name="Tag") - CustomField = apps.get_model(app_label=app_name, model_name="CustomField") - MailRule = apps.get_model(app_label="paperless_mail", model_name="MailRule") - User = apps.get_model(app_label="auth", model_name="User") - Group = apps.get_model(app_label="auth", model_name="Group") - - with transaction.atomic(): - for template in ConsumptionTemplate.objects.all(): - trigger = WorkflowTrigger( - type=1, # WorkflowTriggerType.CONSUMPTION - sources=template.sources, - filter_path=template.filter_path, - filter_filename=template.filter_filename, - ) - if template.filter_mailrule is not None: - trigger.filter_mailrule = MailRule.objects.get( - id=template.filter_mailrule.id, - ) - trigger.save() - - action = WorkflowAction.objects.create( - assign_title=template.assign_title, - ) - if template.assign_document_type is not None: - action.assign_document_type = DocumentType.objects.get( - id=template.assign_document_type.id, - ) - if template.assign_correspondent is not None: - action.assign_correspondent = Correspondent.objects.get( - id=template.assign_correspondent.id, - ) - if template.assign_storage_path is not None: - action.assign_storage_path = StoragePath.objects.get( - id=template.assign_storage_path.id, - ) - if template.assign_owner is not None: - action.assign_owner = User.objects.get(id=template.assign_owner.id) - if template.assign_tags is not None: - action.assign_tags.set( - Tag.objects.filter( - id__in=[t.id for t in template.assign_tags.all()], - ).all(), - ) - if template.assign_view_users is not None: - action.assign_view_users.set( - User.objects.filter( - id__in=[u.id for u in template.assign_view_users.all()], - ).all(), - ) - if template.assign_view_groups is not None: - action.assign_view_groups.set( - Group.objects.filter( - id__in=[g.id for g in template.assign_view_groups.all()], - ).all(), - ) - if template.assign_change_users is not None: - action.assign_change_users.set( - User.objects.filter( - id__in=[u.id for u in template.assign_change_users.all()], - ).all(), - ) - if template.assign_change_groups is not None: - action.assign_change_groups.set( - Group.objects.filter( - id__in=[g.id for g in template.assign_change_groups.all()], - ).all(), - ) - if template.assign_custom_fields is not None: - action.assign_custom_fields.set( - CustomField.objects.filter( - id__in=[cf.id for cf in template.assign_custom_fields.all()], - ).all(), - ) - action.save() - - workflow = Workflow.objects.create( - name=template.name, - order=template.order, - ) - workflow.triggers.set([trigger]) - workflow.actions.set([action]) - workflow.save() - - -def unmigrate_consumption_templates(apps, schema_editor): - app_name = "documents" - - ConsumptionTemplate = apps.get_model( - app_label=app_name, - model_name="ConsumptionTemplate", - ) - Workflow = apps.get_model(app_label=app_name, model_name="Workflow") - - for workflow in Workflow.objects.all(): - template = ConsumptionTemplate.objects.create( - name=workflow.name, - order=workflow.order, - sources=workflow.triggers.first().sources, - filter_path=workflow.triggers.first().filter_path, - filter_filename=workflow.triggers.first().filter_filename, - filter_mailrule=workflow.triggers.first().filter_mailrule, - assign_title=workflow.actions.first().assign_title, - assign_document_type=workflow.actions.first().assign_document_type, - assign_correspondent=workflow.actions.first().assign_correspondent, - assign_storage_path=workflow.actions.first().assign_storage_path, - assign_owner=workflow.actions.first().assign_owner, - ) - template.assign_tags.set(workflow.actions.first().assign_tags.all()) - template.assign_view_users.set(workflow.actions.first().assign_view_users.all()) - template.assign_view_groups.set( - workflow.actions.first().assign_view_groups.all(), - ) - template.assign_change_users.set( - workflow.actions.first().assign_change_users.all(), - ) - template.assign_change_groups.set( - workflow.actions.first().assign_change_groups.all(), - ) - template.assign_custom_fields.set( - workflow.actions.first().assign_custom_fields.all(), - ) - template.save() - - -def delete_consumption_template_content_type(apps, schema_editor): - with transaction.atomic(): - apps.get_model("contenttypes", "ContentType").objects.filter( - app_label="documents", - model="consumptiontemplate", - ).delete() - - -def undelete_consumption_template_content_type(apps, schema_editor): - apps.get_model("contenttypes", "ContentType").objects.create( - app_label="documents", - model="consumptiontemplate", - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0023_remove_mailrule_filter_attachment_filename_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("auth", "0012_alter_user_first_name_max_length"), - ("documents", "1043_alter_savedviewfilterrule_rule_type"), - ] - - operations = [ - migrations.CreateModel( - name="Workflow", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "name", - models.CharField(max_length=256, unique=True, verbose_name="name"), - ), - ("order", models.IntegerField(default=0, verbose_name="order")), - ( - "enabled", - models.BooleanField(default=True, verbose_name="enabled"), - ), - ], - ), - migrations.CreateModel( - name="WorkflowAction", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "type", - models.PositiveIntegerField( - choices=[(1, "Assignment")], - default=1, - verbose_name="Workflow Action Type", - ), - ), - ( - "assign_title", - models.CharField( - blank=True, - help_text="Assign a document title, can include some placeholders, see documentation.", - max_length=256, - null=True, - verbose_name="assign title", - ), - ), - ( - "assign_change_groups", - models.ManyToManyField( - blank=True, - related_name="+", - to="auth.group", - verbose_name="grant change permissions to these groups", - ), - ), - ( - "assign_change_users", - models.ManyToManyField( - blank=True, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="grant change permissions to these users", - ), - ), - ( - "assign_correspondent", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.correspondent", - verbose_name="assign this correspondent", - ), - ), - ( - "assign_custom_fields", - models.ManyToManyField( - blank=True, - related_name="+", - to="documents.customfield", - verbose_name="assign these custom fields", - ), - ), - ( - "assign_document_type", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.documenttype", - verbose_name="assign this document type", - ), - ), - ( - "assign_owner", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="assign this owner", - ), - ), - ( - "assign_storage_path", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.storagepath", - verbose_name="assign this storage path", - ), - ), - ( - "assign_tags", - models.ManyToManyField( - blank=True, - to="documents.tag", - verbose_name="assign this tag", - ), - ), - ( - "assign_view_groups", - models.ManyToManyField( - blank=True, - related_name="+", - to="auth.group", - verbose_name="grant view permissions to these groups", - ), - ), - ( - "assign_view_users", - models.ManyToManyField( - blank=True, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="grant view permissions to these users", - ), - ), - ], - options={ - "verbose_name": "workflow action", - "verbose_name_plural": "workflow actions", - }, - ), - migrations.CreateModel( - name="WorkflowTrigger", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "type", - models.PositiveIntegerField( - choices=[ - (1, "Consumption Started"), - (2, "Document Added"), - (3, "Document Updated"), - ], - default=1, - verbose_name="Workflow Trigger Type", - ), - ), - ( - "sources", - multiselectfield.db.fields.MultiSelectField( - choices=[ - (1, "Consume Folder"), - (2, "Api Upload"), - (3, "Mail Fetch"), - ], - default="1,2,3", - max_length=5, - ), - ), - ( - "filter_path", - models.CharField( - blank=True, - help_text="Only consume documents with a path that matches this if specified. Wildcards specified as * are allowed. Case insensitive.", - max_length=256, - null=True, - verbose_name="filter path", - ), - ), - ( - "filter_filename", - models.CharField( - blank=True, - help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", - max_length=256, - null=True, - verbose_name="filter filename", - ), - ), - ( - "filter_mailrule", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="paperless_mail.mailrule", - verbose_name="filter documents from this mail rule", - ), - ), - ( - "matching_algorithm", - models.PositiveIntegerField( - choices=[ - (0, "None"), - (1, "Any word"), - (2, "All words"), - (3, "Exact match"), - (4, "Regular expression"), - (5, "Fuzzy word"), - ], - default=0, - verbose_name="matching algorithm", - ), - ), - ( - "match", - models.CharField(blank=True, max_length=256, verbose_name="match"), - ), - ( - "is_insensitive", - models.BooleanField(default=True, verbose_name="is insensitive"), - ), - ( - "filter_has_tags", - models.ManyToManyField( - blank=True, - to="documents.tag", - verbose_name="has these tag(s)", - ), - ), - ( - "filter_has_document_type", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.documenttype", - verbose_name="has this document type", - ), - ), - ( - "filter_has_correspondent", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.correspondent", - verbose_name="has this correspondent", - ), - ), - ], - options={ - "verbose_name": "workflow trigger", - "verbose_name_plural": "workflow triggers", - }, - ), - migrations.RunPython( - add_workflow_permissions, - remove_workflow_permissions, - ), - migrations.AddField( - model_name="workflow", - name="actions", - field=models.ManyToManyField( - related_name="workflows", - to="documents.workflowaction", - verbose_name="actions", - ), - ), - migrations.AddField( - model_name="workflow", - name="triggers", - field=models.ManyToManyField( - related_name="workflows", - to="documents.workflowtrigger", - verbose_name="triggers", - ), - ), - migrations.RunPython( - migrate_consumption_templates, - unmigrate_consumption_templates, - ), - migrations.DeleteModel("ConsumptionTemplate"), - migrations.RunPython( - delete_consumption_template_content_type, - undelete_consumption_template_content_type, - ), - ] diff --git a/src/documents/migrations/1045_alter_customfieldinstance_value_monetary.py b/src/documents/migrations/1045_alter_customfieldinstance_value_monetary.py deleted file mode 100644 index 597fbb7f9..000000000 --- a/src/documents/migrations/1045_alter_customfieldinstance_value_monetary.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.10 on 2024-02-22 03:52 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1044_workflow_workflowaction_workflowtrigger_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="customfieldinstance", - name="value_monetary", - field=models.CharField(max_length=128, null=True), - ), - ] diff --git a/src/documents/migrations/1045_alter_customfieldinstance_value_monetary_squashed_1049_document_deleted_at_document_restored_at.py b/src/documents/migrations/1045_alter_customfieldinstance_value_monetary_squashed_1049_document_deleted_at_document_restored_at.py deleted file mode 100644 index 2987e4812..000000000 --- a/src/documents/migrations/1045_alter_customfieldinstance_value_monetary_squashed_1049_document_deleted_at_document_restored_at.py +++ /dev/null @@ -1,331 +0,0 @@ -# Generated by Django 4.2.13 on 2024-06-28 19:39 - -import django.core.validators -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - replaces = [ - ("documents", "1045_alter_customfieldinstance_value_monetary"), - ("documents", "1046_workflowaction_remove_all_correspondents_and_more"), - ("documents", "1047_savedview_display_mode_and_more"), - ("documents", "1048_alter_savedviewfilterrule_rule_type"), - ("documents", "1049_document_deleted_at_document_restored_at"), - ] - - dependencies = [ - ("documents", "1044_workflow_workflowaction_workflowtrigger_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("auth", "0012_alter_user_first_name_max_length"), - ] - - operations = [ - migrations.AlterField( - model_name="customfieldinstance", - name="value_monetary", - field=models.CharField(max_length=128, null=True), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_correspondents", - field=models.BooleanField( - default=False, - verbose_name="remove all correspondents", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_custom_fields", - field=models.BooleanField( - default=False, - verbose_name="remove all custom fields", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_document_types", - field=models.BooleanField( - default=False, - verbose_name="remove all document types", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_owners", - field=models.BooleanField(default=False, verbose_name="remove all owners"), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_permissions", - field=models.BooleanField( - default=False, - verbose_name="remove all permissions", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_storage_paths", - field=models.BooleanField( - default=False, - verbose_name="remove all storage paths", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_tags", - field=models.BooleanField(default=False, verbose_name="remove all tags"), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_change_groups", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="auth.group", - verbose_name="remove change permissions for these groups", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_change_users", - field=models.ManyToManyField( - blank=True, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="remove change permissions for these users", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_correspondents", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.correspondent", - verbose_name="remove these correspondent(s)", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_custom_fields", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.customfield", - verbose_name="remove these custom fields", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_document_types", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.documenttype", - verbose_name="remove these document type(s)", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_owners", - field=models.ManyToManyField( - blank=True, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="remove these owner(s)", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_storage_paths", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.storagepath", - verbose_name="remove these storage path(s)", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_tags", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.tag", - verbose_name="remove these tag(s)", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_view_groups", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="auth.group", - verbose_name="remove view permissions for these groups", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_view_users", - field=models.ManyToManyField( - blank=True, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="remove view permissions for these users", - ), - ), - migrations.AlterField( - model_name="workflowaction", - name="assign_correspondent", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="documents.correspondent", - verbose_name="assign this correspondent", - ), - ), - migrations.AlterField( - model_name="workflowaction", - name="assign_document_type", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="documents.documenttype", - verbose_name="assign this document type", - ), - ), - migrations.AlterField( - model_name="workflowaction", - name="assign_storage_path", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="documents.storagepath", - verbose_name="assign this storage path", - ), - ), - migrations.AlterField( - model_name="workflowaction", - name="assign_tags", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.tag", - verbose_name="assign this tag", - ), - ), - migrations.AlterField( - model_name="workflowaction", - name="type", - field=models.PositiveIntegerField( - choices=[(1, "Assignment"), (2, "Removal")], - default=1, - verbose_name="Workflow Action Type", - ), - ), - migrations.AddField( - model_name="savedview", - name="display_mode", - field=models.CharField( - blank=True, - choices=[ - ("table", "Table"), - ("smallCards", "Small Cards"), - ("largeCards", "Large Cards"), - ], - max_length=128, - null=True, - verbose_name="View display mode", - ), - ), - migrations.AddField( - model_name="savedview", - name="page_size", - field=models.PositiveIntegerField( - blank=True, - null=True, - validators=[django.core.validators.MinValueValidator(1)], - verbose_name="View page size", - ), - ), - migrations.AddField( - model_name="savedview", - name="display_fields", - field=models.JSONField( - blank=True, - null=True, - verbose_name="Document display fields", - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - (23, "ASN greater than"), - (24, "ASN less than"), - (25, "storage path is"), - (26, "has correspondent in"), - (27, "does not have correspondent in"), - (28, "has document type in"), - (29, "does not have document type in"), - (30, "has storage path in"), - (31, "does not have storage path in"), - (32, "owner is"), - (33, "has owner in"), - (34, "does not have owner"), - (35, "does not have owner in"), - (36, "has custom field value"), - (37, "is shared by me"), - (38, "has custom fields"), - (39, "has custom field in"), - (40, "does not have custom field in"), - (41, "does not have custom field"), - ], - verbose_name="rule type", - ), - ), - migrations.AddField( - model_name="document", - name="deleted_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="document", - name="restored_at", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/src/documents/migrations/1046_workflowaction_remove_all_correspondents_and_more.py b/src/documents/migrations/1046_workflowaction_remove_all_correspondents_and_more.py deleted file mode 100644 index 3ab010a3c..000000000 --- a/src/documents/migrations/1046_workflowaction_remove_all_correspondents_and_more.py +++ /dev/null @@ -1,222 +0,0 @@ -# Generated by Django 4.2.10 on 2024-02-21 21:19 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("auth", "0012_alter_user_first_name_max_length"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("documents", "1045_alter_customfieldinstance_value_monetary"), - ] - - operations = [ - migrations.AddField( - model_name="workflowaction", - name="remove_all_correspondents", - field=models.BooleanField( - default=False, - verbose_name="remove all correspondents", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_custom_fields", - field=models.BooleanField( - default=False, - verbose_name="remove all custom fields", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_document_types", - field=models.BooleanField( - default=False, - verbose_name="remove all document types", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_owners", - field=models.BooleanField(default=False, verbose_name="remove all owners"), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_permissions", - field=models.BooleanField( - default=False, - verbose_name="remove all permissions", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_storage_paths", - field=models.BooleanField( - default=False, - verbose_name="remove all storage paths", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_all_tags", - field=models.BooleanField(default=False, verbose_name="remove all tags"), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_change_groups", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="auth.group", - verbose_name="remove change permissions for these groups", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_change_users", - field=models.ManyToManyField( - blank=True, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="remove change permissions for these users", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_correspondents", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.correspondent", - verbose_name="remove these correspondent(s)", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_custom_fields", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.customfield", - verbose_name="remove these custom fields", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_document_types", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.documenttype", - verbose_name="remove these document type(s)", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_owners", - field=models.ManyToManyField( - blank=True, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="remove these owner(s)", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_storage_paths", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.storagepath", - verbose_name="remove these storage path(s)", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_tags", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.tag", - verbose_name="remove these tag(s)", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_view_groups", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="auth.group", - verbose_name="remove view permissions for these groups", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="remove_view_users", - field=models.ManyToManyField( - blank=True, - related_name="+", - to=settings.AUTH_USER_MODEL, - verbose_name="remove view permissions for these users", - ), - ), - migrations.AlterField( - model_name="workflowaction", - name="assign_correspondent", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="documents.correspondent", - verbose_name="assign this correspondent", - ), - ), - migrations.AlterField( - model_name="workflowaction", - name="assign_document_type", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="documents.documenttype", - verbose_name="assign this document type", - ), - ), - migrations.AlterField( - model_name="workflowaction", - name="assign_storage_path", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="+", - to="documents.storagepath", - verbose_name="assign this storage path", - ), - ), - migrations.AlterField( - model_name="workflowaction", - name="assign_tags", - field=models.ManyToManyField( - blank=True, - related_name="+", - to="documents.tag", - verbose_name="assign this tag", - ), - ), - migrations.AlterField( - model_name="workflowaction", - name="type", - field=models.PositiveIntegerField( - choices=[(1, "Assignment"), (2, "Removal")], - default=1, - verbose_name="Workflow Action Type", - ), - ), - ] diff --git a/src/documents/migrations/1047_savedview_display_mode_and_more.py b/src/documents/migrations/1047_savedview_display_mode_and_more.py deleted file mode 100644 index 904f86bb1..000000000 --- a/src/documents/migrations/1047_savedview_display_mode_and_more.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 4.2.11 on 2024-04-16 18:35 - -import django.core.validators -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1046_workflowaction_remove_all_correspondents_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="savedview", - name="display_mode", - field=models.CharField( - blank=True, - choices=[ - ("table", "Table"), - ("smallCards", "Small Cards"), - ("largeCards", "Large Cards"), - ], - max_length=128, - null=True, - verbose_name="View display mode", - ), - ), - migrations.AddField( - model_name="savedview", - name="page_size", - field=models.PositiveIntegerField( - blank=True, - null=True, - validators=[django.core.validators.MinValueValidator(1)], - verbose_name="View page size", - ), - ), - migrations.AddField( - model_name="savedview", - name="display_fields", - field=models.JSONField( - blank=True, - null=True, - verbose_name="Document display fields", - ), - ), - ] diff --git a/src/documents/migrations/1048_alter_savedviewfilterrule_rule_type.py b/src/documents/migrations/1048_alter_savedviewfilterrule_rule_type.py deleted file mode 100644 index 904ad242c..000000000 --- a/src/documents/migrations/1048_alter_savedviewfilterrule_rule_type.py +++ /dev/null @@ -1,64 +0,0 @@ -# Generated by Django 4.2.11 on 2024-04-24 04:58 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1047_savedview_display_mode_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - (23, "ASN greater than"), - (24, "ASN less than"), - (25, "storage path is"), - (26, "has correspondent in"), - (27, "does not have correspondent in"), - (28, "has document type in"), - (29, "does not have document type in"), - (30, "has storage path in"), - (31, "does not have storage path in"), - (32, "owner is"), - (33, "has owner in"), - (34, "does not have owner"), - (35, "does not have owner in"), - (36, "has custom field value"), - (37, "is shared by me"), - (38, "has custom fields"), - (39, "has custom field in"), - (40, "does not have custom field in"), - (41, "does not have custom field"), - ], - verbose_name="rule type", - ), - ), - ] diff --git a/src/documents/migrations/1049_document_deleted_at_document_restored_at.py b/src/documents/migrations/1049_document_deleted_at_document_restored_at.py deleted file mode 100644 index 39fb41353..000000000 --- a/src/documents/migrations/1049_document_deleted_at_document_restored_at.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.11 on 2024-04-23 07:56 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1048_alter_savedviewfilterrule_rule_type"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="deleted_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="document", - name="restored_at", - field=models.DateTimeField(blank=True, null=True), - ), - ] diff --git a/src/documents/migrations/1050_customfield_extra_data_and_more.py b/src/documents/migrations/1050_customfield_extra_data_and_more.py deleted file mode 100644 index 0c6a77ccc..000000000 --- a/src/documents/migrations/1050_customfield_extra_data_and_more.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 4.2.13 on 2024-07-04 01:02 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1049_document_deleted_at_document_restored_at"), - ] - - operations = [ - migrations.AddField( - model_name="customfield", - name="extra_data", - field=models.JSONField( - blank=True, - help_text="Extra data for the custom field, such as select options", - null=True, - verbose_name="extra data", - ), - ), - migrations.AddField( - model_name="customfieldinstance", - name="value_select", - field=models.PositiveSmallIntegerField(null=True), - ), - migrations.AlterField( - model_name="customfield", - name="data_type", - field=models.CharField( - choices=[ - ("string", "String"), - ("url", "URL"), - ("date", "Date"), - ("boolean", "Boolean"), - ("integer", "Integer"), - ("float", "Float"), - ("monetary", "Monetary"), - ("documentlink", "Document Link"), - ("select", "Select"), - ], - editable=False, - max_length=50, - verbose_name="data type", - ), - ), - ] diff --git a/src/documents/migrations/1051_alter_correspondent_owner_alter_document_owner_and_more.py b/src/documents/migrations/1051_alter_correspondent_owner_alter_document_owner_and_more.py deleted file mode 100644 index e8f0bb97c..000000000 --- a/src/documents/migrations/1051_alter_correspondent_owner_alter_document_owner_and_more.py +++ /dev/null @@ -1,88 +0,0 @@ -# Generated by Django 4.2.13 on 2024-07-09 16:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("documents", "1050_customfield_extra_data_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="correspondent", - name="owner", - field=models.ForeignKey( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AlterField( - model_name="document", - name="owner", - field=models.ForeignKey( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AlterField( - model_name="documenttype", - name="owner", - field=models.ForeignKey( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AlterField( - model_name="savedview", - name="owner", - field=models.ForeignKey( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AlterField( - model_name="storagepath", - name="owner", - field=models.ForeignKey( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AlterField( - model_name="tag", - name="owner", - field=models.ForeignKey( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - ] diff --git a/src/documents/migrations/1052_document_transaction_id.py b/src/documents/migrations/1052_document_transaction_id.py deleted file mode 100644 index 5eb8e2ef9..000000000 --- a/src/documents/migrations/1052_document_transaction_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.15 on 2024-08-20 02:41 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1051_alter_correspondent_owner_alter_document_owner_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="transaction_id", - field=models.UUIDField(blank=True, null=True), - ), - ] diff --git a/src/documents/migrations/1053_document_page_count.py b/src/documents/migrations/1053_document_page_count.py deleted file mode 100644 index 3a8bc5d79..000000000 --- a/src/documents/migrations/1053_document_page_count.py +++ /dev/null @@ -1,67 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-28 04:42 - -from pathlib import Path - -import pikepdf -from django.conf import settings -from django.core.validators import MinValueValidator -from django.db import migrations -from django.db import models -from django.utils.termcolors import colorize as colourise - - -def source_path(self): - if self.filename: - fname = str(self.filename) - - return Path(settings.ORIGINALS_DIR / fname).resolve() - - -def add_number_of_pages_to_page_count(apps, schema_editor): - Document = apps.get_model("documents", "Document") - - if not Document.objects.all().exists(): - return - - for doc in Document.objects.filter(mime_type="application/pdf"): - print( - " {} {} {}".format( - colourise("*", fg="green"), - colourise("Calculating number of pages for", fg="white"), - colourise(doc.filename, fg="cyan"), - ), - ) - - try: - with pikepdf.Pdf.open(source_path(doc)) as pdf: - if pdf.pages is not None: - doc.page_count = len(pdf.pages) - doc.save() - except Exception as e: # pragma: no cover - print(f"Error retrieving number of pages for {doc.filename}: {e}") - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1052_document_transaction_id"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="page_count", - field=models.PositiveIntegerField( - blank=False, - help_text="The number of pages of the document.", - null=True, - unique=False, - validators=[MinValueValidator(1)], - verbose_name="page count", - db_index=False, - ), - ), - migrations.RunPython( - add_number_of_pages_to_page_count, - migrations.RunPython.noop, - ), - ] diff --git a/src/documents/migrations/1054_customfieldinstance_value_monetary_amount_and_more.py b/src/documents/migrations/1054_customfieldinstance_value_monetary_amount_and_more.py deleted file mode 100644 index 92d45de33..000000000 --- a/src/documents/migrations/1054_customfieldinstance_value_monetary_amount_and_more.py +++ /dev/null @@ -1,95 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-29 16:26 - -import django.db.models.functions.comparison -import django.db.models.functions.text -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1053_document_page_count"), - ] - - operations = [ - migrations.AddField( - model_name="customfieldinstance", - name="value_monetary_amount", - field=models.GeneratedField( - db_persist=True, - expression=models.Case( - models.When( - then=django.db.models.functions.comparison.Cast( - django.db.models.functions.text.Substr("value_monetary", 1), - output_field=models.DecimalField( - decimal_places=2, - max_digits=65, - ), - ), - value_monetary__regex="^\\d+", - ), - default=django.db.models.functions.comparison.Cast( - django.db.models.functions.text.Substr("value_monetary", 4), - output_field=models.DecimalField( - decimal_places=2, - max_digits=65, - ), - ), - output_field=models.DecimalField(decimal_places=2, max_digits=65), - ), - output_field=models.DecimalField(decimal_places=2, max_digits=65), - ), - ), - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - (23, "ASN greater than"), - (24, "ASN less than"), - (25, "storage path is"), - (26, "has correspondent in"), - (27, "does not have correspondent in"), - (28, "has document type in"), - (29, "does not have document type in"), - (30, "has storage path in"), - (31, "does not have storage path in"), - (32, "owner is"), - (33, "has owner in"), - (34, "does not have owner"), - (35, "does not have owner in"), - (36, "has custom field value"), - (37, "is shared by me"), - (38, "has custom fields"), - (39, "has custom field in"), - (40, "does not have custom field in"), - (41, "does not have custom field"), - (42, "custom fields query"), - ], - verbose_name="rule type", - ), - ), - ] diff --git a/src/documents/migrations/1055_alter_storagepath_path.py b/src/documents/migrations/1055_alter_storagepath_path.py deleted file mode 100644 index 1421bf824..000000000 --- a/src/documents/migrations/1055_alter_storagepath_path.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-03 14:47 - -from django.conf import settings -from django.db import migrations -from django.db import models -from django.db import transaction -from filelock import FileLock - -from documents.templating.utils import convert_format_str_to_template_format - - -def convert_from_format_to_template(apps, schema_editor): - StoragePath = apps.get_model("documents", "StoragePath") - - with transaction.atomic(), FileLock(settings.MEDIA_LOCK): - for storage_path in StoragePath.objects.all(): - storage_path.path = convert_format_str_to_template_format(storage_path.path) - storage_path.save() - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1054_customfieldinstance_value_monetary_amount_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="storagepath", - name="path", - field=models.TextField(verbose_name="path"), - ), - migrations.RunPython( - convert_from_format_to_template, - migrations.RunPython.noop, - ), - ] diff --git a/src/documents/migrations/1056_customfieldinstance_deleted_at_and_more.py b/src/documents/migrations/1056_customfieldinstance_deleted_at_and_more.py deleted file mode 100644 index eba1e4281..000000000 --- a/src/documents/migrations/1056_customfieldinstance_deleted_at_and_more.py +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by Django 5.1.2 on 2024-10-28 01:55 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1055_alter_storagepath_path"), - ] - - operations = [ - migrations.AddField( - model_name="customfieldinstance", - name="deleted_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="customfieldinstance", - name="restored_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="customfieldinstance", - name="transaction_id", - field=models.UUIDField(blank=True, null=True), - ), - migrations.AddField( - model_name="note", - name="deleted_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="note", - name="restored_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="note", - name="transaction_id", - field=models.UUIDField(blank=True, null=True), - ), - migrations.AddField( - model_name="sharelink", - name="deleted_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="sharelink", - name="restored_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="sharelink", - name="transaction_id", - field=models.UUIDField(blank=True, null=True), - ), - ] diff --git a/src/documents/migrations/1057_paperlesstask_owner.py b/src/documents/migrations/1057_paperlesstask_owner.py deleted file mode 100644 index e9f108d3a..000000000 --- a/src/documents/migrations/1057_paperlesstask_owner.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.1 on 2024-11-04 21:56 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1056_customfieldinstance_deleted_at_and_more"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name="paperlesstask", - name="owner", - field=models.ForeignKey( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - ] diff --git a/src/documents/migrations/1058_workflowtrigger_schedule_date_custom_field_and_more.py b/src/documents/migrations/1058_workflowtrigger_schedule_date_custom_field_and_more.py deleted file mode 100644 index 05d38578a..000000000 --- a/src/documents/migrations/1058_workflowtrigger_schedule_date_custom_field_and_more.py +++ /dev/null @@ -1,143 +0,0 @@ -# Generated by Django 5.1.1 on 2024-11-05 05:19 - -import django.core.validators -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1057_paperlesstask_owner"), - ] - - operations = [ - migrations.AddField( - model_name="workflowtrigger", - name="schedule_date_custom_field", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.customfield", - verbose_name="schedule date custom field", - ), - ), - migrations.AddField( - model_name="workflowtrigger", - name="schedule_date_field", - field=models.CharField( - choices=[ - ("added", "Added"), - ("created", "Created"), - ("modified", "Modified"), - ("custom_field", "Custom Field"), - ], - default="added", - help_text="The field to check for a schedule trigger.", - max_length=20, - verbose_name="schedule date field", - ), - ), - migrations.AddField( - model_name="workflowtrigger", - name="schedule_is_recurring", - field=models.BooleanField( - default=False, - help_text="If the schedule should be recurring.", - verbose_name="schedule is recurring", - ), - ), - migrations.AddField( - model_name="workflowtrigger", - name="schedule_offset_days", - field=models.PositiveIntegerField( - default=0, - help_text="The number of days to offset the schedule trigger by.", - verbose_name="schedule offset days", - ), - ), - migrations.AddField( - model_name="workflowtrigger", - name="schedule_recurring_interval_days", - field=models.PositiveIntegerField( - default=1, - help_text="The number of days between recurring schedule triggers.", - validators=[django.core.validators.MinValueValidator(1)], - verbose_name="schedule recurring delay in days", - ), - ), - migrations.AlterField( - model_name="workflowtrigger", - name="type", - field=models.PositiveIntegerField( - choices=[ - (1, "Consumption Started"), - (2, "Document Added"), - (3, "Document Updated"), - (4, "Scheduled"), - ], - default=1, - verbose_name="Workflow Trigger Type", - ), - ), - migrations.CreateModel( - name="WorkflowRun", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "type", - models.PositiveIntegerField( - choices=[ - (1, "Consumption Started"), - (2, "Document Added"), - (3, "Document Updated"), - (4, "Scheduled"), - ], - null=True, - verbose_name="workflow trigger type", - ), - ), - ( - "run_at", - models.DateTimeField( - db_index=True, - default=django.utils.timezone.now, - verbose_name="date run", - ), - ), - ( - "document", - models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="workflow_runs", - to="documents.document", - verbose_name="document", - ), - ), - ( - "workflow", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="runs", - to="documents.workflow", - verbose_name="workflow", - ), - ), - ], - options={ - "verbose_name": "workflow run", - "verbose_name_plural": "workflow runs", - }, - ), - ] diff --git a/src/documents/migrations/1059_workflowactionemail_workflowactionwebhook_and_more.py b/src/documents/migrations/1059_workflowactionemail_workflowactionwebhook_and_more.py deleted file mode 100644 index d94470285..000000000 --- a/src/documents/migrations/1059_workflowactionemail_workflowactionwebhook_and_more.py +++ /dev/null @@ -1,154 +0,0 @@ -# Generated by Django 5.1.3 on 2024-11-26 04:07 - -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1058_workflowtrigger_schedule_date_custom_field_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="WorkflowActionEmail", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "subject", - models.CharField( - help_text="The subject of the email, can include some placeholders, see documentation.", - max_length=256, - verbose_name="email subject", - ), - ), - ( - "body", - models.TextField( - help_text="The body (message) of the email, can include some placeholders, see documentation.", - verbose_name="email body", - ), - ), - ( - "to", - models.TextField( - help_text="The destination email addresses, comma separated.", - verbose_name="emails to", - ), - ), - ( - "include_document", - models.BooleanField( - default=False, - verbose_name="include document in email", - ), - ), - ], - ), - migrations.CreateModel( - name="WorkflowActionWebhook", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "url", - models.URLField( - help_text="The destination URL for the notification.", - verbose_name="webhook url", - ), - ), - ( - "use_params", - models.BooleanField(default=True, verbose_name="use parameters"), - ), - ( - "params", - models.JSONField( - blank=True, - help_text="The parameters to send with the webhook URL if body not used.", - null=True, - verbose_name="webhook parameters", - ), - ), - ( - "body", - models.TextField( - blank=True, - help_text="The body to send with the webhook URL if parameters not used.", - null=True, - verbose_name="webhook body", - ), - ), - ( - "headers", - models.JSONField( - blank=True, - help_text="The headers to send with the webhook URL.", - null=True, - verbose_name="webhook headers", - ), - ), - ( - "include_document", - models.BooleanField( - default=False, - verbose_name="include document in webhook", - ), - ), - ], - ), - migrations.AlterField( - model_name="workflowaction", - name="type", - field=models.PositiveIntegerField( - choices=[ - (1, "Assignment"), - (2, "Removal"), - (3, "Email"), - (4, "Webhook"), - ], - default=1, - verbose_name="Workflow Action Type", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="email", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="action", - to="documents.workflowactionemail", - verbose_name="email", - ), - ), - migrations.AddField( - model_name="workflowaction", - name="webhook", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="action", - to="documents.workflowactionwebhook", - verbose_name="webhook", - ), - ), - ] diff --git a/src/documents/migrations/1060_alter_customfieldinstance_value_select.py b/src/documents/migrations/1060_alter_customfieldinstance_value_select.py deleted file mode 100644 index 21f3f8b41..000000000 --- a/src/documents/migrations/1060_alter_customfieldinstance_value_select.py +++ /dev/null @@ -1,79 +0,0 @@ -# Generated by Django 5.1.1 on 2024-11-13 05:14 - -from django.db import migrations -from django.db import models -from django.db import transaction -from django.utils.crypto import get_random_string - - -def migrate_customfield_selects(apps, schema_editor): - """ - Migrate the custom field selects from a simple list of strings to a list of dictionaries with - label and id. Then update all instances of the custom field to use the new format. - """ - CustomFieldInstance = apps.get_model("documents", "CustomFieldInstance") - CustomField = apps.get_model("documents", "CustomField") - - with transaction.atomic(): - for custom_field in CustomField.objects.filter( - data_type="select", - ): # CustomField.FieldDataType.SELECT - old_select_options = custom_field.extra_data["select_options"] - custom_field.extra_data["select_options"] = [ - {"id": get_random_string(16), "label": value} - for value in old_select_options - ] - custom_field.save() - - for instance in CustomFieldInstance.objects.filter(field=custom_field): - if instance.value_select: - instance.value_select = custom_field.extra_data["select_options"][ - int(instance.value_select) - ]["id"] - instance.save() - - -def reverse_migrate_customfield_selects(apps, schema_editor): - """ - Reverse the migration of the custom field selects from a list of dictionaries with label and id - to a simple list of strings. Then update all instances of the custom field to use the old format, - which is just the index of the selected option. - """ - CustomFieldInstance = apps.get_model("documents", "CustomFieldInstance") - CustomField = apps.get_model("documents", "CustomField") - - with transaction.atomic(): - for custom_field in CustomField.objects.all(): - if custom_field.data_type == "select": # CustomField.FieldDataType.SELECT - old_select_options = custom_field.extra_data["select_options"] - custom_field.extra_data["select_options"] = [ - option["label"] - for option in custom_field.extra_data["select_options"] - ] - custom_field.save() - - for instance in CustomFieldInstance.objects.filter(field=custom_field): - instance.value_select = next( - index - for index, option in enumerate(old_select_options) - if option.get("id") == instance.value_select - ) - instance.save() - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1059_workflowactionemail_workflowactionwebhook_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="customfieldinstance", - name="value_select", - field=models.CharField(max_length=16, null=True), - ), - migrations.RunPython( - migrate_customfield_selects, - reverse_migrate_customfield_selects, - ), - ] diff --git a/src/documents/migrations/1061_workflowactionwebhook_as_json.py b/src/documents/migrations/1061_workflowactionwebhook_as_json.py deleted file mode 100644 index f1945cfc1..000000000 --- a/src/documents/migrations/1061_workflowactionwebhook_as_json.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.4 on 2025-01-18 19:35 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1060_alter_customfieldinstance_value_select"), - ] - - operations = [ - migrations.AddField( - model_name="workflowactionwebhook", - name="as_json", - field=models.BooleanField(default=False, verbose_name="send as JSON"), - ), - ] diff --git a/src/documents/migrations/1062_alter_savedviewfilterrule_rule_type.py b/src/documents/migrations/1062_alter_savedviewfilterrule_rule_type.py deleted file mode 100644 index c5a6bb90e..000000000 --- a/src/documents/migrations/1062_alter_savedviewfilterrule_rule_type.py +++ /dev/null @@ -1,70 +0,0 @@ -# Generated by Django 5.1.4 on 2025-02-06 05:54 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1061_workflowactionwebhook_as_json"), - ] - - operations = [ - migrations.AlterField( - model_name="savedviewfilterrule", - name="rule_type", - field=models.PositiveIntegerField( - choices=[ - (0, "title contains"), - (1, "content contains"), - (2, "ASN is"), - (3, "correspondent is"), - (4, "document type is"), - (5, "is in inbox"), - (6, "has tag"), - (7, "has any tag"), - (8, "created before"), - (9, "created after"), - (10, "created year is"), - (11, "created month is"), - (12, "created day is"), - (13, "added before"), - (14, "added after"), - (15, "modified before"), - (16, "modified after"), - (17, "does not have tag"), - (18, "does not have ASN"), - (19, "title or content contains"), - (20, "fulltext query"), - (21, "more like this"), - (22, "has tags in"), - (23, "ASN greater than"), - (24, "ASN less than"), - (25, "storage path is"), - (26, "has correspondent in"), - (27, "does not have correspondent in"), - (28, "has document type in"), - (29, "does not have document type in"), - (30, "has storage path in"), - (31, "does not have storage path in"), - (32, "owner is"), - (33, "has owner in"), - (34, "does not have owner"), - (35, "does not have owner in"), - (36, "has custom field value"), - (37, "is shared by me"), - (38, "has custom fields"), - (39, "has custom field in"), - (40, "does not have custom field in"), - (41, "does not have custom field"), - (42, "custom fields query"), - (43, "created to"), - (44, "created from"), - (45, "added to"), - (46, "added from"), - (47, "mime type is"), - ], - verbose_name="rule type", - ), - ), - ] diff --git a/src/documents/migrations/1063_paperlesstask_type_alter_paperlesstask_task_name_and_more.py b/src/documents/migrations/1063_paperlesstask_type_alter_paperlesstask_task_name_and_more.py deleted file mode 100644 index aeedbd6a0..000000000 --- a/src/documents/migrations/1063_paperlesstask_type_alter_paperlesstask_task_name_and_more.py +++ /dev/null @@ -1,92 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-21 16:34 - -import multiselectfield.db.fields -from django.db import migrations -from django.db import models - - -# WebUI source was added, so all existing APIUpload sources should be updated to include WebUI -def update_workflow_sources(apps, schema_editor): - WorkflowTrigger = apps.get_model("documents", "WorkflowTrigger") - for trigger in WorkflowTrigger.objects.all(): - sources = list(trigger.sources) - if 2 in sources: - sources.append(4) - trigger.sources = sources - trigger.save() - - -def make_existing_tasks_consume_auto(apps, schema_editor): - PaperlessTask = apps.get_model("documents", "PaperlessTask") - PaperlessTask.objects.all().update(type="auto_task", task_name="consume_file") - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1062_alter_savedviewfilterrule_rule_type"), - ] - - operations = [ - migrations.AddField( - model_name="paperlesstask", - name="type", - field=models.CharField( - choices=[ - ("auto_task", "Auto Task"), - ("scheduled_task", "Scheduled Task"), - ("manual_task", "Manual Task"), - ], - default="auto_task", - help_text="The type of task that was run", - max_length=30, - verbose_name="Task Type", - ), - ), - migrations.AlterField( - model_name="paperlesstask", - name="task_name", - field=models.CharField( - choices=[ - ("consume_file", "Consume File"), - ("train_classifier", "Train Classifier"), - ("check_sanity", "Check Sanity"), - ("index_optimize", "Index Optimize"), - ], - help_text="Name of the task that was run", - max_length=255, - null=True, - verbose_name="Task Name", - ), - ), - migrations.RunPython( - code=make_existing_tasks_consume_auto, - reverse_code=migrations.RunPython.noop, - ), - migrations.AlterField( - model_name="workflowactionwebhook", - name="url", - field=models.CharField( - help_text="The destination URL for the notification.", - max_length=256, - verbose_name="webhook url", - ), - ), - migrations.AlterField( - model_name="workflowtrigger", - name="sources", - field=multiselectfield.db.fields.MultiSelectField( - choices=[ - (1, "Consume Folder"), - (2, "Api Upload"), - (3, "Mail Fetch"), - (4, "Web UI"), - ], - default="1,2,3,4", - max_length=7, - ), - ), - migrations.RunPython( - code=update_workflow_sources, - reverse_code=migrations.RunPython.noop, - ), - ] diff --git a/src/documents/migrations/1064_delete_log.py b/src/documents/migrations/1064_delete_log.py deleted file mode 100644 index ec0830a91..000000000 --- a/src/documents/migrations/1064_delete_log.py +++ /dev/null @@ -1,15 +0,0 @@ -# Generated by Django 5.1.6 on 2025-02-28 15:19 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1063_paperlesstask_type_alter_paperlesstask_task_name_and_more"), - ] - - operations = [ - migrations.DeleteModel( - name="Log", - ), - ] diff --git a/src/documents/migrations/1065_workflowaction_assign_custom_fields_values.py b/src/documents/migrations/1065_workflowaction_assign_custom_fields_values.py deleted file mode 100644 index 35fae02be..000000000 --- a/src/documents/migrations/1065_workflowaction_assign_custom_fields_values.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.6 on 2025-03-01 18:10 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1064_delete_log"), - ] - - operations = [ - migrations.AddField( - model_name="workflowaction", - name="assign_custom_fields_values", - field=models.JSONField( - blank=True, - help_text="Optional values to assign to the custom fields.", - null=True, - verbose_name="custom field values", - default=dict, - ), - ), - ] diff --git a/src/documents/migrations/1066_alter_workflowtrigger_schedule_offset_days.py b/src/documents/migrations/1066_alter_workflowtrigger_schedule_offset_days.py deleted file mode 100644 index eaf23ad64..000000000 --- a/src/documents/migrations/1066_alter_workflowtrigger_schedule_offset_days.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.1.7 on 2025-04-15 19:18 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1065_workflowaction_assign_custom_fields_values"), - ] - - operations = [ - migrations.AlterField( - model_name="workflowtrigger", - name="schedule_offset_days", - field=models.IntegerField( - default=0, - help_text="The number of days to offset the schedule trigger by.", - verbose_name="schedule offset days", - ), - ), - ] diff --git a/src/documents/migrations/1067_alter_document_created.py b/src/documents/migrations/1067_alter_document_created.py deleted file mode 100644 index 0f96bce3d..000000000 --- a/src/documents/migrations/1067_alter_document_created.py +++ /dev/null @@ -1,76 +0,0 @@ -# Generated by Django 5.1.7 on 2025-04-04 01:08 - - -import datetime - -from django.db import migrations -from django.db import models -from django.utils.timezone import localtime - - -def migrate_date(apps, schema_editor): - Document = apps.get_model("documents", "Document") - - # Batch to avoid loading all objects into memory at once, - # which would be problematic for large datasets. - batch_size = 500 - updates = [] - total_updated = 0 - total_checked = 0 - - for doc in Document.objects.only("id", "created").iterator(chunk_size=batch_size): - total_checked += 1 - if doc.created: - doc.created_date = localtime(doc.created).date() - updates.append(doc) - - if len(updates) >= batch_size: - Document.objects.bulk_update(updates, ["created_date"]) - total_updated += len(updates) - print( - f"[1067_alter_document_created] {total_updated} of {total_checked} processed...", - ) - updates.clear() - - if updates: - Document.objects.bulk_update(updates, ["created_date"]) - total_updated += len(updates) - print( - f"[1067_alter_document_created] {total_updated} of {total_checked} processed...", - ) - - if total_checked > 0: - print(f"[1067_alter_document_created] completed for {total_checked} documents.") - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1066_alter_workflowtrigger_schedule_offset_days"), - ] - - operations = [ - migrations.AddField( - model_name="document", - name="created_date", - field=models.DateField(null=True), - ), - migrations.RunPython(migrate_date, reverse_code=migrations.RunPython.noop), - migrations.RemoveField( - model_name="document", - name="created", - ), - migrations.RenameField( - model_name="document", - old_name="created_date", - new_name="created", - ), - migrations.AlterField( - model_name="document", - name="created", - field=models.DateField( - db_index=True, - default=datetime.datetime.today, - verbose_name="created", - ), - ), - ] diff --git a/src/documents/migrations/1068_alter_document_created.py b/src/documents/migrations/1068_alter_document_created.py deleted file mode 100644 index b673f6584..000000000 --- a/src/documents/migrations/1068_alter_document_created.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 5.1.8 on 2025-05-23 05:50 - -import datetime - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1067_alter_document_created"), - ] - - operations = [ - migrations.AlterField( - model_name="document", - name="created", - field=models.DateField( - db_index=True, - default=datetime.date.today, - verbose_name="created", - ), - ), - ] diff --git a/src/documents/migrations/1069_workflowtrigger_filter_has_storage_path_and_more.py b/src/documents/migrations/1069_workflowtrigger_filter_has_storage_path_and_more.py deleted file mode 100644 index 47db2fd91..000000000 --- a/src/documents/migrations/1069_workflowtrigger_filter_has_storage_path_and_more.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-11 17:29 - -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1068_alter_document_created"), - ] - - operations = [ - migrations.AddField( - model_name="workflowtrigger", - name="filter_has_storage_path", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.storagepath", - verbose_name="has this storage path", - ), - ), - migrations.AlterField( - model_name="workflowaction", - name="assign_title", - field=models.TextField( - blank=True, - help_text="Assign a document title, must be a Jinja2 template, see documentation.", - null=True, - verbose_name="assign title", - ), - ), - ] diff --git a/src/documents/migrations/1070_customfieldinstance_value_long_text_and_more.py b/src/documents/migrations/1070_customfieldinstance_value_long_text_and_more.py deleted file mode 100644 index 69c77d29a..000000000 --- a/src/documents/migrations/1070_customfieldinstance_value_long_text_and_more.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-13 17:11 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1069_workflowtrigger_filter_has_storage_path_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="customfieldinstance", - name="value_long_text", - field=models.TextField(null=True), - ), - migrations.AlterField( - model_name="customfield", - name="data_type", - field=models.CharField( - choices=[ - ("string", "String"), - ("url", "URL"), - ("date", "Date"), - ("boolean", "Boolean"), - ("integer", "Integer"), - ("float", "Float"), - ("monetary", "Monetary"), - ("documentlink", "Document Link"), - ("select", "Select"), - ("longtext", "Long Text"), - ], - editable=False, - max_length=50, - verbose_name="data type", - ), - ), - ] diff --git a/src/documents/migrations/1071_tag_tn_ancestors_count_tag_tn_ancestors_pks_and_more.py b/src/documents/migrations/1071_tag_tn_ancestors_count_tag_tn_ancestors_pks_and_more.py deleted file mode 100644 index 3e097620e..000000000 --- a/src/documents/migrations/1071_tag_tn_ancestors_count_tag_tn_ancestors_pks_and_more.py +++ /dev/null @@ -1,159 +0,0 @@ -# Generated by Django 5.2.6 on 2025-09-12 18:42 - -import django.core.validators -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1070_customfieldinstance_value_long_text_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="tag", - name="tn_ancestors_count", - field=models.PositiveIntegerField( - default=0, - editable=False, - verbose_name="Ancestors count", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_ancestors_pks", - field=models.TextField( - blank=True, - default="", - editable=False, - verbose_name="Ancestors pks", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_children_count", - field=models.PositiveIntegerField( - default=0, - editable=False, - verbose_name="Children count", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_children_pks", - field=models.TextField( - blank=True, - default="", - editable=False, - verbose_name="Children pks", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_depth", - field=models.PositiveIntegerField( - default=0, - editable=False, - validators=[ - django.core.validators.MinValueValidator(0), - django.core.validators.MaxValueValidator(10), - ], - verbose_name="Depth", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_descendants_count", - field=models.PositiveIntegerField( - default=0, - editable=False, - verbose_name="Descendants count", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_descendants_pks", - field=models.TextField( - blank=True, - default="", - editable=False, - verbose_name="Descendants pks", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_index", - field=models.PositiveIntegerField( - default=0, - editable=False, - verbose_name="Index", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_level", - field=models.PositiveIntegerField( - default=1, - editable=False, - validators=[ - django.core.validators.MinValueValidator(1), - django.core.validators.MaxValueValidator(10), - ], - verbose_name="Level", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_order", - field=models.PositiveIntegerField( - default=0, - editable=False, - verbose_name="Order", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_parent", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="tn_children", - to="documents.tag", - verbose_name="Parent", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_priority", - field=models.PositiveIntegerField( - default=0, - validators=[ - django.core.validators.MinValueValidator(0), - django.core.validators.MaxValueValidator(9999999999), - ], - verbose_name="Priority", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_siblings_count", - field=models.PositiveIntegerField( - default=0, - editable=False, - verbose_name="Siblings count", - ), - ), - migrations.AddField( - model_name="tag", - name="tn_siblings_pks", - field=models.TextField( - blank=True, - default="", - editable=False, - verbose_name="Siblings pks", - ), - ), - ] diff --git a/src/documents/migrations/1072_workflowtrigger_filter_custom_field_query_and_more.py b/src/documents/migrations/1072_workflowtrigger_filter_custom_field_query_and_more.py deleted file mode 100644 index 1a22f6b4f..000000000 --- a/src/documents/migrations/1072_workflowtrigger_filter_custom_field_query_and_more.py +++ /dev/null @@ -1,73 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-07 18:52 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1071_tag_tn_ancestors_count_tag_tn_ancestors_pks_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="workflowtrigger", - name="filter_custom_field_query", - field=models.TextField( - blank=True, - help_text="JSON-encoded custom field query expression.", - null=True, - verbose_name="filter custom field query", - ), - ), - migrations.AddField( - model_name="workflowtrigger", - name="filter_has_all_tags", - field=models.ManyToManyField( - blank=True, - related_name="workflowtriggers_has_all", - to="documents.tag", - verbose_name="has all of these tag(s)", - ), - ), - migrations.AddField( - model_name="workflowtrigger", - name="filter_has_not_correspondents", - field=models.ManyToManyField( - blank=True, - related_name="workflowtriggers_has_not_correspondent", - to="documents.correspondent", - verbose_name="does not have these correspondent(s)", - ), - ), - migrations.AddField( - model_name="workflowtrigger", - name="filter_has_not_document_types", - field=models.ManyToManyField( - blank=True, - related_name="workflowtriggers_has_not_document_type", - to="documents.documenttype", - verbose_name="does not have these document type(s)", - ), - ), - migrations.AddField( - model_name="workflowtrigger", - name="filter_has_not_storage_paths", - field=models.ManyToManyField( - blank=True, - related_name="workflowtriggers_has_not_storage_path", - to="documents.storagepath", - verbose_name="does not have these storage path(s)", - ), - ), - migrations.AddField( - model_name="workflowtrigger", - name="filter_has_not_tags", - field=models.ManyToManyField( - blank=True, - related_name="workflowtriggers_has_not", - to="documents.tag", - verbose_name="does not have these tag(s)", - ), - ), - ] diff --git a/src/documents/migrations/1073_migrate_workflow_title_jinja.py b/src/documents/migrations/1073_migrate_workflow_title_jinja.py deleted file mode 100644 index 9d80a277f..000000000 --- a/src/documents/migrations/1073_migrate_workflow_title_jinja.py +++ /dev/null @@ -1,63 +0,0 @@ -# Generated by Django 5.2.5 on 2025-08-27 22:02 -import logging - -from django.db import migrations -from django.db import models - -from documents.templating.utils import convert_format_str_to_template_format - -logger = logging.getLogger("paperless.migrations") - - -def convert_from_format_to_template(apps, schema_editor): - WorkflowAction = apps.get_model("documents", "WorkflowAction") - - batch_size = 500 - actions_to_update = [] - - queryset = ( - WorkflowAction.objects.filter(assign_title__isnull=False) - .exclude(assign_title="") - .only("id", "assign_title") - ) - - for action in queryset: - action.assign_title = convert_format_str_to_template_format( - action.assign_title, - ) - logger.debug( - "Converted WorkflowAction id %d title to template format: %s", - action.id, - action.assign_title, - ) - actions_to_update.append(action) - - if actions_to_update: - WorkflowAction.objects.bulk_update( - actions_to_update, - ["assign_title"], - batch_size=batch_size, - ) - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1072_workflowtrigger_filter_custom_field_query_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="workflowaction", - name="assign_title", - field=models.TextField( - blank=True, - help_text="Assign a document title, must be a Jinja2 template, see documentation.", - null=True, - verbose_name="assign title", - ), - ), - migrations.RunPython( - convert_from_format_to_template, - migrations.RunPython.noop, - ), - ] diff --git a/src/documents/migrations/1074_workflowrun_deleted_at_workflowrun_restored_at_and_more.py b/src/documents/migrations/1074_workflowrun_deleted_at_workflowrun_restored_at_and_more.py deleted file mode 100644 index 4381eabb1..000000000 --- a/src/documents/migrations/1074_workflowrun_deleted_at_workflowrun_restored_at_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.6 on 2025-10-27 15:11 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1073_migrate_workflow_title_jinja"), - ] - - operations = [ - migrations.AddField( - model_name="workflowrun", - name="deleted_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="workflowrun", - name="restored_at", - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name="workflowrun", - name="transaction_id", - field=models.UUIDField(blank=True, null=True), - ), - ] diff --git a/src/documents/models.py b/src/documents/models.py index a6f62ae55..f74952a8e 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -154,13 +154,6 @@ class StoragePath(MatchingModel): class Document(SoftDeleteModel, ModelWithOwner): - STORAGE_TYPE_UNENCRYPTED = "unencrypted" - STORAGE_TYPE_GPG = "gpg" - STORAGE_TYPES = ( - (STORAGE_TYPE_UNENCRYPTED, _("Unencrypted")), - (STORAGE_TYPE_GPG, _("Encrypted with GNU Privacy Guard")), - ) - correspondent = models.ForeignKey( Correspondent, blank=True, @@ -250,14 +243,6 @@ class Document(SoftDeleteModel, ModelWithOwner): db_index=True, ) - storage_type = models.CharField( - _("storage type"), - max_length=11, - choices=STORAGE_TYPES, - default=STORAGE_TYPE_UNENCRYPTED, - editable=False, - ) - added = models.DateTimeField( _("added"), default=timezone.now, @@ -353,12 +338,7 @@ class Document(SoftDeleteModel, ModelWithOwner): @property def source_path(self) -> Path: - if self.filename: - fname = str(self.filename) - else: - fname = f"{self.pk:07}{self.file_type}" - if self.storage_type == self.STORAGE_TYPE_GPG: - fname += ".gpg" # pragma: no cover + fname = str(self.filename) if self.filename else f"{self.pk:07}{self.file_type}" return (settings.ORIGINALS_DIR / Path(fname)).resolve() @@ -407,8 +387,6 @@ class Document(SoftDeleteModel, ModelWithOwner): @property def thumbnail_path(self) -> Path: webp_file_name = f"{self.pk:07}.webp" - if self.storage_type == self.STORAGE_TYPE_GPG: - webp_file_name += ".gpg" webp_file_path = settings.THUMBNAIL_DIR / Path(webp_file_name) @@ -598,6 +576,7 @@ class PaperlessTask(ModelWithOwner): TRAIN_CLASSIFIER = ("train_classifier", _("Train Classifier")) CHECK_SANITY = ("check_sanity", _("Check Sanity")) INDEX_OPTIMIZE = ("index_optimize", _("Index Optimize")) + LLMINDEX_UPDATE = ("llmindex_update", _("LLM Index Update")) task_id = models.CharField( max_length=255, @@ -1298,6 +1277,8 @@ class WorkflowAction(models.Model): default=WorkflowActionType.ASSIGNMENT, ) + order = models.PositiveIntegerField(_("order"), default=0) + assign_title = models.TextField( _("assign title"), null=True, diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 402f01d6f..1553ef3f9 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -580,30 +580,34 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer): ), ) def get_children(self, obj): - filter_q = self.context.get("document_count_filter") - request = self.context.get("request") - if filter_q is None: - user = getattr(request, "user", None) if request else None - filter_q = get_document_count_filter_for_user(user) - self.context["document_count_filter"] = filter_q + children_map = self.context.get("children_map") + if children_map is not None: + children = children_map.get(obj.pk, []) + else: + filter_q = self.context.get("document_count_filter") + request = self.context.get("request") + if filter_q is None: + user = getattr(request, "user", None) if request else None + filter_q = get_document_count_filter_for_user(user) + self.context["document_count_filter"] = filter_q - children_queryset = ( - obj.get_children_queryset() - .select_related("owner") - .annotate(document_count=Count("documents", filter=filter_q)) - ) + children = ( + obj.get_children_queryset() + .select_related("owner") + .annotate(document_count=Count("documents", filter=filter_q)) + ) - view = self.context.get("view") - ordering = ( - OrderingFilter().get_ordering(request, children_queryset, view) - if request and view - else None - ) - ordering = ordering or (Lower("name"),) - children_queryset = children_queryset.order_by(*ordering) + view = self.context.get("view") + ordering = ( + OrderingFilter().get_ordering(request, children, view) + if request and view + else None + ) + ordering = ordering or (Lower("name"),) + children = children.order_by(*ordering) serializer = TagSerializer( - children_queryset, + children, many=True, user=self.user, full_perms=self.full_perms, @@ -2588,7 +2592,8 @@ class WorkflowSerializer(serializers.ModelSerializer): set_triggers.append(trigger_instance) if actions is not None and actions is not serializers.empty: - for action in actions: + for index, action in enumerate(actions): + action["order"] = index assign_tags = action.pop("assign_tags", None) assign_view_users = action.pop("assign_view_users", None) assign_view_groups = action.pop("assign_view_groups", None) @@ -2715,6 +2720,16 @@ class WorkflowSerializer(serializers.ModelSerializer): return instance + def to_representation(self, instance): + data = super().to_representation(instance) + actions = instance.actions.order_by("order", "pk") + data["actions"] = WorkflowActionSerializer( + actions, + many=True, + context=self.context, + ).data + return data + class TrashSerializer(SerializerWithPerms): documents = serializers.ListField( diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 7d55bc801..a0c897183 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -26,6 +26,8 @@ from filelock import FileLock from documents import matching from documents.caching import clear_document_caches +from documents.caching import invalidate_llm_suggestions_cache +from documents.data_models import ConsumableDocument from documents.file_handling import create_source_path_directory from documents.file_handling import delete_empty_directories from documents.file_handling import generate_filename @@ -53,6 +55,7 @@ from documents.workflows.mutations import apply_assignment_to_overrides from documents.workflows.mutations import apply_removal_to_document from documents.workflows.mutations import apply_removal_to_overrides from documents.workflows.utils import get_workflows_for_trigger +from paperless.config import AIConfig if TYPE_CHECKING: from documents.classifier import DocumentClassifier @@ -419,7 +422,15 @@ def update_filename_and_move_files( return instance = instance.document - def validate_move(instance, old_path: Path, new_path: Path): + def validate_move(instance, old_path: Path, new_path: Path, root: Path): + if not new_path.is_relative_to(root): + msg = ( + f"Document {instance!s}: Refusing to move file outside root {root}: " + f"{new_path}." + ) + logger.warning(msg) + raise CannotMoveFilesException(msg) + if not old_path.is_file(): # Can't do anything if the old file does not exist anymore. msg = f"Document {instance!s}: File {old_path} doesn't exist." @@ -508,12 +519,22 @@ def update_filename_and_move_files( return if move_original: - validate_move(instance, old_source_path, instance.source_path) + validate_move( + instance, + old_source_path, + instance.source_path, + settings.ORIGINALS_DIR, + ) create_source_path_directory(instance.source_path) shutil.move(old_source_path, instance.source_path) if move_archive: - validate_move(instance, old_archive_path, instance.archive_path) + validate_move( + instance, + old_archive_path, + instance.archive_path, + settings.ARCHIVE_DIR, + ) create_source_path_directory(instance.archive_path) shutil.move(old_archive_path, instance.archive_path) @@ -639,6 +660,15 @@ def cleanup_custom_field_deletion(sender, instance: CustomField, **kwargs): ) +@receiver(models.signals.post_save, sender=Document) +def update_llm_suggestions_cache(sender, instance, **kwargs): + """ + Invalidate the LLM suggestions cache when a document is saved. + """ + # Invalidate the cache for the document + invalidate_llm_suggestions_cache(instance.pk) + + @receiver(models.signals.post_delete, sender=User) @receiver(models.signals.post_delete, sender=Group) def cleanup_user_deletion(sender, instance: User | Group, **kwargs): @@ -752,7 +782,7 @@ def run_workflows( if matching.document_matches_workflow(document, workflow, trigger_type): action: WorkflowAction - for action in workflow.actions.all(): + for action in workflow.actions.order_by("order", "pk"): message = f"Applying {action} from {workflow}" if not use_overrides: logger.info(message, extra={"group": logging_group}) @@ -947,3 +977,26 @@ def close_connection_pool_on_worker_init(**kwargs): for conn in connections.all(initialized_only=True): if conn.alias == "default" and hasattr(conn, "pool") and conn.pool: conn.close_pool() + + +def add_or_update_document_in_llm_index(sender, document, **kwargs): + """ + Add or update a document in the LLM index when it is created or updated. + """ + ai_config = AIConfig() + if ai_config.llm_index_enabled: + from documents.tasks import update_document_in_llm_index + + update_document_in_llm_index.delay(document) + + +@receiver(models.signals.post_delete, sender=Document) +def delete_document_from_llm_index(sender, instance: Document, **kwargs): + """ + Delete a document from the LLM index when it is deleted. + """ + ai_config = AIConfig() + if ai_config.llm_index_enabled: + from documents.tasks import remove_document_from_llm_index + + remove_document_from_llm_index.delay(instance) diff --git a/src/documents/tasks.py b/src/documents/tasks.py index 6c415ad69..fed8a65f7 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -54,6 +54,10 @@ from documents.signals import document_updated from documents.signals.handlers import cleanup_document_deletion from documents.signals.handlers import run_workflows from documents.workflows.utils import get_workflows_for_trigger +from paperless.config import AIConfig +from paperless_ai.indexing import llm_index_add_or_update_document +from paperless_ai.indexing import llm_index_remove_document +from paperless_ai.indexing import update_llm_index if settings.AUDIT_LOG_ENABLED: from auditlog.models import LogEntry @@ -242,6 +246,13 @@ def bulk_update_documents(document_ids): for doc in documents: index.update_document(writer, doc) + ai_config = AIConfig() + if ai_config.llm_index_enabled: + update_llm_index( + progress_bar_disable=True, + rebuild=False, + ) + @shared_task def update_document_content_maybe_archive_file(document_id): @@ -341,6 +352,10 @@ def update_document_content_maybe_archive_file(document_id): with index.open_index_writer() as writer: index.update_document(writer, document) + ai_config = AIConfig() + if ai_config.llm_index_enabled: + llm_index_add_or_update_document(document) + clear_document_caches(document.pk) except Exception: @@ -558,3 +573,55 @@ def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None: if affected: bulk_update_documents.delay(document_ids=list(affected)) + + +@shared_task +def llmindex_index( + *, + progress_bar_disable=True, + rebuild=False, + scheduled=True, + auto=False, +): + ai_config = AIConfig() + if ai_config.llm_index_enabled: + task = PaperlessTask.objects.create( + type=PaperlessTask.TaskType.SCHEDULED_TASK + if scheduled + else PaperlessTask.TaskType.AUTO + if auto + else PaperlessTask.TaskType.MANUAL_TASK, + task_id=uuid.uuid4(), + task_name=PaperlessTask.TaskName.LLMINDEX_UPDATE, + status=states.STARTED, + date_created=timezone.now(), + date_started=timezone.now(), + ) + from paperless_ai.indexing import update_llm_index + + try: + result = update_llm_index( + progress_bar_disable=progress_bar_disable, + rebuild=rebuild, + ) + task.status = states.SUCCESS + task.result = result + except Exception as e: + logger.error("LLM index error: " + str(e)) + task.status = states.FAILURE + task.result = str(e) + + task.date_done = timezone.now() + task.save(update_fields=["status", "result", "date_done"]) + else: + logger.info("LLM index is disabled, skipping update.") + + +@shared_task +def update_document_in_llm_index(document): + llm_index_add_or_update_document(document) + + +@shared_task +def remove_document_from_llm_index(document): + llm_index_remove_document(document) diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py index 7d76e7f31..3647948ea 100644 --- a/src/documents/templating/filepath.py +++ b/src/documents/templating/filepath.py @@ -108,7 +108,6 @@ def create_dummy_document(): page_count=5, created=timezone.now(), modified=timezone.now(), - storage_type=Document.STORAGE_TYPE_UNENCRYPTED, added=timezone.now(), filename="/dummy/filename.pdf", archive_filename="/dummy/archive_filename.pdf", @@ -262,6 +261,17 @@ def get_custom_fields_context( return field_data +def _is_safe_relative_path(value: str) -> bool: + if value == "": + return True + + path = PurePath(value) + if path.is_absolute() or path.drive: + return False + + return ".." not in path.parts + + def validate_filepath_template_and_render( template_string: str, document: Document | None = None, @@ -309,6 +319,12 @@ def validate_filepath_template_and_render( ) rendered_template = template.render(context) + if not _is_safe_relative_path(rendered_template): + logger.warning( + "Template rendered an unsafe path (absolute or containing traversal).", + ) + return None + # We're good! return rendered_template except UndefinedError: diff --git a/src/documents/templating/workflows.py b/src/documents/templating/workflows.py index 67f3ac930..66fd97e01 100644 --- a/src/documents/templating/workflows.py +++ b/src/documents/templating/workflows.py @@ -40,6 +40,7 @@ def parse_w_workflow_placeholders( created: date | None = None, doc_title: str | None = None, doc_url: str | None = None, + doc_id: int | None = None, ) -> str: """ Available title placeholders for Workflows depend on what has already been assigned, @@ -79,6 +80,8 @@ def parse_w_workflow_placeholders( formatting.update({"doc_title": doc_title}) if doc_url is not None: formatting.update({"doc_url": doc_url}) + if doc_id is not None: + formatting.update({"doc_id": str(doc_id)}) logger.debug(f"Parsing Workflow Jinja template: {text}") try: diff --git a/src/documents/tests/samples/documents/originals/0000004.pdf b/src/documents/tests/samples/documents/originals/0000004.pdf new file mode 100644 index 000000000..953bb88ab Binary files /dev/null and b/src/documents/tests/samples/documents/originals/0000004.pdf differ diff --git a/src/documents/tests/samples/documents/originals/0000004.pdf.gpg b/src/documents/tests/samples/documents/originals/0000004.pdf.gpg deleted file mode 100644 index 754efcbf6..000000000 Binary files a/src/documents/tests/samples/documents/originals/0000004.pdf.gpg and /dev/null differ diff --git a/src/documents/tests/samples/documents/thumbnails/0000004.webp b/src/documents/tests/samples/documents/thumbnails/0000004.webp new file mode 100644 index 000000000..a7ff623b2 Binary files /dev/null and b/src/documents/tests/samples/documents/thumbnails/0000004.webp differ diff --git a/src/documents/tests/samples/documents/thumbnails/0000004.webp.gpg b/src/documents/tests/samples/documents/thumbnails/0000004.webp.gpg deleted file mode 100644 index 3abc69d36..000000000 Binary files a/src/documents/tests/samples/documents/thumbnails/0000004.webp.gpg and /dev/null differ diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index 6f487f5b0..2480e52ac 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -1,6 +1,7 @@ import json from io import BytesIO from pathlib import Path +from unittest.mock import patch from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile @@ -66,6 +67,13 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): "barcode_max_pages": None, "barcode_enable_tag": None, "barcode_tag_mapping": None, + "ai_enabled": False, + "llm_embedding_backend": None, + "llm_embedding_model": None, + "llm_backend": None, + "llm_model": None, + "llm_api_key": None, + "llm_endpoint": None, }, ) @@ -611,3 +619,76 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): ) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) self.assertEqual(ApplicationConfiguration.objects.count(), 1) + + def test_update_llm_api_key(self): + """ + GIVEN: + - Existing config with llm_api_key specified + WHEN: + - API to update llm_api_key is called with all *s + - API to update llm_api_key is called with empty string + THEN: + - llm_api_key is unchanged + - llm_api_key is set to None + """ + config = ApplicationConfiguration.objects.first() + config.llm_api_key = "1234567890" + config.save() + + # Test with all * + response = self.client.patch( + f"{self.ENDPOINT}1/", + json.dumps( + { + "llm_api_key": "*" * 32, + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + config.refresh_from_db() + self.assertEqual(config.llm_api_key, "1234567890") + # Test with empty string + response = self.client.patch( + f"{self.ENDPOINT}1/", + json.dumps( + { + "llm_api_key": "", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + config.refresh_from_db() + self.assertEqual(config.llm_api_key, None) + + def test_enable_ai_index_triggers_update(self): + """ + GIVEN: + - Existing config with AI disabled + WHEN: + - Config is updated to enable AI with llm_embedding_backend + THEN: + - LLM index is triggered to update + """ + config = ApplicationConfiguration.objects.first() + config.ai_enabled = False + config.llm_embedding_backend = None + config.save() + + with ( + patch("documents.tasks.llmindex_index.delay") as mock_update, + patch("paperless_ai.indexing.vector_store_file_exists") as mock_exists, + ): + mock_exists.return_value = False + self.client.patch( + f"{self.ENDPOINT}1/", + json.dumps( + { + "ai_enabled": True, + "llm_embedding_backend": "openai", + }, + ), + content_type="application/json", + ) + mock_update.assert_called_once() diff --git a/src/documents/tests/test_api_objects.py b/src/documents/tests/test_api_objects.py index 014dd3c2a..0eb99f023 100644 --- a/src/documents/tests/test_api_objects.py +++ b/src/documents/tests/test_api_objects.py @@ -219,6 +219,30 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(StoragePath.objects.count(), 1) + def test_api_create_storage_path_rejects_traversal(self): + """ + GIVEN: + - API request to create a storage paths + - Storage path attempts directory traversal + WHEN: + - API is called + THEN: + - Correct HTTP 400 response + - No storage path is created + """ + response = self.client.post( + self.ENDPOINT, + json.dumps( + { + "name": "Traversal path", + "path": "../../../../../tmp/proof", + }, + ), + content_type="application/json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(StoragePath.objects.count(), 1) + def test_api_storage_path_placeholders(self): """ GIVEN: diff --git a/src/documents/tests/test_api_status.py b/src/documents/tests/test_api_status.py index 9b7bf37ad..8e29c53d2 100644 --- a/src/documents/tests/test_api_status.py +++ b/src/documents/tests/test_api_status.py @@ -1,4 +1,6 @@ import os +import shutil +import tempfile from pathlib import Path from unittest import mock @@ -16,9 +18,19 @@ class TestSystemStatus(APITestCase): ENDPOINT = "/api/status/" def setUp(self): + super().setUp() self.user = User.objects.create_superuser( username="temp_admin", ) + self.tmp_dir = Path(tempfile.mkdtemp()) + self.override = override_settings(MEDIA_ROOT=self.tmp_dir) + self.override.enable() + + def tearDown(self): + super().tearDown() + + self.override.disable() + shutil.rmtree(self.tmp_dir) def test_system_status(self): """ @@ -310,3 +322,69 @@ class TestSystemStatus(APITestCase): "ERROR", ) self.assertIsNotNone(response.data["tasks"]["sanity_check_error"]) + + def test_system_status_ai_disabled(self): + """ + GIVEN: + - The AI feature is disabled + WHEN: + - The user requests the system status + THEN: + - The response contains the correct AI status + """ + with override_settings(AI_ENABLED=False): + self.client.force_login(self.user) + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["tasks"]["llmindex_status"], "DISABLED") + self.assertIsNone(response.data["tasks"]["llmindex_error"]) + + def test_system_status_ai_enabled(self): + """ + GIVEN: + - The AI index feature is enabled, but no tasks are found + - The AI index feature is enabled and a task is found + WHEN: + - The user requests the system status + THEN: + - The response contains the correct AI status + """ + with override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="openai"): + self.client.force_login(self.user) + + # No tasks found + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["tasks"]["llmindex_status"], "WARNING") + + PaperlessTask.objects.create( + type=PaperlessTask.TaskType.SCHEDULED_TASK, + status=states.SUCCESS, + task_name=PaperlessTask.TaskName.LLMINDEX_UPDATE, + ) + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["tasks"]["llmindex_status"], "OK") + self.assertIsNone(response.data["tasks"]["llmindex_error"]) + + def test_system_status_ai_error(self): + """ + GIVEN: + - The AI index feature is enabled and a task is found with an error + WHEN: + - The user requests the system status + THEN: + - The response contains the correct AI status + """ + with override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="openai"): + PaperlessTask.objects.create( + type=PaperlessTask.TaskType.SCHEDULED_TASK, + status=states.FAILURE, + task_name=PaperlessTask.TaskName.LLMINDEX_UPDATE, + result="AI index update failed", + ) + self.client.force_login(self.user) + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["tasks"]["llmindex_status"], "ERROR") + self.assertIsNotNone(response.data["tasks"]["llmindex_error"]) diff --git a/src/documents/tests/test_api_uisettings.py b/src/documents/tests/test_api_uisettings.py index 26c6f17ae..c733315e6 100644 --- a/src/documents/tests/test_api_uisettings.py +++ b/src/documents/tests/test_api_uisettings.py @@ -49,6 +49,7 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase): "backend_setting": "default", }, "email_enabled": False, + "ai_enabled": False, }, ) diff --git a/src/documents/tests/test_checks.py b/src/documents/tests/test_checks.py index 4af05746f..304074e37 100644 --- a/src/documents/tests/test_checks.py +++ b/src/documents/tests/test_checks.py @@ -1,4 +1,3 @@ -import textwrap from unittest import mock from django.core.checks import Error @@ -6,60 +5,11 @@ from django.core.checks import Warning from django.test import TestCase from django.test import override_settings -from documents.checks import changed_password_check from documents.checks import filename_format_check from documents.checks import parser_check -from documents.models import Document -from documents.tests.factories import DocumentFactory class TestDocumentChecks(TestCase): - def test_changed_password_check_empty_db(self): - self.assertListEqual(changed_password_check(None), []) - - def test_changed_password_check_no_encryption(self): - DocumentFactory.create(storage_type=Document.STORAGE_TYPE_UNENCRYPTED) - self.assertListEqual(changed_password_check(None), []) - - def test_encrypted_missing_passphrase(self): - DocumentFactory.create(storage_type=Document.STORAGE_TYPE_GPG) - msgs = changed_password_check(None) - self.assertEqual(len(msgs), 1) - msg_text = msgs[0].msg - self.assertEqual( - msg_text, - "The database contains encrypted documents but no password is set.", - ) - - @override_settings( - PASSPHRASE="test", - ) - @mock.patch("paperless.db.GnuPG.decrypted") - @mock.patch("documents.models.Document.source_file") - def test_encrypted_decrypt_fails(self, mock_decrypted, mock_source_file): - mock_decrypted.return_value = None - mock_source_file.return_value = b"" - - DocumentFactory.create(storage_type=Document.STORAGE_TYPE_GPG) - - msgs = changed_password_check(None) - - self.assertEqual(len(msgs), 1) - msg_text = msgs[0].msg - self.assertEqual( - msg_text, - textwrap.dedent( - """ - The current password doesn't match the password of the - existing documents. - - If you intend to change your password, you must first export - all of the old documents, start fresh with the new password - and then re-import them." - """, - ), - ) - def test_parser_check(self): self.assertEqual(parser_check(None), []) diff --git a/src/documents/tests/test_file_handling.py b/src/documents/tests/test_file_handling.py index befc7050f..f6764d3f8 100644 --- a/src/documents/tests/test_file_handling.py +++ b/src/documents/tests/test_file_handling.py @@ -34,22 +34,14 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): def test_generate_source_filename(self): document = Document() document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED document.save() self.assertEqual(generate_filename(document), Path(f"{document.pk:07d}.pdf")) - document.storage_type = Document.STORAGE_TYPE_GPG - self.assertEqual( - generate_filename(document), - Path(f"{document.pk:07d}.pdf.gpg"), - ) - @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") def test_file_renaming(self): document = Document() document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED document.save() # Test default source_path @@ -63,11 +55,6 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): # Ensure that filename is properly generated self.assertEqual(document.filename, Path("none/none.pdf")) - # Enable encryption and check again - document.storage_type = Document.STORAGE_TYPE_GPG - document.filename = generate_filename(document) - self.assertEqual(document.filename, Path("none/none.pdf.gpg")) - document.save() # test that creating dirs for the source_path creates the correct directory @@ -87,14 +74,14 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): settings.ORIGINALS_DIR / "none", ) self.assertIsFile( - settings.ORIGINALS_DIR / "test" / "test.pdf.gpg", + settings.ORIGINALS_DIR / "test" / "test.pdf", ) @override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}") def test_file_renaming_missing_permissions(self): document = Document() document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED + document.save() # Ensure that filename is properly generated @@ -128,14 +115,13 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): def test_file_renaming_database_error(self): Document.objects.create( mime_type="application/pdf", - storage_type=Document.STORAGE_TYPE_UNENCRYPTED, checksum="AAAAA", ) document = Document() document.mime_type = "application/pdf" document.checksum = "BBBBB" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED + document.save() # Ensure that filename is properly generated @@ -170,7 +156,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): def test_document_delete(self): document = Document() document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED + document.save() # Ensure that filename is properly generated @@ -196,7 +182,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): def test_document_delete_trash_dir(self): document = Document() document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED + document.save() # Ensure that filename is properly generated @@ -221,7 +207,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): # Create an identical document and ensure it is trashed under a new name document = Document() document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED + document.save() document.filename = generate_filename(document) document.save() @@ -235,7 +221,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): def test_document_delete_nofile(self): document = Document() document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED + document.save() document.delete() @@ -245,7 +231,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): def test_directory_not_empty(self): document = Document() document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED + document.save() # Ensure that filename is properly generated @@ -362,7 +348,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): def test_nested_directory_cleanup(self): document = Document() document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED + document.save() # Ensure that filename is properly generated @@ -390,7 +376,6 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): document = Document() document.pk = 1 document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED self.assertEqual(generate_filename(document), Path("0000001.pdf")) @@ -403,7 +388,6 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): document = Document() document.pk = 1 document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED self.assertEqual(generate_filename(document), Path("0000001.pdf")) @@ -429,7 +413,6 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): document = Document() document.pk = 1 document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED self.assertEqual(generate_filename(document), Path("0000001.pdf")) @@ -438,7 +421,6 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase): document = Document() document.pk = 1 document.mime_type = "application/pdf" - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED self.assertEqual(generate_filename(document), Path("0000001.pdf")) @@ -1258,7 +1240,7 @@ class TestFilenameGeneration(DirectoriesMixin, TestCase): title="doc1", mime_type="application/pdf", ) - document.storage_type = Document.STORAGE_TYPE_UNENCRYPTED + document.save() # Ensure that filename is properly generated @@ -1732,7 +1714,6 @@ class TestPathDateLocalization: document = DocumentFactory.create( title="My Document", mime_type="application/pdf", - storage_type=Document.STORAGE_TYPE_UNENCRYPTED, created=self.TEST_DATE, # 2023-10-26 (which is a Thursday) ) with override_settings(FILENAME_FORMAT=filename_format): diff --git a/src/documents/tests/test_management.py b/src/documents/tests/test_management.py index 014f5d673..e1b88633c 100644 --- a/src/documents/tests/test_management.py +++ b/src/documents/tests/test_management.py @@ -1,7 +1,5 @@ import filecmp -import hashlib import shutil -import tempfile from io import StringIO from pathlib import Path from unittest import mock @@ -96,66 +94,6 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase): self.assertEqual(doc2.archive_filename, "document_01.pdf") -class TestDecryptDocuments(FileSystemAssertsMixin, TestCase): - @mock.patch("documents.management.commands.decrypt_documents.input") - def test_decrypt(self, m): - media_dir = tempfile.mkdtemp() - originals_dir = Path(media_dir) / "documents" / "originals" - thumb_dir = Path(media_dir) / "documents" / "thumbnails" - originals_dir.mkdir(parents=True, exist_ok=True) - thumb_dir.mkdir(parents=True, exist_ok=True) - - with override_settings( - ORIGINALS_DIR=originals_dir, - THUMBNAIL_DIR=thumb_dir, - PASSPHRASE="test", - FILENAME_FORMAT=None, - ): - doc = Document.objects.create( - checksum="82186aaa94f0b98697d704b90fd1c072", - title="wow", - filename="0000004.pdf.gpg", - mime_type="application/pdf", - storage_type=Document.STORAGE_TYPE_GPG, - ) - - shutil.copy( - ( - Path(__file__).parent - / "samples" - / "documents" - / "originals" - / "0000004.pdf.gpg" - ), - originals_dir / "0000004.pdf.gpg", - ) - shutil.copy( - ( - Path(__file__).parent - / "samples" - / "documents" - / "thumbnails" - / "0000004.webp.gpg" - ), - thumb_dir / f"{doc.id:07}.webp.gpg", - ) - - call_command("decrypt_documents") - - doc.refresh_from_db() - - self.assertEqual(doc.storage_type, Document.STORAGE_TYPE_UNENCRYPTED) - self.assertEqual(doc.filename, "0000004.pdf") - self.assertIsFile(Path(originals_dir) / "0000004.pdf") - self.assertIsFile(doc.source_path) - self.assertIsFile(Path(thumb_dir) / f"{doc.id:07}.webp") - self.assertIsFile(doc.thumbnail_path) - - with doc.source_file as f: - checksum: str = hashlib.md5(f.read()).hexdigest() - self.assertEqual(checksum, doc.checksum) - - class TestMakeIndex(TestCase): @mock.patch("documents.management.commands.document_index.index_reindex") def test_reindex(self, m): diff --git a/src/documents/tests/test_management_consumer.py b/src/documents/tests/test_management_consumer.py index 38b9eadda..46aa3d374 100644 --- a/src/documents/tests/test_management_consumer.py +++ b/src/documents/tests/test_management_consumer.py @@ -1,438 +1,1018 @@ -import filecmp +""" +Tests for the document consumer management command. + +Tests are organized into classes by component: +- TestFileStabilityTracker: Unit tests for FileStabilityTracker +- TestConsumerFilter: Unit tests for ConsumerFilter +- TestConsumeFile: Unit tests for the _consume_file function +- TestTagsFromPath: Unit tests for _tags_from_path +- TestCommandValidation: Tests for command argument validation +- TestCommandOneshot: Tests for oneshot mode +- TestCommandWatch: Integration tests for the watch loop +""" + +from __future__ import annotations + +import re import shutil from pathlib import Path from threading import Thread +from time import monotonic from time import sleep -from unittest import mock +from typing import TYPE_CHECKING -from django.conf import settings +import pytest +from django import db from django.core.management import CommandError -from django.core.management import call_command -from django.test import TransactionTestCase +from django.db import DatabaseError from django.test import override_settings +from watchfiles import Change -from documents.consumer import ConsumerError from documents.data_models import ConsumableDocument -from documents.management.commands import document_consumer +from documents.data_models import DocumentSource +from documents.management.commands.document_consumer import Command +from documents.management.commands.document_consumer import ConsumerFilter +from documents.management.commands.document_consumer import FileStabilityTracker +from documents.management.commands.document_consumer import TrackedFile +from documents.management.commands.document_consumer import _consume_file +from documents.management.commands.document_consumer import _tags_from_path from documents.models import Tag -from documents.tests.utils import DirectoriesMixin -from documents.tests.utils import DocumentConsumeDelayMixin + +if TYPE_CHECKING: + from collections.abc import Callable + from collections.abc import Generator + from unittest.mock import MagicMock + + from pytest_django.fixtures import SettingsWrapper + from pytest_mock import MockerFixture + + +@pytest.fixture +def stability_tracker() -> FileStabilityTracker: + """Create a FileStabilityTracker with a short delay for testing.""" + return FileStabilityTracker(stability_delay=0.1) + + +@pytest.fixture +def temp_file(tmp_path: Path) -> Path: + """Create a temporary file for testing.""" + file_path = tmp_path / "test_file.pdf" + file_path.write_bytes(b"test content") + return file_path + + +@pytest.fixture +def consumption_dir(tmp_path: Path) -> Path: + """Create a temporary consumption directory for testing.""" + consume_dir = tmp_path / "consume" + consume_dir.mkdir() + return consume_dir + + +@pytest.fixture +def scratch_dir(tmp_path: Path) -> Path: + """Create a temporary scratch directory for testing.""" + scratch = tmp_path / "scratch" + scratch.mkdir() + return scratch + + +@pytest.fixture +def sample_pdf(tmp_path: Path) -> Path: + """Create a sample PDF file.""" + pdf_content = b"%PDF-1.4\n%test\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF" + pdf_path = tmp_path / "sample.pdf" + pdf_path.write_bytes(pdf_content) + return pdf_path + + +@pytest.fixture +def consumer_filter() -> ConsumerFilter: + """Create a ConsumerFilter for testing.""" + return ConsumerFilter( + supported_extensions=frozenset({".pdf", ".png", ".jpg"}), + ignore_patterns=[r"^custom_ignore"], + ) + + +@pytest.fixture +def mock_consume_file_delay(mocker: MockerFixture) -> MagicMock: + """Mock the consume_file.delay celery task.""" + mock_task = mocker.patch( + "documents.management.commands.document_consumer.consume_file", + ) + mock_task.delay = mocker.MagicMock() + return mock_task + + +@pytest.fixture +def mock_supported_extensions(mocker: MockerFixture) -> MagicMock: + """Mock get_supported_file_extensions to return only .pdf.""" + return mocker.patch( + "documents.management.commands.document_consumer.get_supported_file_extensions", + return_value={".pdf"}, + ) + + +class TestTrackedFile: + """Tests for the TrackedFile dataclass.""" + + def test_update_stats_existing_file(self, temp_file: Path) -> None: + """Test update_stats succeeds for existing file.""" + tracked = TrackedFile(path=temp_file, last_event_time=monotonic()) + assert tracked.update_stats() is True + assert tracked.last_mtime is not None + assert tracked.last_size is not None + assert tracked.last_size == len(b"test content") + + def test_update_stats_nonexistent_file(self, tmp_path: Path) -> None: + """Test update_stats fails for nonexistent file.""" + tracked = TrackedFile( + path=tmp_path / "nonexistent.pdf", + last_event_time=monotonic(), + ) + assert tracked.update_stats() is False + assert tracked.last_mtime is None + assert tracked.last_size is None + + def test_is_unchanged_same_stats(self, temp_file: Path) -> None: + """Test is_unchanged returns True when stats haven't changed.""" + tracked = TrackedFile(path=temp_file, last_event_time=monotonic()) + tracked.update_stats() + assert tracked.is_unchanged() is True + + def test_is_unchanged_modified_file(self, temp_file: Path) -> None: + """Test is_unchanged returns False when file is modified.""" + tracked = TrackedFile(path=temp_file, last_event_time=monotonic()) + tracked.update_stats() + temp_file.write_bytes(b"modified content that is longer") + assert tracked.is_unchanged() is False + + def test_is_unchanged_deleted_file(self, temp_file: Path) -> None: + """Test is_unchanged returns False when file is deleted.""" + tracked = TrackedFile(path=temp_file, last_event_time=monotonic()) + tracked.update_stats() + temp_file.unlink() + assert tracked.is_unchanged() is False + + +class TestFileStabilityTracker: + """Tests for the FileStabilityTracker class.""" + + def test_track_new_file( + self, + stability_tracker: FileStabilityTracker, + temp_file: Path, + ) -> None: + """Test tracking a new file adds it to pending.""" + stability_tracker.track(temp_file, Change.added) + assert stability_tracker.pending_count == 1 + assert stability_tracker.has_pending_files() is True + + def test_track_modified_file( + self, + stability_tracker: FileStabilityTracker, + temp_file: Path, + ) -> None: + """Test tracking a modified file updates its event time.""" + stability_tracker.track(temp_file, Change.added) + sleep(0.05) + stability_tracker.track(temp_file, Change.modified) + assert stability_tracker.pending_count == 1 + + def test_track_deleted_file( + self, + stability_tracker: FileStabilityTracker, + temp_file: Path, + ) -> None: + """Test tracking a deleted file removes it from pending.""" + stability_tracker.track(temp_file, Change.added) + assert stability_tracker.pending_count == 1 + stability_tracker.track(temp_file, Change.deleted) + assert stability_tracker.pending_count == 0 + assert stability_tracker.has_pending_files() is False + + def test_track_nonexistent_file( + self, + stability_tracker: FileStabilityTracker, + tmp_path: Path, + ) -> None: + """Test tracking a nonexistent file doesn't add it.""" + nonexistent = tmp_path / "nonexistent.pdf" + stability_tracker.track(nonexistent, Change.added) + assert stability_tracker.pending_count == 0 + + def test_get_stable_files_before_delay( + self, + stability_tracker: FileStabilityTracker, + temp_file: Path, + ) -> None: + """Test get_stable_files returns nothing before delay expires.""" + stability_tracker.track(temp_file, Change.added) + stable = list(stability_tracker.get_stable_files()) + assert len(stable) == 0 + assert stability_tracker.pending_count == 1 + + def test_get_stable_files_after_delay( + self, + stability_tracker: FileStabilityTracker, + temp_file: Path, + ) -> None: + """Test get_stable_files returns file after delay expires.""" + stability_tracker.track(temp_file, Change.added) + sleep(0.15) + stable = list(stability_tracker.get_stable_files()) + assert len(stable) == 1 + assert stable[0] == temp_file + assert stability_tracker.pending_count == 0 + + def test_get_stable_files_modified_during_check( + self, + stability_tracker: FileStabilityTracker, + temp_file: Path, + ) -> None: + """Test file is not returned if modified during stability check.""" + stability_tracker.track(temp_file, Change.added) + sleep(0.12) + temp_file.write_bytes(b"modified content") + stable = list(stability_tracker.get_stable_files()) + assert len(stable) == 0 + assert stability_tracker.pending_count == 1 + + def test_get_stable_files_deleted_during_check(self, temp_file: Path) -> None: + """Test deleted file is not returned during stability check.""" + tracker = FileStabilityTracker(stability_delay=0.1) + tracker.track(temp_file, Change.added) + sleep(0.12) + temp_file.unlink() + stable = list(tracker.get_stable_files()) + assert len(stable) == 0 + assert tracker.pending_count == 0 + + def test_get_stable_files_error_during_check( + self, + temp_file: Path, + mocker: MockerFixture, + ) -> None: + """Test a file which has become inaccessible is removed from tracking""" + + mocker.patch.object(Path, "stat", side_effect=PermissionError("denied")) + + tracker = FileStabilityTracker(stability_delay=0.1) + tracker.track(temp_file, Change.added) + stable = list(tracker.get_stable_files()) + assert len(stable) == 0 + assert tracker.pending_count == 0 + + def test_multiple_files_tracking( + self, + stability_tracker: FileStabilityTracker, + tmp_path: Path, + ) -> None: + """Test tracking multiple files independently.""" + file1 = tmp_path / "file1.pdf" + file2 = tmp_path / "file2.pdf" + file1.write_bytes(b"content1") + file2.write_bytes(b"content2") + + stability_tracker.track(file1, Change.added) + sleep(0.05) + stability_tracker.track(file2, Change.added) + + assert stability_tracker.pending_count == 2 + + sleep(0.06) + stable = list(stability_tracker.get_stable_files()) + assert len(stable) == 1 + assert stable[0] == file1 + + sleep(0.06) + stable = list(stability_tracker.get_stable_files()) + assert len(stable) == 1 + assert stable[0] == file2 + + def test_track_resolves_path( + self, + stability_tracker: FileStabilityTracker, + temp_file: Path, + ) -> None: + """Test that tracking resolves paths consistently.""" + stability_tracker.track(temp_file, Change.added) + stability_tracker.track(temp_file.resolve(), Change.modified) + assert stability_tracker.pending_count == 1 + + +class TestConsumerFilter: + """Tests for the ConsumerFilter class.""" + + @pytest.mark.parametrize( + ("filename", "should_accept"), + [ + pytest.param("document.pdf", True, id="supported_pdf"), + pytest.param("image.png", True, id="supported_png"), + pytest.param("photo.jpg", True, id="supported_jpg"), + pytest.param("document.PDF", True, id="case_insensitive"), + pytest.param("document.xyz", False, id="unsupported_ext"), + pytest.param("document", False, id="no_extension"), + pytest.param(".DS_Store", False, id="ds_store"), + pytest.param(".DS_STORE", False, id="ds_store_upper"), + pytest.param("._document.pdf", False, id="macos_resource_fork"), + pytest.param("._hidden", False, id="macos_resource_no_ext"), + pytest.param("Thumbs.db", False, id="thumbs_db"), + pytest.param("desktop.ini", False, id="desktop_ini"), + pytest.param("custom_ignore_this.pdf", False, id="custom_pattern"), + pytest.param("stfolder.pdf", True, id="similar_to_ignored"), + pytest.param("my_document.pdf", True, id="normal_with_underscore"), + ], + ) + def test_file_filtering( + self, + consumer_filter: ConsumerFilter, + tmp_path: Path, + filename: str, + should_accept: bool, # noqa: FBT001 + ) -> None: + """Test filter correctly accepts or rejects files.""" + test_file = tmp_path / filename + test_file.touch() + assert consumer_filter(Change.added, str(test_file)) is should_accept + + @pytest.mark.parametrize( + ("dirname", "should_accept"), + [ + pytest.param(".stfolder", False, id="syncthing_stfolder"), + pytest.param(".stversions", False, id="syncthing_stversions"), + pytest.param("@eaDir", False, id="synology_eadir"), + pytest.param(".Spotlight-V100", False, id="macos_spotlight"), + pytest.param(".Trashes", False, id="macos_trashes"), + pytest.param("__MACOSX", False, id="macos_archive"), + pytest.param(".localized", False, id="macos_localized"), + pytest.param("documents", True, id="normal_dir"), + pytest.param("invoices", True, id="normal_dir_2"), + ], + ) + def test_directory_filtering( + self, + consumer_filter: ConsumerFilter, + tmp_path: Path, + dirname: str, + should_accept: bool, # noqa: FBT001 + ) -> None: + """Test filter correctly accepts or rejects directories.""" + test_dir = tmp_path / dirname + test_dir.mkdir() + assert consumer_filter(Change.added, str(test_dir)) is should_accept + + def test_default_patterns_are_valid_regex(self) -> None: + """Test that default patterns are valid regex.""" + for pattern in ConsumerFilter.DEFAULT_IGNORE_PATTERNS: + re.compile(pattern) + + def test_custom_ignore_dirs(self, tmp_path: Path) -> None: + """Test filter respects custom ignore_dirs.""" + filter_obj = ConsumerFilter( + supported_extensions=frozenset({".pdf"}), + ignore_dirs=["custom_ignored_dir"], + ) + + # Custom ignored directory should be rejected + custom_dir = tmp_path / "custom_ignored_dir" + custom_dir.mkdir() + assert filter_obj(Change.added, str(custom_dir)) is False + + # Normal directory should be accepted + normal_dir = tmp_path / "normal_dir" + normal_dir.mkdir() + assert filter_obj(Change.added, str(normal_dir)) is True + + # Default ignored directories should still be ignored + stfolder = tmp_path / ".stfolder" + stfolder.mkdir() + assert filter_obj(Change.added, str(stfolder)) is False + + +class TestConsumerFilterDefaults: + """Tests for ConsumerFilter with default settings.""" + + def test_filter_with_mocked_extensions( + self, + tmp_path: Path, + mocker: MockerFixture, + ) -> None: + """Test filter works when using mocked extensions from parser.""" + mocker.patch( + "documents.management.commands.document_consumer.get_supported_file_extensions", + return_value={".pdf", ".png"}, + ) + filter_obj = ConsumerFilter() + test_file = tmp_path / "document.pdf" + test_file.touch() + assert filter_obj(Change.added, str(test_file)) is True + + +class TestConsumeFile: + """Tests for the _consume_file function.""" + + def test_consume_queues_file( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + ) -> None: + """Test _consume_file queues a valid file.""" + target = consumption_dir / "document.pdf" + shutil.copy(sample_pdf, target) + + _consume_file( + filepath=target, + consumption_dir=consumption_dir, + subdirs_as_tags=False, + ) + + mock_consume_file_delay.delay.assert_called_once() + call_args = mock_consume_file_delay.delay.call_args + consumable_doc = call_args[0][0] + assert isinstance(consumable_doc, ConsumableDocument) + assert consumable_doc.original_file == target + assert consumable_doc.source == DocumentSource.ConsumeFolder + + def test_consume_nonexistent_file( + self, + consumption_dir: Path, + mock_consume_file_delay: MagicMock, + ) -> None: + """Test _consume_file handles nonexistent files gracefully.""" + _consume_file( + filepath=consumption_dir / "nonexistent.pdf", + consumption_dir=consumption_dir, + subdirs_as_tags=False, + ) + mock_consume_file_delay.delay.assert_not_called() + + def test_consume_directory( + self, + consumption_dir: Path, + mock_consume_file_delay: MagicMock, + ) -> None: + """Test _consume_file ignores directories.""" + subdir = consumption_dir / "subdir" + subdir.mkdir() + + _consume_file( + filepath=subdir, + consumption_dir=consumption_dir, + subdirs_as_tags=False, + ) + mock_consume_file_delay.delay.assert_not_called() + + def test_consume_with_permission_error( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + mocker: MockerFixture, + ) -> None: + """Test _consume_file handles permission errors.""" + target = consumption_dir / "document.pdf" + shutil.copy(sample_pdf, target) + + mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied")) + _consume_file( + filepath=target, + consumption_dir=consumption_dir, + subdirs_as_tags=False, + ) + mock_consume_file_delay.delay.assert_not_called() + + def test_consume_with_tags_error( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + mocker: MockerFixture, + ) -> None: + """Test _consume_file handles errors during tag creation""" + target = consumption_dir / "document.pdf" + shutil.copy(sample_pdf, target) + + mocker.patch( + "documents.management.commands.document_consumer._tags_from_path", + side_effect=DatabaseError("Something happened"), + ) + + _consume_file( + filepath=target, + consumption_dir=consumption_dir, + subdirs_as_tags=True, + ) + mock_consume_file_delay.delay.assert_called_once() + call_args = mock_consume_file_delay.delay.call_args + overrides = call_args[0][1] + assert overrides.tag_ids is None + + +@pytest.mark.django_db +class TestTagsFromPath: + """Tests for the _tags_from_path function.""" + + def test_creates_tags_from_subdirectories(self, consumption_dir: Path) -> None: + """Test tags are created for each subdirectory.""" + subdir = consumption_dir / "Invoice" / "2024" + subdir.mkdir(parents=True) + target = subdir / "document.pdf" + target.touch() + + tag_ids = _tags_from_path(target, consumption_dir) + + assert len(tag_ids) == 2 + assert Tag.objects.filter(name="Invoice").exists() + assert Tag.objects.filter(name="2024").exists() + + def test_reuses_existing_tags(self, consumption_dir: Path) -> None: + """Test existing tags are reused (case-insensitive).""" + existing_tag = Tag.objects.create(name="existing") + + subdir = consumption_dir / "EXISTING" + subdir.mkdir(parents=True) + target = subdir / "document.pdf" + target.touch() + + tag_ids = _tags_from_path(target, consumption_dir) + + assert len(tag_ids) == 1 + assert existing_tag.pk in tag_ids + assert Tag.objects.filter(name__iexact="existing").count() == 1 + + def test_no_tags_for_root_file(self, consumption_dir: Path) -> None: + """Test no tags created for files directly in consumption dir.""" + target = consumption_dir / "document.pdf" + target.touch() + + tag_ids = _tags_from_path(target, consumption_dir) + + assert len(tag_ids) == 0 + + +class TestCommandValidation: + """Tests for command argument validation.""" + + def test_raises_for_missing_consumption_dir( + self, + settings: SettingsWrapper, + ) -> None: + """Test command raises error when directory is not provided.""" + settings.CONSUMPTION_DIR = None + with pytest.raises(CommandError, match="not configured"): + cmd = Command() + cmd.handle(directory=None, oneshot=True, testing=False) + + def test_raises_for_nonexistent_directory(self, tmp_path: Path) -> None: + """Test command raises error for nonexistent directory.""" + nonexistent = tmp_path / "nonexistent" + + with pytest.raises(CommandError, match="does not exist"): + cmd = Command() + cmd.handle(directory=str(nonexistent), oneshot=True, testing=False) + + def test_raises_for_file_instead_of_directory(self, sample_pdf: Path) -> None: + """Test command raises error when path is a file, not directory.""" + with pytest.raises(CommandError, match="not a directory"): + cmd = Command() + cmd.handle(directory=str(sample_pdf), oneshot=True, testing=False) + + +@pytest.mark.usefixtures("mock_supported_extensions") +class TestCommandOneshot: + """Tests for oneshot mode.""" + + def test_processes_existing_files( + self, + consumption_dir: Path, + scratch_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + settings: SettingsWrapper, + ) -> None: + """Test oneshot mode processes existing files.""" + target = consumption_dir / "document.pdf" + shutil.copy(sample_pdf, target) + + settings.SCRATCH_DIR = scratch_dir + settings.CONSUMER_IGNORE_PATTERNS = [] + + cmd = Command() + cmd.handle(directory=str(consumption_dir), oneshot=True, testing=False) + + mock_consume_file_delay.delay.assert_called_once() + + def test_processes_recursive( + self, + consumption_dir: Path, + scratch_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + settings: SettingsWrapper, + ) -> None: + """Test oneshot mode processes files recursively.""" + subdir = consumption_dir / "subdir" + subdir.mkdir() + target = subdir / "document.pdf" + shutil.copy(sample_pdf, target) + + settings.SCRATCH_DIR = scratch_dir + settings.CONSUMER_RECURSIVE = True + settings.CONSUMER_IGNORE_PATTERNS = [] + + cmd = Command() + cmd.handle(directory=str(consumption_dir), oneshot=True, testing=False) + + mock_consume_file_delay.delay.assert_called_once() + + def test_ignores_unsupported_extensions( + self, + consumption_dir: Path, + scratch_dir: Path, + mock_consume_file_delay: MagicMock, + settings: SettingsWrapper, + ) -> None: + """Test oneshot mode ignores unsupported file extensions.""" + target = consumption_dir / "document.xyz" + target.write_bytes(b"content") + + settings.SCRATCH_DIR = scratch_dir + settings.CONSUMER_IGNORE_PATTERNS = [] + + cmd = Command() + cmd.handle(directory=str(consumption_dir), oneshot=True, testing=False) + + mock_consume_file_delay.delay.assert_not_called() class ConsumerThread(Thread): - def __init__(self): + """Thread wrapper for running the consumer command with proper cleanup.""" + + def __init__( + self, + consumption_dir: Path, + scratch_dir: Path, + *, + recursive: bool = False, + subdirs_as_tags: bool = False, + polling_interval: float = 0, + stability_delay: float = 0.1, + ) -> None: super().__init__() - self.cmd = document_consumer.Command() + self.consumption_dir = consumption_dir + self.scratch_dir = scratch_dir + self.recursive = recursive + self.subdirs_as_tags = subdirs_as_tags + self.polling_interval = polling_interval + self.stability_delay = stability_delay + self.cmd = Command() self.cmd.stop_flag.clear() + # Non-daemon ensures finally block runs and connections are closed + self.daemon = False + self.exception: Exception | None = None def run(self) -> None: - self.cmd.handle(directory=settings.CONSUMPTION_DIR, oneshot=False, testing=True) + try: + # Use override_settings to avoid polluting global settings + # which would affect other tests running on the same worker + with override_settings( + SCRATCH_DIR=self.scratch_dir, + CONSUMER_RECURSIVE=self.recursive, + CONSUMER_SUBDIRS_AS_TAGS=self.subdirs_as_tags, + CONSUMER_POLLING_INTERVAL=self.polling_interval, + CONSUMER_STABILITY_DELAY=self.stability_delay, + CONSUMER_IGNORE_PATTERNS=[], + ): + self.cmd.handle( + directory=str(self.consumption_dir), + oneshot=False, + testing=True, + ) + except Exception as e: + self.exception = e + finally: + # Close database connections created in this thread + db.connections.close_all() - def stop(self): - # Consumer checks this every second. + def stop(self) -> None: self.cmd.stop_flag.set() - -def chunked(size, source): - for i in range(0, len(source), size): - yield source[i : i + size] - - -class ConsumerThreadMixin(DocumentConsumeDelayMixin): - """ - Provides a thread which runs the consumer management command at setUp - and stops it at tearDown - """ - - sample_file: Path = ( - Path(__file__).parent / Path("samples") / Path("simple.pdf") - ).resolve() - - def setUp(self) -> None: - super().setUp() - self.t = None - - def t_start(self): - self.t = ConsumerThread() - self.t.start() - # give the consumer some time to do initial work - sleep(1) - - def tearDown(self) -> None: - if self.t: - # set the stop flag - self.t.stop() - # wait for the consumer to exit. - self.t.join() - self.t = None - - super().tearDown() - - def wait_for_task_mock_call(self, expected_call_count=1): - n = 0 - while n < 50: - if self.consume_file_mock.call_count >= expected_call_count: - # give task_mock some time to finish and raise errors - sleep(1) - return - n += 1 - sleep(0.1) - - # A bogus async_task that will simply check the file for - # completeness and raise an exception otherwise. - def bogus_task( - self, - input_doc: ConsumableDocument, - overrides=None, - ): - eq = filecmp.cmp(input_doc.original_file, self.sample_file, shallow=False) - if not eq: - print("Consumed an INVALID file.") # noqa: T201 - raise ConsumerError("Incomplete File READ FAILED") - else: - print("Consumed a perfectly valid file.") # noqa: T201 - - def slow_write_file(self, target, *, incomplete=False): - with Path(self.sample_file).open("rb") as f: - pdf_bytes = f.read() - - if incomplete: - pdf_bytes = pdf_bytes[: len(pdf_bytes) - 100] - - with Path(target).open("wb") as f: - # this will take 2 seconds, since the file is about 20k. - print("Start writing file.") # noqa: T201 - for b in chunked(1000, pdf_bytes): - f.write(b) - sleep(0.1) - print("file completed.") # noqa: T201 - - -@override_settings( - CONSUMER_INOTIFY_DELAY=0.01, -) -class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase): - def test_consume_file(self): - self.t_start() - - f = Path(self.dirs.consumption_dir) / "my_file.pdf" - shutil.copy(self.sample_file, f) - - self.wait_for_task_mock_call() - - self.consume_file_mock.assert_called_once() - - input_doc, _ = self.get_last_consume_delay_call_args() - - self.assertEqual(input_doc.original_file, f) - - def test_consume_file_invalid_ext(self): - self.t_start() - - f = Path(self.dirs.consumption_dir) / "my_file.wow" - shutil.copy(self.sample_file, f) - - self.wait_for_task_mock_call() - - self.consume_file_mock.assert_not_called() - - def test_consume_existing_file(self): - f = Path(self.dirs.consumption_dir) / "my_file.pdf" - shutil.copy(self.sample_file, f) - - self.t_start() - self.consume_file_mock.assert_called_once() - - input_doc, _ = self.get_last_consume_delay_call_args() - - self.assertEqual(input_doc.original_file, f) - - @mock.patch("documents.management.commands.document_consumer.logger.error") - def test_slow_write_pdf(self, error_logger): - self.consume_file_mock.side_effect = self.bogus_task - - self.t_start() - - fname = Path(self.dirs.consumption_dir) / "my_file.pdf" - - self.slow_write_file(fname) - - self.wait_for_task_mock_call() - - error_logger.assert_not_called() - - self.consume_file_mock.assert_called_once() - - input_doc, _ = self.get_last_consume_delay_call_args() - - self.assertEqual(input_doc.original_file, fname) - - @mock.patch("documents.management.commands.document_consumer.logger.error") - def test_slow_write_and_move(self, error_logger): - self.consume_file_mock.side_effect = self.bogus_task - - self.t_start() - - fname = Path(self.dirs.consumption_dir) / "my_file.~df" - fname2 = Path(self.dirs.consumption_dir) / "my_file.pdf" - - self.slow_write_file(fname) - shutil.move(fname, fname2) - - self.wait_for_task_mock_call() - - self.consume_file_mock.assert_called_once() - - input_doc, _ = self.get_last_consume_delay_call_args() - - self.assertEqual(input_doc.original_file, fname2) - - error_logger.assert_not_called() - - @mock.patch("documents.management.commands.document_consumer.logger.error") - def test_slow_write_incomplete(self, error_logger): - self.consume_file_mock.side_effect = self.bogus_task - - self.t_start() - - fname = Path(self.dirs.consumption_dir) / "my_file.pdf" - self.slow_write_file(fname, incomplete=True) - - self.wait_for_task_mock_call() - - self.consume_file_mock.assert_called_once() - - input_doc, _ = self.get_last_consume_delay_call_args() - - self.assertEqual(input_doc.original_file, fname) - - # assert that we have an error logged with this invalid file. - error_logger.assert_called_once() - - @mock.patch("documents.management.commands.document_consumer.logger.warning") - def test_permission_error_on_prechecks(self, warning_logger): - filepath = Path(self.dirs.consumption_dir) / "selinux.txt" - filepath.touch() - - original_stat = Path.stat - - def raising_stat(self, *args, **kwargs): - if self == filepath: - raise PermissionError("Permission denied") - return original_stat(self, *args, **kwargs) - - with mock.patch("pathlib.Path.stat", new=raising_stat): - document_consumer._consume(filepath) - - warning_logger.assert_called_once() - (args, _) = warning_logger.call_args - self.assertIn("Permission denied", args[0]) - self.consume_file_mock.assert_not_called() - - @override_settings(CONSUMPTION_DIR="does_not_exist") - def test_consumption_directory_invalid(self): - self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot") - - @override_settings(CONSUMPTION_DIR="") - def test_consumption_directory_unset(self): - self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot") - - def test_mac_write(self): - self.consume_file_mock.side_effect = self.bogus_task - - self.t_start() - - shutil.copy( - self.sample_file, - Path(self.dirs.consumption_dir) / ".DS_STORE", - ) - shutil.copy( - self.sample_file, - Path(self.dirs.consumption_dir) / "my_file.pdf", - ) - shutil.copy( - self.sample_file, - Path(self.dirs.consumption_dir) / "._my_file.pdf", - ) - shutil.copy( - self.sample_file, - Path(self.dirs.consumption_dir) / "my_second_file.pdf", - ) - shutil.copy( - self.sample_file, - Path(self.dirs.consumption_dir) / "._my_second_file.pdf", - ) - - sleep(5) - - self.wait_for_task_mock_call(expected_call_count=2) - - self.assertEqual(2, self.consume_file_mock.call_count) - - consumed_files = [] - for input_doc, _ in self.get_all_consume_delay_call_args(): - consumed_files.append(input_doc.original_file.name) - - self.assertCountEqual(consumed_files, ["my_file.pdf", "my_second_file.pdf"]) - - def test_is_ignored(self): - test_paths = [ - { - "path": str(Path(self.dirs.consumption_dir) / "foo.pdf"), - "ignore": False, - }, - { - "path": str( - Path(self.dirs.consumption_dir) / "foo" / "bar.pdf", - ), - "ignore": False, - }, - { - "path": str(Path(self.dirs.consumption_dir) / ".DS_STORE"), - "ignore": True, - }, - { - "path": str(Path(self.dirs.consumption_dir) / ".DS_Store"), - "ignore": True, - }, - { - "path": str( - Path(self.dirs.consumption_dir) / ".stfolder" / "foo.pdf", - ), - "ignore": True, - }, - { - "path": str(Path(self.dirs.consumption_dir) / ".stfolder.pdf"), - "ignore": False, - }, - { - "path": str( - Path(self.dirs.consumption_dir) / ".stversions" / "foo.pdf", - ), - "ignore": True, - }, - { - "path": str( - Path(self.dirs.consumption_dir) / ".stversions.pdf", - ), - "ignore": False, - }, - { - "path": str(Path(self.dirs.consumption_dir) / "._foo.pdf"), - "ignore": True, - }, - { - "path": str(Path(self.dirs.consumption_dir) / "my_foo.pdf"), - "ignore": False, - }, - { - "path": str( - Path(self.dirs.consumption_dir) / "._foo" / "bar.pdf", - ), - "ignore": True, - }, - { - "path": str( - Path(self.dirs.consumption_dir) - / "@eaDir" - / "SYNO@.fileindexdb" - / "_1jk.fnm", - ), - "ignore": True, - }, - ] - for test_setup in test_paths: - filepath = test_setup["path"] - expected_ignored_result = test_setup["ignore"] - self.assertEqual( - expected_ignored_result, - document_consumer._is_ignored(filepath), - f'_is_ignored("{filepath}") != {expected_ignored_result}', + def stop_and_wait(self, timeout: float = 5.0) -> None: + """Stop the thread and wait for it to finish, with cleanup.""" + self.stop() + self.join(timeout=timeout) + if self.is_alive(): + # Thread didn't stop in time - this is a test failure + raise RuntimeError( + f"Consumer thread did not stop within {timeout}s timeout", ) - @mock.patch("documents.management.commands.document_consumer.Path.open") - def test_consume_file_busy(self, open_mock): - # Calling this mock always raises this - open_mock.side_effect = OSError - self.t_start() +@pytest.fixture +def start_consumer( + consumption_dir: Path, + scratch_dir: Path, + mock_supported_extensions: MagicMock, +) -> Generator[Callable[..., ConsumerThread], None, None]: + """Start a consumer thread and ensure cleanup.""" + threads: list[ConsumerThread] = [] - f = Path(self.dirs.consumption_dir) / "my_file.pdf" - shutil.copy(self.sample_file, f) + def _start(**kwargs) -> ConsumerThread: + thread = ConsumerThread(consumption_dir, scratch_dir, **kwargs) + threads.append(thread) + thread.start() + sleep(0.5) # Give thread time to start + return thread - self.wait_for_task_mock_call() + try: + yield _start + finally: + # Cleanup all threads that were started + for thread in threads: + thread.stop_and_wait() - self.consume_file_mock.assert_not_called() + failed_threads = [] + for thread in threads: + thread.join(timeout=5.0) + if thread.is_alive(): + failed_threads.append(thread) + + # Clean up any Tags created by threads (they bypass test transaction isolation) + Tag.objects.all().delete() + + db.connections.close_all() + + if failed_threads: + pytest.fail( + f"{len(failed_threads)} consumer thread(s) did not stop within timeout", + ) -@override_settings( - CONSUMER_POLLING=1, - # please leave the delay here and down below - # see https://github.com/paperless-ngx/paperless-ngx/pull/66 - CONSUMER_POLLING_DELAY=3, - CONSUMER_POLLING_RETRY_COUNT=20, -) -class TestConsumerPolling(TestConsumer): - # just do all the tests with polling - pass +@pytest.mark.django_db +class TestCommandWatch: + """Integration tests for the watch loop.""" + + def test_detects_new_file( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + start_consumer: Callable[..., ConsumerThread], + ) -> None: + """Test watch mode detects and consumes new files.""" + thread = start_consumer() + + target = consumption_dir / "document.pdf" + shutil.copy(sample_pdf, target) + sleep(0.5) + + if thread.exception: + raise thread.exception + + mock_consume_file_delay.delay.assert_called() + + def test_detects_moved_file( + self, + consumption_dir: Path, + scratch_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + start_consumer: Callable[..., ConsumerThread], + ) -> None: + """Test watch mode detects moved/renamed files.""" + temp_location = scratch_dir / "temp.pdf" + shutil.copy(sample_pdf, temp_location) + + thread = start_consumer() + + target = consumption_dir / "document.pdf" + shutil.move(temp_location, target) + sleep(0.5) + + if thread.exception: + raise thread.exception + + mock_consume_file_delay.delay.assert_called() + + def test_handles_slow_write( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + start_consumer: Callable[..., ConsumerThread], + ) -> None: + """Test watch mode waits for slow writes to complete.""" + pdf_bytes = sample_pdf.read_bytes() + + thread = start_consumer(stability_delay=0.2) + + target = consumption_dir / "document.pdf" + with target.open("wb") as f: + for i in range(0, len(pdf_bytes), 100): + f.write(pdf_bytes[i : i + 100]) + f.flush() + sleep(0.05) + + sleep(0.5) + + if thread.exception: + raise thread.exception + + mock_consume_file_delay.delay.assert_called() + + def test_ignores_macos_files( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + start_consumer: Callable[..., ConsumerThread], + ) -> None: + """Test watch mode ignores macOS system files.""" + thread = start_consumer() + + (consumption_dir / ".DS_Store").write_bytes(b"test") + (consumption_dir / "._document.pdf").write_bytes(b"test") + shutil.copy(sample_pdf, consumption_dir / "valid.pdf") + + sleep(0.5) + + if thread.exception: + raise thread.exception + + assert mock_consume_file_delay.delay.call_count == 1 + call_args = mock_consume_file_delay.delay.call_args[0][0] + assert call_args.original_file.name == "valid.pdf" + + @pytest.mark.django_db + @pytest.mark.usefixtures("mock_supported_extensions") + def test_stop_flag_stops_consumer( + self, + consumption_dir: Path, + scratch_dir: Path, + mock_consume_file_delay: MagicMock, + ) -> None: + """Test stop flag properly stops the consumer.""" + thread = ConsumerThread(consumption_dir, scratch_dir) + try: + thread.start() + sleep(0.3) + assert thread.is_alive() + finally: + thread.stop_and_wait(timeout=5.0) + # Clean up any Tags created by the thread + Tag.objects.all().delete() + + assert not thread.is_alive() -@override_settings(CONSUMER_INOTIFY_DELAY=0.01, CONSUMER_RECURSIVE=True) -class TestConsumerRecursive(TestConsumer): - # just do all the tests with recursive - pass +class TestCommandWatchPolling: + """Tests for polling mode.""" + + @pytest.mark.django_db + @pytest.mark.flaky(reruns=2) + def test_polling_mode_works( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + start_consumer: Callable[..., ConsumerThread], + ) -> None: + """ + Test polling mode detects files. + Note: At times, there appears to be a timing issue, where delay has not yet been called, hence this is marked as flaky. + """ + # Use shorter polling interval for faster test + thread = start_consumer(polling_interval=0.5, stability_delay=0.1) + + target = consumption_dir / "document.pdf" + shutil.copy(sample_pdf, target) + + # Wait for: poll interval + stability delay + another poll + margin + # CI can be slow, so use generous timeout + sleep(3.0) + + if thread.exception: + raise thread.exception + + mock_consume_file_delay.delay.assert_called() -@override_settings( - CONSUMER_RECURSIVE=True, - CONSUMER_POLLING=1, - CONSUMER_POLLING_DELAY=3, - CONSUMER_POLLING_RETRY_COUNT=20, -) -class TestConsumerRecursivePolling(TestConsumer): - # just do all the tests with polling and recursive - pass +@pytest.mark.django_db +class TestCommandWatchRecursive: + """Tests for recursive watching.""" + + def test_recursive_detects_nested_files( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + start_consumer: Callable[..., ConsumerThread], + ) -> None: + """Test recursive mode detects files in subdirectories.""" + subdir = consumption_dir / "level1" / "level2" + subdir.mkdir(parents=True) + + thread = start_consumer(recursive=True) + + target = subdir / "document.pdf" + shutil.copy(sample_pdf, target) + sleep(0.5) + + if thread.exception: + raise thread.exception + + mock_consume_file_delay.delay.assert_called() + + def test_subdirs_as_tags( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + start_consumer: Callable[..., ConsumerThread], + mocker: MockerFixture, + ) -> None: + """Test subdirs_as_tags creates tags from directory names.""" + # Mock _tags_from_path to avoid database operations in the consumer thread + mock_tags = mocker.patch( + "documents.management.commands.document_consumer._tags_from_path", + return_value=[1, 2], + ) + + subdir = consumption_dir / "Invoices" / "2024" + subdir.mkdir(parents=True) + + thread = start_consumer(recursive=True, subdirs_as_tags=True) + + target = subdir / "document.pdf" + shutil.copy(sample_pdf, target) + sleep(0.5) + + if thread.exception: + raise thread.exception + + mock_consume_file_delay.delay.assert_called() + mock_tags.assert_called() + call_args = mock_consume_file_delay.delay.call_args + overrides = call_args[0][1] + assert overrides.tag_ids is not None + assert len(overrides.tag_ids) == 2 -class TestConsumerTags(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase): - @override_settings(CONSUMER_RECURSIVE=True, CONSUMER_SUBDIRS_AS_TAGS=True) - def test_consume_file_with_path_tags(self): - tag_names = ("existingTag", "Space Tag") - # Create a Tag prior to consuming a file using it in path - tag_ids = [ - Tag.objects.create(name="existingtag").pk, - ] +@pytest.mark.django_db +class TestCommandWatchEdgeCases: + """Tests for edge cases and error handling.""" - self.t_start() + def test_handles_deleted_before_stable( + self, + consumption_dir: Path, + sample_pdf: Path, + mock_consume_file_delay: MagicMock, + start_consumer: Callable[..., ConsumerThread], + ) -> None: + """Test handles files deleted before becoming stable.""" + thread = start_consumer(stability_delay=0.3) - path = Path(self.dirs.consumption_dir) / "/".join(tag_names) - path.mkdir(parents=True, exist_ok=True) - f = path / "my_file.pdf" - # Wait at least inotify read_delay for recursive watchers - # to be created for the new directories - sleep(1) - shutil.copy(self.sample_file, f) + target = consumption_dir / "document.pdf" + shutil.copy(sample_pdf, target) + sleep(0.1) + target.unlink() - self.wait_for_task_mock_call() + sleep(0.5) - self.consume_file_mock.assert_called_once() + if thread.exception: + raise thread.exception - # Add the pk of the Tag created by _consume() - tag_ids.append(Tag.objects.get(name=tag_names[1]).pk) + mock_consume_file_delay.delay.assert_not_called() - input_doc, overrides = self.get_last_consume_delay_call_args() + @pytest.mark.usefixtures("mock_supported_extensions") + def test_handles_task_exception( + self, + consumption_dir: Path, + scratch_dir: Path, + sample_pdf: Path, + mocker: MockerFixture, + ) -> None: + """Test handles exceptions from consume task gracefully.""" + mock_task = mocker.patch( + "documents.management.commands.document_consumer.consume_file", + ) + mock_task.delay.side_effect = Exception("Task error") - self.assertEqual(input_doc.original_file, f) + thread = ConsumerThread(consumption_dir, scratch_dir) + try: + thread.start() + sleep(0.3) - # assertCountEqual has a bad name, but test that the first - # sequence contains the same elements as second, regardless of - # their order. - self.assertCountEqual(overrides.tag_ids, tag_ids) + target = consumption_dir / "document.pdf" + shutil.copy(sample_pdf, target) + sleep(0.5) - @override_settings( - CONSUMER_POLLING=1, - CONSUMER_POLLING_DELAY=3, - CONSUMER_POLLING_RETRY_COUNT=20, - ) - def test_consume_file_with_path_tags_polling(self): - self.test_consume_file_with_path_tags() + # Consumer should still be running despite the exception + assert thread.is_alive() + finally: + thread.stop_and_wait(timeout=5.0) + # Clean up any Tags created by the thread + Tag.objects.all().delete() diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py index b01b8d47e..81262779a 100644 --- a/src/documents/tests/test_management_exporter.py +++ b/src/documents/tests/test_management_exporter.py @@ -86,9 +86,8 @@ class TestExportImport( content="Content", checksum="82186aaa94f0b98697d704b90fd1c072", title="wow_dec", - filename="0000004.pdf.gpg", + filename="0000004.pdf", mime_type="application/pdf", - storage_type=Document.STORAGE_TYPE_GPG, ) self.note = Note.objects.create( @@ -242,11 +241,6 @@ class TestExportImport( checksum = hashlib.md5(f.read()).hexdigest() self.assertEqual(checksum, element["fields"]["checksum"]) - self.assertEqual( - element["fields"]["storage_type"], - Document.STORAGE_TYPE_UNENCRYPTED, - ) - if document_exporter.EXPORTER_ARCHIVE_NAME in element: fname = ( self.target / element[document_exporter.EXPORTER_ARCHIVE_NAME] @@ -436,7 +430,7 @@ class TestExportImport( Document.objects.create( checksum="AAAAAAAAAAAAAAAAA", title="wow", - filename="0000004.pdf", + filename="0000010.pdf", mime_type="application/pdf", ) self.assertRaises(FileNotFoundError, call_command, "document_exporter", target) diff --git a/src/documents/tests/test_management_superuser.py b/src/documents/tests/test_management_superuser.py index 01f03c8e1..343d5f568 100644 --- a/src/documents/tests/test_management_superuser.py +++ b/src/documents/tests/test_management_superuser.py @@ -33,8 +33,7 @@ class TestManageSuperUser(DirectoriesMixin, TestCase): # just the consumer user which is created # during migration, and AnonymousUser - self.assertEqual(User.objects.count(), 2) - self.assertTrue(User.objects.filter(username="consumer").exists()) + self.assertEqual(User.objects.count(), 1) self.assertEqual(User.objects.filter(is_superuser=True).count(), 0) self.assertEqual( out, @@ -54,7 +53,7 @@ class TestManageSuperUser(DirectoriesMixin, TestCase): # count is 3 as there's the consumer # user already created during migration, and AnonymousUser user: User = User.objects.get_by_natural_key("admin") - self.assertEqual(User.objects.count(), 3) + self.assertEqual(User.objects.count(), 2) self.assertTrue(user.is_superuser) self.assertEqual(user.email, "root@localhost") self.assertEqual(out, 'Created superuser "admin" with provided password.\n') @@ -71,7 +70,7 @@ class TestManageSuperUser(DirectoriesMixin, TestCase): out = self.call_command(environ={"PAPERLESS_ADMIN_PASSWORD": "123456"}) - self.assertEqual(User.objects.count(), 3) + self.assertEqual(User.objects.count(), 2) with self.assertRaises(User.DoesNotExist): User.objects.get_by_natural_key("admin") self.assertEqual( @@ -92,7 +91,7 @@ class TestManageSuperUser(DirectoriesMixin, TestCase): out = self.call_command(environ={"PAPERLESS_ADMIN_PASSWORD": "123456"}) - self.assertEqual(User.objects.count(), 3) + self.assertEqual(User.objects.count(), 2) user: User = User.objects.get_by_natural_key("admin") self.assertTrue(user.check_password("password")) self.assertEqual(out, "Did not create superuser, a user admin already exists\n") @@ -111,7 +110,7 @@ class TestManageSuperUser(DirectoriesMixin, TestCase): out = self.call_command(environ={"PAPERLESS_ADMIN_PASSWORD": "123456"}) - self.assertEqual(User.objects.count(), 3) + self.assertEqual(User.objects.count(), 2) user: User = User.objects.get_by_natural_key("admin") self.assertTrue(user.check_password("password")) self.assertFalse(user.is_superuser) @@ -150,7 +149,7 @@ class TestManageSuperUser(DirectoriesMixin, TestCase): ) user: User = User.objects.get_by_natural_key("admin") - self.assertEqual(User.objects.count(), 3) + self.assertEqual(User.objects.count(), 2) self.assertTrue(user.is_superuser) self.assertEqual(user.email, "hello@world.com") self.assertEqual(user.username, "admin") @@ -174,7 +173,7 @@ class TestManageSuperUser(DirectoriesMixin, TestCase): ) user: User = User.objects.get_by_natural_key("super") - self.assertEqual(User.objects.count(), 3) + self.assertEqual(User.objects.count(), 2) self.assertTrue(user.is_superuser) self.assertEqual(user.email, "hello@world.com") self.assertEqual(user.username, "super") diff --git a/src/documents/tests/test_migration_archive_files.py b/src/documents/tests/test_migration_archive_files.py deleted file mode 100644 index a2e8a5f8f..000000000 --- a/src/documents/tests/test_migration_archive_files.py +++ /dev/null @@ -1,574 +0,0 @@ -import hashlib -import importlib -import shutil -from pathlib import Path -from unittest import mock - -import pytest -from django.conf import settings -from django.test import override_settings - -from documents.parsers import ParseError -from documents.tests.utils import DirectoriesMixin -from documents.tests.utils import FileSystemAssertsMixin -from documents.tests.utils import TestMigrations - -STORAGE_TYPE_GPG = "gpg" - -migration_1012_obj = importlib.import_module( - "documents.migrations.1012_fix_archive_files", -) - - -def archive_name_from_filename(filename: Path) -> Path: - return Path(filename.stem + ".pdf") - - -def archive_path_old(self) -> Path: - if self.filename: - fname = archive_name_from_filename(Path(self.filename)) - else: - fname = Path(f"{self.pk:07}.pdf") - - return Path(settings.ARCHIVE_DIR) / fname - - -def archive_path_new(doc): - if doc.archive_filename is not None: - return Path(settings.ARCHIVE_DIR) / str(doc.archive_filename) - else: - return None - - -def source_path(doc): - if doc.filename: - fname = str(doc.filename) - else: - fname = f"{doc.pk:07}{doc.file_type}" - if doc.storage_type == STORAGE_TYPE_GPG: - fname += ".gpg" # pragma: no cover - - return Path(settings.ORIGINALS_DIR) / fname - - -def thumbnail_path(doc): - file_name = f"{doc.pk:07}.png" - if doc.storage_type == STORAGE_TYPE_GPG: - file_name += ".gpg" - - return Path(settings.THUMBNAIL_DIR) / file_name - - -def make_test_document( - document_class, - title: str, - mime_type: str, - original: str, - original_filename: str, - archive: str | None = None, - archive_filename: str | None = None, -): - doc = document_class() - doc.filename = original_filename - doc.title = title - doc.mime_type = mime_type - doc.content = "the content, does not matter for this test" - doc.save() - - shutil.copy2(original, source_path(doc)) - with Path(original).open("rb") as f: - doc.checksum = hashlib.md5(f.read()).hexdigest() - - if archive: - if archive_filename: - doc.archive_filename = archive_filename - shutil.copy2(archive, archive_path_new(doc)) - else: - shutil.copy2(archive, archive_path_old(doc)) - - with Path(archive).open("rb") as f: - doc.archive_checksum = hashlib.md5(f.read()).hexdigest() - - doc.save() - - Path(thumbnail_path(doc)).touch() - - return doc - - -simple_jpg = Path(__file__).parent / "samples" / "simple.jpg" -simple_pdf = Path(__file__).parent / "samples" / "simple.pdf" -simple_pdf2 = ( - Path(__file__).parent / "samples" / "documents" / "originals" / "0000002.pdf" -) -simple_pdf3 = ( - Path(__file__).parent / "samples" / "documents" / "originals" / "0000003.pdf" -) -simple_txt = Path(__file__).parent / "samples" / "simple.txt" -simple_png = Path(__file__).parent / "samples" / "simple-noalpha.png" -simple_png2 = Path(__file__).parent / "examples" / "no-text.png" - - -@override_settings(FILENAME_FORMAT="") -class TestMigrateArchiveFiles(DirectoriesMixin, FileSystemAssertsMixin, TestMigrations): - migrate_from = "1006_auto_20201208_2209_squashed_1011_auto_20210101_2340" - migrate_to = "1012_fix_archive_files" - - def setUpBeforeMigration(self, apps): - Document = apps.get_model("documents", "Document") - - self.unrelated = make_test_document( - Document, - "unrelated", - "application/pdf", - simple_pdf3, - "unrelated.pdf", - simple_pdf, - ) - self.no_text = make_test_document( - Document, - "no-text", - "image/png", - simple_png2, - "no-text.png", - simple_pdf, - ) - self.doc_no_archive = make_test_document( - Document, - "no_archive", - "text/plain", - simple_txt, - "no_archive.txt", - ) - self.clash1 = make_test_document( - Document, - "clash", - "application/pdf", - simple_pdf, - "clash.pdf", - simple_pdf, - ) - self.clash2 = make_test_document( - Document, - "clash", - "image/jpeg", - simple_jpg, - "clash.jpg", - simple_pdf, - ) - self.clash3 = make_test_document( - Document, - "clash", - "image/png", - simple_png, - "clash.png", - simple_pdf, - ) - self.clash4 = make_test_document( - Document, - "clash.png", - "application/pdf", - simple_pdf2, - "clash.png.pdf", - simple_pdf2, - ) - - self.assertEqual(archive_path_old(self.clash1), archive_path_old(self.clash2)) - self.assertEqual(archive_path_old(self.clash1), archive_path_old(self.clash3)) - self.assertNotEqual( - archive_path_old(self.clash1), - archive_path_old(self.clash4), - ) - - def testArchiveFilesMigrated(self): - Document = self.apps.get_model("documents", "Document") - - for doc in Document.objects.all(): - if doc.archive_checksum: - self.assertIsNotNone(doc.archive_filename) - self.assertIsFile(archive_path_new(doc)) - else: - self.assertIsNone(doc.archive_filename) - - with Path(source_path(doc)).open("rb") as f: - original_checksum = hashlib.md5(f.read()).hexdigest() - self.assertEqual(original_checksum, doc.checksum) - - if doc.archive_checksum: - self.assertIsFile(archive_path_new(doc)) - with archive_path_new(doc).open("rb") as f: - archive_checksum = hashlib.md5(f.read()).hexdigest() - self.assertEqual(archive_checksum, doc.archive_checksum) - - self.assertEqual( - Document.objects.filter(archive_checksum__isnull=False).count(), - 6, - ) - - def test_filenames(self): - Document = self.apps.get_model("documents", "Document") - self.assertEqual( - Document.objects.get(id=self.unrelated.id).archive_filename, - "unrelated.pdf", - ) - self.assertEqual( - Document.objects.get(id=self.no_text.id).archive_filename, - "no-text.pdf", - ) - self.assertEqual( - Document.objects.get(id=self.doc_no_archive.id).archive_filename, - None, - ) - self.assertEqual( - Document.objects.get(id=self.clash1.id).archive_filename, - f"{self.clash1.id:07}.pdf", - ) - self.assertEqual( - Document.objects.get(id=self.clash2.id).archive_filename, - f"{self.clash2.id:07}.pdf", - ) - self.assertEqual( - Document.objects.get(id=self.clash3.id).archive_filename, - f"{self.clash3.id:07}.pdf", - ) - self.assertEqual( - Document.objects.get(id=self.clash4.id).archive_filename, - "clash.png.pdf", - ) - - -@override_settings(FILENAME_FORMAT="{correspondent}/{title}") -class TestMigrateArchiveFilesWithFilenameFormat(TestMigrateArchiveFiles): - def test_filenames(self): - Document = self.apps.get_model("documents", "Document") - self.assertEqual( - Document.objects.get(id=self.unrelated.id).archive_filename, - "unrelated.pdf", - ) - self.assertEqual( - Document.objects.get(id=self.no_text.id).archive_filename, - "no-text.pdf", - ) - self.assertEqual( - Document.objects.get(id=self.doc_no_archive.id).archive_filename, - None, - ) - self.assertEqual( - Document.objects.get(id=self.clash1.id).archive_filename, - "none/clash.pdf", - ) - self.assertEqual( - Document.objects.get(id=self.clash2.id).archive_filename, - "none/clash_01.pdf", - ) - self.assertEqual( - Document.objects.get(id=self.clash3.id).archive_filename, - "none/clash_02.pdf", - ) - self.assertEqual( - Document.objects.get(id=self.clash4.id).archive_filename, - "clash.png.pdf", - ) - - -def fake_parse_wrapper(parser, path, mime_type, file_name): - parser.archive_path = None - parser.text = "the text" - - -@override_settings(FILENAME_FORMAT="") -class TestMigrateArchiveFilesErrors(DirectoriesMixin, TestMigrations): - migrate_from = "1006_auto_20201208_2209_squashed_1011_auto_20210101_2340" - migrate_to = "1012_fix_archive_files" - auto_migrate = False - - @pytest.mark.skip(reason="Fails with migration tearDown util. Needs investigation.") - def test_archive_missing(self): - Document = self.apps.get_model("documents", "Document") - - doc = make_test_document( - Document, - "clash", - "application/pdf", - simple_pdf, - "clash.pdf", - simple_pdf, - ) - archive_path_old(doc).unlink() - - self.assertRaisesMessage( - ValueError, - "does not exist at: ", - self.performMigration, - ) - - @pytest.mark.skip(reason="Fails with migration tearDown util. Needs investigation.") - def test_parser_missing(self): - Document = self.apps.get_model("documents", "Document") - - make_test_document( - Document, - "document", - "invalid/typesss768", - simple_png, - "document.png", - simple_pdf, - ) - make_test_document( - Document, - "document", - "invalid/typesss768", - simple_jpg, - "document.jpg", - simple_pdf, - ) - - self.assertRaisesMessage( - ValueError, - "no parsers are available", - self.performMigration, - ) - - @mock.patch(f"{__name__}.migration_1012_obj.parse_wrapper") - def test_parser_error(self, m): - m.side_effect = ParseError() - Document = self.apps.get_model("documents", "Document") - - doc1 = make_test_document( - Document, - "document", - "image/png", - simple_png, - "document.png", - simple_pdf, - ) - doc2 = make_test_document( - Document, - "document", - "application/pdf", - simple_jpg, - "document.jpg", - simple_pdf, - ) - - self.assertIsNotNone(doc1.archive_checksum) - self.assertIsNotNone(doc2.archive_checksum) - - with self.assertLogs() as capture: - self.performMigration() - - self.assertEqual(m.call_count, 6) - - self.assertEqual( - len( - list( - filter( - lambda log: "Parse error, will try again in 5 seconds" in log, - capture.output, - ), - ), - ), - 4, - ) - - self.assertEqual( - len( - list( - filter( - lambda log: "Unable to regenerate archive document for ID:" - in log, - capture.output, - ), - ), - ), - 2, - ) - - Document = self.apps.get_model("documents", "Document") - - doc1 = Document.objects.get(id=doc1.id) - doc2 = Document.objects.get(id=doc2.id) - - self.assertIsNone(doc1.archive_checksum) - self.assertIsNone(doc2.archive_checksum) - self.assertIsNone(doc1.archive_filename) - self.assertIsNone(doc2.archive_filename) - - @mock.patch(f"{__name__}.migration_1012_obj.parse_wrapper") - def test_parser_no_archive(self, m): - m.side_effect = fake_parse_wrapper - - Document = self.apps.get_model("documents", "Document") - - doc1 = make_test_document( - Document, - "document", - "image/png", - simple_png, - "document.png", - simple_pdf, - ) - doc2 = make_test_document( - Document, - "document", - "application/pdf", - simple_jpg, - "document.jpg", - simple_pdf, - ) - - with self.assertLogs() as capture: - self.performMigration() - - self.assertEqual( - len( - list( - filter( - lambda log: "Parser did not return an archive document for document" - in log, - capture.output, - ), - ), - ), - 2, - ) - - Document = self.apps.get_model("documents", "Document") - - doc1 = Document.objects.get(id=doc1.id) - doc2 = Document.objects.get(id=doc2.id) - - self.assertIsNone(doc1.archive_checksum) - self.assertIsNone(doc2.archive_checksum) - self.assertIsNone(doc1.archive_filename) - self.assertIsNone(doc2.archive_filename) - - -@override_settings(FILENAME_FORMAT="") -class TestMigrateArchiveFilesBackwards( - DirectoriesMixin, - FileSystemAssertsMixin, - TestMigrations, -): - migrate_from = "1012_fix_archive_files" - migrate_to = "1006_auto_20201208_2209_squashed_1011_auto_20210101_2340" - - def setUpBeforeMigration(self, apps): - Document = apps.get_model("documents", "Document") - - make_test_document( - Document, - "unrelated", - "application/pdf", - simple_pdf2, - "unrelated.txt", - simple_pdf2, - "unrelated.pdf", - ) - make_test_document( - Document, - "no_archive", - "text/plain", - simple_txt, - "no_archive.txt", - ) - make_test_document( - Document, - "clash", - "image/jpeg", - simple_jpg, - "clash.jpg", - simple_pdf, - "clash_02.pdf", - ) - - def testArchiveFilesReverted(self): - Document = self.apps.get_model("documents", "Document") - - for doc in Document.objects.all(): - if doc.archive_checksum: - self.assertIsFile(archive_path_old(doc)) - with Path(source_path(doc)).open("rb") as f: - original_checksum = hashlib.md5(f.read()).hexdigest() - self.assertEqual(original_checksum, doc.checksum) - - if doc.archive_checksum: - self.assertIsFile(archive_path_old(doc)) - with archive_path_old(doc).open("rb") as f: - archive_checksum = hashlib.md5(f.read()).hexdigest() - self.assertEqual(archive_checksum, doc.archive_checksum) - - self.assertEqual( - Document.objects.filter(archive_checksum__isnull=False).count(), - 2, - ) - - -@override_settings(FILENAME_FORMAT="{correspondent}/{title}") -class TestMigrateArchiveFilesBackwardsWithFilenameFormat( - TestMigrateArchiveFilesBackwards, -): - pass - - -@override_settings(FILENAME_FORMAT="") -class TestMigrateArchiveFilesBackwardsErrors(DirectoriesMixin, TestMigrations): - migrate_from = "1012_fix_archive_files" - migrate_to = "1006_auto_20201208_2209_squashed_1011_auto_20210101_2340" - auto_migrate = False - - def test_filename_clash(self): - Document = self.apps.get_model("documents", "Document") - - self.clashA = make_test_document( - Document, - "clash", - "application/pdf", - simple_pdf, - "clash.pdf", - simple_pdf, - "clash_02.pdf", - ) - self.clashB = make_test_document( - Document, - "clash", - "image/jpeg", - simple_jpg, - "clash.jpg", - simple_pdf, - "clash_01.pdf", - ) - - self.assertRaisesMessage( - ValueError, - "would clash with another archive filename", - self.performMigration, - ) - - def test_filename_exists(self): - Document = self.apps.get_model("documents", "Document") - - self.clashA = make_test_document( - Document, - "clash", - "application/pdf", - simple_pdf, - "clash.pdf", - simple_pdf, - "clash.pdf", - ) - self.clashB = make_test_document( - Document, - "clash", - "image/jpeg", - simple_jpg, - "clash.jpg", - simple_pdf, - "clash_01.pdf", - ) - - self.assertRaisesMessage( - ValueError, - "file already exists.", - self.performMigration, - ) diff --git a/src/documents/tests/test_migration_consumption_templates.py b/src/documents/tests/test_migration_consumption_templates.py deleted file mode 100644 index 917007116..000000000 --- a/src/documents/tests/test_migration_consumption_templates.py +++ /dev/null @@ -1,50 +0,0 @@ -from django.contrib.auth import get_user_model - -from documents.tests.utils import TestMigrations - - -class TestMigrateConsumptionTemplate(TestMigrations): - migrate_from = "1038_sharelink" - migrate_to = "1039_consumptiontemplate" - - def setUpBeforeMigration(self, apps): - User = get_user_model() - Group = apps.get_model("auth.Group") - self.Permission = apps.get_model("auth", "Permission") - self.user = User.objects.create(username="user1") - self.group = Group.objects.create(name="group1") - permission = self.Permission.objects.get(codename="add_document") - self.user.user_permissions.add(permission.id) - self.group.permissions.add(permission.id) - - def test_users_with_add_documents_get_add_consumptiontemplate(self): - permission = self.Permission.objects.get(codename="add_consumptiontemplate") - self.assertTrue(self.user.has_perm(f"documents.{permission.codename}")) - self.assertTrue(permission in self.group.permissions.all()) - - -class TestReverseMigrateConsumptionTemplate(TestMigrations): - migrate_from = "1039_consumptiontemplate" - migrate_to = "1038_sharelink" - - def setUpBeforeMigration(self, apps): - User = get_user_model() - Group = apps.get_model("auth.Group") - self.Permission = apps.get_model("auth", "Permission") - self.user = User.objects.create(username="user1") - self.group = Group.objects.create(name="group1") - permission = self.Permission.objects.filter( - codename="add_consumptiontemplate", - ).first() - if permission is not None: - self.user.user_permissions.add(permission.id) - self.group.permissions.add(permission.id) - - def test_remove_consumptiontemplate_permissions(self): - permission = self.Permission.objects.filter( - codename="add_consumptiontemplate", - ).first() - # can be None ? now that CTs removed - if permission is not None: - self.assertFalse(self.user.has_perm(f"documents.{permission.codename}")) - self.assertFalse(permission in self.group.permissions.all()) diff --git a/src/documents/tests/test_migration_created.py b/src/documents/tests/test_migration_created.py deleted file mode 100644 index 89e97cbe1..000000000 --- a/src/documents/tests/test_migration_created.py +++ /dev/null @@ -1,33 +0,0 @@ -from datetime import date -from datetime import datetime -from datetime import timedelta - -from django.utils.timezone import make_aware -from pytz import UTC - -from documents.tests.utils import DirectoriesMixin -from documents.tests.utils import TestMigrations - - -class TestMigrateDocumentCreated(DirectoriesMixin, TestMigrations): - migrate_from = "1066_alter_workflowtrigger_schedule_offset_days" - migrate_to = "1067_alter_document_created" - - def setUpBeforeMigration(self, apps): - # create 600 documents - for i in range(600): - Document = apps.get_model("documents", "Document") - naive = datetime(2023, 10, 1, 12, 0, 0) + timedelta(days=i) - Document.objects.create( - title=f"test{i}", - mime_type="application/pdf", - filename=f"file{i}.pdf", - created=make_aware(naive, timezone=UTC), - checksum=i, - ) - - def testDocumentCreatedMigrated(self): - Document = self.apps.get_model("documents", "Document") - - doc = Document.objects.get(id=1) - self.assertEqual(doc.created, date(2023, 10, 1)) diff --git a/src/documents/tests/test_migration_custom_field_selects.py b/src/documents/tests/test_migration_custom_field_selects.py deleted file mode 100644 index 59004bf21..000000000 --- a/src/documents/tests/test_migration_custom_field_selects.py +++ /dev/null @@ -1,87 +0,0 @@ -from unittest.mock import ANY - -from documents.tests.utils import TestMigrations - - -class TestMigrateCustomFieldSelects(TestMigrations): - migrate_from = "1059_workflowactionemail_workflowactionwebhook_and_more" - migrate_to = "1060_alter_customfieldinstance_value_select" - - def setUpBeforeMigration(self, apps): - CustomField = apps.get_model("documents.CustomField") - self.old_format = CustomField.objects.create( - name="cf1", - data_type="select", - extra_data={"select_options": ["Option 1", "Option 2", "Option 3"]}, - ) - Document = apps.get_model("documents.Document") - doc = Document.objects.create(title="doc1") - CustomFieldInstance = apps.get_model("documents.CustomFieldInstance") - self.old_instance = CustomFieldInstance.objects.create( - field=self.old_format, - value_select=0, - document=doc, - ) - - def test_migrate_old_to_new_select_fields(self): - self.old_format.refresh_from_db() - self.old_instance.refresh_from_db() - - self.assertEqual( - self.old_format.extra_data["select_options"], - [ - {"label": "Option 1", "id": ANY}, - {"label": "Option 2", "id": ANY}, - {"label": "Option 3", "id": ANY}, - ], - ) - - self.assertEqual( - self.old_instance.value_select, - self.old_format.extra_data["select_options"][0]["id"], - ) - - -class TestMigrationCustomFieldSelectsReverse(TestMigrations): - migrate_from = "1060_alter_customfieldinstance_value_select" - migrate_to = "1059_workflowactionemail_workflowactionwebhook_and_more" - - def setUpBeforeMigration(self, apps): - CustomField = apps.get_model("documents.CustomField") - self.new_format = CustomField.objects.create( - name="cf1", - data_type="select", - extra_data={ - "select_options": [ - {"label": "Option 1", "id": "id1"}, - {"label": "Option 2", "id": "id2"}, - {"label": "Option 3", "id": "id3"}, - ], - }, - ) - Document = apps.get_model("documents.Document") - doc = Document.objects.create(title="doc1") - CustomFieldInstance = apps.get_model("documents.CustomFieldInstance") - self.new_instance = CustomFieldInstance.objects.create( - field=self.new_format, - value_select="id1", - document=doc, - ) - - def test_migrate_new_to_old_select_fields(self): - self.new_format.refresh_from_db() - self.new_instance.refresh_from_db() - - self.assertEqual( - self.new_format.extra_data["select_options"], - [ - "Option 1", - "Option 2", - "Option 3", - ], - ) - - self.assertEqual( - self.new_instance.value_select, - 0, - ) diff --git a/src/documents/tests/test_migration_customfields.py b/src/documents/tests/test_migration_customfields.py deleted file mode 100644 index 79308bceb..000000000 --- a/src/documents/tests/test_migration_customfields.py +++ /dev/null @@ -1,43 +0,0 @@ -from django.contrib.auth import get_user_model - -from documents.tests.utils import TestMigrations - - -class TestMigrateCustomFields(TestMigrations): - migrate_from = "1039_consumptiontemplate" - migrate_to = "1040_customfield_customfieldinstance_and_more" - - def setUpBeforeMigration(self, apps): - User = get_user_model() - Group = apps.get_model("auth.Group") - self.Permission = apps.get_model("auth", "Permission") - self.user = User.objects.create(username="user1") - self.group = Group.objects.create(name="group1") - permission = self.Permission.objects.get(codename="add_document") - self.user.user_permissions.add(permission.id) - self.group.permissions.add(permission.id) - - def test_users_with_add_documents_get_add_customfields(self): - permission = self.Permission.objects.get(codename="add_customfield") - self.assertTrue(self.user.has_perm(f"documents.{permission.codename}")) - self.assertTrue(permission in self.group.permissions.all()) - - -class TestReverseMigrateCustomFields(TestMigrations): - migrate_from = "1040_customfield_customfieldinstance_and_more" - migrate_to = "1039_consumptiontemplate" - - def setUpBeforeMigration(self, apps): - User = get_user_model() - Group = apps.get_model("auth.Group") - self.Permission = apps.get_model("auth", "Permission") - self.user = User.objects.create(username="user1") - self.group = Group.objects.create(name="group1") - permission = self.Permission.objects.get(codename="add_customfield") - self.user.user_permissions.add(permission.id) - self.group.permissions.add(permission.id) - - def test_remove_consumptiontemplate_permissions(self): - permission = self.Permission.objects.get(codename="add_customfield") - self.assertFalse(self.user.has_perm(f"documents.{permission.codename}")) - self.assertFalse(permission in self.group.permissions.all()) diff --git a/src/documents/tests/test_migration_document_pages_count.py b/src/documents/tests/test_migration_document_pages_count.py deleted file mode 100644 index e8f297acb..000000000 --- a/src/documents/tests/test_migration_document_pages_count.py +++ /dev/null @@ -1,59 +0,0 @@ -import shutil -from pathlib import Path - -from django.conf import settings - -from documents.tests.utils import DirectoriesMixin -from documents.tests.utils import TestMigrations - - -def source_path_before(self) -> Path: - if self.filename: - fname = str(self.filename) - - return Path(settings.ORIGINALS_DIR) / fname - - -class TestMigrateDocumentPageCount(DirectoriesMixin, TestMigrations): - migrate_from = "1052_document_transaction_id" - migrate_to = "1053_document_page_count" - - def setUpBeforeMigration(self, apps): - Document = apps.get_model("documents", "Document") - doc = Document.objects.create( - title="test1", - mime_type="application/pdf", - filename="file1.pdf", - ) - self.doc_id = doc.id - shutil.copy( - Path(__file__).parent / "samples" / "simple.pdf", - source_path_before(doc), - ) - - def testDocumentPageCountMigrated(self): - Document = self.apps.get_model("documents", "Document") - - doc = Document.objects.get(id=self.doc_id) - self.assertEqual(doc.page_count, 1) - - -class TestMigrateDocumentPageCountBackwards(TestMigrations): - migrate_from = "1053_document_page_count" - migrate_to = "1052_document_transaction_id" - - def setUpBeforeMigration(self, apps): - Document = apps.get_model("documents", "Document") - doc = Document.objects.create( - title="test1", - mime_type="application/pdf", - filename="file1.pdf", - page_count=8, - ) - self.doc_id = doc.id - - def test_remove_number_of_pages_to_page_count(self): - Document = self.apps.get_model("documents", "Document") - self.assertFalse( - "page_count" in [field.name for field in Document._meta.get_fields()], - ) diff --git a/src/documents/tests/test_migration_encrypted_webp_conversion.py b/src/documents/tests/test_migration_encrypted_webp_conversion.py deleted file mode 100644 index 0660df368..000000000 --- a/src/documents/tests/test_migration_encrypted_webp_conversion.py +++ /dev/null @@ -1,283 +0,0 @@ -import importlib -import shutil -import tempfile -from collections.abc import Callable -from collections.abc import Iterable -from pathlib import Path -from unittest import mock - -from django.test import override_settings - -from documents.tests.utils import TestMigrations - -# https://github.com/python/cpython/issues/100950 -migration_1037_obj = importlib.import_module( - "documents.migrations.1037_webp_encrypted_thumbnail_conversion", -) - - -@override_settings(PASSPHRASE="test") -@mock.patch( - f"{__name__}.migration_1037_obj.multiprocessing.pool.Pool.map", -) -@mock.patch(f"{__name__}.migration_1037_obj.run_convert") -class TestMigrateToEncrytpedWebPThumbnails(TestMigrations): - migrate_from = ( - "1022_paperlesstask_squashed_1036_alter_savedviewfilterrule_rule_type" - ) - migrate_to = "1037_webp_encrypted_thumbnail_conversion" - auto_migrate = False - - def pretend_convert_output(self, *args, **kwargs): - """ - Pretends to do the conversion, by copying the input file - to the output file - """ - shutil.copy2( - Path(kwargs["input_file"].rstrip("[0]")), - Path(kwargs["output_file"]), - ) - - def pretend_map(self, func: Callable, iterable: Iterable): - """ - Pretends to be the map of a multiprocessing.Pool, but secretly does - everything in series - """ - for item in iterable: - func(item) - - def create_dummy_thumbnails( - self, - thumb_dir: Path, - ext: str, - count: int, - start_count: int = 0, - ): - """ - Helper to create a certain count of files of given extension in a given directory - """ - for idx in range(count): - (Path(thumb_dir) / Path(f"{start_count + idx:07}.{ext}")).touch() - # Triple check expected files exist - self.assert_file_count_by_extension(ext, thumb_dir, count) - - def create_webp_thumbnail_files( - self, - thumb_dir: Path, - count: int, - start_count: int = 0, - ): - """ - Creates a dummy WebP thumbnail file in the given directory, based on - the database Document - """ - self.create_dummy_thumbnails(thumb_dir, "webp", count, start_count) - - def create_encrypted_webp_thumbnail_files( - self, - thumb_dir: Path, - count: int, - start_count: int = 0, - ): - """ - Creates a dummy encrypted WebP thumbnail file in the given directory, based on - the database Document - """ - self.create_dummy_thumbnails(thumb_dir, "webp.gpg", count, start_count) - - def create_png_thumbnail_files( - self, - thumb_dir: Path, - count: int, - start_count: int = 0, - ): - """ - Creates a dummy PNG thumbnail file in the given directory, based on - the database Document - """ - - self.create_dummy_thumbnails(thumb_dir, "png", count, start_count) - - def create_encrypted_png_thumbnail_files( - self, - thumb_dir: Path, - count: int, - start_count: int = 0, - ): - """ - Creates a dummy encrypted PNG thumbnail file in the given directory, based on - the database Document - """ - - self.create_dummy_thumbnails(thumb_dir, "png.gpg", count, start_count) - - def assert_file_count_by_extension( - self, - ext: str, - dir: str | Path, - expected_count: int, - ): - """ - Helper to assert a certain count of given extension files in given directory - """ - if not isinstance(dir, Path): - dir = Path(dir) - matching_files = list(dir.glob(f"*.{ext}")) - self.assertEqual(len(matching_files), expected_count) - - def assert_encrypted_png_file_count(self, dir: Path, expected_count: int): - """ - Helper to assert a certain count of excrypted PNG extension files in given directory - """ - self.assert_file_count_by_extension("png.gpg", dir, expected_count) - - def assert_encrypted_webp_file_count(self, dir: Path, expected_count: int): - """ - Helper to assert a certain count of encrypted WebP extension files in given directory - """ - self.assert_file_count_by_extension("webp.gpg", dir, expected_count) - - def assert_webp_file_count(self, dir: Path, expected_count: int): - """ - Helper to assert a certain count of WebP extension files in given directory - """ - self.assert_file_count_by_extension("webp", dir, expected_count) - - def assert_png_file_count(self, dir: Path, expected_count: int): - """ - Helper to assert a certain count of PNG extension files in given directory - """ - self.assert_file_count_by_extension("png", dir, expected_count) - - def setUp(self): - self.thumbnail_dir = Path(tempfile.mkdtemp()).resolve() - - return super().setUp() - - def tearDown(self) -> None: - shutil.rmtree(self.thumbnail_dir) - - return super().tearDown() - - def test_do_nothing_if_converted( - self, - run_convert_mock: mock.MagicMock, - map_mock: mock.MagicMock, - ): - """ - GIVEN: - - Encrypted document exists with existing encrypted WebP thumbnail path - WHEN: - - Migration is attempted - THEN: - - Nothing is converted - """ - map_mock.side_effect = self.pretend_map - - with override_settings( - THUMBNAIL_DIR=self.thumbnail_dir, - ): - self.create_encrypted_webp_thumbnail_files(self.thumbnail_dir, 3) - - self.performMigration() - run_convert_mock.assert_not_called() - - self.assert_encrypted_webp_file_count(self.thumbnail_dir, 3) - - def test_convert_thumbnails( - self, - run_convert_mock: mock.MagicMock, - map_mock: mock.MagicMock, - ): - """ - GIVEN: - - Encrypted documents exist with PNG thumbnail - WHEN: - - Migration is attempted - THEN: - - Thumbnails are converted to webp & re-encrypted - """ - map_mock.side_effect = self.pretend_map - run_convert_mock.side_effect = self.pretend_convert_output - - with override_settings( - THUMBNAIL_DIR=self.thumbnail_dir, - ): - self.create_encrypted_png_thumbnail_files(self.thumbnail_dir, 3) - - self.performMigration() - - run_convert_mock.assert_called() - self.assertEqual(run_convert_mock.call_count, 3) - - self.assert_encrypted_webp_file_count(self.thumbnail_dir, 3) - - def test_convert_errors_out( - self, - run_convert_mock: mock.MagicMock, - map_mock: mock.MagicMock, - ): - """ - GIVEN: - - Encrypted document exists with PNG thumbnail - WHEN: - - Migration is attempted, but raises an exception - THEN: - - Single thumbnail is converted - """ - map_mock.side_effect = self.pretend_map - run_convert_mock.side_effect = OSError - - with override_settings( - THUMBNAIL_DIR=self.thumbnail_dir, - ): - self.create_encrypted_png_thumbnail_files(self.thumbnail_dir, 3) - - self.performMigration() - - run_convert_mock.assert_called() - self.assertEqual(run_convert_mock.call_count, 3) - - self.assert_encrypted_png_file_count(self.thumbnail_dir, 3) - - def test_convert_mixed( - self, - run_convert_mock: mock.MagicMock, - map_mock: mock.MagicMock, - ): - """ - GIVEN: - - Documents exist with PNG, encrypted PNG and WebP thumbnails - WHEN: - - Migration is attempted - THEN: - - Only encrypted PNG thumbnails are converted - """ - map_mock.side_effect = self.pretend_map - run_convert_mock.side_effect = self.pretend_convert_output - - with override_settings( - THUMBNAIL_DIR=self.thumbnail_dir, - ): - self.create_png_thumbnail_files(self.thumbnail_dir, 3) - self.create_encrypted_png_thumbnail_files( - self.thumbnail_dir, - 3, - start_count=3, - ) - self.create_webp_thumbnail_files(self.thumbnail_dir, 2, start_count=6) - self.create_encrypted_webp_thumbnail_files( - self.thumbnail_dir, - 3, - start_count=8, - ) - - self.performMigration() - - run_convert_mock.assert_called() - self.assertEqual(run_convert_mock.call_count, 3) - - self.assert_png_file_count(self.thumbnail_dir, 3) - self.assert_encrypted_webp_file_count(self.thumbnail_dir, 6) - self.assert_webp_file_count(self.thumbnail_dir, 2) - self.assert_encrypted_png_file_count(self.thumbnail_dir, 0) diff --git a/src/documents/tests/test_migration_mime_type.py b/src/documents/tests/test_migration_mime_type.py deleted file mode 100644 index 7805799fe..000000000 --- a/src/documents/tests/test_migration_mime_type.py +++ /dev/null @@ -1,108 +0,0 @@ -import shutil -from pathlib import Path - -from django.conf import settings -from django.test import override_settings - -from documents.parsers import get_default_file_extension -from documents.tests.utils import DirectoriesMixin -from documents.tests.utils import TestMigrations - -STORAGE_TYPE_UNENCRYPTED = "unencrypted" -STORAGE_TYPE_GPG = "gpg" - - -def source_path_before(self): - if self.filename: - fname = str(self.filename) - else: - fname = f"{self.pk:07}.{self.file_type}" - if self.storage_type == STORAGE_TYPE_GPG: - fname += ".gpg" - - return Path(settings.ORIGINALS_DIR) / fname - - -def file_type_after(self): - return get_default_file_extension(self.mime_type) - - -def source_path_after(doc): - if doc.filename: - fname = str(doc.filename) - else: - fname = f"{doc.pk:07}{file_type_after(doc)}" - if doc.storage_type == STORAGE_TYPE_GPG: - fname += ".gpg" # pragma: no cover - - return Path(settings.ORIGINALS_DIR) / fname - - -@override_settings(PASSPHRASE="test") -class TestMigrateMimeType(DirectoriesMixin, TestMigrations): - migrate_from = "1002_auto_20201111_1105" - migrate_to = "1003_mime_types" - - def setUpBeforeMigration(self, apps): - Document = apps.get_model("documents", "Document") - doc = Document.objects.create( - title="test", - file_type="pdf", - filename="file1.pdf", - ) - self.doc_id = doc.id - shutil.copy( - Path(__file__).parent / "samples" / "simple.pdf", - source_path_before(doc), - ) - - doc2 = Document.objects.create( - checksum="B", - file_type="pdf", - storage_type=STORAGE_TYPE_GPG, - ) - self.doc2_id = doc2.id - shutil.copy( - ( - Path(__file__).parent - / "samples" - / "documents" - / "originals" - / "0000004.pdf.gpg" - ), - source_path_before(doc2), - ) - - def testMimeTypesMigrated(self): - Document = self.apps.get_model("documents", "Document") - - doc = Document.objects.get(id=self.doc_id) - self.assertEqual(doc.mime_type, "application/pdf") - - doc2 = Document.objects.get(id=self.doc2_id) - self.assertEqual(doc2.mime_type, "application/pdf") - - -@override_settings(PASSPHRASE="test") -class TestMigrateMimeTypeBackwards(DirectoriesMixin, TestMigrations): - migrate_from = "1003_mime_types" - migrate_to = "1002_auto_20201111_1105" - - def setUpBeforeMigration(self, apps): - Document = apps.get_model("documents", "Document") - doc = Document.objects.create( - title="test", - mime_type="application/pdf", - filename="file1.pdf", - ) - self.doc_id = doc.id - shutil.copy( - Path(__file__).parent / "samples" / "simple.pdf", - source_path_after(doc), - ) - - def testMimeTypesReverted(self): - Document = self.apps.get_model("documents", "Document") - - doc = Document.objects.get(id=self.doc_id) - self.assertEqual(doc.file_type, "pdf") diff --git a/src/documents/tests/test_migration_remove_null_characters.py b/src/documents/tests/test_migration_remove_null_characters.py deleted file mode 100644 index c47bc80ca..000000000 --- a/src/documents/tests/test_migration_remove_null_characters.py +++ /dev/null @@ -1,15 +0,0 @@ -from documents.tests.utils import DirectoriesMixin -from documents.tests.utils import TestMigrations - - -class TestMigrateNullCharacters(DirectoriesMixin, TestMigrations): - migrate_from = "1014_auto_20210228_1614" - migrate_to = "1015_remove_null_characters" - - def setUpBeforeMigration(self, apps): - Document = apps.get_model("documents", "Document") - self.doc = Document.objects.create(content="aaa\0bbb") - - def testMimeTypesMigrated(self): - Document = self.apps.get_model("documents", "Document") - self.assertNotIn("\0", Document.objects.get(id=self.doc.id).content) diff --git a/src/documents/tests/test_migration_storage_path_template.py b/src/documents/tests/test_migration_storage_path_template.py deleted file mode 100644 index 37b87a115..000000000 --- a/src/documents/tests/test_migration_storage_path_template.py +++ /dev/null @@ -1,30 +0,0 @@ -from documents.models import StoragePath -from documents.tests.utils import TestMigrations - - -class TestMigrateStoragePathToTemplate(TestMigrations): - migrate_from = "1054_customfieldinstance_value_monetary_amount_and_more" - migrate_to = "1055_alter_storagepath_path" - - def setUpBeforeMigration(self, apps): - self.old_format = StoragePath.objects.create( - name="sp1", - path="Something/{title}", - ) - self.new_format = StoragePath.objects.create( - name="sp2", - path="{{asn}}/{{title}}", - ) - self.no_formatting = StoragePath.objects.create( - name="sp3", - path="Some/Fixed/Path", - ) - - def test_migrate_old_to_new_storage_path(self): - self.old_format.refresh_from_db() - self.new_format.refresh_from_db() - self.no_formatting.refresh_from_db() - - self.assertEqual(self.old_format.path, "Something/{{ title }}") - self.assertEqual(self.new_format.path, "{{asn}}/{{title}}") - self.assertEqual(self.no_formatting.path, "Some/Fixed/Path") diff --git a/src/documents/tests/test_migration_tag_colors.py b/src/documents/tests/test_migration_tag_colors.py deleted file mode 100644 index 0643fe883..000000000 --- a/src/documents/tests/test_migration_tag_colors.py +++ /dev/null @@ -1,36 +0,0 @@ -from documents.tests.utils import DirectoriesMixin -from documents.tests.utils import TestMigrations - - -class TestMigrateTagColor(DirectoriesMixin, TestMigrations): - migrate_from = "1012_fix_archive_files" - migrate_to = "1013_migrate_tag_colour" - - def setUpBeforeMigration(self, apps): - Tag = apps.get_model("documents", "Tag") - self.t1_id = Tag.objects.create(name="tag1").id - self.t2_id = Tag.objects.create(name="tag2", colour=1).id - self.t3_id = Tag.objects.create(name="tag3", colour=5).id - - def testMimeTypesMigrated(self): - Tag = self.apps.get_model("documents", "Tag") - self.assertEqual(Tag.objects.get(id=self.t1_id).color, "#a6cee3") - self.assertEqual(Tag.objects.get(id=self.t2_id).color, "#a6cee3") - self.assertEqual(Tag.objects.get(id=self.t3_id).color, "#fb9a99") - - -class TestMigrateTagColorBackwards(DirectoriesMixin, TestMigrations): - migrate_from = "1013_migrate_tag_colour" - migrate_to = "1012_fix_archive_files" - - def setUpBeforeMigration(self, apps): - Tag = apps.get_model("documents", "Tag") - self.t1_id = Tag.objects.create(name="tag1").id - self.t2_id = Tag.objects.create(name="tag2", color="#cab2d6").id - self.t3_id = Tag.objects.create(name="tag3", color="#123456").id - - def testMimeTypesReverted(self): - Tag = self.apps.get_model("documents", "Tag") - self.assertEqual(Tag.objects.get(id=self.t1_id).colour, 1) - self.assertEqual(Tag.objects.get(id=self.t2_id).colour, 9) - self.assertEqual(Tag.objects.get(id=self.t3_id).colour, 1) diff --git a/src/documents/tests/test_migration_webp_conversion.py b/src/documents/tests/test_migration_webp_conversion.py deleted file mode 100644 index cd148ed6f..000000000 --- a/src/documents/tests/test_migration_webp_conversion.py +++ /dev/null @@ -1,230 +0,0 @@ -import importlib -import shutil -import tempfile -from collections.abc import Callable -from collections.abc import Iterable -from pathlib import Path -from unittest import mock - -from django.test import override_settings - -from documents.tests.utils import TestMigrations - -# https://github.com/python/cpython/issues/100950 -migration_1021_obj = importlib.import_module( - "documents.migrations.1021_webp_thumbnail_conversion", -) - - -@mock.patch( - f"{__name__}.migration_1021_obj.multiprocessing.pool.Pool.map", -) -@mock.patch(f"{__name__}.migration_1021_obj.run_convert") -class TestMigrateWebPThumbnails(TestMigrations): - migrate_from = "1016_auto_20210317_1351_squashed_1020_merge_20220518_1839" - migrate_to = "1021_webp_thumbnail_conversion" - auto_migrate = False - - def pretend_convert_output(self, *args, **kwargs): - """ - Pretends to do the conversion, by copying the input file - to the output file - """ - shutil.copy2( - Path(kwargs["input_file"].rstrip("[0]")), - Path(kwargs["output_file"]), - ) - - def pretend_map(self, func: Callable, iterable: Iterable): - """ - Pretends to be the map of a multiprocessing.Pool, but secretly does - everything in series - """ - for item in iterable: - func(item) - - def create_dummy_thumbnails( - self, - thumb_dir: Path, - ext: str, - count: int, - start_count: int = 0, - ): - """ - Helper to create a certain count of files of given extension in a given directory - """ - for idx in range(count): - (Path(thumb_dir) / Path(f"{start_count + idx:07}.{ext}")).touch() - # Triple check expected files exist - self.assert_file_count_by_extension(ext, thumb_dir, count) - - def create_webp_thumbnail_files( - self, - thumb_dir: Path, - count: int, - start_count: int = 0, - ): - """ - Creates a dummy WebP thumbnail file in the given directory, based on - the database Document - """ - self.create_dummy_thumbnails(thumb_dir, "webp", count, start_count) - - def create_png_thumbnail_file( - self, - thumb_dir: Path, - count: int, - start_count: int = 0, - ): - """ - Creates a dummy PNG thumbnail file in the given directory, based on - the database Document - """ - self.create_dummy_thumbnails(thumb_dir, "png", count, start_count) - - def assert_file_count_by_extension( - self, - ext: str, - dir: str | Path, - expected_count: int, - ): - """ - Helper to assert a certain count of given extension files in given directory - """ - if not isinstance(dir, Path): - dir = Path(dir) - matching_files = list(dir.glob(f"*.{ext}")) - self.assertEqual(len(matching_files), expected_count) - - def assert_png_file_count(self, dir: Path, expected_count: int): - """ - Helper to assert a certain count of PNG extension files in given directory - """ - self.assert_file_count_by_extension("png", dir, expected_count) - - def assert_webp_file_count(self, dir: Path, expected_count: int): - """ - Helper to assert a certain count of WebP extension files in given directory - """ - self.assert_file_count_by_extension("webp", dir, expected_count) - - def setUp(self): - self.thumbnail_dir = Path(tempfile.mkdtemp()).resolve() - - return super().setUp() - - def tearDown(self) -> None: - shutil.rmtree(self.thumbnail_dir) - - return super().tearDown() - - def test_do_nothing_if_converted( - self, - run_convert_mock: mock.MagicMock, - map_mock: mock.MagicMock, - ): - """ - GIVEN: - - Document exists with default WebP thumbnail path - WHEN: - - Thumbnail conversion is attempted - THEN: - - Nothing is converted - """ - map_mock.side_effect = self.pretend_map - - with override_settings( - THUMBNAIL_DIR=self.thumbnail_dir, - ): - self.create_webp_thumbnail_files(self.thumbnail_dir, 3) - - self.performMigration() - run_convert_mock.assert_not_called() - - self.assert_webp_file_count(self.thumbnail_dir, 3) - - def test_convert_single_thumbnail( - self, - run_convert_mock: mock.MagicMock, - map_mock: mock.MagicMock, - ): - """ - GIVEN: - - Document exists with PNG thumbnail - WHEN: - - Thumbnail conversion is attempted - THEN: - - Single thumbnail is converted - """ - map_mock.side_effect = self.pretend_map - run_convert_mock.side_effect = self.pretend_convert_output - - with override_settings( - THUMBNAIL_DIR=self.thumbnail_dir, - ): - self.create_png_thumbnail_file(self.thumbnail_dir, 3) - - self.performMigration() - - run_convert_mock.assert_called() - self.assertEqual(run_convert_mock.call_count, 3) - - self.assert_webp_file_count(self.thumbnail_dir, 3) - - def test_convert_errors_out( - self, - run_convert_mock: mock.MagicMock, - map_mock: mock.MagicMock, - ): - """ - GIVEN: - - Document exists with PNG thumbnail - WHEN: - - Thumbnail conversion is attempted, but raises an exception - THEN: - - Single thumbnail is converted - """ - map_mock.side_effect = self.pretend_map - run_convert_mock.side_effect = OSError - - with override_settings( - THUMBNAIL_DIR=self.thumbnail_dir, - ): - self.create_png_thumbnail_file(self.thumbnail_dir, 3) - - self.performMigration() - - run_convert_mock.assert_called() - self.assertEqual(run_convert_mock.call_count, 3) - - self.assert_png_file_count(self.thumbnail_dir, 3) - - def test_convert_mixed( - self, - run_convert_mock: mock.MagicMock, - map_mock: mock.MagicMock, - ): - """ - GIVEN: - - Document exists with PNG thumbnail - WHEN: - - Thumbnail conversion is attempted, but raises an exception - THEN: - - Single thumbnail is converted - """ - map_mock.side_effect = self.pretend_map - run_convert_mock.side_effect = self.pretend_convert_output - - with override_settings( - THUMBNAIL_DIR=self.thumbnail_dir, - ): - self.create_png_thumbnail_file(self.thumbnail_dir, 3) - self.create_webp_thumbnail_files(self.thumbnail_dir, 2, start_count=3) - - self.performMigration() - - run_convert_mock.assert_called() - self.assertEqual(run_convert_mock.call_count, 3) - - self.assert_png_file_count(self.thumbnail_dir, 0) - self.assert_webp_file_count(self.thumbnail_dir, 5) diff --git a/src/documents/tests/test_migration_workflows.py b/src/documents/tests/test_migration_workflows.py deleted file mode 100644 index 60e429d68..000000000 --- a/src/documents/tests/test_migration_workflows.py +++ /dev/null @@ -1,134 +0,0 @@ -from documents.data_models import DocumentSource -from documents.tests.utils import TestMigrations - - -class TestMigrateWorkflow(TestMigrations): - migrate_from = "1043_alter_savedviewfilterrule_rule_type" - migrate_to = "1044_workflow_workflowaction_workflowtrigger_and_more" - dependencies = ( - ( - "paperless_mail", - "0029_mailrule_pdf_layout", - ), - ) - - def setUpBeforeMigration(self, apps): - User = apps.get_model("auth", "User") - Group = apps.get_model("auth", "Group") - self.Permission = apps.get_model("auth", "Permission") - self.user = User.objects.create(username="user1") - self.group = Group.objects.create(name="group1") - permission = self.Permission.objects.get(codename="add_document") - self.user.user_permissions.add(permission.id) - self.group.permissions.add(permission.id) - - # create a CT to migrate - c = apps.get_model("documents", "Correspondent").objects.create( - name="Correspondent Name", - ) - dt = apps.get_model("documents", "DocumentType").objects.create( - name="DocType Name", - ) - t1 = apps.get_model("documents", "Tag").objects.create(name="t1") - sp = apps.get_model("documents", "StoragePath").objects.create(path="/test/") - cf1 = apps.get_model("documents", "CustomField").objects.create( - name="Custom Field 1", - data_type="string", - ) - ma = apps.get_model("paperless_mail", "MailAccount").objects.create( - name="MailAccount 1", - ) - mr = apps.get_model("paperless_mail", "MailRule").objects.create( - name="MailRule 1", - order=0, - account=ma, - ) - - user2 = User.objects.create(username="user2") - user3 = User.objects.create(username="user3") - group2 = Group.objects.create(name="group2") - - ConsumptionTemplate = apps.get_model("documents", "ConsumptionTemplate") - - ct = ConsumptionTemplate.objects.create( - name="Template 1", - order=0, - sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}", - filter_filename="*simple*", - filter_path="*/samples/*", - filter_mailrule=mr, - assign_title="Doc from {correspondent}", - assign_correspondent=c, - assign_document_type=dt, - assign_storage_path=sp, - assign_owner=user2, - ) - - ct.assign_tags.add(t1) - ct.assign_view_users.add(user3) - ct.assign_view_groups.add(group2) - ct.assign_change_users.add(user3) - ct.assign_change_groups.add(group2) - ct.assign_custom_fields.add(cf1) - ct.save() - - def test_users_with_add_documents_get_add_and_workflow_templates_get_migrated(self): - permission = self.Permission.objects.get(codename="add_workflow") - self.assertTrue(permission in self.user.user_permissions.all()) - self.assertTrue(permission in self.group.permissions.all()) - - Workflow = self.apps.get_model("documents", "Workflow") - self.assertEqual(Workflow.objects.all().count(), 1) - - -class TestReverseMigrateWorkflow(TestMigrations): - migrate_from = "1044_workflow_workflowaction_workflowtrigger_and_more" - migrate_to = "1043_alter_savedviewfilterrule_rule_type" - - def setUpBeforeMigration(self, apps): - User = apps.get_model("auth", "User") - Group = apps.get_model("auth", "Group") - self.Permission = apps.get_model("auth", "Permission") - self.user = User.objects.create(username="user1") - self.group = Group.objects.create(name="group1") - permission = self.Permission.objects.filter( - codename="add_workflow", - ).first() - if permission is not None: - self.user.user_permissions.add(permission.id) - self.group.permissions.add(permission.id) - - Workflow = apps.get_model("documents", "Workflow") - WorkflowTrigger = apps.get_model("documents", "WorkflowTrigger") - WorkflowAction = apps.get_model("documents", "WorkflowAction") - - trigger = WorkflowTrigger.objects.create( - type=0, - sources=[str(DocumentSource.ConsumeFolder)], - filter_path="*/path/*", - filter_filename="*file*", - ) - - action = WorkflowAction.objects.create( - assign_title="assign title", - ) - workflow = Workflow.objects.create( - name="workflow 1", - order=0, - ) - workflow.triggers.set([trigger]) - workflow.actions.set([action]) - workflow.save() - - def test_remove_workflow_permissions_and_migrate_workflows_to_consumption_templates( - self, - ): - permission = self.Permission.objects.filter( - codename="add_workflow", - ).first() - if permission is not None: - self.assertFalse(permission in self.user.user_permissions.all()) - self.assertFalse(permission in self.group.permissions.all()) - - ConsumptionTemplate = self.apps.get_model("documents", "ConsumptionTemplate") - self.assertEqual(ConsumptionTemplate.objects.all().count(), 1) diff --git a/src/documents/tests/test_tasks.py b/src/documents/tests/test_tasks.py index 11712549a..475709dd0 100644 --- a/src/documents/tests/test_tasks.py +++ b/src/documents/tests/test_tasks.py @@ -3,14 +3,17 @@ from datetime import timedelta from pathlib import Path from unittest import mock +from celery import states from django.conf import settings from django.test import TestCase +from django.test import override_settings from django.utils import timezone from documents import tasks from documents.models import Correspondent from documents.models import Document from documents.models import DocumentType +from documents.models import PaperlessTask from documents.models import Tag from documents.sanity_checker import SanityCheckFailedException from documents.sanity_checker import SanityCheckMessages @@ -270,3 +273,103 @@ class TestUpdateContent(DirectoriesMixin, TestCase): tasks.update_document_content_maybe_archive_file(doc.pk) self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test") + + +class TestAIIndex(DirectoriesMixin, TestCase): + @override_settings( + AI_ENABLED=True, + LLM_EMBEDDING_BACKEND="huggingface", + ) + def test_ai_index_success(self): + """ + GIVEN: + - Document exists, AI is enabled, llm index backend is set + WHEN: + - llmindex_index task is called + THEN: + - update_llm_index is called, and the task is marked as success + """ + Document.objects.create( + title="test", + content="my document", + checksum="wow", + ) + # lazy-loaded so mock the actual function + with mock.patch("paperless_ai.indexing.update_llm_index") as update_llm_index: + update_llm_index.return_value = "LLM index updated successfully." + tasks.llmindex_index() + update_llm_index.assert_called_once() + task = PaperlessTask.objects.get( + task_name=PaperlessTask.TaskName.LLMINDEX_UPDATE, + ) + self.assertEqual(task.status, states.SUCCESS) + self.assertEqual(task.result, "LLM index updated successfully.") + + @override_settings( + AI_ENABLED=True, + LLM_EMBEDDING_BACKEND="huggingface", + ) + def test_ai_index_failure(self): + """ + GIVEN: + - Document exists, AI is enabled, llm index backend is set + WHEN: + - llmindex_index task is called + THEN: + - update_llm_index raises an exception, and the task is marked as failure + """ + Document.objects.create( + title="test", + content="my document", + checksum="wow", + ) + # lazy-loaded so mock the actual function + with mock.patch("paperless_ai.indexing.update_llm_index") as update_llm_index: + update_llm_index.side_effect = Exception("LLM index update failed.") + tasks.llmindex_index() + update_llm_index.assert_called_once() + task = PaperlessTask.objects.get( + task_name=PaperlessTask.TaskName.LLMINDEX_UPDATE, + ) + self.assertEqual(task.status, states.FAILURE) + self.assertIn("LLM index update failed.", task.result) + + def test_update_document_in_llm_index(self): + """ + GIVEN: + - Nothing + WHEN: + - update_document_in_llm_index task is called + THEN: + - llm_index_add_or_update_document is called + """ + doc = Document.objects.create( + title="test", + content="my document", + checksum="wow", + ) + with mock.patch( + "documents.tasks.llm_index_add_or_update_document", + ) as llm_index_add_or_update_document: + tasks.update_document_in_llm_index(doc) + llm_index_add_or_update_document.assert_called_once_with(doc) + + def test_remove_document_from_llm_index(self): + """ + GIVEN: + - Nothing + WHEN: + - remove_document_from_llm_index task is called + THEN: + - llm_index_remove_document is called + """ + doc = Document.objects.create( + title="test", + content="my document", + checksum="wow", + ) + with mock.patch( + "documents.tasks.llm_index_remove_document", + ) as llm_index_remove_document: + tasks.remove_document_from_llm_index(doc) + llm_index_remove_document.assert_called_once_with(doc) diff --git a/src/documents/tests/test_views.py b/src/documents/tests/test_views.py index 4fa8fa833..a73016c26 100644 --- a/src/documents/tests/test_views.py +++ b/src/documents/tests/test_views.py @@ -2,6 +2,8 @@ import json import tempfile from datetime import timedelta from pathlib import Path +from unittest.mock import MagicMock +from unittest.mock import patch from django.conf import settings from django.contrib.auth.models import Group @@ -15,9 +17,15 @@ from django.utils import timezone from guardian.shortcuts import assign_perm from rest_framework import status +from documents.caching import get_llm_suggestion_cache +from documents.caching import set_llm_suggestions_cache +from documents.models import Correspondent from documents.models import Document +from documents.models import DocumentType from documents.models import ShareLink +from documents.models import StoragePath from documents.models import Tag +from documents.signals.handlers import update_llm_suggestions_cache from documents.tests.utils import DirectoriesMixin from paperless.models import ApplicationConfiguration @@ -270,3 +278,176 @@ class TestViews(DirectoriesMixin, TestCase): f"Possible N+1 queries detected: {num_queries_small} queries for 2 tags, " f"but {num_queries_large} queries for 50 tags" ) + + +class TestAISuggestions(DirectoriesMixin, TestCase): + def setUp(self): + self.user = User.objects.create_superuser(username="testuser") + self.document = Document.objects.create( + title="Test Document", + filename="test.pdf", + mime_type="application/pdf", + ) + self.tag1 = Tag.objects.create(name="tag1") + self.correspondent1 = Correspondent.objects.create(name="correspondent1") + self.document_type1 = DocumentType.objects.create(name="type1") + self.path1 = StoragePath.objects.create(name="path1") + super().setUp() + + @patch("documents.views.get_llm_suggestion_cache") + @patch("documents.views.refresh_suggestions_cache") + @override_settings( + AI_ENABLED=True, + LLM_BACKEND="mock_backend", + ) + def test_suggestions_with_cached_llm(self, mock_refresh_cache, mock_get_cache): + mock_get_cache.return_value = MagicMock(suggestions={"tags": ["tag1", "tag2"]}) + + self.client.force_login(user=self.user) + response = self.client.get(f"/api/documents/{self.document.pk}/suggestions/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), {"tags": ["tag1", "tag2"]}) + mock_refresh_cache.assert_called_once_with(self.document.pk) + + @patch("documents.views.get_ai_document_classification") + @override_settings( + AI_ENABLED=True, + LLM_BACKEND="mock_backend", + ) + def test_suggestions_with_ai_enabled( + self, + mock_get_ai_classification, + ): + mock_get_ai_classification.return_value = { + "title": "AI Title", + "tags": ["tag1", "tag2"], + "correspondents": ["correspondent1"], + "document_types": ["type1"], + "storage_paths": ["path1"], + "dates": ["2023-01-01"], + } + + self.client.force_login(user=self.user) + response = self.client.get(f"/api/documents/{self.document.pk}/suggestions/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json(), + { + "title": "AI Title", + "tags": [self.tag1.pk], + "suggested_tags": ["tag2"], + "correspondents": [self.correspondent1.pk], + "suggested_correspondents": [], + "document_types": [self.document_type1.pk], + "suggested_document_types": [], + "storage_paths": [self.path1.pk], + "suggested_storage_paths": [], + "dates": ["2023-01-01"], + }, + ) + + def test_invalidate_suggestions_cache(self): + self.client.force_login(user=self.user) + suggestions = { + "title": "AI Title", + "tags": ["tag1", "tag2"], + "correspondents": ["correspondent1"], + "document_types": ["type1"], + "storage_paths": ["path1"], + "dates": ["2023-01-01"], + } + set_llm_suggestions_cache( + self.document.pk, + suggestions, + backend="mock_backend", + ) + self.assertEqual( + get_llm_suggestion_cache( + self.document.pk, + backend="mock_backend", + ).suggestions, + suggestions, + ) + # post_save signal triggered + update_llm_suggestions_cache( + sender=None, + instance=self.document, + ) + self.assertIsNone( + get_llm_suggestion_cache( + self.document.pk, + backend="mock_backend", + ), + ) + + +class TestAIChatStreamingView(DirectoriesMixin, TestCase): + ENDPOINT = "/api/documents/chat/" + + def setUp(self): + self.user = User.objects.create_user(username="testuser", password="pass") + self.client.force_login(user=self.user) + self.document = Document.objects.create( + title="Test Document", + filename="test.pdf", + mime_type="application/pdf", + ) + super().setUp() + + @override_settings(AI_ENABLED=False) + def test_post_ai_disabled(self): + response = self.client.post( + self.ENDPOINT, + data='{"q": "question"}', + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn(b"AI is required for this feature", response.content) + + @patch("documents.views.stream_chat_with_documents") + @patch("documents.views.get_objects_for_user_owner_aware") + @override_settings(AI_ENABLED=True) + def test_post_no_document_id(self, mock_get_objects, mock_stream_chat): + mock_get_objects.return_value = [self.document] + mock_stream_chat.return_value = iter([b"data"]) + response = self.client.post( + self.ENDPOINT, + data='{"q": "question"}', + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/event-stream") + + @patch("documents.views.stream_chat_with_documents") + @override_settings(AI_ENABLED=True) + def test_post_with_document_id(self, mock_stream_chat): + mock_stream_chat.return_value = iter([b"data"]) + response = self.client.post( + self.ENDPOINT, + data=f'{{"q": "question", "document_id": {self.document.pk}}}', + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "text/event-stream") + + @override_settings(AI_ENABLED=True) + def test_post_with_invalid_document_id(self): + response = self.client.post( + self.ENDPOINT, + data='{"q": "question", "document_id": 999999}', + content_type="application/json", + ) + self.assertEqual(response.status_code, 400) + self.assertIn(b"Document not found", response.content) + + @patch("documents.views.has_perms_owner_aware") + @override_settings(AI_ENABLED=True) + def test_post_with_document_id_no_permission(self, mock_has_perms): + mock_has_perms.return_value = False + response = self.client.post( + self.ENDPOINT, + data=f'{{"q": "question", "document_id": {self.document.pk}}}', + content_type="application/json", + ) + self.assertEqual(response.status_code, 403) + self.assertIn(b"Insufficient permissions", response.content) diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 726da7fd6..0f4a4d4bd 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -3300,7 +3300,7 @@ class TestWorkflows( ) webhook_action = WorkflowActionWebhook.objects.create( use_params=False, - body="Test message: {{doc_url}}", + body="Test message: {{doc_url}} with id {{doc_id}}", url="http://paperless-ngx.com", include_document=False, ) @@ -3330,7 +3330,10 @@ class TestWorkflows( mock_post.assert_called_once_with( url="http://paperless-ngx.com", - data=f"Test message: http://localhost:8000/paperless/documents/{doc.id}/", + data=( + f"Test message: http://localhost:8000/paperless/documents/{doc.id}/" + f" with id {doc.id}" + ), headers={}, files=None, as_json=False, diff --git a/src/documents/views.py b/src/documents/views.py index 680600c4b..96b1f50b0 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -45,6 +45,7 @@ from django.http import HttpResponseBadRequest from django.http import HttpResponseForbidden from django.http import HttpResponseRedirect from django.http import HttpResponseServerError +from django.http import StreamingHttpResponse from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.decorators import method_decorator @@ -52,6 +53,7 @@ from django.utils.timezone import make_aware from django.utils.translation import get_language from django.views import View from django.views.decorators.cache import cache_control +from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import condition from django.views.decorators.http import last_modified from django.views.generic import TemplateView @@ -91,10 +93,12 @@ from documents import index from documents.bulk_download import ArchiveOnlyStrategy from documents.bulk_download import OriginalAndArchiveStrategy from documents.bulk_download import OriginalsOnlyStrategy +from documents.caching import get_llm_suggestion_cache from documents.caching import get_metadata_cache from documents.caching import get_suggestion_cache from documents.caching import refresh_metadata_cache from documents.caching import refresh_suggestions_cache +from documents.caching import set_llm_suggestions_cache from documents.caching import set_metadata_cache from documents.caching import set_suggestions_cache from documents.classifier import load_classifier @@ -182,18 +186,26 @@ from documents.signals import document_updated from documents.tasks import consume_file from documents.tasks import empty_trash from documents.tasks import index_optimize +from documents.tasks import llmindex_index from documents.tasks import sanity_check from documents.tasks import train_classifier from documents.tasks import update_document_parent_tags from documents.utils import get_boolean from paperless import version from paperless.celery import app as celery_app +from paperless.config import AIConfig from paperless.config import GeneralConfig -from paperless.db import GnuPG from paperless.models import ApplicationConfiguration from paperless.serialisers import GroupSerializer from paperless.serialisers import UserSerializer from paperless.views import StandardPagination +from paperless_ai.ai_classifier import get_ai_document_classification +from paperless_ai.chat import stream_chat_with_documents +from paperless_ai.matching import extract_unmatched_names +from paperless_ai.matching import match_correspondents_by_name +from paperless_ai.matching import match_document_types_by_name +from paperless_ai.matching import match_storage_paths_by_name +from paperless_ai.matching import match_tags_by_name from paperless_mail.models import MailAccount from paperless_mail.models import MailRule from paperless_mail.oauth import PaperlessMailOAuth2Manager @@ -448,8 +460,43 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin): def get_serializer_context(self): context = super().get_serializer_context() context["document_count_filter"] = self.get_document_count_filter() + if hasattr(self, "_children_map"): + context["children_map"] = self._children_map return context + def list(self, request, *args, **kwargs): + """ + Build a children map once to avoid per-parent queries in the serializer. + """ + queryset = self.filter_queryset(self.get_queryset()) + ordering = OrderingFilter().get_ordering(request, queryset, self) or ( + Lower("name"), + ) + queryset = queryset.order_by(*ordering) + + all_tags = list(queryset) + descendant_pks = {pk for tag in all_tags for pk in tag.get_descendants_pks()} + + if descendant_pks: + filter_q = self.get_document_count_filter() + children_source = ( + Tag.objects.filter(pk__in=descendant_pks | {t.pk for t in all_tags}) + .select_related("owner") + .annotate(document_count=Count("documents", filter=filter_q)) + .order_by(*ordering) + ) + else: + children_source = all_tags + + children_map = {} + for tag in children_source: + children_map.setdefault(tag.tn_parent_id, []).append(tag) + self._children_map = children_map + + page = self.paginate_queryset(queryset) + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + def perform_update(self, serializer): old_parent = self.get_object().get_parent() tag = serializer.save() @@ -899,37 +946,103 @@ class DocumentViewSet( ): return HttpResponseForbidden("Insufficient permissions") - document_suggestions = get_suggestion_cache(doc.pk) + ai_config = AIConfig() - if document_suggestions is not None: - refresh_suggestions_cache(doc.pk) - return Response(document_suggestions.suggestions) - - classifier = load_classifier() - - dates = [] - if settings.NUMBER_OF_SUGGESTED_DATES > 0: - gen = parse_date_generator(doc.filename, doc.content) - dates = sorted( - {i for i in itertools.islice(gen, settings.NUMBER_OF_SUGGESTED_DATES)}, + if ai_config.ai_enabled: + cached_llm_suggestions = get_llm_suggestion_cache( + doc.pk, + backend=ai_config.llm_backend, ) - resp_data = { - "correspondents": [ - c.id for c in match_correspondents(doc, classifier, request.user) - ], - "tags": [t.id for t in match_tags(doc, classifier, request.user)], - "document_types": [ - dt.id for dt in match_document_types(doc, classifier, request.user) - ], - "storage_paths": [ - dt.id for dt in match_storage_paths(doc, classifier, request.user) - ], - "dates": [date.strftime("%Y-%m-%d") for date in dates if date is not None], - } + if cached_llm_suggestions: + refresh_suggestions_cache(doc.pk) + return Response(cached_llm_suggestions.suggestions) - # Cache the suggestions and the classifier hash for later - set_suggestions_cache(doc.pk, resp_data, classifier) + llm_suggestions = get_ai_document_classification(doc, request.user) + + matched_tags = match_tags_by_name( + llm_suggestions.get("tags", []), + request.user, + ) + matched_correspondents = match_correspondents_by_name( + llm_suggestions.get("correspondents", []), + request.user, + ) + matched_types = match_document_types_by_name( + llm_suggestions.get("document_types", []), + request.user, + ) + matched_paths = match_storage_paths_by_name( + llm_suggestions.get("storage_paths", []), + request.user, + ) + + resp_data = { + "title": llm_suggestions.get("title"), + "tags": [t.id for t in matched_tags], + "suggested_tags": extract_unmatched_names( + llm_suggestions.get("tags", []), + matched_tags, + ), + "correspondents": [c.id for c in matched_correspondents], + "suggested_correspondents": extract_unmatched_names( + llm_suggestions.get("correspondents", []), + matched_correspondents, + ), + "document_types": [d.id for d in matched_types], + "suggested_document_types": extract_unmatched_names( + llm_suggestions.get("document_types", []), + matched_types, + ), + "storage_paths": [s.id for s in matched_paths], + "suggested_storage_paths": extract_unmatched_names( + llm_suggestions.get("storage_paths", []), + matched_paths, + ), + "dates": llm_suggestions.get("dates", []), + } + + set_llm_suggestions_cache(doc.pk, resp_data, backend=ai_config.llm_backend) + else: + document_suggestions = get_suggestion_cache(doc.pk) + + if document_suggestions is not None: + refresh_suggestions_cache(doc.pk) + return Response(document_suggestions.suggestions) + + classifier = load_classifier() + + dates = [] + if settings.NUMBER_OF_SUGGESTED_DATES > 0: + gen = parse_date_generator(doc.filename, doc.content) + dates = sorted( + { + i + for i in itertools.islice( + gen, + settings.NUMBER_OF_SUGGESTED_DATES, + ) + }, + ) + + resp_data = { + "correspondents": [ + c.id for c in match_correspondents(doc, classifier, request.user) + ], + "tags": [t.id for t in match_tags(doc, classifier, request.user)], + "document_types": [ + dt.id for dt in match_document_types(doc, classifier, request.user) + ], + "storage_paths": [ + dt.id for dt in match_storage_paths(doc, classifier, request.user) + ], + "dates": [ + date.strftime("%Y-%m-%d") for date in dates if date is not None + ], + } + + # Cache the suggestions and the classifier hash for later + set_suggestions_cache(doc.pk, resp_data, classifier) return Response(resp_data) @@ -957,10 +1070,8 @@ class DocumentViewSet( doc, ): return HttpResponseForbidden("Insufficient permissions") - if doc.storage_type == Document.STORAGE_TYPE_GPG: - handle = GnuPG.decrypted(doc.thumbnail_file) - else: - handle = doc.thumbnail_file + + handle = doc.thumbnail_file return HttpResponse(handle, content_type="image/webp") except (FileNotFoundError, Document.DoesNotExist): @@ -1253,6 +1364,59 @@ class DocumentViewSet( ) +class ChatStreamingSerializer(serializers.Serializer): + q = serializers.CharField(required=True) + document_id = serializers.IntegerField(required=False, allow_null=True) + + +@method_decorator( + [ + ensure_csrf_cookie, + cache_control(no_cache=True), + ], + name="dispatch", +) +class ChatStreamingView(GenericAPIView): + permission_classes = (IsAuthenticated,) + serializer_class = ChatStreamingSerializer + + def post(self, request, *args, **kwargs): + request.compress_exempt = True + ai_config = AIConfig() + if not ai_config.ai_enabled: + return HttpResponseBadRequest("AI is required for this feature") + + try: + question = request.data["q"] + except KeyError: + return HttpResponseBadRequest("Invalid request") + + doc_id = request.data.get("document_id") + + if doc_id: + try: + document = Document.objects.get(id=doc_id) + except Document.DoesNotExist: + return HttpResponseBadRequest("Document not found") + + if not has_perms_owner_aware(request.user, "view_document", document): + return HttpResponseForbidden("Insufficient permissions") + + documents = [document] + else: + documents = get_objects_for_user_owner_aware( + request.user, + "view_document", + Document, + ) + + response = StreamingHttpResponse( + stream_chat_with_documents(query_str=question, documents=documents), + content_type="text/event-stream", + ) + return response + + @extend_schema_view( list=extend_schema( description="Document views including search", @@ -2411,6 +2575,10 @@ class UiSettingsView(GenericAPIView): ui_settings["email_enabled"] = settings.EMAIL_ENABLED + ai_config = AIConfig() + + ui_settings["ai_enabled"] = ai_config.ai_enabled + user_resp = { "id": user.id, "username": user.username, @@ -2552,6 +2720,10 @@ class TasksViewSet(ReadOnlyModelViewSet): sanity_check, {"scheduled": False, "raise_on_error": False}, ), + PaperlessTask.TaskName.LLMINDEX_UPDATE: ( + llmindex_index, + {"scheduled": False, "rebuild": False}, + ), } def get_queryset(self): @@ -2649,9 +2821,6 @@ def serve_file(*, doc: Document, use_archive: bool, disposition: str): if mime_type in {"application/csv", "text/csv"} and disposition == "inline": mime_type = "text/plain" - if doc.storage_type == Document.STORAGE_TYPE_GPG: - file_handle = GnuPG.decrypted(file_handle) - response = HttpResponse(file_handle, content_type=mime_type) # Firefox is not able to handle unicode characters in filename field # RFC 5987 addresses this issue @@ -3071,6 +3240,31 @@ class SystemStatusView(PassUserMixin): last_sanity_check.date_done if last_sanity_check else None ) + ai_config = AIConfig() + if not ai_config.llm_index_enabled: + llmindex_status = "DISABLED" + llmindex_error = None + llmindex_last_modified = None + else: + last_llmindex_update = ( + PaperlessTask.objects.filter( + task_name=PaperlessTask.TaskName.LLMINDEX_UPDATE, + ) + .order_by("-date_done") + .first() + ) + llmindex_status = "OK" + llmindex_error = None + if last_llmindex_update is None: + llmindex_status = "WARNING" + llmindex_error = "No LLM index update tasks found" + elif last_llmindex_update and last_llmindex_update.status == states.FAILURE: + llmindex_status = "ERROR" + llmindex_error = last_llmindex_update.result + llmindex_last_modified = ( + last_llmindex_update.date_done if last_llmindex_update else None + ) + return Response( { "pngx_version": current_version, @@ -3108,6 +3302,9 @@ class SystemStatusView(PassUserMixin): "sanity_check_status": sanity_check_status, "sanity_check_last_run": sanity_check_last_run, "sanity_check_error": sanity_check_error, + "llmindex_status": llmindex_status, + "llmindex_last_modified": llmindex_last_modified, + "llmindex_error": llmindex_error, }, }, ) diff --git a/src/documents/workflows/actions.py b/src/documents/workflows/actions.py index 6c3c49ba8..442bc0abe 100644 --- a/src/documents/workflows/actions.py +++ b/src/documents/workflows/actions.py @@ -46,6 +46,7 @@ def build_workflow_action_context( "current_filename": document.filename or "", "added": timezone.localtime(document.added), "created": document.created, + "id": document.pk, } correspondent_obj = ( @@ -77,6 +78,7 @@ def build_workflow_action_context( "current_filename": filename, "added": timezone.localtime(timezone.now()), "created": overrides.created if overrides else None, + "id": "", } @@ -111,6 +113,7 @@ def execute_email_action( context["created"], context["title"], context["doc_url"], + context["id"], ) if action.email.subject else "" @@ -127,6 +130,7 @@ def execute_email_action( context["created"], context["title"], context["doc_url"], + context["id"], ) if action.email.body else "" @@ -205,6 +209,7 @@ def execute_webhook_action( context["created"], context["title"], context["doc_url"], + context["id"], ) except Exception as e: logger.error( @@ -223,6 +228,7 @@ def execute_webhook_action( context["created"], context["title"], context["doc_url"], + context["id"], ) headers = {} if action.webhook.headers: diff --git a/src/documents/workflows/mutations.py b/src/documents/workflows/mutations.py index ef85dba0f..b93a26781 100644 --- a/src/documents/workflows/mutations.py +++ b/src/documents/workflows/mutations.py @@ -55,6 +55,9 @@ def apply_assignment_to_document( document.original_filename or "", document.filename or "", document.created, + "", # dont pass the title to avoid recursion + "", # no urls in titles + document.pk, ) except Exception: # pragma: no cover logger.exception( diff --git a/src/documents/workflows/utils.py b/src/documents/workflows/utils.py index 553622252..0a644b0eb 100644 --- a/src/documents/workflows/utils.py +++ b/src/documents/workflows/utils.py @@ -20,9 +20,6 @@ def get_workflows_for_trigger( wrap it in a list; otherwise fetch enabled workflows for the trigger with the prefetches used by the runner. """ - if workflow_to_run is not None: - return [workflow_to_run] - annotated_actions = ( WorkflowAction.objects.select_related( "assign_correspondent", @@ -105,10 +102,25 @@ def get_workflows_for_trigger( ) ) + action_prefetch = Prefetch( + "actions", + queryset=annotated_actions.order_by("order", "pk"), + ) + + if workflow_to_run is not None: + return ( + Workflow.objects.filter(pk=workflow_to_run.pk) + .prefetch_related( + action_prefetch, + "triggers", + ) + .distinct() + ) + return ( Workflow.objects.filter(enabled=True, triggers__type=trigger_type) .prefetch_related( - Prefetch("actions", queryset=annotated_actions), + action_prefetch, "triggers", ) .order_by("order") diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 850c20ed5..7e4bf0abf 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-01-08 21:50+0000\n" +"POT-Creation-Date: 2026-01-25 03:30+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -57,31 +57,31 @@ msgstr "" msgid "Custom field not found" msgstr "" -#: documents/models.py:38 documents/models.py:768 +#: documents/models.py:38 documents/models.py:747 msgid "owner" msgstr "" -#: documents/models.py:55 documents/models.py:983 +#: documents/models.py:55 documents/models.py:962 msgid "None" msgstr "" -#: documents/models.py:56 documents/models.py:984 +#: documents/models.py:56 documents/models.py:963 msgid "Any word" msgstr "" -#: documents/models.py:57 documents/models.py:985 +#: documents/models.py:57 documents/models.py:964 msgid "All words" msgstr "" -#: documents/models.py:58 documents/models.py:986 +#: documents/models.py:58 documents/models.py:965 msgid "Exact match" msgstr "" -#: documents/models.py:59 documents/models.py:987 +#: documents/models.py:59 documents/models.py:966 msgid "Regular expression" msgstr "" -#: documents/models.py:60 documents/models.py:988 +#: documents/models.py:60 documents/models.py:967 msgid "Fuzzy word" msgstr "" @@ -89,24 +89,24 @@ msgstr "" msgid "Automatic" msgstr "" -#: documents/models.py:64 documents/models.py:456 documents/models.py:1526 +#: documents/models.py:64 documents/models.py:434 documents/models.py:1507 #: paperless_mail/models.py:23 paperless_mail/models.py:143 msgid "name" msgstr "" -#: documents/models.py:66 documents/models.py:1052 +#: documents/models.py:66 documents/models.py:1031 msgid "match" msgstr "" -#: documents/models.py:69 documents/models.py:1055 +#: documents/models.py:69 documents/models.py:1034 msgid "matching algorithm" msgstr "" -#: documents/models.py:74 documents/models.py:1060 +#: documents/models.py:74 documents/models.py:1039 msgid "is insensitive" msgstr "" -#: documents/models.py:97 documents/models.py:170 +#: documents/models.py:97 documents/models.py:163 msgid "correspondent" msgstr "" @@ -132,7 +132,7 @@ msgstr "" msgid "tag" msgstr "" -#: documents/models.py:117 documents/models.py:208 +#: documents/models.py:117 documents/models.py:201 msgid "tags" msgstr "" @@ -144,7 +144,7 @@ msgstr "" msgid "Cannot set parent to a descendant." msgstr "" -#: documents/models.py:142 documents/models.py:190 +#: documents/models.py:142 documents/models.py:183 msgid "document type" msgstr "" @@ -156,7 +156,7 @@ msgstr "" msgid "path" msgstr "" -#: documents/models.py:152 documents/models.py:179 +#: documents/models.py:152 documents/models.py:172 msgid "storage path" msgstr "" @@ -164,1090 +164,1083 @@ msgstr "" msgid "storage paths" msgstr "" -#: documents/models.py:160 -msgid "Unencrypted" -msgstr "" - -#: documents/models.py:161 -msgid "Encrypted with GNU Privacy Guard" -msgstr "" - -#: documents/models.py:182 +#: documents/models.py:175 msgid "title" msgstr "" -#: documents/models.py:194 documents/models.py:682 +#: documents/models.py:187 documents/models.py:661 msgid "content" msgstr "" -#: documents/models.py:197 +#: documents/models.py:190 msgid "" "The raw, text-only data of the document. This field is primarily used for " "searching." msgstr "" -#: documents/models.py:202 +#: documents/models.py:195 msgid "mime type" msgstr "" -#: documents/models.py:212 +#: documents/models.py:205 msgid "checksum" msgstr "" -#: documents/models.py:216 +#: documents/models.py:209 msgid "The checksum of the original document." msgstr "" -#: documents/models.py:220 +#: documents/models.py:213 msgid "archive checksum" msgstr "" -#: documents/models.py:225 +#: documents/models.py:218 msgid "The checksum of the archived document." msgstr "" -#: documents/models.py:229 +#: documents/models.py:222 msgid "page count" msgstr "" -#: documents/models.py:236 +#: documents/models.py:229 msgid "The number of pages of the document." msgstr "" -#: documents/models.py:241 documents/models.py:688 documents/models.py:726 -#: documents/models.py:798 documents/models.py:857 +#: documents/models.py:234 documents/models.py:667 documents/models.py:705 +#: documents/models.py:777 documents/models.py:836 msgid "created" msgstr "" -#: documents/models.py:247 +#: documents/models.py:240 msgid "modified" msgstr "" -#: documents/models.py:254 -msgid "storage type" -msgstr "" - -#: documents/models.py:262 +#: documents/models.py:247 msgid "added" msgstr "" -#: documents/models.py:269 +#: documents/models.py:254 msgid "filename" msgstr "" -#: documents/models.py:275 +#: documents/models.py:260 msgid "Current filename in storage" msgstr "" -#: documents/models.py:279 +#: documents/models.py:264 msgid "archive filename" msgstr "" -#: documents/models.py:285 +#: documents/models.py:270 msgid "Current archive filename in storage" msgstr "" -#: documents/models.py:289 +#: documents/models.py:274 msgid "original filename" msgstr "" -#: documents/models.py:295 +#: documents/models.py:280 msgid "The original name of the file when it was uploaded" msgstr "" -#: documents/models.py:302 +#: documents/models.py:287 msgid "archive serial number" msgstr "" -#: documents/models.py:312 +#: documents/models.py:297 msgid "The position of this document in your physical document archive." msgstr "" -#: documents/models.py:318 documents/models.py:699 documents/models.py:753 -#: documents/models.py:1569 +#: documents/models.py:303 documents/models.py:678 documents/models.py:732 +#: documents/models.py:1550 msgid "document" msgstr "" -#: documents/models.py:319 +#: documents/models.py:304 msgid "documents" msgstr "" -#: documents/models.py:437 +#: documents/models.py:415 msgid "Table" msgstr "" -#: documents/models.py:438 +#: documents/models.py:416 msgid "Small Cards" msgstr "" -#: documents/models.py:439 +#: documents/models.py:417 msgid "Large Cards" msgstr "" -#: documents/models.py:442 +#: documents/models.py:420 msgid "Title" msgstr "" -#: documents/models.py:443 documents/models.py:1004 +#: documents/models.py:421 documents/models.py:983 msgid "Created" msgstr "" -#: documents/models.py:444 documents/models.py:1003 +#: documents/models.py:422 documents/models.py:982 msgid "Added" msgstr "" -#: documents/models.py:445 +#: documents/models.py:423 msgid "Tags" msgstr "" -#: documents/models.py:446 +#: documents/models.py:424 msgid "Correspondent" msgstr "" -#: documents/models.py:447 +#: documents/models.py:425 msgid "Document Type" msgstr "" -#: documents/models.py:448 +#: documents/models.py:426 msgid "Storage Path" msgstr "" -#: documents/models.py:449 +#: documents/models.py:427 msgid "Note" msgstr "" -#: documents/models.py:450 +#: documents/models.py:428 msgid "Owner" msgstr "" -#: documents/models.py:451 +#: documents/models.py:429 msgid "Shared" msgstr "" -#: documents/models.py:452 +#: documents/models.py:430 msgid "ASN" msgstr "" -#: documents/models.py:453 +#: documents/models.py:431 msgid "Pages" msgstr "" -#: documents/models.py:459 +#: documents/models.py:437 msgid "show on dashboard" msgstr "" -#: documents/models.py:462 +#: documents/models.py:440 msgid "show in sidebar" msgstr "" -#: documents/models.py:466 +#: documents/models.py:444 msgid "sort field" msgstr "" -#: documents/models.py:471 +#: documents/models.py:449 msgid "sort reverse" msgstr "" -#: documents/models.py:474 +#: documents/models.py:452 msgid "View page size" msgstr "" -#: documents/models.py:482 +#: documents/models.py:460 msgid "View display mode" msgstr "" -#: documents/models.py:489 +#: documents/models.py:467 msgid "Document display fields" msgstr "" -#: documents/models.py:496 documents/models.py:559 +#: documents/models.py:474 documents/models.py:537 msgid "saved view" msgstr "" -#: documents/models.py:497 +#: documents/models.py:475 msgid "saved views" msgstr "" -#: documents/models.py:505 +#: documents/models.py:483 msgid "title contains" msgstr "" -#: documents/models.py:506 +#: documents/models.py:484 msgid "content contains" msgstr "" -#: documents/models.py:507 +#: documents/models.py:485 msgid "ASN is" msgstr "" -#: documents/models.py:508 +#: documents/models.py:486 msgid "correspondent is" msgstr "" -#: documents/models.py:509 +#: documents/models.py:487 msgid "document type is" msgstr "" -#: documents/models.py:510 +#: documents/models.py:488 msgid "is in inbox" msgstr "" -#: documents/models.py:511 +#: documents/models.py:489 msgid "has tag" msgstr "" -#: documents/models.py:512 +#: documents/models.py:490 msgid "has any tag" msgstr "" -#: documents/models.py:513 +#: documents/models.py:491 msgid "created before" msgstr "" -#: documents/models.py:514 +#: documents/models.py:492 msgid "created after" msgstr "" -#: documents/models.py:515 +#: documents/models.py:493 msgid "created year is" msgstr "" -#: documents/models.py:516 +#: documents/models.py:494 msgid "created month is" msgstr "" -#: documents/models.py:517 +#: documents/models.py:495 msgid "created day is" msgstr "" -#: documents/models.py:518 +#: documents/models.py:496 msgid "added before" msgstr "" -#: documents/models.py:519 +#: documents/models.py:497 msgid "added after" msgstr "" -#: documents/models.py:520 +#: documents/models.py:498 msgid "modified before" msgstr "" -#: documents/models.py:521 +#: documents/models.py:499 msgid "modified after" msgstr "" -#: documents/models.py:522 +#: documents/models.py:500 msgid "does not have tag" msgstr "" -#: documents/models.py:523 +#: documents/models.py:501 msgid "does not have ASN" msgstr "" -#: documents/models.py:524 +#: documents/models.py:502 msgid "title or content contains" msgstr "" -#: documents/models.py:525 +#: documents/models.py:503 msgid "fulltext query" msgstr "" -#: documents/models.py:526 +#: documents/models.py:504 msgid "more like this" msgstr "" -#: documents/models.py:527 +#: documents/models.py:505 msgid "has tags in" msgstr "" -#: documents/models.py:528 +#: documents/models.py:506 msgid "ASN greater than" msgstr "" -#: documents/models.py:529 +#: documents/models.py:507 msgid "ASN less than" msgstr "" -#: documents/models.py:530 +#: documents/models.py:508 msgid "storage path is" msgstr "" -#: documents/models.py:531 +#: documents/models.py:509 msgid "has correspondent in" msgstr "" -#: documents/models.py:532 +#: documents/models.py:510 msgid "does not have correspondent in" msgstr "" -#: documents/models.py:533 +#: documents/models.py:511 msgid "has document type in" msgstr "" -#: documents/models.py:534 +#: documents/models.py:512 msgid "does not have document type in" msgstr "" -#: documents/models.py:535 +#: documents/models.py:513 msgid "has storage path in" msgstr "" -#: documents/models.py:536 +#: documents/models.py:514 msgid "does not have storage path in" msgstr "" -#: documents/models.py:537 +#: documents/models.py:515 msgid "owner is" msgstr "" -#: documents/models.py:538 +#: documents/models.py:516 msgid "has owner in" msgstr "" -#: documents/models.py:539 +#: documents/models.py:517 msgid "does not have owner" msgstr "" -#: documents/models.py:540 +#: documents/models.py:518 msgid "does not have owner in" msgstr "" -#: documents/models.py:541 +#: documents/models.py:519 msgid "has custom field value" msgstr "" -#: documents/models.py:542 +#: documents/models.py:520 msgid "is shared by me" msgstr "" -#: documents/models.py:543 +#: documents/models.py:521 msgid "has custom fields" msgstr "" -#: documents/models.py:544 +#: documents/models.py:522 msgid "has custom field in" msgstr "" -#: documents/models.py:545 +#: documents/models.py:523 msgid "does not have custom field in" msgstr "" -#: documents/models.py:546 +#: documents/models.py:524 msgid "does not have custom field" msgstr "" -#: documents/models.py:547 +#: documents/models.py:525 msgid "custom fields query" msgstr "" -#: documents/models.py:548 +#: documents/models.py:526 msgid "created to" msgstr "" -#: documents/models.py:549 +#: documents/models.py:527 msgid "created from" msgstr "" -#: documents/models.py:550 +#: documents/models.py:528 msgid "added to" msgstr "" -#: documents/models.py:551 +#: documents/models.py:529 msgid "added from" msgstr "" -#: documents/models.py:552 +#: documents/models.py:530 msgid "mime type is" msgstr "" -#: documents/models.py:562 +#: documents/models.py:540 msgid "rule type" msgstr "" -#: documents/models.py:564 +#: documents/models.py:542 msgid "value" msgstr "" -#: documents/models.py:567 +#: documents/models.py:545 msgid "filter rule" msgstr "" -#: documents/models.py:568 +#: documents/models.py:546 msgid "filter rules" msgstr "" -#: documents/models.py:592 +#: documents/models.py:570 msgid "Auto Task" msgstr "" -#: documents/models.py:593 +#: documents/models.py:571 msgid "Scheduled Task" msgstr "" -#: documents/models.py:594 +#: documents/models.py:572 msgid "Manual Task" msgstr "" -#: documents/models.py:597 +#: documents/models.py:575 msgid "Consume File" msgstr "" -#: documents/models.py:598 +#: documents/models.py:576 msgid "Train Classifier" msgstr "" -#: documents/models.py:599 +#: documents/models.py:577 msgid "Check Sanity" msgstr "" -#: documents/models.py:600 +#: documents/models.py:578 msgid "Index Optimize" msgstr "" -#: documents/models.py:605 +#: documents/models.py:579 +msgid "LLM Index Update" +msgstr "" + +#: documents/models.py:584 msgid "Task ID" msgstr "" -#: documents/models.py:606 +#: documents/models.py:585 msgid "Celery ID for the Task that was run" msgstr "" -#: documents/models.py:611 +#: documents/models.py:590 msgid "Acknowledged" msgstr "" -#: documents/models.py:612 +#: documents/models.py:591 msgid "If the task is acknowledged via the frontend or API" msgstr "" -#: documents/models.py:618 +#: documents/models.py:597 msgid "Task Filename" msgstr "" -#: documents/models.py:619 +#: documents/models.py:598 msgid "Name of the file which the Task was run for" msgstr "" -#: documents/models.py:626 +#: documents/models.py:605 msgid "Task Name" msgstr "" -#: documents/models.py:627 +#: documents/models.py:606 msgid "Name of the task that was run" msgstr "" -#: documents/models.py:634 +#: documents/models.py:613 msgid "Task State" msgstr "" -#: documents/models.py:635 +#: documents/models.py:614 msgid "Current state of the task being run" msgstr "" -#: documents/models.py:641 +#: documents/models.py:620 msgid "Created DateTime" msgstr "" -#: documents/models.py:642 +#: documents/models.py:621 msgid "Datetime field when the task result was created in UTC" msgstr "" -#: documents/models.py:648 +#: documents/models.py:627 msgid "Started DateTime" msgstr "" -#: documents/models.py:649 +#: documents/models.py:628 msgid "Datetime field when the task was started in UTC" msgstr "" -#: documents/models.py:655 +#: documents/models.py:634 msgid "Completed DateTime" msgstr "" -#: documents/models.py:656 +#: documents/models.py:635 msgid "Datetime field when the task was completed in UTC" msgstr "" -#: documents/models.py:662 +#: documents/models.py:641 msgid "Result Data" msgstr "" -#: documents/models.py:664 +#: documents/models.py:643 msgid "The data returned by the task" msgstr "" -#: documents/models.py:672 +#: documents/models.py:651 msgid "Task Type" msgstr "" -#: documents/models.py:673 +#: documents/models.py:652 msgid "The type of task that was run" msgstr "" -#: documents/models.py:684 +#: documents/models.py:663 msgid "Note for the document" msgstr "" -#: documents/models.py:708 +#: documents/models.py:687 msgid "user" msgstr "" -#: documents/models.py:713 +#: documents/models.py:692 msgid "note" msgstr "" -#: documents/models.py:714 +#: documents/models.py:693 msgid "notes" msgstr "" -#: documents/models.py:722 +#: documents/models.py:701 msgid "Archive" msgstr "" -#: documents/models.py:723 +#: documents/models.py:702 msgid "Original" msgstr "" -#: documents/models.py:734 paperless_mail/models.py:75 +#: documents/models.py:713 paperless_mail/models.py:75 msgid "expiration" msgstr "" -#: documents/models.py:741 +#: documents/models.py:720 msgid "slug" msgstr "" -#: documents/models.py:773 +#: documents/models.py:752 msgid "share link" msgstr "" -#: documents/models.py:774 +#: documents/models.py:753 msgid "share links" msgstr "" -#: documents/models.py:786 +#: documents/models.py:765 msgid "String" msgstr "" -#: documents/models.py:787 +#: documents/models.py:766 msgid "URL" msgstr "" -#: documents/models.py:788 +#: documents/models.py:767 msgid "Date" msgstr "" -#: documents/models.py:789 +#: documents/models.py:768 msgid "Boolean" msgstr "" -#: documents/models.py:790 +#: documents/models.py:769 msgid "Integer" msgstr "" -#: documents/models.py:791 +#: documents/models.py:770 msgid "Float" msgstr "" -#: documents/models.py:792 +#: documents/models.py:771 msgid "Monetary" msgstr "" -#: documents/models.py:793 +#: documents/models.py:772 msgid "Document Link" msgstr "" -#: documents/models.py:794 +#: documents/models.py:773 msgid "Select" msgstr "" -#: documents/models.py:795 +#: documents/models.py:774 msgid "Long Text" msgstr "" -#: documents/models.py:807 +#: documents/models.py:786 msgid "data type" msgstr "" -#: documents/models.py:814 +#: documents/models.py:793 msgid "extra data" msgstr "" -#: documents/models.py:818 +#: documents/models.py:797 msgid "Extra data for the custom field, such as select options" msgstr "" -#: documents/models.py:824 +#: documents/models.py:803 msgid "custom field" msgstr "" -#: documents/models.py:825 +#: documents/models.py:804 msgid "custom fields" msgstr "" -#: documents/models.py:925 +#: documents/models.py:904 msgid "custom field instance" msgstr "" -#: documents/models.py:926 +#: documents/models.py:905 msgid "custom field instances" msgstr "" -#: documents/models.py:991 +#: documents/models.py:970 msgid "Consumption Started" msgstr "" -#: documents/models.py:992 +#: documents/models.py:971 msgid "Document Added" msgstr "" -#: documents/models.py:993 +#: documents/models.py:972 msgid "Document Updated" msgstr "" -#: documents/models.py:994 +#: documents/models.py:973 msgid "Scheduled" msgstr "" -#: documents/models.py:997 +#: documents/models.py:976 msgid "Consume Folder" msgstr "" -#: documents/models.py:998 +#: documents/models.py:977 msgid "Api Upload" msgstr "" -#: documents/models.py:999 +#: documents/models.py:978 msgid "Mail Fetch" msgstr "" -#: documents/models.py:1000 +#: documents/models.py:979 msgid "Web UI" msgstr "" -#: documents/models.py:1005 +#: documents/models.py:984 msgid "Modified" msgstr "" -#: documents/models.py:1006 +#: documents/models.py:985 msgid "Custom Field" msgstr "" -#: documents/models.py:1009 +#: documents/models.py:988 msgid "Workflow Trigger Type" msgstr "" -#: documents/models.py:1021 +#: documents/models.py:1000 msgid "filter path" msgstr "" -#: documents/models.py:1026 +#: documents/models.py:1005 msgid "" "Only consume documents with a path that matches this if specified. Wildcards " "specified as * are allowed. Case insensitive." msgstr "" -#: documents/models.py:1033 +#: documents/models.py:1012 msgid "filter filename" msgstr "" -#: documents/models.py:1038 paperless_mail/models.py:200 +#: documents/models.py:1017 paperless_mail/models.py:200 msgid "" "Only consume documents which entirely match this filename if specified. " "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." msgstr "" -#: documents/models.py:1049 +#: documents/models.py:1028 msgid "filter documents from this mail rule" msgstr "" -#: documents/models.py:1065 +#: documents/models.py:1044 msgid "has these tag(s)" msgstr "" -#: documents/models.py:1072 +#: documents/models.py:1051 msgid "has all of these tag(s)" msgstr "" -#: documents/models.py:1079 +#: documents/models.py:1058 msgid "does not have these tag(s)" msgstr "" -#: documents/models.py:1087 +#: documents/models.py:1066 msgid "has this document type" msgstr "" -#: documents/models.py:1094 +#: documents/models.py:1073 msgid "does not have these document type(s)" msgstr "" -#: documents/models.py:1102 +#: documents/models.py:1081 msgid "has this correspondent" msgstr "" -#: documents/models.py:1109 +#: documents/models.py:1088 msgid "does not have these correspondent(s)" msgstr "" -#: documents/models.py:1117 +#: documents/models.py:1096 msgid "has this storage path" msgstr "" -#: documents/models.py:1124 +#: documents/models.py:1103 msgid "does not have these storage path(s)" msgstr "" -#: documents/models.py:1128 +#: documents/models.py:1107 msgid "filter custom field query" msgstr "" -#: documents/models.py:1131 +#: documents/models.py:1110 msgid "JSON-encoded custom field query expression." msgstr "" -#: documents/models.py:1135 +#: documents/models.py:1114 msgid "schedule offset days" msgstr "" -#: documents/models.py:1138 +#: documents/models.py:1117 msgid "The number of days to offset the schedule trigger by." msgstr "" -#: documents/models.py:1143 +#: documents/models.py:1122 msgid "schedule is recurring" msgstr "" -#: documents/models.py:1146 +#: documents/models.py:1125 msgid "If the schedule should be recurring." msgstr "" -#: documents/models.py:1151 +#: documents/models.py:1130 msgid "schedule recurring delay in days" msgstr "" -#: documents/models.py:1155 +#: documents/models.py:1134 msgid "The number of days between recurring schedule triggers." msgstr "" -#: documents/models.py:1160 +#: documents/models.py:1139 msgid "schedule date field" msgstr "" -#: documents/models.py:1165 +#: documents/models.py:1144 msgid "The field to check for a schedule trigger." msgstr "" -#: documents/models.py:1174 +#: documents/models.py:1153 msgid "schedule date custom field" msgstr "" -#: documents/models.py:1178 +#: documents/models.py:1157 msgid "workflow trigger" msgstr "" -#: documents/models.py:1179 +#: documents/models.py:1158 msgid "workflow triggers" msgstr "" -#: documents/models.py:1187 +#: documents/models.py:1166 msgid "email subject" msgstr "" -#: documents/models.py:1191 +#: documents/models.py:1170 msgid "" "The subject of the email, can include some placeholders, see documentation." msgstr "" -#: documents/models.py:1197 +#: documents/models.py:1176 msgid "email body" msgstr "" -#: documents/models.py:1200 +#: documents/models.py:1179 msgid "" "The body (message) of the email, can include some placeholders, see " "documentation." msgstr "" -#: documents/models.py:1206 +#: documents/models.py:1185 msgid "emails to" msgstr "" -#: documents/models.py:1209 +#: documents/models.py:1188 msgid "The destination email addresses, comma separated." msgstr "" -#: documents/models.py:1215 +#: documents/models.py:1194 msgid "include document in email" msgstr "" -#: documents/models.py:1226 +#: documents/models.py:1205 msgid "webhook url" msgstr "" -#: documents/models.py:1229 +#: documents/models.py:1208 msgid "The destination URL for the notification." msgstr "" -#: documents/models.py:1234 +#: documents/models.py:1213 msgid "use parameters" msgstr "" -#: documents/models.py:1239 +#: documents/models.py:1218 msgid "send as JSON" msgstr "" -#: documents/models.py:1243 +#: documents/models.py:1222 msgid "webhook parameters" msgstr "" -#: documents/models.py:1246 +#: documents/models.py:1225 msgid "The parameters to send with the webhook URL if body not used." msgstr "" -#: documents/models.py:1250 +#: documents/models.py:1229 msgid "webhook body" msgstr "" -#: documents/models.py:1253 +#: documents/models.py:1232 msgid "The body to send with the webhook URL if parameters not used." msgstr "" -#: documents/models.py:1257 +#: documents/models.py:1236 msgid "webhook headers" msgstr "" -#: documents/models.py:1260 +#: documents/models.py:1239 msgid "The headers to send with the webhook URL." msgstr "" -#: documents/models.py:1265 +#: documents/models.py:1244 msgid "include document in webhook" msgstr "" -#: documents/models.py:1276 +#: documents/models.py:1255 msgid "Assignment" msgstr "" -#: documents/models.py:1280 +#: documents/models.py:1259 msgid "Removal" msgstr "" -#: documents/models.py:1284 documents/templates/account/password_reset.html:15 +#: documents/models.py:1263 documents/templates/account/password_reset.html:15 msgid "Email" msgstr "" -#: documents/models.py:1288 +#: documents/models.py:1267 msgid "Webhook" msgstr "" -#: documents/models.py:1292 +#: documents/models.py:1271 msgid "Workflow Action Type" msgstr "" -#: documents/models.py:1298 -msgid "assign title" -msgstr "" - -#: documents/models.py:1302 -msgid "Assign a document title, must be a Jinja2 template, see documentation." -msgstr "" - -#: documents/models.py:1310 paperless_mail/models.py:274 -msgid "assign this tag" -msgstr "" - -#: documents/models.py:1319 paperless_mail/models.py:282 -msgid "assign this document type" -msgstr "" - -#: documents/models.py:1328 paperless_mail/models.py:296 -msgid "assign this correspondent" -msgstr "" - -#: documents/models.py:1337 -msgid "assign this storage path" -msgstr "" - -#: documents/models.py:1346 -msgid "assign this owner" -msgstr "" - -#: documents/models.py:1353 -msgid "grant view permissions to these users" -msgstr "" - -#: documents/models.py:1360 -msgid "grant view permissions to these groups" -msgstr "" - -#: documents/models.py:1367 -msgid "grant change permissions to these users" -msgstr "" - -#: documents/models.py:1374 -msgid "grant change permissions to these groups" -msgstr "" - -#: documents/models.py:1381 -msgid "assign these custom fields" -msgstr "" - -#: documents/models.py:1385 -msgid "custom field values" -msgstr "" - -#: documents/models.py:1389 -msgid "Optional values to assign to the custom fields." -msgstr "" - -#: documents/models.py:1398 -msgid "remove these tag(s)" -msgstr "" - -#: documents/models.py:1403 -msgid "remove all tags" -msgstr "" - -#: documents/models.py:1410 -msgid "remove these document type(s)" -msgstr "" - -#: documents/models.py:1415 -msgid "remove all document types" -msgstr "" - -#: documents/models.py:1422 -msgid "remove these correspondent(s)" -msgstr "" - -#: documents/models.py:1427 -msgid "remove all correspondents" -msgstr "" - -#: documents/models.py:1434 -msgid "remove these storage path(s)" -msgstr "" - -#: documents/models.py:1439 -msgid "remove all storage paths" -msgstr "" - -#: documents/models.py:1446 -msgid "remove these owner(s)" -msgstr "" - -#: documents/models.py:1451 -msgid "remove all owners" -msgstr "" - -#: documents/models.py:1458 -msgid "remove view permissions for these users" -msgstr "" - -#: documents/models.py:1465 -msgid "remove view permissions for these groups" -msgstr "" - -#: documents/models.py:1472 -msgid "remove change permissions for these users" -msgstr "" - -#: documents/models.py:1479 -msgid "remove change permissions for these groups" -msgstr "" - -#: documents/models.py:1484 -msgid "remove all permissions" -msgstr "" - -#: documents/models.py:1491 -msgid "remove these custom fields" -msgstr "" - -#: documents/models.py:1496 -msgid "remove all custom fields" -msgstr "" - -#: documents/models.py:1505 -msgid "email" -msgstr "" - -#: documents/models.py:1514 -msgid "webhook" -msgstr "" - -#: documents/models.py:1518 -msgid "workflow action" -msgstr "" - -#: documents/models.py:1519 -msgid "workflow actions" -msgstr "" - -#: documents/models.py:1528 paperless_mail/models.py:145 +#: documents/models.py:1276 documents/models.py:1509 +#: paperless_mail/models.py:145 msgid "order" msgstr "" -#: documents/models.py:1534 +#: documents/models.py:1279 +msgid "assign title" +msgstr "" + +#: documents/models.py:1283 +msgid "Assign a document title, must be a Jinja2 template, see documentation." +msgstr "" + +#: documents/models.py:1291 paperless_mail/models.py:274 +msgid "assign this tag" +msgstr "" + +#: documents/models.py:1300 paperless_mail/models.py:282 +msgid "assign this document type" +msgstr "" + +#: documents/models.py:1309 paperless_mail/models.py:296 +msgid "assign this correspondent" +msgstr "" + +#: documents/models.py:1318 +msgid "assign this storage path" +msgstr "" + +#: documents/models.py:1327 +msgid "assign this owner" +msgstr "" + +#: documents/models.py:1334 +msgid "grant view permissions to these users" +msgstr "" + +#: documents/models.py:1341 +msgid "grant view permissions to these groups" +msgstr "" + +#: documents/models.py:1348 +msgid "grant change permissions to these users" +msgstr "" + +#: documents/models.py:1355 +msgid "grant change permissions to these groups" +msgstr "" + +#: documents/models.py:1362 +msgid "assign these custom fields" +msgstr "" + +#: documents/models.py:1366 +msgid "custom field values" +msgstr "" + +#: documents/models.py:1370 +msgid "Optional values to assign to the custom fields." +msgstr "" + +#: documents/models.py:1379 +msgid "remove these tag(s)" +msgstr "" + +#: documents/models.py:1384 +msgid "remove all tags" +msgstr "" + +#: documents/models.py:1391 +msgid "remove these document type(s)" +msgstr "" + +#: documents/models.py:1396 +msgid "remove all document types" +msgstr "" + +#: documents/models.py:1403 +msgid "remove these correspondent(s)" +msgstr "" + +#: documents/models.py:1408 +msgid "remove all correspondents" +msgstr "" + +#: documents/models.py:1415 +msgid "remove these storage path(s)" +msgstr "" + +#: documents/models.py:1420 +msgid "remove all storage paths" +msgstr "" + +#: documents/models.py:1427 +msgid "remove these owner(s)" +msgstr "" + +#: documents/models.py:1432 +msgid "remove all owners" +msgstr "" + +#: documents/models.py:1439 +msgid "remove view permissions for these users" +msgstr "" + +#: documents/models.py:1446 +msgid "remove view permissions for these groups" +msgstr "" + +#: documents/models.py:1453 +msgid "remove change permissions for these users" +msgstr "" + +#: documents/models.py:1460 +msgid "remove change permissions for these groups" +msgstr "" + +#: documents/models.py:1465 +msgid "remove all permissions" +msgstr "" + +#: documents/models.py:1472 +msgid "remove these custom fields" +msgstr "" + +#: documents/models.py:1477 +msgid "remove all custom fields" +msgstr "" + +#: documents/models.py:1486 +msgid "email" +msgstr "" + +#: documents/models.py:1495 +msgid "webhook" +msgstr "" + +#: documents/models.py:1499 +msgid "workflow action" +msgstr "" + +#: documents/models.py:1500 +msgid "workflow actions" +msgstr "" + +#: documents/models.py:1515 msgid "triggers" msgstr "" -#: documents/models.py:1541 +#: documents/models.py:1522 msgid "actions" msgstr "" -#: documents/models.py:1544 paperless_mail/models.py:154 +#: documents/models.py:1525 paperless_mail/models.py:154 msgid "enabled" msgstr "" -#: documents/models.py:1555 +#: documents/models.py:1536 msgid "workflow" msgstr "" -#: documents/models.py:1559 +#: documents/models.py:1540 msgid "workflow trigger type" msgstr "" -#: documents/models.py:1573 +#: documents/models.py:1554 msgid "date run" msgstr "" -#: documents/models.py:1579 +#: documents/models.py:1560 msgid "workflow run" msgstr "" -#: documents/models.py:1580 +#: documents/models.py:1561 msgid "workflow runs" msgstr "" -#: documents/serialisers.py:642 +#: documents/serialisers.py:646 msgid "Invalid color." msgstr "" -#: documents/serialisers.py:1846 +#: documents/serialisers.py:1850 #, python-format msgid "File type %(type)s not supported" msgstr "" -#: documents/serialisers.py:1890 +#: documents/serialisers.py:1894 #, python-format msgid "Custom field id must be an integer: %(id)s" msgstr "" -#: documents/serialisers.py:1897 +#: documents/serialisers.py:1901 #, python-format msgid "Custom field with id %(id)s does not exist" msgstr "" -#: documents/serialisers.py:1914 documents/serialisers.py:1924 +#: documents/serialisers.py:1918 documents/serialisers.py:1928 msgid "" "Custom fields must be a list of integers or an object mapping ids to values." msgstr "" -#: documents/serialisers.py:1919 +#: documents/serialisers.py:1923 msgid "Some custom fields don't exist or were specified twice." msgstr "" -#: documents/serialisers.py:2034 +#: documents/serialisers.py:2038 msgid "Invalid variable detected." msgstr "" @@ -1594,263 +1587,303 @@ msgstr "" msgid "CMYK" msgstr "" -#: paperless/models.py:83 +#: paperless/models.py:78 paperless/models.py:87 +msgid "OpenAI" +msgstr "" + +#: paperless/models.py:79 +msgid "Huggingface" +msgstr "" + +#: paperless/models.py:88 +msgid "Ollama" +msgstr "" + +#: paperless/models.py:97 msgid "Sets the output PDF type" msgstr "" -#: paperless/models.py:95 +#: paperless/models.py:109 msgid "Do OCR from page 1 to this value" msgstr "" -#: paperless/models.py:101 +#: paperless/models.py:115 msgid "Do OCR using these languages" msgstr "" -#: paperless/models.py:108 +#: paperless/models.py:122 msgid "Sets the OCR mode" msgstr "" -#: paperless/models.py:116 +#: paperless/models.py:130 msgid "Controls the generation of an archive file" msgstr "" -#: paperless/models.py:124 +#: paperless/models.py:138 msgid "Sets image DPI fallback value" msgstr "" -#: paperless/models.py:131 +#: paperless/models.py:145 msgid "Controls the unpaper cleaning" msgstr "" -#: paperless/models.py:138 +#: paperless/models.py:152 msgid "Enables deskew" msgstr "" -#: paperless/models.py:141 +#: paperless/models.py:155 msgid "Enables page rotation" msgstr "" -#: paperless/models.py:146 +#: paperless/models.py:160 msgid "Sets the threshold for rotation of pages" msgstr "" -#: paperless/models.py:152 +#: paperless/models.py:166 msgid "Sets the maximum image size for decompression" msgstr "" -#: paperless/models.py:158 +#: paperless/models.py:172 msgid "Sets the Ghostscript color conversion strategy" msgstr "" -#: paperless/models.py:166 +#: paperless/models.py:180 msgid "Adds additional user arguments for OCRMyPDF" msgstr "" -#: paperless/models.py:175 +#: paperless/models.py:189 msgid "Application title" msgstr "" -#: paperless/models.py:182 +#: paperless/models.py:196 msgid "Application logo" msgstr "" -#: paperless/models.py:197 +#: paperless/models.py:211 msgid "Enables barcode scanning" msgstr "" -#: paperless/models.py:203 +#: paperless/models.py:217 msgid "Enables barcode TIFF support" msgstr "" -#: paperless/models.py:209 +#: paperless/models.py:223 msgid "Sets the barcode string" msgstr "" -#: paperless/models.py:217 +#: paperless/models.py:231 msgid "Retains split pages" msgstr "" -#: paperless/models.py:223 +#: paperless/models.py:237 msgid "Enables ASN barcode" msgstr "" -#: paperless/models.py:229 +#: paperless/models.py:243 msgid "Sets the ASN barcode prefix" msgstr "" -#: paperless/models.py:237 +#: paperless/models.py:251 msgid "Sets the barcode upscale factor" msgstr "" -#: paperless/models.py:244 +#: paperless/models.py:258 msgid "Sets the barcode DPI" msgstr "" -#: paperless/models.py:251 +#: paperless/models.py:265 msgid "Sets the maximum pages for barcode" msgstr "" -#: paperless/models.py:258 +#: paperless/models.py:272 msgid "Enables tag barcode" msgstr "" -#: paperless/models.py:264 +#: paperless/models.py:278 msgid "Sets the tag barcode mapping" msgstr "" -#: paperless/models.py:269 +#: paperless/models.py:287 +msgid "Enables AI features" +msgstr "" + +#: paperless/models.py:293 +msgid "Sets the LLM embedding backend" +msgstr "" + +#: paperless/models.py:301 +msgid "Sets the LLM embedding model" +msgstr "" + +#: paperless/models.py:308 +msgid "Sets the LLM backend" +msgstr "" + +#: paperless/models.py:316 +msgid "Sets the LLM model" +msgstr "" + +#: paperless/models.py:323 +msgid "Sets the LLM API key" +msgstr "" + +#: paperless/models.py:330 +msgid "Sets the LLM endpoint, optional" +msgstr "" + +#: paperless/models.py:337 msgid "paperless application settings" msgstr "" -#: paperless/settings.py:768 +#: paperless/settings.py:800 msgid "English (US)" msgstr "" -#: paperless/settings.py:769 +#: paperless/settings.py:801 msgid "Arabic" msgstr "" -#: paperless/settings.py:770 +#: paperless/settings.py:802 msgid "Afrikaans" msgstr "" -#: paperless/settings.py:771 +#: paperless/settings.py:803 msgid "Belarusian" msgstr "" -#: paperless/settings.py:772 +#: paperless/settings.py:804 msgid "Bulgarian" msgstr "" -#: paperless/settings.py:773 +#: paperless/settings.py:805 msgid "Catalan" msgstr "" -#: paperless/settings.py:774 +#: paperless/settings.py:806 msgid "Czech" msgstr "" -#: paperless/settings.py:775 +#: paperless/settings.py:807 msgid "Danish" msgstr "" -#: paperless/settings.py:776 +#: paperless/settings.py:808 msgid "German" msgstr "" -#: paperless/settings.py:777 +#: paperless/settings.py:809 msgid "Greek" msgstr "" -#: paperless/settings.py:778 +#: paperless/settings.py:810 msgid "English (GB)" msgstr "" -#: paperless/settings.py:779 +#: paperless/settings.py:811 msgid "Spanish" msgstr "" -#: paperless/settings.py:780 +#: paperless/settings.py:812 msgid "Persian" msgstr "" -#: paperless/settings.py:781 +#: paperless/settings.py:813 msgid "Finnish" msgstr "" -#: paperless/settings.py:782 +#: paperless/settings.py:814 msgid "French" msgstr "" -#: paperless/settings.py:783 +#: paperless/settings.py:815 msgid "Hungarian" msgstr "" -#: paperless/settings.py:784 +#: paperless/settings.py:816 msgid "Indonesian" msgstr "" -#: paperless/settings.py:785 +#: paperless/settings.py:817 msgid "Italian" msgstr "" -#: paperless/settings.py:786 +#: paperless/settings.py:818 msgid "Japanese" msgstr "" -#: paperless/settings.py:787 +#: paperless/settings.py:819 msgid "Korean" msgstr "" -#: paperless/settings.py:788 +#: paperless/settings.py:820 msgid "Luxembourgish" msgstr "" -#: paperless/settings.py:789 +#: paperless/settings.py:821 msgid "Norwegian" msgstr "" -#: paperless/settings.py:790 +#: paperless/settings.py:822 msgid "Dutch" msgstr "" -#: paperless/settings.py:791 +#: paperless/settings.py:823 msgid "Polish" msgstr "" -#: paperless/settings.py:792 +#: paperless/settings.py:824 msgid "Portuguese (Brazil)" msgstr "" -#: paperless/settings.py:793 +#: paperless/settings.py:825 msgid "Portuguese" msgstr "" -#: paperless/settings.py:794 +#: paperless/settings.py:826 msgid "Romanian" msgstr "" -#: paperless/settings.py:795 +#: paperless/settings.py:827 msgid "Russian" msgstr "" -#: paperless/settings.py:796 +#: paperless/settings.py:828 msgid "Slovak" msgstr "" -#: paperless/settings.py:797 +#: paperless/settings.py:829 msgid "Slovenian" msgstr "" -#: paperless/settings.py:798 +#: paperless/settings.py:830 msgid "Serbian" msgstr "" -#: paperless/settings.py:799 +#: paperless/settings.py:831 msgid "Swedish" msgstr "" -#: paperless/settings.py:800 +#: paperless/settings.py:832 msgid "Turkish" msgstr "" -#: paperless/settings.py:801 +#: paperless/settings.py:833 msgid "Ukrainian" msgstr "" -#: paperless/settings.py:802 +#: paperless/settings.py:834 msgid "Vietnamese" msgstr "" -#: paperless/settings.py:803 +#: paperless/settings.py:835 msgid "Chinese Simplified" msgstr "" -#: paperless/settings.py:804 +#: paperless/settings.py:836 msgid "Chinese Traditional" msgstr "" -#: paperless/urls.py:370 +#: paperless/urls.py:376 msgid "Paperless-ngx administration" msgstr "" diff --git a/src/paperless/config.py b/src/paperless/config.py index fb3139d79..edebb232f 100644 --- a/src/paperless/config.py +++ b/src/paperless/config.py @@ -169,3 +169,37 @@ class GeneralConfig(BaseConfig): self.app_title = app_config.app_title or None self.app_logo = app_config.app_logo.url if app_config.app_logo else None + + +@dataclasses.dataclass +class AIConfig(BaseConfig): + """ + AI related settings that require global scope + """ + + ai_enabled: bool = dataclasses.field(init=False) + llm_embedding_backend: str = dataclasses.field(init=False) + llm_embedding_model: str = dataclasses.field(init=False) + llm_backend: str = dataclasses.field(init=False) + llm_model: str = dataclasses.field(init=False) + llm_api_key: str = dataclasses.field(init=False) + llm_endpoint: str = dataclasses.field(init=False) + + def __post_init__(self) -> None: + app_config = self._get_config_instance() + + self.ai_enabled = app_config.ai_enabled or settings.AI_ENABLED + self.llm_embedding_backend = ( + app_config.llm_embedding_backend or settings.LLM_EMBEDDING_BACKEND + ) + self.llm_embedding_model = ( + app_config.llm_embedding_model or settings.LLM_EMBEDDING_MODEL + ) + self.llm_backend = app_config.llm_backend or settings.LLM_BACKEND + self.llm_model = app_config.llm_model or settings.LLM_MODEL + self.llm_api_key = app_config.llm_api_key or settings.LLM_API_KEY + self.llm_endpoint = app_config.llm_endpoint or settings.LLM_ENDPOINT + + @property + def llm_index_enabled(self) -> bool: + return bool(self.ai_enabled and self.llm_embedding_backend) diff --git a/src/paperless/db.py b/src/paperless/db.py deleted file mode 100644 index 286ccb094..000000000 --- a/src/paperless/db.py +++ /dev/null @@ -1,17 +0,0 @@ -import gnupg -from django.conf import settings - - -class GnuPG: - """ - A handy singleton to use when handling encrypted files. - """ - - gpg = gnupg.GPG(gnupghome=settings.GNUPG_HOME) - - @classmethod - def decrypted(cls, file_handle, passphrase=None): - if not passphrase: - passphrase = settings.PASSPHRASE - - return cls.gpg.decrypt_file(file_handle, passphrase=passphrase).data diff --git a/src/paperless/migrations/0005_applicationconfiguration_ai_enabled_and_more.py b/src/paperless/migrations/0005_applicationconfiguration_ai_enabled_and_more.py new file mode 100644 index 000000000..f8a71ea6b --- /dev/null +++ b/src/paperless/migrations/0005_applicationconfiguration_ai_enabled_and_more.py @@ -0,0 +1,84 @@ +# Generated by Django 5.2.6 on 2025-09-30 17:43 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("paperless", "0004_applicationconfiguration_barcode_asn_prefix_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="applicationconfiguration", + name="ai_enabled", + field=models.BooleanField( + default=False, + null=True, + verbose_name="Enables AI features", + ), + ), + migrations.AddField( + model_name="applicationconfiguration", + name="llm_api_key", + field=models.CharField( + blank=True, + max_length=1024, + null=True, + verbose_name="Sets the LLM API key", + ), + ), + migrations.AddField( + model_name="applicationconfiguration", + name="llm_backend", + field=models.CharField( + blank=True, + choices=[("openai", "OpenAI"), ("ollama", "Ollama")], + max_length=128, + null=True, + verbose_name="Sets the LLM backend", + ), + ), + migrations.AddField( + model_name="applicationconfiguration", + name="llm_embedding_backend", + field=models.CharField( + blank=True, + choices=[("openai", "OpenAI"), ("huggingface", "Huggingface")], + max_length=128, + null=True, + verbose_name="Sets the LLM embedding backend", + ), + ), + migrations.AddField( + model_name="applicationconfiguration", + name="llm_embedding_model", + field=models.CharField( + blank=True, + max_length=128, + null=True, + verbose_name="Sets the LLM embedding model", + ), + ), + migrations.AddField( + model_name="applicationconfiguration", + name="llm_endpoint", + field=models.CharField( + blank=True, + max_length=256, + null=True, + verbose_name="Sets the LLM endpoint, optional", + ), + ), + migrations.AddField( + model_name="applicationconfiguration", + name="llm_model", + field=models.CharField( + blank=True, + max_length=128, + null=True, + verbose_name="Sets the LLM model", + ), + ), + ] diff --git a/src/paperless/models.py b/src/paperless/models.py index 1c44f1414..0f727972a 100644 --- a/src/paperless/models.py +++ b/src/paperless/models.py @@ -74,6 +74,20 @@ class ColorConvertChoices(models.TextChoices): CMYK = ("CMYK", _("CMYK")) +class LLMEmbeddingBackend(models.TextChoices): + OPENAI = ("openai", _("OpenAI")) + HUGGINGFACE = ("huggingface", _("Huggingface")) + + +class LLMBackend(models.TextChoices): + """ + Matches to --llm-backend + """ + + OPENAI = ("openai", _("OpenAI")) + OLLAMA = ("ollama", _("Ollama")) + + class ApplicationConfiguration(AbstractSingletonModel): """ Settings which are common across more than 1 parser @@ -265,6 +279,60 @@ class ApplicationConfiguration(AbstractSingletonModel): null=True, ) + """ + AI related settings + """ + + ai_enabled = models.BooleanField( + verbose_name=_("Enables AI features"), + null=True, + default=False, + ) + + llm_embedding_backend = models.CharField( + verbose_name=_("Sets the LLM embedding backend"), + blank=True, + null=True, + max_length=128, + choices=LLMEmbeddingBackend.choices, + ) + + llm_embedding_model = models.CharField( + verbose_name=_("Sets the LLM embedding model"), + blank=True, + null=True, + max_length=128, + ) + + llm_backend = models.CharField( + verbose_name=_("Sets the LLM backend"), + blank=True, + null=True, + max_length=128, + choices=LLMBackend.choices, + ) + + llm_model = models.CharField( + verbose_name=_("Sets the LLM model"), + blank=True, + null=True, + max_length=128, + ) + + llm_api_key = models.CharField( + verbose_name=_("Sets the LLM API key"), + blank=True, + null=True, + max_length=1024, + ) + + llm_endpoint = models.CharField( + verbose_name=_("Sets the LLM endpoint, optional"), + blank=True, + null=True, + max_length=256, + ) + class Meta: verbose_name = _("paperless application settings") diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index 256f40680..97a2bee7e 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -206,6 +206,10 @@ class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer): class ApplicationConfigurationSerializer(serializers.ModelSerializer): user_args = serializers.JSONField(binary=True, allow_null=True) barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True) + llm_api_key = ObfuscatedPasswordField( + required=False, + allow_null=True, + ) def run_validation(self, data): # Empty strings treated as None to avoid unexpected behavior @@ -215,6 +219,11 @@ class ApplicationConfigurationSerializer(serializers.ModelSerializer): data["barcode_tag_mapping"] = None if "language" in data and data["language"] == "": data["language"] = None + if "llm_api_key" in data and data["llm_api_key"] is not None: + if data["llm_api_key"] == "": + data["llm_api_key"] = None + elif len(data["llm_api_key"].replace("*", "")) == 0: + del data["llm_api_key"] return super().run_validation(data) def update(self, instance, validated_data): diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 1cd357f86..30ee213d1 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -12,6 +12,7 @@ from typing import Final from urllib.parse import urlparse from celery.schedules import crontab +from compression_middleware.middleware import CompressionMiddleware from dateparser.languages.loader import LocaleDataLoader from django.utils.translation import gettext_lazy as _ from dotenv import load_dotenv @@ -229,6 +230,17 @@ def _parse_beat_schedule() -> dict: "expires": 59.0 * 60.0, }, }, + { + "name": "Rebuild LLM index", + "env_key": "PAPERLESS_LLM_INDEX_TASK_CRON", + # Default daily at 02:10 + "env_default": "10 2 * * *", + "task": "documents.tasks.llmindex_index", + "options": { + # 1 hour before default schedule sends again + "expires": 23.0 * 60.0 * 60.0, + }, + }, ] for task in tasks: # Either get the environment setting or use the default @@ -287,6 +299,7 @@ MODEL_FILE = __get_path( "PAPERLESS_MODEL_FILE", DATA_DIR / "classification_model.pickle", ) +LLM_INDEX_DIR = DATA_DIR / "llm_index" LOGGING_DIR = __get_path("PAPERLESS_LOGGING_DIR", DATA_DIR / "log") @@ -380,6 +393,19 @@ MIDDLEWARE = [ if __get_boolean("PAPERLESS_ENABLE_COMPRESSION", "yes"): # pragma: no cover MIDDLEWARE.insert(0, "compression_middleware.middleware.CompressionMiddleware") +# Workaround to not compress streaming responses (e.g. chat). +# See https://github.com/friedelwolff/django-compression-middleware/pull/7 +original_process_response = CompressionMiddleware.process_response + + +def patched_process_response(self, request, response): + if getattr(request, "compress_exempt", False): + return response + return original_process_response(self, request, response) + + +CompressionMiddleware.process_response = patched_process_response + ROOT_URLCONF = "paperless.urls" @@ -585,6 +611,10 @@ X_FRAME_OPTIONS = "SAMEORIGIN" # The next 3 settings can also be set using just PAPERLESS_URL CSRF_TRUSTED_ORIGINS = __get_list("PAPERLESS_CSRF_TRUSTED_ORIGINS") +if DEBUG: + # Allow access from the angular development server during debugging + CSRF_TRUSTED_ORIGINS.append("http://localhost:4200") + # We allow CORS from localhost:8000 CORS_ALLOWED_ORIGINS = __get_list( "PAPERLESS_CORS_ALLOWED_HOSTS", @@ -595,6 +625,8 @@ if DEBUG: # Allow access from the angular development server during debugging CORS_ALLOWED_ORIGINS.append("http://localhost:4200") +CORS_ALLOW_CREDENTIALS = True + CORS_EXPOSE_HEADERS = [ "Content-Disposition", ] @@ -868,6 +900,7 @@ LOGGING = { "loggers": { "paperless": {"handlers": ["file_paperless"], "level": "DEBUG"}, "paperless_mail": {"handlers": ["file_mail"], "level": "DEBUG"}, + "paperless_ai": {"handlers": ["file_paperless"], "level": "DEBUG"}, "ocrmypdf": {"handlers": ["file_paperless"], "level": "INFO"}, "celery": {"handlers": ["file_celery"], "level": "DEBUG"}, "kombu": {"handlers": ["file_celery"], "level": "DEBUG"}, @@ -1011,29 +1044,30 @@ IGNORABLE_FILES: Final[list[str]] = [ "Thumbs.db", ] -CONSUMER_POLLING = int(os.getenv("PAPERLESS_CONSUMER_POLLING", 0)) +CONSUMER_POLLING_INTERVAL = float(os.getenv("PAPERLESS_CONSUMER_POLLING_INTERVAL", 0)) -CONSUMER_POLLING_DELAY = int(os.getenv("PAPERLESS_CONSUMER_POLLING_DELAY", 5)) - -CONSUMER_POLLING_RETRY_COUNT = int( - os.getenv("PAPERLESS_CONSUMER_POLLING_RETRY_COUNT", 5), -) - -CONSUMER_INOTIFY_DELAY: Final[float] = __get_float( - "PAPERLESS_CONSUMER_INOTIFY_DELAY", - 0.5, -) +CONSUMER_STABILITY_DELAY = float(os.getenv("PAPERLESS_CONSUMER_STABILITY_DELAY", 5)) CONSUMER_DELETE_DUPLICATES = __get_boolean("PAPERLESS_CONSUMER_DELETE_DUPLICATES") CONSUMER_RECURSIVE = __get_boolean("PAPERLESS_CONSUMER_RECURSIVE") -# Ignore glob patterns, relative to PAPERLESS_CONSUMPTION_DIR +# Ignore regex patterns, matched against filename only CONSUMER_IGNORE_PATTERNS = list( json.loads( os.getenv( "PAPERLESS_CONSUMER_IGNORE_PATTERNS", - json.dumps(IGNORABLE_FILES), + json.dumps([]), + ), + ), +) + +# Directories to always ignore. These are matched by directory name, not full path +CONSUMER_IGNORE_DIRS = list( + json.loads( + os.getenv( + "PAPERLESS_CONSUMER_IGNORE_DIRS", + json.dumps([]), ), ), ) @@ -1169,19 +1203,6 @@ EMAIL_PARSE_DEFAULT_LAYOUT = __get_int( 1, # MailRule.PdfLayout.TEXT_HTML but that can't be imported here ) -# Pre-2.x versions of Paperless stored your documents locally with GPG -# encryption, but that is no longer the default. This behaviour is still -# available, but it must be explicitly enabled by setting -# `PAPERLESS_PASSPHRASE` in your environment or config file. The default is to -# store these files unencrypted. -# -# Translation: -# * If you're a new user, you can safely ignore this setting. -# * If you're upgrading from 1.x, this must be set, OR you can run -# `./manage.py change_storage_type gpg unencrypted` to decrypt your files, -# after which you can unset this value. -PASSPHRASE = os.getenv("PAPERLESS_PASSPHRASE") - # Trigger a script after every successful document consumption? PRE_CONSUME_SCRIPT = os.getenv("PAPERLESS_PRE_CONSUME_SCRIPT") POST_CONSUME_SCRIPT = os.getenv("PAPERLESS_POST_CONSUME_SCRIPT") @@ -1404,3 +1425,16 @@ WEBHOOKS_ALLOW_INTERNAL_REQUESTS = __get_boolean( REMOTE_OCR_ENGINE = os.getenv("PAPERLESS_REMOTE_OCR_ENGINE") REMOTE_OCR_API_KEY = os.getenv("PAPERLESS_REMOTE_OCR_API_KEY") REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT") + +################################################################################ +# AI Settings # +################################################################################ +AI_ENABLED = __get_boolean("PAPERLESS_AI_ENABLED", "NO") +LLM_EMBEDDING_BACKEND = os.getenv( + "PAPERLESS_AI_LLM_EMBEDDING_BACKEND", +) # "huggingface" or "openai" +LLM_EMBEDDING_MODEL = os.getenv("PAPERLESS_AI_LLM_EMBEDDING_MODEL") +LLM_BACKEND = os.getenv("PAPERLESS_AI_LLM_BACKEND") # "ollama" or "openai" +LLM_MODEL = os.getenv("PAPERLESS_AI_LLM_MODEL") +LLM_API_KEY = os.getenv("PAPERLESS_AI_LLM_API_KEY") +LLM_ENDPOINT = os.getenv("PAPERLESS_AI_LLM_ENDPOINT") diff --git a/src/paperless/tests/test_settings.py b/src/paperless/tests/test_settings.py index 10995291e..f09ddcefa 100644 --- a/src/paperless/tests/test_settings.py +++ b/src/paperless/tests/test_settings.py @@ -160,6 +160,7 @@ class TestCeleryScheduleParsing(TestCase): SANITY_EXPIRE_TIME = ((7.0 * 24.0) - 1.0) * 60.0 * 60.0 EMPTY_TRASH_EXPIRE_TIME = 23.0 * 60.0 * 60.0 RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME = 59.0 * 60.0 + LLM_INDEX_EXPIRE_TIME = 23.0 * 60.0 * 60.0 def test_schedule_configuration_default(self): """ @@ -204,6 +205,13 @@ class TestCeleryScheduleParsing(TestCase): "schedule": crontab(minute="5", hour="*/1"), "options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME}, }, + "Rebuild LLM index": { + "task": "documents.tasks.llmindex_index", + "schedule": crontab(minute=10, hour=2), + "options": { + "expires": self.LLM_INDEX_EXPIRE_TIME, + }, + }, }, schedule, ) @@ -256,6 +264,13 @@ class TestCeleryScheduleParsing(TestCase): "schedule": crontab(minute="5", hour="*/1"), "options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME}, }, + "Rebuild LLM index": { + "task": "documents.tasks.llmindex_index", + "schedule": crontab(minute=10, hour=2), + "options": { + "expires": self.LLM_INDEX_EXPIRE_TIME, + }, + }, }, schedule, ) @@ -300,6 +315,13 @@ class TestCeleryScheduleParsing(TestCase): "schedule": crontab(minute="5", hour="*/1"), "options": {"expires": self.RUN_SCHEDULED_WORKFLOWS_EXPIRE_TIME}, }, + "Rebuild LLM index": { + "task": "documents.tasks.llmindex_index", + "schedule": crontab(minute=10, hour=2), + "options": { + "expires": self.LLM_INDEX_EXPIRE_TIME, + }, + }, }, schedule, ) @@ -322,6 +344,7 @@ class TestCeleryScheduleParsing(TestCase): "PAPERLESS_INDEX_TASK_CRON": "disable", "PAPERLESS_EMPTY_TRASH_TASK_CRON": "disable", "PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON": "disable", + "PAPERLESS_LLM_INDEX_TASK_CRON": "disable", }, ): schedule = _parse_beat_schedule() diff --git a/src/paperless/urls.py b/src/paperless/urls.py index e24d1a459..179af14e0 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -18,6 +18,7 @@ from rest_framework.routers import DefaultRouter from documents.views import BulkDownloadView from documents.views import BulkEditObjectsView from documents.views import BulkEditView +from documents.views import ChatStreamingView from documents.views import CorrespondentViewSet from documents.views import CustomFieldViewSet from documents.views import DocumentTypeViewSet @@ -139,6 +140,11 @@ urlpatterns = [ SelectionDataView.as_view(), name="selection_data", ), + re_path( + "^chat/", + ChatStreamingView.as_view(), + name="chat_streaming_view", + ), ], ), ), diff --git a/src/paperless/version.py b/src/paperless/version.py index c0c6439d4..aeeee68e0 100644 --- a/src/paperless/version.py +++ b/src/paperless/version.py @@ -1,6 +1,6 @@ from typing import Final -__version__: Final[tuple[int, int, int]] = (2, 20, 3) +__version__: Final[tuple[int, int, int]] = (2, 20, 5) # Version string like X.Y.Z __full_version_str__: Final[str] = ".".join(map(str, __version__)) # Version string like X.Y diff --git a/src/paperless/views.py b/src/paperless/views.py index e79c0e668..f9aa68297 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -35,6 +35,7 @@ from rest_framework.viewsets import ModelViewSet from documents.index import DelayedQuery from documents.permissions import PaperlessObjectPermissions +from documents.tasks import llmindex_index from paperless.filters import GroupFilterSet from paperless.filters import UserFilterSet from paperless.models import ApplicationConfiguration @@ -43,6 +44,7 @@ from paperless.serialisers import GroupSerializer from paperless.serialisers import PaperlessAuthTokenSerializer from paperless.serialisers import ProfileSerializer from paperless.serialisers import UserSerializer +from paperless_ai.indexing import vector_store_file_exists class PaperlessObtainAuthTokenView(ObtainAuthToken): @@ -358,6 +360,30 @@ class ApplicationConfigurationViewSet(ModelViewSet): def create(self, request, *args, **kwargs): return Response(status=405) # Not Allowed + def perform_update(self, serializer): + old_instance = ApplicationConfiguration.objects.all().first() + old_ai_index_enabled = ( + old_instance.ai_enabled and old_instance.llm_embedding_backend + ) + + new_instance: ApplicationConfiguration = serializer.save() + new_ai_index_enabled = ( + new_instance.ai_enabled and new_instance.llm_embedding_backend + ) + + if ( + not old_ai_index_enabled + and new_ai_index_enabled + and not vector_store_file_exists() + ): + # AI index was just enabled and vector store file does not exist + llmindex_index.delay( + progress_bar_disable=True, + rebuild=True, + scheduled=False, + auto=True, + ) + @extend_schema_view( post=extend_schema( diff --git a/src/paperless_ai/__init__.py b/src/paperless_ai/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/paperless_ai/ai_classifier.py b/src/paperless_ai/ai_classifier.py new file mode 100644 index 000000000..e60ca37ff --- /dev/null +++ b/src/paperless_ai/ai_classifier.py @@ -0,0 +1,102 @@ +import logging + +from django.contrib.auth.models import User + +from documents.models import Document +from documents.permissions import get_objects_for_user_owner_aware +from paperless.config import AIConfig +from paperless_ai.client import AIClient +from paperless_ai.indexing import query_similar_documents +from paperless_ai.indexing import truncate_content + +logger = logging.getLogger("paperless_ai.rag_classifier") + + +def build_prompt_without_rag(document: Document) -> str: + filename = document.filename or "" + content = truncate_content(document.content[:4000] or "") + + return f""" + You are a document classification assistant. + + Analyze the following document and extract the following information: + - A short descriptive title + - Tags that reflect the content + - Names of people or organizations mentioned + - The type or category of the document + - Suggested folder paths for storing the document + - Up to 3 relevant dates in YYYY-MM-DD format + + Filename: + {filename} + + Content: + {content} + """.strip() + + +def build_prompt_with_rag(document: Document, user: User | None = None) -> str: + base_prompt = build_prompt_without_rag(document) + context = truncate_content(get_context_for_document(document, user)) + + return f"""{base_prompt} + + Additional context from similar documents: + {context} + """.strip() + + +def get_context_for_document( + doc: Document, + user: User | None = None, + max_docs: int = 5, +) -> str: + visible_documents = ( + get_objects_for_user_owner_aware( + user, + "view_document", + Document, + ) + if user + else None + ) + similar_docs = query_similar_documents( + document=doc, + document_ids=[document.pk for document in visible_documents] + if visible_documents + else None, + )[:max_docs] + context_blocks = [] + for similar in similar_docs: + text = similar.content[:1000] or "" + title = similar.title or similar.filename or "Untitled" + context_blocks.append(f"TITLE: {title}\n{text}") + return "\n\n".join(context_blocks) + + +def parse_ai_response(raw: dict) -> dict: + return { + "title": raw.get("title", ""), + "tags": raw.get("tags", []), + "correspondents": raw.get("correspondents", []), + "document_types": raw.get("document_types", []), + "storage_paths": raw.get("storage_paths", []), + "dates": raw.get("dates", []), + } + + +def get_ai_document_classification( + document: Document, + user: User | None = None, +) -> dict: + ai_config = AIConfig() + + prompt = ( + build_prompt_with_rag(document, user) + if ai_config.llm_embedding_backend + else build_prompt_without_rag(document) + ) + + client = AIClient() + result = client.run_llm_query(prompt) + return parse_ai_response(result) diff --git a/src/paperless_ai/base_model.py b/src/paperless_ai/base_model.py new file mode 100644 index 000000000..2924f2c8c --- /dev/null +++ b/src/paperless_ai/base_model.py @@ -0,0 +1,10 @@ +from llama_index.core.bridge.pydantic import BaseModel + + +class DocumentClassifierSchema(BaseModel): + title: str + tags: list[str] + correspondents: list[str] + document_types: list[str] + storage_paths: list[str] + dates: list[str] diff --git a/src/paperless_ai/chat.py b/src/paperless_ai/chat.py new file mode 100644 index 000000000..f662a7bee --- /dev/null +++ b/src/paperless_ai/chat.py @@ -0,0 +1,105 @@ +import logging +import sys + +from llama_index.core import VectorStoreIndex +from llama_index.core.prompts import PromptTemplate +from llama_index.core.query_engine import RetrieverQueryEngine + +from documents.models import Document +from paperless_ai.client import AIClient +from paperless_ai.indexing import load_or_build_index + +logger = logging.getLogger("paperless_ai.chat") + +MAX_SINGLE_DOC_CONTEXT_CHARS = 15000 +SINGLE_DOC_SNIPPET_CHARS = 800 + +CHAT_PROMPT_TMPL = PromptTemplate( + template="""Context information is below. + --------------------- + {context_str} + --------------------- + Given the context information and not prior knowledge, answer the query. + Query: {query_str} + Answer:""", +) + + +def stream_chat_with_documents(query_str: str, documents: list[Document]): + client = AIClient() + index = load_or_build_index() + + doc_ids = [str(doc.pk) for doc in documents] + + # Filter only the node(s) that match the document IDs + nodes = [ + node + for node in index.docstore.docs.values() + if node.metadata.get("document_id") in doc_ids + ] + + if len(nodes) == 0: + logger.warning("No nodes found for the given documents.") + yield "Sorry, I couldn't find any content to answer your question." + return + + local_index = VectorStoreIndex(nodes=nodes) + retriever = local_index.as_retriever( + similarity_top_k=3 if len(documents) == 1 else 5, + ) + + if len(documents) == 1: + # Just one doc — provide full content + doc = documents[0] + # TODO: include document metadata in the context + content = doc.content or "" + context_body = content + + if len(content) > MAX_SINGLE_DOC_CONTEXT_CHARS: + logger.info( + "Truncating single-document context from %s to %s characters", + len(content), + MAX_SINGLE_DOC_CONTEXT_CHARS, + ) + context_body = content[:MAX_SINGLE_DOC_CONTEXT_CHARS] + + top_nodes = retriever.retrieve(query_str) + if len(top_nodes) > 0: + snippets = "\n\n".join( + f"TITLE: {node.metadata.get('title')}\n{node.text[:SINGLE_DOC_SNIPPET_CHARS]}" + for node in top_nodes + ) + context_body = f"{context_body}\n\nTOP MATCHES:\n{snippets}" + + context = f"TITLE: {doc.title or doc.filename}\n{context_body}" + else: + top_nodes = retriever.retrieve(query_str) + + if len(top_nodes) == 0: + logger.warning("Retriever returned no nodes for the given documents.") + yield "Sorry, I couldn't find any content to answer your question." + return + + context = "\n\n".join( + f"TITLE: {node.metadata.get('title')}\n{node.text[:SINGLE_DOC_SNIPPET_CHARS]}" + for node in top_nodes + ) + + prompt = CHAT_PROMPT_TMPL.partial_format( + context_str=context, + query_str=query_str, + ).format(llm=client.llm) + + query_engine = RetrieverQueryEngine.from_args( + retriever=retriever, + llm=client.llm, + streaming=True, + ) + + logger.debug("Document chat prompt: %s", prompt) + + response_stream = query_engine.query(prompt) + + for chunk in response_stream.response_gen: + yield chunk + sys.stdout.flush() diff --git a/src/paperless_ai/client.py b/src/paperless_ai/client.py new file mode 100644 index 000000000..1f52c56c7 --- /dev/null +++ b/src/paperless_ai/client.py @@ -0,0 +1,69 @@ +import logging + +from llama_index.core.llms import ChatMessage +from llama_index.core.program.function_program import get_function_tool +from llama_index.llms.ollama import Ollama +from llama_index.llms.openai import OpenAI + +from paperless.config import AIConfig +from paperless_ai.base_model import DocumentClassifierSchema + +logger = logging.getLogger("paperless_ai.client") + + +class AIClient: + """ + A client for interacting with an LLM backend. + """ + + def __init__(self): + self.settings = AIConfig() + self.llm = self.get_llm() + + def get_llm(self) -> Ollama | OpenAI: + if self.settings.llm_backend == "ollama": + return Ollama( + model=self.settings.llm_model or "llama3.1", + base_url=self.settings.llm_endpoint or "http://localhost:11434", + request_timeout=120, + ) + elif self.settings.llm_backend == "openai": + return OpenAI( + model=self.settings.llm_model or "gpt-3.5-turbo", + api_base=self.settings.llm_endpoint or None, + api_key=self.settings.llm_api_key, + ) + else: + raise ValueError(f"Unsupported LLM backend: {self.settings.llm_backend}") + + def run_llm_query(self, prompt: str) -> str: + logger.debug( + "Running LLM query against %s with model %s", + self.settings.llm_backend, + self.settings.llm_model, + ) + + user_msg = ChatMessage(role="user", content=prompt) + tool = get_function_tool(DocumentClassifierSchema) + result = self.llm.chat_with_tools( + tools=[tool], + user_msg=user_msg, + chat_history=[], + ) + tool_calls = self.llm.get_tool_calls_from_response( + result, + error_on_no_tool_call=True, + ) + logger.debug("LLM query result: %s", tool_calls) + parsed = DocumentClassifierSchema(**tool_calls[0].tool_kwargs) + return parsed.model_dump() + + def run_chat(self, messages: list[ChatMessage]) -> str: + logger.debug( + "Running chat query against %s with model %s", + self.settings.llm_backend, + self.settings.llm_model, + ) + result = self.llm.chat(messages) + logger.debug("Chat result: %s", result) + return result diff --git a/src/paperless_ai/embedding.py b/src/paperless_ai/embedding.py new file mode 100644 index 000000000..993c9ae30 --- /dev/null +++ b/src/paperless_ai/embedding.py @@ -0,0 +1,92 @@ +import json +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +from django.conf import settings +from llama_index.core.base.embeddings.base import BaseEmbedding +from llama_index.embeddings.huggingface import HuggingFaceEmbedding +from llama_index.embeddings.openai import OpenAIEmbedding + +from documents.models import Document +from documents.models import Note +from paperless.config import AIConfig +from paperless.models import LLMEmbeddingBackend + + +def get_embedding_model() -> BaseEmbedding: + config = AIConfig() + + match config.llm_embedding_backend: + case LLMEmbeddingBackend.OPENAI: + return OpenAIEmbedding( + model=config.llm_embedding_model or "text-embedding-3-small", + api_key=config.llm_api_key, + ) + case LLMEmbeddingBackend.HUGGINGFACE: + return HuggingFaceEmbedding( + model_name=config.llm_embedding_model + or "sentence-transformers/all-MiniLM-L6-v2", + ) + case _: + raise ValueError( + f"Unsupported embedding backend: {config.llm_embedding_backend}", + ) + + +def get_embedding_dim() -> int: + """ + Loads embedding dimension from meta.json if available, otherwise infers it + from a dummy embedding and stores it for future use. + """ + config = AIConfig() + model = config.llm_embedding_model or ( + "text-embedding-3-small" + if config.llm_embedding_backend == "openai" + else "sentence-transformers/all-MiniLM-L6-v2" + ) + + meta_path: Path = settings.LLM_INDEX_DIR / "meta.json" + if meta_path.exists(): + with meta_path.open() as f: + meta = json.load(f) + if meta.get("embedding_model") != model: + raise RuntimeError( + f"Embedding model changed from {meta.get('embedding_model')} to {model}. " + "You must rebuild the index.", + ) + return meta["dim"] + + embedding_model = get_embedding_model() + test_embed = embedding_model.get_text_embedding("test") + dim = len(test_embed) + + with meta_path.open("w") as f: + json.dump({"embedding_model": model, "dim": dim}, f) + + return dim + + +def build_llm_index_text(doc: Document) -> str: + lines = [ + f"Title: {doc.title}", + f"Filename: {doc.filename}", + f"Created: {doc.created}", + f"Added: {doc.added}", + f"Modified: {doc.modified}", + f"Tags: {', '.join(tag.name for tag in doc.tags.all())}", + f"Document Type: {doc.document_type.name if doc.document_type else ''}", + f"Correspondent: {doc.correspondent.name if doc.correspondent else ''}", + f"Storage Path: {doc.storage_path.name if doc.storage_path else ''}", + f"Archive Serial Number: {doc.archive_serial_number or ''}", + f"Notes: {','.join([str(c.note) for c in Note.objects.filter(document=doc)])}", + ] + + for instance in doc.custom_fields.all(): + lines.append(f"Custom Field - {instance.field.name}: {instance}") + + lines.append("\nContent:\n") + lines.append(doc.content or "") + + return "\n".join(lines) diff --git a/src/paperless_ai/indexing.py b/src/paperless_ai/indexing.py new file mode 100644 index 000000000..03c8aa9be --- /dev/null +++ b/src/paperless_ai/indexing.py @@ -0,0 +1,283 @@ +import logging +import shutil +from pathlib import Path + +import faiss +import llama_index.core.settings as llama_settings +import tqdm +from django.conf import settings +from llama_index.core import Document as LlamaDocument +from llama_index.core import StorageContext +from llama_index.core import VectorStoreIndex +from llama_index.core import load_index_from_storage +from llama_index.core.indices.prompt_helper import PromptHelper +from llama_index.core.node_parser import SimpleNodeParser +from llama_index.core.prompts import PromptTemplate +from llama_index.core.retrievers import VectorIndexRetriever +from llama_index.core.schema import BaseNode +from llama_index.core.storage.docstore import SimpleDocumentStore +from llama_index.core.storage.index_store import SimpleIndexStore +from llama_index.core.text_splitter import TokenTextSplitter +from llama_index.vector_stores.faiss import FaissVectorStore + +from documents.models import Document +from paperless_ai.embedding import build_llm_index_text +from paperless_ai.embedding import get_embedding_dim +from paperless_ai.embedding import get_embedding_model + +logger = logging.getLogger("paperless_ai.indexing") + + +def get_or_create_storage_context(*, rebuild=False): + """ + Loads or creates the StorageContext (vector store, docstore, index store). + If rebuild=True, deletes and recreates everything. + """ + if rebuild: + shutil.rmtree(settings.LLM_INDEX_DIR, ignore_errors=True) + settings.LLM_INDEX_DIR.mkdir(parents=True, exist_ok=True) + + if rebuild or not settings.LLM_INDEX_DIR.exists(): + embedding_dim = get_embedding_dim() + faiss_index = faiss.IndexFlatL2(embedding_dim) + vector_store = FaissVectorStore(faiss_index=faiss_index) + docstore = SimpleDocumentStore() + index_store = SimpleIndexStore() + else: + vector_store = FaissVectorStore.from_persist_dir(settings.LLM_INDEX_DIR) + docstore = SimpleDocumentStore.from_persist_dir(settings.LLM_INDEX_DIR) + index_store = SimpleIndexStore.from_persist_dir(settings.LLM_INDEX_DIR) + + return StorageContext.from_defaults( + docstore=docstore, + index_store=index_store, + vector_store=vector_store, + persist_dir=settings.LLM_INDEX_DIR, + ) + + +def build_document_node(document: Document) -> list[BaseNode]: + """ + Given a Document, returns parsed Nodes ready for indexing. + """ + text = build_llm_index_text(document) + metadata = { + "document_id": str(document.id), + "title": document.title, + "tags": [t.name for t in document.tags.all()], + "correspondent": document.correspondent.name + if document.correspondent + else None, + "document_type": document.document_type.name + if document.document_type + else None, + "created": document.created.isoformat() if document.created else None, + "added": document.added.isoformat() if document.added else None, + "modified": document.modified.isoformat(), + } + doc = LlamaDocument(text=text, metadata=metadata) + parser = SimpleNodeParser() + return parser.get_nodes_from_documents([doc]) + + +def load_or_build_index(nodes=None): + """ + Load an existing VectorStoreIndex if present, + or build a new one using provided nodes if storage is empty. + """ + embed_model = get_embedding_model() + llama_settings.Settings.embed_model = embed_model + storage_context = get_or_create_storage_context() + try: + return load_index_from_storage(storage_context=storage_context) + except ValueError as e: + logger.warning("Failed to load index from storage: %s", e) + if not nodes: + logger.info("No nodes provided for index creation.") + raise + return VectorStoreIndex( + nodes=nodes, + storage_context=storage_context, + embed_model=embed_model, + ) + + +def remove_document_docstore_nodes(document: Document, index: VectorStoreIndex): + """ + Removes existing documents from docstore for a given document from the index. + This is necessary because FAISS IndexFlatL2 is append-only. + """ + all_node_ids = list(index.docstore.docs.keys()) + existing_nodes = [ + node.node_id + for node in index.docstore.get_nodes(all_node_ids) + if node.metadata.get("document_id") == str(document.id) + ] + for node_id in existing_nodes: + # Delete from docstore, FAISS IndexFlatL2 are append-only + index.docstore.delete_document(node_id) + + +def vector_store_file_exists(): + """ + Check if the vector store file exists in the LLM index directory. + """ + return Path(settings.LLM_INDEX_DIR / "default__vector_store.json").exists() + + +def update_llm_index(*, progress_bar_disable=False, rebuild=False) -> str: + """ + Rebuild or update the LLM index. + """ + nodes = [] + + documents = Document.objects.all() + if not documents.exists(): + msg = "No documents found to index." + logger.warning(msg) + return msg + + if rebuild or not vector_store_file_exists(): + # remove meta.json to force re-detection of embedding dim + (settings.LLM_INDEX_DIR / "meta.json").unlink(missing_ok=True) + # Rebuild index from scratch + logger.info("Rebuilding LLM index.") + embed_model = get_embedding_model() + llama_settings.Settings.embed_model = embed_model + storage_context = get_or_create_storage_context(rebuild=True) + for document in tqdm.tqdm(documents, disable=progress_bar_disable): + document_nodes = build_document_node(document) + nodes.extend(document_nodes) + + index = VectorStoreIndex( + nodes=nodes, + storage_context=storage_context, + embed_model=embed_model, + show_progress=not progress_bar_disable, + ) + msg = "LLM index rebuilt successfully." + else: + # Update existing index + index = load_or_build_index() + all_node_ids = list(index.docstore.docs.keys()) + existing_nodes = { + node.metadata.get("document_id"): node + for node in index.docstore.get_nodes(all_node_ids) + } + + for document in tqdm.tqdm(documents, disable=progress_bar_disable): + doc_id = str(document.id) + document_modified = document.modified.isoformat() + + if doc_id in existing_nodes: + node = existing_nodes[doc_id] + node_modified = node.metadata.get("modified") + + if node_modified == document_modified: + continue + + # Again, delete from docstore, FAISS IndexFlatL2 are append-only + index.docstore.delete_document(node.node_id) + nodes.extend(build_document_node(document)) + else: + # New document, add it + nodes.extend(build_document_node(document)) + + if nodes: + msg = "LLM index updated successfully." + logger.info( + "Updating %d nodes in LLM index.", + len(nodes), + ) + index.insert_nodes(nodes) + else: + msg = "No changes detected in LLM index." + logger.info(msg) + + index.storage_context.persist(persist_dir=settings.LLM_INDEX_DIR) + return msg + + +def llm_index_add_or_update_document(document: Document): + """ + Adds or updates a document in the LLM index. + If the document already exists, it will be replaced. + """ + new_nodes = build_document_node(document) + + index = load_or_build_index(nodes=new_nodes) + + remove_document_docstore_nodes(document, index) + + index.insert_nodes(new_nodes) + + index.storage_context.persist(persist_dir=settings.LLM_INDEX_DIR) + + +def llm_index_remove_document(document: Document): + """ + Removes a document from the LLM index. + """ + index = load_or_build_index() + + remove_document_docstore_nodes(document, index) + + index.storage_context.persist(persist_dir=settings.LLM_INDEX_DIR) + + +def truncate_content(content: str) -> str: + prompt_helper = PromptHelper( + context_window=8192, + num_output=512, + chunk_overlap_ratio=0.1, + chunk_size_limit=None, + ) + splitter = TokenTextSplitter(separator=" ", chunk_size=512, chunk_overlap=50) + content_chunks = splitter.split_text(content) + truncated_chunks = prompt_helper.truncate( + prompt=PromptTemplate(template="{content}"), + text_chunks=content_chunks, + padding=5, + ) + return " ".join(truncated_chunks) + + +def query_similar_documents( + document: Document, + top_k: int = 5, + document_ids: list[int] | None = None, +) -> list[Document]: + """ + Runs a similarity query and returns top-k similar Document objects. + """ + index = load_or_build_index() + + # constrain only the node(s) that match the document IDs, if given + doc_node_ids = ( + [ + node.node_id + for node in index.docstore.docs.values() + if node.metadata.get("document_id") in document_ids + ] + if document_ids + else None + ) + + retriever = VectorIndexRetriever( + index=index, + similarity_top_k=top_k, + doc_ids=doc_node_ids, + ) + + query_text = truncate_content( + (document.title or "") + "\n" + (document.content or ""), + ) + results = retriever.retrieve(query_text) + + document_ids = [ + int(node.metadata["document_id"]) + for node in results + if "document_id" in node.metadata + ] + + return list(Document.objects.filter(pk__in=document_ids)) diff --git a/src/paperless_ai/matching.py b/src/paperless_ai/matching.py new file mode 100644 index 000000000..f1dfc62db --- /dev/null +++ b/src/paperless_ai/matching.py @@ -0,0 +1,102 @@ +import difflib +import logging +import re + +from django.contrib.auth.models import User + +from documents.models import Correspondent +from documents.models import DocumentType +from documents.models import StoragePath +from documents.models import Tag +from documents.permissions import get_objects_for_user_owner_aware + +MATCH_THRESHOLD = 0.8 + +logger = logging.getLogger("paperless_ai.matching") + + +def match_tags_by_name(names: list[str], user: User) -> list[Tag]: + queryset = get_objects_for_user_owner_aware( + user, + ["view_tag"], + Tag, + ) + return _match_names_to_queryset(names, queryset, "name") + + +def match_correspondents_by_name(names: list[str], user: User) -> list[Correspondent]: + queryset = get_objects_for_user_owner_aware( + user, + ["view_correspondent"], + Correspondent, + ) + return _match_names_to_queryset(names, queryset, "name") + + +def match_document_types_by_name(names: list[str], user: User) -> list[DocumentType]: + queryset = get_objects_for_user_owner_aware( + user, + ["view_documenttype"], + DocumentType, + ) + return _match_names_to_queryset(names, queryset, "name") + + +def match_storage_paths_by_name(names: list[str], user: User) -> list[StoragePath]: + queryset = get_objects_for_user_owner_aware( + user, + ["view_storagepath"], + StoragePath, + ) + return _match_names_to_queryset(names, queryset, "name") + + +def _normalize(s: str) -> str: + s = s.lower() + s = re.sub(r"[^\w\s]", "", s) # remove punctuation + s = s.strip() + return s + + +def _match_names_to_queryset(names: list[str], queryset, attr: str): + results = [] + objects = list(queryset) + object_names = [_normalize(getattr(obj, attr)) for obj in objects] + + for name in names: + if not name: + continue + target = _normalize(name) + + # First try exact match + if target in object_names: + index = object_names.index(target) + matched = objects.pop(index) + object_names.pop(index) # keep object list aligned after removal + results.append(matched) + continue + + # Fuzzy match fallback + matches = difflib.get_close_matches( + target, + object_names, + n=1, + cutoff=MATCH_THRESHOLD, + ) + if matches: + index = object_names.index(matches[0]) + matched = objects.pop(index) + object_names.pop(index) + results.append(matched) + else: + pass + return results + + +def extract_unmatched_names( + names: list[str], + matched_objects: list, + attr="name", +) -> list[str]: + matched_names = {getattr(obj, attr).lower() for obj in matched_objects} + return [name for name in names if name.lower() not in matched_names] diff --git a/src/paperless_ai/tests/__init__.py b/src/paperless_ai/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/paperless_ai/tests/test_ai_classifier.py b/src/paperless_ai/tests/test_ai_classifier.py new file mode 100644 index 000000000..115d51cd4 --- /dev/null +++ b/src/paperless_ai/tests/test_ai_classifier.py @@ -0,0 +1,186 @@ +import json +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from django.test import override_settings + +from documents.models import Document +from paperless_ai.ai_classifier import build_prompt_with_rag +from paperless_ai.ai_classifier import build_prompt_without_rag +from paperless_ai.ai_classifier import get_ai_document_classification +from paperless_ai.ai_classifier import get_context_for_document + + +@pytest.fixture +def mock_document(): + doc = MagicMock(spec=Document) + doc.title = "Test Title" + doc.filename = "test_file.pdf" + doc.created = "2023-01-01" + doc.added = "2023-01-02" + doc.modified = "2023-01-03" + + tag1 = MagicMock() + tag1.name = "Tag1" + tag2 = MagicMock() + tag2.name = "Tag2" + doc.tags.all = MagicMock(return_value=[tag1, tag2]) + + doc.document_type = MagicMock() + doc.document_type.name = "Invoice" + doc.correspondent = MagicMock() + doc.correspondent.name = "Test Correspondent" + doc.archive_serial_number = "12345" + doc.content = "This is the document content." + + cf1 = MagicMock(__str__=lambda x: "Value1") + cf1.field = MagicMock() + cf1.field.name = "Field1" + cf1.value = "Value1" + cf2 = MagicMock(__str__=lambda x: "Value2") + cf2.field = MagicMock() + cf2.field.name = "Field2" + cf2.value = "Value2" + doc.custom_fields.all = MagicMock(return_value=[cf1, cf2]) + + return doc + + +@pytest.fixture +def mock_similar_documents(): + doc1 = MagicMock() + doc1.content = "Content of document 1" + doc1.title = "Title 1" + doc1.filename = "file1.txt" + + doc2 = MagicMock() + doc2.content = "Content of document 2" + doc2.title = None + doc2.filename = "file2.txt" + + doc3 = MagicMock() + doc3.content = None + doc3.title = None + doc3.filename = None + + return [doc1, doc2, doc3] + + +@pytest.mark.django_db +@patch("paperless_ai.client.AIClient.run_llm_query") +@override_settings( + LLM_BACKEND="ollama", + LLM_MODEL="some_model", +) +def test_get_ai_document_classification_success(mock_run_llm_query, mock_document): + mock_run_llm_query.return_value = { + "title": "Test Title", + "tags": ["test", "document"], + "correspondents": ["John Doe"], + "document_types": ["report"], + "storage_paths": ["Reports"], + "dates": ["2023-01-01"], + } + + result = get_ai_document_classification(mock_document) + + assert result["title"] == "Test Title" + assert result["tags"] == ["test", "document"] + assert result["correspondents"] == ["John Doe"] + assert result["document_types"] == ["report"] + assert result["storage_paths"] == ["Reports"] + assert result["dates"] == ["2023-01-01"] + + +@pytest.mark.django_db +@patch("paperless_ai.client.AIClient.run_llm_query") +def test_get_ai_document_classification_failure(mock_run_llm_query, mock_document): + mock_run_llm_query.side_effect = Exception("LLM query failed") + + # assert raises an exception + with pytest.raises(Exception): + get_ai_document_classification(mock_document) + + +@pytest.mark.django_db +@patch("paperless_ai.client.AIClient.run_llm_query") +@patch("paperless_ai.ai_classifier.build_prompt_with_rag") +@override_settings( + LLM_EMBEDDING_BACKEND="huggingface", + LLM_EMBEDDING_MODEL="some_model", + LLM_BACKEND="ollama", + LLM_MODEL="some_model", +) +def test_use_rag_if_configured( + mock_build_prompt_with_rag, + mock_run_llm_query, + mock_document, +): + mock_build_prompt_with_rag.return_value = "Prompt with RAG" + mock_run_llm_query.return_value.text = json.dumps({}) + get_ai_document_classification(mock_document) + mock_build_prompt_with_rag.assert_called_once() + + +@pytest.mark.django_db +@patch("paperless_ai.client.AIClient.run_llm_query") +@patch("paperless_ai.ai_classifier.build_prompt_without_rag") +@patch("paperless.config.AIConfig") +@override_settings( + LLM_BACKEND="ollama", + LLM_MODEL="some_model", +) +def test_use_without_rag_if_not_configured( + mock_ai_config, + mock_build_prompt_without_rag, + mock_run_llm_query, + mock_document, +): + mock_ai_config.llm_embedding_backend = None + mock_build_prompt_without_rag.return_value = "Prompt without RAG" + mock_run_llm_query.return_value.text = json.dumps({}) + get_ai_document_classification(mock_document) + mock_build_prompt_without_rag.assert_called_once() + + +@pytest.mark.django_db +@override_settings( + LLM_EMBEDDING_BACKEND="huggingface", + LLM_BACKEND="ollama", + LLM_MODEL="some_model", +) +def test_prompt_with_without_rag(mock_document): + with patch( + "paperless_ai.ai_classifier.get_context_for_document", + return_value="Context from similar documents", + ): + prompt = build_prompt_without_rag(mock_document) + assert "Additional context from similar documents:" not in prompt + + prompt = build_prompt_with_rag(mock_document) + assert "Additional context from similar documents:" in prompt + + +@patch("paperless_ai.ai_classifier.query_similar_documents") +def test_get_context_for_document( + mock_query_similar_documents, + mock_document, + mock_similar_documents, +): + mock_query_similar_documents.return_value = mock_similar_documents + + result = get_context_for_document(mock_document, max_docs=2) + + expected_result = ( + "TITLE: Title 1\nContent of document 1\n\n" + "TITLE: file2.txt\nContent of document 2" + ) + assert result == expected_result + mock_query_similar_documents.assert_called_once() + + +def test_get_context_for_document_no_similar_docs(mock_document): + with patch("paperless_ai.ai_classifier.query_similar_documents", return_value=[]): + result = get_context_for_document(mock_document) + assert result == "" diff --git a/src/paperless_ai/tests/test_ai_indexing.py b/src/paperless_ai/tests/test_ai_indexing.py new file mode 100644 index 000000000..bd217fb89 --- /dev/null +++ b/src/paperless_ai/tests/test_ai_indexing.py @@ -0,0 +1,334 @@ +import json +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from django.test import override_settings +from django.utils import timezone +from llama_index.core.base.embeddings.base import BaseEmbedding + +from documents.models import Document +from paperless_ai import indexing + + +@pytest.fixture +def temp_llm_index_dir(tmp_path): + original_dir = indexing.settings.LLM_INDEX_DIR + indexing.settings.LLM_INDEX_DIR = tmp_path + yield tmp_path + indexing.settings.LLM_INDEX_DIR = original_dir + + +@pytest.fixture +def real_document(db): + return Document.objects.create( + title="Test Document", + content="This is some test content.", + added=timezone.now(), + ) + + +@pytest.fixture +def mock_embed_model(): + fake = FakeEmbedding() + with ( + patch("paperless_ai.indexing.get_embedding_model") as mock_index, + patch( + "paperless_ai.embedding.get_embedding_model", + ) as mock_embedding, + ): + mock_index.return_value = fake + mock_embedding.return_value = fake + yield mock_index + + +class FakeEmbedding(BaseEmbedding): + # TODO: maybe a better way to do this? + def _aget_query_embedding(self, query: str) -> list[float]: + return [0.1] * self.get_query_embedding_dim() + + def _get_query_embedding(self, query: str) -> list[float]: + return [0.1] * self.get_query_embedding_dim() + + def _get_text_embedding(self, text: str) -> list[float]: + return [0.1] * self.get_query_embedding_dim() + + def get_query_embedding_dim(self) -> int: + return 384 # Match your real FAISS config + + +@pytest.mark.django_db +def test_build_document_node(real_document): + nodes = indexing.build_document_node(real_document) + assert len(nodes) > 0 + assert nodes[0].metadata["document_id"] == str(real_document.id) + + +@pytest.mark.django_db +def test_update_llm_index( + temp_llm_index_dir, + real_document, + mock_embed_model, +): + with patch("documents.models.Document.objects.all") as mock_all: + mock_queryset = MagicMock() + mock_queryset.exists.return_value = True + mock_queryset.__iter__.return_value = iter([real_document]) + mock_all.return_value = mock_queryset + indexing.update_llm_index(rebuild=True) + + assert any(temp_llm_index_dir.glob("*.json")) + + +@pytest.mark.django_db +def test_update_llm_index_removes_meta( + temp_llm_index_dir, + real_document, + mock_embed_model, +): + # Pre-create a meta.json with incorrect data + (temp_llm_index_dir / "meta.json").write_text( + json.dumps({"embedding_model": "old", "dim": 1}), + ) + + with patch("documents.models.Document.objects.all") as mock_all: + mock_queryset = MagicMock() + mock_queryset.exists.return_value = True + mock_queryset.__iter__.return_value = iter([real_document]) + mock_all.return_value = mock_queryset + indexing.update_llm_index(rebuild=True) + + meta = json.loads((temp_llm_index_dir / "meta.json").read_text()) + from paperless.config import AIConfig + + config = AIConfig() + expected_model = config.llm_embedding_model or ( + "text-embedding-3-small" + if config.llm_embedding_backend == "openai" + else "sentence-transformers/all-MiniLM-L6-v2" + ) + assert meta == {"embedding_model": expected_model, "dim": 384} + + +@pytest.mark.django_db +def test_update_llm_index_partial_update( + temp_llm_index_dir, + real_document, + mock_embed_model, +): + doc2 = Document.objects.create( + title="Test Document 2", + content="This is some test content 2.", + added=timezone.now(), + checksum="1234567890abcdef", + ) + # Initial index + with patch("documents.models.Document.objects.all") as mock_all: + mock_queryset = MagicMock() + mock_queryset.exists.return_value = True + mock_queryset.__iter__.return_value = iter([real_document, doc2]) + mock_all.return_value = mock_queryset + + indexing.update_llm_index(rebuild=True) + + # modify document + updated_document = real_document + updated_document.modified = timezone.now() # simulate modification + + # new doc + doc3 = Document.objects.create( + title="Test Document 3", + content="This is some test content 3.", + added=timezone.now(), + checksum="abcdef1234567890", + ) + + with patch("documents.models.Document.objects.all") as mock_all: + mock_queryset = MagicMock() + mock_queryset.exists.return_value = True + mock_queryset.__iter__.return_value = iter([updated_document, doc2, doc3]) + mock_all.return_value = mock_queryset + + # assert logs "Updating LLM index with %d new nodes and removing %d old nodes." + with patch("paperless_ai.indexing.logger") as mock_logger: + indexing.update_llm_index(rebuild=False) + mock_logger.info.assert_called_once_with( + "Updating %d nodes in LLM index.", + 2, + ) + indexing.update_llm_index(rebuild=False) + + assert any(temp_llm_index_dir.glob("*.json")) + + +def test_get_or_create_storage_context_raises_exception( + temp_llm_index_dir, + mock_embed_model, +): + with pytest.raises(Exception): + indexing.get_or_create_storage_context(rebuild=False) + + +@override_settings( + LLM_EMBEDDING_BACKEND="huggingface", +) +def test_load_or_build_index_builds_when_nodes_given( + temp_llm_index_dir, + real_document, + mock_embed_model, +): + with ( + patch( + "paperless_ai.indexing.load_index_from_storage", + side_effect=ValueError("Index not found"), + ), + patch( + "paperless_ai.indexing.VectorStoreIndex", + return_value=MagicMock(), + ) as mock_index_cls, + patch( + "paperless_ai.indexing.get_or_create_storage_context", + return_value=MagicMock(), + ) as mock_storage, + ): + mock_storage.return_value.persist_dir = temp_llm_index_dir + indexing.load_or_build_index( + nodes=[indexing.build_document_node(real_document)], + ) + mock_index_cls.assert_called_once() + + +def test_load_or_build_index_raises_exception_when_no_nodes( + temp_llm_index_dir, + mock_embed_model, +): + with ( + patch( + "paperless_ai.indexing.load_index_from_storage", + side_effect=ValueError("Index not found"), + ), + patch( + "paperless_ai.indexing.get_or_create_storage_context", + return_value=MagicMock(), + ), + ): + with pytest.raises(Exception): + indexing.load_or_build_index() + + +@pytest.mark.django_db +def test_load_or_build_index_succeeds_when_nodes_given( + temp_llm_index_dir, + mock_embed_model, +): + with ( + patch( + "paperless_ai.indexing.load_index_from_storage", + side_effect=ValueError("Index not found"), + ), + patch( + "paperless_ai.indexing.VectorStoreIndex", + return_value=MagicMock(), + ) as mock_index_cls, + patch( + "paperless_ai.indexing.get_or_create_storage_context", + return_value=MagicMock(), + ) as mock_storage, + ): + mock_storage.return_value.persist_dir = temp_llm_index_dir + indexing.load_or_build_index( + nodes=[MagicMock()], + ) + mock_index_cls.assert_called_once() + + +@pytest.mark.django_db +def test_add_or_update_document_updates_existing_entry( + temp_llm_index_dir, + real_document, + mock_embed_model, +): + indexing.update_llm_index(rebuild=True) + indexing.llm_index_add_or_update_document(real_document) + + assert any(temp_llm_index_dir.glob("*.json")) + + +@pytest.mark.django_db +def test_remove_document_deletes_node_from_docstore( + temp_llm_index_dir, + real_document, + mock_embed_model, +): + indexing.update_llm_index(rebuild=True) + index = indexing.load_or_build_index() + assert len(index.docstore.docs) == 1 + + indexing.llm_index_remove_document(real_document) + index = indexing.load_or_build_index() + assert len(index.docstore.docs) == 0 + + +@pytest.mark.django_db +def test_update_llm_index_no_documents( + temp_llm_index_dir, + mock_embed_model, +): + with patch("documents.models.Document.objects.all") as mock_all: + mock_queryset = MagicMock() + mock_queryset.exists.return_value = False + mock_queryset.__iter__.return_value = iter([]) + mock_all.return_value = mock_queryset + + # check log message + with patch("paperless_ai.indexing.logger") as mock_logger: + indexing.update_llm_index(rebuild=True) + mock_logger.warning.assert_called_once_with( + "No documents found to index.", + ) + + +@override_settings( + LLM_EMBEDDING_BACKEND="huggingface", + LLM_BACKEND="ollama", +) +def test_query_similar_documents( + temp_llm_index_dir, + real_document, +): + with ( + patch("paperless_ai.indexing.get_or_create_storage_context") as mock_storage, + patch("paperless_ai.indexing.load_or_build_index") as mock_load_or_build_index, + patch("paperless_ai.indexing.VectorIndexRetriever") as mock_retriever_cls, + patch("paperless_ai.indexing.Document.objects.filter") as mock_filter, + ): + mock_storage.return_value = MagicMock() + mock_storage.return_value.persist_dir = temp_llm_index_dir + + mock_index = MagicMock() + mock_load_or_build_index.return_value = mock_index + + mock_retriever = MagicMock() + mock_retriever_cls.return_value = mock_retriever + + mock_node1 = MagicMock() + mock_node1.metadata = {"document_id": 1} + + mock_node2 = MagicMock() + mock_node2.metadata = {"document_id": 2} + + mock_retriever.retrieve.return_value = [mock_node1, mock_node2] + + mock_filtered_docs = [MagicMock(pk=1), MagicMock(pk=2)] + mock_filter.return_value = mock_filtered_docs + + result = indexing.query_similar_documents(real_document, top_k=3) + + mock_load_or_build_index.assert_called_once() + mock_retriever_cls.assert_called_once() + mock_retriever.retrieve.assert_called_once_with( + "Test Document\nThis is some test content.", + ) + mock_filter.assert_called_once_with(pk__in=[1, 2]) + + assert result == mock_filtered_docs diff --git a/src/paperless_ai/tests/test_chat.py b/src/paperless_ai/tests/test_chat.py new file mode 100644 index 000000000..688d78058 --- /dev/null +++ b/src/paperless_ai/tests/test_chat.py @@ -0,0 +1,140 @@ +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from llama_index.core import VectorStoreIndex +from llama_index.core.schema import TextNode + +from paperless_ai.chat import stream_chat_with_documents + + +@pytest.fixture(autouse=True) +def patch_embed_model(): + from llama_index.core import settings as llama_settings + from llama_index.core.embeddings.mock_embed_model import MockEmbedding + + # Use a real BaseEmbedding subclass to satisfy llama-index 0.14 validation + llama_settings.Settings.embed_model = MockEmbedding(embed_dim=1536) + yield + llama_settings.Settings.embed_model = None + + +@pytest.fixture(autouse=True) +def patch_embed_nodes(): + with patch( + "llama_index.core.indices.vector_store.base.embed_nodes", + ) as mock_embed_nodes: + mock_embed_nodes.side_effect = lambda nodes, *_args, **_kwargs: { + node.node_id: [0.1] * 1536 for node in nodes + } + yield + + +@pytest.fixture +def mock_document(): + doc = MagicMock() + doc.pk = 1 + doc.title = "Test Document" + doc.filename = "test_file.pdf" + doc.content = "This is the document content." + return doc + + +def test_stream_chat_with_one_document_full_content(mock_document): + with ( + patch("paperless_ai.chat.AIClient") as mock_client_cls, + patch("paperless_ai.chat.load_or_build_index") as mock_load_index, + patch( + "paperless_ai.chat.RetrieverQueryEngine.from_args", + ) as mock_query_engine_cls, + ): + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.llm = MagicMock() + + mock_node = TextNode( + text="This is node content.", + metadata={"document_id": str(mock_document.pk), "title": "Test Document"}, + ) + mock_index = MagicMock() + mock_index.docstore.docs.values.return_value = [mock_node] + mock_load_index.return_value = mock_index + + mock_response_stream = MagicMock() + mock_response_stream.response_gen = iter(["chunk1", "chunk2"]) + mock_query_engine = MagicMock() + mock_query_engine_cls.return_value = mock_query_engine + mock_query_engine.query.return_value = mock_response_stream + + output = list(stream_chat_with_documents("What is this?", [mock_document])) + + assert output == ["chunk1", "chunk2"] + + +def test_stream_chat_with_multiple_documents_retrieval(patch_embed_nodes): + with ( + patch("paperless_ai.chat.AIClient") as mock_client_cls, + patch("paperless_ai.chat.load_or_build_index") as mock_load_index, + patch( + "paperless_ai.chat.RetrieverQueryEngine.from_args", + ) as mock_query_engine_cls, + patch.object(VectorStoreIndex, "as_retriever") as mock_as_retriever, + ): + # Mock AIClient and LLM + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.llm = MagicMock() + + # Create two real TextNodes + mock_node1 = TextNode( + text="Content for doc 1.", + metadata={"document_id": "1", "title": "Document 1"}, + ) + mock_node2 = TextNode( + text="Content for doc 2.", + metadata={"document_id": "2", "title": "Document 2"}, + ) + mock_index = MagicMock() + mock_index.docstore.docs.values.return_value = [mock_node1, mock_node2] + mock_load_index.return_value = mock_index + + # Patch as_retriever to return a retriever whose retrieve() returns mock_node1 and mock_node2 + mock_retriever = MagicMock() + mock_retriever.retrieve.return_value = [mock_node1, mock_node2] + mock_as_retriever.return_value = mock_retriever + + # Mock response stream + mock_response_stream = MagicMock() + mock_response_stream.response_gen = iter(["chunk1", "chunk2"]) + + # Mock RetrieverQueryEngine + mock_query_engine = MagicMock() + mock_query_engine_cls.return_value = mock_query_engine + mock_query_engine.query.return_value = mock_response_stream + + # Fake documents + doc1 = MagicMock(pk=1) + doc2 = MagicMock(pk=2) + + output = list(stream_chat_with_documents("What's up?", [doc1, doc2])) + + assert output == ["chunk1", "chunk2"] + + +def test_stream_chat_no_matching_nodes(): + with ( + patch("paperless_ai.chat.AIClient") as mock_client_cls, + patch("paperless_ai.chat.load_or_build_index") as mock_load_index, + ): + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.llm = MagicMock() + + mock_index = MagicMock() + # No matching nodes + mock_index.docstore.docs.values.return_value = [] + mock_load_index.return_value = mock_index + + output = list(stream_chat_with_documents("Any info?", [MagicMock(pk=1)])) + + assert output == ["Sorry, I couldn't find any content to answer your question."] diff --git a/src/paperless_ai/tests/test_client.py b/src/paperless_ai/tests/test_client.py new file mode 100644 index 000000000..47053ab20 --- /dev/null +++ b/src/paperless_ai/tests/test_client.py @@ -0,0 +1,111 @@ +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from llama_index.core.llms import ChatMessage +from llama_index.core.llms.llm import ToolSelection + +from paperless_ai.client import AIClient + + +@pytest.fixture +def mock_ai_config(): + with patch("paperless_ai.client.AIConfig") as MockAIConfig: + mock_config = MagicMock() + MockAIConfig.return_value = mock_config + yield mock_config + + +@pytest.fixture +def mock_ollama_llm(): + with patch("paperless_ai.client.Ollama") as MockOllama: + yield MockOllama + + +@pytest.fixture +def mock_openai_llm(): + with patch("paperless_ai.client.OpenAI") as MockOpenAI: + yield MockOpenAI + + +def test_get_llm_ollama(mock_ai_config, mock_ollama_llm): + mock_ai_config.llm_backend = "ollama" + mock_ai_config.llm_model = "test_model" + mock_ai_config.llm_endpoint = "http://test-url" + + client = AIClient() + + mock_ollama_llm.assert_called_once_with( + model="test_model", + base_url="http://test-url", + request_timeout=120, + ) + assert client.llm == mock_ollama_llm.return_value + + +def test_get_llm_openai(mock_ai_config, mock_openai_llm): + mock_ai_config.llm_backend = "openai" + mock_ai_config.llm_model = "test_model" + mock_ai_config.llm_api_key = "test_api_key" + mock_ai_config.llm_endpoint = "http://test-url" + + client = AIClient() + + mock_openai_llm.assert_called_once_with( + model="test_model", + api_base="http://test-url", + api_key="test_api_key", + ) + assert client.llm == mock_openai_llm.return_value + + +def test_get_llm_unsupported_backend(mock_ai_config): + mock_ai_config.llm_backend = "unsupported" + + with pytest.raises(ValueError, match="Unsupported LLM backend: unsupported"): + AIClient() + + +def test_run_llm_query(mock_ai_config, mock_ollama_llm): + mock_ai_config.llm_backend = "ollama" + mock_ai_config.llm_model = "test_model" + mock_ai_config.llm_endpoint = "http://test-url" + + mock_llm_instance = mock_ollama_llm.return_value + + tool_selection = ToolSelection( + tool_id="call_test", + tool_name="DocumentClassifierSchema", + tool_kwargs={ + "title": "Test Title", + "tags": ["test", "document"], + "correspondents": ["John Doe"], + "document_types": ["report"], + "storage_paths": ["Reports"], + "dates": ["2023-01-01"], + }, + ) + + mock_llm_instance.chat_with_tools.return_value = MagicMock() + mock_llm_instance.get_tool_calls_from_response.return_value = [tool_selection] + + client = AIClient() + result = client.run_llm_query("test_prompt") + + assert result["title"] == "Test Title" + + +def test_run_chat(mock_ai_config, mock_ollama_llm): + mock_ai_config.llm_backend = "ollama" + mock_ai_config.llm_model = "test_model" + mock_ai_config.llm_endpoint = "http://test-url" + + mock_llm_instance = mock_ollama_llm.return_value + mock_llm_instance.chat.return_value = "test_chat_result" + + client = AIClient() + messages = [ChatMessage(role="user", content="Hello")] + result = client.run_chat(messages) + + mock_llm_instance.chat.assert_called_once_with(messages) + assert result == "test_chat_result" diff --git a/src/paperless_ai/tests/test_embedding.py b/src/paperless_ai/tests/test_embedding.py new file mode 100644 index 000000000..9430205fa --- /dev/null +++ b/src/paperless_ai/tests/test_embedding.py @@ -0,0 +1,169 @@ +import json +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest +from django.conf import settings + +from documents.models import Document +from paperless.models import LLMEmbeddingBackend +from paperless_ai.embedding import build_llm_index_text +from paperless_ai.embedding import get_embedding_dim +from paperless_ai.embedding import get_embedding_model + + +@pytest.fixture +def mock_ai_config(): + with patch("paperless_ai.embedding.AIConfig") as MockAIConfig: + yield MockAIConfig + + +@pytest.fixture +def temp_llm_index_dir(tmp_path): + original_dir = settings.LLM_INDEX_DIR + settings.LLM_INDEX_DIR = tmp_path + yield tmp_path + settings.LLM_INDEX_DIR = original_dir + + +@pytest.fixture +def mock_document(): + doc = MagicMock(spec=Document) + doc.title = "Test Title" + doc.filename = "test_file.pdf" + doc.created = "2023-01-01" + doc.added = "2023-01-02" + doc.modified = "2023-01-03" + + tag1 = MagicMock() + tag1.name = "Tag1" + tag2 = MagicMock() + tag2.name = "Tag2" + doc.tags.all = MagicMock(return_value=[tag1, tag2]) + + doc.document_type = MagicMock() + doc.document_type.name = "Invoice" + doc.correspondent = MagicMock() + doc.correspondent.name = "Test Correspondent" + doc.archive_serial_number = "12345" + doc.content = "This is the document content." + + cf1 = MagicMock(__str__=lambda x: "Value1") + cf1.field = MagicMock() + cf1.field.name = "Field1" + cf1.value = "Value1" + cf2 = MagicMock(__str__=lambda x: "Value2") + cf2.field = MagicMock() + cf2.field.name = "Field2" + cf2.value = "Value2" + doc.custom_fields.all = MagicMock(return_value=[cf1, cf2]) + + return doc + + +def test_get_embedding_model_openai(mock_ai_config): + mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI + mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small" + mock_ai_config.return_value.llm_api_key = "test_api_key" + + with patch("paperless_ai.embedding.OpenAIEmbedding") as MockOpenAIEmbedding: + model = get_embedding_model() + MockOpenAIEmbedding.assert_called_once_with( + model="text-embedding-3-small", + api_key="test_api_key", + ) + assert model == MockOpenAIEmbedding.return_value + + +def test_get_embedding_model_huggingface(mock_ai_config): + mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.HUGGINGFACE + mock_ai_config.return_value.llm_embedding_model = ( + "sentence-transformers/all-MiniLM-L6-v2" + ) + + with patch( + "paperless_ai.embedding.HuggingFaceEmbedding", + ) as MockHuggingFaceEmbedding: + model = get_embedding_model() + MockHuggingFaceEmbedding.assert_called_once_with( + model_name="sentence-transformers/all-MiniLM-L6-v2", + ) + assert model == MockHuggingFaceEmbedding.return_value + + +def test_get_embedding_model_invalid_backend(mock_ai_config): + mock_ai_config.return_value.llm_embedding_backend = "INVALID_BACKEND" + + with pytest.raises( + ValueError, + match="Unsupported embedding backend: INVALID_BACKEND", + ): + get_embedding_model() + + +def test_get_embedding_dim_infers_and_saves(temp_llm_index_dir, mock_ai_config): + mock_ai_config.return_value.llm_embedding_backend = "openai" + mock_ai_config.return_value.llm_embedding_model = None + + class DummyEmbedding: + def get_text_embedding(self, text): + return [0.0] * 7 + + with patch( + "paperless_ai.embedding.get_embedding_model", + return_value=DummyEmbedding(), + ) as mock_get: + dim = get_embedding_dim() + mock_get.assert_called_once() + + assert dim == 7 + meta = json.loads((temp_llm_index_dir / "meta.json").read_text()) + assert meta == {"embedding_model": "text-embedding-3-small", "dim": 7} + + +def test_get_embedding_dim_reads_existing_meta(temp_llm_index_dir, mock_ai_config): + mock_ai_config.return_value.llm_embedding_backend = "openai" + mock_ai_config.return_value.llm_embedding_model = None + + (temp_llm_index_dir / "meta.json").write_text( + json.dumps({"embedding_model": "text-embedding-3-small", "dim": 11}), + ) + + with patch("paperless_ai.embedding.get_embedding_model") as mock_get: + assert get_embedding_dim() == 11 + mock_get.assert_not_called() + + +def test_get_embedding_dim_raises_on_model_change(temp_llm_index_dir, mock_ai_config): + mock_ai_config.return_value.llm_embedding_backend = "openai" + mock_ai_config.return_value.llm_embedding_model = None + + (temp_llm_index_dir / "meta.json").write_text( + json.dumps({"embedding_model": "old", "dim": 11}), + ) + + with pytest.raises( + RuntimeError, + match="Embedding model changed from old to text-embedding-3-small", + ): + get_embedding_dim() + + +def test_build_llm_index_text(mock_document): + with patch("documents.models.Note.objects.filter") as mock_notes_filter: + mock_notes_filter.return_value = [ + MagicMock(note="Note1"), + MagicMock(note="Note2"), + ] + + result = build_llm_index_text(mock_document) + + assert "Title: Test Title" in result + assert "Filename: test_file.pdf" in result + assert "Created: 2023-01-01" in result + assert "Tags: Tag1, Tag2" in result + assert "Document Type: Invoice" in result + assert "Correspondent: Test Correspondent" in result + assert "Notes: Note1,Note2" in result + assert "Content:\n\nThis is the document content." in result + assert "Custom Field - Field1: Value1\nCustom Field - Field2: Value2" in result diff --git a/src/paperless_ai/tests/test_matching.py b/src/paperless_ai/tests/test_matching.py new file mode 100644 index 000000000..87a42a1a4 --- /dev/null +++ b/src/paperless_ai/tests/test_matching.py @@ -0,0 +1,86 @@ +from unittest.mock import patch + +from django.test import TestCase + +from documents.models import Correspondent +from documents.models import DocumentType +from documents.models import StoragePath +from documents.models import Tag +from paperless_ai.matching import extract_unmatched_names +from paperless_ai.matching import match_correspondents_by_name +from paperless_ai.matching import match_document_types_by_name +from paperless_ai.matching import match_storage_paths_by_name +from paperless_ai.matching import match_tags_by_name + + +class TestAIMatching(TestCase): + def setUp(self): + # Create test data for Tag + self.tag1 = Tag.objects.create(name="Test Tag 1") + self.tag2 = Tag.objects.create(name="Test Tag 2") + + # Create test data for Correspondent + self.correspondent1 = Correspondent.objects.create(name="Test Correspondent 1") + self.correspondent2 = Correspondent.objects.create(name="Test Correspondent 2") + + # Create test data for DocumentType + self.document_type1 = DocumentType.objects.create(name="Test Document Type 1") + self.document_type2 = DocumentType.objects.create(name="Test Document Type 2") + + # Create test data for StoragePath + self.storage_path1 = StoragePath.objects.create(name="Test Storage Path 1") + self.storage_path2 = StoragePath.objects.create(name="Test Storage Path 2") + + @patch("paperless_ai.matching.get_objects_for_user_owner_aware") + def test_match_tags_by_name(self, mock_get_objects): + mock_get_objects.return_value = Tag.objects.all() + names = ["Test Tag 1", "Nonexistent Tag"] + result = match_tags_by_name(names, user=None) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "Test Tag 1") + + @patch("paperless_ai.matching.get_objects_for_user_owner_aware") + def test_match_correspondents_by_name(self, mock_get_objects): + mock_get_objects.return_value = Correspondent.objects.all() + names = ["Test Correspondent 1", "Nonexistent Correspondent"] + result = match_correspondents_by_name(names, user=None) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "Test Correspondent 1") + + @patch("paperless_ai.matching.get_objects_for_user_owner_aware") + def test_match_document_types_by_name(self, mock_get_objects): + mock_get_objects.return_value = DocumentType.objects.all() + names = ["Test Document Type 1", "Nonexistent Document Type"] + result = match_document_types_by_name(names, user=None) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "Test Document Type 1") + + @patch("paperless_ai.matching.get_objects_for_user_owner_aware") + def test_match_storage_paths_by_name(self, mock_get_objects): + mock_get_objects.return_value = StoragePath.objects.all() + names = ["Test Storage Path 1", "Nonexistent Storage Path"] + result = match_storage_paths_by_name(names, user=None) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "Test Storage Path 1") + + def test_extract_unmatched_names(self): + llm_names = ["Test Tag 1", "Nonexistent Tag"] + matched_objects = [self.tag1] + unmatched_names = extract_unmatched_names(llm_names, matched_objects) + self.assertEqual(unmatched_names, ["Nonexistent Tag"]) + + @patch("paperless_ai.matching.get_objects_for_user_owner_aware") + def test_match_tags_by_name_with_empty_names(self, mock_get_objects): + mock_get_objects.return_value = Tag.objects.all() + names = [None, "", " "] + result = match_tags_by_name(names, user=None) + self.assertEqual(result, []) + + @patch("paperless_ai.matching.get_objects_for_user_owner_aware") + def test_match_tags_with_fuzzy_matching(self, mock_get_objects): + mock_get_objects.return_value = Tag.objects.all() + names = ["Test Taag 1", "Teest Tag 2"] + result = match_tags_by_name(names, user=None) + self.assertEqual(len(result), 2) + self.assertEqual(result[0].name, "Test Tag 1") + self.assertEqual(result[1].name, "Test Tag 2") diff --git a/src/paperless_mail/migrations/0001_initial.py b/src/paperless_mail/migrations/0001_initial.py index f7e717f0e..aab6a8239 100644 --- a/src/paperless_mail/migrations/0001_initial.py +++ b/src/paperless_mail/migrations/0001_initial.py @@ -1,6 +1,8 @@ -# Generated by Django 3.1.3 on 2020-11-15 22:54 +# Generated by Django 5.2.9 on 2026-01-20 18:46 import django.db.models.deletion +import django.utils.timezone +from django.conf import settings from django.db import migrations from django.db import models @@ -9,7 +11,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("documents", "1002_auto_20201111_1105"), + ("documents", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -25,9 +28,23 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("name", models.CharField(max_length=256, unique=True)), - ("imap_server", models.CharField(max_length=256)), - ("imap_port", models.IntegerField(blank=True, null=True)), + ( + "name", + models.CharField(max_length=256, unique=True, verbose_name="name"), + ), + ( + "imap_server", + models.CharField(max_length=256, verbose_name="IMAP server"), + ), + ( + "imap_port", + models.IntegerField( + blank=True, + help_text="This is usually 143 for unencrypted and STARTTLS connections, and 993 for SSL connections.", + null=True, + verbose_name="IMAP port", + ), + ), ( "imap_security", models.PositiveIntegerField( @@ -37,11 +54,69 @@ class Migration(migrations.Migration): (3, "Use STARTTLS"), ], default=2, + verbose_name="IMAP security", + ), + ), + ("username", models.CharField(max_length=256, verbose_name="username")), + ("password", models.TextField(verbose_name="password")), + ( + "is_token", + models.BooleanField( + default=False, + verbose_name="Is token authentication", + ), + ), + ( + "character_set", + models.CharField( + default="UTF-8", + help_text="The character set to use when communicating with the mail server, such as 'UTF-8' or 'US-ASCII'.", + max_length=256, + verbose_name="character set", + ), + ), + ( + "account_type", + models.PositiveIntegerField( + choices=[(1, "IMAP"), (2, "Gmail OAuth"), (3, "Outlook OAuth")], + default=1, + verbose_name="account type", + ), + ), + ( + "refresh_token", + models.TextField( + blank=True, + help_text="The refresh token to use for token authentication e.g. with oauth2.", + null=True, + verbose_name="refresh token", + ), + ), + ( + "expiration", + models.DateTimeField( + blank=True, + help_text="The expiration date of the refresh token. ", + null=True, + verbose_name="expiration", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="owner", ), ), - ("username", models.CharField(max_length=256)), - ("password", models.CharField(max_length=256)), ], + options={ + "verbose_name": "mail account", + "verbose_name_plural": "mail accounts", + }, ), migrations.CreateModel( name="MailRule", @@ -55,21 +130,126 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("name", models.CharField(max_length=256)), - ("folder", models.CharField(default="INBOX", max_length=256)), + ("name", models.CharField(max_length=256, verbose_name="name")), + ("order", models.IntegerField(default=0, verbose_name="order")), + ("enabled", models.BooleanField(default=True, verbose_name="enabled")), + ( + "folder", + models.CharField( + default="INBOX", + help_text="Subfolders must be separated by a delimiter, often a dot ('.') or slash ('/'), but it varies by mail server.", + max_length=256, + verbose_name="folder", + ), + ), ( "filter_from", - models.CharField(blank=True, max_length=256, null=True), + models.CharField( + blank=True, + max_length=256, + null=True, + verbose_name="filter from", + ), + ), + ( + "filter_to", + models.CharField( + blank=True, + max_length=256, + null=True, + verbose_name="filter to", + ), ), ( "filter_subject", - models.CharField(blank=True, max_length=256, null=True), + models.CharField( + blank=True, + max_length=256, + null=True, + verbose_name="filter subject", + ), ), ( "filter_body", - models.CharField(blank=True, max_length=256, null=True), + models.CharField( + blank=True, + max_length=256, + null=True, + verbose_name="filter body", + ), + ), + ( + "filter_attachment_filename_include", + models.CharField( + blank=True, + help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", + max_length=256, + null=True, + verbose_name="filter attachment filename inclusive", + ), + ), + ( + "filter_attachment_filename_exclude", + models.CharField( + blank=True, + help_text="Do not consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", + max_length=256, + null=True, + verbose_name="filter attachment filename exclusive", + ), + ), + ( + "maximum_age", + models.PositiveIntegerField( + default=30, + help_text="Specified in days.", + verbose_name="maximum age", + ), + ), + ( + "attachment_type", + models.PositiveIntegerField( + choices=[ + (1, "Only process attachments."), + (2, "Process all files, including 'inline' attachments."), + ], + default=1, + help_text="Inline attachments include embedded images, so it's best to combine this option with a filename filter.", + verbose_name="attachment type", + ), + ), + ( + "consumption_scope", + models.PositiveIntegerField( + choices=[ + (1, "Only process attachments."), + ( + 2, + "Process full Mail (with embedded attachments in file) as .eml", + ), + ( + 3, + "Process full Mail (with embedded attachments in file) as .eml + process attachments as separate documents", + ), + ], + default=1, + verbose_name="consumption scope", + ), + ), + ( + "pdf_layout", + models.PositiveIntegerField( + choices=[ + (0, "System default"), + (1, "Text, then HTML"), + (2, "HTML, then text"), + (3, "HTML only"), + (4, "Text only"), + ], + default=0, + verbose_name="pdf layout", + ), ), - ("maximum_age", models.PositiveIntegerField(default=30)), ( "action", models.PositiveIntegerField( @@ -78,18 +258,23 @@ class Migration(migrations.Migration): (2, "Move to specified folder"), (3, "Mark as read, don't process read mails"), (4, "Flag the mail, don't process flagged mails"), + ( + 5, + "Tag the mail with specified tag, don't process tagged mails", + ), ], default=3, - help_text="The action applied to the mail. This action is only performed when documents were consumed from the mail. Mails without attachments will remain entirely untouched.", + verbose_name="action", ), ), ( "action_parameter", models.CharField( blank=True, - help_text="Additional parameter for the action selected above, i.e., the target folder of the move to folder action.", + help_text="Additional parameter for the action selected above, i.e., the target folder of the move to folder action. Subfolders must be separated by dots.", max_length=256, null=True, + verbose_name="action parameter", ), ), ( @@ -98,8 +283,10 @@ class Migration(migrations.Migration): choices=[ (1, "Use subject as title"), (2, "Use attachment filename as title"), + (3, "Do not assign title from rule"), ], default=1, + verbose_name="assign title from", ), ), ( @@ -112,6 +299,14 @@ class Migration(migrations.Migration): (4, "Use correspondent selected below"), ], default=1, + verbose_name="assign correspondent from", + ), + ), + ( + "assign_owner_from_rule", + models.BooleanField( + default=True, + verbose_name="Assign the rule owner to documents", ), ), ( @@ -120,6 +315,7 @@ class Migration(migrations.Migration): on_delete=django.db.models.deletion.CASCADE, related_name="rules", to="paperless_mail.mailaccount", + verbose_name="account", ), ), ( @@ -129,6 +325,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, to="documents.correspondent", + verbose_name="assign this correspondent", ), ), ( @@ -138,17 +335,136 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, to="documents.documenttype", + verbose_name="assign this document type", ), ), ( - "assign_tag", + "assign_tags", + models.ManyToManyField( + blank=True, + to="documents.tag", + verbose_name="assign this tag", + ), + ), + ( + "owner", models.ForeignKey( blank=True, + default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, - to="documents.tag", + to=settings.AUTH_USER_MODEL, + verbose_name="owner", ), ), ], + options={ + "verbose_name": "mail rule", + "verbose_name_plural": "mail rules", + }, + ), + migrations.CreateModel( + name="ProcessedMail", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "folder", + models.CharField( + editable=False, + max_length=256, + verbose_name="folder", + ), + ), + ( + "uid", + models.CharField( + editable=False, + max_length=256, + verbose_name="uid", + ), + ), + ( + "subject", + models.CharField( + editable=False, + max_length=256, + verbose_name="subject", + ), + ), + ( + "received", + models.DateTimeField(editable=False, verbose_name="received"), + ), + ( + "processed", + models.DateTimeField( + default=django.utils.timezone.now, + editable=False, + verbose_name="processed", + ), + ), + ( + "status", + models.CharField( + editable=False, + max_length=256, + verbose_name="status", + ), + ), + ( + "error", + models.TextField( + blank=True, + editable=False, + null=True, + verbose_name="error", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ( + "rule", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.CASCADE, + to="paperless_mail.mailrule", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddConstraint( + model_name="mailrule", + constraint=models.UniqueConstraint( + fields=("name", "owner"), + name="paperless_mail_mailrule_unique_name_owner", + ), + ), + migrations.AddConstraint( + model_name="mailrule", + constraint=models.UniqueConstraint( + condition=models.Q(("owner__isnull", True)), + fields=("name",), + name="paperless_mail_mailrule_name_unique", + ), ), ] diff --git a/src/paperless_mail/migrations/0001_initial_squashed_0009_mailrule_assign_tags.py b/src/paperless_mail/migrations/0001_initial_squashed_0009_mailrule_assign_tags.py deleted file mode 100644 index aad22918f..000000000 --- a/src/paperless_mail/migrations/0001_initial_squashed_0009_mailrule_assign_tags.py +++ /dev/null @@ -1,477 +0,0 @@ -# Generated by Django 4.2.13 on 2024-06-28 17:46 - -import django.db.migrations.operations.special -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - replaces = [ - ("paperless_mail", "0001_initial"), - ("paperless_mail", "0002_auto_20201117_1334"), - ("paperless_mail", "0003_auto_20201118_1940"), - ("paperless_mail", "0004_mailrule_order"), - ("paperless_mail", "0005_help_texts"), - ("paperless_mail", "0006_auto_20210101_2340"), - ("paperless_mail", "0007_auto_20210106_0138"), - ("paperless_mail", "0008_auto_20210516_0940"), - ("paperless_mail", "0009_mailrule_assign_tags"), - ] - - dependencies = [ - ("documents", "1002_auto_20201111_1105"), - ("documents", "1011_auto_20210101_2340"), - ] - - operations = [ - migrations.CreateModel( - name="MailAccount", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=256, unique=True)), - ("imap_server", models.CharField(max_length=256)), - ("imap_port", models.IntegerField(blank=True, null=True)), - ( - "imap_security", - models.PositiveIntegerField( - choices=[ - (1, "No encryption"), - (2, "Use SSL"), - (3, "Use STARTTLS"), - ], - default=2, - ), - ), - ("username", models.CharField(max_length=256)), - ("password", models.CharField(max_length=256)), - ], - ), - migrations.CreateModel( - name="MailRule", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=256)), - ("folder", models.CharField(default="INBOX", max_length=256)), - ( - "filter_from", - models.CharField(blank=True, max_length=256, null=True), - ), - ( - "filter_subject", - models.CharField(blank=True, max_length=256, null=True), - ), - ( - "filter_body", - models.CharField(blank=True, max_length=256, null=True), - ), - ("maximum_age", models.PositiveIntegerField(default=30)), - ( - "action", - models.PositiveIntegerField( - choices=[ - (1, "Delete"), - (2, "Move to specified folder"), - (3, "Mark as read, don't process read mails"), - (4, "Flag the mail, don't process flagged mails"), - ], - default=3, - help_text="The action applied to the mail. This action is only performed when documents were consumed from the mail. Mails without attachments will remain entirely untouched.", - ), - ), - ( - "action_parameter", - models.CharField( - blank=True, - help_text="Additional parameter for the action selected above, i.e., the target folder of the move to folder action.", - max_length=256, - null=True, - ), - ), - ( - "assign_title_from", - models.PositiveIntegerField( - choices=[ - (1, "Use subject as title"), - (2, "Use attachment filename as title"), - ], - default=1, - ), - ), - ( - "assign_correspondent_from", - models.PositiveIntegerField( - choices=[ - (1, "Do not assign a correspondent"), - (2, "Use mail address"), - (3, "Use name (or mail address if not available)"), - (4, "Use correspondent selected below"), - ], - default=1, - ), - ), - ( - "account", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="rules", - to="paperless_mail.mailaccount", - ), - ), - ( - "assign_correspondent", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.correspondent", - ), - ), - ( - "assign_document_type", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.documenttype", - ), - ), - ( - "assign_tag", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.tag", - ), - ), - ], - ), - migrations.RunPython( - code=django.db.migrations.operations.special.RunPython.noop, - reverse_code=django.db.migrations.operations.special.RunPython.noop, - ), - migrations.AlterField( - model_name="mailaccount", - name="imap_port", - field=models.IntegerField( - blank=True, - help_text="This is usually 143 for unencrypted and STARTTLS connections, and 993 for SSL connections.", - null=True, - ), - ), - migrations.AlterField( - model_name="mailrule", - name="name", - field=models.CharField(max_length=256, unique=True), - ), - migrations.AddField( - model_name="mailrule", - name="order", - field=models.IntegerField(default=0), - ), - migrations.AlterField( - model_name="mailrule", - name="action", - field=models.PositiveIntegerField( - choices=[ - (3, "Mark as read, don't process read mails"), - (4, "Flag the mail, don't process flagged mails"), - (2, "Move to specified folder"), - (1, "Delete"), - ], - default=3, - ), - ), - migrations.AlterField( - model_name="mailrule", - name="maximum_age", - field=models.PositiveIntegerField( - default=30, - help_text="Specified in days.", - ), - ), - migrations.AlterModelOptions( - name="mailaccount", - options={ - "verbose_name": "mail account", - "verbose_name_plural": "mail accounts", - }, - ), - migrations.AlterModelOptions( - name="mailrule", - options={"verbose_name": "mail rule", "verbose_name_plural": "mail rules"}, - ), - migrations.AlterField( - model_name="mailaccount", - name="imap_port", - field=models.IntegerField( - blank=True, - help_text="This is usually 143 for unencrypted and STARTTLS connections, and 993 for SSL connections.", - null=True, - verbose_name="IMAP port", - ), - ), - migrations.AlterField( - model_name="mailaccount", - name="imap_security", - field=models.PositiveIntegerField( - choices=[(1, "No encryption"), (2, "Use SSL"), (3, "Use STARTTLS")], - default=2, - verbose_name="IMAP security", - ), - ), - migrations.AlterField( - model_name="mailaccount", - name="imap_server", - field=models.CharField(max_length=256, verbose_name="IMAP server"), - ), - migrations.AlterField( - model_name="mailaccount", - name="name", - field=models.CharField(max_length=256, unique=True, verbose_name="name"), - ), - migrations.AlterField( - model_name="mailaccount", - name="password", - field=models.CharField(max_length=256, verbose_name="password"), - ), - migrations.AlterField( - model_name="mailaccount", - name="username", - field=models.CharField(max_length=256, verbose_name="username"), - ), - migrations.AlterField( - model_name="mailrule", - name="account", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="rules", - to="paperless_mail.mailaccount", - verbose_name="account", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="action", - field=models.PositiveIntegerField( - choices=[ - (3, "Mark as read, don't process read mails"), - (4, "Flag the mail, don't process flagged mails"), - (2, "Move to specified folder"), - (1, "Delete"), - ], - default=3, - verbose_name="action", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="action_parameter", - field=models.CharField( - blank=True, - help_text="Additional parameter for the action selected above, i.e., the target folder of the move to folder action.", - max_length=256, - null=True, - verbose_name="action parameter", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_correspondent", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.correspondent", - verbose_name="assign this correspondent", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_correspondent_from", - field=models.PositiveIntegerField( - choices=[ - (1, "Do not assign a correspondent"), - (2, "Use mail address"), - (3, "Use name (or mail address if not available)"), - (4, "Use correspondent selected below"), - ], - default=1, - verbose_name="assign correspondent from", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_document_type", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.documenttype", - verbose_name="assign this document type", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_tag", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.tag", - verbose_name="assign this tag", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_title_from", - field=models.PositiveIntegerField( - choices=[ - (1, "Use subject as title"), - (2, "Use attachment filename as title"), - ], - default=1, - verbose_name="assign title from", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="filter_body", - field=models.CharField( - blank=True, - max_length=256, - null=True, - verbose_name="filter body", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="filter_from", - field=models.CharField( - blank=True, - max_length=256, - null=True, - verbose_name="filter from", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="filter_subject", - field=models.CharField( - blank=True, - max_length=256, - null=True, - verbose_name="filter subject", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="folder", - field=models.CharField( - default="INBOX", - max_length=256, - verbose_name="folder", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="maximum_age", - field=models.PositiveIntegerField( - default=30, - help_text="Specified in days.", - verbose_name="maximum age", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="name", - field=models.CharField(max_length=256, unique=True, verbose_name="name"), - ), - migrations.AlterField( - model_name="mailrule", - name="order", - field=models.IntegerField(default=0, verbose_name="order"), - ), - migrations.AddField( - model_name="mailrule", - name="attachment_type", - field=models.PositiveIntegerField( - choices=[ - (1, "Only process attachments."), - (2, "Process all files, including 'inline' attachments."), - ], - default=1, - help_text="Inline attachments include embedded images, so it's best to combine this option with a filename filter.", - verbose_name="attachment type", - ), - ), - migrations.AddField( - model_name="mailrule", - name="filter_attachment_filename", - field=models.CharField( - blank=True, - help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", - max_length=256, - null=True, - verbose_name="filter attachment filename", - ), - ), - migrations.AddField( - model_name="mailaccount", - name="character_set", - field=models.CharField( - default="UTF-8", - help_text="The character set to use when communicating with the mail server, such as 'UTF-8' or 'US-ASCII'.", - max_length=256, - verbose_name="character set", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="action_parameter", - field=models.CharField( - blank=True, - help_text="Additional parameter for the action selected above, i.e., the target folder of the move to folder action. Subfolders must be separated by dots.", - max_length=256, - null=True, - verbose_name="action parameter", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="folder", - field=models.CharField( - default="INBOX", - help_text="Subfolders must be separated by dots.", - max_length=256, - verbose_name="folder", - ), - ), - migrations.AddField( - model_name="mailrule", - name="assign_tags", - field=models.ManyToManyField( - blank=True, - related_name="mail_rules_multi", - to="documents.tag", - verbose_name="assign this tag", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0002_auto_20201117_1334.py b/src/paperless_mail/migrations/0002_auto_20201117_1334.py deleted file mode 100644 index 1f4df3f6d..000000000 --- a/src/paperless_mail/migrations/0002_auto_20201117_1334.py +++ /dev/null @@ -1,12 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-17 13:34 - -from django.db import migrations -from django.db.migrations import RunPython - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0001_initial"), - ] - - operations = [RunPython(migrations.RunPython.noop, migrations.RunPython.noop)] diff --git a/src/paperless_mail/migrations/0003_auto_20201118_1940.py b/src/paperless_mail/migrations/0003_auto_20201118_1940.py deleted file mode 100644 index a1263db05..000000000 --- a/src/paperless_mail/migrations/0003_auto_20201118_1940.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-18 19:40 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0002_auto_20201117_1334"), - ] - - operations = [ - migrations.AlterField( - model_name="mailaccount", - name="imap_port", - field=models.IntegerField( - blank=True, - help_text="This is usually 143 for unencrypted and STARTTLS connections, and 993 for SSL connections.", - null=True, - ), - ), - migrations.AlterField( - model_name="mailrule", - name="name", - field=models.CharField(max_length=256, unique=True), - ), - ] diff --git a/src/paperless_mail/migrations/0005_help_texts.py b/src/paperless_mail/migrations/0005_help_texts.py deleted file mode 100644 index 8e49238e9..000000000 --- a/src/paperless_mail/migrations/0005_help_texts.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 3.1.3 on 2020-11-22 10:36 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0004_mailrule_order"), - ] - - operations = [ - migrations.AlterField( - model_name="mailrule", - name="action", - field=models.PositiveIntegerField( - choices=[ - (3, "Mark as read, don't process read mails"), - (4, "Flag the mail, don't process flagged mails"), - (2, "Move to specified folder"), - (1, "Delete"), - ], - default=3, - ), - ), - migrations.AlterField( - model_name="mailrule", - name="maximum_age", - field=models.PositiveIntegerField( - default=30, - help_text="Specified in days.", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0006_auto_20210101_2340.py b/src/paperless_mail/migrations/0006_auto_20210101_2340.py deleted file mode 100644 index 2c2ff9fa8..000000000 --- a/src/paperless_mail/migrations/0006_auto_20210101_2340.py +++ /dev/null @@ -1,217 +0,0 @@ -# Generated by Django 3.1.4 on 2021-01-01 23:40 - -import django.db.models.deletion -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("documents", "1011_auto_20210101_2340"), - ("paperless_mail", "0005_help_texts"), - ] - - operations = [ - migrations.AlterModelOptions( - name="mailaccount", - options={ - "verbose_name": "mail account", - "verbose_name_plural": "mail accounts", - }, - ), - migrations.AlterModelOptions( - name="mailrule", - options={"verbose_name": "mail rule", "verbose_name_plural": "mail rules"}, - ), - migrations.AlterField( - model_name="mailaccount", - name="imap_port", - field=models.IntegerField( - blank=True, - help_text="This is usually 143 for unencrypted and STARTTLS connections, and 993 for SSL connections.", - null=True, - verbose_name="IMAP port", - ), - ), - migrations.AlterField( - model_name="mailaccount", - name="imap_security", - field=models.PositiveIntegerField( - choices=[(1, "No encryption"), (2, "Use SSL"), (3, "Use STARTTLS")], - default=2, - verbose_name="IMAP security", - ), - ), - migrations.AlterField( - model_name="mailaccount", - name="imap_server", - field=models.CharField(max_length=256, verbose_name="IMAP server"), - ), - migrations.AlterField( - model_name="mailaccount", - name="name", - field=models.CharField(max_length=256, unique=True, verbose_name="name"), - ), - migrations.AlterField( - model_name="mailaccount", - name="password", - field=models.CharField(max_length=256, verbose_name="password"), - ), - migrations.AlterField( - model_name="mailaccount", - name="username", - field=models.CharField(max_length=256, verbose_name="username"), - ), - migrations.AlterField( - model_name="mailrule", - name="account", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="rules", - to="paperless_mail.mailaccount", - verbose_name="account", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="action", - field=models.PositiveIntegerField( - choices=[ - (3, "Mark as read, don't process read mails"), - (4, "Flag the mail, don't process flagged mails"), - (2, "Move to specified folder"), - (1, "Delete"), - ], - default=3, - verbose_name="action", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="action_parameter", - field=models.CharField( - blank=True, - help_text="Additional parameter for the action selected above, i.e., the target folder of the move to folder action.", - max_length=256, - null=True, - verbose_name="action parameter", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_correspondent", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.correspondent", - verbose_name="assign this correspondent", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_correspondent_from", - field=models.PositiveIntegerField( - choices=[ - (1, "Do not assign a correspondent"), - (2, "Use mail address"), - (3, "Use name (or mail address if not available)"), - (4, "Use correspondent selected below"), - ], - default=1, - verbose_name="assign correspondent from", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_document_type", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.documenttype", - verbose_name="assign this document type", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_tag", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="documents.tag", - verbose_name="assign this tag", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_title_from", - field=models.PositiveIntegerField( - choices=[ - (1, "Use subject as title"), - (2, "Use attachment filename as title"), - ], - default=1, - verbose_name="assign title from", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="filter_body", - field=models.CharField( - blank=True, - max_length=256, - null=True, - verbose_name="filter body", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="filter_from", - field=models.CharField( - blank=True, - max_length=256, - null=True, - verbose_name="filter from", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="filter_subject", - field=models.CharField( - blank=True, - max_length=256, - null=True, - verbose_name="filter subject", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="folder", - field=models.CharField( - default="INBOX", - max_length=256, - verbose_name="folder", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="maximum_age", - field=models.PositiveIntegerField( - default=30, - help_text="Specified in days.", - verbose_name="maximum age", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="name", - field=models.CharField(max_length=256, unique=True, verbose_name="name"), - ), - migrations.AlterField( - model_name="mailrule", - name="order", - field=models.IntegerField(default=0, verbose_name="order"), - ), - ] diff --git a/src/paperless_mail/migrations/0007_auto_20210106_0138.py b/src/paperless_mail/migrations/0007_auto_20210106_0138.py deleted file mode 100644 index c51a4aebe..000000000 --- a/src/paperless_mail/migrations/0007_auto_20210106_0138.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 3.1.5 on 2021-01-06 01:38 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0006_auto_20210101_2340"), - ] - - operations = [ - migrations.AddField( - model_name="mailrule", - name="attachment_type", - field=models.PositiveIntegerField( - choices=[ - (1, "Only process attachments."), - (2, "Process all files, including 'inline' attachments."), - ], - default=1, - help_text="Inline attachments include embedded images, so it's best to combine this option with a filename filter.", - verbose_name="attachment type", - ), - ), - migrations.AddField( - model_name="mailrule", - name="filter_attachment_filename", - field=models.CharField( - blank=True, - help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", - max_length=256, - null=True, - verbose_name="filter attachment filename", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0008_auto_20210516_0940.py b/src/paperless_mail/migrations/0008_auto_20210516_0940.py deleted file mode 100644 index b2fc062dd..000000000 --- a/src/paperless_mail/migrations/0008_auto_20210516_0940.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 3.2.3 on 2021-05-16 09:40 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0007_auto_20210106_0138"), - ] - - operations = [ - migrations.AddField( - model_name="mailaccount", - name="character_set", - field=models.CharField( - default="UTF-8", - help_text="The character set to use when communicating with the mail server, such as 'UTF-8' or 'US-ASCII'.", - max_length=256, - verbose_name="character set", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="action_parameter", - field=models.CharField( - blank=True, - help_text="Additional parameter for the action selected above, i.e., the target folder of the move to folder action. Subfolders must be separated by dots.", - max_length=256, - null=True, - verbose_name="action parameter", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="folder", - field=models.CharField( - default="INBOX", - help_text="Subfolders must be separated by dots.", - max_length=256, - verbose_name="folder", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0009_alter_mailrule_action_alter_mailrule_folder.py b/src/paperless_mail/migrations/0009_alter_mailrule_action_alter_mailrule_folder.py deleted file mode 100644 index 47fdaff12..000000000 --- a/src/paperless_mail/migrations/0009_alter_mailrule_action_alter_mailrule_folder.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 4.0.3 on 2022-03-28 17:40 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0008_auto_20210516_0940"), - ] - - operations = [ - migrations.AlterField( - model_name="mailrule", - name="action", - field=models.PositiveIntegerField( - choices=[ - (1, "Mark as read, don't process read mails"), - (2, "Flag the mail, don't process flagged mails"), - (3, "Move to specified folder"), - (4, "Delete"), - ], - default=3, - verbose_name="action", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="folder", - field=models.CharField( - default="INBOX", - help_text="Subfolders must be separated by a delimiter, often a dot ('.') or slash ('/'), but it varies by mail server.", - max_length=256, - verbose_name="folder", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0009_mailrule_assign_tags.py b/src/paperless_mail/migrations/0009_mailrule_assign_tags.py deleted file mode 100644 index fbe359814..000000000 --- a/src/paperless_mail/migrations/0009_mailrule_assign_tags.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.12 on 2022-03-11 15:00 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0008_auto_20210516_0940"), - ] - - operations = [ - migrations.AddField( - model_name="mailrule", - name="assign_tags", - field=models.ManyToManyField( - blank=True, - related_name="mail_rules_multi", - to="documents.Tag", - verbose_name="assign this tag", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0010_auto_20220311_1602.py b/src/paperless_mail/migrations/0010_auto_20220311_1602.py deleted file mode 100644 index 0511608ca..000000000 --- a/src/paperless_mail/migrations/0010_auto_20220311_1602.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 3.2.12 on 2022-03-11 15:02 - -from django.db import migrations - - -def migrate_tag_to_tags(apps, schema_editor): - # Manual data migration, see - # https://docs.djangoproject.com/en/3.2/topics/migrations/#data-migrations - # - # Copy the assign_tag property to the new assign_tags set if it exists. - MailRule = apps.get_model("paperless_mail", "MailRule") - for mail_rule in MailRule.objects.all(): - if mail_rule.assign_tag: - mail_rule.assign_tags.add(mail_rule.assign_tag) - mail_rule.save() - - -def migrate_tags_to_tag(apps, schema_editor): - # Manual data migration, see - # https://docs.djangoproject.com/en/3.2/topics/migrations/#data-migrations - # - # Copy the unique value in the assign_tags set to the old assign_tag property. - # Do nothing if the tag is not unique. - MailRule = apps.get_model("paperless_mail", "MailRule") - for mail_rule in MailRule.objects.all(): - tags = mail_rule.assign_tags.all() - if len(tags) == 1: - mail_rule.assign_tag = tags[0] - mail_rule.save() - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0009_mailrule_assign_tags"), - ] - - operations = [ - migrations.RunPython(migrate_tag_to_tags, migrate_tags_to_tag), - ] diff --git a/src/paperless_mail/migrations/0011_remove_mailrule_assign_tag_squashed_0024_alter_mailrule_name_and_more.py b/src/paperless_mail/migrations/0011_remove_mailrule_assign_tag_squashed_0024_alter_mailrule_name_and_more.py deleted file mode 100644 index c48ebf33b..000000000 --- a/src/paperless_mail/migrations/0011_remove_mailrule_assign_tag_squashed_0024_alter_mailrule_name_and_more.py +++ /dev/null @@ -1,321 +0,0 @@ -# Generated by Django 4.2.13 on 2024-06-28 17:47 - -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - replaces = [ - ("paperless_mail", "0011_remove_mailrule_assign_tag"), - ("paperless_mail", "0012_alter_mailrule_assign_tags"), - ("paperless_mail", "0009_alter_mailrule_action_alter_mailrule_folder"), - ("paperless_mail", "0013_merge_20220412_1051"), - ("paperless_mail", "0014_alter_mailrule_action"), - ("paperless_mail", "0015_alter_mailrule_action"), - ("paperless_mail", "0016_mailrule_consumption_scope"), - ("paperless_mail", "0017_mailaccount_owner_mailrule_owner"), - ("paperless_mail", "0018_processedmail"), - ("paperless_mail", "0019_mailrule_filter_to"), - ("paperless_mail", "0020_mailaccount_is_token"), - ("paperless_mail", "0021_alter_mailaccount_password"), - ("paperless_mail", "0022_mailrule_assign_owner_from_rule_and_more"), - ("paperless_mail", "0023_remove_mailrule_filter_attachment_filename_and_more"), - ("paperless_mail", "0024_alter_mailrule_name_and_more"), - ] - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("paperless_mail", "0010_auto_20220311_1602"), - ] - - operations = [ - migrations.RemoveField( - model_name="mailrule", - name="assign_tag", - ), - migrations.AlterField( - model_name="mailrule", - name="assign_tags", - field=models.ManyToManyField( - blank=True, - to="documents.tag", - verbose_name="assign this tag", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="action", - field=models.PositiveIntegerField( - choices=[ - (1, "Mark as read, don't process read mails"), - (2, "Flag the mail, don't process flagged mails"), - (3, "Move to specified folder"), - (4, "Delete"), - ], - default=3, - verbose_name="action", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="folder", - field=models.CharField( - default="INBOX", - help_text="Subfolders must be separated by a delimiter, often a dot ('.') or slash ('/'), but it varies by mail server.", - max_length=256, - verbose_name="folder", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="action", - field=models.PositiveIntegerField( - choices=[ - (1, "Delete"), - (2, "Move to specified folder"), - (3, "Mark as read, don't process read mails"), - (4, "Flag the mail, don't process flagged mails"), - ], - default=3, - verbose_name="action", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="action", - field=models.PositiveIntegerField( - choices=[ - (1, "Delete"), - (2, "Move to specified folder"), - (3, "Mark as read, don't process read mails"), - (4, "Flag the mail, don't process flagged mails"), - (5, "Tag the mail with specified tag, don't process tagged mails"), - ], - default=3, - verbose_name="action", - ), - ), - migrations.AddField( - model_name="mailrule", - name="consumption_scope", - field=models.PositiveIntegerField( - choices=[ - (1, "Only process attachments."), - ( - 2, - "Process full Mail (with embedded attachments in file) as .eml", - ), - ( - 3, - "Process full Mail (with embedded attachments in file) as .eml + process attachments as separate documents", - ), - ], - default=1, - verbose_name="consumption scope", - ), - ), - migrations.AddField( - model_name="mailaccount", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="mailrule", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.CreateModel( - name="ProcessedMail", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "folder", - models.CharField( - editable=False, - max_length=256, - verbose_name="folder", - ), - ), - ( - "uid", - models.CharField( - editable=False, - max_length=256, - verbose_name="uid", - ), - ), - ( - "subject", - models.CharField( - editable=False, - max_length=256, - verbose_name="subject", - ), - ), - ( - "received", - models.DateTimeField(editable=False, verbose_name="received"), - ), - ( - "processed", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="processed", - ), - ), - ( - "status", - models.CharField( - editable=False, - max_length=256, - verbose_name="status", - ), - ), - ( - "error", - models.TextField( - blank=True, - editable=False, - null=True, - verbose_name="error", - ), - ), - ( - "owner", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - ( - "rule", - models.ForeignKey( - editable=False, - on_delete=django.db.models.deletion.CASCADE, - to="paperless_mail.mailrule", - ), - ), - ], - options={ - "abstract": False, - }, - ), - migrations.AddField( - model_name="mailrule", - name="filter_to", - field=models.CharField( - blank=True, - max_length=256, - null=True, - verbose_name="filter to", - ), - ), - migrations.AddField( - model_name="mailaccount", - name="is_token", - field=models.BooleanField( - default=False, - verbose_name="Is token authentication", - ), - ), - migrations.AlterField( - model_name="mailaccount", - name="password", - field=models.CharField(max_length=2048, verbose_name="password"), - ), - migrations.AddField( - model_name="mailrule", - name="assign_owner_from_rule", - field=models.BooleanField( - default=True, - verbose_name="Assign the rule owner to documents", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_title_from", - field=models.PositiveIntegerField( - choices=[ - (1, "Use subject as title"), - (2, "Use attachment filename as title"), - (3, "Do not assign title from rule"), - ], - default=1, - verbose_name="assign title from", - ), - ), - migrations.RenameField( - model_name="mailrule", - old_name="filter_attachment_filename", - new_name="filter_attachment_filename_include", - ), - migrations.AddField( - model_name="mailrule", - name="filter_attachment_filename_exclude", - field=models.CharField( - blank=True, - help_text="Do not consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", - max_length=256, - null=True, - verbose_name="filter attachment filename exclusive", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="filter_attachment_filename_include", - field=models.CharField( - blank=True, - help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", - max_length=256, - null=True, - verbose_name="filter attachment filename inclusive", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="name", - field=models.CharField(max_length=256, verbose_name="name"), - ), - migrations.AddConstraint( - model_name="mailrule", - constraint=models.UniqueConstraint( - fields=("name", "owner"), - name="paperless_mail_mailrule_unique_name_owner", - ), - ), - migrations.AddConstraint( - model_name="mailrule", - constraint=models.UniqueConstraint( - condition=models.Q(("owner__isnull", True)), - fields=("name",), - name="paperless_mail_mailrule_name_unique", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0012_alter_mailrule_assign_tags.py b/src/paperless_mail/migrations/0012_alter_mailrule_assign_tags.py deleted file mode 100644 index 83ece3bba..000000000 --- a/src/paperless_mail/migrations/0012_alter_mailrule_assign_tags.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.2.12 on 2022-03-11 16:21 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0011_remove_mailrule_assign_tag"), - ] - - operations = [ - migrations.AlterField( - model_name="mailrule", - name="assign_tags", - field=models.ManyToManyField( - blank=True, - to="documents.Tag", - verbose_name="assign this tag", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0013_merge_20220412_1051.py b/src/paperless_mail/migrations/0013_merge_20220412_1051.py deleted file mode 100644 index 0310fd083..000000000 --- a/src/paperless_mail/migrations/0013_merge_20220412_1051.py +++ /dev/null @@ -1,12 +0,0 @@ -# Generated by Django 4.0.4 on 2022-04-12 08:51 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0009_alter_mailrule_action_alter_mailrule_folder"), - ("paperless_mail", "0012_alter_mailrule_assign_tags"), - ] - - operations = [] diff --git a/src/paperless_mail/migrations/0014_alter_mailrule_action.py b/src/paperless_mail/migrations/0014_alter_mailrule_action.py deleted file mode 100644 index 6be3ddf69..000000000 --- a/src/paperless_mail/migrations/0014_alter_mailrule_action.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 4.0.4 on 2022-04-18 22:57 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0013_merge_20220412_1051"), - ] - - operations = [ - migrations.AlterField( - model_name="mailrule", - name="action", - field=models.PositiveIntegerField( - choices=[ - (1, "Delete"), - (2, "Move to specified folder"), - (3, "Mark as read, don't process read mails"), - (4, "Flag the mail, don't process flagged mails"), - ], - default=3, - verbose_name="action", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0015_alter_mailrule_action.py b/src/paperless_mail/migrations/0015_alter_mailrule_action.py deleted file mode 100644 index 80de9b2b1..000000000 --- a/src/paperless_mail/migrations/0015_alter_mailrule_action.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.0.4 on 2022-05-29 13:21 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0014_alter_mailrule_action"), - ] - - operations = [ - migrations.AlterField( - model_name="mailrule", - name="action", - field=models.PositiveIntegerField( - choices=[ - (1, "Delete"), - (2, "Move to specified folder"), - (3, "Mark as read, don't process read mails"), - (4, "Flag the mail, don't process flagged mails"), - (5, "Tag the mail with specified tag, don't process tagged mails"), - ], - default=3, - verbose_name="action", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0016_mailrule_consumption_scope.py b/src/paperless_mail/migrations/0016_mailrule_consumption_scope.py deleted file mode 100644 index d4a0ba590..000000000 --- a/src/paperless_mail/migrations/0016_mailrule_consumption_scope.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 4.0.4 on 2022-07-11 22:02 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0015_alter_mailrule_action"), - ] - - operations = [ - migrations.AddField( - model_name="mailrule", - name="consumption_scope", - field=models.PositiveIntegerField( - choices=[ - (1, "Only process attachments."), - ( - 2, - "Process full Mail (with embedded attachments in file) as .eml", - ), - ( - 3, - "Process full Mail (with embedded attachments in file) as .eml + process attachments as separate documents", - ), - ], - default=1, - verbose_name="consumption scope", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0017_mailaccount_owner_mailrule_owner.py b/src/paperless_mail/migrations/0017_mailaccount_owner_mailrule_owner.py deleted file mode 100644 index 98cfef014..000000000 --- a/src/paperless_mail/migrations/0017_mailaccount_owner_mailrule_owner.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 4.1.3 on 2022-12-06 04:48 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("paperless_mail", "0016_mailrule_consumption_scope"), - ] - - operations = [ - migrations.AddField( - model_name="mailaccount", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AddField( - model_name="mailrule", - name="owner", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0018_processedmail.py b/src/paperless_mail/migrations/0018_processedmail.py deleted file mode 100644 index 3307f7579..000000000 --- a/src/paperless_mail/migrations/0018_processedmail.py +++ /dev/null @@ -1,105 +0,0 @@ -# Generated by Django 4.1.5 on 2023-03-03 18:38 - -import django.db.models.deletion -import django.utils.timezone -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("paperless_mail", "0017_mailaccount_owner_mailrule_owner"), - ] - - operations = [ - migrations.CreateModel( - name="ProcessedMail", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "folder", - models.CharField( - editable=False, - max_length=256, - verbose_name="folder", - ), - ), - ( - "uid", - models.CharField( - editable=False, - max_length=256, - verbose_name="uid", - ), - ), - ( - "subject", - models.CharField( - editable=False, - max_length=256, - verbose_name="subject", - ), - ), - ( - "received", - models.DateTimeField(editable=False, verbose_name="received"), - ), - ( - "processed", - models.DateTimeField( - default=django.utils.timezone.now, - editable=False, - verbose_name="processed", - ), - ), - ( - "status", - models.CharField( - editable=False, - max_length=256, - verbose_name="status", - ), - ), - ( - "error", - models.TextField( - blank=True, - editable=False, - null=True, - verbose_name="error", - ), - ), - ( - "owner", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - ( - "rule", - models.ForeignKey( - editable=False, - on_delete=django.db.models.deletion.CASCADE, - to="paperless_mail.mailrule", - ), - ), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/src/paperless_mail/migrations/0019_mailrule_filter_to.py b/src/paperless_mail/migrations/0019_mailrule_filter_to.py deleted file mode 100644 index 8951be290..000000000 --- a/src/paperless_mail/migrations/0019_mailrule_filter_to.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.1.7 on 2023-03-11 21:08 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0018_processedmail"), - ] - - operations = [ - migrations.AddField( - model_name="mailrule", - name="filter_to", - field=models.CharField( - blank=True, - max_length=256, - null=True, - verbose_name="filter to", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0020_mailaccount_is_token.py b/src/paperless_mail/migrations/0020_mailaccount_is_token.py deleted file mode 100644 index 81ce50a19..000000000 --- a/src/paperless_mail/migrations/0020_mailaccount_is_token.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.1.7 on 2023-03-22 17:51 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0019_mailrule_filter_to"), - ] - - operations = [ - migrations.AddField( - model_name="mailaccount", - name="is_token", - field=models.BooleanField( - default=False, - verbose_name="Is token authentication", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0021_alter_mailaccount_password.py b/src/paperless_mail/migrations/0021_alter_mailaccount_password.py deleted file mode 100644 index 0c012b98b..000000000 --- a/src/paperless_mail/migrations/0021_alter_mailaccount_password.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.7 on 2023-04-20 15:03 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0020_mailaccount_is_token"), - ] - - operations = [ - migrations.AlterField( - model_name="mailaccount", - name="password", - field=models.CharField(max_length=2048, verbose_name="password"), - ), - ] diff --git a/src/paperless_mail/migrations/0022_mailrule_assign_owner_from_rule_and_more.py b/src/paperless_mail/migrations/0022_mailrule_assign_owner_from_rule_and_more.py deleted file mode 100644 index f2c59a5bf..000000000 --- a/src/paperless_mail/migrations/0022_mailrule_assign_owner_from_rule_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 4.1.11 on 2023-09-18 18:50 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0021_alter_mailaccount_password"), - ] - - operations = [ - migrations.AddField( - model_name="mailrule", - name="assign_owner_from_rule", - field=models.BooleanField( - default=True, - verbose_name="Assign the rule owner to documents", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="assign_title_from", - field=models.PositiveIntegerField( - choices=[ - (1, "Use subject as title"), - (2, "Use attachment filename as title"), - (3, "Do not assign title from rule"), - ], - default=1, - verbose_name="assign title from", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0023_remove_mailrule_filter_attachment_filename_and_more.py b/src/paperless_mail/migrations/0023_remove_mailrule_filter_attachment_filename_and_more.py deleted file mode 100644 index 1a1eac790..000000000 --- a/src/paperless_mail/migrations/0023_remove_mailrule_filter_attachment_filename_and_more.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 4.2.7 on 2023-12-04 03:06 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0022_mailrule_assign_owner_from_rule_and_more"), - ] - - operations = [ - migrations.RenameField( - model_name="mailrule", - old_name="filter_attachment_filename", - new_name="filter_attachment_filename_include", - ), - migrations.AddField( - model_name="mailrule", - name="filter_attachment_filename_exclude", - field=models.CharField( - blank=True, - help_text="Do not consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", - max_length=256, - null=True, - verbose_name="filter attachment filename exclusive", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="filter_attachment_filename_include", - field=models.CharField( - blank=True, - help_text="Only consume documents which entirely match this filename if specified. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive.", - max_length=256, - null=True, - verbose_name="filter attachment filename inclusive", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0024_alter_mailrule_name_and_more.py b/src/paperless_mail/migrations/0024_alter_mailrule_name_and_more.py deleted file mode 100644 index c2840d0e4..000000000 --- a/src/paperless_mail/migrations/0024_alter_mailrule_name_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 4.2.11 on 2024-06-05 16:51 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0023_remove_mailrule_filter_attachment_filename_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="mailrule", - name="name", - field=models.CharField(max_length=256, verbose_name="name"), - ), - migrations.AddConstraint( - model_name="mailrule", - constraint=models.UniqueConstraint( - fields=("name", "owner"), - name="paperless_mail_mailrule_unique_name_owner", - ), - ), - migrations.AddConstraint( - model_name="mailrule", - constraint=models.UniqueConstraint( - condition=models.Q(("owner__isnull", True)), - fields=("name",), - name="paperless_mail_mailrule_name_unique", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0025_alter_mailaccount_owner_alter_mailrule_owner_and_more.py b/src/paperless_mail/migrations/0025_alter_mailaccount_owner_alter_mailrule_owner_and_more.py deleted file mode 100644 index 308ebdf15..000000000 --- a/src/paperless_mail/migrations/0025_alter_mailaccount_owner_alter_mailrule_owner_and_more.py +++ /dev/null @@ -1,52 +0,0 @@ -# Generated by Django 4.2.13 on 2024-07-09 16:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("paperless_mail", "0024_alter_mailrule_name_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="mailaccount", - name="owner", - field=models.ForeignKey( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AlterField( - model_name="mailrule", - name="owner", - field=models.ForeignKey( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - migrations.AlterField( - model_name="processedmail", - name="owner", - field=models.ForeignKey( - blank=True, - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - verbose_name="owner", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0026_mailrule_enabled.py b/src/paperless_mail/migrations/0026_mailrule_enabled.py deleted file mode 100644 index c10ee698c..000000000 --- a/src/paperless_mail/migrations/0026_mailrule_enabled.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.1.1 on 2024-09-30 15:17 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "paperless_mail", - "0025_alter_mailaccount_owner_alter_mailrule_owner_and_more", - ), - ] - - operations = [ - migrations.AddField( - model_name="mailrule", - name="enabled", - field=models.BooleanField(default=True, verbose_name="enabled"), - ), - ] diff --git a/src/paperless_mail/migrations/0027_mailaccount_expiration_mailaccount_account_type_and_more.py b/src/paperless_mail/migrations/0027_mailaccount_expiration_mailaccount_account_type_and_more.py deleted file mode 100644 index 3fb1e6af2..000000000 --- a/src/paperless_mail/migrations/0027_mailaccount_expiration_mailaccount_account_type_and_more.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-05 17:12 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0026_mailrule_enabled"), - ] - - operations = [ - migrations.AlterField( - model_name="mailaccount", - name="password", - field=models.CharField(max_length=3072, verbose_name="password"), - ), - migrations.AddField( - model_name="mailaccount", - name="expiration", - field=models.DateTimeField( - blank=True, - help_text="The expiration date of the refresh token. ", - null=True, - verbose_name="expiration", - ), - ), - migrations.AddField( - model_name="mailaccount", - name="account_type", - field=models.PositiveIntegerField( - choices=[(1, "IMAP"), (2, "Gmail OAuth"), (3, "Outlook OAuth")], - default=1, - verbose_name="account type", - ), - ), - migrations.AddField( - model_name="mailaccount", - name="refresh_token", - field=models.CharField( - blank=True, - help_text="The refresh token to use for token authentication e.g. with oauth2.", - max_length=3072, - null=True, - verbose_name="refresh token", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0028_alter_mailaccount_password_and_more.py b/src/paperless_mail/migrations/0028_alter_mailaccount_password_and_more.py deleted file mode 100644 index 2a0279651..000000000 --- a/src/paperless_mail/migrations/0028_alter_mailaccount_password_and_more.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-30 04:31 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ( - "paperless_mail", - "0027_mailaccount_expiration_mailaccount_account_type_and_more", - ), - ] - - operations = [ - migrations.AlterField( - model_name="mailaccount", - name="password", - field=models.TextField(verbose_name="password"), - ), - migrations.AlterField( - model_name="mailaccount", - name="refresh_token", - field=models.TextField( - blank=True, - help_text="The refresh token to use for token authentication e.g. with oauth2.", - null=True, - verbose_name="refresh token", - ), - ), - ] diff --git a/src/paperless_mail/migrations/0029_mailrule_pdf_layout.py b/src/paperless_mail/migrations/0029_mailrule_pdf_layout.py deleted file mode 100644 index fe7a93b71..000000000 --- a/src/paperless_mail/migrations/0029_mailrule_pdf_layout.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.3 on 2024-11-24 12:39 - -from django.db import migrations -from django.db import models - - -class Migration(migrations.Migration): - dependencies = [ - ("paperless_mail", "0028_alter_mailaccount_password_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="mailrule", - name="pdf_layout", - field=models.PositiveIntegerField( - choices=[ - (0, "System default"), - (1, "Text, then HTML"), - (2, "HTML, then text"), - (3, "HTML only"), - (4, "Text only"), - ], - default=0, - verbose_name="pdf layout", - ), - ), - ] diff --git a/src/paperless_mail/tests/test_preprocessor.py b/src/paperless_mail/tests/test_preprocessor.py index 90df77ba8..2ad9410f9 100644 --- a/src/paperless_mail/tests/test_preprocessor.py +++ b/src/paperless_mail/tests/test_preprocessor.py @@ -1,5 +1,7 @@ import email import email.contentmanager +import shutil +import subprocess import tempfile from email.message import Message from email.mime.application import MIMEApplication @@ -34,6 +36,30 @@ class MessageEncryptor: ) self.gpg.gen_key(input_data) + def cleanup(self) -> None: + """ + Kill the gpg-agent process and clean up the temporary GPG home directory. + + This uses gpgconf to properly terminate the agent, which is the officially + recommended cleanup method from the GnuPG project. python-gnupg does not + provide built-in cleanup methods as it's only a wrapper around the gpg CLI. + """ + # Kill the gpg-agent using the official GnuPG cleanup tool + try: + subprocess.run( + ["gpgconf", "--kill", "gpg-agent"], + env={"GNUPGHOME": self.gpg_home}, + check=False, + capture_output=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + # gpgconf not found or hung - agent will timeout eventually + pass + + # Clean up the temporary directory + shutil.rmtree(self.gpg_home, ignore_errors=True) + @staticmethod def get_email_body_without_headers(email_message: Message) -> bytes: """ @@ -85,8 +111,20 @@ class MessageEncryptor: class TestMailMessageGpgDecryptor(TestMail): + @classmethod + def setUpClass(cls): + """Create GPG encryptor once for all tests in this class.""" + super().setUpClass() + cls.messageEncryptor = MessageEncryptor() + + @classmethod + def tearDownClass(cls): + """Clean up GPG resources after all tests complete.""" + if hasattr(cls, "messageEncryptor"): + cls.messageEncryptor.cleanup() + super().tearDownClass() + def setUp(self): - self.messageEncryptor = MessageEncryptor() with override_settings( EMAIL_GNUPG_HOME=self.messageEncryptor.gpg_home, EMAIL_ENABLE_GPG_DECRYPTOR=True, @@ -138,13 +176,28 @@ class TestMailMessageGpgDecryptor(TestMail): def test_decrypt_fails(self): encrypted_message, _ = self.create_encrypted_unencrypted_message_pair() + # This test creates its own empty GPG home to test decryption failure empty_gpg_home = tempfile.mkdtemp() - with override_settings( - EMAIL_ENABLE_GPG_DECRYPTOR=True, - EMAIL_GNUPG_HOME=empty_gpg_home, - ): - message_decryptor = MailMessageDecryptor() - self.assertRaises(Exception, message_decryptor.run, encrypted_message) + try: + with override_settings( + EMAIL_ENABLE_GPG_DECRYPTOR=True, + EMAIL_GNUPG_HOME=empty_gpg_home, + ): + message_decryptor = MailMessageDecryptor() + self.assertRaises(Exception, message_decryptor.run, encrypted_message) + finally: + # Clean up the temporary GPG home used only by this test + try: + subprocess.run( + ["gpgconf", "--kill", "gpg-agent"], + env={"GNUPGHOME": empty_gpg_home}, + check=False, + capture_output=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + shutil.rmtree(empty_gpg_home, ignore_errors=True) def test_decrypt_encrypted_mail(self): """ diff --git a/uv.lock b/uv.lock index c621b203d..da7c721f5 100644 --- a/uv.lock +++ b/uv.lock @@ -2,11 +2,13 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version >= '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'linux'", "python_full_version < '3.11' and sys_platform == 'linux'", ] supported-markers = [ @@ -14,6 +16,145 @@ supported-markers = [ "sys_platform == 'linux'", ] +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "aiosignal", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "async-timeout", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, + { name = "attrs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "yarl", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d6/5aec9313ee6ea9c7cde8b891b69f4ff4001416867104580670a31daeba5b/aiohttp-3.13.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a372fd5afd301b3a89582817fdcdb6c34124787c70dbcc616f259013e7eef7", size = 738950, upload-time = "2026-01-03T17:29:13.002Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/8fa90a7e6d11ff20a18837a8e2b5dd23db01aabc475aa9271c8ad33299f5/aiohttp-3.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:147e422fd1223005c22b4fe080f5d93ced44460f5f9c105406b753612b587821", size = 496099, upload-time = "2026-01-03T17:29:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/b81f744d402510a8366b74eb420fc0cc1170d0c43daca12d10814df85f10/aiohttp-3.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:859bd3f2156e81dd01432f5849fc73e2243d4a487c4fd26609b1299534ee1845", size = 491072, upload-time = "2026-01-03T17:29:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e1/56d1d1c0dd334cd203dd97706ce004c1aa24b34a813b0b8daf3383039706/aiohttp-3.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dca68018bf48c251ba17c72ed479f4dafe9dbd5a73707ad8d28a38d11f3d42af", size = 1671588, upload-time = "2026-01-03T17:29:18.539Z" }, + { url = "https://files.pythonhosted.org/packages/5f/34/8d7f962604f4bc2b4e39eb1220dac7d4e4cba91fb9ba0474b4ecd67db165/aiohttp-3.13.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fee0c6bc7db1de362252affec009707a17478a00ec69f797d23ca256e36d5940", size = 1640334, upload-time = "2026-01-03T17:29:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/94/1d/fcccf2c668d87337ddeef9881537baee13c58d8f01f12ba8a24215f2b804/aiohttp-3.13.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c048058117fd649334d81b4b526e94bde3ccaddb20463a815ced6ecbb7d11160", size = 1722656, upload-time = "2026-01-03T17:29:22.531Z" }, + { url = "https://files.pythonhosted.org/packages/aa/98/c6f3b081c4c606bc1e5f2ec102e87d6411c73a9ef3616fea6f2d5c98c062/aiohttp-3.13.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:215a685b6fbbfcf71dfe96e3eba7a6f58f10da1dfdf4889c7dd856abe430dca7", size = 1817625, upload-time = "2026-01-03T17:29:24.276Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c0/cfcc3d2e11b477f86e1af2863f3858c8850d751ce8dc39c4058a072c9e54/aiohttp-3.13.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2c184bb1fe2cbd2cefba613e9db29a5ab559323f994b6737e370d3da0ac455", size = 1672604, upload-time = "2026-01-03T17:29:26.099Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/6b4ffcbcac4c6a5d041343a756f34a6dd26174ae07f977a64fe028dda5b0/aiohttp-3.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75ca857eba4e20ce9f546cd59c7007b33906a4cd48f2ff6ccf1ccfc3b646f279", size = 1554370, upload-time = "2026-01-03T17:29:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f0/e3ddfa93f17d689dbe014ba048f18e0c9f9b456033b70e94349a2e9048be/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81e97251d9298386c2b7dbeb490d3d1badbdc69107fb8c9299dd04eb39bddc0e", size = 1642023, upload-time = "2026-01-03T17:29:30.002Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/c14019c9ec60a8e243d06d601b33dcc4fd92379424bde3021725859d7f99/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0e2d366af265797506f0283487223146af57815b388623f0357ef7eac9b209d", size = 1649680, upload-time = "2026-01-03T17:29:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fd/09c9451dae5aa5c5ed756df95ff9ef549d45d4be663bafd1e4954fd836f0/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4e239d501f73d6db1522599e14b9b321a7e3b1de66ce33d53a765d975e9f4808", size = 1692407, upload-time = "2026-01-03T17:29:33.392Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/938bc2ec33c10efd6637ccb3d22f9f3160d08e8f3aa2587a2c2d5ab578eb/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0db318f7a6f065d84cb1e02662c526294450b314a02bd9e2a8e67f0d8564ce40", size = 1543047, upload-time = "2026-01-03T17:29:34.855Z" }, + { url = "https://files.pythonhosted.org/packages/f7/23/80488ee21c8d567c83045e412e1d9b7077d27171591a4eb7822586e8c06a/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bfc1cc2fe31a6026a8a88e4ecfb98d7f6b1fec150cfd708adbfd1d2f42257c29", size = 1715264, upload-time = "2026-01-03T17:29:36.389Z" }, + { url = "https://files.pythonhosted.org/packages/e2/83/259a8da6683182768200b368120ab3deff5370bed93880fb9a3a86299f34/aiohttp-3.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af71fff7bac6bb7508956696dce8f6eec2bbb045eceb40343944b1ae62b5ef11", size = 1657275, upload-time = "2026-01-03T17:29:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "amqp" version = "5.3.1" @@ -26,6 +167,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.11.0" @@ -104,23 +254,22 @@ dependencies = [ { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/7b/8115cd713e2caa5e44def85f2b7ebd02a74ae74d7113ba20bdd41fd6dd80/azure_ai_documentintelligence-1.0.2.tar.gz", hash = "sha256:4d75a2513f2839365ebabc0e0e1772f5601b3a8c9a71e75da12440da13b63484", size = 170940 } +sdist = { url = "https://files.pythonhosted.org/packages/44/7b/8115cd713e2caa5e44def85f2b7ebd02a74ae74d7113ba20bdd41fd6dd80/azure_ai_documentintelligence-1.0.2.tar.gz", hash = "sha256:4d75a2513f2839365ebabc0e0e1772f5601b3a8c9a71e75da12440da13b63484", size = 170940, upload-time = "2025-03-27T02:46:20.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl", hash = "sha256:e1fb446abbdeccc9759d897898a0fe13141ed29f9ad11fc705f951925822ed59", size = 106005 }, + { url = "https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl", hash = "sha256:e1fb446abbdeccc9759d897898a0fe13141ed29f9ad11fc705f951925822ed59", size = 106005, upload-time = "2025-03-27T02:46:22.356Z" }, ] [[package]] name = "azure-core" -version = "1.33.0" +version = "1.38.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "six", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/aa/7c9db8edd626f1a7d99d09ef7926f6f4fb34d5f9fa00dc394afdfe8e2a80/azure_core-1.33.0.tar.gz", hash = "sha256:f367aa07b5e3005fec2c1e184b882b0b039910733907d001c20fb08ebb8c0eb9", size = 295633 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/b7/76b7e144aa53bd206bf1ce34fa75350472c3f69bf30e5c8c18bc9881035d/azure_core-1.33.0-py3-none-any.whl", hash = "sha256:9b5b6d0223a1d38c37500e6971118c1e0f13f54951e6893968b38910bc9cda8f", size = 207071 }, + { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" }, ] [[package]] @@ -146,6 +295,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, ] +[[package]] +name = "banks" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/f8/25ef24814f77f3fd7f0fd3bd1ef3749e38a9dbd23502fbb53034de49900c/banks-2.2.0.tar.gz", hash = "sha256:d1446280ce6e00301e3e952dd754fd8cee23ff277d29ed160994a84d0d7ffe62", size = 179052, upload-time = "2025-07-18T16:28:26.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/d6/f9168956276934162ec8d48232f9920f2985ee45aa7602e3c6b4bc203613/banks-2.2.0-py3-none-any.whl", hash = "sha256:963cd5c85a587b122abde4f4064078def35c50c688c1b9d36f43c92503854e7d", size = 29244, upload-time = "2025-07-18T16:28:27.835Z" }, +] + [[package]] name = "billiard" version = "4.2.2" @@ -169,64 +334,50 @@ wheels = [ [[package]] name = "brotli" -version = "1.1.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2f/c2/f9e977608bdf958650638c3f1e28f85a1b075f075ebbe77db8555463787b/Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724", size = 7372270, upload-time = "2023-09-07T14:05:41.643Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/3a/dbf4fb970c1019a57b5e492e1e0eae745d32e59ba4d6161ab5422b08eefe/Brotli-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e1140c64812cb9b06c922e77f1c26a75ec5e3f0fb2bf92cc8c58720dec276752", size = 873045, upload-time = "2023-09-07T14:03:16.894Z" }, - { url = "https://files.pythonhosted.org/packages/dd/11/afc14026ea7f44bd6eb9316d800d439d092c8d508752055ce8d03086079a/Brotli-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8fd5270e906eef71d4a8d19b7c6a43760c6abcfcc10c9101d14eb2357418de9", size = 446218, upload-time = "2023-09-07T14:03:18.917Z" }, - { url = "https://files.pythonhosted.org/packages/36/83/7545a6e7729db43cb36c4287ae388d6885c85a86dd251768a47015dfde32/Brotli-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ae56aca0402a0f9a3431cddda62ad71666ca9d4dc3a10a142b9dce2e3c0cda3", size = 2903872, upload-time = "2023-09-07T14:03:20.398Z" }, - { url = "https://files.pythonhosted.org/packages/32/23/35331c4d9391fcc0f29fd9bec2c76e4b4eeab769afbc4b11dd2e1098fb13/Brotli-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43ce1b9935bfa1ede40028054d7f48b5469cd02733a365eec8a329ffd342915d", size = 2941254, upload-time = "2023-09-07T14:03:21.914Z" }, - { url = "https://files.pythonhosted.org/packages/3b/24/1671acb450c902edb64bd765d73603797c6c7280a9ada85a195f6b78c6e5/Brotli-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c4855522edb2e6ae7fdb58e07c3ba9111e7621a8956f481c68d5d979c93032e", size = 2857293, upload-time = "2023-09-07T14:03:24Z" }, - { url = "https://files.pythonhosted.org/packages/d5/00/40f760cc27007912b327fe15bf6bfd8eaecbe451687f72a8abc587d503b3/Brotli-1.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:38025d9f30cf4634f8309c6874ef871b841eb3c347e90b0851f63d1ded5212da", size = 3002385, upload-time = "2023-09-07T14:03:26.248Z" }, - { url = "https://files.pythonhosted.org/packages/b8/cb/8aaa83f7a4caa131757668c0fb0c4b6384b09ffa77f2fba9570d87ab587d/Brotli-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e6a904cb26bfefc2f0a6f240bdf5233be78cd2488900a2f846f3c3ac8489ab80", size = 2911104, upload-time = "2023-09-07T14:03:27.849Z" }, - { url = "https://files.pythonhosted.org/packages/bc/c4/65456561d89d3c49f46b7fbeb8fe6e449f13bdc8ea7791832c5d476b2faf/Brotli-1.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a37b8f0391212d29b3a91a799c8e4a2855e0576911cdfb2515487e30e322253d", size = 2809981, upload-time = "2023-09-07T14:03:29.92Z" }, - { url = "https://files.pythonhosted.org/packages/05/1b/cf49528437bae28abce5f6e059f0d0be6fecdcc1d3e33e7c54b3ca498425/Brotli-1.1.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:e84799f09591700a4154154cab9787452925578841a94321d5ee8fb9a9a328f0", size = 2935297, upload-time = "2023-09-07T14:03:32.035Z" }, - { url = "https://files.pythonhosted.org/packages/81/ff/190d4af610680bf0c5a09eb5d1eac6e99c7c8e216440f9c7cfd42b7adab5/Brotli-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f66b5337fa213f1da0d9000bc8dc0cb5b896b726eefd9c6046f699b169c41b9e", size = 2930735, upload-time = "2023-09-07T14:03:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/80/7d/f1abbc0c98f6e09abd3cad63ec34af17abc4c44f308a7a539010f79aae7a/Brotli-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5dab0844f2cf82be357a0eb11a9087f70c5430b2c241493fc122bb6f2bb0917c", size = 2933107, upload-time = "2024-10-18T12:32:09.016Z" }, - { url = "https://files.pythonhosted.org/packages/34/ce/5a5020ba48f2b5a4ad1c0522d095ad5847a0be508e7d7569c8630ce25062/Brotli-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e4fe605b917c70283db7dfe5ada75e04561479075761a0b3866c081d035b01c1", size = 2845400, upload-time = "2024-10-18T12:32:11.134Z" }, - { url = "https://files.pythonhosted.org/packages/44/89/fa2c4355ab1eecf3994e5a0a7f5492c6ff81dfcb5f9ba7859bd534bb5c1a/Brotli-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1e9a65b5736232e7a7f91ff3d02277f11d339bf34099a56cdab6a8b3410a02b2", size = 3031985, upload-time = "2024-10-18T12:32:12.813Z" }, - { url = "https://files.pythonhosted.org/packages/af/a4/79196b4a1674143d19dca400866b1a4d1a089040df7b93b88ebae81f3447/Brotli-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:58d4b711689366d4a03ac7957ab8c28890415e267f9b6589969e74b6e42225ec", size = 2927099, upload-time = "2024-10-18T12:32:14.733Z" }, - { url = "https://files.pythonhosted.org/packages/96/12/ad41e7fadd5db55459c4c401842b47f7fee51068f86dd2894dd0dcfc2d2a/Brotli-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3daabb76a78f829cafc365531c972016e4aa8d5b4bf60660ad8ecee19df7ccc", size = 873068, upload-time = "2023-09-07T14:03:37.779Z" }, - { url = "https://files.pythonhosted.org/packages/95/4e/5afab7b2b4b61a84e9c75b17814198ce515343a44e2ed4488fac314cd0a9/Brotli-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c8146669223164fc87a7e3de9f81e9423c67a79d6b3447994dfb9c95da16e2d6", size = 446244, upload-time = "2023-09-07T14:03:39.223Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e6/f305eb61fb9a8580c525478a4a34c5ae1a9bcb12c3aee619114940bc513d/Brotli-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30924eb4c57903d5a7526b08ef4a584acc22ab1ffa085faceb521521d2de32dd", size = 2906500, upload-time = "2023-09-07T14:03:40.858Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4f/af6846cfbc1550a3024e5d3775ede1e00474c40882c7bf5b37a43ca35e91/Brotli-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ceb64bbc6eac5a140ca649003756940f8d6a7c444a68af170b3187623b43bebf", size = 2943950, upload-time = "2023-09-07T14:03:42.896Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e7/ca2993c7682d8629b62630ebf0d1f3bb3d579e667ce8e7ca03a0a0576a2d/Brotli-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a469274ad18dc0e4d316eefa616d1d0c2ff9da369af19fa6f3daa4f09671fd61", size = 2918527, upload-time = "2023-09-07T14:03:44.552Z" }, - { url = "https://files.pythonhosted.org/packages/b3/96/da98e7bedc4c51104d29cc61e5f449a502dd3dbc211944546a4cc65500d3/Brotli-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524f35912131cc2cabb00edfd8d573b07f2d9f21fa824bd3fb19725a9cf06327", size = 2845489, upload-time = "2023-09-07T14:03:46.594Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/ccbc16947d6ce943a7f57e1a40596c75859eeb6d279c6994eddd69615265/Brotli-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5b3cc074004d968722f51e550b41a27be656ec48f8afaeeb45ebf65b561481dd", size = 2914080, upload-time = "2023-09-07T14:03:48.204Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/0bd38d758d1afa62a5524172f0b18626bb2392d717ff94806f741fcd5ee9/Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", size = 2813051, upload-time = "2023-09-07T14:03:50.348Z" }, - { url = "https://files.pythonhosted.org/packages/14/56/48859dd5d129d7519e001f06dcfbb6e2cf6db92b2702c0c2ce7d97e086c1/Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", size = 2938172, upload-time = "2023-09-07T14:03:52.395Z" }, - { url = "https://files.pythonhosted.org/packages/3d/77/a236d5f8cd9e9f4348da5acc75ab032ab1ab2c03cc8f430d24eea2672888/Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", size = 2933023, upload-time = "2023-09-07T14:03:53.96Z" }, - { url = "https://files.pythonhosted.org/packages/f1/87/3b283efc0f5cb35f7f84c0c240b1e1a1003a5e47141a4881bf87c86d0ce2/Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f", size = 2935871, upload-time = "2024-10-18T12:32:16.688Z" }, - { url = "https://files.pythonhosted.org/packages/f3/eb/2be4cc3e2141dc1a43ad4ca1875a72088229de38c68e842746b342667b2a/Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757", size = 2847784, upload-time = "2024-10-18T12:32:18.459Z" }, - { url = "https://files.pythonhosted.org/packages/66/13/b58ddebfd35edde572ccefe6890cf7c493f0c319aad2a5badee134b4d8ec/Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0", size = 3034905, upload-time = "2024-10-18T12:32:20.192Z" }, - { url = "https://files.pythonhosted.org/packages/84/9c/bc96b6c7db824998a49ed3b38e441a2cae9234da6fa11f6ed17e8cf4f147/Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b", size = 2929467, upload-time = "2024-10-18T12:32:21.774Z" }, - { url = "https://files.pythonhosted.org/packages/5c/d0/5373ae13b93fe00095a58efcbce837fd470ca39f703a235d2a999baadfbc/Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28", size = 815693, upload-time = "2024-10-18T12:32:23.824Z" }, - { url = "https://files.pythonhosted.org/packages/8e/48/f6e1cdf86751300c288c1459724bfa6917a80e30dbfc326f92cea5d3683a/Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f", size = 422489, upload-time = "2024-10-18T12:32:25.641Z" }, - { url = "https://files.pythonhosted.org/packages/06/88/564958cedce636d0f1bed313381dfc4b4e3d3f6015a63dae6146e1b8c65c/Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", size = 873081, upload-time = "2023-09-07T14:03:57.967Z" }, - { url = "https://files.pythonhosted.org/packages/58/79/b7026a8bb65da9a6bb7d14329fd2bd48d2b7f86d7329d5cc8ddc6a90526f/Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", size = 446244, upload-time = "2023-09-07T14:03:59.319Z" }, - { url = "https://files.pythonhosted.org/packages/e5/18/c18c32ecea41b6c0004e15606e274006366fe19436b6adccc1ae7b2e50c2/Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", size = 2906505, upload-time = "2023-09-07T14:04:01.327Z" }, - { url = "https://files.pythonhosted.org/packages/08/c8/69ec0496b1ada7569b62d85893d928e865df29b90736558d6c98c2031208/Brotli-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f4bf76817c14aa98cc6697ac02f3972cb8c3da93e9ef16b9c66573a68014f91", size = 2944152, upload-time = "2023-09-07T14:04:03.033Z" }, - { url = "https://files.pythonhosted.org/packages/ab/fb/0517cea182219d6768113a38167ef6d4eb157a033178cc938033a552ed6d/Brotli-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0c5516f0aed654134a2fc936325cc2e642f8a0e096d075209672eb321cff408", size = 2919252, upload-time = "2023-09-07T14:04:04.675Z" }, - { url = "https://files.pythonhosted.org/packages/c7/53/73a3431662e33ae61a5c80b1b9d2d18f58dfa910ae8dd696e57d39f1a2f5/Brotli-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c3020404e0b5eefd7c9485ccf8393cfb75ec38ce75586e046573c9dc29967a0", size = 2845955, upload-time = "2023-09-07T14:04:06.585Z" }, - { url = "https://files.pythonhosted.org/packages/55/ac/bd280708d9c5ebdbf9de01459e625a3e3803cce0784f47d633562cf40e83/Brotli-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ed11165dd45ce798d99a136808a794a748d5dc38511303239d4e2363c0695dc", size = 2914304, upload-time = "2023-09-07T14:04:08.668Z" }, - { url = "https://files.pythonhosted.org/packages/76/58/5c391b41ecfc4527d2cc3350719b02e87cb424ef8ba2023fb662f9bf743c/Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", size = 2814452, upload-time = "2023-09-07T14:04:10.736Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4e/91b8256dfe99c407f174924b65a01f5305e303f486cc7a2e8a5d43c8bec3/Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", size = 2938751, upload-time = "2023-09-07T14:04:12.875Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a6/e2a39a5d3b412938362bbbeba5af904092bf3f95b867b4a3eb856104074e/Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", size = 2933757, upload-time = "2023-09-07T14:04:14.551Z" }, - { url = "https://files.pythonhosted.org/packages/13/f0/358354786280a509482e0e77c1a5459e439766597d280f28cb097642fc26/Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9", size = 2936146, upload-time = "2024-10-18T12:32:27.257Z" }, - { url = "https://files.pythonhosted.org/packages/80/f7/daf538c1060d3a88266b80ecc1d1c98b79553b3f117a485653f17070ea2a/Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb", size = 2848055, upload-time = "2024-10-18T12:32:29.376Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/0eaa0585c4077d3c2d1edf322d8e97aabf317941d3a72d7b3ad8bce004b0/Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111", size = 3035102, upload-time = "2024-10-18T12:32:31.371Z" }, - { url = "https://files.pythonhosted.org/packages/d8/63/1c1585b2aa554fe6dbce30f0c18bdbc877fa9a1bf5ff17677d9cca0ac122/Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839", size = 2930029, upload-time = "2024-10-18T12:32:33.293Z" }, - { url = "https://files.pythonhosted.org/packages/0a/9f/fb37bb8ffc52a8da37b1c03c459a8cd55df7a57bdccd8831d500e994a0ca/Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5", size = 815681, upload-time = "2024-10-18T12:32:34.942Z" }, - { url = "https://files.pythonhosted.org/packages/06/b3/dbd332a988586fefb0aa49c779f59f47cae76855c2d00f450364bb574cac/Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8", size = 422475, upload-time = "2024-10-18T12:32:36.485Z" }, - { url = "https://files.pythonhosted.org/packages/bb/80/6aaddc2f63dbcf2d93c2d204e49c11a9ec93a8c7c63261e2b4bd35198283/Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f", size = 2906173, upload-time = "2024-10-18T12:32:37.978Z" }, - { url = "https://files.pythonhosted.org/packages/ea/1d/e6ca79c96ff5b641df6097d299347507d39a9604bde8915e76bf026d6c77/Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648", size = 2943803, upload-time = "2024-10-18T12:32:39.606Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a3/d98d2472e0130b7dd3acdbb7f390d478123dbf62b7d32bda5c830a96116d/Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0", size = 2918946, upload-time = "2024-10-18T12:32:41.679Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a5/c69e6d272aee3e1423ed005d8915a7eaa0384c7de503da987f2d224d0721/Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089", size = 2845707, upload-time = "2024-10-18T12:32:43.478Z" }, - { url = "https://files.pythonhosted.org/packages/58/9f/4149d38b52725afa39067350696c09526de0125ebfbaab5acc5af28b42ea/Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368", size = 2936231, upload-time = "2024-10-18T12:32:45.224Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5a/145de884285611838a16bebfdb060c231c52b8f84dfbe52b852a15780386/Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c", size = 2848157, upload-time = "2024-10-18T12:32:46.894Z" }, - { url = "https://files.pythonhosted.org/packages/50/ae/408b6bfb8525dadebd3b3dd5b19d631da4f7d46420321db44cd99dcf2f2c/Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284", size = 3035122, upload-time = "2024-10-18T12:32:48.844Z" }, - { url = "https://files.pythonhosted.org/packages/af/85/a94e5cfaa0ca449d8f91c3d6f78313ebf919a0dbd55a100c711c6e9655bc/Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7", size = 2930206, upload-time = "2024-10-18T12:32:51.198Z" }, + { url = "https://files.pythonhosted.org/packages/64/10/a090475284fc4a71aed40a96f32e44a7fe5bda39687353dd977720b211b6/brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", size = 863089, upload-time = "2025-11-05T18:38:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/03/41/17416630e46c07ac21e378c3464815dd2e120b441e641bc516ac32cc51d2/brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", size = 445442, upload-time = "2025-11-05T18:38:02.434Z" }, + { url = "https://files.pythonhosted.org/packages/24/31/90cc06584deb5d4fcafc0985e37741fc6b9717926a78674bbb3ce018957e/brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", size = 1532658, upload-time = "2025-11-05T18:38:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/62/17/33bf0c83bcbc96756dfd712201d87342732fad70bb3472c27e833a44a4f9/brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", size = 1631241, upload-time = "2025-11-05T18:38:04.582Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/f47854a1917b62efe29bc98ac18e5d4f71df03f629184575b862ef2e743b/brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", size = 1424307, upload-time = "2025-11-05T18:38:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b7/f88eb461719259c17483484ea8456925ee057897f8e64487d76e24e5e38d/brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", size = 1488208, upload-time = "2025-11-05T18:38:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/26/59/41bbcb983a0c48b0b8004203e74706c6b6e99a04f3c7ca6f4f41f364db50/brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d", size = 1597574, upload-time = "2025-11-05T18:38:07.838Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e6/8c89c3bdabbe802febb4c5c6ca224a395e97913b5df0dff11b54f23c1788/brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", size = 1492109, upload-time = "2025-11-05T18:38:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, ] [[package]] @@ -264,14 +415,14 @@ redis = [ [[package]] name = "celery-types" -version = "0.23.0" +version = "0.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/d1/0823e71c281e4ad0044e278cf1577d1a68e05f2809424bf94e1614925c5d/celery_types-0.23.0.tar.gz", hash = "sha256:402ed0555aea3cd5e1e6248f4632e4f18eec8edb2435173f9e6dc08449fa101e", size = 31479, upload-time = "2025-03-03T23:56:51.547Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/25/2276a1f00f8ab9fc88128c939333933a24db7df1d75aa57ecc27b7dd3a22/celery_types-0.24.0.tar.gz", hash = "sha256:c93fbcd0b04a9e9c2f55d5540aca4aa1ea4cc06a870c0c8dee5062fdd59663fe", size = 33148, upload-time = "2025-12-23T17:16:30.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/8b/92bb54dd74d145221c3854aa245c84f4dc04cc9366147496182cec8e88e3/celery_types-0.23.0-py3-none-any.whl", hash = "sha256:0cc495b8d7729891b7e070d0ec8d4906d2373209656a6e8b8276fe1ed306af9a", size = 50189, upload-time = "2025-03-03T23:56:50.458Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7e/3252cba5f5c9a65a3f52a69734d8e51e023db8981022b503e8183cf0225e/celery_types-0.24.0-py3-none-any.whl", hash = "sha256:a21e04681e68719a208335e556a79909da4be9c5e0d6d2fd0dd4c5615954b3fd", size = 60473, upload-time = "2025-12-23T17:16:29.89Z" }, ] [[package]] @@ -359,15 +510,15 @@ wheels = [ [[package]] name = "channels" -version = "4.3.1" +version = "4.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/a0/46450fcf9e56af18a6b0440ba49db6635419bb7bc84142c35f4143b1a66c/channels-4.3.1.tar.gz", hash = "sha256:97413ffd674542db08e16a9ef09cd86ec0113e5f8125fbd33cf0854adcf27cdb", size = 26896, upload-time = "2025-08-01T13:25:19.952Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/1c/eae1c2a8c195760376e7f65d0bdcc3e966695d29cfbe5c54841ce5c71408/channels-4.3.1-py3-none-any.whl", hash = "sha256:b091d4b26f91d807de3e84aead7ba785314f27eaf5bac31dd51b1c956b883859", size = 31286, upload-time = "2025-08-01T13:25:18.845Z" }, + { url = "https://files.pythonhosted.org/packages/16/34/c32915288b7ef482377b6adc401192f98c6a99b3a145423d3b8aed807898/channels-4.3.2-py3-none-any.whl", hash = "sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176", size = 31313, upload-time = "2025-11-20T15:13:02.357Z" }, ] [[package]] @@ -654,6 +805,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/34/6171ab34715ed210bcd6c2b38839cc792993cff4fe2493f50bc92b0086a0/daphne-4.2.1-py3-none-any.whl", hash = "sha256:881e96b387b95b35ad85acd855f229d7f5b79073d6649089c8a33f661885e055", size = 29015, upload-time = "2025-07-02T12:57:03.793Z" }, ] +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-inspect", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, +] + [[package]] name = "dateparser" version = "1.2.2" @@ -693,6 +857,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, ] +[[package]] +name = "dirtyjson" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/04/d24f6e645ad82ba0ef092fa17d9ef7a21953781663648a01c9371d9e8e98/dirtyjson-1.0.8.tar.gz", hash = "sha256:90ca4a18f3ff30ce849d100dcf4a003953c79d3a2348ef056f1d9c22231a25fd", size = 30782, upload-time = "2022-11-28T23:32:33.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/69/1bcf70f81de1b4a9f21b3a62ec0c83bdff991c88d6cc2267d02408457e88/dirtyjson-1.0.8-py3-none-any.whl", hash = "sha256:125e27248435a58acace26d5c2c4c11a1c0de0a9c5124c5a94ba78e517d74f53", size = 25197, upload-time = "2022-11-28T23:32:31.219Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -702,28 +875,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "django" -version = "5.2.7" +version = "5.2.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sqlparse", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/96/bd84e2bb997994de8bcda47ae4560991084e86536541d7214393880f01a8/django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd", size = 10865812, upload-time = "2025-10-01T14:22:12.081Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" }, + { url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" }, ] [[package]] name = "django-allauth" -version = "65.12.1" +version = "65.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/94/75d7f8c59e061d1b66a6d917b287817fe02d2671c9e6376a4ddfb3954989/django_allauth-65.12.1.tar.gz", hash = "sha256:662666ff2d5c71766f66b1629ac7345c30796813221184e13e11ed7460940c6a", size = 1967971, upload-time = "2025-10-16T16:39:58.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/42a048ba1dedbb6b553f5376a6126b1c753c10c70d1edab8f94c560c8066/django_allauth-65.13.1.tar.gz", hash = "sha256:2af0d07812f8c1a8e3732feaabe6a9db5ecf3fad6b45b6a0f7fd825f656c5a15", size = 1983857, upload-time = "2025-11-20T16:34:40.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/98/9d44ae1468abfdb521d651fb67f914165c7812dfdd97be16190c9b1cc246/django_allauth-65.13.1-py3-none-any.whl", hash = "sha256:2887294beedfd108b4b52ebd182e0ed373deaeb927fc5a22f77bbde3174704a6", size = 1787349, upload-time = "2025-11-20T16:34:37.354Z" }, +] [package.optional-dependencies] mfa = [ @@ -738,15 +923,15 @@ socialaccount = [ [[package]] name = "django-auditlog" -version = "3.3.0" +version = "3.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/d8/ddd1c653ffb7ed1984596420982e32a0b163a0be316721a801a54dcbf016/django_auditlog-3.3.0.tar.gz", hash = "sha256:01331a0e7bb1a8ff7573311b486c88f3d0c431c388f5a1e4a9b6b26911dd79b8", size = 85941, upload-time = "2025-10-02T17:16:27.591Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/e5/2beb2b256775c4fc041ed60cb44f5d77acb6cde307f01567dcf2756721a7/django_auditlog-3.4.1.tar.gz", hash = "sha256:ad07b9db452d5fa8303822cccd78cd3fcb2c2863aeb6abe039ec45739b4d7e33", size = 91611, upload-time = "2025-12-18T08:56:35.378Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/bc/6e1b503d1755ab09cff6480cb088def073f1303165ab59b1a09247a2e756/django_auditlog-3.3.0-py3-none-any.whl", hash = "sha256:ab0f0f556a7107ac01c8fa87137bdfbb2b6f0debf70f7753169d9a40673d2636", size = 39676, upload-time = "2025-10-02T17:15:42.922Z" }, + { url = "https://files.pythonhosted.org/packages/91/37/3239143dddb34a3f64582c43f56587a80c9ac136cbd34fc215077f83beb9/django_auditlog-3.4.1-py3-none-any.whl", hash = "sha256:29958ecacfee00144127214f3ccef3f0c203c3659bcb6dd404a0f3d5551a10a5", size = 49541, upload-time = "2025-12-18T08:56:23.483Z" }, ] [[package]] @@ -867,19 +1052,19 @@ wheels = [ [[package]] name = "django-soft-delete" -version = "1.0.21" +version = "1.0.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/bf/13996c18bffee3bbcf294830c1737bfb5564164b8319c51e6714b6bdf783/django_soft_delete-1.0.21.tar.gz", hash = "sha256:542bd4650d2769105a4363ea7bb7fbdb3c28429dbaa66417160f8f4b5dc689d5", size = 21153, upload-time = "2025-09-17T08:46:30.476Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/d1/c990b731676f93bd4594dee4b5133df52f5d0eee1eb8a969b4030014ac54/django_soft_delete-1.0.22.tar.gz", hash = "sha256:32d0bb95f180c28a40163e78a558acc18901fd56011f91f8ee735c171a6d4244", size = 21982, upload-time = "2025-10-25T13:11:46.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/e6/8f4fed14499c63e35ca33cf9f424ad2e14e963ec5545594d7c7dc2f710f4/django_soft_delete-1.0.21-py3-none-any.whl", hash = "sha256:dd91e671d9d431ff96f4db727ce03e7fbb4008ae4541b1d162d5d06cc9becd2a", size = 18681, upload-time = "2025-09-17T08:46:29.272Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c2/fca2bf69b7ca7e18aed9ac059e89f1043663e207a514e8fb652450e49631/django_soft_delete-1.0.22-py3-none-any.whl", hash = "sha256:81973c541d21452d249151085d617ebbfb5ec463899f47cd6b1306677481e94c", size = 19221, upload-time = "2025-10-25T13:11:44.755Z" }, ] [[package]] name = "django-stubs" -version = "5.2.5" +version = "5.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -888,9 +1073,9 @@ dependencies = [ { name = "types-pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/8e/286150f593481c33f54d14efb58d72178f159d57d31043529d38bbc98e2f/django_stubs-5.2.5.tar.gz", hash = "sha256:fc78384e28d8c5292d60983075a5934f644f7c304c25ae2793fc57aa66d5018b", size = 247794, upload-time = "2025-09-12T19:29:49.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/75/97626224fd8f1787bb6f7f06944efcfddd5da7764bf741cf7f59d102f4a0/django_stubs-5.2.8.tar.gz", hash = "sha256:9bba597c9a8ed8c025cae4696803d5c8be1cf55bfc7648a084cbf864187e2f8b", size = 257709, upload-time = "2025-12-01T08:13:09.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/02/cdbf7652ef2c9a7a1fed7c279484b7f3806f15b1bb34aec9fef8e8cfacbf/django_stubs-5.2.5-py3-none-any.whl", hash = "sha256:223c1a3324cd4873b7629dec6e9adbe224a94508284c1926b25fddff7a92252b", size = 490196, upload-time = "2025-09-12T19:29:47.954Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3f/7c9543ad5ade5ce1d33d187a3abd82164570314ebee72c6206ab5c044ebf/django_stubs-5.2.8-py3-none-any.whl", hash = "sha256:a3c63119fd7062ac63d58869698d07c9e5ec0561295c4e700317c54e8d26716c", size = 508136, upload-time = "2025-12-01T08:13:07.963Z" }, ] [package.optional-dependencies] @@ -900,24 +1085,24 @@ compatible-mypy = [ [[package]] name = "django-stubs-ext" -version = "5.2.5" +version = "5.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/94/c9b8f4c47084a0fa666da9066c36771098101932688adf2c17a40fab79c2/django_stubs_ext-5.2.5.tar.gz", hash = "sha256:ecc628df29d36cede638567c4e33ff485dd7a99f1552ad0cece8c60e9c3a8872", size = 6489, upload-time = "2025-09-12T19:29:06.008Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/a2/d67f4a5200ff7626b104eddceaf529761cba4ed318a73ffdb0677551be73/django_stubs_ext-5.2.8.tar.gz", hash = "sha256:b39938c46d7a547cd84e4a6378dbe51a3dd64d70300459087229e5fee27e5c6b", size = 6487, upload-time = "2025-12-01T08:12:37.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/fe/a85a105fddffadb4a8d50e500caeee87d836b679d51a19d52dfa0cc6c660/django_stubs_ext-5.2.5-py3-none-any.whl", hash = "sha256:9b4b8ac9d32f7e6c304fd05477f8688fae6ed57f6a0f9f4d074f9e55b5a3da14", size = 9310, upload-time = "2025-09-12T19:29:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/da/2d/cb0151b780c3730cf0f2c0fcb1b065a5e88f877cf7a9217483c375353af1/django_stubs_ext-5.2.8-py3-none-any.whl", hash = "sha256:1dd5470c9675591362c78a157a3cf8aec45d0e7a7f0cf32f227a1363e54e0652", size = 9949, upload-time = "2025-12-01T08:12:36.397Z" }, ] [[package]] name = "django-treenode" -version = "0.23.2" +version = "0.23.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/f3/274b84607fd64c0844e98659985f964190a46c2460f2523a446c4a946216/django_treenode-0.23.2.tar.gz", hash = "sha256:3c5a6ff5e0c83e34da88749f602b3013dd1ab0527f51952c616e3c21bf265d52", size = 26700, upload-time = "2025-09-04T21:16:53.497Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/58/86edbbd1075bb8bc0962c6feb13bc06822405a10fea8352ad73ab2babdd9/django_treenode-0.23.3.tar.gz", hash = "sha256:714c825d5b925a3d2848d0709f29973941ea41a606b8e2b64cbec46010a8cce3", size = 27812, upload-time = "2025-12-01T23:01:24.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/61/e17d3dee5c6bb24b8faf0c101e17f9a8cafeba6384166176e066c80e8cbb/django_treenode-0.23.2-py3-none-any.whl", hash = "sha256:9363cb50f753654a9acfad6ec4df2a664a5f89dfdf8b55ffd964f27461bef85e", size = 21879, upload-time = "2025-09-04T21:16:51.811Z" }, + { url = "https://files.pythonhosted.org/packages/bc/52/696db237167483324ef38d8d090fb0fcc33dbb70ebe66c75868005fb7c75/django_treenode-0.23.3-py3-none-any.whl", hash = "sha256:8072e1ac688c1ed3ab95a98a797c5e965380de5228a389d60a4ef8b9a6449387", size = 22014, upload-time = "2025-12-01T23:01:23.266Z" }, ] [[package]] @@ -948,18 +1133,17 @@ wheels = [ [[package]] name = "djangorestframework-stubs" -version = "3.16.4" +version = "3.16.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django-stubs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "types-pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "types-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/33/79c0dd2a02ead2702a1b3d25579b56ffdc1bc6d1271c31a0979ce9ad10fa/djangorestframework_stubs-3.16.4.tar.gz", hash = "sha256:f43136bfbef568dd0e10848427c01bd9ef759dd328195949f6f7f9a2292a34f6", size = 31960, upload-time = "2025-09-29T20:11:20.57Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/50/889b1121dc0831aa9f6ece8409d41a5f4667da2a963172516841f343fd35/djangorestframework_stubs-3.16.7.tar.gz", hash = "sha256:e53bc346e9950ebdd1bb2bbc19d7e5c8b7acc894e381df55da69248f47ab78ff", size = 32296, upload-time = "2026-01-13T11:42:48.3Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/45/63fac5c34313986acc005529fdba638822bae973f2b61a0542a6c8494847/djangorestframework_stubs-3.16.4-py3-none-any.whl", hash = "sha256:3b27353fa797876f55da87eceafe4c2f265a93924fa7763d257e509d865df1b2", size = 56503, upload-time = "2025-09-29T20:11:19.317Z" }, + { url = "https://files.pythonhosted.org/packages/9e/99/7c969728d66388e22fdaba94e1a9c56490954e2f12f598416e380a53b26d/djangorestframework_stubs-3.16.7-py3-none-any.whl", hash = "sha256:70f80050144875f80ce8ac823ff8628f6e3eb7336495394bb9803251721d9358", size = 56522, upload-time = "2026-01-13T11:42:46.118Z" }, ] [package.optional-dependencies] @@ -970,7 +1154,7 @@ compatible-mypy = [ [[package]] name = "drf-spectacular" -version = "0.28.0" +version = "0.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -980,9 +1164,9 @@ dependencies = [ { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "uritemplate", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/b9/741056455aed00fa51a1df41fad5ad27c8e0d433b6bf490d4e60e2808bc6/drf_spectacular-0.28.0.tar.gz", hash = "sha256:2c778a47a40ab2f5078a7c42e82baba07397bb35b074ae4680721b2805943061", size = 237849, upload-time = "2024-11-30T08:49:02.355Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/0e/a4f50d83e76cbe797eda88fc0083c8ca970cfa362b5586359ef06ec6f70a/drf_spectacular-0.29.0.tar.gz", hash = "sha256:0a069339ea390ce7f14a75e8b5af4a0860a46e833fd4af027411a3e94fc1a0cc", size = 241722, upload-time = "2025-11-02T03:40:26.348Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/66/c2929871393b1515c3767a670ff7d980a6882964a31a4ca2680b30d7212a/drf_spectacular-0.28.0-py3-none-any.whl", hash = "sha256:856e7edf1056e49a4245e87a61e8da4baff46c83dbc25be1da2df77f354c7cb4", size = 103928, upload-time = "2024-11-30T08:48:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/502c56fc3ca960075d00956283f1c44e8cafe433dada03f9ed2821f3073b/drf_spectacular-0.29.0-py3-none-any.whl", hash = "sha256:d1ee7c9535d89848affb4427347f7c4a22c5d22530b8842ef133d7b72e19b41a", size = 105433, upload-time = "2025-11-02T03:40:24.823Z" }, ] [[package]] @@ -1038,6 +1222,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, ] +[[package]] +name = "faiss-cpu" +version = "1.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, + { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux')" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c9/671f66f6b31ec48e5825d36435f0cb91189fa8bb6b50724029dbff4ca83c/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:a9064eb34f8f64438dd5b95c8f03a780b1a3f0b99c46eeacb1f0b5d15fc02dc1", size = 3452776, upload-time = "2025-12-24T10:27:01.419Z" }, + { url = "https://files.pythonhosted.org/packages/5a/4a/97150aa1582fb9c2bca95bd8fc37f27d3b470acec6f0a6833844b21e4b40/faiss_cpu-1.13.2-cp310-abi3-macosx_14_0_x86_64.whl", hash = "sha256:c8d097884521e1ecaea6467aeebbf1aa56ee4a36350b48b2ca6b39366565c317", size = 7896434, upload-time = "2025-12-24T10:27:03.592Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d0/0940575f059591ca31b63a881058adb16a387020af1709dcb7669460115c/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ee330a284042c2480f2e90450a10378fd95655d62220159b1408f59ee83ebf1", size = 11485825, upload-time = "2025-12-24T10:27:05.681Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e1/a5acac02aa593809f0123539afe7b4aff61d1db149e7093239888c9053e1/faiss_cpu-1.13.2-cp310-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab88ee287c25a119213153d033f7dd64c3ccec466ace267395872f554b648cd7", size = 23845772, upload-time = "2025-12-24T10:27:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7b/49dcaf354834ec457e85ca769d50bc9b5f3003fab7c94a9dcf08cf742793/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:85511129b34f890d19c98b82a0cd5ffb27d89d1cec2ee41d2621ee9f9ef8cf3f", size = 13477567, upload-time = "2025-12-24T10:27:10.822Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6b/12bb4037921c38bb2c0b4cfc213ca7e04bbbebbfea89b0b5746248ce446e/faiss_cpu-1.13.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b32eb4065bac352b52a9f5ae07223567fab0a976c7d05017c01c45a1c24264f", size = 25102239, upload-time = "2025-12-24T10:27:13.476Z" }, +] + [[package]] name = "faker" version = "37.8.0" @@ -1064,11 +1266,20 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.0" +version = "3.20.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, ] [[package]] @@ -1087,6 +1298,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/ff/ee2f67c0ff146ec98b5df1df637b2bc2d17beeb05df9f427a67bd7a7d79c/flower-2.0.1-py2.py3-none-any.whl", hash = "sha256:9db2c621eeefbc844c8dd88be64aef61e84e2deb29b271e02ab2b5b9f01068e2", size = 383553, upload-time = "2023-08-13T14:37:41.552Z" }, ] +[[package]] +name = "frozenlist" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/f4/d744cba2da59b5c1d88823cf9e8a6c74e4659e2b27604ed973be2a0bf5ab/frozenlist-1.6.0.tar.gz", hash = "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68", size = 42831, upload-time = "2025-04-17T22:38:53.099Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/03/22e4eb297981d48468c3d9982ab6076b10895106d3039302a943bb60fd70/frozenlist-1.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e6e558ea1e47fd6fa8ac9ccdad403e5dd5ecc6ed8dda94343056fa4277d5c65e", size = 160584, upload-time = "2025-04-17T22:35:48.163Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/c213e35bcf1c20502c6fd491240b08cdd6ceec212ea54873f4cae99a51e4/frozenlist-1.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4b3cd7334a4bbc0c472164f3744562cb72d05002cc6fcf58adb104630bbc352", size = 124099, upload-time = "2025-04-17T22:35:50.241Z" }, + { url = "https://files.pythonhosted.org/packages/2b/33/df17b921c2e37b971407b4045deeca6f6de7caf0103c43958da5e1b85e40/frozenlist-1.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9799257237d0479736e2b4c01ff26b5c7f7694ac9692a426cb717f3dc02fff9b", size = 122106, upload-time = "2025-04-17T22:35:51.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/09/93f0293e8a95c05eea7cf9277fef8929fb4d0a2234ad9394cd2a6b6a6bb4/frozenlist-1.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a7bb0fe1f7a70fb5c6f497dc32619db7d2cdd53164af30ade2f34673f8b1fc", size = 287205, upload-time = "2025-04-17T22:35:53.441Z" }, + { url = "https://files.pythonhosted.org/packages/5e/34/35612f6f1b1ae0f66a4058599687d8b39352ade8ed329df0890fb553ea1e/frozenlist-1.6.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:36d2fc099229f1e4237f563b2a3e0ff7ccebc3999f729067ce4e64a97a7f2869", size = 295079, upload-time = "2025-04-17T22:35:55.617Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ca/51577ef6cc4ec818aab94a0034ef37808d9017c2e53158fef8834dbb3a07/frozenlist-1.6.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f27a9f9a86dcf00708be82359db8de86b80d029814e6693259befe82bb58a106", size = 308068, upload-time = "2025-04-17T22:35:57.119Z" }, + { url = "https://files.pythonhosted.org/packages/36/27/c63a23863b9dcbd064560f0fea41b516bbbf4d2e8e7eec3ff880a96f0224/frozenlist-1.6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75ecee69073312951244f11b8627e3700ec2bfe07ed24e3a685a5979f0412d24", size = 305640, upload-time = "2025-04-17T22:35:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/33/c2/91720b3562a6073ba604547a417c8d3bf5d33e4c8f1231f3f8ff6719e05c/frozenlist-1.6.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2c7d5aa19714b1b01a0f515d078a629e445e667b9da869a3cd0e6fe7dec78bd", size = 278509, upload-time = "2025-04-17T22:36:00.199Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6e/1b64671ab2fca1ebf32c5b500205724ac14c98b9bc1574b2ef55853f4d71/frozenlist-1.6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69bbd454f0fb23b51cadc9bdba616c9678e4114b6f9fa372d462ff2ed9323ec8", size = 287318, upload-time = "2025-04-17T22:36:02.179Z" }, + { url = "https://files.pythonhosted.org/packages/66/30/589a8d8395d5ebe22a6b21262a4d32876df822c9a152e9f2919967bb8e1a/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7daa508e75613809c7a57136dec4871a21bca3080b3a8fc347c50b187df4f00c", size = 290923, upload-time = "2025-04-17T22:36:03.766Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e0/2bd0d2a4a7062b7e4b5aad621697cd3579e5d1c39d99f2833763d91e746d/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:89ffdb799154fd4d7b85c56d5fa9d9ad48946619e0eb95755723fffa11022d75", size = 304847, upload-time = "2025-04-17T22:36:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/a1a44204398a4b308c3ee1b7bf3bf56b9dcbcc4e61c890e038721d1498db/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:920b6bd77d209931e4c263223381d63f76828bec574440f29eb497cf3394c249", size = 285580, upload-time = "2025-04-17T22:36:07.538Z" }, + { url = "https://files.pythonhosted.org/packages/78/ed/3862bc9abe05839a6a5f5bab8b6bbdf0fc9369505cb77cd15b8c8948f6a0/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d3ceb265249fb401702fce3792e6b44c1166b9319737d21495d3611028d95769", size = 304033, upload-time = "2025-04-17T22:36:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9c/1c48454a9e1daf810aa6d977626c894b406651ca79d722fce0f13c7424f1/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52021b528f1571f98a7d4258c58aa8d4b1a96d4f01d00d51f1089f2e0323cb02", size = 307566, upload-time = "2025-04-17T22:36:10.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/ef/cb43655c21f1bad5c42bcd540095bba6af78bf1e474b19367f6fd67d029d/frozenlist-1.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0f2ca7810b809ed0f1917293050163c7654cefc57a49f337d5cd9de717b8fad3", size = 295354, upload-time = "2025-04-17T22:36:12.181Z" }, + { url = "https://files.pythonhosted.org/packages/53/b5/bc883b5296ec902115c00be161da93bf661199c465ec4c483feec6ea4c32/frozenlist-1.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae8337990e7a45683548ffb2fee1af2f1ed08169284cd829cdd9a7fa7470530d", size = 160912, upload-time = "2025-04-17T22:36:17.235Z" }, + { url = "https://files.pythonhosted.org/packages/6f/93/51b058b563d0704b39c56baa222828043aafcac17fd3734bec5dbeb619b1/frozenlist-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c952f69dd524558694818a461855f35d36cc7f5c0adddce37e962c85d06eac0", size = 124315, upload-time = "2025-04-17T22:36:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e0/46cd35219428d350558b874d595e132d1c17a9471a1bd0d01d518a261e7c/frozenlist-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f5fef13136c4e2dee91bfb9a44e236fff78fc2cd9f838eddfc470c3d7d90afe", size = 122230, upload-time = "2025-04-17T22:36:20.6Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0f/7ad2ce928ad06d6dd26a61812b959ded573d3e9d0ee6109d96c2be7172e9/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:716bbba09611b4663ecbb7cd022f640759af8259e12a6ca939c0a6acd49eedba", size = 314842, upload-time = "2025-04-17T22:36:22.088Z" }, + { url = "https://files.pythonhosted.org/packages/34/76/98cbbd8a20a5c3359a2004ae5e5b216af84a150ccbad67c8f8f30fb2ea91/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7b8c4dc422c1a3ffc550b465090e53b0bf4839047f3e436a34172ac67c45d595", size = 304919, upload-time = "2025-04-17T22:36:24.247Z" }, + { url = "https://files.pythonhosted.org/packages/9a/fa/258e771ce3a44348c05e6b01dffc2bc67603fba95761458c238cd09a2c77/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b11534872256e1666116f6587a1592ef395a98b54476addb5e8d352925cb5d4a", size = 324074, upload-time = "2025-04-17T22:36:26.291Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a4/047d861fd8c538210e12b208c0479912273f991356b6bdee7ea8356b07c9/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c6eceb88aaf7221f75be6ab498dc622a151f5f88d536661af3ffc486245a626", size = 321292, upload-time = "2025-04-17T22:36:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/c0/25/cfec8af758b4525676cabd36efcaf7102c1348a776c0d1ad046b8a7cdc65/frozenlist-1.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62c828a5b195570eb4b37369fcbbd58e96c905768d53a44d13044355647838ff", size = 301569, upload-time = "2025-04-17T22:36:29.448Z" }, + { url = "https://files.pythonhosted.org/packages/87/2f/0c819372fa9f0c07b153124bf58683b8d0ca7bb73ea5ccde9b9ef1745beb/frozenlist-1.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1c6bd2c6399920c9622362ce95a7d74e7f9af9bfec05fff91b8ce4b9647845a", size = 313625, upload-time = "2025-04-17T22:36:31.55Z" }, + { url = "https://files.pythonhosted.org/packages/50/5f/f0cf8b0fdedffdb76b3745aa13d5dbe404d63493cc211ce8250f2025307f/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49ba23817781e22fcbd45fd9ff2b9b8cdb7b16a42a4851ab8025cae7b22e96d0", size = 312523, upload-time = "2025-04-17T22:36:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6c/38c49108491272d3e84125bbabf2c2d0b304899b52f49f0539deb26ad18d/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:431ef6937ae0f853143e2ca67d6da76c083e8b1fe3df0e96f3802fd37626e606", size = 322657, upload-time = "2025-04-17T22:36:34.688Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4b/3bd3bad5be06a9d1b04b1c22be80b5fe65b502992d62fab4bdb25d9366ee/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9d124b38b3c299ca68433597ee26b7819209cb8a3a9ea761dfe9db3a04bba584", size = 303414, upload-time = "2025-04-17T22:36:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/5b/89/7e225a30bef6e85dbfe22622c24afe932e9444de3b40d58b1ea589a14ef8/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:118e97556306402e2b010da1ef21ea70cb6d6122e580da64c056b96f524fbd6a", size = 320321, upload-time = "2025-04-17T22:36:38.16Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/7e3acef4dd9e86366cb8f4d8f28e852c2b7e116927e9722b31a6f71ea4b0/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fb3b309f1d4086b5533cf7bbcf3f956f0ae6469664522f1bde4feed26fba60f1", size = 323975, upload-time = "2025-04-17T22:36:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/d8/85/e5da03d20507e13c66ce612c9792b76811b7a43e3320cce42d95b85ac755/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54dece0d21dce4fdb188a1ffc555926adf1d1c516e493c2914d7c370e454bc9e", size = 316553, upload-time = "2025-04-17T22:36:42.045Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8a/289b7d0de2fbac832ea80944d809759976f661557a38bb8e77db5d9f79b7/frozenlist-1.6.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c5b9e42ace7d95bf41e19b87cec8f262c41d3510d8ad7514ab3862ea2197bfb1", size = 160193, upload-time = "2025-04-17T22:36:47.382Z" }, + { url = "https://files.pythonhosted.org/packages/19/80/2fd17d322aec7f430549f0669f599997174f93ee17929ea5b92781ec902c/frozenlist-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ca9973735ce9f770d24d5484dcb42f68f135351c2fc81a7a9369e48cf2998a29", size = 123831, upload-time = "2025-04-17T22:36:49.401Z" }, + { url = "https://files.pythonhosted.org/packages/99/06/f5812da431273f78c6543e0b2f7de67dfd65eb0a433978b2c9c63d2205e4/frozenlist-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ac40ec76041c67b928ca8aaffba15c2b2ee3f5ae8d0cb0617b5e63ec119ca25", size = 121862, upload-time = "2025-04-17T22:36:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/9e61c6b5fc493cf24d54881731204d27105234d09878be1a5983182cc4a5/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b7a8a3180dfb280eb044fdec562f9b461614c0ef21669aea6f1d3dac6ee576", size = 316361, upload-time = "2025-04-17T22:36:53.402Z" }, + { url = "https://files.pythonhosted.org/packages/9d/55/22ca9362d4f0222324981470fd50192be200154d51509ee6eb9baa148e96/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c444d824e22da6c9291886d80c7d00c444981a72686e2b59d38b285617cb52c8", size = 307115, upload-time = "2025-04-17T22:36:55.016Z" }, + { url = "https://files.pythonhosted.org/packages/ae/39/4fff42920a57794881e7bb3898dc7f5f539261711ea411b43bba3cde8b79/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb52c8166499a8150bfd38478248572c924c003cbb45fe3bcd348e5ac7c000f9", size = 322505, upload-time = "2025-04-17T22:36:57.12Z" }, + { url = "https://files.pythonhosted.org/packages/55/f2/88c41f374c1e4cf0092a5459e5f3d6a1e17ed274c98087a76487783df90c/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b35298b2db9c2468106278537ee529719228950a5fdda686582f68f247d1dc6e", size = 322666, upload-time = "2025-04-17T22:36:58.735Z" }, + { url = "https://files.pythonhosted.org/packages/75/51/034eeb75afdf3fd03997856195b500722c0b1a50716664cde64e28299c4b/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d108e2d070034f9d57210f22fefd22ea0d04609fc97c5f7f5a686b3471028590", size = 302119, upload-time = "2025-04-17T22:37:00.512Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a6/564ecde55ee633270a793999ef4fd1d2c2b32b5a7eec903b1012cb7c5143/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e1be9111cb6756868ac242b3c2bd1f09d9aea09846e4f5c23715e7afb647103", size = 316226, upload-time = "2025-04-17T22:37:02.102Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6c0682c32377f402b8a6174fb16378b683cf6379ab4d2827c580892ab3c7/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:94bb451c664415f02f07eef4ece976a2c65dcbab9c2f1705b7031a3a75349d8c", size = 312788, upload-time = "2025-04-17T22:37:03.578Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b8/10fbec38f82c5d163ca1750bfff4ede69713badf236a016781cf1f10a0f0/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d1a686d0b0949182b8faddea596f3fc11f44768d1f74d4cad70213b2e139d821", size = 325914, upload-time = "2025-04-17T22:37:05.213Z" }, + { url = "https://files.pythonhosted.org/packages/62/ca/2bf4f3a1bd40cdedd301e6ecfdbb291080d5afc5f9ce350c0739f773d6b9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ea8e59105d802c5a38bdbe7362822c522230b3faba2aa35c0fa1765239b7dd70", size = 305283, upload-time = "2025-04-17T22:37:06.985Z" }, + { url = "https://files.pythonhosted.org/packages/09/64/20cc13ccf94abc2a1f482f74ad210703dc78a590d0b805af1c9aa67f76f9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:abc4e880a9b920bc5020bf6a431a6bb40589d9bca3975c980495f63632e8382f", size = 319264, upload-time = "2025-04-17T22:37:08.618Z" }, + { url = "https://files.pythonhosted.org/packages/20/ff/86c6a2bbe98cfc231519f5e6d712a0898488ceac804a917ce014f32e68f6/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9a79713adfe28830f27a3c62f6b5406c37376c892b05ae070906f07ae4487046", size = 326482, upload-time = "2025-04-17T22:37:10.196Z" }, + { url = "https://files.pythonhosted.org/packages/2f/da/8e381f66367d79adca245d1d71527aac774e30e291d41ef161ce2d80c38e/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a0318c2068e217a8f5e3b85e35899f5a19e97141a45bb925bb357cfe1daf770", size = 318248, upload-time = "2025-04-17T22:37:12.284Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e5/04c7090c514d96ca00887932417f04343ab94904a56ab7f57861bf63652d/frozenlist-1.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d7fb014fe0fbfee3efd6a94fc635aeaa68e5e1720fe9e57357f2e2c6e1a647e", size = 158182, upload-time = "2025-04-17T22:37:16.837Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8f/60d0555c61eec855783a6356268314d204137f5e0c53b59ae2fc28938c99/frozenlist-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01bcaa305a0fdad12745502bfd16a1c75b14558dabae226852f9159364573117", size = 122838, upload-time = "2025-04-17T22:37:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a7/d0ec890e3665b4b3b7c05dc80e477ed8dc2e2e77719368e78e2cd9fec9c8/frozenlist-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b314faa3051a6d45da196a2c495e922f987dc848e967d8cfeaee8a0328b1cd4", size = 120980, upload-time = "2025-04-17T22:37:19.857Z" }, + { url = "https://files.pythonhosted.org/packages/cc/19/9b355a5e7a8eba903a008579964192c3e427444752f20b2144b10bb336df/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da62fecac21a3ee10463d153549d8db87549a5e77eefb8c91ac84bb42bb1e4e3", size = 305463, upload-time = "2025-04-17T22:37:21.328Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8d/5b4c758c2550131d66935ef2fa700ada2461c08866aef4229ae1554b93ca/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1eb89bf3454e2132e046f9599fbcf0a4483ed43b40f545551a39316d0201cd1", size = 297985, upload-time = "2025-04-17T22:37:23.55Z" }, + { url = "https://files.pythonhosted.org/packages/48/2c/537ec09e032b5865715726b2d1d9813e6589b571d34d01550c7aeaad7e53/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18689b40cb3936acd971f663ccb8e2589c45db5e2c5f07e0ec6207664029a9c", size = 311188, upload-time = "2025-04-17T22:37:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/1aa74b33f74d54817055de9a4961eff798f066cdc6f67591905d4fc82a84/frozenlist-1.6.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e67ddb0749ed066b1a03fba812e2dcae791dd50e5da03be50b6a14d0c1a9ee45", size = 311874, upload-time = "2025-04-17T22:37:26.791Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/cfec18838f13ebf4b37cfebc8649db5ea71a1b25dacd691444a10729776c/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc5e64626e6682638d6e44398c9baf1d6ce6bc236d40b4b57255c9d3f9761f1f", size = 291897, upload-time = "2025-04-17T22:37:28.958Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a5/deb39325cbbea6cd0a46db8ccd76150ae2fcbe60d63243d9df4a0b8c3205/frozenlist-1.6.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:437cfd39564744ae32ad5929e55b18ebd88817f9180e4cc05e7d53b75f79ce85", size = 305799, upload-time = "2025-04-17T22:37:30.889Z" }, + { url = "https://files.pythonhosted.org/packages/78/22/6ddec55c5243a59f605e4280f10cee8c95a449f81e40117163383829c241/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62dd7df78e74d924952e2feb7357d826af8d2f307557a779d14ddf94d7311be8", size = 302804, upload-time = "2025-04-17T22:37:32.489Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/d9ca9bab87f28855063c4d202936800219e39db9e46f9fb004d521152623/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a66781d7e4cddcbbcfd64de3d41a61d6bdde370fc2e38623f30b2bd539e84a9f", size = 316404, upload-time = "2025-04-17T22:37:34.59Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/1255305db7874d0b9eddb4fe4a27469e1fb63720f1fc6d325a5118492d18/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:482fe06e9a3fffbcd41950f9d890034b4a54395c60b5e61fae875d37a699813f", size = 295572, upload-time = "2025-04-17T22:37:36.337Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f2/8d38eeee39a0e3a91b75867cc102159ecccf441deb6ddf67be96d3410b84/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e4f9373c500dfc02feea39f7a56e4f543e670212102cc2eeb51d3a99c7ffbde6", size = 307601, upload-time = "2025-04-17T22:37:37.923Z" }, + { url = "https://files.pythonhosted.org/packages/38/04/80ec8e6b92f61ef085422d7b196822820404f940950dde5b2e367bede8bc/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e69bb81de06827147b7bfbaeb284d85219fa92d9f097e32cc73675f279d70188", size = 314232, upload-time = "2025-04-17T22:37:39.669Z" }, + { url = "https://files.pythonhosted.org/packages/3a/58/93b41fb23e75f38f453ae92a2f987274c64637c450285577bd81c599b715/frozenlist-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7613d9977d2ab4a9141dde4a149f4357e4065949674c5649f920fec86ecb393e", size = 308187, upload-time = "2025-04-17T22:37:41.662Z" }, + { url = "https://files.pythonhosted.org/packages/df/bd/cc6d934991c1e5d9cafda83dfdc52f987c7b28343686aef2e58a9cf89f20/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:46138f5a0773d064ff663d273b309b696293d7a7c00a0994c5c13a5078134b64", size = 174937, upload-time = "2025-04-17T22:37:46.635Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/daf945f335abdbfdd5993e9dc348ef4507436936ab3c26d7cfe72f4843bf/frozenlist-1.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f88bc0a2b9c2a835cb888b32246c27cdab5740059fb3688852bf91e915399b91", size = 136029, upload-time = "2025-04-17T22:37:48.192Z" }, + { url = "https://files.pythonhosted.org/packages/51/65/4c3145f237a31247c3429e1c94c384d053f69b52110a0d04bfc8afc55fb2/frozenlist-1.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:777704c1d7655b802c7850255639672e90e81ad6fa42b99ce5ed3fbf45e338dd", size = 134831, upload-time = "2025-04-17T22:37:50.485Z" }, + { url = "https://files.pythonhosted.org/packages/77/38/03d316507d8dea84dfb99bdd515ea245628af964b2bf57759e3c9205cc5e/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85ef8d41764c7de0dcdaf64f733a27352248493a85a80661f3c678acd27e31f2", size = 392981, upload-time = "2025-04-17T22:37:52.558Z" }, + { url = "https://files.pythonhosted.org/packages/37/02/46285ef9828f318ba400a51d5bb616ded38db8466836a9cfa39f3903260b/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:da5cb36623f2b846fb25009d9d9215322318ff1c63403075f812b3b2876c8506", size = 371999, upload-time = "2025-04-17T22:37:54.092Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/1212fea37a112c3c5c05bfb5f0a81af4836ce349e69be75af93f99644da9/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cbb56587a16cf0fb8acd19e90ff9924979ac1431baea8681712716a8337577b0", size = 392200, upload-time = "2025-04-17T22:37:55.951Z" }, + { url = "https://files.pythonhosted.org/packages/81/ce/9a6ea1763e3366e44a5208f76bf37c76c5da570772375e4d0be85180e588/frozenlist-1.6.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6154c3ba59cda3f954c6333025369e42c3acd0c6e8b6ce31eb5c5b8116c07e0", size = 390134, upload-time = "2025-04-17T22:37:57.633Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/939738b0b495b2c6d0c39ba51563e453232813042a8d908b8f9544296c29/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e8246877afa3f1ae5c979fe85f567d220f86a50dc6c493b9b7d8191181ae01e", size = 365208, upload-time = "2025-04-17T22:37:59.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8b/939e62e93c63409949c25220d1ba8e88e3960f8ef6a8d9ede8f94b459d27/frozenlist-1.6.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b0f6cce16306d2e117cf9db71ab3a9e8878a28176aeaf0dbe35248d97b28d0c", size = 385548, upload-time = "2025-04-17T22:38:01.416Z" }, + { url = "https://files.pythonhosted.org/packages/62/38/22d2873c90102e06a7c5a3a5b82ca47e393c6079413e8a75c72bff067fa8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1b8e8cd8032ba266f91136d7105706ad57770f3522eac4a111d77ac126a25a9b", size = 391123, upload-time = "2025-04-17T22:38:03.049Z" }, + { url = "https://files.pythonhosted.org/packages/44/78/63aaaf533ee0701549500f6d819be092c6065cb5c577edb70c09df74d5d0/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e2ada1d8515d3ea5378c018a5f6d14b4994d4036591a52ceaf1a1549dec8e1ad", size = 394199, upload-time = "2025-04-17T22:38:04.776Z" }, + { url = "https://files.pythonhosted.org/packages/54/45/71a6b48981d429e8fbcc08454dc99c4c2639865a646d549812883e9c9dd3/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:cdb2c7f071e4026c19a3e32b93a09e59b12000751fc9b0b7758da899e657d215", size = 373854, upload-time = "2025-04-17T22:38:06.576Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f3/dbf2a5e11736ea81a66e37288bf9f881143a7822b288a992579ba1b4204d/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:03572933a1969a6d6ab509d509e5af82ef80d4a5d4e1e9f2e1cdd22c77a3f4d2", size = 395412, upload-time = "2025-04-17T22:38:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f1/c63166806b331f05104d8ea385c4acd511598568b1f3e4e8297ca54f2676/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:77effc978947548b676c54bbd6a08992759ea6f410d4987d69feea9cd0919911", size = 394936, upload-time = "2025-04-17T22:38:10.056Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ea/4f3e69e179a430473eaa1a75ff986526571215fefc6b9281cdc1f09a4eb8/frozenlist-1.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a2bda8be77660ad4089caf2223fdbd6db1858462c4b85b67fbfa22102021e497", size = 391459, upload-time = "2025-04-17T22:38:11.826Z" }, + { url = "https://files.pythonhosted.org/packages/71/3e/b04a0adda73bd52b390d730071c0d577073d3d26740ee1bad25c3ad0f37b/frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", size = 12404, upload-time = "2025-04-17T22:38:51.668Z" }, +] + +[[package]] +name = "fsspec" +version = "2025.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/d8/8425e6ba5fcec61a1d16e41b1b71d2bf9344f1fe48012c2b48b9620feae5/fsspec-2025.3.2.tar.gz", hash = "sha256:e52c77ef398680bbd6a98c0e628fbc469491282981209907bbc8aea76a04fdc6", size = 299281, upload-time = "2025-03-31T15:27:08.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/4b/e0cfc1a6f17e990f3e64b7d941ddc4acdc7b19d6edd51abf495f32b1a9e4/fsspec-2025.3.2-py3-none-any.whl", hash = "sha256:2daf8dc3d1dfa65b6aa37748d112773a7a08416f6c70d96b264c96476ecaf711", size = 194435, upload-time = "2025-03-31T15:27:07.028Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -1101,15 +1405,15 @@ wheels = [ [[package]] name = "gotenberg-client" -version = "0.12.0" +version = "0.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/6d/07ea213c146bbe91dffebff2d8f4dc61e7076d3dd34d4fd1467f9163e752/gotenberg_client-0.12.0.tar.gz", hash = "sha256:1ab50878024469fc003c414ee9810ceeb00d4d7d7c36bd2fb75318fbff139e9b", size = 1210884, upload-time = "2025-10-15T15:32:37.669Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/6c/aaadd6657ca42fbd148b1c00604b98c1ead5a22552f4e5365ce5f0632430/gotenberg_client-0.13.1.tar.gz", hash = "sha256:cdd6bbb535cd739b87446cd1b4f6347ed7f9af6a0d4b19baf7c064b75528ee54", size = 1211143, upload-time = "2025-12-04T20:45:24.151Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/39/fcb24ff053b1be7e5124f56c3d358706a23a328f685c6db33bc9dbc5472d/gotenberg_client-0.12.0-py3-none-any.whl", hash = "sha256:a540b35ac518e902c2860a88fbe448c15fe5a56fe8ec8604e6a2c8c2228fd0cb", size = 51051, upload-time = "2025-10-15T15:32:36.32Z" }, + { url = "https://files.pythonhosted.org/packages/79/f6/7a6e6785295332d2538f729ae19516cef712273a5ab8b90d015f08e37a45/gotenberg_client-0.13.1-py3-none-any.whl", hash = "sha256:613f7083a5e8a81699dd8d715c97e5806a424ac48920aad25d7c11b600cdfaf3", size = 51058, upload-time = "2025-12-04T20:45:22.603Z" }, ] [[package]] @@ -1201,6 +1505,66 @@ uvloop = [ { name = "uvloop", marker = "(platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_python_implementation == 'CPython' and sys_platform == 'linux')" }, ] +[[package]] +name = "greenlet" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/74/907bb43af91782e0366b0960af62a8ce1f9398e4291cac7beaeffbee0c04/greenlet-3.2.1.tar.gz", hash = "sha256:9f4dd4b4946b14bb3bf038f81e1d2e535b7d94f1b2a59fdba1293cd9c1a0a4d7", size = 184475, upload-time = "2025-04-22T14:40:18.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3e/6332bb2d1e43ec6270e0b97bf253cd704691ee55e4e52196cb7da8f774e9/greenlet-3.2.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:777c1281aa7c786738683e302db0f55eb4b0077c20f1dc53db8852ffaea0a6b0", size = 267364, upload-time = "2025-04-22T14:25:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/73/c1/c47cc96878c4eda993a2deaba15af3cfdc87cf8e2e3c4c20726dea541a8c/greenlet-3.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3059c6f286b53ea4711745146ffe5a5c5ff801f62f6c56949446e0f6461f8157", size = 625721, upload-time = "2025-04-22T14:53:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/df1ff1a505a62b08d31da498ddc0c9992e9c536c01944f8b800a7cf17ac6/greenlet-3.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e1a40a17e2c7348f5eee5d8e1b4fa6a937f0587eba89411885a36a8e1fc29bd2", size = 636983, upload-time = "2025-04-22T14:54:55.568Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1d/29944dcaaf5e482f7bff617de15f29e17cc0e74c7393888f8a43d7f6229e/greenlet-3.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5193135b3a8d0017cb438de0d49e92bf2f6c1c770331d24aa7500866f4db4017", size = 632880, upload-time = "2025-04-22T15:04:32.187Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c6/6c0891fd775b4fc5613593181526ba282771682dfe7bd0206d283403bcbb/greenlet-3.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:639a94d001fe874675b553f28a9d44faed90f9864dc57ba0afef3f8d76a18b04", size = 631638, upload-time = "2025-04-22T14:27:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/c0/50/3d8cadd4dfab17ef72bf0476cc2dacab368273ed29a79bbe66c36c6007a4/greenlet-3.2.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fe303381e7e909e42fb23e191fc69659910909fdcd056b92f6473f80ef18543", size = 580577, upload-time = "2025-04-22T14:25:54.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fe/bb0fc421318c69a840e5b98fdeea29d8dcb38f43ffe8b49664aeb10cc3dc/greenlet-3.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:72c9b668454e816b5ece25daac1a42c94d1c116d5401399a11b77ce8d883110c", size = 1109788, upload-time = "2025-04-22T14:58:54.243Z" }, + { url = "https://files.pythonhosted.org/packages/89/e9/db23a39effaef855deac9083a9054cbe34e1623dcbabed01e34a9d4174c7/greenlet-3.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6079ae990bbf944cf66bea64a09dcb56085815630955109ffa98984810d71565", size = 1133412, upload-time = "2025-04-22T14:28:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/a6ee52c59f75a387ec1f0c0075cf7981fb4644e4162afd3401dabeaa83ca/greenlet-3.2.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aa30066fd6862e1153eaae9b51b449a6356dcdb505169647f69e6ce315b9468b", size = 268609, upload-time = "2025-04-22T14:26:58.208Z" }, + { url = "https://files.pythonhosted.org/packages/ad/11/bd7a900629a4dd0e691dda88f8c2a7bfa44d0c4cffdb47eb5302f87a30d0/greenlet-3.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b0f3a0a67786facf3b907a25db80efe74310f9d63cc30869e49c79ee3fcef7e", size = 628776, upload-time = "2025-04-22T14:53:43.036Z" }, + { url = "https://files.pythonhosted.org/packages/46/f1/686754913fcc2707addadf815c884fd49c9f00a88e6dac277a1e1a8b8086/greenlet-3.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64a4d0052de53ab3ad83ba86de5ada6aeea8f099b4e6c9ccce70fb29bc02c6a2", size = 640827, upload-time = "2025-04-22T14:54:57.409Z" }, + { url = "https://files.pythonhosted.org/packages/03/74/bef04fa04125f6bcae2c1117e52f99c5706ac6ee90b7300b49b3bc18fc7d/greenlet-3.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852ef432919830022f71a040ff7ba3f25ceb9fe8f3ab784befd747856ee58530", size = 636752, upload-time = "2025-04-22T15:04:33.707Z" }, + { url = "https://files.pythonhosted.org/packages/aa/08/e8d493ab65ae1e9823638b8d0bf5d6b44f062221d424c5925f03960ba3d0/greenlet-3.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4818116e75a0dd52cdcf40ca4b419e8ce5cb6669630cb4f13a6c384307c9543f", size = 635993, upload-time = "2025-04-22T14:27:04.408Z" }, + { url = "https://files.pythonhosted.org/packages/1f/9d/3a3a979f2b019fb756c9a92cd5e69055aded2862ebd0437de109cf7472a2/greenlet-3.2.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9afa05fe6557bce1642d8131f87ae9462e2a8e8c46f7ed7929360616088a3975", size = 583927, upload-time = "2025-04-22T14:25:55.896Z" }, + { url = "https://files.pythonhosted.org/packages/59/21/a00d27d9abb914c1213926be56b2a2bf47999cf0baf67d9ef5b105b8eb5b/greenlet-3.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5c12f0d17a88664757e81a6e3fc7c2452568cf460a2f8fb44f90536b2614000b", size = 1112891, upload-time = "2025-04-22T14:58:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/20/c7/922082bf41f0948a78d703d75261d5297f3db894758317409e4677dc1446/greenlet-3.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dbb4e1aa2000852937dd8f4357fb73e3911da426df8ca9b8df5db231922da474", size = 1138318, upload-time = "2025-04-22T14:28:09.451Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d1/e4777b188a04726f6cf69047830d37365b9191017f54caf2f7af336a6f18/greenlet-3.2.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:0ba2811509a30e5f943be048895a983a8daf0b9aa0ac0ead526dfb5d987d80ea", size = 270381, upload-time = "2025-04-22T14:25:43.69Z" }, + { url = "https://files.pythonhosted.org/packages/59/e7/b5b738f5679247ddfcf2179c38945519668dced60c3164c20d55c1a7bb4a/greenlet-3.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4245246e72352b150a1588d43ddc8ab5e306bef924c26571aafafa5d1aaae4e8", size = 637195, upload-time = "2025-04-22T14:53:44.563Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9f/57968c88a5f6bc371364baf983a2e5549cca8f503bfef591b6dd81332cbc/greenlet-3.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7abc0545d8e880779f0c7ce665a1afc3f72f0ca0d5815e2b006cafc4c1cc5840", size = 651381, upload-time = "2025-04-22T14:54:59.439Z" }, + { url = "https://files.pythonhosted.org/packages/40/81/1533c9a458e9f2ebccb3ae22f1463b2093b0eb448a88aac36182f1c2cd3d/greenlet-3.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6dcc6d604a6575c6225ac0da39df9335cc0c6ac50725063fa90f104f3dbdb2c9", size = 646110, upload-time = "2025-04-22T15:04:35.739Z" }, + { url = "https://files.pythonhosted.org/packages/06/66/25f7e4b1468ebe4a520757f2e41c2a36a2f49a12e963431b82e9f98df2a0/greenlet-3.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2273586879affca2d1f414709bb1f61f0770adcabf9eda8ef48fd90b36f15d12", size = 648070, upload-time = "2025-04-22T14:27:05.976Z" }, + { url = "https://files.pythonhosted.org/packages/d7/4c/49d366565c4c4d29e6f666287b9e2f471a66c3a3d8d5066692e347f09e27/greenlet-3.2.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff38c869ed30fff07f1452d9a204ece1ec6d3c0870e0ba6e478ce7c1515acf22", size = 603816, upload-time = "2025-04-22T14:25:57.224Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/1612bb61506f44b6b8b6bebb6488702b1fe1432547e95dda57874303a1f5/greenlet-3.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e934591a7a4084fa10ee5ef50eb9d2ac8c4075d5c9cf91128116b5dca49d43b1", size = 1119572, upload-time = "2025-04-22T14:58:58.277Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2f/002b99dacd1610e825876f5cbbe7f86740aa2a6b76816e5eca41c8457e85/greenlet-3.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:063bcf7f8ee28eb91e7f7a8148c65a43b73fbdc0064ab693e024b5a940070145", size = 1147442, upload-time = "2025-04-22T14:28:11.243Z" }, + { url = "https://files.pythonhosted.org/packages/77/2a/581b3808afec55b2db838742527c40b4ce68b9b64feedff0fd0123f4b19a/greenlet-3.2.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:e1967882f0c42eaf42282a87579685c8673c51153b845fde1ee81be720ae27ac", size = 269119, upload-time = "2025-04-22T14:25:01.798Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f3/1c4e27fbdc84e13f05afc2baf605e704668ffa26e73a43eca93e1120813e/greenlet-3.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e77ae69032a95640a5fe8c857ec7bee569a0997e809570f4c92048691ce4b437", size = 637314, upload-time = "2025-04-22T14:53:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/9fc43cb0044f425f7252da9847893b6de4e3b20c0a748bce7ab3f063d5bc/greenlet-3.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3227c6ec1149d4520bc99edac3b9bc8358d0034825f3ca7572165cb502d8f29a", size = 651421, upload-time = "2025-04-22T14:55:00.852Z" }, + { url = "https://files.pythonhosted.org/packages/8a/65/d47c03cdc62c6680206b7420c4a98363ee997e87a5e9da1e83bd7eeb57a8/greenlet-3.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ddda0197c5b46eedb5628d33dad034c455ae77708c7bf192686e760e26d6a0c", size = 645789, upload-time = "2025-04-22T15:04:37.702Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/0faf8bee1b106c241780f377b9951dd4564ef0972de1942ef74687aa6bba/greenlet-3.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de62b542e5dcf0b6116c310dec17b82bb06ef2ceb696156ff7bf74a7a498d982", size = 648262, upload-time = "2025-04-22T14:27:07.55Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a8/73305f713183c2cb08f3ddd32eaa20a6854ba9c37061d682192db9b021c3/greenlet-3.2.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c07a0c01010df42f1f058b3973decc69c4d82e036a951c3deaf89ab114054c07", size = 606770, upload-time = "2025-04-22T14:25:58.34Z" }, + { url = "https://files.pythonhosted.org/packages/c3/05/7d726e1fb7f8a6ac55ff212a54238a36c57db83446523c763e20cd30b837/greenlet-3.2.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2530bfb0abcd451ea81068e6d0a1aac6dabf3f4c23c8bd8e2a8f579c2dd60d95", size = 1117960, upload-time = "2025-04-22T14:59:00.373Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9f/2b6cb1bd9f1537e7b08c08705c4a1d7bd4f64489c67d102225c4fd262bda/greenlet-3.2.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c472adfca310f849903295c351d297559462067f618944ce2650a1878b84123", size = 1145500, upload-time = "2025-04-22T14:28:12.441Z" }, + { url = "https://files.pythonhosted.org/packages/f1/72/2a251d74a596af7bb1717e891ad4275a3fd5ac06152319d7ad8c77f876af/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:175d583f7d5ee57845591fc30d852b75b144eb44b05f38b67966ed6df05c8526", size = 629889, upload-time = "2025-04-22T14:53:48.434Z" }, + { url = "https://files.pythonhosted.org/packages/29/2e/d7ed8bf97641bf704b6a43907c0e082cdf44d5bc026eb8e1b79283e7a719/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ecc9d33ca9428e4536ea53e79d781792cee114d2fa2695b173092bdbd8cd6d5", size = 635261, upload-time = "2025-04-22T14:55:02.258Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/802aa27848a6fcb5e566f69c64534f572e310f0f12d41e9201a81e741551/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f56382ac4df3860ebed8ed838f268f03ddf4e459b954415534130062b16bc32", size = 632523, upload-time = "2025-04-22T15:04:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/56/09/f7c1c3bab9b4c589ad356503dd71be00935e9c4db4db516ed88fc80f1187/greenlet-3.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc45a7189c91c0f89aaf9d69da428ce8301b0fd66c914a499199cfb0c28420fc", size = 628816, upload-time = "2025-04-22T14:27:08.869Z" }, + { url = "https://files.pythonhosted.org/packages/79/e0/1bb90d30b5450eac2dffeaac6b692857c4bd642c21883b79faa8fa056cf2/greenlet-3.2.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51a2f49da08cff79ee42eb22f1658a2aed60c72792f0a0a95f5f0ca6d101b1fb", size = 593687, upload-time = "2025-04-22T14:25:59.676Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/adbe03c8b4c178add20cc716021183ae6b0326d56ba8793d7828c94286f6/greenlet-3.2.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:0c68bbc639359493420282d2f34fa114e992a8724481d700da0b10d10a7611b8", size = 1105754, upload-time = "2025-04-22T14:59:02.585Z" }, + { url = "https://files.pythonhosted.org/packages/39/93/84582d7ef38dec009543ccadec6ab41079a6cbc2b8c0566bcd07bf1aaf6c/greenlet-3.2.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:e775176b5c203a1fa4be19f91da00fd3bff536868b77b237da3f4daa5971ae5d", size = 1125160, upload-time = "2025-04-22T14:28:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/01/e6/f9d759788518a6248684e3afeb3691f3ab0276d769b6217a1533362298c8/greenlet-3.2.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d6668caf15f181c1b82fb6406f3911696975cc4c37d782e19cb7ba499e556189", size = 269897, upload-time = "2025-04-22T14:27:14.044Z" }, +] + +[[package]] +name = "griffe" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/3e/5aa9a61f7c3c47b0b52a1d930302992229d191bf4bc76447b324b731510a/griffe-1.7.3.tar.gz", hash = "sha256:52ee893c6a3a968b639ace8015bec9d36594961e156e23315c8e8e51401fa50b", size = 395137, upload-time = "2025-04-23T11:29:09.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/c6/5c20af38c2a57c15d87f7f38bee77d63c1d2a3689f74fefaf35915dd12b2/griffe-1.7.3-py3-none-any.whl", hash = "sha256:c6b3ee30c2f0f17f30bcdef5068d6ab7a2a4f1b8bf1a3e74b56fffd21e1c5f75", size = 129303, upload-time = "2025-04-23T11:29:07.145Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1342,6 +1706,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/4b/2b81e876abf77b4af3372aff731f4f6722840ebc7dcfd85778eaba271733/httpx_oauth-0.16.1-py3-none-any.whl", hash = "sha256:2fcad82f80f28d0473a0fc4b4eda223dc952050af7e3a8c8781342d850f09fb5", size = 38056, upload-time = "2024-12-20T07:23:00.394Z" }, ] +[[package]] +name = "huggingface-hub" +version = "0.30.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "fsspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/22/8eb91736b1dcb83d879bd49050a09df29a57cc5cd9f38e48a4b1c45ee890/huggingface_hub-0.30.2.tar.gz", hash = "sha256:9a7897c5b6fd9dad3168a794a8998d6378210f5b9688d0dfc180b1a228dc2466", size = 400868, upload-time = "2025-04-08T08:32:45.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/27/1fb384a841e9661faad1c31cbfa62864f59632e876df5d795234da51c395/huggingface_hub-0.30.2-py3-none-any.whl", hash = "sha256:68ff05969927058cfa41df4f2155d4bb48f5f54f719dd0390103eefa9b191e28", size = 481433, upload-time = "2025-04-08T08:32:43.305Z" }, +] + +[package.optional-dependencies] +inference = [ + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] + [[package]] name = "humanize" version = "4.13.0" @@ -1410,11 +1797,11 @@ wheels = [ [[package]] name = "imap-tools" -version = "1.11.0" +version = "1.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/76/2d74bf4702d7d9fb2dd056e058929961a05389be47b990f3275e8596012e/imap_tools-1.11.0.tar.gz", hash = "sha256:77b055d301f24e668ff54ad50cc32a36d1579c6aa9b26e5fb6501fb622feb6ea", size = 46191, upload-time = "2025-06-30T05:47:21.111Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/eb/de91770dc8f34691c33355f7b6277500db32f68ce039c08e2a40ad5a0536/imap_tools-1.11.1.tar.gz", hash = "sha256:e3aa02ff3415a2b50a47707eacbf7386bb79aabc34e370c6bb95f9ad20504389", size = 46562, upload-time = "2026-01-15T08:25:47.357Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/8f/75524e1a040183cc437332e2de6e8f975c345fff8b5aaa35e0d20dec24f9/imap_tools-1.11.0-py3-none-any.whl", hash = "sha256:7c797b421fdf1b898b4ee0042fe02d10037d56f9acacca64086c2af36d830a24", size = 34855, upload-time = "2025-06-30T05:47:15.657Z" }, + { url = "https://files.pythonhosted.org/packages/64/bb/94eca066102559a20953475779a4d4d6b39712985014bceba726e1f65aab/imap_tools-1.11.1-py3-none-any.whl", hash = "sha256:0749d0a1f2b9041be1533ea98cc3e9f7977ba86ad3669c1fcf89c27969dcfb0a", size = 35291, upload-time = "2026-01-15T08:25:42.773Z" }, ] [[package]] @@ -1458,34 +1845,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] -[[package]] -name = "inotify-simple" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/5c/bfe40e15d684bc30b0073aa97c39be410a5fbef3d33cad6f0bf2012571e0/inotify_simple-2.0.1.tar.gz", hash = "sha256:f010bbbd8283bd71a9f4eb2de94765804ede24bd47320b0e6ef4136e541cdc2c", size = 7101, upload-time = "2025-08-25T06:28:20.998Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/86/8be1ac7e90f80b413e81f1e235148e8db771218886a2353392f02da01be3/inotify_simple-2.0.1-py3-none-any.whl", hash = "sha256:e5da495f2064889f8e68b67f9358b0d102e03b783c2d42e5b8e132ab859a5d8a", size = 7449, upload-time = "2025-08-25T06:28:19.919Z" }, -] - -[[package]] -name = "inotifyrecursive" -version = "0.3.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "inotify-simple", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/3a/9ed038cb750a3ba8090869cf3ad50f5628077a936d911aee14ca83e40f6a/inotifyrecursive-0.3.5.tar.gz", hash = "sha256:a2c450b317693e4538416f90eb1d7858506dafe6b8b885037bd2dd9ae2dafa1e", size = 4576, upload-time = "2020-11-20T12:38:48.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/fc/4e5a141c3f7c7bed550ac1f69e599e92b6be449dd4677ec09f325cad0955/inotifyrecursive-0.3.5-py3-none-any.whl", hash = "sha256:7e5f4a2e1dc2bef0efa3b5f6b339c41fb4599055a2b54909d020e9e932cc8d2f", size = 8009, upload-time = "2020-11-20T12:38:46.981Z" }, -] - [[package]] name = "isodate" version = "0.7.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705 } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320 }, + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, ] [[package]] @@ -1500,6 +1866,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jiter" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/c2/e4562507f52f0af7036da125bb699602ead37a2332af0788f8e0a3417f36/jiter-0.9.0.tar.gz", hash = "sha256:aadba0964deb424daa24492abc3d229c60c4a31bfee205aedbf1acc7639d7893", size = 162604, upload-time = "2025-03-10T21:37:03.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/82/39f7c9e67b3b0121f02a0b90d433626caa95a565c3d2449fea6bcfa3f5f5/jiter-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:816ec9b60fdfd1fec87da1d7ed46c66c44ffec37ab2ef7de5b147b2fce3fd5ad", size = 314540, upload-time = "2025-03-10T21:35:02.218Z" }, + { url = "https://files.pythonhosted.org/packages/01/07/7bf6022c5a152fca767cf5c086bb41f7c28f70cf33ad259d023b53c0b858/jiter-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b1d3086f8a3ee0194ecf2008cf81286a5c3e540d977fa038ff23576c023c0ea", size = 321065, upload-time = "2025-03-10T21:35:04.274Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b2/de3f3446ecba7c48f317568e111cc112613da36c7b29a6de45a1df365556/jiter-0.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1339f839b91ae30b37c409bf16ccd3dc453e8b8c3ed4bd1d6a567193651a4a51", size = 341664, upload-time = "2025-03-10T21:35:06.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/cf/6485a4012af5d407689c91296105fcdb080a3538e0658d2abf679619c72f/jiter-0.9.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ffba79584b3b670fefae66ceb3a28822365d25b7bf811e030609a3d5b876f538", size = 364635, upload-time = "2025-03-10T21:35:07.749Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f7/4a491c568f005553240b486f8e05c82547340572d5018ef79414b4449327/jiter-0.9.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cfc7d0a8e899089d11f065e289cb5b2daf3d82fbe028f49b20d7b809193958d", size = 406288, upload-time = "2025-03-10T21:35:09.238Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ca/f4263ecbce7f5e6bded8f52a9f1a66540b270c300b5c9f5353d163f9ac61/jiter-0.9.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e00a1a2bbfaaf237e13c3d1592356eab3e9015d7efd59359ac8b51eb56390a12", size = 397499, upload-time = "2025-03-10T21:35:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a2/522039e522a10bac2f2194f50e183a49a360d5f63ebf46f6d890ef8aa3f9/jiter-0.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1d9870561eb26b11448854dce0ff27a9a27cb616b632468cafc938de25e9e51", size = 352926, upload-time = "2025-03-10T21:35:13.85Z" }, + { url = "https://files.pythonhosted.org/packages/b1/67/306a5c5abc82f2e32bd47333a1c9799499c1c3a415f8dde19dbf876f00cb/jiter-0.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9872aeff3f21e437651df378cb75aeb7043e5297261222b6441a620218b58708", size = 384506, upload-time = "2025-03-10T21:35:15.735Z" }, + { url = "https://files.pythonhosted.org/packages/0f/89/c12fe7b65a4fb74f6c0d7b5119576f1f16c79fc2953641f31b288fad8a04/jiter-0.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1fd19112d1049bdd47f17bfbb44a2c0001061312dcf0e72765bfa8abd4aa30e5", size = 520621, upload-time = "2025-03-10T21:35:17.55Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2b/d57900c5c06e6273fbaa76a19efa74dbc6e70c7427ab421bf0095dfe5d4a/jiter-0.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6ef5da104664e526836070e4a23b5f68dec1cc673b60bf1edb1bfbe8a55d0678", size = 512613, upload-time = "2025-03-10T21:35:19.178Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/e241a043f114299254e44d7e777ead311da400517f179665e59611ab0ee4/jiter-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6c4d99c71508912a7e556d631768dcdef43648a93660670986916b297f1c54af", size = 314654, upload-time = "2025-03-10T21:35:23.939Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/a7e5e42db9fa262baaa9489d8d14ca93f8663e7f164ed5e9acc9f467fc00/jiter-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f60fb8ce7df529812bf6c625635a19d27f30806885139e367af93f6e734ef58", size = 320909, upload-time = "2025-03-10T21:35:26.127Z" }, + { url = "https://files.pythonhosted.org/packages/60/bf/8ebdfce77bc04b81abf2ea316e9c03b4a866a7d739cf355eae4d6fd9f6fe/jiter-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51c4e1a4f8ea84d98b7b98912aa4290ac3d1eabfde8e3c34541fae30e9d1f08b", size = 341733, upload-time = "2025-03-10T21:35:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4e/754ebce77cff9ab34d1d0fa0fe98f5d42590fd33622509a3ba6ec37ff466/jiter-0.9.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f4c677c424dc76684fea3e7285a7a2a7493424bea89ac441045e6a1fb1d7b3b", size = 365097, upload-time = "2025-03-10T21:35:29.605Z" }, + { url = "https://files.pythonhosted.org/packages/32/2c/6019587e6f5844c612ae18ca892f4cd7b3d8bbf49461ed29e384a0f13d98/jiter-0.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2221176dfec87f3470b21e6abca056e6b04ce9bff72315cb0b243ca9e835a4b5", size = 406603, upload-time = "2025-03-10T21:35:31.696Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/c9e6546c817ab75a1a7dab6dcc698e62e375e1017113e8e983fccbd56115/jiter-0.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c7adb66f899ffa25e3c92bfcb593391ee1947dbdd6a9a970e0d7e713237d572", size = 396625, upload-time = "2025-03-10T21:35:33.182Z" }, + { url = "https://files.pythonhosted.org/packages/be/bd/976b458add04271ebb5a255e992bd008546ea04bb4dcadc042a16279b4b4/jiter-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98d27330fdfb77913c1097a7aab07f38ff2259048949f499c9901700789ac15", size = 351832, upload-time = "2025-03-10T21:35:35.394Z" }, + { url = "https://files.pythonhosted.org/packages/07/51/fe59e307aaebec9265dbad44d9d4381d030947e47b0f23531579b9a7c2df/jiter-0.9.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eda3f8cc74df66892b1d06b5d41a71670c22d95a1ca2cbab73654745ce9d0419", size = 384590, upload-time = "2025-03-10T21:35:37.171Z" }, + { url = "https://files.pythonhosted.org/packages/db/55/5dcd2693794d8e6f4889389ff66ef3be557a77f8aeeca8973a97a7c00557/jiter-0.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dd5ab5ddc11418dce28343123644a100f487eaccf1de27a459ab36d6cca31043", size = 520690, upload-time = "2025-03-10T21:35:38.717Z" }, + { url = "https://files.pythonhosted.org/packages/54/d5/9f51dc90985e9eb251fbbb747ab2b13b26601f16c595a7b8baba964043bd/jiter-0.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:42f8a68a69f047b310319ef8e2f52fdb2e7976fb3313ef27df495cf77bcad965", size = 512649, upload-time = "2025-03-10T21:35:40.157Z" }, + { url = "https://files.pythonhosted.org/packages/af/d7/c55086103d6f29b694ec79156242304adf521577530d9031317ce5338c59/jiter-0.9.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7b46249cfd6c48da28f89eb0be3f52d6fdb40ab88e2c66804f546674e539ec11", size = 309203, upload-time = "2025-03-10T21:35:44.852Z" }, + { url = "https://files.pythonhosted.org/packages/b0/01/f775dfee50beb420adfd6baf58d1c4d437de41c9b666ddf127c065e5a488/jiter-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:609cf3c78852f1189894383cf0b0b977665f54cb38788e3e6b941fa6d982c00e", size = 319678, upload-time = "2025-03-10T21:35:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b8/09b73a793714726893e5d46d5c534a63709261af3d24444ad07885ce87cb/jiter-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d726a3890a54561e55a9c5faea1f7655eda7f105bd165067575ace6e65f80bb2", size = 341816, upload-time = "2025-03-10T21:35:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/35/6f/b8f89ec5398b2b0d344257138182cc090302854ed63ed9c9051e9c673441/jiter-0.9.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e89dc075c1fef8fa9be219e249f14040270dbc507df4215c324a1839522ea75", size = 364152, upload-time = "2025-03-10T21:35:49.397Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/978cc3183113b8e4484cc7e210a9ad3c6614396e7abd5407ea8aa1458eef/jiter-0.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04e8ffa3c353b1bc4134f96f167a2082494351e42888dfcf06e944f2729cbe1d", size = 406991, upload-time = "2025-03-10T21:35:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/13/3a/72861883e11a36d6aa314b4922125f6ae90bdccc225cd96d24cc78a66385/jiter-0.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:203f28a72a05ae0e129b3ed1f75f56bc419d5f91dfacd057519a8bd137b00c42", size = 395824, upload-time = "2025-03-10T21:35:52.162Z" }, + { url = "https://files.pythonhosted.org/packages/87/67/22728a86ef53589c3720225778f7c5fdb617080e3deaed58b04789418212/jiter-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca1a02ad60ec30bb230f65bc01f611c8608b02d269f998bc29cca8619a919dc", size = 351318, upload-time = "2025-03-10T21:35:53.566Z" }, + { url = "https://files.pythonhosted.org/packages/69/b9/f39728e2e2007276806d7a6609cda7fac44ffa28ca0d02c49a4f397cc0d9/jiter-0.9.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:237e5cee4d5d2659aaf91bbf8ec45052cc217d9446070699441a91b386ae27dc", size = 384591, upload-time = "2025-03-10T21:35:54.95Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8f/8a708bc7fd87b8a5d861f1c118a995eccbe6d672fe10c9753e67362d0dd0/jiter-0.9.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:528b6b71745e7326eed73c53d4aa57e2a522242320b6f7d65b9c5af83cf49b6e", size = 520746, upload-time = "2025-03-10T21:35:56.444Z" }, + { url = "https://files.pythonhosted.org/packages/95/1e/65680c7488bd2365dbd2980adaf63c562d3d41d3faac192ebc7ef5b4ae25/jiter-0.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9f48e86b57bc711eb5acdfd12b6cb580a59cc9a993f6e7dcb6d8b50522dcd50d", size = 512754, upload-time = "2025-03-10T21:35:58.789Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1b/4cd165c362e8f2f520fdb43245e2b414f42a255921248b4f8b9c8d871ff1/jiter-0.9.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2764891d3f3e8b18dce2cff24949153ee30c9239da7c00f032511091ba688ff7", size = 308197, upload-time = "2025-03-10T21:36:03.828Z" }, + { url = "https://files.pythonhosted.org/packages/13/aa/7a890dfe29c84c9a82064a9fe36079c7c0309c91b70c380dc138f9bea44a/jiter-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:387b22fbfd7a62418d5212b4638026d01723761c75c1c8232a8b8c37c2f1003b", size = 318160, upload-time = "2025-03-10T21:36:05.281Z" }, + { url = "https://files.pythonhosted.org/packages/6a/38/5888b43fc01102f733f085673c4f0be5a298f69808ec63de55051754e390/jiter-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d8da8629ccae3606c61d9184970423655fb4e33d03330bcdfe52d234d32f69", size = 341259, upload-time = "2025-03-10T21:36:06.716Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5e/bbdbb63305bcc01006de683b6228cd061458b9b7bb9b8d9bc348a58e5dc2/jiter-0.9.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1be73d8982bdc278b7b9377426a4b44ceb5c7952073dd7488e4ae96b88e1103", size = 363730, upload-time = "2025-03-10T21:36:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/75/85/53a3edc616992fe4af6814c25f91ee3b1e22f7678e979b6ea82d3bc0667e/jiter-0.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2228eaaaa111ec54b9e89f7481bffb3972e9059301a878d085b2b449fbbde635", size = 405126, upload-time = "2025-03-10T21:36:10.934Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b3/1ee26b12b2693bd3f0b71d3188e4e5d817b12e3c630a09e099e0a89e28fa/jiter-0.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11509bfecbc319459647d4ac3fd391d26fdf530dad00c13c4dadabf5b81f01a4", size = 393668, upload-time = "2025-03-10T21:36:12.468Z" }, + { url = "https://files.pythonhosted.org/packages/11/87/e084ce261950c1861773ab534d49127d1517b629478304d328493f980791/jiter-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f22238da568be8bbd8e0650e12feeb2cfea15eda4f9fc271d3b362a4fa0604d", size = 352350, upload-time = "2025-03-10T21:36:14.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/06/7dca84b04987e9df563610aa0bc154ea176e50358af532ab40ffb87434df/jiter-0.9.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17f5d55eb856597607562257c8e36c42bc87f16bef52ef7129b7da11afc779f3", size = 384204, upload-time = "2025-03-10T21:36:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/16/2f/82e1c6020db72f397dd070eec0c85ebc4df7c88967bc86d3ce9864148f28/jiter-0.9.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6a99bed9fbb02f5bed416d137944419a69aa4c423e44189bc49718859ea83bc5", size = 520322, upload-time = "2025-03-10T21:36:17.016Z" }, + { url = "https://files.pythonhosted.org/packages/36/fd/4f0cd3abe83ce208991ca61e7e5df915aa35b67f1c0633eb7cf2f2e88ec7/jiter-0.9.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e057adb0cd1bd39606100be0eafe742de2de88c79df632955b9ab53a086b3c8d", size = 512184, upload-time = "2025-03-10T21:36:18.47Z" }, + { url = "https://files.pythonhosted.org/packages/78/0f/77a63ca7aa5fed9a1b9135af57e190d905bcd3702b36aca46a01090d39ad/jiter-0.9.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f0b2827fb88dda2cbecbbc3e596ef08d69bda06c6f57930aec8e79505dc17001", size = 317281, upload-time = "2025-03-10T21:36:22.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/a3a1571712c2bf6ec4c657f0d66da114a63a2e32b7e4eb8e0b83295ee034/jiter-0.9.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:062b756ceb1d40b0b28f326cba26cfd575a4918415b036464a52f08632731e5a", size = 350273, upload-time = "2025-03-10T21:36:24.414Z" }, +] + [[package]] name = "joblib" version = "1.5.2" @@ -1565,6 +1981,193 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" } +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/13/57b06758a13550c5f09563893b004f98e9537ee6ec67b7df85c3571c8832/librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d", size = 56521, upload-time = "2026-01-14T12:54:40.066Z" }, + { url = "https://files.pythonhosted.org/packages/c2/24/bbea34d1452a10612fb45ac8356f95351ba40c2517e429602160a49d1fd0/librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b", size = 58456, upload-time = "2026-01-14T12:54:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/04/72/a168808f92253ec3a810beb1eceebc465701197dbc7e865a1c9ceb3c22c7/librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d", size = 164392, upload-time = "2026-01-14T12:54:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/14/5c/4c0d406f1b02735c2e7af8ff1ff03a6577b1369b91aa934a9fa2cc42c7ce/librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d", size = 172959, upload-time = "2026-01-14T12:54:44.602Z" }, + { url = "https://files.pythonhosted.org/packages/82/5f/3e85351c523f73ad8d938989e9a58c7f59fb9c17f761b9981b43f0025ce7/librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c", size = 186717, upload-time = "2026-01-14T12:54:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/08/f8/18bfe092e402d00fe00d33aa1e01dda1bd583ca100b393b4373847eade6d/librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c", size = 184585, upload-time = "2026-01-14T12:54:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fc/f43972ff56fd790a9fa55028a52ccea1875100edbb856b705bd393b601e3/librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d", size = 180497, upload-time = "2026-01-14T12:54:48.946Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3a/25e36030315a410d3ad0b7d0f19f5f188e88d1613d7d3fd8150523ea1093/librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0", size = 200052, upload-time = "2026-01-14T12:54:50.382Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, +] + +[[package]] +name = "llama-index-core" +version = "0.14.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "aiosqlite", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "banks", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "dataclasses-json", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "deprecated", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "dirtyjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "filetype", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "fsspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "llama-index-workflows", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "nest-asyncio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "networkx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "nltk", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, + { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux')" }, + { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "setuptools", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sqlalchemy", extra = ["asyncio"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "tenacity", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-inspect", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/d3/9d65f3c631a41fbb0dac47c52adad0fdbbaee3456518a97d558d8c754788/llama_index_core-0.14.12.tar.gz", hash = "sha256:6917e5865c6c789046dca001ebeea5a7f80e1ba83ac646dc793aaa041e8feb12", size = 11584083, upload-time = "2025-12-30T01:06:24.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/c7/a39d65aeb7b1df234719e918a27b9122eeee81744e2e39f60b12a57a3e09/llama_index_core-0.14.12-py3-none-any.whl", hash = "sha256:a3a7e3a084b01700458874dd635ba40d00af2680daa47f072e357e8f0d172872", size = 11927498, upload-time = "2025-12-30T01:06:27.459Z" }, +] + +[[package]] +name = "llama-index-embeddings-huggingface" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub", extra = ["inference"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sentence-transformers", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/a0/77beca4ed28af68db6ab9c647b3fa75fae905d33ace96e91010cc9b96027/llama_index_embeddings_huggingface-0.6.1.tar.gz", hash = "sha256:3b21ffeda22f8221ed55778bb3daed71664ab07b341f1dd2f408963bd20355b9", size = 8694, upload-time = "2025-09-08T20:25:27.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/b0/4d327cb2f2e039606feb9345b4de975090b9659c4ee84b00443bb149a80d/llama_index_embeddings_huggingface-0.6.1-py3-none-any.whl", hash = "sha256:b63990cf71ee7a36c51f36657133fcf76130e9bf5dcf9eb5a73a5087106d6881", size = 8903, upload-time = "2025-09-08T20:25:27.038Z" }, +] + +[[package]] +name = "llama-index-embeddings-openai" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/36/90336d054a5061a3f5bc17ac2c18ef63d9d84c55c14d557de484e811ea4d/llama_index_embeddings_openai-0.5.1.tar.gz", hash = "sha256:1c89867a48b0d0daa3d2d44f5e76b394b2b2ef9935932daf921b9e77939ccda8", size = 7020, upload-time = "2025-09-08T20:17:44.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/4a/8ab11026cf8deff8f555aa73919be0bac48332683111e5fc4290f352dc50/llama_index_embeddings_openai-0.5.1-py3-none-any.whl", hash = "sha256:a2fcda3398bbd987b5ce3f02367caee8e84a56b930fdf43cc1d059aa9fd20ca5", size = 7011, upload-time = "2025-09-08T20:17:44.015Z" }, +] + +[[package]] +name = "llama-index-instrumentation" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/b9/a7a74de6d8aacf4be329329495983d78d96b1a6e69b6d9fcf4a233febd4b/llama_index_instrumentation-0.4.2.tar.gz", hash = "sha256:dc4957b64da0922060690e85a6be9698ac08e34e0f69e90b01364ddec4f3de7f", size = 46146, upload-time = "2025-10-13T20:44:48.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/54/df8063b0441242e250e03d1e31ebde5dffbe24e1af32b025cb1a4544150c/llama_index_instrumentation-0.4.2-py3-none-any.whl", hash = "sha256:b4989500e6454059ab3f3c4a193575d47ab1fadb730c2e8f2b962649ae88b70b", size = 15411, upload-time = "2025-10-13T20:44:47.685Z" }, +] + +[[package]] +name = "llama-index-llms-ollama" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/42/866e43f86ebe6c8ae0c3a2a473642f1438d0f7e006188a998c3ef4e5e357/llama_index_llms_ollama-0.9.1.tar.gz", hash = "sha256:d5885ed65ae2e2bc74ba9e3ffd3a5bcd7c5341ef0670e3d9fe200880fc19f9a6", size = 9077, upload-time = "2025-12-19T03:24:46.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/cd/aa2499dc38c1cc513cab56f9a74e5526d1a97717f35a62295fda0d363bf6/llama_index_llms_ollama-0.9.1-py3-none-any.whl", hash = "sha256:da6469be951605841f7e33ce1bbb5b72c65de0f084b250e7f78aacac84379d5f", size = 8721, upload-time = "2025-12-19T03:24:47.438Z" }, +] + +[[package]] +name = "llama-index-llms-openai" +version = "0.6.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/f9/8ee42db8ce58e72846c119ba2061facbf3475f648506990a61ccf0d5d643/llama_index_llms_openai-0.6.13.tar.gz", hash = "sha256:e3b7422bc72276e00a980d826477d0b14d5bf743ba69c4a4f0bdee0f5225d450", size = 25784, upload-time = "2026-01-13T11:42:12.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/42/ab78f9c472d99552e4a1227a097d017b2f8ec8814cfec1e9813c036cd37d/llama_index_llms_openai-0.6.13-py3-none-any.whl", hash = "sha256:f0f8665381eb8e553de187a492da999830817733357364f151a3d4f3e3db746f", size = 26787, upload-time = "2026-01-13T11:42:13.812Z" }, +] + +[[package]] +name = "llama-index-vector-stores-faiss" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/5f/c4ae340f178f202cf09dcc24dd0953a41d9ab24bc33e1f7220544ba86e41/llama_index_vector_stores_faiss-0.5.2.tar.gz", hash = "sha256:924504765e68b1f84ec602feb2d9516be6a6c12fad5e133f19cc5da3b23f4282", size = 5910, upload-time = "2025-12-17T21:01:13.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/c1/c8317250c2a83d1d439814d1a7f41fa34a23c224b3099da898f08a249859/llama_index_vector_stores_faiss-0.5.2-py3-none-any.whl", hash = "sha256:72a3a03d9f25c70bbcc8c61aa860cd1db69f2a8070606ecc3266d767b71ff2a2", size = 7605, upload-time = "2025-12-17T21:01:12.429Z" }, +] + +[[package]] +name = "llama-index-workflows" +version = "2.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llama-index-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/ee/8c58554942933f33752ccb86451ea0a15493808eb934f4899e4d2c43a408/llama_index_workflows-2.12.2.tar.gz", hash = "sha256:37e05cd3483c64f410176fe614db8c84b6f42fc32cdadb3cc8ac8de18f01a97b", size = 79771, upload-time = "2026-01-15T20:30:24.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2a/4188d3cb65c539fcac3a467f77d7ac32d6136d0802d0b2ba113d51adfbf6/llama_index_workflows-2.12.2-py3-none-any.whl", hash = "sha256:888baf7e557f7fbe10d442f354bdc3415390757f0ac9268d32f89401128ae508", size = 102617, upload-time = "2026-01-15T20:30:25.42Z" }, +] + [[package]] name = "lxml" version = "6.0.2" @@ -1763,6 +2366,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, ] +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1832,7 +2447,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.7.0" +version = "9.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -1847,9 +2462,9 @@ dependencies = [ { name = "pymdown-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/3b/111b84cd6ff28d9e955b5f799ef217a17bc1684ac346af333e6100e413cb/mkdocs_material-9.7.0.tar.gz", hash = "sha256:602b359844e906ee402b7ed9640340cf8a474420d02d8891451733b6b02314ec", size = 4094546, upload-time = "2025-11-11T08:49:09.73Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/87/eefe8d5e764f4cf50ed91b943f8e8f96b5efd65489d8303b7a36e2e79834/mkdocs_material-9.7.0-py3-none-any.whl", hash = "sha256:da2866ea53601125ff5baa8aa06404c6e07af3c5ce3d5de95e3b52b80b442887", size = 9283770, upload-time = "2025-11-11T08:49:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, ] [[package]] @@ -1861,6 +2476,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + [[package]] name = "msgpack" version = "1.1.1" @@ -1902,43 +2526,131 @@ wheels = [ ] [[package]] -name = "mypy" -version = "1.18.2" +name = "multidict" +version = "6.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/2c/e367dfb4c6538614a0c9453e510d75d66099edf1c4e69da1b5ce691a1931/multidict-6.4.3.tar.gz", hash = "sha256:3ada0b058c9f213c5f95ba301f922d402ac234f1111a7d8fd70f1b99f3c281ec", size = 89372, upload-time = "2025-04-10T22:20:17.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/44/45e798d4cd1b5dfe41ddf36266c7aca6d954e3c7a8b0d599ad555ce2b4f8/multidict-6.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32a998bd8a64ca48616eac5a8c1cc4fa38fb244a3facf2eeb14abe186e0f6cc5", size = 65822, upload-time = "2025-04-10T22:17:32.83Z" }, + { url = "https://files.pythonhosted.org/packages/10/fb/9ea024f928503f8c758f8463759d21958bf27b1f7a1103df73e5022e6a7c/multidict-6.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a54ec568f1fc7f3c313c2f3b16e5db346bf3660e1309746e7fccbbfded856188", size = 38706, upload-time = "2025-04-10T22:17:35.028Z" }, + { url = "https://files.pythonhosted.org/packages/6d/eb/7013316febca37414c0e1469fccadcb1a0e4315488f8f57ca5d29b384863/multidict-6.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7be07e5df178430621c716a63151165684d3e9958f2bbfcb644246162007ab7", size = 37979, upload-time = "2025-04-10T22:17:36.626Z" }, + { url = "https://files.pythonhosted.org/packages/64/28/5a7bf4e7422613ea80f9ebc529d3845b20a422cfa94d4355504ac98047ee/multidict-6.4.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b128dbf1c939674a50dd0b28f12c244d90e5015e751a4f339a96c54f7275e291", size = 220233, upload-time = "2025-04-10T22:17:37.807Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/b4c58850f71befde6a16548968b48331a155a80627750b150bb5962e4dea/multidict-6.4.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b9cb19dfd83d35b6ff24a4022376ea6e45a2beba8ef3f0836b8a4b288b6ad685", size = 217762, upload-time = "2025-04-10T22:17:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/99/a3/393e23bba1e9a00f95b3957acd8f5e3ee3446e78c550f593be25f9de0483/multidict-6.4.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3cf62f8e447ea2c1395afa289b332e49e13d07435369b6f4e41f887db65b40bf", size = 230699, upload-time = "2025-04-10T22:17:41.207Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a7/52c63069eb1a079f824257bb8045d93e692fa2eb34d08323d1fdbdfc398a/multidict-6.4.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:909f7d43ff8f13d1adccb6a397094adc369d4da794407f8dd592c51cf0eae4b1", size = 226801, upload-time = "2025-04-10T22:17:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e9/40d2b73e7d6574d91074d83477a990e3701affbe8b596010d4f5e6c7a6fa/multidict-6.4.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0bb8f8302fbc7122033df959e25777b0b7659b1fd6bcb9cb6bed76b5de67afef", size = 219833, upload-time = "2025-04-10T22:17:44.046Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6a/0572b22fe63c632254f55a1c1cb7d29f644002b1d8731d6103a290edc754/multidict-6.4.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:224b79471b4f21169ea25ebc37ed6f058040c578e50ade532e2066562597b8a9", size = 212920, upload-time = "2025-04-10T22:17:45.48Z" }, + { url = "https://files.pythonhosted.org/packages/33/fe/c63735db9dece0053868b2d808bcc2592a83ce1830bc98243852a2b34d42/multidict-6.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a7bd27f7ab3204f16967a6f899b3e8e9eb3362c0ab91f2ee659e0345445e0078", size = 225263, upload-time = "2025-04-10T22:17:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/2db296d64d41525110c27ed38fadd5eb571c6b936233e75a5ea61b14e337/multidict-6.4.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:99592bd3162e9c664671fd14e578a33bfdba487ea64bcb41d281286d3c870ad7", size = 214249, upload-time = "2025-04-10T22:17:48.95Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/8bc26e54c79f9a0f111350b1b28a9cacaaee53ecafccd53c90e59754d55a/multidict-6.4.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a62d78a1c9072949018cdb05d3c533924ef8ac9bcb06cbf96f6d14772c5cd451", size = 221650, upload-time = "2025-04-10T22:17:50.265Z" }, + { url = "https://files.pythonhosted.org/packages/af/d7/2ce87606e3799d9a08a941f4c170930a9895886ea8bd0eca75c44baeebe3/multidict-6.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3ccdde001578347e877ca4f629450973c510e88e8865d5aefbcb89b852ccc666", size = 231235, upload-time = "2025-04-10T22:17:51.579Z" }, + { url = "https://files.pythonhosted.org/packages/07/e1/d191a7ad3b90c613fc4b130d07a41c380e249767586148709b54d006ca17/multidict-6.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:eccb67b0e78aa2e38a04c5ecc13bab325a43e5159a181a9d1a6723db913cbb3c", size = 226056, upload-time = "2025-04-10T22:17:53.092Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/a57490cf6a8d5854f4af2d17dfc54924f37fbb683986e133b76710a36079/multidict-6.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b6fcf6054fc4114a27aa865f8840ef3d675f9316e81868e0ad5866184a6cba5", size = 220014, upload-time = "2025-04-10T22:17:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/16/e0/53cf7f27eda48fffa53cfd4502329ed29e00efb9e4ce41362cbf8aa54310/multidict-6.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f6f19170197cc29baccd33ccc5b5d6a331058796485857cf34f7635aa25fb0cd", size = 65259, upload-time = "2025-04-10T22:17:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/44/79/1dcd93ce7070cf01c2ee29f781c42b33c64fce20033808f1cc9ec8413d6e/multidict-6.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f2882bf27037eb687e49591690e5d491e677272964f9ec7bc2abbe09108bdfb8", size = 38451, upload-time = "2025-04-10T22:18:01.202Z" }, + { url = "https://files.pythonhosted.org/packages/f4/35/2292cf29ab5f0d0b3613fad1b75692148959d3834d806be1885ceb49a8ff/multidict-6.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fbf226ac85f7d6b6b9ba77db4ec0704fde88463dc17717aec78ec3c8546c70ad", size = 37706, upload-time = "2025-04-10T22:18:02.276Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d1/6b157110b2b187b5a608b37714acb15ee89ec773e3800315b0107ea648cd/multidict-6.4.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e329114f82ad4b9dd291bef614ea8971ec119ecd0f54795109976de75c9a852", size = 226669, upload-time = "2025-04-10T22:18:03.436Z" }, + { url = "https://files.pythonhosted.org/packages/40/7f/61a476450651f177c5570e04bd55947f693077ba7804fe9717ee9ae8de04/multidict-6.4.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1f4e0334d7a555c63f5c8952c57ab6f1c7b4f8c7f3442df689fc9f03df315c08", size = 223182, upload-time = "2025-04-10T22:18:04.922Z" }, + { url = "https://files.pythonhosted.org/packages/51/7b/eaf7502ac4824cdd8edcf5723e2e99f390c879866aec7b0c420267b53749/multidict-6.4.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:740915eb776617b57142ce0bb13b7596933496e2f798d3d15a20614adf30d229", size = 235025, upload-time = "2025-04-10T22:18:06.274Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f6/facdbbd73c96b67a93652774edd5778ab1167854fa08ea35ad004b1b70ad/multidict-6.4.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255dac25134d2b141c944b59a0d2f7211ca12a6d4779f7586a98b4b03ea80508", size = 231481, upload-time = "2025-04-10T22:18:07.742Z" }, + { url = "https://files.pythonhosted.org/packages/70/57/c008e861b3052405eebf921fd56a748322d8c44dcfcab164fffbccbdcdc4/multidict-6.4.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4e8535bd4d741039b5aad4285ecd9b902ef9e224711f0b6afda6e38d7ac02c7", size = 223492, upload-time = "2025-04-10T22:18:09.095Z" }, + { url = "https://files.pythonhosted.org/packages/30/4d/7d8440d3a12a6ae5d6b202d6e7f2ac6ab026e04e99aaf1b73f18e6bc34bc/multidict-6.4.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c433a33be000dd968f5750722eaa0991037be0be4a9d453eba121774985bc8", size = 217279, upload-time = "2025-04-10T22:18:10.474Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e7/bca0df4dd057597b94138d2d8af04eb3c27396a425b1b0a52e082f9be621/multidict-6.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4eb33b0bdc50acd538f45041f5f19945a1f32b909b76d7b117c0c25d8063df56", size = 228733, upload-time = "2025-04-10T22:18:11.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/383827c3f1c38d7c92dbad00a8a041760228573b1c542fbf245c37bbca8a/multidict-6.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:75482f43465edefd8a5d72724887ccdcd0c83778ded8f0cb1e0594bf71736cc0", size = 218089, upload-time = "2025-04-10T22:18:13.153Z" }, + { url = "https://files.pythonhosted.org/packages/36/8a/a5174e8a7d8b94b4c8f9c1e2cf5d07451f41368ffe94d05fc957215b8e72/multidict-6.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce5b3082e86aee80b3925ab4928198450d8e5b6466e11501fe03ad2191c6d777", size = 225257, upload-time = "2025-04-10T22:18:14.654Z" }, + { url = "https://files.pythonhosted.org/packages/8c/76/1d4b7218f0fd00b8e5c90b88df2e45f8af127f652f4e41add947fa54c1c4/multidict-6.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e413152e3212c4d39f82cf83c6f91be44bec9ddea950ce17af87fbf4e32ca6b2", size = 234728, upload-time = "2025-04-10T22:18:16.236Z" }, + { url = "https://files.pythonhosted.org/packages/64/44/18372a4f6273fc7ca25630d7bf9ae288cde64f29593a078bff450c7170b6/multidict-6.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8aac2eeff69b71f229a405c0a4b61b54bade8e10163bc7b44fcd257949620618", size = 230087, upload-time = "2025-04-10T22:18:17.979Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/28728c314a698d8a6d9491fcacc897077348ec28dd85884d09e64df8a855/multidict-6.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ab583ac203af1d09034be41458feeab7863c0635c650a16f15771e1386abf2d7", size = 223137, upload-time = "2025-04-10T22:18:19.362Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bb/3abdaf8fe40e9226ce8a2ba5ecf332461f7beec478a455d6587159f1bf92/multidict-6.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1f1c2f58f08b36f8475f3ec6f5aeb95270921d418bf18f90dffd6be5c7b0e676", size = 64019, upload-time = "2025-04-10T22:18:23.174Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b5/1b2e8de8217d2e89db156625aa0fe4a6faad98972bfe07a7b8c10ef5dd6b/multidict-6.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:26ae9ad364fc61b936fb7bf4c9d8bd53f3a5b4417142cd0be5c509d6f767e2f1", size = 37925, upload-time = "2025-04-10T22:18:24.834Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e2/3ca91c112644a395c8eae017144c907d173ea910c913ff8b62549dcf0bbf/multidict-6.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:659318c6c8a85f6ecfc06b4e57529e5a78dfdd697260cc81f683492ad7e9435a", size = 37008, upload-time = "2025-04-10T22:18:26.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/79bc78146c7ac8d1ac766b2770ca2e07c2816058b8a3d5da6caed8148637/multidict-6.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1eb72c741fd24d5a28242ce72bb61bc91f8451877131fa3fe930edb195f7054", size = 224374, upload-time = "2025-04-10T22:18:27.714Z" }, + { url = "https://files.pythonhosted.org/packages/86/35/77950ed9ebd09136003a85c1926ba42001ca5be14feb49710e4334ee199b/multidict-6.4.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cd06d88cb7398252284ee75c8db8e680aa0d321451132d0dba12bc995f0adcc", size = 230869, upload-time = "2025-04-10T22:18:29.162Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/2a33c6e7d90bc116c636c14b2abab93d6521c0c052d24bfcc231cbf7f0e7/multidict-6.4.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4543d8dc6470a82fde92b035a92529317191ce993533c3c0c68f56811164ed07", size = 231949, upload-time = "2025-04-10T22:18:30.679Z" }, + { url = "https://files.pythonhosted.org/packages/56/ce/e9b5d9fcf854f61d6686ada7ff64893a7a5523b2a07da6f1265eaaea5151/multidict-6.4.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30a3ebdc068c27e9d6081fca0e2c33fdf132ecea703a72ea216b81a66860adde", size = 231032, upload-time = "2025-04-10T22:18:32.146Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ac/7ced59dcdfeddd03e601edb05adff0c66d81ed4a5160c443e44f2379eef0/multidict-6.4.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b038f10e23f277153f86f95c777ba1958bcd5993194fda26a1d06fae98b2f00c", size = 223517, upload-time = "2025-04-10T22:18:33.538Z" }, + { url = "https://files.pythonhosted.org/packages/db/e6/325ed9055ae4e085315193a1b58bdb4d7fc38ffcc1f4975cfca97d015e17/multidict-6.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c605a2b2dc14282b580454b9b5d14ebe0668381a3a26d0ac39daa0ca115eb2ae", size = 216291, upload-time = "2025-04-10T22:18:34.962Z" }, + { url = "https://files.pythonhosted.org/packages/fa/84/eeee6d477dd9dcb7691c3bb9d08df56017f5dd15c730bcc9383dcf201cf4/multidict-6.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8bd2b875f4ca2bb527fe23e318ddd509b7df163407b0fb717df229041c6df5d3", size = 228982, upload-time = "2025-04-10T22:18:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/82/94/4d1f3e74e7acf8b0c85db350e012dcc61701cd6668bc2440bb1ecb423c90/multidict-6.4.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c2e98c840c9c8e65c0e04b40c6c5066c8632678cd50c8721fdbcd2e09f21a507", size = 226823, upload-time = "2025-04-10T22:18:37.924Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/1e54b95bda7cd01080e5732f9abb7b76ab5cc795b66605877caeb2197476/multidict-6.4.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66eb80dd0ab36dbd559635e62fba3083a48a252633164857a1d1684f14326427", size = 222714, upload-time = "2025-04-10T22:18:39.807Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a2/f6cbca875195bd65a3e53b37ab46486f3cc125bdeab20eefe5042afa31fb/multidict-6.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c23831bdee0a2a3cf21be057b5e5326292f60472fb6c6f86392bbf0de70ba731", size = 233739, upload-time = "2025-04-10T22:18:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/79/68/9891f4d2b8569554723ddd6154375295f789dc65809826c6fb96a06314fd/multidict-6.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1535cec6443bfd80d028052e9d17ba6ff8a5a3534c51d285ba56c18af97e9713", size = 230809, upload-time = "2025-04-10T22:18:42.817Z" }, + { url = "https://files.pythonhosted.org/packages/e6/72/a7be29ba1e87e4fc5ceb44dabc7940b8005fd2436a332a23547709315f70/multidict-6.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b73e7227681f85d19dec46e5b881827cd354aabe46049e1a61d2f9aaa4e285a", size = 226934, upload-time = "2025-04-10T22:18:44.311Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4b/86fd786d03915c6f49998cf10cd5fe6b6ac9e9a071cb40885d2e080fb90d/multidict-6.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a76534263d03ae0cfa721fea40fd2b5b9d17a6f85e98025931d41dc49504474", size = 63831, upload-time = "2025-04-10T22:18:48.748Z" }, + { url = "https://files.pythonhosted.org/packages/45/05/9b51fdf7aef2563340a93be0a663acba2c428c4daeaf3960d92d53a4a930/multidict-6.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:805031c2f599eee62ac579843555ed1ce389ae00c7e9f74c2a1b45e0564a88dd", size = 37888, upload-time = "2025-04-10T22:18:50.021Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/53fc25394386c911822419b522181227ca450cf57fea76e6188772a1bd91/multidict-6.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c56c179839d5dcf51d565132185409d1d5dd8e614ba501eb79023a6cab25576b", size = 36852, upload-time = "2025-04-10T22:18:51.246Z" }, + { url = "https://files.pythonhosted.org/packages/8a/68/7b99c751e822467c94a235b810a2fd4047d4ecb91caef6b5c60116991c4b/multidict-6.4.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c64f4ddb3886dd8ab71b68a7431ad4aa01a8fa5be5b11543b29674f29ca0ba3", size = 223644, upload-time = "2025-04-10T22:18:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/80/1b/d458d791e4dd0f7e92596667784fbf99e5c8ba040affe1ca04f06b93ae92/multidict-6.4.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3002a856367c0b41cad6784f5b8d3ab008eda194ed7864aaa58f65312e2abcac", size = 230446, upload-time = "2025-04-10T22:18:54.509Z" }, + { url = "https://files.pythonhosted.org/packages/e2/46/9793378d988905491a7806d8987862dc5a0bae8a622dd896c4008c7b226b/multidict-6.4.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d75e621e7d887d539d6e1d789f0c64271c250276c333480a9e1de089611f790", size = 231070, upload-time = "2025-04-10T22:18:56.019Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b8/b127d3e1f8dd2a5bf286b47b24567ae6363017292dc6dec44656e6246498/multidict-6.4.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:995015cf4a3c0d72cbf453b10a999b92c5629eaf3a0c3e1efb4b5c1f602253bb", size = 229956, upload-time = "2025-04-10T22:18:59.146Z" }, + { url = "https://files.pythonhosted.org/packages/0c/93/f70a4c35b103fcfe1443059a2bb7f66e5c35f2aea7804105ff214f566009/multidict-6.4.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2b0fabae7939d09d7d16a711468c385272fa1b9b7fb0d37e51143585d8e72e0", size = 222599, upload-time = "2025-04-10T22:19:00.657Z" }, + { url = "https://files.pythonhosted.org/packages/63/8c/e28e0eb2fe34921d6aa32bfc4ac75b09570b4d6818cc95d25499fe08dc1d/multidict-6.4.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:61ed4d82f8a1e67eb9eb04f8587970d78fe7cddb4e4d6230b77eda23d27938f9", size = 216136, upload-time = "2025-04-10T22:19:02.244Z" }, + { url = "https://files.pythonhosted.org/packages/72/f5/fbc81f866585b05f89f99d108be5d6ad170e3b6c4d0723d1a2f6ba5fa918/multidict-6.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:062428944a8dc69df9fdc5d5fc6279421e5f9c75a9ee3f586f274ba7b05ab3c8", size = 228139, upload-time = "2025-04-10T22:19:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ba/7d196bad6b85af2307d81f6979c36ed9665f49626f66d883d6c64d156f78/multidict-6.4.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b90e27b4674e6c405ad6c64e515a505c6d113b832df52fdacb6b1ffd1fa9a1d1", size = 226251, upload-time = "2025-04-10T22:19:06.117Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e2/fae46a370dce79d08b672422a33df721ec8b80105e0ea8d87215ff6b090d/multidict-6.4.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7d50d4abf6729921e9613d98344b74241572b751c6b37feed75fb0c37bd5a817", size = 221868, upload-time = "2025-04-10T22:19:07.981Z" }, + { url = "https://files.pythonhosted.org/packages/26/20/bbc9a3dec19d5492f54a167f08546656e7aef75d181d3d82541463450e88/multidict-6.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:43fe10524fb0a0514be3954be53258e61d87341008ce4914f8e8b92bee6f875d", size = 233106, upload-time = "2025-04-10T22:19:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8d/f30ae8f5ff7a2461177f4d8eb0d8f69f27fb6cfe276b54ec4fd5a282d918/multidict-6.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:236966ca6c472ea4e2d3f02f6673ebfd36ba3f23159c323f5a496869bc8e47c9", size = 230163, upload-time = "2025-04-10T22:19:11Z" }, + { url = "https://files.pythonhosted.org/packages/15/e9/2833f3c218d3c2179f3093f766940ded6b81a49d2e2f9c46ab240d23dfec/multidict-6.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:422a5ec315018e606473ba1f5431e064cf8b2a7468019233dcf8082fabad64c8", size = 225906, upload-time = "2025-04-10T22:19:12.875Z" }, + { url = "https://files.pythonhosted.org/packages/c9/13/2ead63b9ab0d2b3080819268acb297bd66e238070aa8d42af12b08cbee1c/multidict-6.4.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:be8751869e28b9c0d368d94f5afcb4234db66fe8496144547b4b6d6a0645cfc6", size = 68642, upload-time = "2025-04-10T22:19:17.527Z" }, + { url = "https://files.pythonhosted.org/packages/85/45/f1a751e1eede30c23951e2ae274ce8fad738e8a3d5714be73e0a41b27b16/multidict-6.4.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d4b31f8a68dccbcd2c0ea04f0e014f1defc6b78f0eb8b35f2265e8716a6df0c", size = 40028, upload-time = "2025-04-10T22:19:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/a7/29/fcc53e886a2cc5595cc4560df333cb9630257bda65003a7eb4e4e0d8f9c1/multidict-6.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:032efeab3049e37eef2ff91271884303becc9e54d740b492a93b7e7266e23756", size = 39424, upload-time = "2025-04-10T22:19:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f0/056c81119d8b88703971f937b371795cab1407cd3c751482de5bfe1a04a9/multidict-6.4.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e78006af1a7c8a8007e4f56629d7252668344442f66982368ac06522445e375", size = 226178, upload-time = "2025-04-10T22:19:22.17Z" }, + { url = "https://files.pythonhosted.org/packages/a3/79/3b7e5fea0aa80583d3a69c9d98b7913dfd4fbc341fb10bb2fb48d35a9c21/multidict-6.4.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:daeac9dd30cda8703c417e4fddccd7c4dc0c73421a0b54a7da2713be125846be", size = 222617, upload-time = "2025-04-10T22:19:23.773Z" }, + { url = "https://files.pythonhosted.org/packages/06/db/3ed012b163e376fc461e1d6a67de69b408339bc31dc83d39ae9ec3bf9578/multidict-6.4.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f6f90700881438953eae443a9c6f8a509808bc3b185246992c4233ccee37fea", size = 227919, upload-time = "2025-04-10T22:19:25.35Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/0433c104bca380989bc04d3b841fc83e95ce0c89f680e9ea4251118b52b6/multidict-6.4.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f84627997008390dd15762128dcf73c3365f4ec0106739cde6c20a07ed198ec8", size = 226097, upload-time = "2025-04-10T22:19:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/910db2618175724dd254b7ae635b6cd8d2947a8b76b0376de7b96d814dab/multidict-6.4.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3307b48cd156153b117c0ea54890a3bdbf858a5b296ddd40dc3852e5f16e9b02", size = 220706, upload-time = "2025-04-10T22:19:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d1/af/aa176c6f5f1d901aac957d5258d5e22897fe13948d1e69063ae3d5d0ca01/multidict-6.4.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ead46b0fa1dcf5af503a46e9f1c2e80b5d95c6011526352fa5f42ea201526124", size = 211728, upload-time = "2025-04-10T22:19:30.481Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/d51cc5fc1527c3717d7f85137d6c79bb7a93cd214c26f1fc57523774dbb5/multidict-6.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1748cb2743bedc339d63eb1bca314061568793acd603a6e37b09a326334c9f44", size = 226276, upload-time = "2025-04-10T22:19:32.454Z" }, + { url = "https://files.pythonhosted.org/packages/28/6b/d836dea45e0b8432343ba4acf9a8ecaa245da4c0960fb7ab45088a5e568a/multidict-6.4.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:acc9fa606f76fc111b4569348cc23a771cb52c61516dcc6bcef46d612edb483b", size = 212069, upload-time = "2025-04-10T22:19:34.17Z" }, + { url = "https://files.pythonhosted.org/packages/55/34/0ee1a7adb3560e18ee9289c6e5f7db54edc312b13e5c8263e88ea373d12c/multidict-6.4.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:31469d5832b5885adeb70982e531ce86f8c992334edd2f2254a10fa3182ac504", size = 217858, upload-time = "2025-04-10T22:19:35.879Z" }, + { url = "https://files.pythonhosted.org/packages/04/08/586d652c2f5acefe0cf4e658eedb4d71d4ba6dfd4f189bd81b400fc1bc6b/multidict-6.4.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ba46b51b6e51b4ef7bfb84b82f5db0dc5e300fb222a8a13b8cd4111898a869cf", size = 226988, upload-time = "2025-04-10T22:19:37.434Z" }, + { url = "https://files.pythonhosted.org/packages/82/e3/cc59c7e2bc49d7f906fb4ffb6d9c3a3cf21b9f2dd9c96d05bef89c2b1fd1/multidict-6.4.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:389cfefb599edf3fcfd5f64c0410da686f90f5f5e2c4d84e14f6797a5a337af4", size = 220435, upload-time = "2025-04-10T22:19:39.005Z" }, + { url = "https://files.pythonhosted.org/packages/e0/32/5c3a556118aca9981d883f38c4b1bfae646f3627157f70f4068e5a648955/multidict-6.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:64bc2bbc5fba7b9db5c2c8d750824f41c6994e3882e6d73c903c2afa78d091e4", size = 221494, upload-time = "2025-04-10T22:19:41.447Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/7d526c8974f017f1e7ca584c71ee62a638e9334d8d33f27d7cdfc9ae79e4/multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9", size = 10400, upload-time = "2025-04-10T22:20:16.445Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux')" }, { name = "mypy-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "pathspec", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, - { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, - { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, - { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, - { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, - { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, - { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, - { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, - { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, - { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, - { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, - { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, - { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] @@ -1956,6 +2668,24 @@ version = "2.2.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/61/68/810093cb579daae426794bbd9d88aa830fae296e85172d18cb0f0e5dd4bc/mysqlclient-2.2.7.tar.gz", hash = "sha256:24ae22b59416d5fcce7e99c9d37548350b4565baac82f95e149cac6ce4163845", size = 91383, upload-time = "2025-01-10T12:06:00.763Z" } +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + [[package]] name = "nltk" version = "3.9.2" @@ -2040,10 +2770,12 @@ name = "numpy" version = "2.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version >= '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'linux'", ] sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" } wheels = [ @@ -2114,7 +2846,7 @@ wheels = [ [[package]] name = "ocrmypdf" -version = "16.12.0" +version = "16.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2127,9 +2859,41 @@ dependencies = [ { name = "pluggy", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "rich", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/ed/dacc0f189e4fcefc52d709e9961929e3f622a85efa5ae47c9d9663d75cab/ocrmypdf-16.12.0.tar.gz", hash = "sha256:a0f6509e7780b286391f8847fae1811d2b157b14283ad74a2431d6755c5c0ed0", size = 7037326, upload-time = "2025-11-11T22:30:14.223Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/52/be1aaece0703a736757d8957c0d4f19c37561054169b501eb0e7132f15e5/ocrmypdf-16.13.0.tar.gz", hash = "sha256:29d37e915234ce717374863a9cc5dd32d29e063dfe60c51380dda71254c88248", size = 7042247, upload-time = "2025-12-24T07:58:35.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/34/d9d04420e6f7a71e2135b41599dae273e4ef36e2ce79b065b65fb2471636/ocrmypdf-16.12.0-py3-none-any.whl", hash = "sha256:0ea5c42027db9cf3bd12b0d0b4190689027ef813fdad3377106ea66bba0012c3", size = 163415, upload-time = "2025-11-11T22:30:11.56Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/e2e7ad98de0d3ee05b44dbc3f78ccb158a620f3add82d00c85490120e7f2/ocrmypdf-16.13.0-py3-none-any.whl", hash = "sha256:fad8a6f7cc52cdc6225095c401a1766c778c47efe9f1e854ae4dc64a550a3d37", size = 165377, upload-time = "2025-12-24T07:58:33.925Z" }, +] + +[[package]] +name = "ollama" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" }, +] + +[[package]] +name = "openai" +version = "1.109.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "distro", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "jiter", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sniffio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/a1/a303104dc55fc546a3f6914c842d3da471c64eec92043aef8f652eb6c524/openai-1.109.1.tar.gz", hash = "sha256:d173ed8dbca665892a6db099b4a2dfac624f94d20a93f46eb0b56aae940ed869", size = 564133, upload-time = "2025-09-24T13:00:53.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/2a/7dd3d207ec669cacc1f186fd856a0f61dbc255d24f6fdc1a6715d6051b0f/openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315", size = 948627, upload-time = "2025-09-24T13:00:50.754Z" }, ] [[package]] @@ -2152,7 +2916,7 @@ wheels = [ [[package]] name = "paperless-ngx" -version = "2.20.3" +version = "2.20.5" source = { virtual = "." } dependencies = [ { name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2181,16 +2945,23 @@ dependencies = [ { name = "drf-spectacular", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "drf-spectacular-sidecar", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "drf-writable-nested", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "faiss-cpu", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "flower", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "gotenberg-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "httpx-oauth", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "imap-tools", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "inotifyrecursive", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "langdetect", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "llama-index-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "llama-index-embeddings-huggingface", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "llama-index-embeddings-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "llama-index-llms-ollama", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "llama-index-llms-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "llama-index-vector-stores-faiss", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "nltk", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "ocrmypdf", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "pathvalidate", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "pdf2image", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2203,10 +2974,13 @@ dependencies = [ { name = "redis", extra = ["hiredis"], marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "scikit-learn", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sentence-transformers", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "setproctitle", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "tika-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "torch", version = "2.9.1", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.9.1+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux'" }, { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, - { name = "watchdog", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "watchfiles", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "whitenoise", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "whoosh-reloaded", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "zxing-cpp", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version != '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version != '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, @@ -2302,8 +3076,8 @@ requires-dist = [ { name = "concurrent-log-handler", specifier = "~=0.9.25" }, { name = "dateparser", specifier = "~=1.2" }, { name = "django", specifier = "~=5.2.5" }, - { name = "django-allauth", extras = ["mfa", "socialaccount"], specifier = "~=65.12.1" }, - { name = "django-auditlog", specifier = "~=3.3.0" }, + { name = "django-allauth", extras = ["mfa", "socialaccount"], specifier = "~=65.13.1" }, + { name = "django-auditlog", specifier = "~=3.4.1" }, { name = "django-cachalot", specifier = "~=2.8.0" }, { name = "django-celery-results", specifier = "~=2.6.0" }, { name = "django-compression-middleware", specifier = "~=0.5.0" }, @@ -2319,25 +3093,32 @@ requires-dist = [ { name = "drf-spectacular", specifier = "~=0.28" }, { name = "drf-spectacular-sidecar", specifier = "~=2025.10.1" }, { name = "drf-writable-nested", specifier = "~=0.7.1" }, + { name = "faiss-cpu", specifier = ">=1.10" }, { name = "filelock", specifier = "~=3.20.0" }, { name = "flower", specifier = "~=2.0.1" }, - { name = "gotenberg-client", specifier = "~=0.12.0" }, + { name = "gotenberg-client", specifier = "~=0.13.1" }, { name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = "~=2.5.1" }, { name = "httpx-oauth", specifier = "~=0.16" }, { name = "imap-tools", specifier = "~=1.11.0" }, - { name = "inotifyrecursive", specifier = "~=0.3" }, { name = "jinja2", specifier = "~=3.1.5" }, { name = "langdetect", specifier = "~=1.0.9" }, + { name = "llama-index-core", specifier = ">=0.14.12" }, + { name = "llama-index-embeddings-huggingface", specifier = ">=0.6.1" }, + { name = "llama-index-embeddings-openai", specifier = ">=0.5.1" }, + { name = "llama-index-llms-ollama", specifier = ">=0.9.1" }, + { name = "llama-index-llms-openai", specifier = ">=0.6.13" }, + { name = "llama-index-vector-stores-faiss", specifier = ">=0.5.2" }, { name = "mysqlclient", marker = "extra == 'mariadb'", specifier = "~=2.2.7" }, { name = "nltk", specifier = "~=3.9.1" }, - { name = "ocrmypdf", specifier = "~=16.12.0" }, + { name = "ocrmypdf", specifier = "~=16.13.0" }, + { name = "openai", specifier = ">=1.76" }, { name = "pathvalidate", specifier = "~=3.3.1" }, { name = "pdf2image", specifier = "~=1.17.0" }, { name = "psycopg", extras = ["c", "pool"], marker = "extra == 'postgres'", specifier = "==3.2.12" }, { name = "psycopg-c", marker = "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux' and extra == 'postgres'", url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-bookworm-3.2.12/psycopg_c-3.2.12-cp312-cp312-linux_aarch64.whl" }, { name = "psycopg-c", marker = "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux' and extra == 'postgres'", url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-bookworm-3.2.12/psycopg_c-3.2.12-cp312-cp312-linux_x86_64.whl" }, { name = "psycopg-c", marker = "(python_full_version != '3.12.*' and platform_machine == 'aarch64' and extra == 'postgres') or (python_full_version != '3.12.*' and platform_machine == 'x86_64' and extra == 'postgres') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'postgres') or (sys_platform != 'linux' and extra == 'postgres')", specifier = "==3.2.12" }, - { name = "psycopg-pool", marker = "extra == 'postgres'", specifier = "==3.2.7" }, + { name = "psycopg-pool", marker = "extra == 'postgres'", specifier = "==3.3" }, { name = "python-dateutil", specifier = "~=2.9.0" }, { name = "python-dotenv", specifier = "~=1.2.1" }, { name = "python-gnupg", specifier = "~=0.5.4" }, @@ -2348,10 +3129,12 @@ requires-dist = [ { name = "redis", extras = ["hiredis"], specifier = "~=5.2.1" }, { name = "regex", specifier = ">=2025.9.18" }, { name = "scikit-learn", specifier = "~=1.7.0" }, + { name = "sentence-transformers", specifier = ">=4.1" }, { name = "setproctitle", specifier = "~=1.3.4" }, { name = "tika-client", specifier = "~=0.10.0" }, + { name = "torch", specifier = "~=2.9.1", index = "https://download.pytorch.org/whl/cpu" }, { name = "tqdm", specifier = "~=4.67.1" }, - { name = "watchdog", specifier = "~=6.0" }, + { name = "watchfiles", specifier = ">=1.1.1" }, { name = "whitenoise", specifier = "~=6.9" }, { name = "whoosh-reloaded", specifier = ">=2.7.5" }, { name = "zxing-cpp", marker = "(python_full_version != '3.12.*' and platform_machine == 'aarch64') or (python_full_version != '3.12.*' and platform_machine == 'x86_64') or (platform_machine != 'aarch64' and platform_machine != 'x86_64') or sys_platform != 'linux'", specifier = "~=2.3.0" }, @@ -2367,7 +3150,7 @@ dev = [ { name = "imagehash" }, { name = "mkdocs-glightbox", specifier = "~=0.5.1" }, { name = "mkdocs-material", specifier = "~=9.7.0" }, - { name = "pre-commit", specifier = "~=4.4.0" }, + { name = "pre-commit", specifier = "~=4.5.1" }, { name = "pre-commit-uv", specifier = "~=4.2.0" }, { name = "pytest", specifier = "~=8.4.1" }, { name = "pytest-cov", specifier = "~=7.0.0" }, @@ -2385,7 +3168,7 @@ docs = [ { name = "mkdocs-material", specifier = "~=9.7.0" }, ] lint = [ - { name = "pre-commit", specifier = "~=4.4.0" }, + { name = "pre-commit", specifier = "~=4.5.1" }, { name = "pre-commit-uv", specifier = "~=4.2.0" }, { name = "ruff", specifier = "~=0.14.0" }, ] @@ -2666,7 +3449,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.4.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -2675,9 +3458,9 @@ dependencies = [ { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "virtualenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] [[package]] @@ -2714,6 +3497,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] +[[package]] +name = "propcache" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/c8/fdc6686a986feae3541ea23dcaa661bd93972d3940460646c6bb96e21c40/propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf", size = 43651, upload-time = "2025-03-26T03:06:12.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/56/e27c136101addf877c8291dbda1b3b86ae848f3837ce758510a0d806c92f/propcache-0.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f27785888d2fdd918bc36de8b8739f2d6c791399552333721b58193f68ea3e98", size = 80224, upload-time = "2025-03-26T03:03:35.81Z" }, + { url = "https://files.pythonhosted.org/packages/63/bd/88e98836544c4f04db97eefd23b037c2002fa173dd2772301c61cd3085f9/propcache-0.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4e89cde74154c7b5957f87a355bb9c8ec929c167b59c83d90654ea36aeb6180", size = 46491, upload-time = "2025-03-26T03:03:38.107Z" }, + { url = "https://files.pythonhosted.org/packages/15/43/0b8eb2a55753c4a574fc0899885da504b521068d3b08ca56774cad0bea2b/propcache-0.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:730178f476ef03d3d4d255f0c9fa186cb1d13fd33ffe89d39f2cda4da90ceb71", size = 45927, upload-time = "2025-03-26T03:03:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6c/d01f9dfbbdc613305e0a831016844987a1fb4861dd221cd4c69b1216b43f/propcache-0.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:967a8eec513dbe08330f10137eacb427b2ca52118769e82ebcfcab0fba92a649", size = 206135, upload-time = "2025-03-26T03:03:40.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8a/e6e1c77394088f4cfdace4a91a7328e398ebed745d59c2f6764135c5342d/propcache-0.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b9145c35cc87313b5fd480144f8078716007656093d23059e8993d3a8fa730f", size = 220517, upload-time = "2025-03-26T03:03:42.657Z" }, + { url = "https://files.pythonhosted.org/packages/19/3b/6c44fa59d6418f4239d5db8b1ece757351e85d6f3ca126dfe37d427020c8/propcache-0.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e64e948ab41411958670f1093c0a57acfdc3bee5cf5b935671bbd5313bcf229", size = 218952, upload-time = "2025-03-26T03:03:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e4/4aeb95a1cd085e0558ab0de95abfc5187329616193a1012a6c4c930e9f7a/propcache-0.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:319fa8765bfd6a265e5fa661547556da381e53274bc05094fc9ea50da51bfd46", size = 206593, upload-time = "2025-03-26T03:03:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/6a/29fa75de1cbbb302f1e1d684009b969976ca603ee162282ae702287b6621/propcache-0.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66d8ccbc902ad548312b96ed8d5d266d0d2c6d006fd0f66323e9d8f2dd49be7", size = 196745, upload-time = "2025-03-26T03:03:48.02Z" }, + { url = "https://files.pythonhosted.org/packages/19/7e/2237dad1dbffdd2162de470599fa1a1d55df493b16b71e5d25a0ac1c1543/propcache-0.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2d219b0dbabe75e15e581fc1ae796109b07c8ba7d25b9ae8d650da582bed01b0", size = 203369, upload-time = "2025-03-26T03:03:49.63Z" }, + { url = "https://files.pythonhosted.org/packages/a4/bc/a82c5878eb3afb5c88da86e2cf06e1fe78b7875b26198dbb70fe50a010dc/propcache-0.3.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd6a55f65241c551eb53f8cf4d2f4af33512c39da5d9777694e9d9c60872f519", size = 198723, upload-time = "2025-03-26T03:03:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/17/76/9632254479c55516f51644ddbf747a45f813031af5adcb8db91c0b824375/propcache-0.3.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9979643ffc69b799d50d3a7b72b5164a2e97e117009d7af6dfdd2ab906cb72cd", size = 200751, upload-time = "2025-03-26T03:03:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c3/a90b773cf639bd01d12a9e20c95be0ae978a5a8abe6d2d343900ae76cd71/propcache-0.3.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4cf9e93a81979f1424f1a3d155213dc928f1069d697e4353edb8a5eba67c6259", size = 210730, upload-time = "2025-03-26T03:03:54.498Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ec/ad5a952cdb9d65c351f88db7c46957edd3d65ffeee72a2f18bd6341433e0/propcache-0.3.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2fce1df66915909ff6c824bbb5eb403d2d15f98f1518e583074671a30fe0c21e", size = 213499, upload-time = "2025-03-26T03:03:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/ea5133dda43e298cd2010ec05c2821b391e10980e64ee72c0a76cdbb813a/propcache-0.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4d0dfdd9a2ebc77b869a0b04423591ea8823f791293b527dc1bb896c1d6f1136", size = 207132, upload-time = "2025-03-26T03:03:57.398Z" }, + { url = "https://files.pythonhosted.org/packages/90/0f/5a5319ee83bd651f75311fcb0c492c21322a7fc8f788e4eef23f44243427/propcache-0.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7f30241577d2fef2602113b70ef7231bf4c69a97e04693bde08ddab913ba0ce5", size = 80243, upload-time = "2025-03-26T03:04:01.912Z" }, + { url = "https://files.pythonhosted.org/packages/ce/84/3db5537e0879942783e2256616ff15d870a11d7ac26541336fe1b673c818/propcache-0.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:43593c6772aa12abc3af7784bff4a41ffa921608dd38b77cf1dfd7f5c4e71371", size = 46503, upload-time = "2025-03-26T03:04:03.704Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c8/b649ed972433c3f0d827d7f0cf9ea47162f4ef8f4fe98c5f3641a0bc63ff/propcache-0.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a75801768bbe65499495660b777e018cbe90c7980f07f8aa57d6be79ea6f71da", size = 45934, upload-time = "2025-03-26T03:04:05.257Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/4c0a5cf6974c2c43b1a6810c40d889769cc8f84cea676cbe1e62766a45f8/propcache-0.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6f1324db48f001c2ca26a25fa25af60711e09b9aaf4b28488602776f4f9a744", size = 233633, upload-time = "2025-03-26T03:04:07.044Z" }, + { url = "https://files.pythonhosted.org/packages/e7/64/66f2f4d1b4f0007c6e9078bd95b609b633d3957fe6dd23eac33ebde4b584/propcache-0.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cdb0f3e1eb6dfc9965d19734d8f9c481b294b5274337a8cb5cb01b462dcb7e0", size = 241124, upload-time = "2025-03-26T03:04:08.676Z" }, + { url = "https://files.pythonhosted.org/packages/aa/bf/7b8c9fd097d511638fa9b6af3d986adbdf567598a567b46338c925144c1b/propcache-0.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1eb34d90aac9bfbced9a58b266f8946cb5935869ff01b164573a7634d39fbcb5", size = 240283, upload-time = "2025-03-26T03:04:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/e85aeeeaae83358e2a1ef32d6ff50a483a5d5248bc38510d030a6f4e2816/propcache-0.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f35c7070eeec2cdaac6fd3fe245226ed2a6292d3ee8c938e5bb645b434c5f256", size = 232498, upload-time = "2025-03-26T03:04:11.616Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/acb88e1f30ef5536d785c283af2e62931cb934a56a3ecf39105887aa8905/propcache-0.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b23c11c2c9e6d4e7300c92e022046ad09b91fd00e36e83c44483df4afa990073", size = 221486, upload-time = "2025-03-26T03:04:13.102Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f9/233ddb05ffdcaee4448508ee1d70aa7deff21bb41469ccdfcc339f871427/propcache-0.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3e19ea4ea0bf46179f8a3652ac1426e6dcbaf577ce4b4f65be581e237340420d", size = 222675, upload-time = "2025-03-26T03:04:14.658Z" }, + { url = "https://files.pythonhosted.org/packages/98/b8/eb977e28138f9e22a5a789daf608d36e05ed93093ef12a12441030da800a/propcache-0.3.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bd39c92e4c8f6cbf5f08257d6360123af72af9f4da75a690bef50da77362d25f", size = 215727, upload-time = "2025-03-26T03:04:16.207Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/5f52d9c579f67b8ee1edd9ec073c91b23cc5b7ff7951a1e449e04ed8fdf3/propcache-0.3.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0313e8b923b3814d1c4a524c93dfecea5f39fa95601f6a9b1ac96cd66f89ea0", size = 217878, upload-time = "2025-03-26T03:04:18.11Z" }, + { url = "https://files.pythonhosted.org/packages/7a/fd/5283e5ed8a82b00c7a989b99bb6ea173db1ad750bf0bf8dff08d3f4a4e28/propcache-0.3.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e861ad82892408487be144906a368ddbe2dc6297074ade2d892341b35c59844a", size = 230558, upload-time = "2025-03-26T03:04:19.562Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/ab17d75938ef7ac87332c588857422ae126b1c76253f0f5b1242032923ca/propcache-0.3.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:61014615c1274df8da5991a1e5da85a3ccb00c2d4701ac6f3383afd3ca47ab0a", size = 233754, upload-time = "2025-03-26T03:04:21.065Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/3b921b9c60659ae464137508d3b4c2b3f52f592ceb1964aa2533b32fcf0b/propcache-0.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:71ebe3fe42656a2328ab08933d420df5f3ab121772eef78f2dc63624157f0ed9", size = 226088, upload-time = "2025-03-26T03:04:22.718Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/ca78d9be314d1e15ff517b992bebbed3bdfef5b8919e85bf4940e57b6137/propcache-0.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f78eb8422acc93d7b69964012ad7048764bb45a54ba7a39bb9e146c72ea29723", size = 80430, upload-time = "2025-03-26T03:04:26.436Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d8/f0c17c44d1cda0ad1979af2e593ea290defdde9eaeb89b08abbe02a5e8e1/propcache-0.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89498dd49c2f9a026ee057965cdf8192e5ae070ce7d7a7bd4b66a8e257d0c976", size = 46637, upload-time = "2025-03-26T03:04:27.932Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bd/c1e37265910752e6e5e8a4c1605d0129e5b7933c3dc3cf1b9b48ed83b364/propcache-0.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09400e98545c998d57d10035ff623266927cb784d13dd2b31fd33b8a5316b85b", size = 46123, upload-time = "2025-03-26T03:04:30.659Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b0/911eda0865f90c0c7e9f0415d40a5bf681204da5fd7ca089361a64c16b28/propcache-0.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8efd8c5adc5a2c9d3b952815ff8f7710cefdcaf5f2c36d26aff51aeca2f12f", size = 243031, upload-time = "2025-03-26T03:04:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/0a/06/0da53397c76a74271621807265b6eb61fb011451b1ddebf43213df763669/propcache-0.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2fe5c910f6007e716a06d269608d307b4f36e7babee5f36533722660e8c4a70", size = 249100, upload-time = "2025-03-26T03:04:33.45Z" }, + { url = "https://files.pythonhosted.org/packages/f1/eb/13090e05bf6b963fc1653cdc922133ced467cb4b8dab53158db5a37aa21e/propcache-0.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0ab8cf8cdd2194f8ff979a43ab43049b1df0b37aa64ab7eca04ac14429baeb7", size = 250170, upload-time = "2025-03-26T03:04:35.542Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/f72c9e1022b3b043ec7dc475a0f405d4c3e10b9b1d378a7330fecf0652da/propcache-0.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563f9d8c03ad645597b8d010ef4e9eab359faeb11a0a2ac9f7b4bc8c28ebef25", size = 245000, upload-time = "2025-03-26T03:04:37.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fd/970ca0e22acc829f1adf5de3724085e778c1ad8a75bec010049502cb3a86/propcache-0.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb6e0faf8cb6b4beea5d6ed7b5a578254c6d7df54c36ccd3d8b3eb00d6770277", size = 230262, upload-time = "2025-03-26T03:04:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/c4/42/817289120c6b9194a44f6c3e6b2c3277c5b70bbad39e7df648f177cc3634/propcache-0.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1c5c7ab7f2bb3f573d1cb921993006ba2d39e8621019dffb1c5bc94cdbae81e8", size = 236772, upload-time = "2025-03-26T03:04:41.109Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9c/3b3942b302badd589ad6b672da3ca7b660a6c2f505cafd058133ddc73918/propcache-0.3.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:050b571b2e96ec942898f8eb46ea4bfbb19bd5502424747e83badc2d4a99a44e", size = 231133, upload-time = "2025-03-26T03:04:42.544Z" }, + { url = "https://files.pythonhosted.org/packages/98/a1/75f6355f9ad039108ff000dfc2e19962c8dea0430da9a1428e7975cf24b2/propcache-0.3.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e1c4d24b804b3a87e9350f79e2371a705a188d292fd310e663483af6ee6718ee", size = 230741, upload-time = "2025-03-26T03:04:44.06Z" }, + { url = "https://files.pythonhosted.org/packages/67/0c/3e82563af77d1f8731132166da69fdfd95e71210e31f18edce08a1eb11ea/propcache-0.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e4fe2a6d5ce975c117a6bb1e8ccda772d1e7029c1cca1acd209f91d30fa72815", size = 244047, upload-time = "2025-03-26T03:04:45.983Z" }, + { url = "https://files.pythonhosted.org/packages/f7/50/9fb7cca01532a08c4d5186d7bb2da6c4c587825c0ae134b89b47c7d62628/propcache-0.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:feccd282de1f6322f56f6845bf1207a537227812f0a9bf5571df52bb418d79d5", size = 246467, upload-time = "2025-03-26T03:04:47.699Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/ccbcf3e1c604c16cc525309161d57412c23cf2351523aedbb280eb7c9094/propcache-0.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ec314cde7314d2dd0510c6787326bbffcbdc317ecee6b7401ce218b3099075a7", size = 241022, upload-time = "2025-03-26T03:04:49.195Z" }, + { url = "https://files.pythonhosted.org/packages/58/60/f645cc8b570f99be3cf46714170c2de4b4c9d6b827b912811eff1eb8a412/propcache-0.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1528ec4374617a7a753f90f20e2f551121bb558fcb35926f99e3c42367164b8", size = 77865, upload-time = "2025-03-26T03:04:53.406Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d4/c1adbf3901537582e65cf90fd9c26fde1298fde5a2c593f987112c0d0798/propcache-0.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc1915ec523b3b494933b5424980831b636fe483d7d543f7afb7b3bf00f0c10f", size = 45452, upload-time = "2025-03-26T03:04:54.624Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b5/fe752b2e63f49f727c6c1c224175d21b7d1727ce1d4873ef1c24c9216830/propcache-0.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a110205022d077da24e60b3df8bcee73971be9575dec5573dd17ae5d81751111", size = 44800, upload-time = "2025-03-26T03:04:55.844Z" }, + { url = "https://files.pythonhosted.org/packages/62/37/fc357e345bc1971e21f76597028b059c3d795c5ca7690d7a8d9a03c9708a/propcache-0.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d249609e547c04d190e820d0d4c8ca03ed4582bcf8e4e160a6969ddfb57b62e5", size = 225804, upload-time = "2025-03-26T03:04:57.158Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/16e12c33e3dbe7f8b737809bad05719cff1dccb8df4dafbcff5575002c0e/propcache-0.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ced33d827625d0a589e831126ccb4f5c29dfdf6766cac441d23995a65825dcb", size = 230650, upload-time = "2025-03-26T03:04:58.61Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a2/018b9f2ed876bf5091e60153f727e8f9073d97573f790ff7cdf6bc1d1fb8/propcache-0.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4114c4ada8f3181af20808bedb250da6bae56660e4b8dfd9cd95d4549c0962f7", size = 234235, upload-time = "2025-03-26T03:05:00.599Z" }, + { url = "https://files.pythonhosted.org/packages/45/5f/3faee66fc930dfb5da509e34c6ac7128870631c0e3582987fad161fcb4b1/propcache-0.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:975af16f406ce48f1333ec5e912fe11064605d5c5b3f6746969077cc3adeb120", size = 228249, upload-time = "2025-03-26T03:05:02.11Z" }, + { url = "https://files.pythonhosted.org/packages/62/1e/a0d5ebda5da7ff34d2f5259a3e171a94be83c41eb1e7cd21a2105a84a02e/propcache-0.3.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a34aa3a1abc50740be6ac0ab9d594e274f59960d3ad253cd318af76b996dd654", size = 214964, upload-time = "2025-03-26T03:05:03.599Z" }, + { url = "https://files.pythonhosted.org/packages/db/a0/d72da3f61ceab126e9be1f3bc7844b4e98c6e61c985097474668e7e52152/propcache-0.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9cec3239c85ed15bfaded997773fdad9fb5662b0a7cbc854a43f291eb183179e", size = 222501, upload-time = "2025-03-26T03:05:05.107Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/a008e07ad7b905011253adbbd97e5b5375c33f0b961355ca0a30377504ac/propcache-0.3.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:05543250deac8e61084234d5fc54f8ebd254e8f2b39a16b1dce48904f45b744b", size = 217917, upload-time = "2025-03-26T03:05:06.59Z" }, + { url = "https://files.pythonhosted.org/packages/98/37/02c9343ffe59e590e0e56dc5c97d0da2b8b19fa747ebacf158310f97a79a/propcache-0.3.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5cb5918253912e088edbf023788de539219718d3b10aef334476b62d2b53de53", size = 217089, upload-time = "2025-03-26T03:05:08.1Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/d3406629a2c8a5666d4674c50f757a77be119b113eedd47b0375afdf1b42/propcache-0.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f3bbecd2f34d0e6d3c543fdb3b15d6b60dd69970c2b4c822379e5ec8f6f621d5", size = 228102, upload-time = "2025-03-26T03:05:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a7/3664756cf50ce739e5f3abd48febc0be1a713b1f389a502ca819791a6b69/propcache-0.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aca63103895c7d960a5b9b044a83f544b233c95e0dcff114389d64d762017af7", size = 230122, upload-time = "2025-03-26T03:05:11.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/36/0bbabaacdcc26dac4f8139625e930f4311864251276033a52fd52ff2a274/propcache-0.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a0a9898fdb99bf11786265468571e628ba60af80dc3f6eb89a3545540c6b0ef", size = 226818, upload-time = "2025-03-26T03:05:12.909Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a8/0a4fd2f664fc6acc66438370905124ce62e84e2e860f2557015ee4a61c7e/propcache-0.3.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a444192f20f5ce8a5e52761a031b90f5ea6288b1eef42ad4c7e64fef33540b8f", size = 82613, upload-time = "2025-03-26T03:05:16.913Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e5/5ef30eb2cd81576256d7b6caaa0ce33cd1d2c2c92c8903cccb1af1a4ff2f/propcache-0.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fbe94666e62ebe36cd652f5fc012abfbc2342de99b523f8267a678e4dfdee3c", size = 47763, upload-time = "2025-03-26T03:05:18.607Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/87091ceb048efeba4d28e903c0b15bcc84b7c0bf27dc0261e62335d9b7b8/propcache-0.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f011f104db880f4e2166bcdcf7f58250f7a465bc6b068dc84c824a3d4a5c94dc", size = 47175, upload-time = "2025-03-26T03:05:19.85Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2f/854e653c96ad1161f96194c6678a41bbb38c7947d17768e8811a77635a08/propcache-0.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e584b6d388aeb0001d6d5c2bd86b26304adde6d9bb9bfa9c4889805021b96de", size = 292265, upload-time = "2025-03-26T03:05:21.654Z" }, + { url = "https://files.pythonhosted.org/packages/40/8d/090955e13ed06bc3496ba4a9fb26c62e209ac41973cb0d6222de20c6868f/propcache-0.3.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a17583515a04358b034e241f952f1715243482fc2c2945fd99a1b03a0bd77d6", size = 294412, upload-time = "2025-03-26T03:05:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/39/e6/d51601342e53cc7582449e6a3c14a0479fab2f0750c1f4d22302e34219c6/propcache-0.3.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5aed8d8308215089c0734a2af4f2e95eeb360660184ad3912686c181e500b2e7", size = 294290, upload-time = "2025-03-26T03:05:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4d/be5f1a90abc1881884aa5878989a1acdafd379a91d9c7e5e12cef37ec0d7/propcache-0.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d8e309ff9a0503ef70dc9a0ebd3e69cf7b3894c9ae2ae81fc10943c37762458", size = 282926, upload-time = "2025-03-26T03:05:26.459Z" }, + { url = "https://files.pythonhosted.org/packages/57/2b/8f61b998c7ea93a2b7eca79e53f3e903db1787fca9373af9e2cf8dc22f9d/propcache-0.3.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b655032b202028a582d27aeedc2e813299f82cb232f969f87a4fde491a233f11", size = 267808, upload-time = "2025-03-26T03:05:28.188Z" }, + { url = "https://files.pythonhosted.org/packages/11/1c/311326c3dfce59c58a6098388ba984b0e5fb0381ef2279ec458ef99bd547/propcache-0.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f64d91b751df77931336b5ff7bafbe8845c5770b06630e27acd5dbb71e1931c", size = 290916, upload-time = "2025-03-26T03:05:29.757Z" }, + { url = "https://files.pythonhosted.org/packages/4b/74/91939924b0385e54dc48eb2e4edd1e4903ffd053cf1916ebc5347ac227f7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:19a06db789a4bd896ee91ebc50d059e23b3639c25d58eb35be3ca1cbe967c3bf", size = 262661, upload-time = "2025-03-26T03:05:31.472Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/e6079af45136ad325c5337f5dd9ef97ab5dc349e0ff362fe5c5db95e2454/propcache-0.3.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bef100c88d8692864651b5f98e871fb090bd65c8a41a1cb0ff2322db39c96c27", size = 264384, upload-time = "2025-03-26T03:05:32.984Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d5/ba91702207ac61ae6f1c2da81c5d0d6bf6ce89e08a2b4d44e411c0bbe867/propcache-0.3.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:87380fb1f3089d2a0b8b00f006ed12bd41bd858fabfa7330c954c70f50ed8757", size = 291420, upload-time = "2025-03-26T03:05:34.496Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/2117780ed7edcd7ba6b8134cb7802aada90b894a9810ec56b7bb6018bee7/propcache-0.3.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e474fc718e73ba5ec5180358aa07f6aded0ff5f2abe700e3115c37d75c947e18", size = 290880, upload-time = "2025-03-26T03:05:36.256Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1f/ecd9ce27710021ae623631c0146719280a929d895a095f6d85efb6a0be2e/propcache-0.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:17d1c688a443355234f3c031349da69444be052613483f3e4158eef751abcd8a", size = 287407, upload-time = "2025-03-26T03:05:37.799Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d3/c3cb8f1d6ae3b37f83e1de806713a9b3642c5895f0215a62e1a4bd6e5e34/propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", size = 12376, upload-time = "2025-03-26T03:06:10.5Z" }, +] + [[package]] name = "psycopg" version = "3.2.12" @@ -2741,9 +3603,11 @@ name = "psycopg-c" version = "3.2.12" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version < '3.11' and sys_platform == 'darwin'", - "(python_full_version >= '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'linux'", "python_full_version < '3.11' and sys_platform == 'linux'", ] sdist = { url = "https://files.pythonhosted.org/packages/68/27/33699874745d7bb195e78fd0a97349908b64d3ec5fea7b8e5e52f56df04c/psycopg_c-3.2.12.tar.gz", hash = "sha256:1c80042067d5df90d184c6fbd58661350b3620f99d87a01c882953c4d5dfa52b", size = 608386, upload-time = "2025-10-26T00:46:08.727Z" } @@ -2772,23 +3636,23 @@ wheels = [ [[package]] name = "psycopg-pool" -version = "3.2.7" +version = "3.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/8f/3ec52b17087c2ed5fa32b64fd4814dde964c9aa4bd49d0d30fc24725ca6d/psycopg_pool-3.2.7.tar.gz", hash = "sha256:a77d531bfca238e49e5fb5832d65b98e69f2c62bfda3d2d4d833696bdc9ca54b", size = 29765, upload-time = "2025-10-26T00:46:10.379Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/59/74e752f605c6f0e351d4cf1c54fb9a1616dc800db4572b95bbfbb1a6225f/psycopg_pool-3.2.7-py3-none-any.whl", hash = "sha256:4b47bb59d887ef5da522eb63746b9f70e2faf967d34aac4f56ffc65e9606728f", size = 38232, upload-time = "2025-10-26T00:46:00.496Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, ] [[package]] name = "pyasn1" -version = "0.6.1" +version = "0.6.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, ] [[package]] @@ -2812,6 +3676,120 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pydantic-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -3004,11 +3982,11 @@ wheels = [ [[package]] name = "python-gnupg" -version = "0.5.5" +version = "0.5.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/42/d0/72a14a79f26c6119b281f6ccc475a787432ef155560278e60df97ce68a86/python-gnupg-0.5.5.tar.gz", hash = "sha256:3fdcaf76f60a1b948ff8e37dc398d03cf9ce7427065d583082b92da7a4ff5a63", size = 66467, upload-time = "2025-08-04T19:26:55.778Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/2c/6cd2c7cff4bdbb434be5429ef6b8e96ee6b50155551361f30a1bb2ea3c1d/python_gnupg-0.5.6.tar.gz", hash = "sha256:5743e96212d38923fc19083812dc127907e44dbd3bcf0db4d657e291d3c21eac", size = 66825, upload-time = "2025-12-31T17:19:33.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/19/c147f78cc18c8788f54d4a16a22f6c05deba85ead5672d3ddf6dcba5a5fe/python_gnupg-0.5.5-py2.py3-none-any.whl", hash = "sha256:51fa7b8831ff0914bc73d74c59b99c613de7247b91294323c39733bb85ac3fc1", size = 21916, upload-time = "2025-08-04T19:26:54.307Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ab/0ea9de971caf3cd2e268d2b05dfe9883b21cfe686a59249bd2dccb4bae33/python_gnupg-0.5.6-py2.py3-none-any.whl", hash = "sha256:b5050a55663d8ab9fcc8d97556d229af337a87a3ebebd7054cbd8b7e2043394a", size = 22082, upload-time = "2025-12-31T17:16:22.743Z" }, ] [[package]] @@ -3085,10 +4063,12 @@ name = "pywavelets" version = "1.9.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version >= '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'linux'", ] dependencies = [ { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux')" }, @@ -3543,25 +4523,45 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.5" +version = "0.14.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504, upload-time = "2026-01-15T20:15:16.918Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, - { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, - { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, - { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, - { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, - { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, - { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, - { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, - { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, - { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, - { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418, upload-time = "2026-01-15T20:14:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344, upload-time = "2026-01-15T20:15:07.94Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720, upload-time = "2026-01-15T20:15:09.854Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493, upload-time = "2026-01-15T20:15:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174, upload-time = "2026-01-15T20:15:05.74Z" }, + { url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909, upload-time = "2026-01-15T20:15:14.537Z" }, + { url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215, upload-time = "2026-01-15T20:15:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067, upload-time = "2026-01-15T20:14:48.271Z" }, + { url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916, upload-time = "2026-01-15T20:14:57.357Z" }, + { url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207, upload-time = "2026-01-15T20:14:55.111Z" }, + { url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686, upload-time = "2026-01-15T20:14:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837, upload-time = "2026-01-15T20:15:18.921Z" }, + { url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867, upload-time = "2026-01-15T20:14:59.272Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528, upload-time = "2026-01-15T20:15:03.732Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242, upload-time = "2026-01-15T20:15:11.918Z" }, +] + +[[package]] +name = "safetensors" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/7e/2d5d6ee7b40c0682315367ec7475693d110f512922d582fef1bd4a63adc3/safetensors-0.5.3.tar.gz", hash = "sha256:b6b0d6ecacec39a4fdd99cc19f4576f5219ce858e6fd8dbe7609df0b8dc56965", size = 67210, upload-time = "2025-02-26T09:15:13.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/ae/88f6c49dbd0cc4da0e08610019a3c78a7d390879a919411a410a1876d03a/safetensors-0.5.3-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bd20eb133db8ed15b40110b7c00c6df51655a2998132193de2f75f72d99c7073", size = 436917, upload-time = "2025-02-26T09:15:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/11f1b4a2f5d2ab7da34ecc062b0bc301f2be024d110a6466726bec8c055c/safetensors-0.5.3-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:21d01c14ff6c415c485616b8b0bf961c46b3b343ca59110d38d744e577f9cce7", size = 418419, upload-time = "2025-02-26T09:15:01.765Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/add3e6fef267658075c5a41573c26d42d80c935cdc992384dfae435feaef/safetensors-0.5.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11bce6164887cd491ca75c2326a113ba934be596e22b28b1742ce27b1d076467", size = 459493, upload-time = "2025-02-26T09:14:51.812Z" }, + { url = "https://files.pythonhosted.org/packages/df/5c/bf2cae92222513cc23b3ff85c4a1bb2811a2c3583ac0f8e8d502751de934/safetensors-0.5.3-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4a243be3590bc3301c821da7a18d87224ef35cbd3e5f5727e4e0728b8172411e", size = 472400, upload-time = "2025-02-26T09:14:53.549Z" }, + { url = "https://files.pythonhosted.org/packages/58/11/7456afb740bd45782d0f4c8e8e1bb9e572f1bf82899fb6ace58af47b4282/safetensors-0.5.3-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8bd84b12b1670a6f8e50f01e28156422a2bc07fb16fc4e98bded13039d688a0d", size = 522891, upload-time = "2025-02-26T09:14:55.717Z" }, + { url = "https://files.pythonhosted.org/packages/57/3d/fe73a9d2ace487e7285f6e157afee2383bd1ddb911b7cb44a55cf812eae3/safetensors-0.5.3-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:391ac8cab7c829452175f871fcaf414aa1e292b5448bd02620f675a7f3e7abb9", size = 537694, upload-time = "2025-02-26T09:14:57.036Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f8/dae3421624fcc87a89d42e1898a798bc7ff72c61f38973a65d60df8f124c/safetensors-0.5.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cead1fa41fc54b1e61089fa57452e8834f798cb1dc7a09ba3524f1eb08e0317a", size = 471642, upload-time = "2025-02-26T09:15:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/ce/20/1fbe16f9b815f6c5a672f5b760951e20e17e43f67f231428f871909a37f6/safetensors-0.5.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1077f3e94182d72618357b04b5ced540ceb71c8a813d3319f1aba448e68a770d", size = 502241, upload-time = "2025-02-26T09:14:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/18/8e108846b506487aa4629fe4116b27db65c3dde922de2c8e0cc1133f3f29/safetensors-0.5.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:799021e78287bac619c7b3f3606730a22da4cda27759ddf55d37c8db7511c74b", size = 638001, upload-time = "2025-02-26T09:15:05.79Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/c116111d8291af6c8c8a8b40628fe833b9db97d8141c2a82359d14d9e078/safetensors-0.5.3-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df26da01aaac504334644e1b7642fa000bfec820e7cef83aeac4e355e03195ff", size = 734013, upload-time = "2025-02-26T09:15:07.892Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/41fcc4d3b7de837963622e8610d998710705bbde9a8a17221d85e5d0baad/safetensors-0.5.3-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:32c3ef2d7af8b9f52ff685ed0bc43913cdcde135089ae322ee576de93eae5135", size = 670687, upload-time = "2025-02-26T09:15:09.979Z" }, + { url = "https://files.pythonhosted.org/packages/40/ad/2b113098e69c985a3d8fbda4b902778eae4a35b7d5188859b4a63d30c161/safetensors-0.5.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:37f1521be045e56fc2b54c606d4455573e717b2d887c579ee1dbba5f868ece04", size = 643147, upload-time = "2025-02-26T09:15:11.185Z" }, ] [[package]] @@ -3664,10 +4664,12 @@ name = "scipy" version = "1.16.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'", "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", - "(python_full_version >= '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'linux'", ] dependencies = [ { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux')" }, @@ -3764,6 +4766,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/cb/7dc739a484b1a17ccf92a23dfe558ae615c232bd81e78a72049c25d1ff66/selectolax-0.3.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:484274f73839f9a143f4c13ce1b0a0123b5d64be22f967a1dc202a9a78687d67", size = 5727944, upload-time = "2025-04-30T15:16:49.52Z" }, ] +[[package]] +name = "sentence-transformers" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pillow", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "scikit-learn", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, + { name = "scipy", version = "1.16.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux')" }, + { name = "torch", version = "2.9.1", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "torch", version = "2.9.1+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'linux'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "transformers", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/84/b30d1b29ff58cfdff423e36a50efd622c8e31d7039b1a0d5e72066620da1/sentence_transformers-4.1.0.tar.gz", hash = "sha256:f125ffd1c727533e0eca5d4567de72f84728de8f7482834de442fd90c2c3d50b", size = 272420, upload-time = "2025-04-15T13:46:13.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/2d/1151b371f28caae565ad384fdc38198f1165571870217aedda230b9d7497/sentence_transformers-4.1.0-py3-none-any.whl", hash = "sha256:382a7f6be1244a100ce40495fb7523dbe8d71b3c10b299f81e6b735092b3b8ca", size = 345695, upload-time = "2025-04-15T13:46:12.44Z" }, +] + [[package]] name = "service-identity" version = "24.2.0" @@ -3874,6 +4897,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.40" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'darwin') or (python_full_version < '3.14' and platform_machine == 'WIN32' and sys_platform == 'darwin') or (python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'darwin') or (python_full_version < '3.14' and platform_machine == 'amd64' and sys_platform == 'darwin') or (python_full_version < '3.14' and platform_machine == 'ppc64le' and sys_platform == 'darwin') or (python_full_version < '3.14' and platform_machine == 'win32' and sys_platform == 'darwin') or (python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'WIN32' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'amd64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'ppc64le' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'win32' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/c3/3f2bfa5e4dcd9938405fe2fab5b6ab94a9248a4f9536ea2fd497da20525f/sqlalchemy-2.0.40.tar.gz", hash = "sha256:d827099289c64589418ebbcaead0145cd19f4e3e8a93919a0100247af245fa00", size = 9664299, upload-time = "2025-03-27T17:52:31.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fa/8e8fd93684b04e65816be864bebf0000fe1602e5452d006f9acc5db14ce5/sqlalchemy-2.0.40-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1ea21bef99c703f44444ad29c2c1b6bd55d202750b6de8e06a955380f4725d7", size = 2112843, upload-time = "2025-03-27T18:49:25.515Z" }, + { url = "https://files.pythonhosted.org/packages/ba/87/06992f78a9ce545dfd1fea3dd99262bec5221f6f9d2d2066c3e94662529f/sqlalchemy-2.0.40-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:afe63b208153f3a7a2d1a5b9df452b0673082588933e54e7c8aac457cf35e758", size = 2104032, upload-time = "2025-03-27T18:49:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/92/ee/57dc77282e8be22d686bd4681825299aa1069bbe090564868ea270ed5214/sqlalchemy-2.0.40-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8aae085ea549a1eddbc9298b113cffb75e514eadbb542133dd2b99b5fb3b6af", size = 3086406, upload-time = "2025-03-27T18:44:25.302Z" }, + { url = "https://files.pythonhosted.org/packages/94/3f/ceb9ab214b2e42d2e74a9209b3a2f2f073504eee16cddd2df81feeb67c2f/sqlalchemy-2.0.40-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ea9181284754d37db15156eb7be09c86e16e50fbe77610e9e7bee09291771a1", size = 3094652, upload-time = "2025-03-27T18:55:16.174Z" }, + { url = "https://files.pythonhosted.org/packages/00/0a/3401232a5b6d91a2df16c1dc39c6504c54575744c2faafa1e5a50de96621/sqlalchemy-2.0.40-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5434223b795be5c5ef8244e5ac98056e290d3a99bdcc539b916e282b160dda00", size = 3050503, upload-time = "2025-03-27T18:44:28.266Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/ea7171415ab131397f71a2673645c2fe29ebe9a93063d458eb89e42bf051/sqlalchemy-2.0.40-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15d08d5ef1b779af6a0909b97be6c1fd4298057504eb6461be88bd1696cb438e", size = 3076011, upload-time = "2025-03-27T18:55:17.967Z" }, + { url = "https://files.pythonhosted.org/packages/77/7e/55044a9ec48c3249bb38d5faae93f09579c35e862bb318ebd1ed7a1994a5/sqlalchemy-2.0.40-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f6bacab7514de6146a1976bc56e1545bee247242fab030b89e5f70336fc0003e", size = 2114025, upload-time = "2025-03-27T18:49:29.456Z" }, + { url = "https://files.pythonhosted.org/packages/77/0f/dcf7bba95f847aec72f638750747b12d37914f71c8cc7c133cf326ab945c/sqlalchemy-2.0.40-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5654d1ac34e922b6c5711631f2da497d3a7bffd6f9f87ac23b35feea56098011", size = 2104419, upload-time = "2025-03-27T18:49:30.75Z" }, + { url = "https://files.pythonhosted.org/packages/75/70/c86a5c20715e4fe903dde4c2fd44fc7e7a0d5fb52c1b954d98526f65a3ea/sqlalchemy-2.0.40-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35904d63412db21088739510216e9349e335f142ce4a04b69e2528020ee19ed4", size = 3222720, upload-time = "2025-03-27T18:44:29.871Z" }, + { url = "https://files.pythonhosted.org/packages/12/cf/b891a8c1d0c27ce9163361664c2128c7a57de3f35000ea5202eb3a2917b7/sqlalchemy-2.0.40-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c7a80ed86d6aaacb8160a1caef6680d4ddd03c944d985aecee940d168c411d1", size = 3222682, upload-time = "2025-03-27T18:55:20.097Z" }, + { url = "https://files.pythonhosted.org/packages/15/3f/7709d8c8266953d945435a96b7f425ae4172a336963756b58e996fbef7f3/sqlalchemy-2.0.40-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:519624685a51525ddaa7d8ba8265a1540442a2ec71476f0e75241eb8263d6f51", size = 3159542, upload-time = "2025-03-27T18:44:31.333Z" }, + { url = "https://files.pythonhosted.org/packages/85/7e/717eaabaf0f80a0132dc2032ea8f745b7a0914451c984821a7c8737fb75a/sqlalchemy-2.0.40-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2ee5f9999a5b0e9689bed96e60ee53c3384f1a05c2dd8068cc2e8361b0df5b7a", size = 3179864, upload-time = "2025-03-27T18:55:21.784Z" }, + { url = "https://files.pythonhosted.org/packages/92/06/552c1f92e880b57d8b92ce6619bd569b25cead492389b1d84904b55989d8/sqlalchemy-2.0.40-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9d3b31d0a1c44b74d3ae27a3de422dfccd2b8f0b75e51ecb2faa2bf65ab1ba0d", size = 2112620, upload-time = "2025-03-27T18:40:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/a5bc6e76c34cebc071f758161dbe1453de8815ae6e662393910d3be6d70d/sqlalchemy-2.0.40-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37f7a0f506cf78c80450ed1e816978643d3969f99c4ac6b01104a6fe95c5490a", size = 2103004, upload-time = "2025-03-27T18:40:04.204Z" }, + { url = "https://files.pythonhosted.org/packages/bf/fd/0e96c8e6767618ed1a06e4d7a167fe13734c2f8113c4cb704443e6783038/sqlalchemy-2.0.40-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bb933a650323e476a2e4fbef8997a10d0003d4da996aad3fd7873e962fdde4d", size = 3252440, upload-time = "2025-03-27T18:51:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6a/eb82e45b15a64266a2917a6833b51a334ea3c1991728fd905bfccbf5cf63/sqlalchemy-2.0.40-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6959738971b4745eea16f818a2cd086fb35081383b078272c35ece2b07012716", size = 3263277, upload-time = "2025-03-27T18:50:28.142Z" }, + { url = "https://files.pythonhosted.org/packages/45/97/ebe41ab4530f50af99e3995ebd4e0204bf1b0dc0930f32250dde19c389fe/sqlalchemy-2.0.40-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:110179728e442dae85dd39591beb74072ae4ad55a44eda2acc6ec98ead80d5f2", size = 3198591, upload-time = "2025-03-27T18:51:27.543Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1c/a569c1b2b2f5ac20ba6846a1321a2bf52e9a4061001f282bf1c5528dcd69/sqlalchemy-2.0.40-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8040680eaacdce4d635f12c55c714f3d4c7f57da2bc47a01229d115bd319191", size = 3225199, upload-time = "2025-03-27T18:50:30.069Z" }, + { url = "https://files.pythonhosted.org/packages/8c/18/4e3a86cc0232377bc48c373a9ba6a1b3fb79ba32dbb4eda0b357f5a2c59d/sqlalchemy-2.0.40-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:915866fd50dd868fdcc18d61d8258db1bf9ed7fbd6dfec960ba43365952f3b01", size = 2107887, upload-time = "2025-03-27T18:40:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/cb/60/9fa692b1d2ffc4cbd5f47753731fd332afed30137115d862d6e9a1e962c7/sqlalchemy-2.0.40-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a4c5a2905a9ccdc67a8963e24abd2f7afcd4348829412483695c59e0af9a705", size = 2098367, upload-time = "2025-03-27T18:40:07.182Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9f/84b78357ca641714a439eb3fbbddb17297dacfa05d951dbf24f28d7b5c08/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55028d7a3ebdf7ace492fab9895cbc5270153f75442a0472d8516e03159ab364", size = 3184806, upload-time = "2025-03-27T18:51:29.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7d/e06164161b6bfce04c01bfa01518a20cccbd4100d5c951e5a7422189191a/sqlalchemy-2.0.40-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cfedff6878b0e0d1d0a50666a817ecd85051d12d56b43d9d425455e608b5ba0", size = 3198131, upload-time = "2025-03-27T18:50:31.616Z" }, + { url = "https://files.pythonhosted.org/packages/6d/51/354af20da42d7ec7b5c9de99edafbb7663a1d75686d1999ceb2c15811302/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb19e30fdae77d357ce92192a3504579abe48a66877f476880238a962e5b96db", size = 3131364, upload-time = "2025-03-27T18:51:31.336Z" }, + { url = "https://files.pythonhosted.org/packages/7a/2f/48a41ff4e6e10549d83fcc551ab85c268bde7c03cf77afb36303c6594d11/sqlalchemy-2.0.40-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:16d325ea898f74b26ffcd1cf8c593b0beed8714f0317df2bed0d8d1de05a8f26", size = 3159482, upload-time = "2025-03-27T18:50:33.201Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7c/5fc8e802e7506fe8b55a03a2e1dab156eae205c91bee46305755e086d2e2/sqlalchemy-2.0.40-py3-none-any.whl", hash = "sha256:32587e2e1e359276957e6fe5dad089758bc042a971a8a09ae8ecf7a8fe23d07a", size = 1903894, upload-time = "2025-03-27T18:40:43.796Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] + [[package]] name = "sqlparse" version = "0.5.3" @@ -3883,6 +4948,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, ] +[[package]] +name = "sympy" +version = "1.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/8a/5a7fd6284fa8caac23a26c9ddf9c30485a48169344b4bd3b0f02fef1890f/sympy-1.13.3.tar.gz", hash = "sha256:b27fd2c6530e0ab39e275fc9b683895367e51d5da91baa8d3d64db2565fec4d9", size = 7533196, upload-time = "2024-09-18T21:54:25.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/ff/c87e0622b1dadea79d2fb0b25ade9ed98954c9033722eb707053d310d4f3/sympy-1.13.3-py3-none-any.whl", hash = "sha256:54612cf55a62755ee71824ce692986f23c88ffa77207b30c1368eda4a7060f73", size = 6189483, upload-time = "2024-09-18T21:54:23.097Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + [[package]] name = "termcolor" version = "3.1.0" @@ -3915,6 +5001,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/31/002e0fa5bca67d6a19da8c294273486f6c46cbcc83d6879719a38a181461/tika_client-0.10.0-py3-none-any.whl", hash = "sha256:f5486cc884e4522575662aa295bda761bf9f101ac8d92840155b58ab8b96f6e2", size = 18237, upload-time = "2025-08-04T17:47:28.966Z" }, ] +[[package]] +name = "tiktoken" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/cf/756fedf6981e82897f2d570dd25fa597eb3f4459068ae0572d7e888cfd6f/tiktoken-0.9.0.tar.gz", hash = "sha256:d02a5ca6a938e0490e1ff957bc48c8b078c88cb83977be1625b1fd8aac792c5d", size = 35991, upload-time = "2025-02-14T06:03:01.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/f3/50ec5709fad61641e4411eb1b9ac55b99801d71f1993c29853f256c726c9/tiktoken-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:586c16358138b96ea804c034b8acf3f5d3f0258bd2bc3b0227af4af5d622e382", size = 1065770, upload-time = "2025-02-14T06:02:01.251Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f8/5a9560a422cf1755b6e0a9a436e14090eeb878d8ec0f80e0cd3d45b78bf4/tiktoken-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9c59ccc528c6c5dd51820b3474402f69d9a9e1d656226848ad68a8d5b2e5108", size = 1009314, upload-time = "2025-02-14T06:02:02.869Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/3ed4cfff8f809cb902900ae686069e029db74567ee10d017cb254df1d598/tiktoken-0.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0968d5beeafbca2a72c595e8385a1a1f8af58feaebb02b227229b69ca5357fd", size = 1143140, upload-time = "2025-02-14T06:02:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/f1/95/cc2c6d79df8f113bdc6c99cdec985a878768120d87d839a34da4bd3ff90a/tiktoken-0.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92a5fb085a6a3b7350b8fc838baf493317ca0e17bd95e8642f95fc69ecfed1de", size = 1197860, upload-time = "2025-02-14T06:02:06.268Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6c/9c1a4cc51573e8867c9381db1814223c09ebb4716779c7f845d48688b9c8/tiktoken-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15a2752dea63d93b0332fb0ddb05dd909371ededa145fe6a3242f46724fa7990", size = 1259661, upload-time = "2025-02-14T06:02:08.889Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ae/4613a59a2a48e761c5161237fc850eb470b4bb93696db89da51b79a871f1/tiktoken-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f32cc56168eac4851109e9b5d327637f15fd662aa30dd79f964b7c39fbadd26e", size = 1065987, upload-time = "2025-02-14T06:02:14.174Z" }, + { url = "https://files.pythonhosted.org/packages/3f/86/55d9d1f5b5a7e1164d0f1538a85529b5fcba2b105f92db3622e5d7de6522/tiktoken-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:45556bc41241e5294063508caf901bf92ba52d8ef9222023f83d2483a3055348", size = 1009155, upload-time = "2025-02-14T06:02:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/03/58/01fb6240df083b7c1916d1dcb024e2b761213c95d576e9f780dfb5625a76/tiktoken-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03935988a91d6d3216e2ec7c645afbb3d870b37bcb67ada1943ec48678e7ee33", size = 1142898, upload-time = "2025-02-14T06:02:16.666Z" }, + { url = "https://files.pythonhosted.org/packages/b1/73/41591c525680cd460a6becf56c9b17468d3711b1df242c53d2c7b2183d16/tiktoken-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3d80aad8d2c6b9238fc1a5524542087c52b860b10cbf952429ffb714bc1136", size = 1197535, upload-time = "2025-02-14T06:02:18.595Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7c/1069f25521c8f01a1a182f362e5c8e0337907fae91b368b7da9c3e39b810/tiktoken-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b2a21133be05dc116b1d0372af051cd2c6aa1d2188250c9b553f9fa49301b336", size = 1259548, upload-time = "2025-02-14T06:02:20.729Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e5/21ff33ecfa2101c1bb0f9b6df750553bd873b7fb532ce2cb276ff40b197f/tiktoken-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e88f121c1c22b726649ce67c089b90ddda8b9662545a8aeb03cfef15967ddd03", size = 1065073, upload-time = "2025-02-14T06:02:24.768Z" }, + { url = "https://files.pythonhosted.org/packages/8e/03/a95e7b4863ee9ceec1c55983e4cc9558bcfd8f4f80e19c4f8a99642f697d/tiktoken-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a6600660f2f72369acb13a57fb3e212434ed38b045fd8cc6cdd74947b4b5d210", size = 1008075, upload-time = "2025-02-14T06:02:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/40/10/1305bb02a561595088235a513ec73e50b32e74364fef4de519da69bc8010/tiktoken-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95e811743b5dfa74f4b227927ed86cbc57cad4df859cb3b643be797914e41794", size = 1140754, upload-time = "2025-02-14T06:02:28.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/40/da42522018ca496432ffd02793c3a72a739ac04c3794a4914570c9bb2925/tiktoken-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99376e1370d59bcf6935c933cb9ba64adc29033b7e73f5f7569f3aad86552b22", size = 1196678, upload-time = "2025-02-14T06:02:29.845Z" }, + { url = "https://files.pythonhosted.org/packages/5c/41/1e59dddaae270ba20187ceb8aa52c75b24ffc09f547233991d5fd822838b/tiktoken-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:badb947c32739fb6ddde173e14885fb3de4d32ab9d8c591cbd013c22b4c31dd2", size = 1259283, upload-time = "2025-02-14T06:02:33.838Z" }, + { url = "https://files.pythonhosted.org/packages/7a/11/09d936d37f49f4f494ffe660af44acd2d99eb2429d60a57c71318af214e0/tiktoken-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b0e8e05a26eda1249e824156d537015480af7ae222ccb798e5234ae0285dbdb", size = 1064919, upload-time = "2025-02-14T06:02:37.494Z" }, + { url = "https://files.pythonhosted.org/packages/80/0e/f38ba35713edb8d4197ae602e80837d574244ced7fb1b6070b31c29816e0/tiktoken-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:27d457f096f87685195eea0165a1807fae87b97b2161fe8c9b1df5bd74ca6f63", size = 1007877, upload-time = "2025-02-14T06:02:39.516Z" }, + { url = "https://files.pythonhosted.org/packages/fe/82/9197f77421e2a01373e27a79dd36efdd99e6b4115746ecc553318ecafbf0/tiktoken-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cf8ded49cddf825390e36dd1ad35cd49589e8161fdcb52aa25f0583e90a3e01", size = 1140095, upload-time = "2025-02-14T06:02:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/4513da71cac187383541facd0291c4572b03ec23c561de5811781bbd988f/tiktoken-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc156cb314119a8bb9748257a2eaebd5cc0753b6cb491d26694ed42fc7cb3139", size = 1195649, upload-time = "2025-02-14T06:02:43Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5c/74e4c137530dd8504e97e3a41729b1103a4ac29036cbfd3250b11fd29451/tiktoken-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cd69372e8c9dd761f0ab873112aba55a0e3e506332dd9f7522ca466e817b1b7a", size = 1258465, upload-time = "2025-02-14T06:02:45.046Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/76/5ac0c97f1117b91b7eb7323dcd61af80d72f790b4df71249a7850c195f30/tokenizers-0.21.1.tar.gz", hash = "sha256:a1bb04dc5b448985f86ecd4b05407f5a8d97cb2c0532199b2a302a604a0165ab", size = 343256, upload-time = "2025-03-13T10:51:18.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/1f/328aee25f9115bf04262e8b4e5a2050b7b7cf44b59c74e982db7270c7f30/tokenizers-0.21.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e78e413e9e668ad790a29456e677d9d3aa50a9ad311a40905d6861ba7692cf41", size = 2780767, upload-time = "2025-03-13T10:51:09.459Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1a/4526797f3719b0287853f12c5ad563a9be09d446c44ac784cdd7c50f76ab/tokenizers-0.21.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:cd51cd0a91ecc801633829fcd1fda9cf8682ed3477c6243b9a095539de4aecf3", size = 2650555, upload-time = "2025-03-13T10:51:07.692Z" }, + { url = "https://files.pythonhosted.org/packages/4d/7a/a209b29f971a9fdc1da86f917fe4524564924db50d13f0724feed37b2a4d/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28da6b72d4fb14ee200a1bd386ff74ade8992d7f725f2bde2c495a9a98cf4d9f", size = 2937541, upload-time = "2025-03-13T10:50:56.679Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1e/b788b50ffc6191e0b1fc2b0d49df8cff16fe415302e5ceb89f619d12c5bc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34d8cfde551c9916cb92014e040806122295a6800914bab5865deb85623931cf", size = 2819058, upload-time = "2025-03-13T10:50:59.525Z" }, + { url = "https://files.pythonhosted.org/packages/36/aa/3626dfa09a0ecc5b57a8c58eeaeb7dd7ca9a37ad9dd681edab5acd55764c/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaa852d23e125b73d283c98f007e06d4595732104b65402f46e8ef24b588d9f8", size = 3133278, upload-time = "2025-03-13T10:51:04.678Z" }, + { url = "https://files.pythonhosted.org/packages/a4/4d/8fbc203838b3d26269f944a89459d94c858f5b3f9a9b6ee9728cdcf69161/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a21a15d5c8e603331b8a59548bbe113564136dc0f5ad8306dd5033459a226da0", size = 3144253, upload-time = "2025-03-13T10:51:01.261Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1b/2bd062adeb7c7511b847b32e356024980c0ffcf35f28947792c2d8ad2288/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdbd4c067c60a0ac7eca14b6bd18a5bebace54eb757c706b47ea93204f7a37c", size = 3398225, upload-time = "2025-03-13T10:51:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/8a/63/38be071b0c8e06840bc6046991636bcb30c27f6bb1e670f4f4bc87cf49cc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dd9a0061e403546f7377df940e866c3e678d7d4e9643d0461ea442b4f89e61a", size = 3038874, upload-time = "2025-03-13T10:51:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/ec/83/afa94193c09246417c23a3c75a8a0a96bf44ab5630a3015538d0c316dd4b/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:db9484aeb2e200c43b915a1a0150ea885e35f357a5a8fabf7373af333dcc8dbf", size = 9014448, upload-time = "2025-03-13T10:51:10.927Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b3/0e1a37d4f84c0f014d43701c11eb8072704f6efe8d8fc2dcdb79c47d76de/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed248ab5279e601a30a4d67bdb897ecbe955a50f1e7bb62bd99f07dd11c2f5b6", size = 8937877, upload-time = "2025-03-13T10:51:12.688Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/ff08f50e6d615eb180a4a328c65907feb6ded0b8f990ec923969759dc379/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:9ac78b12e541d4ce67b4dfd970e44c060a2147b9b2a21f509566d556a509c67d", size = 9186645, upload-time = "2025-03-13T10:51:14.723Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/8ae85f69a9f6012c6f8011c6f4aa1c96154c816e9eea2e1b758601157833/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e5a69c1a4496b81a5ee5d2c1f3f7fbdf95e90a0196101b0ee89ed9956b8a168f", size = 9384380, upload-time = "2025-03-13T10:51:16.526Z" }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -3948,6 +5089,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "torch" +version = "2.9.1" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "filelock", marker = "sys_platform == 'darwin'" }, + { name = "fsspec", marker = "sys_platform == 'darwin'" }, + { name = "jinja2", marker = "sys_platform == 'darwin'" }, + { name = "networkx", marker = "sys_platform == 'darwin'" }, + { name = "setuptools", marker = "python_full_version >= '3.12' and sys_platform == 'darwin'" }, + { name = "sympy", marker = "sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, +] +wheels = [ + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp310-none-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp311-none-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp312-none-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-cp313t-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1-cp314-cp314t-macosx_11_0_arm64.whl" }, +] + +[[package]] +name = "torch" +version = "2.9.1+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'linux'", +] +dependencies = [ + { name = "filelock", marker = "sys_platform == 'linux'" }, + { name = "fsspec", marker = "sys_platform == 'linux'" }, + { name = "jinja2", marker = "sys_platform == 'linux'" }, + { name = "networkx", marker = "sys_platform == 'linux'" }, + { name = "setuptools", marker = "python_full_version >= '3.12' and sys_platform == 'linux'" }, + { name = "sympy", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp310-cp310-manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl" }, +] + [[package]] name = "tornado" version = "6.5.2" @@ -3973,6 +5179,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "transformers" +version = "4.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "huggingface-hub", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, + { name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux')" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "regex", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "safetensors", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/40/f2d2c3bcf5c6135027cab0fd7db52f6149a1c23acc4e45f914c43d362386/transformers-4.53.0.tar.gz", hash = "sha256:f89520011b4a73066fdc7aabfa158317c3934a22e3cd652d7ffbc512c4063841", size = 9177265, upload-time = "2025-06-26T16:10:54.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/0c/68d03a38f6ab2ba2b2829eb11b334610dd236e7926787f7656001b68e1f2/transformers-4.53.0-py3-none-any.whl", hash = "sha256:7d8039ff032c01a2d7f8a8fe0066620367003275f023815a966e62203f9f5dd7", size = 10821970, upload-time = "2025-06-26T16:10:51.505Z" }, +] + [[package]] name = "twisted" version = "25.5.0" @@ -4183,6 +5411,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2025.2" @@ -4212,34 +5465,34 @@ wheels = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] name = "uv" -version = "0.9.3" +version = "0.9.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/dc/4a0e01bcb38c756130c8118a8561d4bf0a0bb685b70ad11e8f40a0cbfa10/uv-0.9.3.tar.gz", hash = "sha256:a290a1a8783bf04ca2d4a63d5d72191b255dfa4cc3426a9c9b5af4da49a7b5af", size = 3699151, upload-time = "2025-10-15T15:20:15.498Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/51/c56ac81b4bd642d78365741eef118140459e2a94b9385ef973826b1b5306/uv-0.9.6.tar.gz", hash = "sha256:547fd27ab5da7cd1a833288a36858852451d416a056825f162ecf2af5be6f8b8", size = 3704033, upload-time = "2025-10-29T19:40:46.35Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/ad/194e550062e4b3b9a74cb06401dc0afd83490af8e2ec0f414737868d0262/uv-0.9.3-py3-none-linux_armv6l.whl", hash = "sha256:7b1b79dd435ade1de97c6f0b8b90811a6ccf1bd0bdd70f4d034a93696cf0d0a3", size = 20584531, upload-time = "2025-10-15T15:19:14.26Z" }, - { url = "https://files.pythonhosted.org/packages/d0/1a/8e68d0020c29f6f329a265773c23b0c01e002794ea884b8bdbd594c7ea97/uv-0.9.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:596a982c5a061d58412824a2ebe2960b52db23f1b1658083ba9c0e7ae390308a", size = 19577639, upload-time = "2025-10-15T15:19:18.668Z" }, - { url = "https://files.pythonhosted.org/packages/16/25/6df8be6cd549200e80d19374579689fda39b18735afde841345284fb113d/uv-0.9.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:741e80c4230e1b9a5d0869aca2fb082b3832b251ef61537bc9278364b8e74df2", size = 18210073, upload-time = "2025-10-15T15:19:22.16Z" }, - { url = "https://files.pythonhosted.org/packages/07/19/bb8aa38b4441e03c742e71a31779f91b42d9db255ede66f80cdfdb672618/uv-0.9.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:406ab1a8b313b4b3cf67ad747fb8713a0c0cf3d3daf11942b5a4e49f60882339", size = 20022427, upload-time = "2025-10-15T15:19:25.453Z" }, - { url = "https://files.pythonhosted.org/packages/40/15/f190004dd855b443cfc1cc36edb1765e6cd0b6b340a50bb8015531dfff2e/uv-0.9.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73dbd91581a82e53bb4352243d7bc491cf78ac3ebb951d95bb8b7964e5ee0659", size = 20150307, upload-time = "2025-10-15T15:19:28.99Z" }, - { url = "https://files.pythonhosted.org/packages/dd/55/553e90bc2b881f168de9cd57f9e0b0464304a12aee289e71b54c42559e1a/uv-0.9.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:970ac8428678b92eddb990dc132d75e893234bb1b809e87b90a4acd96bb054e4", size = 21152942, upload-time = "2025-10-15T15:19:32.461Z" }, - { url = "https://files.pythonhosted.org/packages/30/fb/768647a31622c2c1da7a9394eaab937e2e7ca0e8c983ca3d1918ec623620/uv-0.9.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:32694e64d6e4ea44b647866c4240659f3964b0317e98f539b73915dbcca7d973", size = 22632018, upload-time = "2025-10-15T15:19:36.091Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/66d660414aed123686bf9a2a3ea167967b847b97c08cacd13d6b2b6d1267/uv-0.9.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36df7eb562b103e3263a03df1b04cee91ee52af88d005d07ee494137c7a5782a", size = 22241856, upload-time = "2025-10-15T15:19:39.662Z" }, - { url = "https://files.pythonhosted.org/packages/0d/99/af8b0cd2c958e8cb9c20e6e2d417de9476338a2b155643492a8ee2baf077/uv-0.9.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:117c5921bcfdac04b88211ee830c6c7e412eaf93a34aa3ad4bb3230bc61646aa", size = 21391699, upload-time = "2025-10-15T15:19:42.933Z" }, - { url = "https://files.pythonhosted.org/packages/82/45/488417c6c0127c00bcdfac3556ae2ea0597df8245fe5f9bcfda35ebdbe85/uv-0.9.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73ae4bbc7d555ba1738da08c64b55f21ab0ea0ff85636708cebaf460d98a440d", size = 21318117, upload-time = "2025-10-15T15:19:46.341Z" }, - { url = "https://files.pythonhosted.org/packages/1d/62/508c20f8dbdd2342cc4821ab6f41e29a9b36e2a469dfb5cbbd042e15218c/uv-0.9.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e75ce14c9375e7e99422d5383fb415e8f0eab9ebdcdfba45756749dee0c42b2", size = 20132999, upload-time = "2025-10-15T15:19:49.578Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fc/ea673d1c68915ea53f1ab7e134b330a2351c543f06e9d0009b4f27cc3057/uv-0.9.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:71faefa9805ccf3f2db645ae27c9e719e47aaa8781e43dfa3760d993aadecb8c", size = 21223810, upload-time = "2025-10-15T15:19:52.711Z" }, - { url = "https://files.pythonhosted.org/packages/97/1f/af8ced7f6c8f6af887c52369088058ecae92ff21819e385531023f9ec923/uv-0.9.3-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:8844103e0b4074821fb2814abf30af59d66f33b6ca1bb2276dd37d4e5997c292", size = 20156823, upload-time = "2025-10-15T15:19:56.552Z" }, - { url = "https://files.pythonhosted.org/packages/05/2d/e1d8f74ec9d95daf57f3c53083c98a2145ee895a4f8502c61c9013c9bf5a/uv-0.9.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:214bb2fb4d87a55e2ba2bc038a8b646a24ec66980528d2ed1e6e7d0612d246e1", size = 20564971, upload-time = "2025-10-15T15:20:00.012Z" }, - { url = "https://files.pythonhosted.org/packages/bc/04/4aaf90e031f0735795407a208c9528f85b0b27b63409abe4ee3bee0d4527/uv-0.9.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ccf4cd2e1907fb011764f6f4bc0e514c500e8d300288f04a4680400d5aa205ec", size = 21506573, upload-time = "2025-10-15T15:20:03.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e6/c0747c9b307a91618e483b0cd78ba076578df70359f08c9096f36b0dae93/uv-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:b2f934737c93f88c906b6a47bcc083170210fe5d66565e80a7c139599e5cbf2f", size = 20632765, upload-time = "2025-10-29T19:39:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d8/eba09583108476b9c21f4e09427553af7c5516a21ac01a18c63c357bcd72/uv-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a7c6067919d87208c4a6092033c3bc9799cb8be1c8bc6ef419a1f6d42a755329", size = 19666984, upload-time = "2025-10-29T19:39:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a3/8d7da102542995ed8b16ae6079ae853221e17a5eec1fff442e6eacf5760c/uv-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:95a62c1f668272555ad0c446bf44a9924dee06054b831d04c162e0bad736dc28", size = 18335059, upload-time = "2025-10-29T19:40:05.138Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0b/597719ad347610588954730f1124761184a6b71cf5aa1600f0a992939ef4/uv-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:0169a85d3ba5ef1c37089d64ff26de573439ca84ecf549276a2eee42d7f833f2", size = 20144462, upload-time = "2025-10-29T19:40:08.328Z" }, + { url = "https://files.pythonhosted.org/packages/31/cf/3f87025168bb377994ea468fc8757d5e01062b3888ec23eddd9b6d119135/uv-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ba311b3ca49d246f36d444d3ee81571619ef95e5f509eb694a81defcbed262", size = 20251834, upload-time = "2025-10-29T19:40:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/55/a0/a4027a220756a88dbc8bb7a6896fffc0e70af9b9ab030d644ab8baba3793/uv-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e89c964f614fa3f0481060cac709d6da50feac553e1e11227d6c4c81c87af7c", size = 21172738, upload-time = "2025-10-29T19:40:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f6/d9fd69247c8c3039c6818ceb20652d18322a874e10f6def3f05599ed8d07/uv-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ea67369918af24ea7e01991dfc8b8988d1b0b7c49cb39d9e5bc0c409930a0a3f", size = 22756338, upload-time = "2025-10-29T19:40:16.032Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f6/6a0b4f43675c48138d62a6ddb5aebed67a1c283bee3758e5258a75f000ed/uv-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90122a76e6441b8c580fc9faf06bd8c4dbe276cb1c185ad91eceb2afa78e492a", size = 22334132, upload-time = "2025-10-29T19:40:18.862Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3f/a17d6e26a795a2e7d6023bae9c5af7da3118eebc23053ec7c0cbbb603638/uv-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86e05782f9b75d39ab1c0af98bf11e87e646a36a61d425021d5b284073e56315", size = 21487365, upload-time = "2025-10-29T19:40:21.709Z" }, + { url = "https://files.pythonhosted.org/packages/e4/29/9cbafd47012a68b39902ff022bd1c7051384fcc23392b2d813d0f418e61f/uv-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c2c2b2b093330e603d838fec26941ab6f62e8d62a012f9fa0d5ed88da39d907", size = 21384698, upload-time = "2025-10-29T19:40:24.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f9/525978cfa7c27ca2616ca0d214460861a8046085c032a0de6c5bedddcf6c/uv-0.9.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e700b2098f9d365061c572d0729b4e8bc71c6468d83dfaae2537cd66e3cb1b98", size = 20255252, upload-time = "2025-10-29T19:40:26.757Z" }, + { url = "https://files.pythonhosted.org/packages/10/6f/89040302486b83e2085579ffebe3078dc92b15f42406f986d9e690e47f1b/uv-0.9.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6403176b55388cf94fb8737e73b26ee2a7b1805a9139da5afa951210986d4fcd", size = 21308498, upload-time = "2025-10-29T19:40:29.273Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/5a3e879f7399c36c97d0b893c2dd5e91b76315c41793f13f86ff2091191a/uv-0.9.6-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:62e3f057a9ae5e5003a7cd56b617e940f519f6dabcbb22d36cdd0149df25d409", size = 20230221, upload-time = "2025-10-29T19:40:32.161Z" }, + { url = "https://files.pythonhosted.org/packages/7a/66/5bdabfd7afc6b429d8be7d6dc6446709f657621384960ec8b85e0088a3d9/uv-0.9.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:538716ec97f8d899baa7e1c427f4411525459c0ef72ea9b3625ce9610c9976e6", size = 20625876, upload-time = "2025-10-29T19:40:34.577Z" }, + { url = "https://files.pythonhosted.org/packages/5d/34/257747253ad446fd155e39f0c30afda4597b3b9e28f44a9de5dee76a6509/uv-0.9.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b31377ebf2d0499afc5abe3fe1abded5ca843f3a1161b432fe26eb0ce15bab8e", size = 21597889, upload-time = "2025-10-29T19:40:36.963Z" }, ] [[package]] @@ -4285,7 +5538,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.34.0" +version = "20.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -4293,9 +5546,9 @@ dependencies = [ { name = "platformdirs", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] [[package]] @@ -4327,6 +5580,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, ] +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + [[package]] name = "wcwidth" version = "0.2.14" @@ -4417,6 +5759,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] +[[package]] +name = "yarl" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "multidict", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "propcache", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/51/c0edba5219027f6eab262e139f73e2417b0f4efffa23bf562f6e18f76ca5/yarl-1.20.0.tar.gz", hash = "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307", size = 185258, upload-time = "2025-04-17T00:45:14.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/ab/66082639f99d7ef647a86b2ff4ca20f8ae13bd68a6237e6e166b8eb92edf/yarl-1.20.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f1f6670b9ae3daedb325fa55fbe31c22c8228f6e0b513772c2e1c623caa6ab22", size = 145054, upload-time = "2025-04-17T00:41:27.071Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c2/4e78185c453c3ca02bd11c7907394d0410d26215f9e4b7378648b3522a30/yarl-1.20.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85a231fa250dfa3308f3c7896cc007a47bc76e9e8e8595c20b7426cac4884c62", size = 96811, upload-time = "2025-04-17T00:41:30.235Z" }, + { url = "https://files.pythonhosted.org/packages/c7/45/91e31dccdcf5b7232dcace78bd51a1bb2d7b4b96c65eece0078b620587d1/yarl-1.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a06701b647c9939d7019acdfa7ebbfbb78ba6aa05985bb195ad716ea759a569", size = 94566, upload-time = "2025-04-17T00:41:32.023Z" }, + { url = "https://files.pythonhosted.org/packages/c8/21/e0aa650bcee881fb804331faa2c0f9a5d6be7609970b2b6e3cdd414e174b/yarl-1.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7595498d085becc8fb9203aa314b136ab0516c7abd97e7d74f7bb4eb95042abe", size = 327297, upload-time = "2025-04-17T00:41:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/58f10870f5c17595c5a37da4c6a0b321589b7d7976e10570088d445d0f47/yarl-1.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af5607159085dcdb055d5678fc2d34949bd75ae6ea6b4381e784bbab1c3aa195", size = 323578, upload-time = "2025-04-17T00:41:36.492Z" }, + { url = "https://files.pythonhosted.org/packages/07/df/2506b1382cc0c4bb0d22a535dc3e7ccd53da9a59b411079013a7904ac35c/yarl-1.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:95b50910e496567434cb77a577493c26bce0f31c8a305135f3bda6a2483b8e10", size = 343212, upload-time = "2025-04-17T00:41:38.396Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4a/d1c901d0e2158ad06bb0b9a92473e32d992f98673b93c8a06293e091bab0/yarl-1.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b594113a301ad537766b4e16a5a6750fcbb1497dcc1bc8a4daae889e6402a634", size = 337956, upload-time = "2025-04-17T00:41:40.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fd/10fcf7d86f49b1a11096d6846257485ef32e3d3d322e8a7fdea5b127880c/yarl-1.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:083ce0393ea173cd37834eb84df15b6853b555d20c52703e21fbababa8c129d2", size = 333889, upload-time = "2025-04-17T00:41:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cd/bae926a25154ba31c5fd15f2aa6e50a545c840e08d85e2e2e0807197946b/yarl-1.20.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f1a350a652bbbe12f666109fbddfdf049b3ff43696d18c9ab1531fbba1c977a", size = 322282, upload-time = "2025-04-17T00:41:44.641Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/c3ac3597dfde746c63c637c5422cf3954ebf622a8de7f09892d20a68900d/yarl-1.20.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fb0caeac4a164aadce342f1597297ec0ce261ec4532bbc5a9ca8da5622f53867", size = 336270, upload-time = "2025-04-17T00:41:46.812Z" }, + { url = "https://files.pythonhosted.org/packages/dd/42/417fd7b8da5846def29712370ea8916a4be2553de42a2c969815153717be/yarl-1.20.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d88cc43e923f324203f6ec14434fa33b85c06d18d59c167a0637164863b8e995", size = 335500, upload-time = "2025-04-17T00:41:48.896Z" }, + { url = "https://files.pythonhosted.org/packages/37/aa/c2339683f8f05f4be16831b6ad58d04406cf1c7730e48a12f755da9f5ac5/yarl-1.20.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e52d6ed9ea8fd3abf4031325dc714aed5afcbfa19ee4a89898d663c9976eb487", size = 339672, upload-time = "2025-04-17T00:41:50.965Z" }, + { url = "https://files.pythonhosted.org/packages/be/12/ab6c4df95f00d7bc9502bf07a92d5354f11d9d3cb855222a6a8d2bd6e8da/yarl-1.20.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ce360ae48a5e9961d0c730cf891d40698a82804e85f6e74658fb175207a77cb2", size = 351840, upload-time = "2025-04-17T00:41:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/83/3c/08d58c51bbd3899be3e7e83cd7a691fdcf3b9f78b8699d663ecc2c090ab7/yarl-1.20.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:06d06c9d5b5bc3eb56542ceeba6658d31f54cf401e8468512447834856fb0e61", size = 359550, upload-time = "2025-04-17T00:41:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/8a/15/de7906c506f85fb476f0edac4bd74569f49e5ffdcf98e246a0313bf593b9/yarl-1.20.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c27d98f4e5c4060582f44e58309c1e55134880558f1add7a87c1bc36ecfade19", size = 351108, upload-time = "2025-04-17T00:41:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/60/82/a59d8e21b20ffc836775fa7daedac51d16bb8f3010c4fcb495c4496aa922/yarl-1.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fdb5204d17cb32b2de2d1e21c7461cabfacf17f3645e4b9039f210c5d3378bf3", size = 145178, upload-time = "2025-04-17T00:42:04.511Z" }, + { url = "https://files.pythonhosted.org/packages/ba/81/315a3f6f95947cfbf37c92d6fbce42a1a6207b6c38e8c2b452499ec7d449/yarl-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eaddd7804d8e77d67c28d154ae5fab203163bd0998769569861258e525039d2a", size = 96859, upload-time = "2025-04-17T00:42:06.43Z" }, + { url = "https://files.pythonhosted.org/packages/ad/17/9b64e575583158551b72272a1023cdbd65af54fe13421d856b2850a6ddb7/yarl-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:634b7ba6b4a85cf67e9df7c13a7fb2e44fa37b5d34501038d174a63eaac25ee2", size = 94647, upload-time = "2025-04-17T00:42:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/2c/29/8f291e7922a58a21349683f6120a85701aeefaa02e9f7c8a2dc24fe3f431/yarl-1.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d409e321e4addf7d97ee84162538c7258e53792eb7c6defd0c33647d754172e", size = 355788, upload-time = "2025-04-17T00:42:09.902Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/b4892c80b805c42c228c6d11e03cafabf81662d371b0853e7f0f513837d5/yarl-1.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ea52f7328a36960ba3231c6677380fa67811b414798a6e071c7085c57b6d20a9", size = 344613, upload-time = "2025-04-17T00:42:11.768Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0e/517aa28d3f848589bae9593717b063a544b86ba0a807d943c70f48fcf3bb/yarl-1.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8703517b924463994c344dcdf99a2d5ce9eca2b6882bb640aa555fb5efc706a", size = 370953, upload-time = "2025-04-17T00:42:13.983Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/5bd09d2f1ad6e6f7c2beae9e50db78edd2cca4d194d227b958955573e240/yarl-1.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:077989b09ffd2f48fb2d8f6a86c5fef02f63ffe6b1dd4824c76de7bb01e4f2e2", size = 369204, upload-time = "2025-04-17T00:42:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/9c/85/d793a703cf4bd0d4cd04e4b13cc3d44149470f790230430331a0c1f52df5/yarl-1.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0acfaf1da020253f3533526e8b7dd212838fdc4109959a2c53cafc6db611bff2", size = 358108, upload-time = "2025-04-17T00:42:18.622Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/b6c71e13549c1f6048fbc14ce8d930ac5fb8bafe4f1a252e621a24f3f1f9/yarl-1.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4230ac0b97ec5eeb91d96b324d66060a43fd0d2a9b603e3327ed65f084e41f8", size = 346610, upload-time = "2025-04-17T00:42:20.9Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/d6087d58bdd0d8a2a37bbcdffac9d9721af6ebe50d85304d9f9b57dfd862/yarl-1.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a6a1e6ae21cdd84011c24c78d7a126425148b24d437b5702328e4ba640a8902", size = 365378, upload-time = "2025-04-17T00:42:22.926Z" }, + { url = "https://files.pythonhosted.org/packages/02/84/e25ddff4cbc001dbc4af76f8d41a3e23818212dd1f0a52044cbc60568872/yarl-1.20.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:86de313371ec04dd2531f30bc41a5a1a96f25a02823558ee0f2af0beaa7ca791", size = 356919, upload-time = "2025-04-17T00:42:25.145Z" }, + { url = "https://files.pythonhosted.org/packages/04/76/898ae362353bf8f64636495d222c8014c8e5267df39b1a9fe1e1572fb7d0/yarl-1.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dd59c9dd58ae16eaa0f48c3d0cbe6be8ab4dc7247c3ff7db678edecbaf59327f", size = 364248, upload-time = "2025-04-17T00:42:27.475Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b0/9d9198d83a622f1c40fdbf7bd13b224a6979f2e1fc2cf50bfb1d8773c495/yarl-1.20.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a0bc5e05f457b7c1994cc29e83b58f540b76234ba6b9648a4971ddc7f6aa52da", size = 378418, upload-time = "2025-04-17T00:42:29.333Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ce/1f50c1cc594cf5d3f5bf4a9b616fca68680deaec8ad349d928445ac52eb8/yarl-1.20.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c9471ca18e6aeb0e03276b5e9b27b14a54c052d370a9c0c04a68cefbd1455eb4", size = 383850, upload-time = "2025-04-17T00:42:31.668Z" }, + { url = "https://files.pythonhosted.org/packages/89/1e/a59253a87b35bfec1a25bb5801fb69943330b67cfd266278eb07e0609012/yarl-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40ed574b4df723583a26c04b298b283ff171bcc387bc34c2683235e2487a65a5", size = 381218, upload-time = "2025-04-17T00:42:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e8/3efdcb83073df978bb5b1a9cc0360ce596680e6c3fac01f2a994ccbb8939/yarl-1.20.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e06b9f6cdd772f9b665e5ba8161968e11e403774114420737f7884b5bd7bdf6f", size = 147089, upload-time = "2025-04-17T00:42:39.602Z" }, + { url = "https://files.pythonhosted.org/packages/60/c3/9e776e98ea350f76f94dd80b408eaa54e5092643dbf65fd9babcffb60509/yarl-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9ae2fbe54d859b3ade40290f60fe40e7f969d83d482e84d2c31b9bff03e359e", size = 97706, upload-time = "2025-04-17T00:42:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/45cdfb64a3b855ce074ae607b9fc40bc82e7613b94e7612b030255c93a09/yarl-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d12b8945250d80c67688602c891237994d203d42427cb14e36d1a732eda480e", size = 95719, upload-time = "2025-04-17T00:42:43.666Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4e/929633b249611eeed04e2f861a14ed001acca3ef9ec2a984a757b1515889/yarl-1.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:087e9731884621b162a3e06dc0d2d626e1542a617f65ba7cc7aeab279d55ad33", size = 343972, upload-time = "2025-04-17T00:42:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/047535d326c913f1a90407a3baf7ff535b10098611eaef2c527e32e81ca1/yarl-1.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69df35468b66c1a6e6556248e6443ef0ec5f11a7a4428cf1f6281f1879220f58", size = 339639, upload-time = "2025-04-17T00:42:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/11566f1176a78f4bafb0937c0072410b1b0d3640b297944a6a7a556e1d0b/yarl-1.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b2992fe29002fd0d4cbaea9428b09af9b8686a9024c840b8a2b8f4ea4abc16f", size = 353745, upload-time = "2025-04-17T00:42:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/26/17/07dfcf034d6ae8837b33988be66045dd52f878dfb1c4e8f80a7343f677be/yarl-1.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c903e0b42aab48abfbac668b5a9d7b6938e721a6341751331bcd7553de2dcae", size = 354178, upload-time = "2025-04-17T00:42:51.588Z" }, + { url = "https://files.pythonhosted.org/packages/15/45/212604d3142d84b4065d5f8cab6582ed3d78e4cc250568ef2a36fe1cf0a5/yarl-1.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf099e2432131093cc611623e0b0bcc399b8cddd9a91eded8bfb50402ec35018", size = 349219, upload-time = "2025-04-17T00:42:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e0/a10b30f294111c5f1c682461e9459935c17d467a760c21e1f7db400ff499/yarl-1.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7f62f5dc70a6c763bec9ebf922be52aa22863d9496a9a30124d65b489ea672", size = 337266, upload-time = "2025-04-17T00:42:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/33/a6/6efa1d85a675d25a46a167f9f3e80104cde317dfdf7f53f112ae6b16a60a/yarl-1.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:54ac15a8b60382b2bcefd9a289ee26dc0920cf59b05368c9b2b72450751c6eb8", size = 360873, upload-time = "2025-04-17T00:42:57.895Z" }, + { url = "https://files.pythonhosted.org/packages/77/67/c8ab718cb98dfa2ae9ba0f97bf3cbb7d45d37f13fe1fbad25ac92940954e/yarl-1.20.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:25b3bc0763a7aca16a0f1b5e8ef0f23829df11fb539a1b70476dcab28bd83da7", size = 360524, upload-time = "2025-04-17T00:43:00.094Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e8/c3f18660cea1bc73d9f8a2b3ef423def8dadbbae6c4afabdb920b73e0ead/yarl-1.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b2586e36dc070fc8fad6270f93242124df68b379c3a251af534030a4a33ef594", size = 365370, upload-time = "2025-04-17T00:43:02.242Z" }, + { url = "https://files.pythonhosted.org/packages/c9/99/33f3b97b065e62ff2d52817155a89cfa030a1a9b43fee7843ef560ad9603/yarl-1.20.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:866349da9d8c5290cfefb7fcc47721e94de3f315433613e01b435473be63daa6", size = 373297, upload-time = "2025-04-17T00:43:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/7519e79e264a5f08653d2446b26d4724b01198a93a74d2e259291d538ab1/yarl-1.20.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:33bb660b390a0554d41f8ebec5cd4475502d84104b27e9b42f5321c5192bfcd1", size = 378771, upload-time = "2025-04-17T00:43:06.609Z" }, + { url = "https://files.pythonhosted.org/packages/3a/58/6c460bbb884abd2917c3eef6f663a4a873f8dc6f498561fc0ad92231c113/yarl-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737e9f171e5a07031cbee5e9180f6ce21a6c599b9d4b2c24d35df20a52fabf4b", size = 375000, upload-time = "2025-04-17T00:43:09.01Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/514c9bff2900c22a4f10e06297714dbaf98707143b37ff0bcba65a956221/yarl-1.20.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2137810a20b933b1b1b7e5cf06a64c3ed3b4747b0e5d79c9447c00db0e2f752f", size = 145030, upload-time = "2025-04-17T00:43:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9d/f88da3fa319b8c9c813389bfb3463e8d777c62654c7168e580a13fadff05/yarl-1.20.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:447c5eadd750db8389804030d15f43d30435ed47af1313303ed82a62388176d3", size = 96894, upload-time = "2025-04-17T00:43:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/cd/57/92e83538580a6968b2451d6c89c5579938a7309d4785748e8ad42ddafdce/yarl-1.20.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42fbe577272c203528d402eec8bf4b2d14fd49ecfec92272334270b850e9cd7d", size = 94457, upload-time = "2025-04-17T00:43:19.431Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ee/7ee43bd4cf82dddd5da97fcaddb6fa541ab81f3ed564c42f146c83ae17ce/yarl-1.20.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18e321617de4ab170226cd15006a565d0fa0d908f11f724a2c9142d6b2812ab0", size = 343070, upload-time = "2025-04-17T00:43:21.426Z" }, + { url = "https://files.pythonhosted.org/packages/4a/12/b5eccd1109e2097bcc494ba7dc5de156e41cf8309fab437ebb7c2b296ce3/yarl-1.20.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4345f58719825bba29895011e8e3b545e6e00257abb984f9f27fe923afca2501", size = 337739, upload-time = "2025-04-17T00:43:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/7d/6b/0eade8e49af9fc2585552f63c76fa59ef469c724cc05b29519b19aa3a6d5/yarl-1.20.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d9b980d7234614bc4674468ab173ed77d678349c860c3af83b1fffb6a837ddc", size = 351338, upload-time = "2025-04-17T00:43:25.695Z" }, + { url = "https://files.pythonhosted.org/packages/45/cb/aaaa75d30087b5183c7b8a07b4fb16ae0682dd149a1719b3a28f54061754/yarl-1.20.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af4baa8a445977831cbaa91a9a84cc09debb10bc8391f128da2f7bd070fc351d", size = 353636, upload-time = "2025-04-17T00:43:27.876Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/d9cb39ec68a91ba6e66fa86d97003f58570327d6713833edf7ad6ce9dde5/yarl-1.20.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:123393db7420e71d6ce40d24885a9e65eb1edefc7a5228db2d62bcab3386a5c0", size = 348061, upload-time = "2025-04-17T00:43:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/72/6b/103940aae893d0cc770b4c36ce80e2ed86fcb863d48ea80a752b8bda9303/yarl-1.20.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab47acc9332f3de1b39e9b702d9c916af7f02656b2a86a474d9db4e53ef8fd7a", size = 334150, upload-time = "2025-04-17T00:43:31.742Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/986bd82aa222c3e6b211a69c9081ba46484cffa9fab2a5235e8d18ca7a27/yarl-1.20.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4a34c52ed158f89876cba9c600b2c964dfc1ca52ba7b3ab6deb722d1d8be6df2", size = 362207, upload-time = "2025-04-17T00:43:34.099Z" }, + { url = "https://files.pythonhosted.org/packages/14/7c/63f5922437b873795d9422cbe7eb2509d4b540c37ae5548a4bb68fd2c546/yarl-1.20.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:04d8cfb12714158abf2618f792c77bc5c3d8c5f37353e79509608be4f18705c9", size = 361277, upload-time = "2025-04-17T00:43:36.202Z" }, + { url = "https://files.pythonhosted.org/packages/81/83/450938cccf732466953406570bdb42c62b5ffb0ac7ac75a1f267773ab5c8/yarl-1.20.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7dc63ad0d541c38b6ae2255aaa794434293964677d5c1ec5d0116b0e308031f5", size = 364990, upload-time = "2025-04-17T00:43:38.551Z" }, + { url = "https://files.pythonhosted.org/packages/b4/de/af47d3a47e4a833693b9ec8e87debb20f09d9fdc9139b207b09a3e6cbd5a/yarl-1.20.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d02b591a64e4e6ca18c5e3d925f11b559c763b950184a64cf47d74d7e41877", size = 374684, upload-time = "2025-04-17T00:43:40.481Z" }, + { url = "https://files.pythonhosted.org/packages/62/0b/078bcc2d539f1faffdc7d32cb29a2d7caa65f1a6f7e40795d8485db21851/yarl-1.20.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:95fc9876f917cac7f757df80a5dda9de59d423568460fe75d128c813b9af558e", size = 382599, upload-time = "2025-04-17T00:43:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/74/a9/4fdb1a7899f1fb47fd1371e7ba9e94bff73439ce87099d5dd26d285fffe0/yarl-1.20.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb769ae5760cd1c6a712135ee7915f9d43f11d9ef769cb3f75a23e398a92d384", size = 378573, upload-time = "2025-04-17T00:43:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2f/422546794196519152fc2e2f475f0e1d4d094a11995c81a465faf5673ffd/yarl-1.20.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b6c4c3d0d6a0ae9b281e492b1465c72de433b782e6b5001c8e7249e085b69051", size = 163575, upload-time = "2025-04-17T00:43:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/90/fc/67c64ddab6c0b4a169d03c637fb2d2a212b536e1989dec8e7e2c92211b7f/yarl-1.20.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8681700f4e4df891eafa4f69a439a6e7d480d64e52bf460918f58e443bd3da7d", size = 106121, upload-time = "2025-04-17T00:43:53.506Z" }, + { url = "https://files.pythonhosted.org/packages/6d/00/29366b9eba7b6f6baed7d749f12add209b987c4cfbfa418404dbadc0f97c/yarl-1.20.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:84aeb556cb06c00652dbf87c17838eb6d92cfd317799a8092cee0e570ee11229", size = 103815, upload-time = "2025-04-17T00:43:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/28/f4/a2a4c967c8323c03689383dff73396281ced3b35d0ed140580825c826af7/yarl-1.20.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f166eafa78810ddb383e930d62e623d288fb04ec566d1b4790099ae0f31485f1", size = 408231, upload-time = "2025-04-17T00:43:57.825Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a1/66f7ffc0915877d726b70cc7a896ac30b6ac5d1d2760613603b022173635/yarl-1.20.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5d3d6d14754aefc7a458261027a562f024d4f6b8a798adb472277f675857b1eb", size = 390221, upload-time = "2025-04-17T00:44:00.526Z" }, + { url = "https://files.pythonhosted.org/packages/41/15/cc248f0504610283271615e85bf38bc014224122498c2016d13a3a1b8426/yarl-1.20.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a8f64df8ed5d04c51260dbae3cc82e5649834eebea9eadfd829837b8093eb00", size = 411400, upload-time = "2025-04-17T00:44:02.853Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/f0823d7e092bfb97d24fce6c7269d67fcd1aefade97d0a8189c4452e4d5e/yarl-1.20.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d9949eaf05b4d30e93e4034a7790634bbb41b8be2d07edd26754f2e38e491de", size = 411714, upload-time = "2025-04-17T00:44:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/70/be418329eae64b9f1b20ecdaac75d53aef098797d4c2299d82ae6f8e4663/yarl-1.20.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c366b254082d21cc4f08f522ac201d0d83a8b8447ab562732931d31d80eb2a5", size = 404279, upload-time = "2025-04-17T00:44:07.721Z" }, + { url = "https://files.pythonhosted.org/packages/19/f5/52e02f0075f65b4914eb890eea1ba97e6fd91dd821cc33a623aa707b2f67/yarl-1.20.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91bc450c80a2e9685b10e34e41aef3d44ddf99b3a498717938926d05ca493f6a", size = 384044, upload-time = "2025-04-17T00:44:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/6a/36/b0fa25226b03d3f769c68d46170b3e92b00ab3853d73127273ba22474697/yarl-1.20.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c2aa4387de4bc3a5fe158080757748d16567119bef215bec643716b4fbf53f9", size = 416236, upload-time = "2025-04-17T00:44:11.734Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3a/54c828dd35f6831dfdd5a79e6c6b4302ae2c5feca24232a83cb75132b205/yarl-1.20.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d2cbca6760a541189cf87ee54ff891e1d9ea6406079c66341008f7ef6ab61145", size = 402034, upload-time = "2025-04-17T00:44:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/10/97/c7bf5fba488f7e049f9ad69c1b8fdfe3daa2e8916b3d321aa049e361a55a/yarl-1.20.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:798a5074e656f06b9fad1a162be5a32da45237ce19d07884d0b67a0aa9d5fdda", size = 407943, upload-time = "2025-04-17T00:44:16.052Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a4/022d2555c1e8fcff08ad7f0f43e4df3aba34f135bff04dd35d5526ce54ab/yarl-1.20.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f106e75c454288472dbe615accef8248c686958c2e7dd3b8d8ee2669770d020f", size = 423058, upload-time = "2025-04-17T00:44:18.547Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f6/0873a05563e5df29ccf35345a6ae0ac9e66588b41fdb7043a65848f03139/yarl-1.20.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3b60a86551669c23dc5445010534d2c5d8a4e012163218fc9114e857c0586fdd", size = 423792, upload-time = "2025-04-17T00:44:20.639Z" }, + { url = "https://files.pythonhosted.org/packages/9e/35/43fbbd082708fa42e923f314c24f8277a28483d219e049552e5007a9aaca/yarl-1.20.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e429857e341d5e8e15806118e0294f8073ba9c4580637e59ab7b238afca836f", size = 422242, upload-time = "2025-04-17T00:44:22.851Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1f/70c57b3d7278e94ed22d85e09685d3f0a38ebdd8c5c73b65ba4c0d0fe002/yarl-1.20.0-py3-none-any.whl", hash = "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124", size = 46124, upload-time = "2025-04-17T00:45:12.199Z" }, +] + [[package]] name = "zope-interface" version = "8.0.1" @@ -4526,9 +5957,11 @@ name = "zxing-cpp" version = "2.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version < '3.11' and sys_platform == 'darwin'", - "(python_full_version >= '3.11' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and platform_machine == 'x86_64' and sys_platform == 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'linux'", "python_full_version < '3.11' and sys_platform == 'linux'", ] sdist = { url = "https://files.pythonhosted.org/packages/d9/f2/b781bf6119abe665069777e3c0f154752cf924fe8a55fca027243abbc555/zxing_cpp-2.3.0.tar.gz", hash = "sha256:3babedb67a4c15c9de2c2b4c42d70af83a6c85780c1b2d9803ac64c6ae69f14e", size = 1172666, upload-time = "2025-01-01T21:54:05.856Z" }