paperless-ngx's signal handlers — 5 functions to address first

Five critical-band functions in paperless-ngx's signal handlers, validators, settings parsers, and document consumer are all in the 'fire' quadrant — structurally complex and actively changing, with the last change 13 days ago.

Stephen Collins ·
Generated by hotspots · free & open source
Activity Risk18.3Low
Hottest Functionupdate_filename_and_move_files

Antipatterns Detected

complex_branching5deeply_nested5god_function5exit_heavy4long_function4middle_man1

Run this on your own codebase

Hotspots runs locally in under a minute — no account, no data leaves your machine.

pip
$ pip install hotspots-cli
npm
$ npm install -g @stephencollinstech/hotspots
Run in any repo
$ hotspots analyze .
★ Star on GitHub

Key Points

What is a god function and why does it matter in paperless-ngx?

A god function is a single function that owns too many responsibilities — it handles input validation, branching logic, side effects, and error recovery that should be distributed across smaller, focused units. Every one of the five top hotspots in paperless-ngx carries this pattern. The concrete problem is blast radius: when `run_workflows` owns workflow matching, action dispatch, document refresh, soft-delete checking, and attachment resolution simultaneously, a change to any one of those concerns can break any other. It also means the function is nearly impossible to unit test in isolation — you have to set up the full document, workflow, action, and override object graph just to exercise a single path.

How do I reduce cyclomatic complexity in Python?

The most effective first step is decompose-conditional: identify the largest `if/elif/else` chain or the most deeply nested block and extract it into a named function with a clear contract. For `update_filename_and_move_files`, which has a cyclomatic complexity of 71, separating the archive-move logic from the original-file-move logic into two functions would cut that number roughly in half in a single session. As a rule of thumb, CC above 15 warrants a plan to split; CC above 30 warrants doing it before the next feature lands on top of the function. In Python specifically, replacing large `if action.type ==` chains with a dictionary dispatch or a per-type method pattern (as in `run_workflows`) removes branching paths without sacrificing readability.

Is paperless-ngx actively maintained?

Yes — all five of the highest-risk functions were touched 3 times each in the last 30 days, with the most recent change 13 days ago at commit `0addb44`. That level of activity on the most structurally complex parts of the codebase is consistent with a project that is actively adding features and fixing issues. The 575 fire-quadrant functions — meaning high structural complexity and high recent activity — represent about 21% of all 2,736 analyzed functions. Active development and structural complexity are not mutually exclusive; the signal here is that development is moving quickly in some of the most intricate corners of the codebase, which is exactly where investment in refactoring pays off most.

How do I reproduce this analysis?

The Hotspots CLI is available at https://github.com/stephencollins/hotspots. Check out the repository at commit `0addb44` with `git checkout 0addb44`, then run `hotspots analyze . --mode snapshot --explain-patterns --force` from the repository root. The same command works on any local git repository without any configuration file — it will produce the quadrant distribution, top hotspots, and pattern annotations shown in this post.

What does activity-weighted risk mean?

Activity-weighted risk multiplies a function's structural complexity — derived from cyclomatic complexity, nesting depth, and fan-out — by how frequently it has been changed recently. A function with cyclomatic complexity 80 that hasn't been touched in two years scores much lower than one with cyclomatic complexity 20 touched every week, because the dormant function has lower near-term regression probability even though it looks more complex in the abstract. The point of this prioritization is to surface the functions where a developer is most likely to introduce a bug in the next sprint, not just the ones that look messy. In paperless-ngx, `update_filename_and_move_files` has an activity-weighted risk score of 18.3 because it combines extreme structural complexity (CC 71, fan-out 37) with confirmed recent activity — 3 touches in the last 30 days, last changed 13 days ago. That intersection is where refactoring investment has the highest expected return right now.

Across 2,736 functions analyzed at commit 0addb44, paperless-ngx has 198 critical-band functions — and the five highest-ranked are all ‘fire’ quadrant: high structural complexity combined with active recent changes. The top-ranked function, update_filename_and_move_files in src/documents/signals/handlers.py, carries an activity-weighted risk score of 18.3 with a cyclomatic complexity of 71 and 3 touches in the last 30 days — that combination makes it a live regression risk, not a cleanup backlog item. I would start there, then move to run_workflows in the same file, because together they account for two of the five most volatile surfaces in the codebase right now.

