Compare commits

..
63 changed files with 1016 additions and 684 deletions

No files matched your search

@@ -39,7 +39,6 @@ declare -a scope=(
"${site_packages}/allauth" "${site_packages}/allauth"
"${site_packages}/drf_spectacular" "${site_packages}/drf_spectacular"
"${site_packages}/drf_spectacular_sidecar" "${site_packages}/drf_spectacular_sidecar"
"${site_packages}/treenode"
"${site_packages}/compression_middleware" "${site_packages}/compression_middleware"
) )
+4 -5
View File
@@ -27,7 +27,7 @@ dependencies = [
# WARNING: django does not use semver. # WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes. # Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.13", "django~=5.2.13",
"django-allauth[mfa,socialaccount]~=65.16.0", "django-allauth[mfa,socialaccount]~=65.20.0",
"django-auditlog~=3.4.1", "django-auditlog~=3.4.1",
"django-cachalot~=2.9.0", "django-cachalot~=2.9.0",
"django-compression-middleware~=0.5.0", "django-compression-middleware~=0.5.0",
@@ -38,12 +38,11 @@ dependencies = [
"django-multiselectfield~=1.0.1", "django-multiselectfield~=1.0.1",
"django-rich~=2.2.0", "django-rich~=2.2.0",
"django-soft-delete~=1.0.18", "django-soft-delete~=1.0.18",
"django-treenode>=0.24",
"djangorestframework~=3.16", "djangorestframework~=3.16",
"drf-spectacular~=0.30", "drf-spectacular~=0.30",
"drf-spectacular-sidecar>=2026.7.1,<2026.10", "drf-spectacular-sidecar>=2026.7.1,<2026.10",
"drf-writable-nested~=0.7.1", "drf-writable-nested~=0.7.1",
"filelock>=3.32,<4.1", "filelock>=3.32,<4.3",
"flower>=2.0.1,<2.3", "flower>=2.0.1,<2.3",
"gotenberg-client[httpx]~=1.0", "gotenberg-client[httpx]~=1.0",
"httpx-oauth~=0.17", "httpx-oauth~=0.17",
@@ -256,8 +255,8 @@ isort.force-single-line = true
[tool.codespell] [tool.codespell]
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin,reprot" ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin,reprot"
skip = """\ skip = """\
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless/tests/samples/mail/*,src/documents/tests/samples\ src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
/*,src/paperless_testing/sample_files/*,*.po,*.json\ /mail/*,src/documents/tests/samples/*,*.po,*.json\
""" """
write-changes = true write-changes = true
+1 -3
View File
@@ -134,9 +134,7 @@ def seed_database() -> None:
originals.mkdir(parents=True) originals.mkdir(parents=True)
thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails" thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails"
thumbnails.mkdir(parents=True) thumbnails.mkdir(parents=True)
sample_pdf = ( sample_pdf = SOURCE_ROOT / "documents" / "tests" / "samples" / "simple.pdf"
SOURCE_ROOT / "paperless_testing" / "sample_files" / "simple-digital.pdf"
)
for document in documents: for document in documents:
shutil.copyfile(sample_pdf, originals / document.filename) shutil.copyfile(sample_pdf, originals / document.filename)
shutil.copyfile( shutil.copyfile(
@@ -19,7 +19,6 @@ import {
PaperlessTaskTriggerSource, PaperlessTaskTriggerSource,
PaperlessTaskType, PaperlessTaskType,
} from 'src/app/data/paperless-task' } from 'src/app/data/paperless-task'
import { Results } from 'src/app/data/results'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
@@ -153,11 +152,6 @@ const tasks: PaperlessTask[] = [
}, },
] ]
const paginatedTasks: Results<PaperlessTask> = {
count: tasks.length,
results: tasks,
}
const sectionCountResponse = { const sectionCountResponse = {
all: 7, all: 7,
needs_attention: 2, needs_attention: 2,
@@ -204,7 +198,7 @@ describe('TasksComponent', () => {
}).compileComponents() }).compileComponents()
tasksService = TestBed.inject(TasksService) tasksService = TestBed.inject(TasksService)
reloadSpy = jest.spyOn(tasksService, 'reload') reloadSpy = jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
router = TestBed.inject(Router) router = TestBed.inject(Router)
@@ -214,15 +208,6 @@ describe('TasksComponent', () => {
jest.useFakeTimers() jest.useFakeTimers()
fixture.detectChanges() fixture.detectChanges()
httpTestingController
.expectOne(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
)
.flush(paginatedTasks)
httpTestingController httpTestingController
.expectOne( .expectOne(
(req) => (req) =>
@@ -344,11 +344,11 @@
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span> <i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) { @if (tasksService.needsAttentionTasksCount > 0 && !slimSidebarEnabled) {
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span> <span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasksCount}}</span>
} }
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) { @if (tasksService.needsAttentionTasksCount > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span> <span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasksCount}}</span>
} }
</a> </a>
</li> </li>
@@ -588,8 +588,8 @@ describe('AppFrameComponent', () => {
it('should show tasks badge for needs-attention tasks', () => { it('should show tasks badge for needs-attention tasks', () => {
jest jest
.spyOn(tasksService, 'needsAttentionTasks', 'get') .spyOn(tasksService, 'needsAttentionTasksCount', 'get')
.mockReturnValue([{} as any, {} as any]) .mockReturnValue(2)
fixture.detectChanges() fixture.detectChanges()
+22 -82
View File
@@ -9,11 +9,7 @@ import {
} from '@angular/common/http/testing' } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { import { PaperlessTaskType } from '../data/paperless-task'
PaperlessTaskStatus,
PaperlessTaskTriggerSource,
PaperlessTaskType,
} from '../data/paperless-task'
import { TasksService } from './tasks.service' import { TasksService } from './tasks.service'
describe('TasksService', () => { describe('TasksService', () => {
@@ -38,23 +34,23 @@ describe('TasksService', () => {
httpTestingController.verify() httpTestingController.verify()
}) })
it('calls tasks api endpoint on reload', () => { it('calls tasks status counts api endpoint on reload', () => {
tasksService.reload() tasksService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` && req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false' && req.params.get('acknowledged') === 'false'
req.params.get('page_size') === '1000'
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
req.flush({ count: 0, results: [] }) req.flush({ all: 5, needs_attention: 3, in_progress: 1, completed: 1 })
expect(tasksService.needsAttentionTasksCount).toEqual(3)
}) })
it('cancels an in-progress reload when reloading again', () => { it('cancels an in-progress reload when reloading again', () => {
tasksService.reload() tasksService.reload()
const staleReload = httpTestingController.expectOne( const staleReload = httpTestingController.expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` req.url === `${environment.apiBaseUrl}tasks/status_counts/`
) )
tasksService.reload() tasksService.reload()
@@ -62,9 +58,9 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` req.url === `${environment.apiBaseUrl}tasks/status_counts/`
) )
.flush({ count: 0, results: [] }) .flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
}) })
it('continues reloading after a reload request fails', () => { it('continues reloading after a reload request fails', () => {
@@ -72,7 +68,7 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` req.url === `${environment.apiBaseUrl}tasks/status_counts/`
) )
.flush('error', { status: 500, statusText: 'error' }) .flush('error', { status: 500, statusText: 'error' })
@@ -82,16 +78,16 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` req.url === `${environment.apiBaseUrl}tasks/status_counts/`
) )
.flush({ count: 0, results: [] }) .flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
}) })
it('reloads after dismissing a task while a reload is already in progress', () => { it('reloads after dismissing a task while a reload is already in progress', () => {
tasksService.reload() tasksService.reload()
const staleReload = httpTestingController.expectOne( const staleReload = httpTestingController.expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` && req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false' req.params.get('acknowledged') === 'false'
) )
@@ -104,12 +100,12 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` && req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false' req.params.get('acknowledged') === 'false'
) )
.flush({ count: 0, results: [] }) .flush({ all: 50, needs_attention: 42, in_progress: 3, completed: 5 })
expect(tasksService.needsAttentionTasks).toHaveLength(0) expect(tasksService.needsAttentionTasksCount).toEqual(42)
}) })
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => { it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
@@ -126,11 +122,10 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` && req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false' && req.params.get('acknowledged') === 'false'
req.params.get('page_size') === '1000'
) )
.flush({ count: 0, results: [] }) .flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
}) })
it('calls acknowledge_tasks api endpoint on dismiss all and reloads', () => { it('calls acknowledge_tasks api endpoint on dismiss all and reloads', () => {
@@ -147,65 +142,10 @@ describe('TasksService', () => {
httpTestingController httpTestingController
.expectOne( .expectOne(
(req: HttpRequest<unknown>) => (req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` && req.url === `${environment.apiBaseUrl}tasks/status_counts/` &&
req.params.get('acknowledged') === 'false' && req.params.get('acknowledged') === 'false'
req.params.get('page_size') === '1000'
) )
.flush({ count: 0, results: [] }) .flush({ all: 0, needs_attention: 0, in_progress: 0, completed: 0 })
})
it('includes revoked tasks in needs attention', () => {
const mockTasks = [
{
task_type: PaperlessTaskType.SanityCheck,
trigger_source: PaperlessTaskTriggerSource.System,
status: PaperlessTaskStatus.Failure,
acknowledged: false,
task_id: '1235',
input_data: {},
date_created: new Date(),
related_document_ids: [],
},
{
task_type: PaperlessTaskType.MailFetch,
trigger_source: PaperlessTaskTriggerSource.Scheduled,
status: PaperlessTaskStatus.Revoked,
acknowledged: false,
task_id: '1236',
input_data: {},
date_created: new Date(),
related_document_ids: [],
},
{
task_type: PaperlessTaskType.EmptyTrash,
trigger_source: PaperlessTaskTriggerSource.Manual,
status: PaperlessTaskStatus.Success,
acknowledged: false,
task_id: '1238',
input_data: {},
date_created: new Date(),
related_document_ids: [],
},
]
tasksService.reload()
const req = httpTestingController.expectOne(
(req: HttpRequest<unknown>) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('acknowledged') === 'false' &&
req.params.get('page_size') === '1000'
)
req.flush({ count: mockTasks.length, results: mockTasks })
expect(tasksService.needsAttentionTasks).toHaveLength(2)
expect(tasksService.needsAttentionTasks.map((task) => task.status)).toEqual(
expect.arrayContaining([
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
])
)
}) })
it('supports running tasks', () => { it('supports running tasks', () => {
+23 -33
View File
@@ -5,14 +5,12 @@ import {
catchError, catchError,
finalize, finalize,
first, first,
map,
switchMap, switchMap,
takeUntil, takeUntil,
tap, tap,
} from 'rxjs/operators' } from 'rxjs/operators'
import { import {
PaperlessTask, PaperlessTask,
PaperlessTaskStatus,
PaperlessTaskStatusCounts, PaperlessTaskStatusCounts,
PaperlessTaskType, PaperlessTaskType,
} from 'src/app/data/paperless-task' } from 'src/app/data/paperless-task'
@@ -27,50 +25,42 @@ export class TasksService {
private baseUrl: string = environment.apiBaseUrl private baseUrl: string = environment.apiBaseUrl
private endpoint: string = 'tasks' private endpoint: string = 'tasks'
private readonly defaultReloadPageSize = 1000
public loading: boolean = false public loading: boolean = false
private readonly tasks = signal<PaperlessTask[]>([]) private readonly tasksStatusCounts = signal<PaperlessTaskStatusCounts>({
all: 0,
needs_attention: 0,
in_progress: 0,
completed: 0,
})
private readonly reloadNotifier = new Subject<void>() private readonly reloadNotifier = new Subject<void>()
private unsubscribeNotifer: Subject<any> = new Subject() private unsubscribeNotifer: Subject<any> = new Subject()
constructor() { constructor() {
this.reloadNotifier this.reloadNotifier
.pipe( .pipe(switchMap(() => this.fetchUnacknowledgedTasksCounts()))
switchMap(() => { .subscribe((taskStatusCounts) => {
this.loading = true this.tasksStatusCounts.set(taskStatusCounts)
return this.http
.get<Results<PaperlessTask>>(`${this.baseUrl}${this.endpoint}/`, {
params: {
acknowledged: 'false',
page_size: this.defaultReloadPageSize,
},
})
.pipe(
map((response) => response.results),
takeUntil(this.unsubscribeNotifer),
catchError(() => EMPTY),
finalize(() => {
this.loading = false
})
)
})
)
.subscribe((tasks) => {
this.tasks.set(tasks)
}) })
} }
public get needsAttentionTasks(): PaperlessTask[] { private fetchUnacknowledgedTasksCounts(): Observable<PaperlessTaskStatusCounts> {
return this.tasks().filter((t) => this.loading = true
[PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked].includes( return this.statusCounts({ acknowledged: false }).pipe(
t.status takeUntil(this.unsubscribeNotifer),
) catchError(() => EMPTY),
finalize(() => {
this.loading = false
})
) )
} }
public get needsAttentionTasksCount(): number {
return this.tasksStatusCounts().needs_attention
}
public reload() { public reload() {
this.reloadNotifier.next() this.reloadNotifier.next()
} }
@@ -105,7 +95,7 @@ export class TasksService {
public dismissTasks(task_ids: Set<number>): Observable<any> { public dismissTasks(task_ids: Set<number>): Observable<any> {
return this.http return this.http
.post(`${this.baseUrl}tasks/acknowledge/`, { .post(`${this.baseUrl}${this.endpoint}/acknowledge/`, {
tasks: [...task_ids], tasks: [...task_ids],
}) })
.pipe( .pipe(
@@ -119,7 +109,7 @@ export class TasksService {
public dismissAllTasks(): Observable<any> { public dismissAllTasks(): Observable<any> {
return this.http return this.http
.post(`${this.baseUrl}tasks/acknowledge/`, { .post(`${this.baseUrl}${this.endpoint}/acknowledge/`, {
all: true, all: true,
}) })
.pipe( .pipe(
-38
View File
@@ -11,8 +11,6 @@ from typing import TYPE_CHECKING
import pytest import pytest
from paperless_testing import samples
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path from pathlib import Path
@@ -151,39 +149,3 @@ def fake_progress_manager(
monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager) monkeypatch.setattr("documents.tasks.ProgressManager", FakeProgressManager)
return FakeProgressManager return FakeProgressManager
@pytest.fixture(scope="session")
def shared_samples_dir() -> Path:
"""Directory of sample files used by more than one app's tests."""
return samples.SHARED_SAMPLES_DIR
@pytest.fixture(scope="session")
def simple_digital_pdf_file() -> Path:
"""One-page PDF with a text layer."""
return samples.SIMPLE_DIGITAL_PDF
@pytest.fixture(scope="session")
def multi_page_digital_pdf_file() -> Path:
"""Three-page PDF with a text layer."""
return samples.MULTI_PAGE_DIGITAL_PDF
@pytest.fixture(scope="session")
def with_form_pdf_file() -> Path:
"""PDF containing a fillable form."""
return samples.WITH_FORM_PDF
@pytest.fixture(scope="session")
def multi_page_images_pdf_file() -> Path:
"""Multi-page PDF of scanned images, no text layer."""
return samples.MULTI_PAGE_IMAGES_PDF
@pytest.fixture(scope="session")
def thumbnail_webp_file() -> Path:
"""Small WebP thumbnail."""
return samples.THUMBNAIL_WEBP
+1 -2
View File
@@ -1,7 +1,6 @@
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from guardian.admin import GuardedModelAdmin from guardian.admin import GuardedModelAdmin
from treenode.admin import TreeNodeModelAdmin
from documents.models import Correspondent from documents.models import Correspondent
from documents.models import CustomField from documents.models import CustomField
@@ -29,7 +28,7 @@ class CorrespondentAdmin(GuardedModelAdmin):
list_editable = ("match", "matching_algorithm") list_editable = ("match", "matching_algorithm")
class TagAdmin(GuardedModelAdmin, TreeNodeModelAdmin): class TagAdmin(GuardedModelAdmin):
list_display = ("name", "color", "match", "matching_algorithm") list_display = ("name", "color", "match", "matching_algorithm")
list_filter = ("matching_algorithm",) list_filter = ("matching_algorithm",)
list_editable = ("color", "match", "matching_algorithm") list_editable = ("color", "match", "matching_algorithm")
+7 -13
View File
@@ -255,19 +255,13 @@ def modify_tags(
affected_docs = list(qs.values_list("pk", flat=True)) affected_docs = list(qs.values_list("pk", flat=True))
DocumentTagRelationship = Document.tags.through DocumentTagRelationship = Document.tags.through
# add with all ancestors hierarchy = Tag.get_hierarchy()
expanded_add_tags: set[int] = set() expanded_add_tags = hierarchy.with_ancestors(
add_tag_objects = Tag.objects.filter(pk__in=add_tags) Tag.objects.filter(pk__in=add_tags).values_list("pk", flat=True),
for t in add_tag_objects: )
expanded_add_tags.add(int(t.id)) expanded_remove_tags = hierarchy.with_descendants(
expanded_add_tags.update(int(pk) for pk in t.get_ancestors_pks()) Tag.objects.filter(pk__in=remove_tags).values_list("pk", flat=True),
)
# remove with all descendants
expanded_remove_tags: set[int] = set()
remove_tag_objects = Tag.objects.filter(pk__in=remove_tags)
for t in remove_tag_objects:
expanded_remove_tags.add(int(t.id))
expanded_remove_tags.update(int(pk) for pk in t.get_descendants_pks())
with transaction.atomic(): with transaction.atomic():
if expanded_remove_tags: if expanded_remove_tags:
@@ -108,6 +108,24 @@ def _deserialize_record(
) from e ) from e
for field_name, field_value in record.get("fields", {}).items(): for field_name, field_value in record.get("fields", {}).items():
# Older exports include django-treenode's derived bookkeeping. The
# parent FK is authoritative; accept only these known removed fields.
if Model is Tag and field_name in {
"tn_ancestors_count",
"tn_ancestors_pks",
"tn_children_count",
"tn_children_pks",
"tn_depth",
"tn_descendants_count",
"tn_descendants_pks",
"tn_index",
"tn_level",
"tn_order",
"tn_priority",
"tn_siblings_count",
"tn_siblings_pks",
}:
continue
field = Model._meta.get_field(field_name) field = Model._meta.get_field(field_name)
remote = field.remote_field remote = field.remote_field
@@ -0,0 +1,64 @@
# Generated by Django 5.2.17 on 2026-10-28 22:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("documents", "0027_documentbarcode"),
]
operations = [
migrations.RemoveField(
model_name="tag",
name="tn_ancestors_count",
),
migrations.RemoveField(
model_name="tag",
name="tn_ancestors_pks",
),
migrations.RemoveField(
model_name="tag",
name="tn_children_count",
),
migrations.RemoveField(
model_name="tag",
name="tn_children_pks",
),
migrations.RemoveField(
model_name="tag",
name="tn_depth",
),
migrations.RemoveField(
model_name="tag",
name="tn_descendants_count",
),
migrations.RemoveField(
model_name="tag",
name="tn_descendants_pks",
),
migrations.RemoveField(
model_name="tag",
name="tn_index",
),
migrations.RemoveField(
model_name="tag",
name="tn_level",
),
migrations.RemoveField(
model_name="tag",
name="tn_order",
),
migrations.RemoveField(
model_name="tag",
name="tn_priority",
),
migrations.RemoveField(
model_name="tag",
name="tn_siblings_count",
),
migrations.RemoveField(
model_name="tag",
name="tn_siblings_pks",
),
]
+53 -23
View File
@@ -14,7 +14,6 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from multiselectfield import MultiSelectField from multiselectfield import MultiSelectField
from treenode.models import TreeNodeModel
if settings.AUDIT_LOG_ENABLED: if settings.AUDIT_LOG_ENABLED:
from auditlog.registry import auditlog from auditlog.registry import auditlog
@@ -100,7 +99,17 @@ class Correspondent(MatchingModel):
verbose_name_plural = _("correspondents") verbose_name_plural = _("correspondents")
class Tag(MatchingModel, TreeNodeModel): class Tag(MatchingModel):
# Keep the existing column and reverse relation during the transition.
tn_parent = models.ForeignKey(
"self",
blank=True,
null=True,
on_delete=models.CASCADE,
related_name="tn_children",
verbose_name=_("Parent"),
)
color = models.CharField(_("color"), max_length=7, default="#a6cee3") color = models.CharField(_("color"), max_length=7, default="#a6cee3")
# Maximum allowed nesting depth for tags (root = 1, max depth = 5) # Maximum allowed nesting depth for tags (root = 1, max depth = 5)
MAX_NESTING_DEPTH: Final[int] = 5 MAX_NESTING_DEPTH: Final[int] = 5
@@ -114,27 +123,51 @@ class Tag(MatchingModel, TreeNodeModel):
), ),
) )
class Meta(MatchingModel.Meta, TreeNodeModel.Meta): class Meta(MatchingModel.Meta):
verbose_name = _("tag") verbose_name = _("tag")
verbose_name_plural = _("tags") verbose_name_plural = _("tags")
@classmethod
def get_hierarchy(cls):
from documents.tag_hierarchy import TagHierarchy
return TagHierarchy(cls.objects.values_list("pk", "tn_parent_id"))
@property
def parent_pk(self):
return self.tn_parent_id
def get_parent(self):
return self.tn_parent
def get_ancestors_pks(self):
return self.get_hierarchy().ancestors(self.tn_parent_id)
def get_ancestors(self):
ids = self.get_ancestors_pks()
tags = self.__class__.objects.in_bulk(ids)
return [tags[pk] for pk in ids]
def get_descendants_pks(self):
return self.get_hierarchy().descendants([self.pk])
def get_descendants(self):
return self.__class__.objects.filter(pk__in=self.get_descendants_pks())
def get_children_queryset(self):
return self.__class__.objects.filter(tn_parent_id=self.pk)
def clean(self) -> None: def clean(self) -> None:
# Prevent self-parenting and assigning a descendant as parent hierarchy = self.get_hierarchy()
parent = self.get_parent() if self.pk is not None and self.tn_parent_id == self.pk:
if parent == self: raise ValidationError({"tn_parent": _("Cannot set itself as parent.")})
raise ValidationError({"parent": _("Cannot set itself as parent.")}) ancestors = hierarchy.ancestors(self.tn_parent_id)
if parent and self.pk is not None and self.is_ancestor_of(parent): if self.pk is not None and self.pk in ancestors:
raise ValidationError({"parent": _("Cannot set parent to a descendant.")}) raise ValidationError(
{"tn_parent": _("Cannot set parent to a descendant.")},
# Enforce maximum nesting depth )
new_parent_depth = 0 if len(ancestors) + 1 + hierarchy.height(self.pk) > self.MAX_NESTING_DEPTH:
if parent: raise ValidationError({"tn_parent": _("Maximum nesting depth exceeded.")})
new_parent_depth = parent.get_ancestors_count() + 1
height = 0 if self.pk is None else self.get_depth()
deepest_new_depth = (new_parent_depth + 1) + height
if deepest_new_depth > self.MAX_NESTING_DEPTH:
raise ValidationError({"parent": _("Maximum nesting depth exceeded.")})
return super().clean() return super().clean()
@@ -539,10 +572,7 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
return self.created return self.created
def add_nested_tags(self, tags) -> None: def add_nested_tags(self, tags) -> None:
tag_ids = set() tag_ids = Tag.get_hierarchy().with_ancestors(tag.pk for tag in tags)
for tag in tags:
tag_ids.add(tag.id)
tag_ids.update(tag.get_ancestors_pks())
tags_to_add = self.tags.model.objects.filter(id__in=tag_ids) tags_to_add = self.tags.model.objects.filter(id__in=tag_ids)
self.tags.add(*tags_to_add) self.tags.add(*tags_to_add)
+28 -50
View File
@@ -589,7 +589,7 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
text_color = serializers.SerializerMethodField() text_color = serializers.SerializerMethodField()
# map to treenode's tn_parent # Preserve the public API parent field.
parent = serializers.PrimaryKeyRelatedField( parent = serializers.PrimaryKeyRelatedField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
allow_null=True, allow_null=True,
@@ -692,7 +692,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
self.instance.clean() self.instance.clean()
except ValidationError as e: except ValidationError as e:
logger.debug("Tag parent validation failed: %s", e) logger.debug("Tag parent validation failed: %s", e)
raise e raise serializers.ValidationError(
{"parent": e.message_dict["tn_parent"]},
) from e
finally: finally:
self.instance.tn_parent = original_parent self.instance.tn_parent = original_parent
else: else:
@@ -702,7 +704,9 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
temp.clean() temp.clean()
except ValidationError as e: except ValidationError as e:
logger.debug("Tag parent validation failed: %s", e) logger.debug("Tag parent validation failed: %s", e)
raise e raise serializers.ValidationError(
{"parent": e.message_dict["tn_parent"]},
) from e
return super().validate(attrs) return super().validate(attrs)
@@ -1198,61 +1202,35 @@ class DocumentSerializer(
doc_id, doc_id,
) )
if "tags" in validated_data: if "tags" in validated_data:
# Respect tag hierarchy on updates: hierarchy = Tag.get_hierarchy()
# - Adding a child adds its ancestors previous_ids = set(instance.tags.values_list("pk", flat=True))
# - Removing a parent removes all its descendants requested_ids = {tag.pk for tag in validated_data["tags"]}
prev_tags = set(instance.tags.all()) required_ids = hierarchy.with_ancestors(requested_ids - previous_ids)
requested_tags = set(validated_data["tags"]) blocked_ids = hierarchy.with_descendants(previous_ids - requested_ids)
blocked_ids.difference_update(required_ids)
final_ids = hierarchy.with_ancestors(requested_ids) - blocked_ids
validated_data["tags"] = list(Tag.objects.filter(pk__in=final_ids))
# Tags newly added in this update and the ancestors they require
added_tags = requested_tags - prev_tags
required_by_add_tags = set(added_tags)
for t in added_tags:
required_by_add_tags.update(t.get_ancestors())
# Tags being removed in this update and all descendants, except
# those required by a tag that is being added in this same update
removed_tags = prev_tags - requested_tags
blocked_tags = set(removed_tags)
for t in removed_tags:
blocked_tags.update(t.get_descendants())
blocked_tags.difference_update(required_by_add_tags)
# Add all parent tags
final_tags = set(requested_tags)
for t in requested_tags:
final_tags.update(t.get_ancestors())
# Drop removed parents and their descendants
final_tags.difference_update(blocked_tags)
validated_data["tags"] = list(final_tags)
if validated_data.get("remove_inbox_tags"): if validated_data.get("remove_inbox_tags"):
current_tag_ids = {t.pk for t in instance.tags.all()} hierarchy = Tag.get_hierarchy()
tags = ( tags = (
validated_data["tags"] validated_data["tags"]
if "tags" in validated_data if "tags" in validated_data
else list(instance.tags.all()) else list(instance.tags.all())
) )
current_ids = set(instance.tags.values_list("pk", flat=True))
# Tags newly added in this update, plus their ancestors, are kept keep_ids = hierarchy.with_ancestors(
keep_ids: set[int] = set() tag.pk for tag in tags if tag.pk not in current_ids
for tag in tags: )
if tag.pk not in current_tag_ids: inbox_ids = (
keep_ids.add(tag.pk)
keep_ids.update(int(pk) for pk in tag.get_ancestors_pks())
# Remove inbox tags and their descendants, except those being kept
remove_ids: set[int] = set()
for inbox_tag in (
Tag.objects.filter(is_inbox_tag=True) Tag.objects.filter(is_inbox_tag=True)
.exclude(pk__in=keep_ids) .exclude(
.only("pk", "tn_descendants_pks") pk__in=keep_ids,
): )
remove_ids.add(inbox_tag.pk) .values_list("pk", flat=True)
remove_ids.update(int(pk) for pk in inbox_tag.get_descendants_pks()) )
remove_ids = hierarchy.with_descendants(inbox_ids) - keep_ids
validated_data["tags"] = [t for t in tags if t.pk not in remove_ids] validated_data["tags"] = [tag for tag in tags if tag.pk not in remove_ids]
if settings.AUDIT_LOG_ENABLED: if settings.AUDIT_LOG_ENABLED:
with set_actor(self.user): with set_actor(self.user):
+8
View File
@@ -1381,9 +1381,17 @@ def task_revoked_handler(
return return
try: try:
close_old_connections() close_old_connections()
reason = "Task revoked before completion."
if expired:
reason = "Task expired before execution."
elif terminated:
reason = "Task terminated during execution."
PaperlessTask.objects.filter(task_id=task_id).update( PaperlessTask.objects.filter(task_id=task_id).update(
status=PaperlessTask.Status.REVOKED, status=PaperlessTask.Status.REVOKED,
date_done=timezone.now(), date_done=timezone.now(),
result_data={
"reason": (reason + " Check worker logs for details."),
},
) )
except Exception: # pragma: no cover except Exception: # pragma: no cover
logger.exception("Updating PaperlessTask on revocation failed") logger.exception("Updating PaperlessTask on revocation failed")
+61
View File
@@ -0,0 +1,61 @@
"""An operation-local snapshot of tag edges, with no persistent derived fields.
Never cache this globally: tasks, bulk updates and other processes can change
parents. Callers traversing multiple tags should share one snapshot.
"""
from collections import defaultdict
from collections.abc import Iterable
class TagHierarchy:
def __init__(self, edges: Iterable[tuple[int, int | None]]) -> None:
self.parents = dict(edges)
self.children: dict[int | None, list[int]] = defaultdict(list)
for pk, parent in self.parents.items():
self.children[parent].append(pk)
def ancestors(self, parent_id: int | None) -> list[int]:
result = []
seen = set()
while parent_id is not None and parent_id not in seen:
seen.add(parent_id)
result.append(parent_id)
parent_id = self.parents.get(parent_id)
return result[::-1]
def with_ancestors(self, roots: Iterable[int]) -> set[int]:
result = set(roots)
for pk in list(result):
result.update(self.ancestors(self.parents.get(pk)))
return result
def with_descendants(self, roots: Iterable[int]) -> set[int]:
roots = set(roots)
return roots | self.descendants(roots)
def descendants(self, roots: Iterable[int]) -> set[int]:
roots = {pk for pk in roots if pk is not None}
seen = set(roots)
pending = list(roots)
while pending:
for child in self.children.get(pending.pop(), ()):
if child not in seen:
seen.add(child)
pending.append(child)
return seen - roots
def height(self, pk: int | None) -> int:
if pk is None:
return 0
seen = {pk}
pending = [(pk, 0)]
height = 0
while pending:
parent, depth = pending.pop()
height = max(height, depth)
for child in self.children.get(parent, ()):
if child not in seen:
seen.add(child)
pending.append((child, depth + 1))
return height
+10 -6
View File
@@ -12,25 +12,29 @@ if TYPE_CHECKING:
from paperless_testing.dirs import PaperlessDirs from paperless_testing.dirs import PaperlessDirs
@pytest.fixture(scope="session")
def document_samples_dir() -> Path:
"""Path to the shared test sample documents."""
return Path(__file__).parent / "samples" / "documents"
@pytest.fixture() @pytest.fixture()
def sample_doc( def sample_doc(
paperless_dirs: "PaperlessDirs", paperless_dirs: "PaperlessDirs",
simple_digital_pdf_file: Path, document_samples_dir: Path,
multi_page_images_pdf_file: Path,
thumbnail_webp_file: Path,
) -> "Document": ) -> "Document":
"""Create a document with valid files and matching checksums.""" """Create a document with valid files and matching checksums."""
with filelock.FileLock(paperless_dirs.media_lock): with filelock.FileLock(paperless_dirs.media_lock):
shutil.copy( shutil.copy(
simple_digital_pdf_file, document_samples_dir / "originals" / "0000001.pdf",
paperless_dirs.originals_dir / "0000001.pdf", paperless_dirs.originals_dir / "0000001.pdf",
) )
shutil.copy( shutil.copy(
multi_page_images_pdf_file, document_samples_dir / "archive" / "0000001.pdf",
paperless_dirs.archive_dir / "0000001.pdf", paperless_dirs.archive_dir / "0000001.pdf",
) )
shutil.copy( shutil.copy(
thumbnail_webp_file, document_samples_dir / "thumbnails" / "0000001.webp",
paperless_dirs.thumbnail_dir / "0000001.webp", paperless_dirs.thumbnail_dir / "0000001.webp",
) )
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
This is a test file.
@@ -53,7 +53,7 @@ class TestBulkDownload(DirectoriesMixin, SampleDirMixin, APITestCase):
archive_checksum="D", archive_checksum="D",
) )
shutil.copy(self.SIMPLE_PDF, self.doc2.source_path) shutil.copy(self.SAMPLE_DIR / "simple.pdf", self.doc2.source_path)
shutil.copy(self.SAMPLE_DIR / "simple.png", self.doc2b.source_path) shutil.copy(self.SAMPLE_DIR / "simple.png", self.doc2b.source_path)
shutil.copy(self.SAMPLE_DIR / "simple.jpg", self.doc3.source_path) shutil.copy(self.SAMPLE_DIR / "simple.jpg", self.doc3.source_path)
shutil.copy(self.SAMPLE_DIR / "test_with_bom.pdf", self.doc3.archive_path) shutil.copy(self.SAMPLE_DIR / "test_with_bom.pdf", self.doc3.archive_path)
+98 -49
View File
@@ -46,6 +46,7 @@ from documents.models import UiSettings
from documents.models import Workflow from documents.models import Workflow
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.search import get_backend
from documents.signals.handlers import run_workflows from documents.signals.handlers import run_workflows
from documents.tests.utils import ConsumeTaskMixin from documents.tests.utils import ConsumeTaskMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
@@ -56,8 +57,6 @@ from paperless_testing.http import read_streaming_response
from paperless_testing.permissions import grant_all_global from paperless_testing.permissions import grant_all_global
from paperless_testing.permissions import grant_global from paperless_testing.permissions import grant_global
from paperless_testing.permissions import grant_object from paperless_testing.permissions import grant_object
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
from paperless_testing.samples import THUMBNAIL_WEBP
class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase): class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
@@ -68,6 +67,70 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
cache.clear() cache.clear()
def test_detail_save_indexes_final_workflow_state(self) -> None:
viewer = UserFactory()
grant_global(viewer, "view_document")
inbox = TagFactory(is_inbox_tag=True)
doc = DocumentFactory(owner=self.user, content="distinctiveword")
doc.tags.add(inbox)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(assign_title="Workflow title")
action.assign_view_users.add(viewer)
action.remove_view_users.add(viewer)
workflow = Workflow.objects.create(name="Update document")
workflow.triggers.add(trigger)
workflow.actions.add(action)
backend = get_backend()
backend.add_or_update(doc)
for action_type in (
WorkflowAction.WorkflowActionType.ASSIGNMENT,
WorkflowAction.WorkflowActionType.REMOVAL,
WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
):
with self.subTest(action_type=action_type):
grant = action_type == WorkflowAction.WorkflowActionType.ASSIGNMENT
action.type = action_type
action.save()
trigger.filter_has_not_tags.set([inbox] if grant else [])
trigger.filter_has_tags.set([] if grant else [inbox])
self.client.force_authenticate(self.user)
response = self.client.patch(
f"/api/documents/{doc.pk}/",
{"tags": [] if grant else [inbox.pk]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.client.force_authenticate(viewer)
self.assertEqual(
self.client.get(f"/api/documents/{doc.pk}/").status_code,
status.HTTP_200_OK if grant else status.HTTP_404_NOT_FOUND,
)
# Check the index directly: the API's DB permission filtering
# would otherwise hide stale grants and trashed index entries.
self.assertEqual(
list(backend.search_ids("distinctiveword", user=viewer)),
[doc.pk] if grant else [],
)
if grant:
response = self.client.get(
"/api/documents/",
{"query": "distinctiveword AND title:workflow"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
[result["id"] for result in response.data["results"]],
[doc.pk],
)
elif action_type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
self.assertFalse(Document.objects.filter(pk=doc.pk).exists())
self.assertEqual(
list(backend.search_ids("distinctiveword", user=None)),
[],
)
def testDocuments(self) -> None: def testDocuments(self) -> None:
response = self.client.get("/api/documents/").data response = self.client.get("/api/documents/").data
@@ -1854,11 +1917,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
) )
with SimpleUploadedFile( with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
"simple.pdf",
SIMPLE_DIGITAL_PDF.read_bytes(),
content_type="application/pdf",
) as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f}, {"document": f},
@@ -1886,7 +1945,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
payload = SimpleUploadedFile( payload = SimpleUploadedFile(
"../../outside.pdf", "../../outside.pdf",
SIMPLE_DIGITAL_PDF.read_bytes(), (Path(__file__).parent / "samples" / "simple.pdf").read_bytes(),
content_type="application/pdf", content_type="application/pdf",
) )
@@ -1915,7 +1974,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
) )
pdf_bytes = SIMPLE_DIGITAL_PDF.read_bytes() pdf_bytes = (Path(__file__).parent / "samples" / "simple.pdf").read_bytes()
boundary = "paperless-boundary" boundary = "paperless-boundary"
payload = ( payload = (
( (
@@ -1991,7 +2050,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
def test_upload_insufficient_permissions(self) -> None: def test_upload_insufficient_permissions(self) -> None:
self.client.force_authenticate(user=UserFactory(username="testuser2")) self.client.force_authenticate(user=UserFactory(username="testuser2"))
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f}, {"document": f},
@@ -2004,11 +2063,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
) )
with SimpleUploadedFile( with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
"simple.pdf",
SIMPLE_DIGITAL_PDF.read_bytes(),
content_type="application/pdf",
) as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{ {
@@ -2041,7 +2096,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
) )
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"documenst": f}, {"documenst": f},
@@ -2067,7 +2122,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
) )
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "title": "my custom title"}, {"document": f, "title": "my custom title"},
@@ -2087,7 +2142,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
) )
c = Correspondent.objects.create(name="test-corres") c = Correspondent.objects.create(name="test-corres")
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "correspondent": c.id}, {"document": f, "correspondent": c.id},
@@ -2106,7 +2161,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
) )
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "correspondent": 3456}, {"document": f, "correspondent": 3456},
@@ -2121,7 +2176,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
) )
dt = DocumentType.objects.create(name="invoice") dt = DocumentType.objects.create(name="invoice")
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "document_type": dt.id}, {"document": f, "document_type": dt.id},
@@ -2140,7 +2195,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
) )
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "document_type": 34578}, {"document": f, "document_type": 34578},
@@ -2155,7 +2210,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
) )
sp = StoragePath.objects.create(name="invoices") sp = StoragePath.objects.create(name="invoices")
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "storage_path": sp.id}, {"document": f, "storage_path": sp.id},
@@ -2174,7 +2229,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
) )
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "storage_path": 34578}, {"document": f, "storage_path": 34578},
@@ -2190,7 +2245,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
t1 = Tag.objects.create(name="tag1") t1 = Tag.objects.create(name="tag1")
t2 = Tag.objects.create(name="tag2") t2 = Tag.objects.create(name="tag2")
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "tags": [t2.id, t1.id]}, {"document": f, "tags": [t2.id, t1.id]},
@@ -2211,7 +2266,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
t1 = Tag.objects.create(name="tag1") t1 = Tag.objects.create(name="tag1")
t2 = Tag.objects.create(name="tag2") t2 = Tag.objects.create(name="tag2")
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "tags": [t2.id, t1.id, 734563]}, {"document": f, "tags": [t2.id, t1.id, 734563]},
@@ -2235,7 +2290,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
0, 0,
tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles"), tzinfo=zoneinfo.ZoneInfo("America/Los_Angeles"),
) )
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "created": created}, {"document": f, "created": created},
@@ -2251,11 +2306,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
) )
with SimpleUploadedFile( with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
"simple.pdf",
SIMPLE_DIGITAL_PDF.read_bytes(),
content_type="application/pdf",
) as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "archive_serial_number": 500}, {"document": f, "archive_serial_number": 500},
@@ -2282,11 +2333,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
data_type=CustomField.FieldDataType.STRING, data_type=CustomField.FieldDataType.STRING,
) )
with SimpleUploadedFile( with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
"simple.pdf",
SIMPLE_DIGITAL_PDF.read_bytes(),
content_type="application/pdf",
) as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{ {
@@ -2341,7 +2388,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
w1.actions.add(action1) w1.actions.add(action1)
w1.save() w1.save()
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{ {
@@ -2382,11 +2429,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
data_type=CustomField.FieldDataType.INT, data_type=CustomField.FieldDataType.INT,
) )
with SimpleUploadedFile( with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
"simple.pdf",
SIMPLE_DIGITAL_PDF.read_bytes(),
content_type="application/pdf",
) as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{ {
@@ -2435,7 +2478,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
] ]
for payload in error_payloads: for payload in error_payloads:
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
data = {"document": f, **payload} data = {"document": f, **payload}
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
@@ -2493,7 +2536,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
) )
with SIMPLE_DIGITAL_PDF.open("rb") as f: with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f:
response = self.client.post( response = self.client.post(
"/api/documents/post_document/", "/api/documents/post_document/",
{"document": f, "from_webui": True}, {"document": f, "from_webui": True},
@@ -2532,8 +2575,14 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
archive_filename="archive.pdf", archive_filename="archive.pdf",
) )
source_file: Path = THUMBNAIL_WEBP source_file: Path = (
archive_file: Path = SIMPLE_DIGITAL_PDF Path(__file__).parent
/ "samples"
/ "documents"
/ "thumbnails"
/ "0000001.webp"
)
archive_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
shutil.copy(source_file, doc.source_path) shutil.copy(source_file, doc.source_path)
shutil.copy(archive_file, doc.archive_path) shutil.copy(archive_file, doc.archive_path)
@@ -2566,7 +2615,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
mime_type="application/pdf", mime_type="application/pdf",
) )
shutil.copy(SIMPLE_DIGITAL_PDF, doc.source_path) shutil.copy(Path(__file__).parent / "samples" / "simple.pdf", doc.source_path)
response = self.client.get(f"/api/documents/{doc.pk}/metadata/") response = self.client.get(f"/api/documents/{doc.pk}/metadata/")
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -4332,8 +4381,8 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
filename="test2.pdf", filename="test2.pdf",
) )
archive_file = SIMPLE_DIGITAL_PDF archive_file = Path(__file__).parent / "samples" / "simple.pdf"
source_file = SIMPLE_DIGITAL_PDF source_file = Path(__file__).parent / "samples" / "simple.pdf"
shutil.copy(archive_file, doc.archive_path) shutil.copy(archive_file, doc.archive_path)
shutil.copy(source_file, doc2.source_path) shutil.copy(source_file, doc2.source_path)
+5 -8
View File
@@ -12,9 +12,6 @@ from documents.tests.utils import SampleDirMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import UserFactory from paperless_testing.factories import UserFactory
from paperless_testing.permissions import grant_global from paperless_testing.permissions import grant_global
from paperless_testing.samples import MULTI_PAGE_DIGITAL_PDF
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
from paperless_testing.samples import WITH_FORM_PDF
class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase): class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
@@ -45,15 +42,15 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
# Copy sample files to document paths (using different files to distinguish versions) # Copy sample files to document paths (using different files to distinguish versions)
shutil.copy( shutil.copy(
SIMPLE_DIGITAL_PDF, self.SAMPLE_DIR / "documents" / "originals" / "0000001.pdf",
self.doc1.archive_path, self.doc1.archive_path,
) )
shutil.copy( shutil.copy(
MULTI_PAGE_DIGITAL_PDF, self.SAMPLE_DIR / "documents" / "originals" / "0000002.pdf",
self.doc1.source_path, self.doc1.source_path,
) )
shutil.copy( shutil.copy(
WITH_FORM_PDF, self.SAMPLE_DIR / "documents" / "originals" / "0000003.pdf",
self.doc2.source_path, self.doc2.source_path,
) )
@@ -380,7 +377,7 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
checksum="3", checksum="3",
filename="test3.pdf", filename="test3.pdf",
) )
shutil.copy(self.SIMPLE_PDF, doc3.source_path) shutil.copy(self.SAMPLE_DIR / "simple.pdf", doc3.source_path)
doc4 = Document.objects.create( doc4 = Document.objects.create(
title="test1", title="test1",
@@ -389,7 +386,7 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
checksum="4", checksum="4",
filename="test4.pdf", filename="test4.pdf",
) )
shutil.copy(self.SIMPLE_PDF, doc4.source_path) shutil.copy(self.SAMPLE_DIR / "simple.pdf", doc4.source_path)
response = self.client.post( response = self.client.post(
self.ENDPOINT, self.ENDPOINT,
+2 -2
View File
@@ -169,7 +169,7 @@ class TestBarcode(
- No barcodes detected - No barcodes detected
- No pages to split on - No pages to split on
""" """
test_file = self.SIMPLE_PDF test_file = self.SAMPLE_DIR / "simple.pdf"
with self.get_reader(test_file) as reader: with self.get_reader(test_file) as reader:
reader.detect() reader.detect()
separator_page_numbers = reader.get_separation_pages() separator_page_numbers = reader.get_separation_pages()
@@ -461,7 +461,7 @@ class TestBarcode(
THEN: THEN:
- Nothing happens - Nothing happens
""" """
test_file = self.SIMPLE_PDF test_file = self.SAMPLE_DIR / "simple.pdf"
with self.get_reader(test_file) as reader: with self.get_reader(test_file) as reader:
try: try:
+30 -9
View File
@@ -29,9 +29,6 @@ from documents.permissions import set_permissions_for_objects
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory from paperless_testing.factories import DocumentFactory
from paperless_testing.permissions import grant_object from paperless_testing.permissions import grant_object
from paperless_testing.samples import MULTI_PAGE_DIGITAL_PDF
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
from paperless_testing.samples import WITH_FORM_PDF
class TestBulkEdit(DirectoriesMixin, TestCase): class TestBulkEdit(DirectoriesMixin, TestCase):
@@ -709,27 +706,47 @@ class TestPDFActions(DirectoriesMixin, TestCase):
super().setUp() super().setUp()
sample1 = self.dirs.scratch_dir / "sample.pdf" sample1 = self.dirs.scratch_dir / "sample.pdf"
shutil.copy( shutil.copy(
SIMPLE_DIGITAL_PDF, Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1, sample1,
) )
sample1_archive = self.dirs.archive_dir / "sample_archive.pdf" sample1_archive = self.dirs.archive_dir / "sample_archive.pdf"
shutil.copy( shutil.copy(
SIMPLE_DIGITAL_PDF, Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1_archive, sample1_archive,
) )
sample2 = self.dirs.scratch_dir / "sample2.pdf" sample2 = self.dirs.scratch_dir / "sample2.pdf"
shutil.copy( shutil.copy(
MULTI_PAGE_DIGITAL_PDF, Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000002.pdf",
sample2, sample2,
) )
sample2_archive = self.dirs.archive_dir / "sample2_archive.pdf" sample2_archive = self.dirs.archive_dir / "sample2_archive.pdf"
shutil.copy( shutil.copy(
MULTI_PAGE_DIGITAL_PDF, Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000002.pdf",
sample2_archive, sample2_archive,
) )
sample3 = self.dirs.scratch_dir / "sample3.pdf" sample3 = self.dirs.scratch_dir / "sample3.pdf"
shutil.copy( shutil.copy(
WITH_FORM_PDF, Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000003.pdf",
sample3, sample3,
) )
self.doc1 = Document.objects.create( self.doc1 = Document.objects.create(
@@ -763,7 +780,11 @@ class TestPDFActions(DirectoriesMixin, TestCase):
) )
img_doc_archive = self.dirs.archive_dir / "sample_image.pdf" img_doc_archive = self.dirs.archive_dir / "sample_image.pdf"
shutil.copy( shutil.copy(
SIMPLE_DIGITAL_PDF, Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
img_doc_archive, img_doc_archive,
) )
self.img_doc = Document.objects.create( self.img_doc = Document.objects.create(
+28 -9
View File
@@ -39,9 +39,6 @@ from paperless_testing.factories import TagFactory
from paperless_testing.factories import UserFactory from paperless_testing.factories import UserFactory
from paperless_testing.fakes.progress import FakeProgressManager from paperless_testing.fakes.progress import FakeProgressManager
from paperless_testing.permissions import grant_object from paperless_testing.permissions import grant_object
from paperless_testing.samples import MULTI_PAGE_DIGITAL_PDF
from paperless_testing.samples import MULTI_PAGE_IMAGES_PDF
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
class _BaseNewStyleParser: class _BaseNewStyleParser:
@@ -118,7 +115,9 @@ class _BaseNewStyleParser:
class DummyParser(_BaseNewStyleParser): class DummyParser(_BaseNewStyleParser):
_ARCHIVE_SRC = MULTI_PAGE_IMAGES_PDF _ARCHIVE_SRC = (
Path(__file__).parent / "samples" / "documents" / "archive" / "0000001.pdf"
)
def parse(self, document_path, mime_type, *, produce_archive: bool = True) -> None: def parse(self, document_path, mime_type, *, produce_archive: bool = True) -> None:
self._text = "The Text" self._text = "The Text"
@@ -204,19 +203,33 @@ class TestConsumer(
self.addCleanup(patcher.stop) self.addCleanup(patcher.stop)
def get_test_file(self): def get_test_file(self):
src = SIMPLE_DIGITAL_PDF src = (
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf"
)
dst = self.dirs.scratch_dir / "sample.pdf" dst = self.dirs.scratch_dir / "sample.pdf"
shutil.copy(src, dst) shutil.copy(src, dst)
return dst return dst
def get_test_file2(self): def get_test_file2(self):
src = MULTI_PAGE_DIGITAL_PDF src = (
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000002.pdf"
)
dst = self.dirs.scratch_dir / "sample2.pdf" dst = self.dirs.scratch_dir / "sample2.pdf"
shutil.copy(src, dst) shutil.copy(src, dst)
return dst return dst
def get_test_archive_file(self): def get_test_archive_file(self):
src = MULTI_PAGE_IMAGES_PDF src = (
Path(__file__).parent / "samples" / "documents" / "archive" / "0000001.pdf"
)
dst = self.dirs.scratch_dir / "sample_archive.pdf" dst = self.dirs.scratch_dir / "sample_archive.pdf"
shutil.copy(src, dst) shutil.copy(src, dst)
return dst return dst
@@ -1111,7 +1124,7 @@ class TestConsumer(
@mock.patch("documents.consumer.get_parser_registry") @mock.patch("documents.consumer.get_parser_registry")
def test_similar_filenames(self, m) -> None: def test_similar_filenames(self, m) -> None:
shutil.copy( shutil.copy(
SIMPLE_DIGITAL_PDF, Path(__file__).parent / "samples" / "simple.pdf",
settings.CONSUMPTION_DIR / "simple.pdf", settings.CONSUMPTION_DIR / "simple.pdf",
) )
shutil.copy( shutil.copy(
@@ -1652,7 +1665,13 @@ class TestConsumerRemoteOCR(
self.addCleanup(patcher.stop) self.addCleanup(patcher.stop)
def _consume(self, *, overrides: DocumentMetadataOverrides | None = None) -> bool: def _consume(self, *, overrides: DocumentMetadataOverrides | None = None) -> bool:
src = SIMPLE_DIGITAL_PDF src = (
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf"
)
dst = self.dirs.scratch_dir / "sample.pdf" dst = self.dirs.scratch_dir / "sample.pdf"
shutil.copy(src, dst) shutil.copy(src, dst)
+2 -5
View File
@@ -37,15 +37,12 @@ class TestDoubleSided(
self.double_sided_dir.mkdir() self.double_sided_dir.mkdir()
self.staging_file = self.dirs.scratch_dir / STAGING_FILE_NAME self.staging_file = self.dirs.scratch_dir / STAGING_FILE_NAME
def _sample(self, name: str) -> Path:
return self.SIMPLE_PDF if name == "simple.pdf" else self.SAMPLE_DIR / name
def consume_file(self, srcname, dstname: str | Path = "foo.pdf"): def consume_file(self, srcname, dstname: str | Path = "foo.pdf"):
""" """
Starts the consume process and also ensures the Starts the consume process and also ensures the
destination file does not exist afterwards destination file does not exist afterwards
""" """
src = self._sample(srcname) src = self.SAMPLE_DIR / srcname
dst = self.double_sided_dir / dstname dst = self.double_sided_dir / dstname
dst.parent.mkdir(parents=True, exist_ok=True) dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(src, dst) shutil.copy(src, dst)
@@ -60,7 +57,7 @@ class TestDoubleSided(
return msg return msg
def create_staging_file(self, src="double-sided-odd.pdf", datetime=None) -> None: def create_staging_file(self, src="double-sided-odd.pdf", datetime=None) -> None:
shutil.copy(self._sample(src), self.staging_file) shutil.copy(self.SAMPLE_DIR / src, self.staging_file)
if datetime is None: if datetime is None:
datetime = dt.datetime.now() datetime = dt.datetime.now()
os.utime(str(self.staging_file), (datetime.timestamp(),) * 2) os.utime(str(self.staging_file), (datetime.timestamp(),) * 2)
+1 -2
View File
@@ -23,9 +23,8 @@ from documents.tasks import update_document_content_maybe_archive_file
from paperless_testing.assertions import FileSystemAssertsMixin from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory from paperless_testing.factories import DocumentFactory
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
sample_file: Path = SIMPLE_DIGITAL_PDF sample_file: Path = Path(__file__).parent / "samples" / "simple.pdf"
@pytest.mark.management @pytest.mark.management
+80 -21
View File
@@ -59,7 +59,6 @@ from paperless_testing.dirs import paperless_environment
from paperless_testing.factories import DocumentBarcodeFactory from paperless_testing.factories import DocumentBarcodeFactory
from paperless_testing.factories import DocumentFactory from paperless_testing.factories import DocumentFactory
from paperless_testing.permissions import grant_object from paperless_testing.permissions import grant_object
from paperless_testing.samples import install_document_samples
@pytest.mark.management @pytest.mark.management
@@ -205,7 +204,10 @@ class TestExportImport(
def test_exporter(self, *, use_filename_format=False) -> None: def test_exporter(self, *, use_filename_format=False) -> None:
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
num_permission_objects = Permission.objects.count() num_permission_objects = Permission.objects.count()
@@ -309,7 +311,10 @@ class TestExportImport(
def test_exporter_with_filename_format(self) -> None: def test_exporter_with_filename_format(self) -> None:
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
with override_settings( with override_settings(
FILENAME_FORMAT="{created_year}/{correspondent}/{title}", FILENAME_FORMAT="{created_year}/{correspondent}/{title}",
@@ -318,7 +323,10 @@ class TestExportImport(
def test_exporter_includes_share_links_and_bundles(self) -> None: def test_exporter_includes_share_links_and_bundles(self) -> None:
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
share_link = ShareLink.objects.create( share_link = ShareLink.objects.create(
slug="share-link-slug", slug="share-link-slug",
@@ -417,7 +425,10 @@ class TestExportImport(
def test_update_export_changed_time(self) -> None: def test_update_export_changed_time(self) -> None:
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
self._do_export() self._do_export()
self.assertIsFile(self.target / "manifest.json") self.assertIsFile(self.target / "manifest.json")
@@ -453,7 +464,10 @@ class TestExportImport(
def test_update_export_changed_checksum(self) -> None: def test_update_export_changed_checksum(self) -> None:
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
self._do_export() self._do_export()
@@ -480,7 +494,10 @@ class TestExportImport(
def test_update_export_deleted_document(self) -> None: def test_update_export_deleted_document(self) -> None:
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
manifest = self._do_export() manifest = self._do_export()
@@ -512,7 +529,10 @@ class TestExportImport(
@override_settings(FILENAME_FORMAT="{title}/{correspondent}") @override_settings(FILENAME_FORMAT="{title}/{correspondent}")
def test_update_export_changed_location(self) -> None: def test_update_export_changed_location(self) -> None:
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
self._do_export(use_filename_format=True) self._do_export(use_filename_format=True)
self.assertIsFile(self.target / "wow1" / "c.pdf") self.assertIsFile(self.target / "wow1" / "c.pdf")
@@ -554,7 +574,10 @@ class TestExportImport(
- Zipfile contains exported files - Zipfile contains exported files
""" """
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
args = ["document_exporter", self.target, "--zip"] args = ["document_exporter", self.target, "--zip"]
@@ -583,7 +606,10 @@ class TestExportImport(
- Zipfile contains exported files - Zipfile contains exported files
""" """
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
args = ["document_exporter", self.target, "--zip", "--use-filename-format"] args = ["document_exporter", self.target, "--zip", "--use-filename-format"]
@@ -619,7 +645,10 @@ class TestExportImport(
- The existing file and directory in target are removed - The existing file and directory in target are removed
""" """
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
# Create stuff in target directory # Create stuff in target directory
existing_file = self.target / "test.txt" existing_file = self.target / "test.txt"
@@ -715,7 +744,10 @@ class TestExportImport(
- Documents can be imported again - Documents can be imported again
""" """
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
manifest = self._do_export() manifest = self._do_export()
has_archive = False has_archive = False
@@ -758,7 +790,10 @@ class TestExportImport(
- Documents can be imported again - Documents can be imported again
""" """
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
manifest = self._do_export() manifest = self._do_export()
has_thumbnail = False has_thumbnail = False
@@ -803,7 +838,10 @@ class TestExportImport(
- Documents can be imported again - Documents can be imported again
""" """
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
manifest = self._do_export(split_manifest=True) manifest = self._do_export(split_manifest=True)
has_document = False has_document = False
@@ -828,7 +866,10 @@ class TestExportImport(
def _export_import_barcodes(self, *, split_manifest: bool) -> None: def _export_import_barcodes(self, *, split_manifest: bool) -> None:
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
DocumentBarcodeFactory(document=self.d1, value="https://example.com") DocumentBarcodeFactory(document=self.d1, value="https://example.com")
DocumentBarcodeFactory(document=self.d2, page=2, value="DE8937") DocumentBarcodeFactory(document=self.d2, page=2, value="DE8937")
@@ -864,7 +905,10 @@ class TestExportImport(
- Documents can be imported again - Documents can be imported again
""" """
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(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) self._do_export(use_folder_prefix=True)
@@ -891,7 +935,10 @@ class TestExportImport(
- Documents can be imported again - Documents can be imported again
""" """
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(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) self._do_export(use_folder_prefix=True, split_manifest=True)
@@ -918,7 +965,10 @@ class TestExportImport(
""" """
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
num_content_type_objects = ContentType.objects.count() num_content_type_objects = ContentType.objects.count()
num_permission_objects = Permission.objects.count() num_permission_objects = Permission.objects.count()
@@ -955,7 +1005,10 @@ class TestExportImport(
def test_exporter_with_auditlog_disabled(self) -> None: def test_exporter_with_auditlog_disabled(self) -> None:
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
with override_settings( with override_settings(
AUDIT_LOG_ENABLED=False, AUDIT_LOG_ENABLED=False,
@@ -975,7 +1028,10 @@ class TestExportImport(
survive the round-trip with deleted_at preserved survive the round-trip with deleted_at preserved
""" """
shutil.rmtree(Path(self.dirs.media_dir) / "documents") shutil.rmtree(Path(self.dirs.media_dir) / "documents")
install_document_samples(Path(self.dirs.media_dir) / "documents") shutil.copytree(
Path(__file__).parent / "samples" / "documents",
Path(self.dirs.media_dir) / "documents",
)
# d1 has self.note and self.cfi1 attached via setUp # d1 has self.note and self.cfi1 attached via setUp
self.d1.delete() self.d1.delete()
@@ -1019,7 +1075,10 @@ class TestExportImport(
""" """
shutil.rmtree(self.dirs.media_dir / "documents") shutil.rmtree(self.dirs.media_dir / "documents")
install_document_samples(self.dirs.media_dir / "documents") shutil.copytree(
self.SAMPLE_DIR / "documents",
self.dirs.media_dir / "documents",
)
_ = self._do_export(data_only=True) _ = self._do_export(data_only=True)
@@ -11,7 +11,6 @@ from documents.models import Document
from documents.parsers import get_default_thumbnail from documents.parsers import get_default_thumbnail
from paperless_testing.assertions import FileSystemAssertsMixin from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
@pytest.mark.management @pytest.mark.management
@@ -25,7 +24,7 @@ class TestMakeThumbnails(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
filename="test.pdf", filename="test.pdf",
) )
shutil.copy( shutil.copy(
SIMPLE_DIGITAL_PDF, Path(__file__).parent / "samples" / "simple.pdf",
self.d1.source_path, self.d1.source_path,
) )
@@ -37,7 +36,7 @@ class TestMakeThumbnails(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
filename="test2.pdf", filename="test2.pdf",
) )
shutil.copy( shutil.copy(
SIMPLE_DIGITAL_PDF, Path(__file__).parent / "samples" / "simple.pdf",
self.d2.source_path, self.d2.source_path,
) )
+104
View File
@@ -0,0 +1,104 @@
from django.core.exceptions import FieldDoesNotExist
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from documents.management.commands.document_importer import _deserialize_record
from documents.models import Tag
from documents.tag_hierarchy import TagHierarchy
from paperless_testing.migrations import TestMigrations
class TestTagAdjacency(TestCase):
def test_held_instance_uses_current_parent_edges(self):
first = Tag.objects.create(name="First")
second = Tag.objects.create(name="Second")
child = Tag.objects.create(name="Child", tn_parent=first)
Tag.objects.filter(pk=child.pk).update(tn_parent=second)
assert first.get_descendants_pks() == set()
assert second.get_descendants_pks() == {child.pk}
def test_queryset_delete_preserves_cascade(self):
root = Tag.objects.create(name="Root")
child = Tag.objects.create(name="Child", tn_parent=root)
Tag.objects.create(name="Grandchild", tn_parent=child)
survivor = Tag.objects.create(name="Other root")
Tag.objects.filter(pk=root.pk).delete()
assert list(Tag.objects.values_list("pk", flat=True)) == [survivor.pk]
def test_flat_writes_do_not_update_other_tags(self):
Tag.objects.bulk_create([Tag(name=f"Tag {i}") for i in range(100)])
with CaptureQueriesContext(connection) as queries:
new = Tag.objects.create(name="New")
assert not any(q["sql"].startswith("UPDATE") for q in queries)
with CaptureQueriesContext(connection) as queries:
new.name = "Renamed"
new.save()
tag_updates = [
q["sql"] for q in queries if q["sql"].startswith('UPDATE "documents_tag"')
]
assert len(tag_updates) == 1
assert 'WHERE "documents_tag"."id" =' in tag_updates[0]
def test_batch_snapshot_is_one_query_and_includes_overlapping_roots(self):
root = Tag.objects.create(name="Root")
child = Tag.objects.create(name="Child", tn_parent=root)
leaf = Tag.objects.create(name="Leaf", tn_parent=child)
with self.assertNumQueries(1):
hierarchy = Tag.get_hierarchy()
assert hierarchy.with_ancestors([leaf.pk]) == {root.pk, child.pk, leaf.pk}
assert hierarchy.with_descendants([root.pk, child.pk]) == {
root.pk,
child.pk,
leaf.pk,
}
assert hierarchy.height(None) == 0
def test_legacy_export_fields_are_ignored_but_parent_is_preserved(self):
model, tag, m2m = _deserialize_record(
{
"model": "documents.tag",
"pk": 2,
"fields": {
"name": "Child",
"tn_parent": 1,
"tn_order": 3,
"tn_siblings_pks": "4,5",
"tn_descendants_pks": "6",
},
},
)
assert model is Tag
assert tag.tn_parent_id == 1
assert tag.name == "Child"
assert not m2m
with self.assertRaises(FieldDoesNotExist):
_deserialize_record(
{"model": "documents.tag", "pk": 3, "fields": {"tn_typo": "bad"}},
)
def test_malformed_cycles_terminate(self):
hierarchy = TagHierarchy([(1, 2), (2, 1)])
assert hierarchy.with_descendants([1]) == {1, 2}
assert set(hierarchy.ancestors(2)) == {1, 2}
class TestTagAdjacencyMigration(TestMigrations):
migrate_from = "0027_documentbarcode"
migrate_to = "0028_replace_tag_tree_fields"
def setUpBeforeMigration(self, apps):
tag = apps.get_model("documents", "Tag")
root = tag.objects.create(name="Root")
child = tag.objects.create(name="Child", tn_parent=root)
self.edges = [(root.pk, None), (child.pk, root.pk)]
def test_parent_edges_survive(self):
tag = self.apps.get_model("documents", "Tag")
assert (
list(tag.objects.order_by("pk").values_list("pk", "tn_parent_id"))
== self.edges
)
assert {
field.name for field in tag._meta.fields if field.name.startswith("tn_")
} == {"tn_parent"}
+30
View File
@@ -429,6 +429,36 @@ class TestTaskRevokedHandler:
assert task.status == PaperlessTask.Status.REVOKED assert task.status == PaperlessTask.Status.REVOKED
assert task.date_done is not None assert task.date_done is not None
@pytest.mark.parametrize(
("expired", "terminated", "reason"),
[
(True, False, "Task expired before execution."),
(False, True, "Task terminated during execution."),
(False, False, "Task revoked before completion."),
],
)
def test_records_revocation_details(
self,
mocker,
expired,
terminated,
reason,
) -> None:
task = PaperlessTaskFactory(status=PaperlessTask.Status.PENDING)
request = mocker.MagicMock()
request.id = task.task_id
task_revoked_handler(
request=request,
expired=expired,
terminated=terminated,
)
task.refresh_from_db()
assert task.result_data == {
"reason": f"{reason} Check worker logs for details.",
}
def test_ignores_none_request(self) -> None: def test_ignores_none_request(self) -> None:
"""task_revoked_handler must not raise when request is None.""" """task_revoked_handler must not raise when request is None."""
+23 -5
View File
@@ -21,7 +21,6 @@ from documents.tests.helpers import dummy_preprocess
from paperless_testing.assertions import FileSystemAssertsMixin from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory from paperless_testing.factories import DocumentFactory
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
@pytest.mark.django_db @pytest.mark.django_db
@@ -230,12 +229,20 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
""" """
sample1 = self.dirs.scratch_dir / "sample.pdf" sample1 = self.dirs.scratch_dir / "sample.pdf"
shutil.copy( shutil.copy(
SIMPLE_DIGITAL_PDF, Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1, sample1,
) )
sample1_archive = self.dirs.archive_dir / "sample_archive.pdf" sample1_archive = self.dirs.archive_dir / "sample_archive.pdf"
shutil.copy( shutil.copy(
SIMPLE_DIGITAL_PDF, Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1_archive, sample1_archive,
) )
doc = Document.objects.create( doc = Document.objects.create(
@@ -263,7 +270,11 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
""" """
sample1 = self.dirs.scratch_dir / "sample.pdf" sample1 = self.dirs.scratch_dir / "sample.pdf"
shutil.copy( shutil.copy(
SIMPLE_DIGITAL_PDF, Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1, sample1,
) )
doc = Document.objects.create( doc = Document.objects.create(
@@ -279,7 +290,14 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
def _create_root_with_version(self) -> tuple[Document, Document]: def _create_root_with_version(self) -> tuple[Document, Document]:
sample1 = self.dirs.scratch_dir / "sample.pdf" sample1 = self.dirs.scratch_dir / "sample.pdf"
shutil.copy(SIMPLE_DIGITAL_PDF, sample1) shutil.copy(
Path(__file__).parent
/ "samples"
/ "documents"
/ "originals"
/ "0000001.pdf",
sample1,
)
root = DocumentFactory(content="root content", mime_type="application/pdf") root = DocumentFactory(content="root content", mime_type="application/pdf")
version = DocumentFactory( version = DocumentFactory(
content="my document", content="my document",
+27 -27
View File
@@ -178,7 +178,7 @@ class TestWorkflows(
self.assertEqual(action.__str__(), "WorkflowAction 1") self.assertEqual(action.__str__(), "WorkflowAction 1")
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -290,7 +290,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -409,7 +409,7 @@ class TestWorkflows(
w2.save() w2.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -478,7 +478,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -531,7 +531,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -608,7 +608,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -687,7 +687,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -765,7 +765,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -876,7 +876,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -928,7 +928,7 @@ class TestWorkflows(
generated = generate_unique_filename(doc) generated = generate_unique_filename(doc)
destination = (settings.ORIGINALS_DIR / generated).resolve() destination = (settings.ORIGINALS_DIR / generated).resolve()
create_source_path_directory(destination) create_source_path_directory(destination)
shutil.copy(self.SIMPLE_PDF, destination) shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix()) Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
doc.refresh_from_db() doc.refresh_from_db()
@@ -2023,7 +2023,7 @@ class TestWorkflows(
superuser = UserFactory(username="superuser", superuser=True) superuser = UserFactory(username="superuser", superuser=True)
self.client.force_authenticate(user=superuser) self.client.force_authenticate(user=superuser)
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
tasks.consume_file( tasks.consume_file(
@@ -3009,7 +3009,7 @@ class TestWorkflows(
generated = generate_unique_filename(doc) generated = generate_unique_filename(doc)
destination = (settings.ORIGINALS_DIR / generated).resolve() destination = (settings.ORIGINALS_DIR / generated).resolve()
create_source_path_directory(destination) create_source_path_directory(destination)
shutil.copy(self.SIMPLE_PDF, destination) shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix()) Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
doc.refresh_from_db() doc.refresh_from_db()
doc.tags.set([self.t1, self.t2]) doc.tags.set([self.t1, self.t2])
@@ -3073,7 +3073,7 @@ class TestWorkflows(
generated = generate_unique_filename(doc) generated = generate_unique_filename(doc)
destination = (settings.ORIGINALS_DIR / generated).resolve() destination = (settings.ORIGINALS_DIR / generated).resolve()
create_source_path_directory(destination) create_source_path_directory(destination)
shutil.copy(self.SIMPLE_PDF, destination) shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix()) Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
doc.refresh_from_db() doc.refresh_from_db()
doc.tags.set([self.t1]) doc.tags.set([self.t1])
@@ -3222,7 +3222,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -3346,7 +3346,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -3496,7 +3496,7 @@ class TestWorkflows(
workflow.actions.set([assignment_action, email_action]) workflow.actions.set([assignment_action, email_action])
temp_working_copy = shutil.copy( temp_working_copy = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "working-copy.pdf", self.dirs.scratch_dir / "working-copy.pdf",
) )
@@ -3596,7 +3596,7 @@ class TestWorkflows(
# move the file # move the file
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -3704,7 +3704,7 @@ class TestWorkflows(
generated = generate_unique_filename(doc) generated = generate_unique_filename(doc)
destination = (settings.ORIGINALS_DIR / generated).resolve() destination = (settings.ORIGINALS_DIR / generated).resolve()
create_source_path_directory(destination) create_source_path_directory(destination)
shutil.copy(self.SIMPLE_PDF, destination) shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix()) Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc) run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
@@ -3915,7 +3915,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -4036,7 +4036,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -4387,7 +4387,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -4835,7 +4835,7 @@ class TestWorkflows(
w.save() w.save()
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
@@ -5027,7 +5027,7 @@ class TestWorkflows(
# Create a test file to be consumed # Create a test file to be consumed
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
test_file_path = Path(test_file) test_file_path = Path(test_file)
@@ -5091,7 +5091,7 @@ class TestWorkflows(
# Create a test file to be consumed # Create a test file to be consumed
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple2.pdf", self.dirs.scratch_dir / "simple2.pdf",
) )
test_file_path = Path(test_file) test_file_path = Path(test_file)
@@ -5529,7 +5529,7 @@ class TestDateWorkflowLocalization(
) )
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
tmp_path / "simple.pdf", tmp_path / "simple.pdf",
) )
@@ -5596,7 +5596,7 @@ class TestRemoteOCRWorkflowAction(DirectoriesMixin, SampleDirMixin, APITestCase)
self._make_workflow(WorkflowTrigger.WorkflowTriggerType.CONSUMPTION) self._make_workflow(WorkflowTrigger.WorkflowTriggerType.CONSUMPTION)
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
overrides = DocumentMetadataOverrides() overrides = DocumentMetadataOverrides()
@@ -5814,7 +5814,7 @@ class TestApplyAISuggestionsWorkflowAction(
) )
test_file = shutil.copy( test_file = shutil.copy(
self.SIMPLE_PDF, self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf", self.dirs.scratch_dir / "simple.pdf",
) )
-3
View File
@@ -11,7 +11,6 @@ from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource from documents.data_models import DocumentSource
from paperless_testing.fakes.progress import FakeProgressManager from paperless_testing.fakes.progress import FakeProgressManager
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
class ConsumeTaskMixin: class ConsumeTaskMixin:
@@ -54,8 +53,6 @@ class SampleDirMixin:
BARCODE_SAMPLE_DIR = SAMPLE_DIR / "barcodes" BARCODE_SAMPLE_DIR = SAMPLE_DIR / "barcodes"
SIMPLE_PDF = SIMPLE_DIGITAL_PDF
class GetConsumerMixin: class GetConsumerMixin:
@contextmanager @contextmanager
+10 -9
View File
@@ -627,7 +627,7 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
queryset = queryset.order_by(*ordering) queryset = queryset.order_by(*ordering)
all_tags = list(queryset) all_tags = list(queryset)
descendant_pks = {pk for tag in all_tags for pk in tag.get_descendants_pks()} descendant_pks = Tag.get_hierarchy().descendants(t.pk for t in all_tags)
if descendant_pks: if descendant_pks:
user = getattr(getattr(self, "request", None), "user", None) user = getattr(getattr(self, "request", None), "user", None)
@@ -1304,13 +1304,19 @@ class DocumentViewSet(
from documents.search import get_backend from documents.search import get_backend
get_backend().add_or_update(refreshed_doc)
document_updated.send( document_updated.send(
sender=self.__class__, sender=self.__class__,
document=refreshed_doc, document=refreshed_doc,
) )
# Index the final workflow state, including deletion.
backend = get_backend()
final_doc = Document.objects.filter(pk=root_doc.pk).first()
if final_doc is None:
backend.remove(root_doc.pk)
else:
backend.add_or_update(final_doc)
return response return response
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
@@ -5054,12 +5060,7 @@ class BulkEditObjectsView(PassUserMixin):
if object_type == "tags": if object_type == "tags":
editable_ids = set(user_permitted_objects.values_list("pk", flat=True)) editable_ids = set(user_permitted_objects.values_list("pk", flat=True))
all_ids = set(objs.values_list("pk", flat=True)) all_ids = set(objs.values_list("pk", flat=True))
for tag in objs: all_ids.update(Tag.get_hierarchy().descendants(all_ids) & editable_ids)
all_ids.update(
descendant.pk
for descendant in tag.get_descendants()
if descendant.pk in editable_ids
)
objs = object_class.objects.filter(pk__in=all_ids) objs = object_class.objects.filter(pk__in=all_ids)
objs = objs.select_related("owner") objs = objs.select_related("owner")
object_ids = list(objs.values_list("pk", flat=True)) object_ids = list(objs.values_list("pk", flat=True))
+10 -12
View File
@@ -6,6 +6,7 @@ from guardian.shortcuts import remove_perm
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import Tag
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.permissions import PermissionsSpec from documents.permissions import PermissionsSpec
from documents.permissions import set_permissions_for_objects from documents.permissions import set_permissions_for_objects
@@ -131,10 +132,9 @@ def apply_assignment_to_overrides(
if action.has_assign_tags: if action.has_assign_tags:
if overrides.tag_ids is None: if overrides.tag_ids is None:
overrides.tag_ids = [] overrides.tag_ids = []
tag_ids_to_add: set[int] = set() tag_ids_to_add = Tag.get_hierarchy().with_ancestors(
for tag in action.assign_tags.all(): tag.pk for tag in action.assign_tags.all()
tag_ids_to_add.add(tag.pk) )
tag_ids_to_add.update(int(pk) for pk in tag.get_ancestors_pks())
overrides.tag_ids = list(set(overrides.tag_ids) | tag_ids_to_add) overrides.tag_ids = list(set(overrides.tag_ids) | tag_ids_to_add)
@@ -213,10 +213,9 @@ def apply_removal_to_document(
if action.remove_all_tags: if action.remove_all_tags:
Document.objects.get(pk=document.pk).tags.clear() Document.objects.get(pk=document.pk).tags.clear()
else: else:
tag_ids_to_remove: set[int] = set() tag_ids_to_remove = Tag.get_hierarchy().with_descendants(
for tag in action.remove_tags.all(): tag.pk for tag in action.remove_tags.all()
tag_ids_to_remove.add(tag.pk) )
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
if tag_ids_to_remove: if tag_ids_to_remove:
Document.objects.get(pk=document.pk).tags.remove(*tag_ids_to_remove) Document.objects.get(pk=document.pk).tags.remove(*tag_ids_to_remove)
@@ -294,10 +293,9 @@ def apply_removal_to_overrides(
if action.remove_all_tags: if action.remove_all_tags:
overrides.tag_ids = None overrides.tag_ids = None
elif overrides.tag_ids: elif overrides.tag_ids:
tag_ids_to_remove: set[int] = set() tag_ids_to_remove = Tag.get_hierarchy().with_descendants(
for tag in action.remove_tags.all(): tag.pk for tag in action.remove_tags.all()
tag_ids_to_remove.add(tag.pk) )
tag_ids_to_remove.update(int(pk) for pk in tag.get_descendants_pks())
overrides.tag_ids = [t for t in overrides.tag_ids if t not in tag_ids_to_remove] overrides.tag_ids = [t for t in overrides.tag_ids if t not in tag_ids_to_remove]
+11 -11
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-24 21:11+0000\n" "POT-Creation-Date: 2026-10-28 02:55+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n" "PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
@@ -1653,7 +1653,7 @@ msgid "workflow runs"
msgstr "" msgstr ""
#: documents/serialisers.py:483 documents/serialisers.py:840 #: documents/serialisers.py:483 documents/serialisers.py:840
#: documents/serialisers.py:2872 documents/views.py:360 documents/views.py:2724 #: documents/serialisers.py:2872 documents/views.py:360 documents/views.py:2730
#: paperless_mail/serialisers.py:156 #: paperless_mail/serialisers.py:156
msgid "Insufficient permissions." msgid "Insufficient permissions."
msgstr "" msgstr ""
@@ -1694,7 +1694,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed." msgid "Duplicate document identifiers are not allowed."
msgstr "" msgstr ""
#: documents/serialisers.py:2958 documents/views.py:4774 #: documents/serialisers.py:2958 documents/views.py:4780
#, python-format #, python-format
msgid "Documents not found: %(ids)s" msgid "Documents not found: %(ids)s"
msgstr "" msgstr ""
@@ -1949,36 +1949,36 @@ msgstr ""
msgid "Specify only one of text, title_search, query, or more_like_id." msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr "" msgstr ""
#: documents/views.py:353 documents/views.py:2721 #: documents/views.py:353 documents/views.py:2727
msgid "Invalid more_like_id" msgid "Invalid more_like_id"
msgstr "" msgstr ""
#: documents/views.py:1659 #: documents/views.py:1665
msgid "Invalid AI configuration." msgid "Invalid AI configuration."
msgstr "" msgstr ""
#: documents/views.py:1670 #: documents/views.py:1676
msgid "AI backend request timed out." msgid "AI backend request timed out."
msgstr "" msgstr ""
#: documents/views.py:1682 #: documents/views.py:1688
msgid "AI backend rejected the request. Check logs for details." msgid "AI backend rejected the request. Check logs for details."
msgstr "" msgstr ""
#: documents/views.py:4790 #: documents/views.py:4796
#, python-format #, python-format
msgid "Insufficient permissions to share document %(id)s." msgid "Insufficient permissions to share document %(id)s."
msgstr "" msgstr ""
#: documents/views.py:4837 #: documents/views.py:4843
msgid "Bundle is already being processed." msgid "Bundle is already being processed."
msgstr "" msgstr ""
#: documents/views.py:4903 #: documents/views.py:4909
msgid "The share link bundle is still being prepared. Please try again later." msgid "The share link bundle is still being prepared. Please try again later."
msgstr "" msgstr ""
#: documents/views.py:4917 #: documents/views.py:4923
msgid "The share link bundle is unavailable." msgid "The share link bundle is unavailable."
msgstr "" msgstr ""
-1
View File
@@ -157,7 +157,6 @@ INSTALLED_APPS = [
"allauth.headless", "allauth.headless",
"drf_spectacular", "drf_spectacular",
"drf_spectacular_sidecar", "drf_spectacular_sidecar",
"treenode",
*env_apps, *env_apps,
] ]
+24 -12
View File
@@ -432,6 +432,30 @@ def tesseract_samples_dir(parser_samples_dir: Path) -> Path:
return parser_samples_dir / "tesseract" return parser_samples_dir / "tesseract"
@pytest.fixture(scope="session")
def multi_page_images_pdf_file(tesseract_samples_dir: Path) -> Path:
"""Path to a multi-page PDF with images.
Returns
-------
Path
Absolute path to ``tesseract/multi-page-images.pdf``.
"""
return tesseract_samples_dir / "multi-page-images.pdf"
@pytest.fixture(scope="session")
def simple_digital_pdf_file(tesseract_samples_dir: Path) -> Path:
"""Path to a simple digital PDF sample file.
Returns
-------
Path
Absolute path to ``tesseract/simple-digital.pdf``.
"""
return tesseract_samples_dir / "simple-digital.pdf"
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def simple_no_dpi_png_file(tesseract_samples_dir: Path) -> Path: def simple_no_dpi_png_file(tesseract_samples_dir: Path) -> Path:
"""Path to a simple PNG without DPI information. """Path to a simple PNG without DPI information.
@@ -456,18 +480,6 @@ def simple_png_file(tesseract_samples_dir: Path) -> Path:
return tesseract_samples_dir / "simple.png" return tesseract_samples_dir / "simple.png"
@pytest.fixture(scope="session")
def rotated_pdf_file(tesseract_samples_dir: Path) -> Path:
"""Path to a landscape (rotated) PDF sample file.
Returns
-------
Path
Absolute path to ``tesseract/rotated.pdf``.
"""
return tesseract_samples_dir / "rotated.pdf"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Tesseract parser instance and settings helpers # Tesseract parser instance and settings helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -161,11 +161,11 @@ class TestGetPageCount:
def test_single_page_pdf( def test_single_page_pdf(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
simple_digital_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
assert ( assert (
tesseract_parser.get_page_count( tesseract_parser.get_page_count(
simple_digital_pdf_file, tesseract_samples_dir / "simple-digital.pdf",
"application/pdf", "application/pdf",
) )
== 1 == 1
@@ -188,7 +188,7 @@ class TestGetPageCount:
self, self,
mocker: MockerFixture, mocker: MockerFixture,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
simple_digital_pdf_file: Path, tesseract_samples_dir: Path,
caplog, caplog,
) -> None: ) -> None:
""" """
@@ -204,7 +204,7 @@ class TestGetPageCount:
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
page_count = tesseract_parser.get_page_count( page_count = tesseract_parser.get_page_count(
simple_digital_pdf_file, tesseract_samples_dir / "simple-digital.pdf",
"application/pdf", "application/pdf",
) )
assert page_count is None assert page_count is None
@@ -273,26 +273,26 @@ class TestGetThumbnail:
def test_thumbnail_is_file( def test_thumbnail_is_file(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
simple_digital_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
thumb = tesseract_parser.get_thumbnail( thumb = tesseract_parser.get_thumbnail(
simple_digital_pdf_file, tesseract_samples_dir / "simple-digital.pdf",
"application/pdf", "application/pdf",
) )
assert thumb.is_file() assert thumb.is_file()
@pytest.mark.parametrize( @pytest.mark.parametrize(
("sample_fixture", "expected_height"), ("filename", "expected_height"),
[ [
pytest.param("simple_digital_pdf_file", 647, id="portrait-letter"), pytest.param("simple-digital.pdf", 647, id="portrait-letter"),
pytest.param("rotated_pdf_file", 386, id="landscape"), pytest.param("rotated.pdf", 386, id="landscape"),
], ],
) )
def test_thumbnail_is_correct_format_and_size( def test_thumbnail_is_correct_format_and_size(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
request: pytest.FixtureRequest, tesseract_samples_dir: Path,
sample_fixture: str, filename: str,
expected_height: int, expected_height: int,
) -> None: ) -> None:
""" """
@@ -304,7 +304,7 @@ class TestGetThumbnail:
- A 500px wide WebP keeping the page's aspect ratio - A 500px wide WebP keeping the page's aspect ratio
""" """
thumb = tesseract_parser.get_thumbnail( thumb = tesseract_parser.get_thumbnail(
request.getfixturevalue(sample_fixture), tesseract_samples_dir / filename,
"application/pdf", "application/pdf",
) )
with Image.open(thumb) as im: with Image.open(thumb) as im:
@@ -316,7 +316,7 @@ class TestGetThumbnail:
self, self,
mocker: MockerFixture, mocker: MockerFixture,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
simple_digital_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -326,7 +326,7 @@ class TestGetThumbnail:
THEN: THEN:
- The PDF is repaired with qpdf and a real thumbnail is rendered - The PDF is repaired with qpdf and a real thumbnail is rendered
""" """
original = simple_digital_pdf_file original = tesseract_samples_dir / "simple-digital.pdf"
def _fail_on_original(in_path: Path, out_path: Path, **kwargs) -> None: def _fail_on_original(in_path: Path, out_path: Path, **kwargs) -> None:
if in_path == original: if in_path == original:
@@ -368,11 +368,11 @@ class TestExtractText:
def test_extract_text_from_digital_pdf( def test_extract_text_from_digital_pdf(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
simple_digital_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
text = tesseract_parser.extract_text( text = tesseract_parser.extract_text(
None, None,
simple_digital_pdf_file, tesseract_samples_dir / "simple-digital.pdf",
) )
assert text is not None assert text is not None
assert "This is a test document." in text.strip() assert "This is a test document." in text.strip()
@@ -387,7 +387,7 @@ class TestParsePdf:
def test_simple_digital_creates_archive( def test_simple_digital_creates_archive(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
multi_page_digital_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -401,7 +401,7 @@ class TestParsePdf:
- Text is extracted - Text is extracted
""" """
tesseract_parser.parse( tesseract_parser.parse(
multi_page_digital_pdf_file, tesseract_samples_dir / "multi-page-digital.pdf",
"application/pdf", "application/pdf",
) )
assert tesseract_parser.archive_path is not None assert tesseract_parser.archive_path is not None
@@ -416,10 +416,10 @@ class TestParsePdf:
def test_with_form_default( def test_with_form_default(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
with_form_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
tesseract_parser.parse( tesseract_parser.parse(
with_form_pdf_file, tesseract_samples_dir / "with-form.pdf",
"application/pdf", "application/pdf",
) )
assert tesseract_parser.archive_path is not None assert tesseract_parser.archive_path is not None
@@ -432,11 +432,11 @@ class TestParsePdf:
def test_with_form_redo_no_archive_when_not_requested( def test_with_form_redo_no_archive_when_not_requested(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
with_form_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
tesseract_parser.settings.mode = ModeChoices.REDO tesseract_parser.settings.mode = ModeChoices.REDO
tesseract_parser.parse( tesseract_parser.parse(
with_form_pdf_file, tesseract_samples_dir / "with-form.pdf",
"application/pdf", "application/pdf",
produce_archive=False, produce_archive=False,
) )
@@ -449,11 +449,11 @@ class TestParsePdf:
def test_with_form_force( def test_with_form_force(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
with_form_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
tesseract_parser.settings.mode = ModeChoices.FORCE tesseract_parser.settings.mode = ModeChoices.FORCE
tesseract_parser.parse( tesseract_parser.parse(
with_form_pdf_file, tesseract_samples_dir / "with-form.pdf",
"application/pdf", "application/pdf",
) )
assert_ordered_substrings( assert_ordered_substrings(
@@ -494,7 +494,7 @@ class TestParsePdf:
self, self,
mocker: MockerFixture, mocker: MockerFixture,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
simple_digital_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
mocker.patch( mocker.patch(
"ocrmypdf.ocr", "ocrmypdf.ocr",
@@ -502,7 +502,7 @@ class TestParsePdf:
) )
with pytest.raises(ParseError): with pytest.raises(ParseError):
tesseract_parser.parse( tesseract_parser.parse(
simple_digital_pdf_file, tesseract_samples_dir / "simple-digital.pdf",
"application/pdf", "application/pdf",
) )
@@ -578,10 +578,10 @@ class TestParseMultiPage:
def test_multi_page_digital( def test_multi_page_digital(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
multi_page_digital_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
tesseract_parser.parse( tesseract_parser.parse(
multi_page_digital_pdf_file, tesseract_samples_dir / "multi-page-digital.pdf",
"application/pdf", "application/pdf",
) )
assert tesseract_parser.archive_path is not None assert tesseract_parser.archive_path is not None
@@ -605,12 +605,12 @@ class TestParseMultiPage:
self, self,
mode: str, mode: str,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
multi_page_digital_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
tesseract_parser.settings.pages = 2 tesseract_parser.settings.pages = 2
tesseract_parser.settings.mode = mode tesseract_parser.settings.mode = mode
tesseract_parser.parse( tesseract_parser.parse(
multi_page_digital_pdf_file, tesseract_samples_dir / "multi-page-digital.pdf",
"application/pdf", "application/pdf",
) )
assert tesseract_parser.archive_path is not None assert tesseract_parser.archive_path is not None
@@ -624,11 +624,11 @@ class TestParseMultiPage:
def test_multi_page_images_skip( def test_multi_page_images_skip(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
multi_page_images_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
tesseract_parser.settings.mode = ModeChoices.AUTO tesseract_parser.settings.mode = ModeChoices.AUTO
tesseract_parser.parse( tesseract_parser.parse(
multi_page_images_pdf_file, tesseract_samples_dir / "multi-page-images.pdf",
"application/pdf", "application/pdf",
) )
assert tesseract_parser.archive_path is not None assert tesseract_parser.archive_path is not None
@@ -642,7 +642,7 @@ class TestParseMultiPage:
def test_multi_page_images_redo_pages_2( def test_multi_page_images_redo_pages_2(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
multi_page_images_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -657,7 +657,7 @@ class TestParseMultiPage:
tesseract_parser.settings.pages = 2 tesseract_parser.settings.pages = 2
tesseract_parser.settings.mode = ModeChoices.REDO tesseract_parser.settings.mode = ModeChoices.REDO
tesseract_parser.parse( tesseract_parser.parse(
multi_page_images_pdf_file, tesseract_samples_dir / "multi-page-images.pdf",
"application/pdf", "application/pdf",
) )
assert tesseract_parser.archive_path is not None assert tesseract_parser.archive_path is not None
@@ -670,7 +670,7 @@ class TestParseMultiPage:
def test_multi_page_images_force_page_1( def test_multi_page_images_force_page_1(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
multi_page_images_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -685,7 +685,7 @@ class TestParseMultiPage:
tesseract_parser.settings.pages = 1 tesseract_parser.settings.pages = 1
tesseract_parser.settings.mode = ModeChoices.FORCE tesseract_parser.settings.mode = ModeChoices.FORCE
tesseract_parser.parse( tesseract_parser.parse(
multi_page_images_pdf_file, tesseract_samples_dir / "multi-page-images.pdf",
"application/pdf", "application/pdf",
) )
assert tesseract_parser.archive_path is not None assert tesseract_parser.archive_path is not None
@@ -781,7 +781,7 @@ class TestSkipArchive:
def test_skip_noarchive_with_text_layer( def test_skip_noarchive_with_text_layer(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
multi_page_digital_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -795,7 +795,7 @@ class TestSkipArchive:
""" """
tesseract_parser.settings.mode = ModeChoices.AUTO tesseract_parser.settings.mode = ModeChoices.AUTO
tesseract_parser.parse( tesseract_parser.parse(
multi_page_digital_pdf_file, tesseract_samples_dir / "multi-page-digital.pdf",
"application/pdf", "application/pdf",
produce_archive=False, produce_archive=False,
) )
@@ -810,7 +810,7 @@ class TestSkipArchive:
def test_skip_noarchive_image_only_creates_archive( def test_skip_noarchive_image_only_creates_archive(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
multi_page_images_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -823,7 +823,7 @@ class TestSkipArchive:
""" """
tesseract_parser.settings.mode = ModeChoices.AUTO tesseract_parser.settings.mode = ModeChoices.AUTO
tesseract_parser.parse( tesseract_parser.parse(
multi_page_images_pdf_file, tesseract_samples_dir / "multi-page-images.pdf",
"application/pdf", "application/pdf",
) )
assert tesseract_parser.archive_path is not None assert tesseract_parser.archive_path is not None
@@ -835,29 +835,29 @@ class TestSkipArchive:
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
("produce_archive", "sample_fixture", "expect_archive"), ("produce_archive", "filename", "expect_archive"),
[ [
pytest.param( pytest.param(
True, True,
"multi_page_digital_pdf_file", "multi-page-digital.pdf",
True, True,
id="produce-archive-with-text", id="produce-archive-with-text",
), ),
pytest.param( pytest.param(
True, True,
"multi_page_images_pdf_file", "multi-page-images.pdf",
True, True,
id="produce-archive-no-text", id="produce-archive-no-text",
), ),
pytest.param( pytest.param(
False, False,
"multi_page_digital_pdf_file", "multi-page-digital.pdf",
False, False,
id="no-archive-with-text-layer", id="no-archive-with-text-layer",
), ),
pytest.param( pytest.param(
False, False,
"multi_page_images_pdf_file", "multi-page-images.pdf",
False, False,
id="no-archive-no-text-layer", id="no-archive-no-text-layer",
), ),
@@ -866,10 +866,10 @@ class TestSkipArchive:
def test_produce_archive_flag( def test_produce_archive_flag(
self, self,
produce_archive: bool, # noqa: FBT001 produce_archive: bool, # noqa: FBT001
sample_fixture: str, filename: str,
expect_archive: bool, # noqa: FBT001 expect_archive: bool, # noqa: FBT001
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
request: pytest.FixtureRequest, tesseract_samples_dir: Path,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -882,9 +882,8 @@ class TestSkipArchive:
- Text is always extracted - Text is always extracted
""" """
tesseract_parser.settings.mode = ModeChoices.AUTO tesseract_parser.settings.mode = ModeChoices.AUTO
sample = request.getfixturevalue(sample_fixture)
tesseract_parser.parse( tesseract_parser.parse(
sample, tesseract_samples_dir / filename,
"application/pdf", "application/pdf",
produce_archive=produce_archive, produce_archive=produce_archive,
) )
@@ -901,7 +900,7 @@ class TestSkipArchive:
self, self,
mocker: MockerFixture, mocker: MockerFixture,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
simple_digital_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -917,7 +916,7 @@ class TestSkipArchive:
tesseract_parser.settings.mode = ModeChoices.AUTO tesseract_parser.settings.mode = ModeChoices.AUTO
mock_ocr = mocker.patch("ocrmypdf.ocr") mock_ocr = mocker.patch("ocrmypdf.ocr")
tesseract_parser.parse( tesseract_parser.parse(
simple_digital_pdf_file, tesseract_samples_dir / "simple-digital.pdf",
"application/pdf", "application/pdf",
produce_archive=False, produce_archive=False,
) )
@@ -956,7 +955,7 @@ class TestSkipArchive:
def test_tagged_pdf_produces_pdfa_archive_without_ocr( def test_tagged_pdf_produces_pdfa_archive_without_ocr(
self, self,
tesseract_parser: RasterisedDocumentParser, tesseract_parser: RasterisedDocumentParser,
simple_digital_pdf_file: Path, tesseract_samples_dir: Path,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -971,7 +970,7 @@ class TestSkipArchive:
""" """
tesseract_parser.settings.mode = ModeChoices.AUTO tesseract_parser.settings.mode = ModeChoices.AUTO
tesseract_parser.parse( tesseract_parser.parse(
simple_digital_pdf_file, tesseract_samples_dir / "simple-digital.pdf",
"application/pdf", "application/pdf",
produce_archive=True, produce_archive=True,
) )
@@ -0,0 +1,19 @@
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
</head>
<body>
<p>Some Text</p>
<p>
<img src="cid:part1.pNdUSz0s.D3NqVtPg@example.de" alt="Has to be rewritten to work..">
<img src="http://localhost:8080/assets/logo_full_white.svg" alt="This image should not be shown.">
</p>
<p>and an embedded image.<br>
</p>
<p id="changeme">Paragraph unchanged.</p>
<scRipt>
document.getElementById("changeme").innerHTML = "Paragraph changed via Java Script.";
</script>
</body>
</html>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.
+11 -11
View File
@@ -18,6 +18,8 @@ from paperless.parsers.utils import read_file_handle_unicode_errors
if TYPE_CHECKING: if TYPE_CHECKING:
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
SAMPLES = Path(__file__).parent / "samples" / "tesseract"
class TestReadFileHandleUnicodeErrors: class TestReadFileHandleUnicodeErrors:
def test_plain_utf8(self, tmp_path: Path) -> None: def test_plain_utf8(self, tmp_path: Path) -> None:
@@ -55,11 +57,11 @@ class TestReadFileHandleUnicodeErrors:
class TestIsTaggedPdf: class TestIsTaggedPdf:
def test_tagged_pdf_returns_true(self, simple_digital_pdf_file: Path) -> None: def test_tagged_pdf_returns_true(self) -> None:
assert is_tagged_pdf(simple_digital_pdf_file) is True assert is_tagged_pdf(SAMPLES / "simple-digital.pdf") is True
def test_untagged_pdf_returns_false(self, multi_page_images_pdf_file: Path) -> None: def test_untagged_pdf_returns_false(self) -> None:
assert is_tagged_pdf(multi_page_images_pdf_file) is False assert is_tagged_pdf(SAMPLES / "multi-page-images.pdf") is False
def test_nonexistent_path_returns_false(self) -> None: def test_nonexistent_path_returns_false(self) -> None:
assert is_tagged_pdf(Path("/nonexistent/file.pdf")) is False assert is_tagged_pdf(Path("/nonexistent/file.pdf")) is False
@@ -93,7 +95,7 @@ class TestGetPdfFirstPageSizePoints:
pdf.save(path) pdf.save(path)
return path return path
def test_letter_sample(self, simple_digital_pdf_file: Path) -> None: def test_letter_sample(self) -> None:
""" """
GIVEN: GIVEN:
- A US Letter sample PDF with no CropBox and no rotation - A US Letter sample PDF with no CropBox and no rotation
@@ -102,7 +104,7 @@ class TestGetPdfFirstPageSizePoints:
THEN: THEN:
- The MediaBox size in points is returned - The MediaBox size in points is returned
""" """
assert get_pdf_first_page_size_points(simple_digital_pdf_file) == ( assert get_pdf_first_page_size_points(SAMPLES / "simple-digital.pdf") == (
612.0, 612.0,
792.0, 792.0,
) )
@@ -197,7 +199,6 @@ class TestGetPdfFirstPageSizePoints:
def test_pdfinfo_not_installed_returns_none( def test_pdfinfo_not_installed_returns_none(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
simple_digital_pdf_file: Path,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -208,7 +209,7 @@ class TestGetPdfFirstPageSizePoints:
- None is returned and nothing is raised - None is returned and nothing is raised
""" """
mocker.patch("subprocess.run", side_effect=FileNotFoundError("pdfinfo")) mocker.patch("subprocess.run", side_effect=FileNotFoundError("pdfinfo"))
assert get_pdf_first_page_size_points(simple_digital_pdf_file) is None assert get_pdf_first_page_size_points(SAMPLES / "simple-digital.pdf") is None
def test_degenerate_box_returns_none(self, tmp_path: Path) -> None: def test_degenerate_box_returns_none(self, tmp_path: Path) -> None:
""" """
@@ -250,7 +251,7 @@ class TestGetPdfFirstPageSizePoints:
bad.write_bytes(b"not a pdf") bad.write_bytes(b"not a pdf")
assert get_pdf_first_page_size_points(bad) is None assert get_pdf_first_page_size_points(bad) is None
def test_encrypted_pdf_returns_none(self, parser_samples_dir: Path) -> None: def test_encrypted_pdf_returns_none(self) -> None:
""" """
GIVEN: GIVEN:
- A password protected PDF - A password protected PDF
@@ -259,8 +260,7 @@ class TestGetPdfFirstPageSizePoints:
THEN: THEN:
- None is returned and nothing is raised - None is returned and nothing is raised
""" """
encrypted = parser_samples_dir / "tesseract" / "encrypted.pdf" assert get_pdf_first_page_size_points(SAMPLES / "encrypted.pdf") is None
assert get_pdf_first_page_size_points(encrypted) is None
class TestPostProcessText: class TestPostProcessText:
-48
View File
@@ -1,48 +0,0 @@
"""Test sample files shared across tests.
A sample used by a single app stays in that app's ``tests/samples`` tree.
Samples needed by more than one app, or stored under more than one name, live
here exactly once.
"""
import shutil
from pathlib import Path
SHARED_SAMPLES_DIR = (Path(__file__).parent / "sample_files").resolve()
SIMPLE_DIGITAL_PDF = SHARED_SAMPLES_DIR / "simple-digital.pdf"
MULTI_PAGE_DIGITAL_PDF = SHARED_SAMPLES_DIR / "multi-page-digital.pdf"
WITH_FORM_PDF = SHARED_SAMPLES_DIR / "with-form.pdf"
MULTI_PAGE_IMAGES_PDF = SHARED_SAMPLES_DIR / "multi-page-images.pdf"
THUMBNAIL_WEBP = SHARED_SAMPLES_DIR / "thumbnail.webp"
# The fake media tree under documents/tests/samples/documents keeps only the
# files unique to it. The files that duplicate a shared sample are laid back
# down under their historical opaque names (DB rows and exporter manifests
# refer to them) by install_document_samples().
_DOCUMENT_SAMPLES_DIR = (
Path(__file__).parent.parent / "documents" / "tests" / "samples" / "documents"
).resolve()
_SHARED_AS_MEDIA = (
("originals/0000001.pdf", SIMPLE_DIGITAL_PDF),
("originals/0000002.pdf", MULTI_PAGE_DIGITAL_PDF),
("originals/0000003.pdf", WITH_FORM_PDF),
("archive/0000001.pdf", MULTI_PAGE_IMAGES_PDF),
("thumbnails/0000001.webp", THUMBNAIL_WEBP),
("thumbnails/0000002.webp", THUMBNAIL_WEBP),
("thumbnails/0000003.webp", THUMBNAIL_WEBP),
("thumbnails/0000004.webp", THUMBNAIL_WEBP),
)
def install_document_samples(dest: Path) -> None:
"""Populate ``dest`` with the full fake media tree.
Replaces ``shutil.copytree(<samples>/documents, dest)`` in tests.
"""
shutil.copytree(_DOCUMENT_SAMPLES_DIR, dest, dirs_exist_ok=True)
for relpath, source in _SHARED_AS_MEDIA:
target = dest / relpath
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
Generated
+138 -105
View File
@@ -424,42 +424,68 @@ wheels = [
[[package]] [[package]]
name = "brotli" name = "brotli"
version = "1.2.0" version = "1.2.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
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" } sdist = { url = "https://files.pythonhosted.org/packages/cf/81/1546c983678a9b4e77cb4718eaedd1b2caf24a6cdbf1eb8775938d22aff7/brotli-1.2.1.tar.gz", hash = "sha256:02b2f7b5a83485efe448a44ddf7983def05c3bb1dcf917d4f7154070260e13de", size = 7389447, upload-time = "2026-10-22T14:00:12.66Z" }
wheels = [ wheels = [
{ 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/63/f5/213512faee9562f069cc0d228a8dce8097d5517e15fdf48b8f05d53e6b55/brotli-1.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:970a3dbce807a3f5c2b8232036052bade01e4bc92ade37d73ea3750418307fa1", size = 863168, upload-time = "2026-10-22T13:57:38.57Z" },
{ 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/3e/5a/7b31d12915cbd1b7bdf5a0ba290159290d7af5904bda55a17770cae89f00/brotli-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59ec359a56f44277ad424fb4b7cf1a0ae2fd855b35ba8f15dcfbcabb3fb51aeb", size = 443278, upload-time = "2026-10-22T13:57:40.157Z" },
{ 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/66/f3/49499c9b368dcd65d6bb64c799ff790ae8ecca28011b40a884c3473be3d9/brotli-1.2.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f51bca8fcac4266519533698c316f3d23340768a8e57c4796c9f6e0a4f353807", size = 1248964, upload-time = "2026-10-22T13:57:41.327Z" },
{ 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/3b/f7/d98bcbc94b10c17bfc109157612b58481a383003e7df90925ff4d7534aaa/brotli-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b0faa0b9af9367748388345d5b12f6add2a9cf896481e72910e88fbfe14ecd54", size = 1409293, upload-time = "2026-10-22T13:57:42.5Z" },
{ 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/95/d6/910649b3ea76e50885a2f67f238d0aedc5d435627b24150cef2792c3c81b/brotli-1.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eaa5178a4c1fe619eff9cbe93b57919d426332e219870301d4ef5fc09a522ae9", size = 1430285, upload-time = "2026-10-22T13:57:43.725Z" },
{ 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/71/7e/c1d314406ce47a39180fc66114681d9ac10c2c667e14e4dc1a5e5089f4fb/brotli-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6b3c3d5794212a54434b4856f808f7bdf7ad48eacadaadb5b2346fc9a639365a", size = 1426895, upload-time = "2026-10-22T13:57:44.984Z" },
{ 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/81/20/cee575e524f9908b406fec81a2505127f65efaa8447655d3061be941e4a0/brotli-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2df1678c2c25db2f669c613fe8b4e587d0dd24c6acf7746bad13d6e87e4b051d", size = 1491281, upload-time = "2026-10-22T13:57:46.218Z" },
{ 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/58/16/6b86ed3e2e475b9d8418d1e88bc1719197934df74f7b5142f9db5a86b4dd/brotli-1.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c4b68ce579b05c66bb18a16356a4f713bebac2446ee498dd8bb1ecab3da77b89", size = 1283748, upload-time = "2026-10-22T13:57:47.398Z" },
{ 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/1e/ba/ca87fec021a63ad4f7f5e01d456c8b0d63defb11862feff6f22fd6238507/brotli-1.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f34405292fca45cd77419005c1ea35ba1c03eaff9042afe76d6fe189fe474669", size = 1599415, upload-time = "2026-10-22T13:57:48.577Z" },
{ 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/ca/fe/6cdf401cc44a2c32d280cb58f032c071f15afd939c2f8579b0a28449065c/brotli-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d47a9f0ba5ee608f3f3a9c942dcd244443068ae5b0d90183dd396dea02236e4", size = 1494419, upload-time = "2026-10-22T13:57:49.783Z" },
{ 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/34/5d/dcf82117cbb506cfe0b75a00c34f2f7e850e5d933d80c0e50e2a191f1fee/brotli-1.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3975fb11f63e212b87a087e4a910187b522c6969a4989ca362d6f85f9dc36acd", size = 861663, upload-time = "2026-10-22T13:57:54.196Z" },
{ 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/98/c3/59d6445882214a47eab34e763aacc8dce18c4e7f9c5e2d9c95df9a7f7a3d/brotli-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de499e2492c8106a5e163d3cf5b6726e2797f9234cfbc6055fbb5f9d8f198f39", size = 442271, upload-time = "2026-10-22T13:57:55.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/87/7b/55b0c895c17dbe747b63e442d2c3a163cc838d116bf5560414a13cc284c9/brotli-1.2.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1f65a91f3576949793b8720b12a47e7ea06debc970a2da6d54ff8458053e2109", size = 1242808, upload-time = "2026-10-22T13:57:56.675Z" },
{ 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/7a/75/d8f2a81748b60733e8b03afd4bc1b70d3fb74a235b3b13dff0b2978e7f87/brotli-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:875975f91870682cca4371260522ad3f22d636e845b5c5355c52bc045c0f75c8", size = 1402417, upload-time = "2026-10-22T13:57:57.827Z" },
{ 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/e6/b2/7addcc2c4965d0946e38a416b60af89700d54141845447f03fe4bff0293c/brotli-1.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c3f667dc60a875b12a5ecd7828062ccd97c0fb79c4adc6c8699d23d5ff2af7e1", size = 1424422, upload-time = "2026-10-22T13:57:59.285Z" },
{ 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/b6/cc/b0b5b6b1ff4544bec3a702d5ad2e0badf22db296d16277a6ee59dabaffdc/brotli-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b9dd0173ca5e01d1eb307d3c184fe8c09bdc168ede560889b1018d216d38c61", size = 1420478, upload-time = "2026-10-22T13:58:00.484Z" },
{ 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/42/dd/1d9bdb8aa35c14ba45ce208768eb977810446b7537d9d418c2bcaae2ed20/brotli-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9307679936e86d7773eadfa444a4a4ee80f6f45230e93379875f4a6897a09a2b", size = 1485966, upload-time = "2026-10-22T13:58:01.715Z" },
{ 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/4d/8e/86e64fc403888244e090a7a3d9fb35f6d6bb2219e789fc88064065464a3c/brotli-1.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb5df75d1b1c42d997adbb39eac61f2cdb48efa26304a0cb9c0b757c9bc20732", size = 1279161, upload-time = "2026-10-22T13:58:02.933Z" },
{ 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/ef/ba/cbfa7d264d4cec16d5951071f1d04028e7024119c65a7bd909e77cafc9e6/brotli-1.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d6ef0035b6ae338a46951c144081c61fb044f4c1eb6d20acc5ba6277f666a4c0", size = 1594070, upload-time = "2026-10-22T13:58:04.421Z" },
{ 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/4d/4d/7abc13404dde1665d2fe3cde3385e06d5f81aed0242c23979acc3f552afd/brotli-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:338856ae9ff63af4fc3dbbd6184a7276a6ae83df2243562e86e5ee5cf980cce7", size = 1489507, upload-time = "2026-10-22T13:58:05.591Z" },
{ 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/f0/85/d74a573b92ba449960eb088ce87dcb61a9dc6a0c5a7ba8d104ae180e64c3/brotli-1.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a84e0ae14d1efe782a9f143021c4e01d5ed986a140fe5070505cc5978eaf0592", size = 861657, upload-time = "2026-10-22T13:58:10.392Z" },
{ 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/ca/bd/848b55fbd96fa1ce9f534aa39c930f864dbf224282f4a3808c0d35ea2819/brotli-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5302747f969d5ec925810176f7697712a3fa2ea0fae0c7a85aa4ad9325247fd", size = 442252, upload-time = "2026-10-22T13:58:11.647Z" },
{ 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/40/41/95d5e6774cb037851001038484bcf98b3af203664fe78d29dd03ece317e7/brotli-1.2.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7ec678c52c3ef5e37482a094d109c04cf9e4a0e2923dd1e0fbddb4bf133a976e", size = 1242773, upload-time = "2026-10-22T13:58:13.182Z" },
{ 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/8b/ad/0942abad71d8b2aabd21472efa5be31ab0be31f98271d58767b9797eec82/brotli-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b5b458950312830913f837e179ce9221000ab7a20b4e42d7b48c1fcd79140de1", size = 1402440, upload-time = "2026-10-22T13:58:14.513Z" },
{ 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/a3/53/2f7e666e61a1419d527904b6018a711f10650a851e7767358abf2919625e/brotli-1.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f37ccd159dae35d4914651d57142c8077aa67ab7e3ffef690b00bcb7a7dbd8e6", size = 1424432, upload-time = "2026-10-22T13:58:15.789Z" },
{ 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/65/85/4c57c3cd2eee42878677b4943086e6b36cefc50214a5447028268045ce63/brotli-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:264fd5b200f6f6170cb551a4b9fcaabf8aa846ddaefe72db4d5f49f9d3743df9", size = 1420480, upload-time = "2026-10-22T13:58:17.007Z" },
{ 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/8f/f7/65e75560e20d522db1f0c75a1b2612c6ebd542ff9df17b8920cdbe4cde68/brotli-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1329c80daa60752f1d94f507479504921e358e71746c5fce9b55b3d51ac038bc", size = 1485998, upload-time = "2026-10-22T13:58:18.422Z" },
{ 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/5d/2a/6cc0cfc4170bd72f6fc0aef11984e7c583ecf0a7fec563bb4292c0d50e15/brotli-1.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:77ef5882c5318ffc3dd94c6abc6a8f1ec2af75d66522319f1a250a07d53d0546", size = 1279199, upload-time = "2026-10-22T13:58:19.709Z" },
{ 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/66/36/97f3eefc9295ce8d87b568433d38e0ca3345b82571341196817c96b2ca50/brotli-1.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6bd29ab84bb8972370cacd310e8a91651f54476d01de1cd1a4cf0bece5eb25e1", size = 1594118, upload-time = "2026-10-22T13:58:20.857Z" },
{ 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/03/61/687b36bd6b714270ff3d7ff96df6199336abc26c1fa98da276d9776d75b7/brotli-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed8e452f5cb34f59f4861d50d029c6203b5f8f11c13d4186264392a64b4def8d", size = 1489530, upload-time = "2026-10-22T13:58:22Z" },
{ 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/af/dd/641dd6072ea8c5e0ef7d1f8dc02149b4b0b77ef68fe12fc08b08320980d5/brotli-1.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b8b2fc7901b31d5885f26521cf2d74feccdc126a272ba1d879b7545bb25468bc", size = 863184, upload-time = "2026-10-22T13:58:27.201Z" },
{ 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" }, { url = "https://files.pythonhosted.org/packages/b2/b8/3c4a50dc10f098a022519042eab84f11ef8c29abb9270fca76086ac392c3/brotli-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:de1bf30d819809a964060356032e6670c9923644666afaaf3d1719d05ddb844e", size = 443276, upload-time = "2026-10-22T13:58:28.47Z" },
{ url = "https://files.pythonhosted.org/packages/b1/a1/5ef931a404de2c61b2cd775e70b1652ff3f4d434f0589da22efbee590f4a/brotli-1.2.1-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ed62c15ce5e8b975b2710a0f7c8bb953cbffb472fccb05055be6afcba9520c19", size = 1243026, upload-time = "2026-10-22T13:58:29.934Z" },
{ url = "https://files.pythonhosted.org/packages/11/95/54e7278ee3fc589a6ac3a9bc191b5412542949411f0b38c56614d0f51282/brotli-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3942e2d365f009e5e7839114fa315e1a2bcfea12b52064a28f1a7281862a2a7", size = 1402517, upload-time = "2026-10-22T13:58:31.231Z" },
{ url = "https://files.pythonhosted.org/packages/4d/db/e065253a23a09d97dc7bd8cb47194a85479d71be35a51b5c575ae5095ebe/brotli-1.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6532d2c58018121a9eb5882656cf862cdbbd853600cb5fbccac59bcff14f66b0", size = 1424572, upload-time = "2026-10-22T13:58:32.468Z" },
{ url = "https://files.pythonhosted.org/packages/c4/07/8948582ce173e8e43cea9fadb8c3c3ea94800fb9814119c9239c1ae6c797/brotli-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a998857af4fcd19920bdf5f30f0f1a23f38472a703d33e49168cd4fd32438377", size = 1420557, upload-time = "2026-10-22T13:58:34.1Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d4/65073a06d6356a8f60b4c6ce0c3ade206d1118b8b8705b548f7b72ed549a/brotli-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f352f797b1477c46a20e45f4cbc3a0e258e61963c8ae8f79f84a7bfb4195b37e", size = 1486209, upload-time = "2026-10-22T13:58:35.331Z" },
{ url = "https://files.pythonhosted.org/packages/5a/19/8663beee29665f6fdf520b9c7ea333e0e930952176092be24c4c8bd8f791/brotli-1.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:be371af72af54b6e508b68a7a87768e05b7e13023f6c82a3e3f5e4d0e829e186", size = 1279217, upload-time = "2026-10-22T13:58:36.719Z" },
{ url = "https://files.pythonhosted.org/packages/bb/a6/3851ec19707a1610412ecf4f057d7c1b5acbb8f8b211494a70e1f451ccf6/brotli-1.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dca1bf90b3a6a7440904f128464eb95bf46ac9da7ad3c85912483dd83128fd95", size = 1594292, upload-time = "2026-10-22T13:58:38.115Z" },
{ url = "https://files.pythonhosted.org/packages/8d/13/6bc9d4cb30fb0a475cd149c2075b9f63b93257a84ff70d0783a403f4b015/brotli-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4a7be59d374c445766fa4ac066a04d19a757365ae00a950053d0ab7388d1039", size = 1489719, upload-time = "2026-10-22T13:58:39.365Z" },
{ url = "https://files.pythonhosted.org/packages/a2/72/85ea89c1088673a297e8b652cf0080668812144d5525db6512eadddf9d19/brotli-1.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eea17e3cdc6f9aecffe15fe9a9eeba8e8c049a5d347de1561eed2240cdf35a79", size = 863002, upload-time = "2026-10-22T13:58:44.325Z" },
{ url = "https://files.pythonhosted.org/packages/81/6a/bd4db4ef0c4d083d8a2e5a1d0b20239b186f261e72b14a9a0ec91c078e92/brotli-1.2.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:761db18dc32b24d00fbedca1013089eb3470053cefa8492bd9c07b11102d72bf", size = 443263, upload-time = "2026-10-22T13:58:45.474Z" },
{ url = "https://files.pythonhosted.org/packages/79/1a/08635be453d523474fdfce81f588583a4e8049c75c04df849b5a278bf027/brotli-1.2.1-cp315-cp315-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:035f3bf861328d8b7fcb61f4a976f9913027158e5666116733d59ffc65f2745e", size = 1244379, upload-time = "2026-10-22T13:58:46.615Z" },
{ url = "https://files.pythonhosted.org/packages/3b/94/52f2facc5ae118a68c4f0e715e03c9f854507a359608804d7de2dfe8d603/brotli-1.2.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:aa32b51702a0b6e9fddc0d1dc66cb6ed6af112f1bba0cac2be9039546a862b5c", size = 1404133, upload-time = "2026-10-22T13:58:47.756Z" },
{ url = "https://files.pythonhosted.org/packages/1e/9b/768635c710b684574da55e12bcdbe36419fd8bf6cb97e01c72a4593f9747/brotli-1.2.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:810aea5958d6a2d92593fd27218df528644aeb72cc7f6d401e650752d6c98949", size = 1424875, upload-time = "2026-10-22T13:58:48.912Z" },
{ url = "https://files.pythonhosted.org/packages/46/39/7b7d53d7cea61f35f6b52ea149804523d01e532bf26017eed4351d82a7ad/brotli-1.2.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f00ff4b56caf151365c77988eb85a4a71eb26fe63047d4084d5614e06d65e3cf", size = 1425345, upload-time = "2026-10-22T13:58:50.253Z" },
{ url = "https://files.pythonhosted.org/packages/cb/5e/652c788a4da5c6415c7ae31382626ba870020c9280e0f9be1a6dfec80261/brotli-1.2.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dafaeb38712f99e5c31ad7f02c366f1ddd3792921b94066015f8c49f197163b0", size = 1487925, upload-time = "2026-10-22T13:58:51.593Z" },
{ url = "https://files.pythonhosted.org/packages/85/c9/1b7753d02c106ff70b3d88056586ed7c2fe329d2aa5707cdaebe90372942/brotli-1.2.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:972e34c5cbbfe6c90358bfae38edae1a29d3456e920da5a4664c98f221ee4df8", size = 1276526, upload-time = "2026-10-22T13:58:53.142Z" },
{ url = "https://files.pythonhosted.org/packages/a5/5f/6ae3e85bfb7a3f4711cea5a1b92d4377fddf1319a666cda213cc83b6c040/brotli-1.2.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:59ca34c7a83d7a3b842247183c426f891a95a8f57db41b7aac92195c51e8a83a", size = 1594681, upload-time = "2026-10-22T13:58:54.571Z" },
{ url = "https://files.pythonhosted.org/packages/8a/52/2808406d83e5462c086d69dbebb892f4ed78d2eb4b6755ea4b9e58357e3b/brotli-1.2.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:90bd49511341dd7c40bb3acaa97cd8b9e5df4821a5714b842117e7069c7cc03e", size = 1486968, upload-time = "2026-10-22T13:58:56.008Z" },
{ url = "https://files.pythonhosted.org/packages/7f/eb/803fb4003fc74fb7202eade0832057729837c0929b690e7f691c1778f230/brotli-1.2.1-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:93269f11303fc9a3e3990b57e0533f41949546156d9f0e535571f313032563cc", size = 416747, upload-time = "2026-10-22T13:59:59.612Z" },
{ url = "https://files.pythonhosted.org/packages/60/fc/48f827dda08844d6313a97c6c34b7f0cffc8310bf4f2cd4d98fae6c198fb/brotli-1.2.1-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:87492842770cd772fce25e9cd279913eaea6c55a91a960b6302aedfa2ec7b152", size = 399671, upload-time = "2026-10-22T14:00:01.176Z" },
{ url = "https://files.pythonhosted.org/packages/c3/2b/21aa1de7b7b5739f8d9b55c12100de794c42b10abd1a83da1a99072e2b46/brotli-1.2.1-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f06dd6a30438692d48c0d54de4b659cd55a57c2c2f6591c969c98d0a5523ab07", size = 404664, upload-time = "2026-10-22T14:00:02.357Z" },
{ url = "https://files.pythonhosted.org/packages/f1/22/bce8a2f6af49807728b4d3394a67190032b37170be0a9f870c98b0e1e4a6/brotli-1.2.1-pp311-pypy311_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb6a4d400aa531c73a718b337116fad77ba8dc6df34fa615c808bfc55ffb19ca", size = 398822, upload-time = "2026-10-22T14:00:03.441Z" },
{ url = "https://files.pythonhosted.org/packages/f5/29/62f7de3d08c53b09fe67b24b024b13ce3ec088fe0dd6940064719e61eba5/brotli-1.2.1-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:5f3da70516b1bd1b2aa183ec4ab5c02dfc6ad5c1184d47bb65ade25e710439b6", size = 416714, upload-time = "2026-10-22T14:00:06.054Z" },
{ url = "https://files.pythonhosted.org/packages/e2/66/3da6c4e928c55fc85f4cdaf0cbd41ebb1639ae70c71f9f26183818460d54/brotli-1.2.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:11aec6ea5c20cad61a33302e06baa48b0243bfa4d173bc2985dabd047e367d08", size = 399657, upload-time = "2026-10-22T14:00:07.095Z" },
{ url = "https://files.pythonhosted.org/packages/85/9d/626bb730a70507151667d950c9214003427b962a3876142c07ba52d96391/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00a50d063aae2e69c2eb28818ccf6dc3e51f2ea5b74e3c2082a47943ee23f399", size = 404727, upload-time = "2026-10-22T14:00:08.438Z" },
{ url = "https://files.pythonhosted.org/packages/bb/34/fb0bbe56d822ee4c75b2383e518bdd579327f18f1cfedf1d779a585df01b/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37de1818ce469b3c37324c89d4d4dbb5e64461d9dc01c16f8abb2a17c465b132", size = 398927, upload-time = "2026-10-22T14:00:09.739Z" },
] ]
[[package]] [[package]]
@@ -988,15 +1014,15 @@ wheels = [
[[package]] [[package]]
name = "django-allauth" name = "django-allauth"
version = "65.16.1" version = "65.20.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "asgiref" }, { name = "asgiref" },
{ name = "django" }, { name = "django" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/3d/df/357187dfff18c7783e4911827a6c69437e290d7259a32a99c23fcd85997f/django_allauth-65.16.1.tar.gz", hash = "sha256:4425ac3088541c4c54983e16e08f6e3eb9f438dc1b1009534fa51c8bb739ed31", size = 2232835, upload-time = "2026-04-17T18:53:59.475Z" } sdist = { url = "https://files.pythonhosted.org/packages/8a/98/a343df4b6230e483cd0437a9265f9acd365e4639d8750c17021455f94a2c/django_allauth-65.20.1.tar.gz", hash = "sha256:954cf5cb39e7793e25420f0352b77c74c9a68ab36ae2c8b9d783c2d710650f19", size = 2304239, upload-time = "2026-10-20T19:59:25.752Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/58/d95b6c3088d83697bfd93782ee57bc6a6462e41eb19121a947b8a015396a/django_allauth-65.16.1-py3-none-any.whl", hash = "sha256:e49df24056bf37c44e56aaad1e51f78994b7d175bc3476d65e8f8f58390a8ce8", size = 2051868, upload-time = "2026-04-17T18:54:12.032Z" }, { url = "https://files.pythonhosted.org/packages/d8/9f/0ea3886dd6310ddfb44d20a20152c04b7dbf6380c911171662d4d766119a/django_allauth-65.20.1-py3-none-any.whl", hash = "sha256:39f4b66d113b6f6df6805d22d577b8f06aa54e251425de1699fb6a3d7f8d1e45", size = 2077848, upload-time = "2026-10-20T19:57:49.207Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -1184,25 +1210,16 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/c3/305a9f7611c0dc96d2a638bc3a67bdcde5a7faa5f4669188db018b8af6fc/django_stubs_ext-6.1.1-py3-none-any.whl", hash = "sha256:64aebb7ce2c327a4a2b75207d67034e879fd7edf027c93487be69da363dc79fa", size = 10853, upload-time = "2026-09-14T08:45:51.289Z" }, { url = "https://files.pythonhosted.org/packages/99/c3/305a9f7611c0dc96d2a638bc3a67bdcde5a7faa5f4669188db018b8af6fc/django_stubs_ext-6.1.1-py3-none-any.whl", hash = "sha256:64aebb7ce2c327a4a2b75207d67034e879fd7edf027c93487be69da363dc79fa", size = 10853, upload-time = "2026-09-14T08:45:51.289Z" },
] ]
[[package]]
name = "django-treenode"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2b/09f04b44b3583a911ae4f5d0e3360a23d529704c9e9115fce2520b7d4420/django_treenode-0.25.0.tar.gz", hash = "sha256:d14d0eb3571c227ce11147210d62a8f87b568bbb30fa76a5be9b9e6d514157c3", size = 29951, upload-time = "2026-08-26T00:20:13.489Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/ac/2140e8c20e2a47457ec23302273527d3430bf5c09edb8940d74c0725c138/django_treenode-0.25.0-py3-none-any.whl", hash = "sha256:f47caab5440420870b6287602b3ee401ca354416d44b24ab97579be5ea658927", size = 22343, upload-time = "2026-08-26T00:20:12.078Z" },
]
[[package]] [[package]]
name = "djangorestframework" name = "djangorestframework"
version = "3.18.1" version = "3.18.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/8a/2e/b3ce9d449b1ed9f9dd74fb7dfbc5f20860d5f40c2b4b10a2c3eabd8ff579/djangorestframework-3.18.1.tar.gz", hash = "sha256:605d79fa2ec2f02905492e5ea13d903c2d842d0b4c915a57f7bf02ab9f3c91dd", size = 915653, upload-time = "2026-09-07T18:04:08.288Z" } sdist = { url = "https://files.pythonhosted.org/packages/8d/b2/1cee76b188301f7d03169ca77fab2b3d3ec5d01b3469189f944af2d7f11a/djangorestframework-3.18.3.tar.gz", hash = "sha256:446a9b352e7eff630421ab3f2328bd2401b109a9470afa4a31189994911ed030", size = 923563, upload-time = "2026-10-06T21:33:51.521Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/83/5ed615e47339d8e65f62eb1a8133f8f046492eb3a25ad40d90acb9a208b3/djangorestframework-3.18.1-py3-none-any.whl", hash = "sha256:f1409d698967aaf82d5d98d76b549c8fba8bd92ebd0d83d24454508f72168dc2", size = 901373, upload-time = "2026-09-07T18:04:06.237Z" }, { url = "https://files.pythonhosted.org/packages/c4/dd/de5f4543765bec94e036a5e9593a42f8fa7efec8de78eb56b9ca834385f3/djangorestframework-3.18.3-py3-none-any.whl", hash = "sha256:8544bb674846731b1e3c9b309236ee1dc412905a0aa725be2ec193ca950a7d12", size = 903986, upload-time = "2026-10-06T21:33:49.917Z" },
] ]
[[package]] [[package]]
@@ -1306,11 +1323,11 @@ wheels = [
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "4.0.12" version = "4.2.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" } sdist = { url = "https://files.pythonhosted.org/packages/d8/28/3893324bcadf8f89b1616637468d766af3f625008c0735bf704fdfa86617/filelock-4.2.4.tar.gz", hash = "sha256:c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d", size = 652444, upload-time = "2026-10-19T01:57:51.001Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" }, { url = "https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl", hash = "sha256:301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e", size = 162475, upload-time = "2026-10-19T01:57:49.419Z" },
] ]
[[package]] [[package]]
@@ -2607,50 +2624,69 @@ wheels = [
[[package]] [[package]]
name = "msgpack" name = "msgpack"
version = "1.2.1" version = "1.2.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } sdist = { url = "https://files.pythonhosted.org/packages/cc/52/1ec7ead2c7528bf3d1db73e886fa4df10f9e6f0eb7854a448b7090cfcef7/msgpack-1.2.4.tar.gz", hash = "sha256:475c44e12f2a5de766a8448783f149c9b33f3f49b88953c6cd739a77d93c732c", size = 197764, upload-time = "2026-10-26T17:55:48.593Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, { url = "https://files.pythonhosted.org/packages/64/cb/a3bd4d234c0f2f7e646b9be259b801c5d6a35c97f93dded4f722e6b80acd/msgpack-1.2.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1095b1ebcc8c9d3e70c51240b7160d8e9da66044e0a45c3ef8403d3a9ef0c5ad", size = 90935, upload-time = "2026-10-26T17:53:46.588Z" },
{ url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, { url = "https://files.pythonhosted.org/packages/56/3c/fc87d06bdfc99b302f4379f0eb34bb8695b6ecd90b965652d0a3195d1acd/msgpack-1.2.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a4b47ec7422bef149223786a87df80067ac65bfc7c0565e5cf1dfbdc40acee50", size = 90187, upload-time = "2026-10-26T17:53:48.151Z" },
{ url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, { url = "https://files.pythonhosted.org/packages/05/a3/abebe12c1b589b7eeab60cb87dd5eb398fcf549bdafca8c30d8d73e48493/msgpack-1.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c75cfda0d3ccb1f6abc20e0608ad6b226e11f317b5ac297721504d2509e2bd9", size = 466911, upload-time = "2026-10-26T17:53:49.536Z" },
{ url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, { url = "https://files.pythonhosted.org/packages/03/41/ed5517a107628f6f7f3a1f46f19de3aca979bd1fc95861cb56ce0e4bcc02/msgpack-1.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a45ceb5f199d5e388f28b550ba881096a3740d4bda10e661d77f59d9a2e8187a", size = 480247, upload-time = "2026-10-26T17:53:51.211Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, { url = "https://files.pythonhosted.org/packages/31/71/14c39858380e11408fcf75aeab1cda0afb253cd8f62a01fbe4ae1838b66f/msgpack-1.2.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ca62b31a873c70b73f367bb2c1efec1d143016642c5191405549578184ff6ff", size = 438298, upload-time = "2026-10-26T17:53:52.919Z" },
{ url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, { url = "https://files.pythonhosted.org/packages/3b/73/ba40b91632e011851e1a397d53e0bd031848751902424a5b37c33f7de412/msgpack-1.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f641971d3eec89ffca57fa36d151ad1cf1b6a214b47faca16cb165c7d45ef8be", size = 461600, upload-time = "2026-10-26T17:53:54.8Z" },
{ url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, { url = "https://files.pythonhosted.org/packages/51/f0/c13ce7e7a7d8dabc1a48eecf61eb789084dbcc64374a7f19770eaf96f2c3/msgpack-1.2.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b9eff629190a4b118549b654df32a56ed535351819f690d0c935d185500cc90b", size = 435020, upload-time = "2026-10-26T17:53:56.433Z" },
{ url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, { url = "https://files.pythonhosted.org/packages/b4/d2/2a4e254f8ee5d5dd5019bd9f99164d79704788dbe16d9d6de77fda4e6727/msgpack-1.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4e22c6571ab6ac9036da87d8a6fddb4baf86981844b480c0c60a4a4be417ee48", size = 479138, upload-time = "2026-10-26T17:53:57.918Z" },
{ url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, { url = "https://files.pythonhosted.org/packages/27/fd/45f6cd13cefbb659a8e0128ea64d51b349571163fec3c9c20f99792dc42a/msgpack-1.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:90cba4996b18ec1201fcaabd01ec48ff4b9755416bcd52105ded2f89fe145c49", size = 91903, upload-time = "2026-10-26T17:54:03.097Z" },
{ url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, { url = "https://files.pythonhosted.org/packages/7f/89/169c2a0b1ffeafe68a4f13d8d96b6ed1cd8d40d8262b3a6690851da7eda1/msgpack-1.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:112933726919f4239646cca9572e2c2fa5626358c670da8440aac09403100a48", size = 90573, upload-time = "2026-10-26T17:54:04.383Z" },
{ url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, { url = "https://files.pythonhosted.org/packages/cf/8e/272a384f2a001cfced1baab0a8abdd912d6281432670f4609d1aab718d7b/msgpack-1.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ca1e78106675f2aed07dc37b842ce19be70010ab22c5c5c04688f0840d48539", size = 461057, upload-time = "2026-10-26T17:54:05.821Z" },
{ url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, { url = "https://files.pythonhosted.org/packages/1e/d9/f59bd841fc2fd2b74dc50ce85860a6a25f58e588dd48946aeaf2862cf990/msgpack-1.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4d5bbdbea8f5669b3a734d6c226772804fb120848f99cbe63a0ddee63b049c9", size = 475652, upload-time = "2026-10-26T17:54:07.414Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, { url = "https://files.pythonhosted.org/packages/00/03/afd000e1b1e28fd535dc8c7bc21ebb88923bc1964a2429fe660fb313ace3/msgpack-1.2.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d9ff7f3aaffd84010f1c501b05406a497584f5cab2f732eb9d89bf960e12f6f", size = 427082, upload-time = "2026-10-26T17:54:09.1Z" },
{ url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, { url = "https://files.pythonhosted.org/packages/f7/7a/e2890b272883c496faad777324ddb70e9cc4a6c63731962a4a39a6a40f44/msgpack-1.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa10dc04c03408f31948ac1896a942141fd8d7a954b3612edac79f19663e4076", size = 451956, upload-time = "2026-10-26T17:54:10.476Z" },
{ url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, { url = "https://files.pythonhosted.org/packages/c2/46/0c1b240b8a580af3e72139c33b018025440a1a42c2579e82e39b1195ee36/msgpack-1.2.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:472a25463678f2795143a7cb42e88a1b59cc2375e9881917e69a9323bde42114", size = 423833, upload-time = "2026-10-26T17:54:12.195Z" },
{ url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, { url = "https://files.pythonhosted.org/packages/80/8e/851415b1a9264e6abf64d8a9617d856769d59e247fb9470106f133aedbae/msgpack-1.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cbb8a677da3e845713925e9a203b8a120b1e9da9989836890ba4a4a187902701", size = 470951, upload-time = "2026-10-26T17:54:13.764Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, { url = "https://files.pythonhosted.org/packages/2d/26/16eaad5fe6e199334fa5bd66070778665717c86e9a273525dc706ddb06a7/msgpack-1.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb811ac3d463a75013b234d2aae56b9f0bdb0105caf4dd2ee65305b6a8a6078d", size = 92057, upload-time = "2026-10-26T17:54:19.616Z" },
{ url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, { url = "https://files.pythonhosted.org/packages/9c/b8/9660be6d120820bc05106f291e824c73d35f4ab77d927788ae67ea80889c/msgpack-1.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:36258fd0909e1bf26e94604439108846432abe4ffa9a23c9dc2f6f1c60f94b60", size = 90542, upload-time = "2026-10-26T17:54:20.965Z" },
{ url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, { url = "https://files.pythonhosted.org/packages/6c/10/cd55a69fec29df26884b178dc4b1803442ec82abf929604aac619881917a/msgpack-1.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9c5ba0f053a7e98f013f083c9d1b2dffb26cad8c897c533d8286e44ed24c669", size = 455798, upload-time = "2026-10-26T17:54:22.434Z" },
{ url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, { url = "https://files.pythonhosted.org/packages/c9/94/562e0733593a780c70b0b18994f30c205149f7f9f4efbe3e9b00b88612c5/msgpack-1.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fabd415c41e62006c6641fcdf5b3e866f581c4e11c58328c0d0f69584b9577d", size = 469262, upload-time = "2026-10-26T17:54:24.043Z" },
{ url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, { url = "https://files.pythonhosted.org/packages/bb/92/76d1a5c597e04e1d360b05573d150bdb4502f5a014e6e9cf089c9537c156/msgpack-1.2.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7433d340de45003db3ed909a642c9469b598d175530f7a707cbaba64287d27ce", size = 420789, upload-time = "2026-10-26T17:54:25.587Z" },
{ url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, { url = "https://files.pythonhosted.org/packages/8a/c6/b72b8d8022718380db459ced8ffbabc7570ad2d22c7f2fa659c872cb6734/msgpack-1.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccd6e4af5347eb9828147320449bcd30e3321f6397d4583ff16047485dd8be3d", size = 447068, upload-time = "2026-10-26T17:54:27.721Z" },
{ url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, { url = "https://files.pythonhosted.org/packages/cb/dc/f378ef1def4aa135e70325c09e22457f3203ae2a390a192e892267906f33/msgpack-1.2.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:23d1459a1d0b4c9f6629df072d592eb74424b3ed3656b69af507bc9023cc027f", size = 418896, upload-time = "2026-10-26T17:54:29.325Z" },
{ url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, { url = "https://files.pythonhosted.org/packages/62/93/a7e507378f43e91415cb6fda47e0ded0c9de1618a1f4ac4f2887912a92dd/msgpack-1.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e73761fe9fc55a4e9d2df8e8f59b0979231af4dbf80b8bf90cd64c74173d779", size = 465764, upload-time = "2026-10-26T17:54:30.719Z" },
{ url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, { url = "https://files.pythonhosted.org/packages/60/76/7f39f49cbcdaa4aafe64b4941b2640772dbdc1cac3a9332f4c9ba6994da9/msgpack-1.2.4-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:d088d1f552e129859db830692268747aba0a1ee3e6e0fe6344dbb3aab1df9f6c", size = 53615, upload-time = "2026-10-26T17:54:32.213Z" },
{ url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, { url = "https://files.pythonhosted.org/packages/86/34/747f9ea2b428088201c73fcb3a885cebcbedcd71def0f1f3c36896294b27/msgpack-1.2.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:895e9369185df4a874768849405c30cba7990caceb3fcb1692ebe62bb050f55e", size = 92449, upload-time = "2026-10-26T17:54:37.503Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, { url = "https://files.pythonhosted.org/packages/83/7e/52d208cb1561f869762c17be0e2c004e4d34f2842d239a450492d6246d62/msgpack-1.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c97f5f17407e1761f23f1944be33cbf1d5ce67017db32544dcd5e16e0b1b4a3b", size = 91202, upload-time = "2026-10-26T17:54:38.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, { url = "https://files.pythonhosted.org/packages/2e/58/5516cabb60da83506024015c669622a0277979aef51eb611308cebaf4cc9/msgpack-1.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2444a4e04f924630d059ae6eae8f91fdfcd28ed9fd17b21bacf4e74550a091f7", size = 455185, upload-time = "2026-10-26T17:54:40.213Z" },
{ url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, { url = "https://files.pythonhosted.org/packages/39/8f/bf975b5d2111e4954a0f8567fc6b60bab22c8766dd4fa509af1334c91a4f/msgpack-1.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d65b791ce4283e02b6267742ad540899155e1b32887b9cbdda47f38f5a076b5", size = 465078, upload-time = "2026-10-26T17:54:41.971Z" },
{ url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, { url = "https://files.pythonhosted.org/packages/32/2f/c74baeafc53d1c1fadfbeb774444d0e6354778d12b40b827e437a7a35856/msgpack-1.2.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5e4c67dbac05dce90a3fa1d845137617383c415e6b319062baac9734540706ae", size = 420144, upload-time = "2026-10-26T17:54:44.994Z" },
{ url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, { url = "https://files.pythonhosted.org/packages/b0/f2/bdf2ce9e284821397dc5549101824c7607ee6ba6e0635390d168a77ac49c/msgpack-1.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:781e856db4f2cb398e4992e2a74305295d3ebf694eec26fa0f4bc766eb9fa0a0", size = 446479, upload-time = "2026-10-26T17:54:46.484Z" },
{ url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, { url = "https://files.pythonhosted.org/packages/76/8d/b4572d24bb75d98bcbbd031a934a3a1d03494ea88a100cd51ab16c4c7d57/msgpack-1.2.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cfc1cc237792f9a5664002b66acb961027d4184528ec9d10e0e42330f7972383", size = 418780, upload-time = "2026-10-26T17:54:48.04Z" },
{ url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, { url = "https://files.pythonhosted.org/packages/55/8e/4493bef4422730cfd3420b84094652fabd27ef8aadc8b443a8995b524d08/msgpack-1.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f60de6ae13d4d2a12a483f15c5e47cb6fa2731aa32d92fb31178e2017d6574a8", size = 462009, upload-time = "2026-10-26T17:54:49.433Z" },
{ url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, { url = "https://files.pythonhosted.org/packages/41/29/efdc3d1d09e4e0e45955b0aad559b4c1f5d908767c9fb1021990c52182a5/msgpack-1.2.4-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:06a0657a4110d563255006b7cb7147432f67827144e429c2cdb1c967548d1817", size = 53714, upload-time = "2026-10-26T17:54:50.753Z" },
{ url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, { url = "https://files.pythonhosted.org/packages/d0/4a/6defc8fd0270c4ba675e6625f4f76ce936fd0c48733395fd7ba912cd423e/msgpack-1.2.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:45506bfb902cde24a0d33fb0581f58497ddb2682686dd1f6aa07e7b769df7ca4", size = 94083, upload-time = "2026-10-26T17:54:57.108Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, { url = "https://files.pythonhosted.org/packages/c8/e8/59f7e076739048864710a4de3403ae1c1b43ed5077b796cd9e8cbce9b3f6/msgpack-1.2.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dc9dc681958e69f46b9f37def3e7fd6cc5dcfaa5943fb00bfe9c223a1a1052f6", size = 95037, upload-time = "2026-10-26T17:54:58.489Z" },
{ url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, { url = "https://files.pythonhosted.org/packages/2c/dd/dbae250b23b831cd0571eb664089f80de3249a39e98c6ef55f9a0efe928d/msgpack-1.2.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bef691e2a95119cc6bf3ded29325dbd89c4b6f8ff233070431948ed70902421", size = 472722, upload-time = "2026-10-26T17:54:59.924Z" },
{ url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, { url = "https://files.pythonhosted.org/packages/a9/e2/383ece1c1b27c1ed322055fce8ab8972bc6d529ede881592065ce0c461bf/msgpack-1.2.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e6106d2d085af42b2de72efeb38ed255241a24b32295ca889707c1d679c442", size = 468726, upload-time = "2026-10-26T17:55:01.492Z" },
{ url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, { url = "https://files.pythonhosted.org/packages/37/2f/5410a8815ae74a824ac87543c612e40cb6674851b70a6ad858bb32813203/msgpack-1.2.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:996dfc4774c240c8e6adf1166ae102141cfbed33c8856fde821bd6c0bd35ee41", size = 420488, upload-time = "2026-10-26T17:55:02.848Z" },
{ url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, { url = "https://files.pythonhosted.org/packages/39/8e/16fdf02b4f91dff9c1480433c1bf80756c2fedc0b74fc6a231f02dfed007/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:efa1357fb0c7201e833de28638be371ee127476f7ccff9e7a94211b0afc33ddf", size = 455541, upload-time = "2026-10-26T17:55:04.564Z" },
{ url = "https://files.pythonhosted.org/packages/c4/76/2454ef54d780a167667552d02e7bc977a3d419943bcbdf6a96a4da6786ad/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b9270b63409e70c8c6deb5faedc81fc27c6bd0196b38084a8773f68286abf034", size = 419514, upload-time = "2026-10-26T17:55:06.257Z" },
{ url = "https://files.pythonhosted.org/packages/ac/8b/2af9bf378def33c24875f250421b0d0b1b06f72a1c2c3f964749fc4cdee1/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2b597bb01aff61d65e305c2cf2589c93c86609929d42d575bfc359f3c54ea8e1", size = 461910, upload-time = "2026-10-26T17:55:08.05Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d8/0465d475c1a7063af1a1fb4f8c69e0dd661c4dd172055c9757a343a07d5d/msgpack-1.2.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:765584b93ed6f0b561f0c56e9735f24945c0682f857c9b3ee3b4caad9c32f66a", size = 92449, upload-time = "2026-10-26T17:55:13.665Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d0/5220a8a53579dda39f3526394ba5b7eafc28736085f2626deda7fcd22cd1/msgpack-1.2.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0221748c30ace75fde0dec8c470abe65fb5374cd4f37adb9db6aca7a3c362c0a", size = 91209, upload-time = "2026-10-26T17:55:14.913Z" },
{ url = "https://files.pythonhosted.org/packages/b2/98/f151742eba413503074a10d8f636bf03dd4f3f7cba29689767a35d19bd1c/msgpack-1.2.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5917972ce1bf5f85f761031e00dab6fe0ad9f880cb3d2d43ed74b330c850400", size = 455713, upload-time = "2026-10-26T17:55:16.406Z" },
{ url = "https://files.pythonhosted.org/packages/b0/54/890510a2c9841aff63258e085d15d4e140c7eef559d4da81ef2d2d867436/msgpack-1.2.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ae8cf6012372cecb43016132d1b87ccc2ebb8ae5ab5078687399e82423386a", size = 466177, upload-time = "2026-10-26T17:55:18.351Z" },
{ url = "https://files.pythonhosted.org/packages/3d/41/a6f20d7c2c380fb072b95ffc5b865a1d62eaf49d77fbee44466c45a1a8de/msgpack-1.2.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e78c66cf97ac5675037f705ca433ad98ac9177f9523e7fdbb34e486ae29044c8", size = 424398, upload-time = "2026-10-26T17:55:19.979Z" },
{ url = "https://files.pythonhosted.org/packages/89/86/fa9559961f2b0d498e6cefa226298ddee2b896cb223449c211702bc34071/msgpack-1.2.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:59887281c3c163a7afff7dffaaad2f78cd2b513b7e1a7843f1ee8e7cd6a94231", size = 445686, upload-time = "2026-10-26T17:55:21.775Z" },
{ url = "https://files.pythonhosted.org/packages/39/23/5a9d94036cdd77d4310bbb7cfaf3ccd1f087c4a5d5a4befec4361c5fc0dc/msgpack-1.2.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9f46e70c8d22e96856967ab9d80c51dcf5bc63133e4da2d66fab64c0d4119b82", size = 422096, upload-time = "2026-10-26T17:55:24Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4b/86a5384112509a94ff6e20028aba6845138743effb7c6617e959ad1256ab/msgpack-1.2.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:0a1fd6c31952155b7cd01061288c70303e6554ae855244dfb38a897fd94f8749", size = 463407, upload-time = "2026-10-26T17:55:26.146Z" },
{ url = "https://files.pythonhosted.org/packages/f4/cf/14db289e0ad531e22ce0f03284865f81d14fbc80bf3f79d3b1714bcf76cf/msgpack-1.2.4-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:b1f08ec826417e3c8dc3edadb50f38debba033af6cb849229f0eac72f80f2342", size = 53743, upload-time = "2026-10-26T17:55:27.497Z" },
{ url = "https://files.pythonhosted.org/packages/b4/fa/37c3219b250d81bee788c0dd78ae2fc673db2da1c21ae51f7982d85698f7/msgpack-1.2.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fb9ddbf23b8c6e89752af399fcf4daed60055e239cda44c6acfaa76d19331e8c", size = 93793, upload-time = "2026-10-26T17:55:32.304Z" },
{ url = "https://files.pythonhosted.org/packages/83/9f/b8afcd20d32cf4be1fa3c327e997656d6d446e667452b0fcc66b91710e82/msgpack-1.2.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e283cf563166c21762131b88e3870ee2ec57757b08a8736929757b5a67541061", size = 94746, upload-time = "2026-10-26T17:55:33.624Z" },
{ url = "https://files.pythonhosted.org/packages/e6/4f/03348d381fcbbc2685356833408cc7e9d2af0fe0775926935a2889650f64/msgpack-1.2.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e91bf8325f492e8eb3c3aa6de06b20593c225336bb52b585c913d7c1da025b", size = 468480, upload-time = "2026-10-26T17:55:34.991Z" },
{ url = "https://files.pythonhosted.org/packages/16/46/e54af8d9a51fb562e068e69a0d75a51e9c2fedec49f911f888515f5477ab/msgpack-1.2.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfa86432bf329d6b976f5a00ad4c9064fd51736e4967a52526750236345cc2da", size = 470064, upload-time = "2026-10-26T17:55:36.615Z" },
{ url = "https://files.pythonhosted.org/packages/74/f5/8f9a6b3af1d803e832b11017c8dad44d761fefdb91dba04ee58cb51838da/msgpack-1.2.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:42e0a7dc26df739149478585d4264338b4d330f24b3e3b57d5aa89e48866e8ab", size = 421267, upload-time = "2026-10-26T17:55:38.076Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8f/7302b0d18eadbbfef9be1dbbdc49905f7f3b847200c3e47068d093976be4/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8e680367090a58c966052d61418cc943111863538fd82471d61fca5271d911e1", size = 451244, upload-time = "2026-10-26T17:55:39.639Z" },
{ url = "https://files.pythonhosted.org/packages/36/c6/05717fb56b510b5e974446f2cb8e9b26fa4347fafc0c5927745b176939b3/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a6c9926f8871b261622fe6b599db72c69be5bca8fdc0976886f9a531a301dee9", size = 420273, upload-time = "2026-10-26T17:55:41.488Z" },
{ url = "https://files.pythonhosted.org/packages/05/d9/a19077d664c37319097e16ba8de14e6b3a2edcc8ee78a2069c0808e38960/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c50bac325dbf1a9c80e10da6dbd1100d7082f6b12e153084501e18e86cd3a037", size = 460421, upload-time = "2026-10-26T17:55:43.223Z" },
] ]
[[package]] [[package]]
@@ -2962,11 +2998,11 @@ wheels = [
[[package]] [[package]]
name = "oauthlib" name = "oauthlib"
version = "3.3.1" version = "4.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } sdist = { url = "https://files.pythonhosted.org/packages/7a/d8/a1bcc8ba112a627f8ffbdc212a78ce18d3ac07e91a5ca65d27918eee25a1/oauthlib-4.0.0.tar.gz", hash = "sha256:efb274799819440f95b4ab3b818869f1ce9ae26c5beacba0201d1a1b76b54f86", size = 187232, upload-time = "2026-09-28T06:01:18.77Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, { url = "https://files.pythonhosted.org/packages/d9/f4/78229a1066068ca14fc60fb26cf7381cabe4382261392b90e5f9552722d4/oauthlib-4.0.0-py3-none-any.whl", hash = "sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd", size = 159715, upload-time = "2026-09-28T06:01:17.008Z" },
] ]
[[package]] [[package]]
@@ -3062,7 +3098,6 @@ dependencies = [
{ name = "django-multiselectfield" }, { name = "django-multiselectfield" },
{ name = "django-rich" }, { name = "django-rich" },
{ name = "django-soft-delete" }, { name = "django-soft-delete" },
{ name = "django-treenode" },
{ name = "djangorestframework" }, { name = "djangorestframework" },
{ name = "drf-spectacular" }, { name = "drf-spectacular" },
{ name = "drf-spectacular-sidecar" }, { name = "drf-spectacular-sidecar" },
@@ -3202,7 +3237,7 @@ requires-dist = [
{ name = "concurrent-log-handler", specifier = "~=0.9.25" }, { name = "concurrent-log-handler", specifier = "~=0.9.25" },
{ name = "dateparser", specifier = "~=1.4" }, { name = "dateparser", specifier = "~=1.4" },
{ name = "django", specifier = "~=5.2.13" }, { name = "django", specifier = "~=5.2.13" },
{ name = "django-allauth", extras = ["mfa", "socialaccount"], specifier = "~=65.16.0" }, { name = "django-allauth", extras = ["mfa", "socialaccount"], specifier = "~=65.20.0" },
{ name = "django-auditlog", specifier = "~=3.4.1" }, { name = "django-auditlog", specifier = "~=3.4.1" },
{ name = "django-cachalot", specifier = "~=2.9.0" }, { name = "django-cachalot", specifier = "~=2.9.0" },
{ name = "django-compression-middleware", specifier = "~=0.5.0" }, { name = "django-compression-middleware", specifier = "~=0.5.0" },
@@ -3213,12 +3248,11 @@ requires-dist = [
{ name = "django-multiselectfield", specifier = "~=1.0.1" }, { name = "django-multiselectfield", specifier = "~=1.0.1" },
{ name = "django-rich", specifier = "~=2.2.0" }, { name = "django-rich", specifier = "~=2.2.0" },
{ name = "django-soft-delete", specifier = "~=1.0.18" }, { name = "django-soft-delete", specifier = "~=1.0.18" },
{ name = "django-treenode", specifier = ">=0.24" },
{ name = "djangorestframework", specifier = "~=3.16" }, { name = "djangorestframework", specifier = "~=3.16" },
{ name = "drf-spectacular", specifier = "~=0.30" }, { name = "drf-spectacular", specifier = "~=0.30" },
{ name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" }, { name = "drf-spectacular-sidecar", specifier = ">=2026.7.1,<2026.10" },
{ name = "drf-writable-nested", specifier = "~=0.7.1" }, { name = "drf-writable-nested", specifier = "~=0.7.1" },
{ name = "filelock", specifier = ">=3.32,<4.1" }, { name = "filelock", specifier = ">=3.32,<4.3" },
{ name = "flower", specifier = ">=2.0.1,<2.3" }, { name = "flower", specifier = ">=2.0.1,<2.3" },
{ name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" }, { name = "gotenberg-client", extras = ["httpx"], specifier = "~=1.0" },
{ name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" }, { name = "granian", extras = ["uvloop"], marker = "extra == 'webserver'", specifier = ">=2.7,<2.9" },
@@ -4873,11 +4907,11 @@ wheels = [
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "83.0.0" version = "84.2.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } sdist = { url = "https://files.pythonhosted.org/packages/70/b3/c19426f783614d625699a4b247faf628ea9ab1db228674491767b417f9e1/setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc", size = 1230289, upload-time = "2026-10-20T19:01:37.742Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, { url = "https://files.pythonhosted.org/packages/ef/55/6dc6d6bb953bdfc52519a0059592c52b6e3b8ad51cf8256dffceb55b0610/setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2", size = 876263, upload-time = "2026-10-20T19:01:35.678Z" },
] ]
[[package]] [[package]]
@@ -6050,7 +6084,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/18/2cff20ec259ab7577ef17e51c67b63b0989e39b1a469ff4b8ea310f71805/zensical-0.0.65-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:62c3a61be738d147d27c499bd84ee2b9afe5c0b97ca072aad6de28e05122e9ee", size = 15334565, upload-time = "2026-09-24T18:07:17.099Z" }, { url = "https://files.pythonhosted.org/packages/2d/18/2cff20ec259ab7577ef17e51c67b63b0989e39b1a469ff4b8ea310f71805/zensical-0.0.65-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:62c3a61be738d147d27c499bd84ee2b9afe5c0b97ca072aad6de28e05122e9ee", size = 15334565, upload-time = "2026-09-24T18:07:17.099Z" },
{ url = "https://files.pythonhosted.org/packages/23/a7/3583b987894dbded915869c7df35016a0e256a7a48627ede009270a8549d/zensical-0.0.65-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:36ccd6f3e2c7e69d35015d4f4c4503146777a1c53d6bd9fbe7adbdc4cbd5a001", size = 15017811, upload-time = "2026-09-24T18:07:21.003Z" }, { url = "https://files.pythonhosted.org/packages/23/a7/3583b987894dbded915869c7df35016a0e256a7a48627ede009270a8549d/zensical-0.0.65-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:36ccd6f3e2c7e69d35015d4f4c4503146777a1c53d6bd9fbe7adbdc4cbd5a001", size = 15017811, upload-time = "2026-09-24T18:07:21.003Z" },
{ url = "https://files.pythonhosted.org/packages/77/7d/40912425a7a77ed929119768526209eb06792d3e0bd200bfd73c89b0ad6d/zensical-0.0.65-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab497d82ed535a04008f8d12e10f028ce8769f1bac34b19281fa5b99712e34e9", size = 15307106, upload-time = "2026-09-24T18:07:24.232Z" }, { url = "https://files.pythonhosted.org/packages/77/7d/40912425a7a77ed929119768526209eb06792d3e0bd200bfd73c89b0ad6d/zensical-0.0.65-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab497d82ed535a04008f8d12e10f028ce8769f1bac34b19281fa5b99712e34e9", size = 15307106, upload-time = "2026-09-24T18:07:24.232Z" },
{ url = "https://files.pythonhosted.org/packages/05/e3/e65a583f0c7e849ce3298b7463fc4a607a3c242433e62b9bd14364272ddc/zensical-0.0.65-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:187f94c4cdfabad0afcec092af6d5944104efe48c6595ae057d54e3213892068", size = 15373933, upload-time = "2026-09-24T18:07:26.934Z" },
{ url = "https://files.pythonhosted.org/packages/b0/0e/55f64f4ac0d80fdb31a278a3b9434cf84742bfeff81ae9d121f1a35b3351/zensical-0.0.65-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64ff25ec94c16853ab0d9bd3cc4a0a569d1381a116a316bd10daab41ec26b3ff", size = 15646714, upload-time = "2026-09-24T18:07:29.649Z" }, { url = "https://files.pythonhosted.org/packages/b0/0e/55f64f4ac0d80fdb31a278a3b9434cf84742bfeff81ae9d121f1a35b3351/zensical-0.0.65-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64ff25ec94c16853ab0d9bd3cc4a0a569d1381a116a316bd10daab41ec26b3ff", size = 15646714, upload-time = "2026-09-24T18:07:29.649Z" },
{ url = "https://files.pythonhosted.org/packages/c4/fb/7e33ed0bafd8d2803b1bb66a540923a6020ac5dc0f4deee835215a5f3838/zensical-0.0.65-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:24f3d65ebdadc58519b0c72065777ed4767e9b4935a21f471798cdfe96fa948f", size = 15483203, upload-time = "2026-09-24T18:07:32.431Z" }, { url = "https://files.pythonhosted.org/packages/c4/fb/7e33ed0bafd8d2803b1bb66a540923a6020ac5dc0f4deee835215a5f3838/zensical-0.0.65-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:24f3d65ebdadc58519b0c72065777ed4767e9b4935a21f471798cdfe96fa948f", size = 15483203, upload-time = "2026-09-24T18:07:32.431Z" },
{ url = "https://files.pythonhosted.org/packages/b6/0f/7b1b9eb2f06ef5aba4efb5060a7289e2082ead11ed372ef10acdcfa5dad4/zensical-0.0.65-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:db9a1b36d08540072e3eb59841b0d6485de51d8c866fb6af8c4e5b771c6b681b", size = 15880927, upload-time = "2026-09-24T18:07:35.258Z" }, { url = "https://files.pythonhosted.org/packages/b6/0f/7b1b9eb2f06ef5aba4efb5060a7289e2082ead11ed372ef10acdcfa5dad4/zensical-0.0.65-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:db9a1b36d08540072e3eb59841b0d6485de51d8c866fb6af8c4e5b771c6b681b", size = 15880927, upload-time = "2026-09-24T18:07:35.258Z" },