Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
a7d7d88ae9 Bump ruff from 0.15.4 to 0.15.5 in the development group
Bumps the development group with 1 update: [ruff](https://github.com/astral-sh/ruff).


Updates `ruff` from 0.15.4 to 0.15.5
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.4...0.15.5)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 20:15:02 +00:00
19 changed files with 512 additions and 651 deletions

View File

@@ -149,16 +149,15 @@ jobs:
mkdir -p /tmp/digests mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}" digest="${{ steps.build.outputs.digest }}"
echo "digest=${digest}" echo "digest=${digest}"
echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt" touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest - name: Upload digest
if: steps.check-push.outputs.should-push == 'true' if: steps.check-push.outputs.should-push == 'true'
uses: actions/upload-artifact@v7.0.0 uses: actions/upload-artifact@v7.0.0
with: with:
name: digests-${{ matrix.arch }} name: digests-${{ matrix.arch }}
path: /tmp/digests/digest-${{ matrix.arch }}.txt path: /tmp/digests/*
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
archive: false
merge-and-push: merge-and-push:
name: Merge and Push Manifest name: Merge and Push Manifest
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -172,7 +171,7 @@ jobs:
uses: actions/download-artifact@v8.0.0 uses: actions/download-artifact@v8.0.0
with: with:
path: /tmp/digests path: /tmp/digests
pattern: digest-*.txt pattern: digests-*
merge-multiple: true merge-multiple: true
- name: List digests - name: List digests
run: | run: |
@@ -218,9 +217,8 @@ jobs:
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "${DOCKER_METADATA_OUTPUT_JSON}") tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "${DOCKER_METADATA_OUTPUT_JSON}")
digests="" digests=""
for digest_file in digest-*.txt; do for digest in *; do
digest=$(cat "${digest_file}") digests+="${{ env.REGISTRY }}/${REPOSITORY}@sha256:${digest} "
digests+="${{ env.REGISTRY }}/${REPOSITORY}@${digest} "
done done
echo "Creating manifest with tags: ${tags}" echo "Creating manifest with tags: ${tags}"

View File

@@ -2,24 +2,13 @@ name: PR Bot
on: on:
pull_request_target: pull_request_target:
types: [opened] types: [opened]
permissions:
contents: read
pull-requests: write
jobs: jobs:
anti-slop:
runs-on: ubuntu-latest
permissions:
contents: read
issues: read
pull-requests: write
steps:
- uses: peakoss/anti-slop@v0.2.1
with:
max-failures: 4
failure-add-pr-labels: 'ai'
pr-bot: pr-bot:
name: Automated PR Bot name: Automated PR Bot
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps: steps:
- name: Label PR by file path or branch name - name: Label PR by file path or branch name
# see .github/labeler.yml for the labeler config # see .github/labeler.yml for the labeler config

View File

@@ -3,6 +3,7 @@ import json
import os import os
import shutil import shutil
import tempfile import tempfile
from itertools import chain
from itertools import islice from itertools import islice
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -80,87 +81,6 @@ def serialize_queryset_batched(
yield serializers.serialize("python", chunk) yield serializers.serialize("python", chunk)
class StreamingManifestWriter:
"""Incrementally writes a JSON array to a file, one record at a time.
Writes to <target>.tmp first; on close(), optionally BLAKE2b-compares
with the existing file (--compare-json) and renames or discards accordingly.
On exception, discard() deletes the tmp file and leaves the original intact.
"""
def __init__(
self,
path: Path,
*,
compare_json: bool = False,
files_in_export_dir: "set[Path] | None" = None,
) -> None:
self._path = path.resolve()
self._tmp_path = self._path.with_suffix(self._path.suffix + ".tmp")
self._compare_json = compare_json
self._files_in_export_dir: set[Path] = (
files_in_export_dir if files_in_export_dir is not None else set()
)
self._file = None
self._first = True
def open(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._file = self._tmp_path.open("w", encoding="utf-8")
self._file.write("[")
self._first = True
def write_record(self, record: dict) -> None:
if not self._first:
self._file.write(",\n")
else:
self._first = False
self._file.write(
json.dumps(record, cls=DjangoJSONEncoder, indent=2, ensure_ascii=False),
)
def write_batch(self, records: list[dict]) -> None:
for record in records:
self.write_record(record)
def close(self) -> None:
if self._file is None:
return
self._file.write("\n]")
self._file.close()
self._file = None
self._finalize()
def discard(self) -> None:
if self._file is not None:
self._file.close()
self._file = None
if self._tmp_path.exists():
self._tmp_path.unlink()
def _finalize(self) -> None:
"""Compare with existing file (if --compare-json) then rename or discard tmp."""
if self._path in self._files_in_export_dir:
self._files_in_export_dir.remove(self._path)
if self._compare_json:
existing_hash = hashlib.blake2b(self._path.read_bytes()).hexdigest()
new_hash = hashlib.blake2b(self._tmp_path.read_bytes()).hexdigest()
if existing_hash == new_hash:
self._tmp_path.unlink()
return
self._tmp_path.rename(self._path)
def __enter__(self) -> "StreamingManifestWriter":
self.open()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
if exc_type is not None:
self.discard()
else:
self.close()
class Command(CryptMixin, BaseCommand): class Command(CryptMixin, BaseCommand):
help = ( help = (
"Decrypt and rename all files in our collection into a given target " "Decrypt and rename all files in our collection into a given target "
@@ -402,83 +322,95 @@ class Command(CryptMixin, BaseCommand):
if settings.AUDIT_LOG_ENABLED: if settings.AUDIT_LOG_ENABLED:
manifest_key_to_object_query["log_entries"] = LogEntry.objects.all() manifest_key_to_object_query["log_entries"] = LogEntry.objects.all()
# Crypto setup before streaming begins with transaction.atomic():
if self.passphrase: manifest_dict = {}
self.setup_crypto(passphrase=self.passphrase)
elif MailAccount.objects.count() > 0 or SocialToken.objects.count() > 0:
self.stdout.write(
self.style.NOTICE(
"No passphrase was given, sensitive fields will be in plaintext",
),
)
document_manifest: list[dict] = [] # Build an overall manifest
manifest_path = (self.target / "manifest.json").resolve() for key, object_query in manifest_key_to_object_query.items():
manifest_dict[key] = list(
with StreamingManifestWriter( chain.from_iterable(
manifest_path, serialize_queryset_batched(
compare_json=self.compare_json, object_query,
files_in_export_dir=self.files_in_export_dir,
) as writer:
with transaction.atomic():
for key, qs in manifest_key_to_object_query.items():
if key == "documents":
# Accumulate for file-copy loop; written to manifest after
for batch in serialize_queryset_batched(
qs,
batch_size=self.batch_size, batch_size=self.batch_size,
): ),
for record in batch: ),
self._encrypt_record_inline(record)
document_manifest.extend(batch)
elif self.split_manifest and key in (
"notes",
"custom_field_instances",
):
# Written per-document in _write_split_manifest
pass
else:
for batch in serialize_queryset_batched(
qs,
batch_size=self.batch_size,
):
for record in batch:
self._encrypt_record_inline(record)
writer.write_batch(batch)
document_map: dict[int, Document] = {
d.pk: d for d in Document.objects.order_by("id")
}
# 3. Export files from each document
for document_dict in tqdm.tqdm(
document_manifest,
total=len(document_manifest),
disable=self.no_progress_bar,
):
document = document_map[document_dict["pk"]]
# 3.1. generate a unique filename
base_name = self.generate_base_name(document)
# 3.2. write filenames into manifest
original_target, thumbnail_target, archive_target = (
self.generate_document_targets(document, base_name, document_dict)
) )
# 3.3. write files to target folder self.encrypt_secret_fields(manifest_dict)
if not self.data_only:
self.copy_document_files(
document,
original_target,
thumbnail_target,
archive_target,
)
if self.split_manifest: # These are treated specially and included in the per-document manifest
self._write_split_manifest(document_dict, document, base_name) # if that setting is enabled. Otherwise, they are just exported to the bulk
else: # manifest
writer.write_record(document_dict) document_map: dict[int, Document] = {
d.pk: d for d in manifest_key_to_object_query["documents"]
}
document_manifest = manifest_dict["documents"]
# 3. Export files from each document
for index, document_dict in tqdm.tqdm(
enumerate(document_manifest),
total=len(document_manifest),
disable=self.no_progress_bar,
):
document = document_map[document_dict["pk"]]
# 3.1. generate a unique filename
base_name = self.generate_base_name(document)
# 3.2. write filenames into manifest
original_target, thumbnail_target, archive_target = (
self.generate_document_targets(document, base_name, document_dict)
)
# 3.3. write files to target folder
if not self.data_only:
self.copy_document_files(
document,
original_target,
thumbnail_target,
archive_target,
)
if self.split_manifest:
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
if self.use_folder_prefix:
manifest_name = Path("json") / manifest_name
manifest_name = (self.target / manifest_name).resolve()
manifest_name.parent.mkdir(parents=True, exist_ok=True)
content = [document_manifest[index]]
content += list(
filter(
lambda d: d["fields"]["document"] == document_dict["pk"],
manifest_dict["notes"],
),
)
content += list(
filter(
lambda d: d["fields"]["document"] == document_dict["pk"],
manifest_dict["custom_field_instances"],
),
)
self.check_and_write_json(
content,
manifest_name,
)
# These were exported already
if self.split_manifest:
del manifest_dict["documents"]
del manifest_dict["notes"]
del manifest_dict["custom_field_instances"]
# 4.1 write primary manifest to target folder
manifest = []
for key, item in manifest_dict.items():
manifest.extend(item)
manifest_path = (self.target / "manifest.json").resolve()
self.check_and_write_json(
manifest,
manifest_path,
)
# 4.2 write version information to target folder # 4.2 write version information to target folder
extra_metadata_path = (self.target / "metadata.json").resolve() extra_metadata_path = (self.target / "metadata.json").resolve()
@@ -600,42 +532,6 @@ class Command(CryptMixin, BaseCommand):
archive_target, archive_target,
) )
def _encrypt_record_inline(self, record: dict) -> None:
"""Encrypt sensitive fields in a single record, if passphrase is set."""
if not self.passphrase:
return
fields = self.CRYPT_FIELDS_BY_MODEL.get(record.get("model", ""))
if fields:
for field in fields:
if record["fields"].get(field):
record["fields"][field] = self.encrypt_string(
value=record["fields"][field],
)
def _write_split_manifest(
self,
document_dict: dict,
document: Document,
base_name: Path,
) -> None:
"""Write per-document manifest file for --split-manifest mode."""
content = [document_dict]
content.extend(
serializers.serialize("python", Note.objects.filter(document=document)),
)
content.extend(
serializers.serialize(
"python",
CustomFieldInstance.objects.filter(document=document),
),
)
manifest_name = base_name.with_name(f"{base_name.stem}-manifest.json")
if self.use_folder_prefix:
manifest_name = Path("json") / manifest_name
manifest_name = (self.target / manifest_name).resolve()
manifest_name.parent.mkdir(parents=True, exist_ok=True)
self.check_and_write_json(content, manifest_name)
def check_and_write_json( def check_and_write_json(
self, self,
content: list[dict] | dict, content: list[dict] | dict,
@@ -653,14 +549,14 @@ class Command(CryptMixin, BaseCommand):
if target in self.files_in_export_dir: if target in self.files_in_export_dir:
self.files_in_export_dir.remove(target) self.files_in_export_dir.remove(target)
if self.compare_json: if self.compare_json:
target_checksum = hashlib.blake2b(target.read_bytes()).hexdigest() target_checksum = hashlib.md5(target.read_bytes()).hexdigest()
src_str = json.dumps( src_str = json.dumps(
content, content,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
indent=2, indent=2,
ensure_ascii=False, ensure_ascii=False,
) )
src_checksum = hashlib.blake2b(src_str.encode("utf-8")).hexdigest() src_checksum = hashlib.md5(src_str.encode("utf-8")).hexdigest()
if src_checksum == target_checksum: if src_checksum == target_checksum:
perform_write = False perform_write = False
@@ -710,3 +606,28 @@ class Command(CryptMixin, BaseCommand):
if perform_copy: if perform_copy:
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
copy_file_with_basic_stats(source, target) copy_file_with_basic_stats(source, target)
def encrypt_secret_fields(self, manifest: dict) -> None:
"""
Encrypts certain fields in the export. Currently limited to the mail account password
"""
if self.passphrase:
self.setup_crypto(passphrase=self.passphrase)
for crypt_config in self.CRYPT_FIELDS:
exporter_key = crypt_config["exporter_key"]
crypt_fields = crypt_config["fields"]
for manifest_record in manifest[exporter_key]:
for field in crypt_fields:
if manifest_record["fields"][field]:
manifest_record["fields"][field] = self.encrypt_string(
value=manifest_record["fields"][field],
)
elif MailAccount.objects.count() > 0 or SocialToken.objects.count() > 0:
self.stdout.write(
self.style.NOTICE(
"No passphrase was given, sensitive fields will be in plaintext",
),
)

View File

@@ -71,7 +71,7 @@ class CryptMixin:
key_size = 32 key_size = 32
kdf_algorithm = "pbkdf2_sha256" kdf_algorithm = "pbkdf2_sha256"
CRYPT_FIELDS: list[CryptFields] = [ CRYPT_FIELDS: CryptFields = [
{ {
"exporter_key": "mail_accounts", "exporter_key": "mail_accounts",
"model_name": "paperless_mail.mailaccount", "model_name": "paperless_mail.mailaccount",
@@ -89,10 +89,6 @@ class CryptMixin:
], ],
}, },
] ]
# O(1) lookup for per-record encryption; derived from CRYPT_FIELDS at class definition time
CRYPT_FIELDS_BY_MODEL: dict[str, list[str]] = {
cfg["model_name"]: cfg["fields"] for cfg in CRYPT_FIELDS
}
def get_crypt_params(self) -> dict[str, dict[str, str | int]]: def get_crypt_params(self) -> dict[str, dict[str, str | int]]:
return { return {

View File

@@ -753,31 +753,6 @@ class TestExportImport(
call_command("document_importer", "--no-progress-bar", self.target) call_command("document_importer", "--no-progress-bar", self.target)
self.assertEqual(Document.objects.count(), 4) self.assertEqual(Document.objects.count(), 4)
def test_folder_prefix_with_split(self) -> None:
"""
GIVEN:
- Request to export documents to directory
WHEN:
- Option use_folder_prefix is used
- Option split manifest is used
THEN:
- Documents can be imported again
"""
shutil.rmtree(Path(self.dirs.media_dir) / "documents")
shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
self._do_export(use_folder_prefix=True, split_manifest=True)
with paperless_environment():
self.assertEqual(Document.objects.count(), 4)
Document.objects.all().delete()
self.assertEqual(Document.objects.count(), 0)
call_command("document_importer", "--no-progress-bar", self.target)
self.assertEqual(Document.objects.count(), 4)
def test_import_db_transaction_failed(self) -> None: def test_import_db_transaction_failed(self) -> None:
""" """
GIVEN: GIVEN:

View File

@@ -1,100 +1,107 @@
import logging from unittest import mock
import pytest
from allauth.account.adapter import get_adapter from allauth.account.adapter import get_adapter
from allauth.core import context from allauth.core import context
from allauth.socialaccount.adapter import get_adapter as get_social_adapter from allauth.socialaccount.adapter import get_adapter as get_social_adapter
from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.forms import ValidationError from django.forms import ValidationError
from django.http import HttpRequest from django.http import HttpRequest
from django.test import TestCase
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from pytest_django.fixtures import SettingsWrapper
from pytest_mock import MockerFixture
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from paperless.adapter import DrfTokenStrategy from paperless.adapter import DrfTokenStrategy
@pytest.mark.django_db class TestCustomAccountAdapter(TestCase):
class TestCustomAccountAdapter: def test_is_open_for_signup(self) -> None:
def test_is_open_for_signup(self, settings: SettingsWrapper) -> None:
adapter = get_adapter() adapter = get_adapter()
# With no accounts, signups should be allowed # With no accounts, signups should be allowed
assert adapter.is_open_for_signup(None) self.assertTrue(adapter.is_open_for_signup(None))
User.objects.create_user("testuser") User.objects.create_user("testuser")
# Test when ACCOUNT_ALLOW_SIGNUPS is True
settings.ACCOUNT_ALLOW_SIGNUPS = True settings.ACCOUNT_ALLOW_SIGNUPS = True
assert adapter.is_open_for_signup(None) self.assertTrue(adapter.is_open_for_signup(None))
# Test when ACCOUNT_ALLOW_SIGNUPS is False
settings.ACCOUNT_ALLOW_SIGNUPS = False settings.ACCOUNT_ALLOW_SIGNUPS = False
assert not adapter.is_open_for_signup(None) self.assertFalse(adapter.is_open_for_signup(None))
def test_is_safe_url(self, settings: SettingsWrapper) -> None: def test_is_safe_url(self) -> None:
request = HttpRequest() request = HttpRequest()
request.get_host = lambda: "example.com" request.get_host = mock.Mock(return_value="example.com")
with context.request_context(request): with context.request_context(request):
adapter = get_adapter() adapter = get_adapter()
with override_settings(ALLOWED_HOSTS=["*"]):
# True because request host is same
url = "https://example.com"
self.assertTrue(adapter.is_safe_url(url))
settings.ALLOWED_HOSTS = ["*"] url = "https://evil.com"
# True because request host is same
assert adapter.is_safe_url("https://example.com")
# False despite wildcard because request host is different # False despite wildcard because request host is different
assert not adapter.is_safe_url("https://evil.com") self.assertFalse(adapter.is_safe_url(url))
settings.ALLOWED_HOSTS = ["example.com"] settings.ALLOWED_HOSTS = ["example.com"]
url = "https://example.com"
# True because request host is same # True because request host is same
assert adapter.is_safe_url("https://example.com") self.assertTrue(adapter.is_safe_url(url))
settings.ALLOWED_HOSTS = ["*", "example.com"] settings.ALLOWED_HOSTS = ["*", "example.com"]
url = "//evil.com"
# False because request host is not in allowed hosts # False because request host is not in allowed hosts
assert not adapter.is_safe_url("//evil.com") self.assertFalse(adapter.is_safe_url(url))
def test_pre_authenticate( @mock.patch("allauth.core.internal.ratelimit.consume", return_value=True)
self, def test_pre_authenticate(self, mock_consume) -> None:
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
mocker.patch("allauth.core.internal.ratelimit.consume", return_value=True)
adapter = get_adapter() adapter = get_adapter()
request = HttpRequest() request = HttpRequest()
request.get_host = lambda: "example.com" request.get_host = mock.Mock(return_value="example.com")
settings.DISABLE_REGULAR_LOGIN = False settings.DISABLE_REGULAR_LOGIN = False
adapter.pre_authenticate(request) adapter.pre_authenticate(request)
settings.DISABLE_REGULAR_LOGIN = True settings.DISABLE_REGULAR_LOGIN = True
with pytest.raises(ValidationError): with self.assertRaises(ValidationError):
adapter.pre_authenticate(request) adapter.pre_authenticate(request)
def test_get_reset_password_from_key_url(self, settings: SettingsWrapper) -> None: def test_get_reset_password_from_key_url(self) -> None:
request = HttpRequest() request = HttpRequest()
request.get_host = lambda: "foo.org" request.get_host = mock.Mock(return_value="foo.org")
with context.request_context(request): with context.request_context(request):
adapter = get_adapter() adapter = get_adapter()
settings.PAPERLESS_URL = None # Test when PAPERLESS_URL is None
settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" with override_settings(
expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}" PAPERLESS_URL=None,
assert adapter.get_reset_password_from_key_url("UID-KEY") == expected_url ACCOUNT_DEFAULT_HTTP_PROTOCOL="https",
):
expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
self.assertEqual(
adapter.get_reset_password_from_key_url("UID-KEY"),
expected_url,
)
settings.PAPERLESS_URL = "https://bar.com" # Test when PAPERLESS_URL is not None
expected_url = f"https://bar.com{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}" with override_settings(PAPERLESS_URL="https://bar.com"):
assert adapter.get_reset_password_from_key_url("UID-KEY") == expected_url expected_url = f"https://bar.com{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
self.assertEqual(
adapter.get_reset_password_from_key_url("UID-KEY"),
expected_url,
)
def test_save_user_adds_groups( @override_settings(ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
self, def test_save_user_adds_groups(self) -> None:
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
settings.ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
Group.objects.create(name="group1") Group.objects.create(name="group1")
user = User.objects.create_user("testuser") user = User.objects.create_user("testuser")
adapter = get_adapter() adapter = get_adapter()
form = mocker.MagicMock( form = mock.Mock(
cleaned_data={ cleaned_data={
"username": "testuser", "username": "testuser",
"email": "user@example.com", "email": "user@example.com",
@@ -103,81 +110,88 @@ class TestCustomAccountAdapter:
user = adapter.save_user(HttpRequest(), user, form, commit=True) user = adapter.save_user(HttpRequest(), user, form, commit=True)
assert user.groups.count() == 1 self.assertEqual(user.groups.count(), 1)
assert user.groups.filter(name="group1").exists() self.assertTrue(user.groups.filter(name="group1").exists())
assert not user.groups.filter(name="group2").exists() self.assertFalse(user.groups.filter(name="group2").exists())
def test_fresh_install_save_creates_superuser(self, mocker: MockerFixture) -> None: def test_fresh_install_save_creates_superuser(self) -> None:
adapter = get_adapter() adapter = get_adapter()
form = mocker.MagicMock( form = mock.Mock(
cleaned_data={ cleaned_data={
"username": "testuser", "username": "testuser",
"email": "user@paperless-ngx.com", "email": "user@paperless-ngx.com",
}, },
) )
user = adapter.save_user(HttpRequest(), User(), form, commit=True) user = adapter.save_user(HttpRequest(), User(), form, commit=True)
assert user.is_superuser self.assertTrue(user.is_superuser)
form = mocker.MagicMock( # Next time, it should not create a superuser
form = mock.Mock(
cleaned_data={ cleaned_data={
"username": "testuser2", "username": "testuser2",
"email": "user2@paperless-ngx.com", "email": "user2@paperless-ngx.com",
}, },
) )
user2 = adapter.save_user(HttpRequest(), User(), form, commit=True) user2 = adapter.save_user(HttpRequest(), User(), form, commit=True)
assert not user2.is_superuser self.assertFalse(user2.is_superuser)
class TestCustomSocialAccountAdapter: class TestCustomSocialAccountAdapter(TestCase):
@pytest.mark.django_db def test_is_open_for_signup(self) -> None:
def test_is_open_for_signup(self, settings: SettingsWrapper) -> None:
adapter = get_social_adapter() adapter = get_social_adapter()
# Test when SOCIALACCOUNT_ALLOW_SIGNUPS is True
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True
assert adapter.is_open_for_signup(None, None) self.assertTrue(adapter.is_open_for_signup(None, None))
# Test when SOCIALACCOUNT_ALLOW_SIGNUPS is False
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = False settings.SOCIALACCOUNT_ALLOW_SIGNUPS = False
assert not adapter.is_open_for_signup(None, None) self.assertFalse(adapter.is_open_for_signup(None, None))
def test_get_connect_redirect_url(self) -> None: def test_get_connect_redirect_url(self) -> None:
adapter = get_social_adapter() adapter = get_social_adapter()
assert adapter.get_connect_redirect_url(None, None) == reverse("base") request = None
socialaccount = None
@pytest.mark.django_db # Test the default URL
def test_save_user_adds_groups( expected_url = reverse("base")
self, self.assertEqual(
settings: SettingsWrapper, adapter.get_connect_redirect_url(request, socialaccount),
mocker: MockerFixture, expected_url,
) -> None: )
settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
@override_settings(SOCIAL_ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
def test_save_user_adds_groups(self) -> None:
Group.objects.create(name="group1") Group.objects.create(name="group1")
adapter = get_social_adapter() adapter = get_social_adapter()
request = HttpRequest()
user = User.objects.create_user("testuser") user = User.objects.create_user("testuser")
sociallogin = mocker.MagicMock(user=user) sociallogin = mock.Mock(
user=user,
)
user = adapter.save_user(HttpRequest(), sociallogin, None) user = adapter.save_user(request, sociallogin, None)
assert user.groups.count() == 1 self.assertEqual(user.groups.count(), 1)
assert user.groups.filter(name="group1").exists() self.assertTrue(user.groups.filter(name="group1").exists())
assert not user.groups.filter(name="group2").exists() self.assertFalse(user.groups.filter(name="group2").exists())
def test_error_logged_on_authentication_error( def test_error_logged_on_authentication_error(self) -> None:
self,
caplog: pytest.LogCaptureFixture,
) -> None:
adapter = get_social_adapter() adapter = get_social_adapter()
with caplog.at_level(logging.INFO, logger="paperless.auth"): request = HttpRequest()
with self.assertLogs("paperless.auth", level="INFO") as log_cm:
adapter.on_authentication_error( adapter.on_authentication_error(
HttpRequest(), request,
provider="test-provider", provider="test-provider",
error="Error", error="Error",
exception="Test authentication error", exception="Test authentication error",
) )
assert any("Test authentication error" in msg for msg in caplog.messages) self.assertTrue(
any("Test authentication error" in message for message in log_cm.output),
)
@pytest.mark.django_db class TestDrfTokenStrategy(TestCase):
class TestDrfTokenStrategy:
def test_create_access_token_creates_new_token(self) -> None: def test_create_access_token_creates_new_token(self) -> None:
""" """
GIVEN: GIVEN:
@@ -187,6 +201,7 @@ class TestDrfTokenStrategy:
THEN: THEN:
- A new token is created and its key is returned - A new token is created and its key is returned
""" """
user = User.objects.create_user("testuser") user = User.objects.create_user("testuser")
request = HttpRequest() request = HttpRequest()
request.user = user request.user = user
@@ -194,9 +209,13 @@ class TestDrfTokenStrategy:
strategy = DrfTokenStrategy() strategy = DrfTokenStrategy()
token_key = strategy.create_access_token(request) token_key = strategy.create_access_token(request)
assert token_key is not None # Verify a token was created
assert Token.objects.filter(user=user).exists() self.assertIsNotNone(token_key)
assert token_key == Token.objects.get(user=user).key self.assertTrue(Token.objects.filter(user=user).exists())
# Verify the returned key matches the created token
token = Token.objects.get(user=user)
self.assertEqual(token_key, token.key)
def test_create_access_token_returns_existing_token(self) -> None: def test_create_access_token_returns_existing_token(self) -> None:
""" """
@@ -207,6 +226,7 @@ class TestDrfTokenStrategy:
THEN: THEN:
- The same token key is returned (no new token created) - The same token key is returned (no new token created)
""" """
user = User.objects.create_user("testuser") user = User.objects.create_user("testuser")
existing_token = Token.objects.create(user=user) existing_token = Token.objects.create(user=user)
@@ -216,8 +236,11 @@ class TestDrfTokenStrategy:
strategy = DrfTokenStrategy() strategy = DrfTokenStrategy()
token_key = strategy.create_access_token(request) token_key = strategy.create_access_token(request)
assert token_key == existing_token.key # Verify the existing token key is returned
assert Token.objects.filter(user=user).count() == 1 self.assertEqual(token_key, existing_token.key)
# Verify only one token exists (no duplicate created)
self.assertEqual(Token.objects.filter(user=user).count(), 1)
def test_create_access_token_returns_none_for_unauthenticated_user(self) -> None: def test_create_access_token_returns_none_for_unauthenticated_user(self) -> None:
""" """
@@ -228,11 +251,12 @@ class TestDrfTokenStrategy:
THEN: THEN:
- None is returned and no token is created - None is returned and no token is created
""" """
request = HttpRequest() request = HttpRequest()
request.user = AnonymousUser() request.user = AnonymousUser()
strategy = DrfTokenStrategy() strategy = DrfTokenStrategy()
token_key = strategy.create_access_token(request) token_key = strategy.create_access_token(request)
assert token_key is None self.assertIsNone(token_key)
assert Token.objects.count() == 0 self.assertEqual(Token.objects.count(), 0)

View File

@@ -1,15 +1,16 @@
import os import os
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
import pytest import pytest
from django.core.checks import Error from django.core.checks import Error
from django.core.checks import Warning from django.core.checks import Warning
from pytest_django.fixtures import SettingsWrapper from django.test import TestCase
from django.test import override_settings
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import FileSystemAssertsMixin
from paperless.checks import audit_log_check from paperless.checks import audit_log_check
from paperless.checks import binaries_check from paperless.checks import binaries_check
from paperless.checks import check_deprecated_db_settings from paperless.checks import check_deprecated_db_settings
@@ -19,84 +20,54 @@ from paperless.checks import paths_check
from paperless.checks import settings_values_check from paperless.checks import settings_values_check
@dataclass(frozen=True, slots=True) class TestChecks(DirectoriesMixin, TestCase):
class PaperlessTestDirs:
data_dir: Path
media_dir: Path
consumption_dir: Path
# TODO: consolidate with documents/tests/conftest.py PaperlessDirs/paperless_dirs
# once the paperless and documents test suites are ready to share fixtures.
@pytest.fixture()
def directories(tmp_path: Path, settings: SettingsWrapper) -> PaperlessTestDirs:
data_dir = tmp_path / "data"
media_dir = tmp_path / "media"
consumption_dir = tmp_path / "consumption"
for d in (data_dir, media_dir, consumption_dir):
d.mkdir()
settings.DATA_DIR = data_dir
settings.MEDIA_ROOT = media_dir
settings.CONSUMPTION_DIR = consumption_dir
return PaperlessTestDirs(
data_dir=data_dir,
media_dir=media_dir,
consumption_dir=consumption_dir,
)
class TestChecks:
def test_binaries(self) -> None: def test_binaries(self) -> None:
assert binaries_check(None) == [] self.assertEqual(binaries_check(None), [])
def test_binaries_fail(self, settings: SettingsWrapper) -> None: @override_settings(CONVERT_BINARY="uuuhh")
settings.CONVERT_BINARY = "uuuhh" def test_binaries_fail(self) -> None:
assert len(binaries_check(None)) == 1 self.assertEqual(len(binaries_check(None)), 1)
@pytest.mark.usefixtures("directories")
def test_paths_check(self) -> None: def test_paths_check(self) -> None:
assert paths_check(None) == [] self.assertEqual(paths_check(None), [])
def test_paths_check_dont_exist(self, settings: SettingsWrapper) -> None: @override_settings(
settings.MEDIA_ROOT = Path("uuh") MEDIA_ROOT=Path("uuh"),
settings.DATA_DIR = Path("whatever") DATA_DIR=Path("whatever"),
settings.CONSUMPTION_DIR = Path("idontcare") CONSUMPTION_DIR=Path("idontcare"),
)
def test_paths_check_dont_exist(self) -> None:
msgs = paths_check(None)
self.assertEqual(len(msgs), 3, str(msgs))
for msg in msgs:
self.assertTrue(msg.msg.endswith("is set but doesn't exist."))
def test_paths_check_no_access(self) -> None:
Path(self.dirs.data_dir).chmod(0o000)
Path(self.dirs.media_dir).chmod(0o000)
Path(self.dirs.consumption_dir).chmod(0o000)
self.addCleanup(os.chmod, self.dirs.data_dir, 0o777)
self.addCleanup(os.chmod, self.dirs.media_dir, 0o777)
self.addCleanup(os.chmod, self.dirs.consumption_dir, 0o777)
msgs = paths_check(None) msgs = paths_check(None)
self.assertEqual(len(msgs), 3)
assert len(msgs) == 3, str(msgs)
for msg in msgs: for msg in msgs:
assert msg.msg.endswith("is set but doesn't exist.") self.assertTrue(msg.msg.endswith("is not writeable"))
def test_paths_check_no_access(self, directories: PaperlessTestDirs) -> None: @override_settings(DEBUG=False)
directories.data_dir.chmod(0o000) def test_debug_disabled(self) -> None:
directories.media_dir.chmod(0o000) self.assertEqual(debug_mode_check(None), [])
directories.consumption_dir.chmod(0o000)
try: @override_settings(DEBUG=True)
msgs = paths_check(None) def test_debug_enabled(self) -> None:
finally: self.assertEqual(len(debug_mode_check(None)), 1)
directories.data_dir.chmod(0o777)
directories.media_dir.chmod(0o777)
directories.consumption_dir.chmod(0o777)
assert len(msgs) == 3
for msg in msgs:
assert msg.msg.endswith("is not writeable")
def test_debug_disabled(self, settings: SettingsWrapper) -> None:
settings.DEBUG = False
assert debug_mode_check(None) == []
def test_debug_enabled(self, settings: SettingsWrapper) -> None:
settings.DEBUG = True
assert len(debug_mode_check(None)) == 1
class TestSettingsChecksAgainstDefaults: class TestSettingsChecksAgainstDefaults(DirectoriesMixin, TestCase):
def test_all_valid(self) -> None: def test_all_valid(self) -> None:
""" """
GIVEN: GIVEN:
@@ -107,71 +78,104 @@ class TestSettingsChecksAgainstDefaults:
- No system check errors reported - No system check errors reported
""" """
msgs = settings_values_check(None) msgs = settings_values_check(None)
assert len(msgs) == 0 self.assertEqual(len(msgs), 0)
class TestOcrSettingsChecks: class TestOcrSettingsChecks(DirectoriesMixin, TestCase):
@pytest.mark.parametrize( @override_settings(OCR_OUTPUT_TYPE="notapdf")
("setting", "value", "expected_msg"), def test_invalid_output_type(self) -> None:
[
pytest.param(
"OCR_OUTPUT_TYPE",
"notapdf",
'OCR output type "notapdf"',
id="invalid-output-type",
),
pytest.param(
"OCR_MODE",
"makeitso",
'OCR output mode "makeitso"',
id="invalid-mode",
),
pytest.param(
"OCR_MODE",
"skip_noarchive",
"deprecated",
id="deprecated-mode",
),
pytest.param(
"OCR_SKIP_ARCHIVE_FILE",
"invalid",
'OCR_SKIP_ARCHIVE_FILE setting "invalid"',
id="invalid-skip-archive-file",
),
pytest.param(
"OCR_CLEAN",
"cleanme",
'OCR clean mode "cleanme"',
id="invalid-clean",
),
],
)
def test_invalid_setting_produces_one_error(
self,
settings: SettingsWrapper,
setting: str,
value: str,
expected_msg: str,
) -> None:
""" """
GIVEN: GIVEN:
- Default settings - Default settings
- One OCR setting is set to an invalid value - OCR output type is invalid
WHEN: WHEN:
- Settings are validated - Settings are validated
THEN: THEN:
- Exactly one system check error is reported containing the expected message - system check error reported for OCR output type
""" """
setattr(settings, setting, value)
msgs = settings_values_check(None) msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
assert len(msgs) == 1 msg = msgs[0]
assert expected_msg in msgs[0].msg
self.assertIn('OCR output type "notapdf"', msg.msg)
@override_settings(OCR_MODE="makeitso")
def test_invalid_ocr_type(self) -> None:
"""
GIVEN:
- Default settings
- OCR type is invalid
WHEN:
- Settings are validated
THEN:
- system check error reported for OCR type
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn('OCR output mode "makeitso"', msg.msg)
@override_settings(OCR_MODE="skip_noarchive")
def test_deprecated_ocr_type(self) -> None:
"""
GIVEN:
- Default settings
- OCR type is deprecated
WHEN:
- Settings are validated
THEN:
- deprecation warning reported for OCR type
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn("deprecated", msg.msg)
@override_settings(OCR_SKIP_ARCHIVE_FILE="invalid")
def test_invalid_ocr_skip_archive_file(self) -> None:
"""
GIVEN:
- Default settings
- OCR_SKIP_ARCHIVE_FILE is invalid
WHEN:
- Settings are validated
THEN:
- system check error reported for OCR_SKIP_ARCHIVE_FILE
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn('OCR_SKIP_ARCHIVE_FILE setting "invalid"', msg.msg)
@override_settings(OCR_CLEAN="cleanme")
def test_invalid_ocr_clean(self) -> None:
"""
GIVEN:
- Default settings
- OCR cleaning type is invalid
WHEN:
- Settings are validated
THEN:
- system check error reported for OCR cleaning type
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn('OCR clean mode "cleanme"', msg.msg)
class TestTimezoneSettingsChecks: class TestTimezoneSettingsChecks(DirectoriesMixin, TestCase):
def test_invalid_timezone(self, settings: SettingsWrapper) -> None: @override_settings(TIME_ZONE="TheMoon\\MyCrater")
def test_invalid_timezone(self) -> None:
""" """
GIVEN: GIVEN:
- Default settings - Default settings
@@ -181,16 +185,17 @@ class TestTimezoneSettingsChecks:
THEN: THEN:
- system check error reported for timezone - system check error reported for timezone
""" """
settings.TIME_ZONE = "TheMoon\\MyCrater"
msgs = settings_values_check(None) msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
assert len(msgs) == 1 msg = msgs[0]
assert 'Timezone "TheMoon\\MyCrater"' in msgs[0].msg
self.assertIn('Timezone "TheMoon\\MyCrater"', msg.msg)
class TestEmailCertSettingsChecks: class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
def test_not_valid_file(self, settings: SettingsWrapper) -> None: @override_settings(EMAIL_CERTIFICATE_FILE=Path("/tmp/not_actually_here.pem"))
def test_not_valid_file(self) -> None:
""" """
GIVEN: GIVEN:
- Default settings - Default settings
@@ -200,22 +205,19 @@ class TestEmailCertSettingsChecks:
THEN: THEN:
- system check error reported for email certificate - system check error reported for email certificate
""" """
cert_path = Path("/tmp/not_actually_here.pem") self.assertIsNotFile("/tmp/not_actually_here.pem")
assert not cert_path.is_file()
settings.EMAIL_CERTIFICATE_FILE = cert_path
msgs = settings_values_check(None) msgs = settings_values_check(None)
assert len(msgs) == 1 self.assertEqual(len(msgs), 1)
assert "Email cert /tmp/not_actually_here.pem is not a file" in msgs[0].msg
msg = msgs[0]
self.assertIn("Email cert /tmp/not_actually_here.pem is not a file", msg.msg)
class TestAuditLogChecks: class TestAuditLogChecks(TestCase):
def test_was_enabled_once( def test_was_enabled_once(self) -> None:
self,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
""" """
GIVEN: GIVEN:
- Audit log is not enabled - Audit log is not enabled
@@ -224,18 +226,23 @@ class TestAuditLogChecks:
THEN: THEN:
- system check error reported for disabling audit log - system check error reported for disabling audit log
""" """
settings.AUDIT_LOG_ENABLED = False introspect_mock = mock.MagicMock()
introspect_mock = mocker.MagicMock()
introspect_mock.introspection.table_names.return_value = ["auditlog_logentry"] introspect_mock.introspection.table_names.return_value = ["auditlog_logentry"]
mocker.patch.dict( with override_settings(AUDIT_LOG_ENABLED=False):
"paperless.checks.connections", with mock.patch.dict(
{"default": introspect_mock}, "paperless.checks.connections",
) {"default": introspect_mock},
):
msgs = audit_log_check(None)
msgs = audit_log_check(None) self.assertEqual(len(msgs), 1)
assert len(msgs) == 1 msg = msgs[0]
assert "auditlog table was found but audit log is disabled." in msgs[0].msg
self.assertIn(
("auditlog table was found but audit log is disabled."),
msg.msg,
)
DEPRECATED_VARS: dict[str, str] = { DEPRECATED_VARS: dict[str, str] = {
@@ -264,16 +271,20 @@ class TestDeprecatedDbSettings:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("env_var", "db_option_key"), ("env_var", "db_option_key"),
[ [
pytest.param("PAPERLESS_DB_TIMEOUT", "timeout", id="db-timeout"), ("PAPERLESS_DB_TIMEOUT", "timeout"),
pytest.param( ("PAPERLESS_DB_POOLSIZE", "pool.min_size / pool.max_size"),
"PAPERLESS_DB_POOLSIZE", ("PAPERLESS_DBSSLMODE", "sslmode"),
"pool.min_size / pool.max_size", ("PAPERLESS_DBSSLROOTCERT", "sslrootcert"),
id="db-poolsize", ("PAPERLESS_DBSSLCERT", "sslcert"),
), ("PAPERLESS_DBSSLKEY", "sslkey"),
pytest.param("PAPERLESS_DBSSLMODE", "sslmode", id="ssl-mode"), ],
pytest.param("PAPERLESS_DBSSLROOTCERT", "sslrootcert", id="ssl-rootcert"), ids=[
pytest.param("PAPERLESS_DBSSLCERT", "sslcert", id="ssl-cert"), "db-timeout",
pytest.param("PAPERLESS_DBSSLKEY", "sslkey", id="ssl-key"), "db-poolsize",
"ssl-mode",
"ssl-rootcert",
"ssl-cert",
"ssl-key",
], ],
) )
def test_single_deprecated_var_produces_one_warning( def test_single_deprecated_var_produces_one_warning(
@@ -392,10 +403,7 @@ class TestV3MinimumUpgradeVersionCheck:
"""Test suite for check_v3_minimum_upgrade_version system check.""" """Test suite for check_v3_minimum_upgrade_version system check."""
@pytest.fixture @pytest.fixture
def build_conn_mock( def build_conn_mock(self, mocker: MockerFixture):
self,
mocker: MockerFixture,
) -> Callable[[list[str], list[str]], mock.MagicMock]:
"""Factory fixture that builds a connections['default'] mock. """Factory fixture that builds a connections['default'] mock.
Usage:: Usage::
@@ -415,7 +423,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_no_migrations_table_fresh_install( def test_no_migrations_table_fresh_install(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock], build_conn_mock,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -434,7 +442,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_no_documents_migrations_fresh_install( def test_no_documents_migrations_fresh_install(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock], build_conn_mock,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -453,7 +461,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_v3_state_with_0001_squashed( def test_v3_state_with_0001_squashed(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock], build_conn_mock,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -477,7 +485,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_v3_state_with_0002_squashed_only( def test_v3_state_with_0002_squashed_only(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock], build_conn_mock,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -496,7 +504,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_v2_20_9_state_ready_to_upgrade( def test_v2_20_9_state_ready_to_upgrade(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock], build_conn_mock,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -523,7 +531,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_v2_20_8_raises_error( def test_v2_20_8_raises_error(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock], build_conn_mock,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -550,7 +558,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_very_old_version_raises_error( def test_very_old_version_raises_error(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock], build_conn_mock,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -577,7 +585,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_error_hint_mentions_v2_20_9( def test_error_hint_mentions_v2_20_9(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock], build_conn_mock,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:

View File

@@ -9,50 +9,35 @@ from paperless.utils import ocr_to_dateparser_languages
@pytest.mark.parametrize( @pytest.mark.parametrize(
("ocr_language", "expected"), ("ocr_language", "expected"),
[ [
pytest.param("eng", ["en"], id="single-language"), # One language
pytest.param("fra+ita+lao", ["fr", "it", "lo"], id="multiple-languages"), ("eng", ["en"]),
pytest.param("fil", ["fil"], id="no-two-letter-equivalent"), # Multiple languages
pytest.param( ("fra+ita+lao", ["fr", "it", "lo"]),
"aze_cyrl+srp_latn", # Languages that don't have a two-letter equivalent
["az-Cyrl", "sr-Latn"], ("fil", ["fil"]),
id="script-supported-by-dateparser", # Languages with a script part supported by dateparser
), ("aze_cyrl+srp_latn", ["az-Cyrl", "sr-Latn"]),
pytest.param( # Languages with a script part not supported by dateparser
"deu_frak", # In this case, default to the language without script
["de"], ("deu_frak", ["de"]),
id="script-not-supported-falls-back-to-language", # Traditional and simplified chinese don't have the same name in dateparser,
), # so they're converted to the general chinese language
pytest.param( ("chi_tra+chi_sim", ["zh"]),
"chi_tra+chi_sim", # If a language is not supported by dateparser, fallback to the supported ones
["zh"], ("eng+unsupported_language+por", ["en", "pt"]),
id="chinese-variants-collapse-to-general", # If no language is supported, fallback to default
), ("unsupported1+unsupported2", []),
pytest.param( # Duplicate languages, should not duplicate in result
"eng+unsupported_language+por", ("eng+eng", ["en"]),
["en", "pt"], # Language with script, but script is not mapped
id="unsupported-language-skipped", ("ita_unknownscript", ["it"]),
),
pytest.param(
"unsupported1+unsupported2",
[],
id="all-unsupported-returns-empty",
),
pytest.param("eng+eng", ["en"], id="duplicates-deduplicated"),
pytest.param(
"ita_unknownscript",
["it"],
id="unknown-script-falls-back-to-language",
),
], ],
) )
def test_ocr_to_dateparser_languages(ocr_language: str, expected: list[str]) -> None: def test_ocr_to_dateparser_languages(ocr_language, expected):
assert sorted(ocr_to_dateparser_languages(ocr_language)) == sorted(expected) assert sorted(ocr_to_dateparser_languages(ocr_language)) == sorted(expected)
def test_ocr_to_dateparser_languages_exception( def test_ocr_to_dateparser_languages_exception(monkeypatch, caplog):
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
# Patch LocaleDataLoader.get_locale_map to raise an exception # Patch LocaleDataLoader.get_locale_map to raise an exception
class DummyLoader: class DummyLoader:
def get_locale_map(self, locales=None): def get_locale_map(self, locales=None):

View File

@@ -1,31 +1,24 @@
import tempfile
from pathlib import Path from pathlib import Path
from django.test import Client from django.test import override_settings
from pytest_django.fixtures import SettingsWrapper
def test_favicon_view( def test_favicon_view(client):
client: Client, with tempfile.TemporaryDirectory() as tmpdir:
tmp_path: Path, static_dir = Path(tmpdir)
settings: SettingsWrapper, favicon_path = static_dir / "paperless" / "img" / "favicon.ico"
) -> None: favicon_path.parent.mkdir(parents=True, exist_ok=True)
favicon_path = tmp_path / "paperless" / "img" / "favicon.ico" favicon_path.write_bytes(b"FAKE ICON DATA")
favicon_path.parent.mkdir(parents=True)
favicon_path.write_bytes(b"FAKE ICON DATA")
settings.STATIC_ROOT = tmp_path with override_settings(STATIC_ROOT=static_dir):
response = client.get("/favicon.ico")
response = client.get("/favicon.ico") assert response.status_code == 200
assert response.status_code == 200 assert response["Content-Type"] == "image/x-icon"
assert response["Content-Type"] == "image/x-icon" assert b"".join(response.streaming_content) == b"FAKE ICON DATA"
assert b"".join(response.streaming_content) == b"FAKE ICON DATA"
def test_favicon_view_missing_file( def test_favicon_view_missing_file(client):
client: Client, with override_settings(STATIC_ROOT=Path(tempfile.mkdtemp())):
tmp_path: Path, response = client.get("/favicon.ico")
settings: SettingsWrapper, assert response.status_code == 404
) -> None:
settings.STATIC_ROOT = tmp_path
response = client.get("/favicon.ico")
assert response.status_code == 404

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel from llama_index.core.bridge.pydantic import BaseModel
class DocumentClassifierSchema(BaseModel): class DocumentClassifierSchema(BaseModel):

View File

@@ -1,6 +1,10 @@
import logging import logging
import sys 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 documents.models import Document
from paperless_ai.client import AIClient from paperless_ai.client import AIClient
from paperless_ai.indexing import load_or_build_index from paperless_ai.indexing import load_or_build_index
@@ -10,13 +14,15 @@ logger = logging.getLogger("paperless_ai.chat")
MAX_SINGLE_DOC_CONTEXT_CHARS = 15000 MAX_SINGLE_DOC_CONTEXT_CHARS = 15000
SINGLE_DOC_SNIPPET_CHARS = 800 SINGLE_DOC_SNIPPET_CHARS = 800
CHAT_PROMPT_TMPL = """Context information is below. CHAT_PROMPT_TMPL = PromptTemplate(
template="""Context information is below.
--------------------- ---------------------
{context_str} {context_str}
--------------------- ---------------------
Given the context information and not prior knowledge, answer the query. Given the context information and not prior knowledge, answer the query.
Query: {query_str} Query: {query_str}
Answer:""" Answer:""",
)
def stream_chat_with_documents(query_str: str, documents: list[Document]): def stream_chat_with_documents(query_str: str, documents: list[Document]):
@@ -37,10 +43,6 @@ def stream_chat_with_documents(query_str: str, documents: list[Document]):
yield "Sorry, I couldn't find any content to answer your question." yield "Sorry, I couldn't find any content to answer your question."
return return
from llama_index.core import VectorStoreIndex
from llama_index.core.prompts import PromptTemplate
from llama_index.core.query_engine import RetrieverQueryEngine
local_index = VectorStoreIndex(nodes=nodes) local_index = VectorStoreIndex(nodes=nodes)
retriever = local_index.as_retriever( retriever = local_index.as_retriever(
similarity_top_k=3 if len(documents) == 1 else 5, similarity_top_k=3 if len(documents) == 1 else 5,
@@ -83,8 +85,7 @@ def stream_chat_with_documents(query_str: str, documents: list[Document]):
for node in top_nodes for node in top_nodes
) )
prompt_template = PromptTemplate(template=CHAT_PROMPT_TMPL) prompt = CHAT_PROMPT_TMPL.partial_format(
prompt = prompt_template.partial_format(
context_str=context, context_str=context,
query_str=query_str, query_str=query_str,
).format(llm=client.llm) ).format(llm=client.llm)

View File

@@ -1,10 +1,9 @@
import logging import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING: from llama_index.core.llms import ChatMessage
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.ollama import Ollama
from llama_index.llms.openai import OpenAI from llama_index.llms.openai import OpenAI
from paperless.config import AIConfig from paperless.config import AIConfig
from paperless_ai.base_model import DocumentClassifierSchema from paperless_ai.base_model import DocumentClassifierSchema
@@ -21,18 +20,14 @@ class AIClient:
self.settings = AIConfig() self.settings = AIConfig()
self.llm = self.get_llm() self.llm = self.get_llm()
def get_llm(self) -> "Ollama | OpenAI": def get_llm(self) -> Ollama | OpenAI:
if self.settings.llm_backend == "ollama": if self.settings.llm_backend == "ollama":
from llama_index.llms.ollama import Ollama
return Ollama( return Ollama(
model=self.settings.llm_model or "llama3.1", model=self.settings.llm_model or "llama3.1",
base_url=self.settings.llm_endpoint or "http://localhost:11434", base_url=self.settings.llm_endpoint or "http://localhost:11434",
request_timeout=120, request_timeout=120,
) )
elif self.settings.llm_backend == "openai": elif self.settings.llm_backend == "openai":
from llama_index.llms.openai import OpenAI
return OpenAI( return OpenAI(
model=self.settings.llm_model or "gpt-3.5-turbo", model=self.settings.llm_model or "gpt-3.5-turbo",
api_base=self.settings.llm_endpoint or None, api_base=self.settings.llm_endpoint or None,
@@ -48,9 +43,6 @@ class AIClient:
self.settings.llm_model, self.settings.llm_model,
) )
from llama_index.core.llms import ChatMessage
from llama_index.core.program.function_program import get_function_tool
user_msg = ChatMessage(role="user", content=prompt) user_msg = ChatMessage(role="user", content=prompt)
tool = get_function_tool(DocumentClassifierSchema) tool = get_function_tool(DocumentClassifierSchema)
result = self.llm.chat_with_tools( result = self.llm.chat_with_tools(
@@ -66,7 +58,7 @@ class AIClient:
parsed = DocumentClassifierSchema(**tool_calls[0].tool_kwargs) parsed = DocumentClassifierSchema(**tool_calls[0].tool_kwargs)
return parsed.model_dump() return parsed.model_dump()
def run_chat(self, messages: list["ChatMessage"]) -> str: def run_chat(self, messages: list[ChatMessage]) -> str:
logger.debug( logger.debug(
"Running chat query against %s with model %s", "Running chat query against %s with model %s",
self.settings.llm_backend, self.settings.llm_backend,

View File

@@ -1,12 +1,13 @@
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.conf import settings
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from llama_index.core.base.embeddings.base import BaseEmbedding 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 Document
from documents.models import Note from documents.models import Note
@@ -14,21 +15,17 @@ from paperless.config import AIConfig
from paperless.models import LLMEmbeddingBackend from paperless.models import LLMEmbeddingBackend
def get_embedding_model() -> "BaseEmbedding": def get_embedding_model() -> BaseEmbedding:
config = AIConfig() config = AIConfig()
match config.llm_embedding_backend: match config.llm_embedding_backend:
case LLMEmbeddingBackend.OPENAI: case LLMEmbeddingBackend.OPENAI:
from llama_index.embeddings.openai import OpenAIEmbedding
return OpenAIEmbedding( return OpenAIEmbedding(
model=config.llm_embedding_model or "text-embedding-3-small", model=config.llm_embedding_model or "text-embedding-3-small",
api_key=config.llm_api_key, api_key=config.llm_api_key,
api_base=config.llm_endpoint or None, api_base=config.llm_endpoint or None,
) )
case LLMEmbeddingBackend.HUGGINGFACE: case LLMEmbeddingBackend.HUGGINGFACE:
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
return HuggingFaceEmbedding( return HuggingFaceEmbedding(
model_name=config.llm_embedding_model model_name=config.llm_embedding_model
or "sentence-transformers/all-MiniLM-L6-v2", or "sentence-transformers/all-MiniLM-L6-v2",

View File

@@ -4,12 +4,26 @@ from collections.abc import Callable
from collections.abc import Iterable from collections.abc import Iterable
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
from typing import TypeVar from typing import TypeVar
import faiss
import llama_index.core.settings as llama_settings
from celery import states from celery import states
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
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 documents.models import Document
from documents.models import PaperlessTask from documents.models import PaperlessTask
@@ -20,10 +34,6 @@ from paperless_ai.embedding import get_embedding_model
_T = TypeVar("_T") _T = TypeVar("_T")
IterWrapper = Callable[[Iterable[_T]], Iterable[_T]] IterWrapper = Callable[[Iterable[_T]], Iterable[_T]]
if TYPE_CHECKING:
from llama_index.core import VectorStoreIndex
from llama_index.core.schema import BaseNode
def _identity(iterable: Iterable[_T]) -> Iterable[_T]: def _identity(iterable: Iterable[_T]) -> Iterable[_T]:
return iterable return iterable
@@ -65,23 +75,12 @@ def get_or_create_storage_context(*, rebuild=False):
settings.LLM_INDEX_DIR.mkdir(parents=True, exist_ok=True) settings.LLM_INDEX_DIR.mkdir(parents=True, exist_ok=True)
if rebuild or not settings.LLM_INDEX_DIR.exists(): if rebuild or not settings.LLM_INDEX_DIR.exists():
import faiss
from llama_index.core import StorageContext
from llama_index.core.storage.docstore import SimpleDocumentStore
from llama_index.core.storage.index_store import SimpleIndexStore
from llama_index.vector_stores.faiss import FaissVectorStore
embedding_dim = get_embedding_dim() embedding_dim = get_embedding_dim()
faiss_index = faiss.IndexFlatL2(embedding_dim) faiss_index = faiss.IndexFlatL2(embedding_dim)
vector_store = FaissVectorStore(faiss_index=faiss_index) vector_store = FaissVectorStore(faiss_index=faiss_index)
docstore = SimpleDocumentStore() docstore = SimpleDocumentStore()
index_store = SimpleIndexStore() index_store = SimpleIndexStore()
else: else:
from llama_index.core import StorageContext
from llama_index.core.storage.docstore import SimpleDocumentStore
from llama_index.core.storage.index_store import SimpleIndexStore
from llama_index.vector_stores.faiss import FaissVectorStore
vector_store = FaissVectorStore.from_persist_dir(settings.LLM_INDEX_DIR) vector_store = FaissVectorStore.from_persist_dir(settings.LLM_INDEX_DIR)
docstore = SimpleDocumentStore.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) index_store = SimpleIndexStore.from_persist_dir(settings.LLM_INDEX_DIR)
@@ -94,7 +93,7 @@ def get_or_create_storage_context(*, rebuild=False):
) )
def build_document_node(document: Document) -> list["BaseNode"]: def build_document_node(document: Document) -> list[BaseNode]:
""" """
Given a Document, returns parsed Nodes ready for indexing. Given a Document, returns parsed Nodes ready for indexing.
""" """
@@ -113,9 +112,6 @@ def build_document_node(document: Document) -> list["BaseNode"]:
"added": document.added.isoformat() if document.added else None, "added": document.added.isoformat() if document.added else None,
"modified": document.modified.isoformat(), "modified": document.modified.isoformat(),
} }
from llama_index.core import Document as LlamaDocument
from llama_index.core.node_parser import SimpleNodeParser
doc = LlamaDocument(text=text, metadata=metadata) doc = LlamaDocument(text=text, metadata=metadata)
parser = SimpleNodeParser() parser = SimpleNodeParser()
return parser.get_nodes_from_documents([doc]) return parser.get_nodes_from_documents([doc])
@@ -126,10 +122,6 @@ def load_or_build_index(nodes=None):
Load an existing VectorStoreIndex if present, Load an existing VectorStoreIndex if present,
or build a new one using provided nodes if storage is empty. or build a new one using provided nodes if storage is empty.
""" """
import llama_index.core.settings as llama_settings
from llama_index.core import VectorStoreIndex
from llama_index.core import load_index_from_storage
embed_model = get_embedding_model() embed_model = get_embedding_model()
llama_settings.Settings.embed_model = embed_model llama_settings.Settings.embed_model = embed_model
storage_context = get_or_create_storage_context() storage_context = get_or_create_storage_context()
@@ -151,7 +143,7 @@ def load_or_build_index(nodes=None):
) )
def remove_document_docstore_nodes(document: Document, index: "VectorStoreIndex"): def remove_document_docstore_nodes(document: Document, index: VectorStoreIndex):
""" """
Removes existing documents from docstore for a given document from the index. Removes existing documents from docstore for a given document from the index.
This is necessary because FAISS IndexFlatL2 is append-only. This is necessary because FAISS IndexFlatL2 is append-only.
@@ -182,8 +174,6 @@ def update_llm_index(
""" """
Rebuild or update the LLM index. Rebuild or update the LLM index.
""" """
from llama_index.core import VectorStoreIndex
nodes = [] nodes = []
documents = Document.objects.all() documents = Document.objects.all()
@@ -197,8 +187,6 @@ def update_llm_index(
(settings.LLM_INDEX_DIR / "meta.json").unlink(missing_ok=True) (settings.LLM_INDEX_DIR / "meta.json").unlink(missing_ok=True)
# Rebuild index from scratch # Rebuild index from scratch
logger.info("Rebuilding LLM index.") logger.info("Rebuilding LLM index.")
import llama_index.core.settings as llama_settings
embed_model = get_embedding_model() embed_model = get_embedding_model()
llama_settings.Settings.embed_model = embed_model llama_settings.Settings.embed_model = embed_model
storage_context = get_or_create_storage_context(rebuild=True) storage_context = get_or_create_storage_context(rebuild=True)
@@ -283,10 +271,6 @@ def llm_index_remove_document(document: Document):
def truncate_content(content: str) -> str: def truncate_content(content: str) -> str:
from llama_index.core.indices.prompt_helper import PromptHelper
from llama_index.core.prompts import PromptTemplate
from llama_index.core.text_splitter import TokenTextSplitter
prompt_helper = PromptHelper( prompt_helper = PromptHelper(
context_window=8192, context_window=8192,
num_output=512, num_output=512,
@@ -331,8 +315,6 @@ def query_similar_documents(
else None else None
) )
from llama_index.core.retrievers import VectorIndexRetriever
retriever = VectorIndexRetriever( retriever = VectorIndexRetriever(
index=index, index=index,
similarity_top_k=top_k, similarity_top_k=top_k,

View File

@@ -181,11 +181,11 @@ def test_load_or_build_index_builds_when_nodes_given(
) -> None: ) -> None:
with ( with (
patch( patch(
"llama_index.core.load_index_from_storage", "paperless_ai.indexing.load_index_from_storage",
side_effect=ValueError("Index not found"), side_effect=ValueError("Index not found"),
), ),
patch( patch(
"llama_index.core.VectorStoreIndex", "paperless_ai.indexing.VectorStoreIndex",
return_value=MagicMock(), return_value=MagicMock(),
) as mock_index_cls, ) as mock_index_cls,
patch( patch(
@@ -206,7 +206,7 @@ def test_load_or_build_index_raises_exception_when_no_nodes(
) -> None: ) -> None:
with ( with (
patch( patch(
"llama_index.core.load_index_from_storage", "paperless_ai.indexing.load_index_from_storage",
side_effect=ValueError("Index not found"), side_effect=ValueError("Index not found"),
), ),
patch( patch(
@@ -225,11 +225,11 @@ def test_load_or_build_index_succeeds_when_nodes_given(
) -> None: ) -> None:
with ( with (
patch( patch(
"llama_index.core.load_index_from_storage", "paperless_ai.indexing.load_index_from_storage",
side_effect=ValueError("Index not found"), side_effect=ValueError("Index not found"),
), ),
patch( patch(
"llama_index.core.VectorStoreIndex", "paperless_ai.indexing.VectorStoreIndex",
return_value=MagicMock(), return_value=MagicMock(),
) as mock_index_cls, ) as mock_index_cls,
patch( patch(
@@ -334,7 +334,7 @@ def test_query_similar_documents(
patch( patch(
"paperless_ai.indexing.vector_store_file_exists", "paperless_ai.indexing.vector_store_file_exists",
) as mock_vector_store_exists, ) as mock_vector_store_exists,
patch("llama_index.core.retrievers.VectorIndexRetriever") as mock_retriever_cls, patch("paperless_ai.indexing.VectorIndexRetriever") as mock_retriever_cls,
patch("paperless_ai.indexing.Document.objects.filter") as mock_filter, patch("paperless_ai.indexing.Document.objects.filter") as mock_filter,
): ):
mock_storage.return_value = MagicMock() mock_storage.return_value = MagicMock()

View File

@@ -45,7 +45,7 @@ def test_stream_chat_with_one_document_full_content(mock_document) -> None:
patch("paperless_ai.chat.AIClient") as mock_client_cls, 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.load_or_build_index") as mock_load_index,
patch( patch(
"llama_index.core.query_engine.RetrieverQueryEngine.from_args", "paperless_ai.chat.RetrieverQueryEngine.from_args",
) as mock_query_engine_cls, ) as mock_query_engine_cls,
): ):
mock_client = MagicMock() mock_client = MagicMock()
@@ -76,7 +76,7 @@ def test_stream_chat_with_multiple_documents_retrieval(patch_embed_nodes) -> Non
patch("paperless_ai.chat.AIClient") as mock_client_cls, 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.load_or_build_index") as mock_load_index,
patch( patch(
"llama_index.core.query_engine.RetrieverQueryEngine.from_args", "paperless_ai.chat.RetrieverQueryEngine.from_args",
) as mock_query_engine_cls, ) as mock_query_engine_cls,
patch.object(VectorStoreIndex, "as_retriever") as mock_as_retriever, patch.object(VectorStoreIndex, "as_retriever") as mock_as_retriever,
): ):

View File

@@ -18,13 +18,13 @@ def mock_ai_config():
@pytest.fixture @pytest.fixture
def mock_ollama_llm(): def mock_ollama_llm():
with patch("llama_index.llms.ollama.Ollama") as MockOllama: with patch("paperless_ai.client.Ollama") as MockOllama:
yield MockOllama yield MockOllama
@pytest.fixture @pytest.fixture
def mock_openai_llm(): def mock_openai_llm():
with patch("llama_index.llms.openai.OpenAI") as MockOpenAI: with patch("paperless_ai.client.OpenAI") as MockOpenAI:
yield MockOpenAI yield MockOpenAI

View File

@@ -67,7 +67,7 @@ def test_get_embedding_model_openai(mock_ai_config):
mock_ai_config.return_value.llm_api_key = "test_api_key" mock_ai_config.return_value.llm_api_key = "test_api_key"
mock_ai_config.return_value.llm_endpoint = "http://test-url" mock_ai_config.return_value.llm_endpoint = "http://test-url"
with patch("llama_index.embeddings.openai.OpenAIEmbedding") as MockOpenAIEmbedding: with patch("paperless_ai.embedding.OpenAIEmbedding") as MockOpenAIEmbedding:
model = get_embedding_model() model = get_embedding_model()
MockOpenAIEmbedding.assert_called_once_with( MockOpenAIEmbedding.assert_called_once_with(
model="text-embedding-3-small", model="text-embedding-3-small",
@@ -84,7 +84,7 @@ def test_get_embedding_model_huggingface(mock_ai_config):
) )
with patch( with patch(
"llama_index.embeddings.huggingface.HuggingFaceEmbedding", "paperless_ai.embedding.HuggingFaceEmbedding",
) as MockHuggingFaceEmbedding: ) as MockHuggingFaceEmbedding:
model = get_embedding_model() model = get_embedding_model()
MockHuggingFaceEmbedding.assert_called_once_with( MockHuggingFaceEmbedding.assert_called_once_with(

32
uv.lock generated
View File

@@ -4172,24 +4172,24 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.4" version = "0.15.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
{ url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
{ url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
{ url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
{ url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
{ url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
{ url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
{ url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
] ]
[[package]] [[package]]