The table below ranks functions by activity-weighted risk — a score that multiplies structural complexity by recent commit frequency. A function that is both hard to understand (high cyclomatic complexity) and actively changing is a higher priority than one that is complex but untouched. CC = cyclomatic complexity (independent execution paths); ND = max nesting depth; FO = fan-out (distinct callees).

Top 5 Hotspots

FunctionFileRiskCCNDFO
update_filename_and_move_filessrc/documents/signals/handlers.py18.371537
reject_dangerous_svgsrc/paperless/validators.py17.036520
parse_dict_from_strsrc/paperless/settings/parsers.py16.730519
_watch_directorysrc/documents/management/commands/document_consumer.py16.646522
run_workflowssrc/documents/signals/handlers.py16.559520

Large Repo Analysis

paperless-ngx is a large repository. To stay within memory constraints, this analysis used hybrid touch mode: structural complexity — CC, ND, FO — is measured precisely for every function. Git activity is tracked at the function level (via git log -L) only for files with 5 or more commits in the last 30 days; other files use a file-level approximation. Rankings therefore surface functions that are both structurally complex and in the most actively-changing parts of the codebase. Dormant code with high structural complexity will rank lower than it would under a full per-function analysis — to surface it, run hotspots analyze . --per-function-touches on a machine with sufficient memory.

Quadrant distribution

Before looking at individual functions, it’s worth noting the overall shape of the codebase.

Quadrant distribution across 2,736 analyzed functions
Fire575Watch2161

2,736 functions analyzed

Every function sits in either ‘fire’ or ‘watch’ — there are no purely dormant high-complexity functions. The 575 fire-quadrant functions are the ones where complexity and recent activity overlap; the 2,161 watch functions are active but structurally manageable.

Detected Antipatterns
Complex Branching×5Complex Branching
High cyclomatic complexity — many independent execution paths, each a potential bug surface and required test case.
Deeply Nested×5Deeply Nested
Control structures nested 4+ levels deep, making it hard to reason about the full execution state at inner branches.
God Function×5God Function
Calls an unusually large number of distinct functions (high fan-out), making it the structural centre of gravity for a subsystem.
Exit Heavy×4Exit Heavy
Multiple return or throw paths dispersed through the body — each exit needs separate test coverage.
Long Function×4Long Function
Function body is too long to review in a single pass; likely contains multiple distinct responsibilities.
Middle Man×1Middle Man
Mostly delegates to one other function without adding meaningful logic — a refactoring candidate for removal or consolidation.

Every top hotspot hits the god_function and complex_branching patterns simultaneously. That’s not a coincidence — it reflects a design pattern where a single function owns the full lifecycle of a cross-cutting concern (file movement, workflow execution, SVG sanitization) rather than delegating to smaller, testable units.


Top 5 hotspots

update_filename_and_move_files — handlers.py

update_filename_and_move_files
src/documents/signals/handlers.py
18.3
critical
CC 71
ND 5
FO 37
touches/30d 3

This is a Django signal handler — it fires when a Document or CustomFieldInstance is saved and needs to rename and physically relocate the document’s file on disk. The source excerpt confirms it handles type discrimination, file lock acquisition, stale-data refresh, filename generation, path collision detection, and move validation all within a single function body. That’s a wide surface area.

A cyclomatic complexity of 71 means there are 71 independent execution paths through this function. At CC 10 a function starts becoming hard to test in isolation; at CC 30 it warrants immediate attention; at 71 it is extremely difficult to reason about and nearly impossible to cover with tests exhaustively. The fan-out of 37 compounds this: in Python, where duck typing means the actual types resolved at runtime can differ from what’s visible statically, 37 distinct callees represent a conservative lower bound on the coupling. A change to any one of those 37 called functions can silently affect this handler’s behavior.

Cyclomatic Complexity 71
threshold: 30
Fan-Out 37
threshold: 15

