mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-24 21:04:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33cd48a17f | ||
|
|
d76dbf5366 | ||
|
|
b66182cd7b | ||
|
|
3fe6562c57 | ||
|
|
737d568f80 | ||
|
|
b992f9fc05 | ||
|
|
6d249b4932 | ||
|
|
371c4f57d3 |
@@ -86,7 +86,7 @@ jobs:
|
||||
# ---- Prepare Release ----
|
||||
- name: Generate requirements file
|
||||
run: |
|
||||
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
|
||||
uv export --quiet --no-dev --all-extras --emit-index-url --format requirements-txt --output-file requirements.txt
|
||||
- name: Compile messages
|
||||
env:
|
||||
PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret"
|
||||
|
||||
+61
-3
@@ -133,7 +133,7 @@ PAPERLESS_DB_OPTIONS="sslmode=require,sslrootcert=/certs/ca.pem,pool.max_size=10
|
||||
|
||||
## OCR and Archive File Generation Settings
|
||||
|
||||
The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** — old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment.
|
||||
The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** - old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment.
|
||||
|
||||
### Removed settings
|
||||
|
||||
@@ -166,7 +166,7 @@ Remove any `PAPERLESS_OCR_SKIP_ARCHIVE_FILE` variable from your environment. If
|
||||
# v2: skip OCR when text present, always archive
|
||||
PAPERLESS_OCR_MODE=skip
|
||||
# v3: equivalent (auto is the new default)
|
||||
# No change needed — auto is the default
|
||||
# No change needed - auto is the default
|
||||
|
||||
# v2: skip OCR when text present, skip archive too
|
||||
PAPERLESS_OCR_MODE=skip_noarchive
|
||||
@@ -189,7 +189,7 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
|
||||
|
||||
If you use the **remote OCR parser** (Azure AI), note that it always produces a
|
||||
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
|
||||
has no effect for documents handled by the remote parser — the archive is produced
|
||||
has no effect for documents handled by the remote parser - the archive is produced
|
||||
unconditionally by the remote engine.
|
||||
|
||||
## Search Index (Whoosh -> Tantivy)
|
||||
@@ -327,6 +327,64 @@ behind a reverse proxy may need to set
|
||||
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
|
||||
or both, to avoid `403 Forbidden` errors on login.
|
||||
|
||||
## Minimum CPU Requirements (NumPy Baseline)
|
||||
|
||||
Starting with NumPy 2.4.0, official `manylinux` x86_64 wheels are compiled with a minimum
|
||||
CPU baseline of `x86-64-v2`, which requires SSE3, SSSE3, SSE4.1, SSE4.2, POPCNT, and
|
||||
CMPXCHG16B. This is a [deliberate upstream change](https://github.com/numpy/numpy/issues/27851)
|
||||
citing that these instructions have been present in over 99.7% of CPUs since 2008
|
||||
([Intel](<https://en.wikipedia.org/wiki/Nehalem_(microarchitecture)>)) or 2011
|
||||
([AMD](<https://en.wikipedia.org/wiki/Bulldozer_(microarchitecture)>)).
|
||||
|
||||
NumPy is a dependency of the document classifier (via scikit-learn), so any CPU that
|
||||
predates SSE4.2 support will crash with `SIGILL` (illegal instruction) when the classifier
|
||||
is loaded or trained, regardless of whether AI features are enabled.
|
||||
|
||||
This differs from NumPy's optional SIMD dispatch (e.g. AVX2, AVX512), which is detected and
|
||||
selected safely at runtime - the `x86-64-v2` requirement above is a hard floor baked into the
|
||||
wheel, with no runtime fallback.
|
||||
|
||||
### Affected hardware
|
||||
|
||||
CPUs older than roughly 2008 (Intel) or 2011 (AMD) that lack SSE4.2 support. This is more
|
||||
likely to affect low-power or embedded hardware - e.g. early Atom, Celeron, or pre-Bulldozer
|
||||
AMD chips - than typical desktop or server hardware from the last decade.
|
||||
|
||||
Check for SSE4.2 support with:
|
||||
|
||||
```bash
|
||||
grep -o -m1 sse4_2 /proc/cpuinfo
|
||||
```
|
||||
|
||||
If this prints nothing, your CPU is affected.
|
||||
|
||||
### Symptoms
|
||||
|
||||
The Celery worker (and potentially the web server) repeatedly crashes and restarts with a
|
||||
`SIGILL` error, typically visible in `dmesg`/`journalctl` as a `trap invalid opcode` inside
|
||||
`_multiarray_umath...so`. Because the classifier is trained on a periodic schedule
|
||||
(hourly, by default), affected instances see intermittent, hard-to-reproduce document
|
||||
consumption failures whenever that scheduled task runs and takes down the worker process
|
||||
mid-task.
|
||||
|
||||
### Action Required (for affected hardware only)
|
||||
|
||||
There is no way to make the classifier itself work on such CPUs - it requires an unofficial
|
||||
NumPy build with `cpu-baseline=none`, which is not something we can ship. The practical
|
||||
path forward is to stop the classifier from ever loading or training, which avoids
|
||||
importing NumPy at all:
|
||||
|
||||
```bash
|
||||
PAPERLESS_TRAIN_TASK_CRON=disable
|
||||
```
|
||||
|
||||
This disables the periodic classifier training task (see
|
||||
[`PAPERLESS_TRAIN_TASK_CRON`](configuration.md#PAPERLESS_TRAIN_TASK_CRON)). Automatic
|
||||
matching based on the classifier (suggested correspondents, document types, tags, and
|
||||
storage paths from trained rules) will no longer be available, but rule-based matching is
|
||||
unaffected, and document consumption itself will no longer be at risk of crashing the
|
||||
worker.
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Some integer fields have been changed to smaller types to reduce database size. If you have any `MailRule` records with a `maximum_age` greater than 32767, they will be clamped to 32767 during the migration to avoid errors during migration.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.0.1"
|
||||
version = "3.0.2"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "3.0.1",
|
||||
"version": "3.0.2",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
|
||||
@@ -8,7 +8,7 @@ export const environment = {
|
||||
apiVersion: '10', // match src/paperless/settings.py
|
||||
appTitle: DEFAULT_APP_TITLE,
|
||||
tag: 'prod',
|
||||
version: '3.0.1',
|
||||
version: '3.0.2',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
@@ -475,7 +475,13 @@ class TantivyBackend:
|
||||
note_texts: list[str] = []
|
||||
for note in document.notes.all():
|
||||
num_notes += 1
|
||||
doc.add_json("notes", {"note": note.note, "user": note.user.username})
|
||||
doc.add_json(
|
||||
"notes",
|
||||
{
|
||||
"note": note.note,
|
||||
"user": note.user.username if note.user else None,
|
||||
},
|
||||
)
|
||||
note_texts.append(note.note)
|
||||
if note_texts:
|
||||
doc.add_text("notes_text", " ".join(note_texts))
|
||||
|
||||
@@ -1264,7 +1264,17 @@ def task_failure_handler(
|
||||
"error_message": str(exception) if exception else "Unknown error",
|
||||
}
|
||||
if traceback:
|
||||
tb_str = "".join(_tb.format_tb(traceback))
|
||||
# billiard/celery pass a pre-formatted string instead of a real
|
||||
# traceback object when the worker process itself died (e.g.
|
||||
# WorkerLostError from a SIGILL) since there's no live traceback
|
||||
# to walk in that case.
|
||||
tb_str = (
|
||||
traceback
|
||||
if isinstance(traceback, str)
|
||||
else "".join(
|
||||
_tb.format_tb(traceback),
|
||||
)
|
||||
)
|
||||
result_data["traceback"] = tb_str[:5000]
|
||||
|
||||
now = timezone.now()
|
||||
|
||||
@@ -844,6 +844,23 @@ class TestFieldHandling:
|
||||
f"Expected 1, got {len(ids)}. Note content should be searchable via notes.note: prefix."
|
||||
)
|
||||
|
||||
def test_notes_without_user_are_indexed(self, backend: TantivyBackend) -> None:
|
||||
"""Notes whose user was deleted (SET_NULL) must not break indexing."""
|
||||
doc = Document.objects.create(
|
||||
title="Doc with orphaned note",
|
||||
content="test",
|
||||
checksum="NT2",
|
||||
pk=81,
|
||||
)
|
||||
Note.objects.create(document=doc, note="Orphaned note", user=None)
|
||||
|
||||
backend.add_or_update(doc)
|
||||
|
||||
ids = backend.search_ids("notes.note:orphaned", user=None)
|
||||
assert len(ids) == 1, (
|
||||
f"Expected 1, got {len(ids)}. Notes without a user should still be indexed."
|
||||
)
|
||||
|
||||
|
||||
class TestHighlightHits:
|
||||
"""Test highlight_hits returns proper HTML strings, not raw Snippet objects."""
|
||||
|
||||
@@ -272,10 +272,7 @@ class MailDocumentParser:
|
||||
logger.debug("Building formatted text from email")
|
||||
self._text = build_formatted_text(mail)
|
||||
|
||||
if is_naive(mail.date):
|
||||
self._date = make_aware(mail.date)
|
||||
else:
|
||||
self._date = mail.date
|
||||
self._date = mail.date
|
||||
|
||||
logger.debug("Creating a PDF from the email")
|
||||
if self._mailrule_id:
|
||||
@@ -502,6 +499,9 @@ class MailDocumentParser:
|
||||
f"Could not parse {filepath}: {err}",
|
||||
) from err
|
||||
|
||||
if is_naive(parsed.date):
|
||||
parsed.date = make_aware(parsed.date)
|
||||
|
||||
return parsed
|
||||
|
||||
def tika_parse(self, html: str) -> str:
|
||||
|
||||
@@ -421,6 +421,11 @@ class TikaDocumentParser:
|
||||
logger.info("Converting %s to PDF as %s", document_path, pdf_path)
|
||||
|
||||
with self._gotenberg_client.libre_office.to_pdf() as route:
|
||||
# Preserve document fields as authored. updateIndexes (Gotenberg's
|
||||
# default) triggers a refresh() that rewrites dynamic fields like
|
||||
# auto-dates to the current date.
|
||||
route.update_indexes(update_indexes=False)
|
||||
|
||||
# Set the output format of the resulting PDF.
|
||||
# OutputTypeConfig reads the database-stored ApplicationConfiguration
|
||||
# first, then falls back to the PAPERLESS_OCR_OUTPUT_TYPE env var.
|
||||
|
||||
@@ -145,6 +145,41 @@ class TestEmailFileParsing:
|
||||
assert parsed_msg.text == "This is just a simple Text Mail.\n"
|
||||
assert parsed_msg.to == ("some@one.de",)
|
||||
|
||||
def test_parse_file_to_message_makes_naive_date_aware(
|
||||
self,
|
||||
mocker: MockerFixture,
|
||||
mail_parser: MailDocumentParser,
|
||||
simple_txt_email_file: Path,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- An email whose parsed date is naive (no tzinfo)
|
||||
WHEN:
|
||||
- The .eml file is parsed into a MailMessage
|
||||
THEN:
|
||||
- The resulting message date is made timezone-aware
|
||||
"""
|
||||
mock_message = mocker.Mock(
|
||||
from_values="mail@someserver.de",
|
||||
date=datetime.datetime(2022, 10, 12, 21, 40, 43),
|
||||
)
|
||||
mocker.patch(
|
||||
"paperless.parsers.mail.MailMessage.from_bytes",
|
||||
return_value=mock_message,
|
||||
)
|
||||
|
||||
parsed_msg = mail_parser.parse_file_to_message(simple_txt_email_file)
|
||||
|
||||
assert timezone.is_aware(parsed_msg.date)
|
||||
assert parsed_msg.date.replace(tzinfo=None) == datetime.datetime(
|
||||
2022,
|
||||
10,
|
||||
12,
|
||||
21,
|
||||
40,
|
||||
43,
|
||||
)
|
||||
|
||||
|
||||
class TestEmailMetadataExtraction:
|
||||
"""
|
||||
|
||||
@@ -223,4 +223,11 @@ class TestTikaParser:
|
||||
|
||||
assert form_field_found
|
||||
|
||||
# Field updates must be disabled so LibreOffice preserves document
|
||||
# fields (e.g. auto-date fields) as authored rather than refreshing
|
||||
# them to the current date.
|
||||
assert any(
|
||||
b'name="updateIndexes"' in part and b"false" in part for part in parts
|
||||
)
|
||||
|
||||
httpx_mock.reset()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Final
|
||||
|
||||
__version__: Final[tuple[int, int, int]] = (3, 0, 1)
|
||||
__version__: Final[tuple[int, int, int]] = (3, 0, 2)
|
||||
# Version string like X.Y.Z
|
||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||
# Version string like X.Y
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Split out of 0002_optimize_integer_field_sizes.py into its own migration
|
||||
# (own transaction). Running this UPDATE in the same transaction as the
|
||||
# later ALTER TABLE on paperless_mail_mailrule can fail on PostgreSQL with
|
||||
# "cannot ALTER TABLE because it has pending trigger events", since the
|
||||
# update queues FK trigger events against that table that are still pending
|
||||
# when the subsequent AlterField tries to rewrite it. Committing the clamp
|
||||
# here first avoids that.
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def clamp_mailrule_maximum_age(apps, schema_editor):
|
||||
# Clamp the maximum_age field of MailRule because of PositiveIntegerField --> PositiveSmallIntegerField
|
||||
MailRule = apps.get_model("paperless_mail", "MailRule")
|
||||
MailRule.objects.filter(maximum_age__gt=32767).update(maximum_age=32767)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless_mail", "0001_squashed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
clamp_mailrule_maximum_age,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -4,9 +4,19 @@ from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
def clamp_mailrule_maximum_age(apps, schema_editor):
|
||||
# Clamp the maximum_age field of MailRule because of PositiveIntegerField --> PositiveSmallIntegerField
|
||||
MailRule = apps.get_model("paperless_mail", "MailRule")
|
||||
MailRule.objects.filter(maximum_age__gt=32767).update(maximum_age=32767)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
# The data update must commit before PostgreSQL rewrites the table, otherwise
|
||||
# pending foreign-key trigger events can block the subsequent ALTER TABLE.
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("paperless_mail", "0001_1_clamp_mailrule_maximum_age"),
|
||||
("paperless_mail", "0001_squashed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -112,6 +122,10 @@ class Migration(migrations.Migration):
|
||||
verbose_name="consumption scope",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
clamp_mailrule_maximum_age,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="mailrule",
|
||||
name="maximum_age",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import importlib
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def test_optimize_integer_fields_preserves_released_migration_history():
|
||||
migration_module = importlib.import_module(
|
||||
"paperless_mail.migrations.0002_optimize_integer_field_sizes",
|
||||
)
|
||||
|
||||
assert migration_module.Migration.dependencies == [
|
||||
("paperless_mail", "0001_squashed"),
|
||||
]
|
||||
|
||||
|
||||
def test_mailrule_maximum_age_is_clamped_before_non_atomic_alter():
|
||||
migration_module = importlib.import_module(
|
||||
"paperless_mail.migrations.0002_optimize_integer_field_sizes",
|
||||
)
|
||||
migration = migration_module.Migration
|
||||
|
||||
clamp_index = next(
|
||||
index
|
||||
for index, operation in enumerate(migration.operations)
|
||||
if isinstance(operation, migrations.RunPython)
|
||||
and operation.code is migration_module.clamp_mailrule_maximum_age
|
||||
)
|
||||
alter_index = next(
|
||||
index
|
||||
for index, operation in enumerate(migration.operations)
|
||||
if isinstance(operation, migrations.AlterField)
|
||||
and operation.model_name == "mailrule"
|
||||
and operation.name == "maximum_age"
|
||||
)
|
||||
|
||||
assert migration.atomic is False
|
||||
assert clamp_index < alter_index
|
||||
Reference in New Issue
Block a user