diff --git a/pyproject.toml b/pyproject.toml index 4d387ecff..65a2e6a03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -262,6 +262,7 @@ extend-select = [ "PLR1733", # https://docs.astral.sh/ruff/rules/#pylint-pl "PLR1736", # https://docs.astral.sh/ruff/rules/#pylint-pl "PLR2044", # https://docs.astral.sh/ruff/rules/#pylint-pl + "PLW", # https://docs.astral.sh/ruff/rules/#pylint-pl "PT010", # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt "PT014", # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt "PT020", # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt diff --git a/src/documents/management/commands/document_consumer.py b/src/documents/management/commands/document_consumer.py index 7e3900842..409d627b4 100644 --- a/src/documents/management/commands/document_consumer.py +++ b/src/documents/management/commands/document_consumer.py @@ -631,23 +631,25 @@ class Command(BaseCommand): ): # Process each change for change_type, path in changes: - path = Path(path).resolve() + resolved_path = Path(path).resolve() if change_type == Change.deleted: # Consumed (or otherwise removed); a later file # reusing this name must not be skipped as # already-queued. - queued.discard(path) - if not path.is_file(): + queued.discard(resolved_path) + if not resolved_path.is_file(): continue - if path in queued: + if resolved_path in queued: # Already queued and awaiting consumption; a stray # event (NAS metadata touch, AV scan, etc.) while # the file sits on disk mid-consumption must not # cause it to be queued a second time (GH #13511). - logger.debug(f"Ignoring event for queued file: {path}") + logger.debug( + f"Ignoring event for queued file: {resolved_path}", + ) continue - logger.debug(f"Event: {change_type.name} for {path}") - tracker.track(path, change_type) + logger.debug(f"Event: {change_type.name} for {resolved_path}") + tracker.track(resolved_path, change_type) # Check for stable files for stable_path in tracker.get_stable_files(): diff --git a/src/documents/search/_backend.py b/src/documents/search/_backend.py index b783cfd2d..2b7781589 100644 --- a/src/documents/search/_backend.py +++ b/src/documents/search/_backend.py @@ -1142,7 +1142,7 @@ def get_backend() -> TantivyBackend: Returns: Thread-safe singleton TantivyBackend instance """ - global _backend, _backend_path + global _backend, _backend_path # noqa: PLW0603 - module-level singleton, no class to hold this state current_path: Path = settings.INDEX_DIR @@ -1173,7 +1173,7 @@ def reset_backend() -> None: Forces creation of a new backend instance on the next get_backend() call. Used for test isolation and when switching between different index directories. """ - global _backend, _backend_path + global _backend, _backend_path # noqa: PLW0603 - module-level singleton, no class to hold this state with _backend_lock: if _backend is not None: diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 2296cf5c7..fd1704e45 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -1101,10 +1101,11 @@ def _extract_input_data( if v is None or k.startswith("_"): continue if isinstance(v, datetime.date): - v = v.isoformat() + override_dict[k] = v.isoformat() elif isinstance(v, Path): - v = str(v) - override_dict[k] = v + override_dict[k] = str(v) + else: + override_dict[k] = v if override_dict: data["overrides"] = override_dict return data diff --git a/src/documents/tasks.py b/src/documents/tasks.py index ca53468eb..1ebf95bb7 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -217,9 +217,9 @@ def consume_file( overrides.filename or input_doc.original_file.name, self.request.id, ) as status_mgr, - TemporaryDirectory(dir=settings.SCRATCH_DIR) as tmp_dir, + TemporaryDirectory(dir=settings.SCRATCH_DIR) as tmp_dir_name, ): - tmp_dir = Path(tmp_dir) + tmp_dir = Path(tmp_dir_name) msg = None for plugin_class in plugins: plugin_name = plugin_class.NAME diff --git a/src/documents/templating/filepath.py b/src/documents/templating/filepath.py index d827b7c89..e00faf38f 100644 --- a/src/documents/templating/filepath.py +++ b/src/documents/templating/filepath.py @@ -78,6 +78,10 @@ class PlaceholderString(str): def __ne__(self, other) -> bool: return not self.__eq__(other) + def __hash__(self) -> int: + # Equal to both "-none-" and "none", so hash to a single canonical value + return hash("-none-") + NO_VALUE_PLACEHOLDER = PlaceholderString("-none-") diff --git a/src/documents/tests/search/test_lock_backoff.py b/src/documents/tests/search/test_lock_backoff.py index 936bd7f2a..157a42939 100644 --- a/src/documents/tests/search/test_lock_backoff.py +++ b/src/documents/tests/search/test_lock_backoff.py @@ -66,7 +66,7 @@ class TestWriteBatchLockRetry: ) mock_sleep = mocker.patch( "documents.search._backend.time.sleep", - side_effect=lambda s: sleep_values.append(s), + side_effect=sleep_values.append, ) # Should not raise — 4th attempt succeeds @@ -111,7 +111,7 @@ class TestWriteBatchLockRetry: sleep_values: list[float] = [] mocker.patch( "documents.search._backend.time.sleep", - side_effect=lambda s: sleep_values.append(s), + side_effect=sleep_values.append, ) for _ in range(50): sleep_values.clear() diff --git a/src/documents/tests/test_barcodes.py b/src/documents/tests/test_barcodes.py index 84abcf2b1..4e7608c08 100644 --- a/src/documents/tests/test_barcodes.py +++ b/src/documents/tests/test_barcodes.py @@ -205,12 +205,12 @@ class TestBarcode( - Barcode is detected on page 1 (zero indexed) """ - for test_file in [ + for test_filename in [ "patch-code-t-middle-reverse.pdf", "patch-code-t-middle-distorted.pdf", "patch-code-t-middle-fuzzy.pdf", ]: - test_file = self.BARCODE_SAMPLE_DIR / test_file + test_file = self.BARCODE_SAMPLE_DIR / test_filename with self.get_reader(test_file) as reader: reader.detect() diff --git a/src/paperless/checks.py b/src/paperless/checks.py index 1676abb34..85fafaf50 100644 --- a/src/paperless/checks.py +++ b/src/paperless/checks.py @@ -341,6 +341,7 @@ def get_tesseract_langs(): proc = subprocess.run( [shutil.which("tesseract"), "--list-langs"], capture_output=True, + check=False, ) # Decode bytes to string, split on newlines, trim out the header diff --git a/src/paperless/parsers/registry.py b/src/paperless/parsers/registry.py index 7a2b57832..2147d5b0d 100644 --- a/src/paperless/parsers/registry.py +++ b/src/paperless/parsers/registry.py @@ -84,7 +84,7 @@ def get_parser_registry() -> ParserRegistry: ParserRegistry The shared registry singleton. """ - global _registry, _discovery_complete + global _registry, _discovery_complete # noqa: PLW0603 - module-level singleton, no class to hold this state with _lock: if _registry is None: @@ -113,7 +113,7 @@ def init_builtin_parsers() -> None: ------- None """ - global _registry + global _registry # noqa: PLW0603 - module-level singleton, no class to hold this state with _lock: if _registry is None: @@ -137,7 +137,7 @@ def reset_parser_registry() -> None: ------- None """ - global _registry, _discovery_complete + global _registry, _discovery_complete # noqa: PLW0603 - module-level singleton, no class to hold this state _registry = None _discovery_complete = False diff --git a/src/paperless/parsers/utils.py b/src/paperless/parsers/utils.py index 9fe1d4908..27b2e15f2 100644 --- a/src/paperless/parsers/utils.py +++ b/src/paperless/parsers/utils.py @@ -306,8 +306,9 @@ def extract_pdf_metadata( for key, value in meta.items(): if isinstance(value, list): - value = " ".join(str(e) for e in value) - value = str(value) + str_value = " ".join(str(e) for e in value) + else: + str_value = str(value) try: m = namespace_pattern.match(key) @@ -329,7 +330,7 @@ def extract_pdf_metadata( namespace=namespace, prefix=meta.REVERSE_NS[namespace], key=key_value, - value=value, + value=str_value, ), ) except Exception as e: diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 401f52959..204dda969 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -294,7 +294,7 @@ if _CHANNELS_BACKEND.startswith("channels_redis."): ############################################################################### EMAIL_HOST: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST", "localhost") -EMAIL_PORT: Final[int] = int(os.getenv("PAPERLESS_EMAIL_PORT", 25)) +EMAIL_PORT: Final[int] = get_int_from_env("PAPERLESS_EMAIL_PORT", 25) EMAIL_HOST_USER: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_USER", "") EMAIL_HOST_PASSWORD: Final[str] = os.getenv("PAPERLESS_EMAIL_HOST_PASSWORD", "") DEFAULT_FROM_EMAIL: Final[str] = os.getenv("PAPERLESS_EMAIL_FROM", EMAIL_HOST_USER) @@ -381,8 +381,9 @@ ACCOUNT_SESSION_REMEMBER = get_bool_from_env( "True", ) SESSION_EXPIRE_AT_BROWSER_CLOSE = not ACCOUNT_SESSION_REMEMBER -SESSION_COOKIE_AGE = int( - os.getenv("PAPERLESS_SESSION_COOKIE_AGE", 60 * 60 * 24 * 7 * 3), +SESSION_COOKIE_AGE = get_int_from_env( + "PAPERLESS_SESSION_COOKIE_AGE", + 60 * 60 * 24 * 7 * 3, ) # https://docs.djangoproject.com/en/5.1/ref/settings/#std-setting-SESSION_ENGINE SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" @@ -395,7 +396,6 @@ if AUTO_LOGIN_USERNAME: def _parse_remote_user_settings() -> str: - global MIDDLEWARE, AUTHENTICATION_BACKENDS, REST_FRAMEWORK enable = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER") enable_api = get_bool_from_env("PAPERLESS_ENABLE_HTTP_REMOTE_USER_API") if enable or enable_api: @@ -454,7 +454,6 @@ if ALLOWED_HOSTS != ["*"]: def _parse_paperless_url(): - global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS url = os.getenv("PAPERLESS_URL") if url: CSRF_TRUSTED_ORIGINS.append(url) @@ -614,8 +613,8 @@ USE_TZ = True LOGGING_DIR.mkdir(parents=True, exist_ok=True) -LOGROTATE_MAX_SIZE = os.getenv("PAPERLESS_LOGROTATE_MAX_SIZE", 1024 * 1024) -LOGROTATE_MAX_BACKUPS = os.getenv("PAPERLESS_LOGROTATE_MAX_BACKUPS", 20) +LOGROTATE_MAX_SIZE = get_int_from_env("PAPERLESS_LOGROTATE_MAX_SIZE", 1024 * 1024) +LOGROTATE_MAX_BACKUPS = get_int_from_env("PAPERLESS_LOGROTATE_MAX_BACKUPS", 20) LOGGING = { "version": 1, @@ -811,9 +810,15 @@ IGNORABLE_FILES: Final[list[str]] = [ "Thumbs.db", ] -CONSUMER_POLLING_INTERVAL = float(os.getenv("PAPERLESS_CONSUMER_POLLING_INTERVAL", 0)) +CONSUMER_POLLING_INTERVAL = get_float_from_env( + "PAPERLESS_CONSUMER_POLLING_INTERVAL", + 0.0, +) -CONSUMER_STABILITY_DELAY = float(os.getenv("PAPERLESS_CONSUMER_STABILITY_DELAY", 5)) +CONSUMER_STABILITY_DELAY = get_float_from_env( + "PAPERLESS_CONSUMER_STABILITY_DELAY", + 5.0, +) CONSUMER_DELETE_DUPLICATES = get_bool_from_env("PAPERLESS_CONSUMER_DELETE_DUPLICATES") diff --git a/src/paperless/tests/test_utils.py b/src/paperless/tests/test_utils.py index 152913867..09846cd42 100644 --- a/src/paperless/tests/test_utils.py +++ b/src/paperless/tests/test_utils.py @@ -59,7 +59,7 @@ def test_ocr_to_dateparser_languages_exception( raise RuntimeError("Simulated error") with caplog.at_level(logging.WARNING): - monkeypatch.setattr(utils, "LocaleDataLoader", lambda: DummyLoader()) + monkeypatch.setattr(utils, "LocaleDataLoader", DummyLoader) result = utils.ocr_to_dateparser_languages("eng+fra") assert result == [] assert ( diff --git a/src/paperless_ai/tests/test_lazy_imports.py b/src/paperless_ai/tests/test_lazy_imports.py index 4ae1fa5a1..ab0e20dd8 100644 --- a/src/paperless_ai/tests/test_lazy_imports.py +++ b/src/paperless_ai/tests/test_lazy_imports.py @@ -21,5 +21,6 @@ class TestLazyAiImports: capture_output=True, text=True, cwd=_SRC_DIR, + check=False, ) assert result.returncode == 0, result.stdout + result.stderr diff --git a/src/paperless_mail/tests/factories.py b/src/paperless_mail/tests/factories.py index 747ad1054..676a947b5 100644 --- a/src/paperless_mail/tests/factories.py +++ b/src/paperless_mail/tests/factories.py @@ -50,7 +50,7 @@ class ProcessedMailFactory(DjangoModelFactory[ProcessedMail]): rule = factory.SubFactory(MailRuleFactory) folder = "INBOX" - uid = factory.Sequence(lambda n: str(n)) + uid = factory.Sequence(str) subject = factory.Faker("sentence", nb_words=4) received = factory.LazyFunction(timezone.now) processed = factory.LazyFunction(timezone.now)