The deeply_nested pattern (max nesting depth 5) and exit_heavy pattern together mean the function has multiple early-return paths interleaved with nested conditionals — the source excerpt shows this clearly in the validate_move inner function and the subsequent lock-guarded block. This file has the highest density of PR review comments of any hotspot here; that level of review discussion suggests this code has already attracted scrutiny and that reviewers find it hard to assess quickly.

With 3 touches in the last 30 days and the last change 13 days ago, this function is actively changing. A bug introduced here doesn’t just corrupt a single document — it can misplace files for any document saved while a rename is in flight.

Recommendation: Extract validate_move (already a nested function) into a standalone module-level function, then separate the archive-file move logic from the original-file move logic into distinct helpers. Getting the CC below 20 is achievable in one focused refactor session and would reduce the regression surface for every future change to filename generation.


reject_dangerous_svg — validators.py

reject_dangerous_svg
src/paperless/validators.py
16.99
critical
CC 36
ND 5
FO 20
touches/30d 3

The docstring in the source excerpt references GHSA-6p53-hqqw-8j62 — a specific GitHub Security Advisory. This function is a security control: it parses SVG uploads and rejects files containing dangerous tags, style patterns, URI schemes, or attributes that could enable XSS or SSRF. The security context alone makes live activity here worth watching closely.

A CC of 36 with nesting depth 5 reflects the layered nature of SVG validation: the function walks the element tree, then branches on tag type, then iterates attributes, then checks namespace expansion, then validates URI schemes. Each of those layers adds branching paths. The exit_heavy pattern shows up here too — multiple raise ValidationError paths are scattered throughout rather than being consolidated, which means test coverage requires exercising each rejection case independently.

Cyclomatic Complexity 36
threshold: 10

Fan-out of 20 in a security validator is a meaningful coupling signal. In Python, the lxml calls, string operations, and allowlist lookups are all implicit dependencies — a change to how ALLOWED_SVG_TAGS, DANGEROUS_STYLE_PATTERNS, or DANGEROUS_SCHEMES are defined affects behavior here without touching this function directly.

Every recorded commit to this file is tagged as a bug fix. Combined with 3 touches in 30 days, this function is being actively hardened. That’s appropriate for a security control, but it also means each change carries regression risk for the cases the previous changes were intended to fix.

Recommendation: Extract each validation concern — tag allowlisting, style pattern scanning, namespace normalization, URI scheme checking — into its own private function. This reduces the CC of reject_dangerous_svg itself to a coordinator and makes each security rule independently testable with a single focused test case.


parse_dict_from_str — parsers.py

parse_dict_from_str
src/paperless/settings/parsers.py
16.7
critical
CC 30
ND 5
FO 19
touches/30d 3

This function lives in the settings layer and parses environment variable strings — the kind of comma-separated key=value input that operators use to configure paperless-ngx at deployment time. The source excerpt shows it supports dot-notation for nested keys, a type_map for casting values, and a defaults dictionary, all combined with custom boolean handling. That’s a lot of contract for a single function.

CC 30 is at the threshold where I’d call immediate attention warranted. The long_function and god_function patterns both flag here, and the source excerpt confirms why: the function defines three nested helper functions (_set_nested, _get_nested, _has_nested) inline, then orchestrates parsing, type casting, and nested assignment in a single pass. Nesting depth 5 with fan-out 19 means the internal control flow is deep even before accounting for the helper calls.

Cyclomatic Complexity 30
threshold: 10

The practical risk is that settings parsing failures at startup are silent or poorly attributed — a malformed environment variable processed by a path through this function that isn’t covered by tests could produce a misconfigured but running instance. With 3 touches in 30 days, the parsing logic is still being extended.

Recommendation: Promote the three nested helper functions to module-level private functions, then split the parsing phase (string splitting, pair extraction) from the type-casting phase into two separate functions. This brings the main function’s CC well below 15 and makes the dot-notation nested-key logic independently testable.


_watch_directory — document_consumer.py

_watch_directory
src/documents/management/commands/document_consumer.py
16.64
critical
CC 46
ND 5
FO 22
touches/30d 3

This is the core event loop for the document consumer management command — it watches an input directory for new files, tracks file stability to avoid consuming partially-written uploads, handles both native filesystem events and polling modes, and manages a periodic rescan cadence. The source excerpt shows a function that configures watchdog timeouts, branches on polling vs. native mode, branches again on testing vs. production mode, and then enters a while not self.stop_flag.is_set() loop. All of that is in one function.

Cyclomatic Complexity 46
threshold: 30

CC 46 with nesting depth 5 in an event loop is particularly hard to reason about because the branching isn’t just logical — it’s temporal. The cap_for_rescan inner function (visible in the excerpt) exists to manage one specific interaction between two timeout values. The deeply_nested and exit_heavy patterns show that the loop body itself has multiple early-exit conditions layered inside the iteration. Fan-out of 22 means the loop touches a broad set of collaborators: the stability tracker, the stop flag, the file watcher, the queued-paths set, and the consumer filter, at minimum.

With 3 touches in the last 30 days and the last change 13 days ago, this event loop is being actively adjusted. A regression here doesn’t surface as a test failure — it surfaces as documents silently not being consumed, or files being processed before they’re fully written.

Recommendation: Extract the timeout calculation logic (the cap_for_rescan helper and the polling/native/testing branching) into a dedicated method, and extract the inner loop body — the per-event handling — into a separate _handle_events method. This would bring the CC of _watch_directory itself down into the low double digits and isolate the timeout math where it can be unit-tested independently.


run_workflows — handlers.py

run_workflows
src/documents/signals/handlers.py
16.45
critical
CC 59
ND 5
FO 20
touches/30d 3

This function lives in the same file as update_filename_and_move_files and carries nearly as much structural weight. Its docstring (visible in the excerpt) describes two distinct execution modes: when overrides is provided, it operates on a DocumentMetadataOverrides object during consumption; when it isn’t, it mutates the actual Document. That dual-mode design is visible in the branching — every action path forks on use_overrides, and the function additionally handles document version detection, soft-delete checking, workflow matching, ordered action iteration, and email/webhook attachment resolution.

Cyclomatic Complexity 59
threshold: 30

CC 59 is extreme. Each of those 59 paths is a required test case to cover the function fully, and Python’s dynamic dispatch means that WorkflowAction.WorkflowActionType comparisons and document_matches_workflow calls are resolved at runtime — the actual execution graph is wider than static analysis can capture. Fan-out of 20 combined with the god_function and long_function patterns means this function owns the full workflow execution contract rather than delegating to per-action-type handlers.

The density of PR review comments on handlers.py (shared with update_filename_and_move_files) is the strongest external signal in the dataset: reviewers are spending significant effort on this file. With 3 touches in 30 days, that review burden is ongoing.

Recommendation: The dual-mode use_overrides pattern is the sharpest split point. I’d extract the consumption-time path (where overrides is provided) into a run_workflows_for_consumption function and the post-save path into run_workflows_for_document. Then introduce a per-action-type dispatcher — a registry or method-per-type pattern — to eliminate the large if action.type == chain. Either step alone reduces CC by roughly half.

Patterns Found

Antipatterns detected across the top functions in this snapshot:

PatternOccurrences
complex_branching5
deeply_nested5
god_function5
exit_heavy4
long_function4
middle_man1

These labels belong to two tiers — Tier 1 (structural): complex_branching, deeply_nested, exit_heavy, long_function, god_function. Tier 2 (relational/temporal): hub_function, cyclic_hub, middle_man, neighbor_risk, stale_complex, churn_magnet, shotgun_target, volatile_god.

Reproduce This Analysis

git clone https://github.com/paperless-ngx/paperless-ngx
cd paperless-ngx
git checkout 0addb448f5b9fc3579b4285f779871ec2823e7a6
hotspots analyze . --mode snapshot --explain-patterns --force --hybrid-touches 5

To run the same analysis on your own codebase, run hotspots analyze . --mode snapshot in any local git repo — no configuration required.

I use Hotspots to highlight structural and activity risk — not “bad code.” I treat these findings as a prioritization aid, not a bug predictor. Editorial policy →

Related Analyses