Skip to content

Add override_settings() context manager for test singleton overrides#75

Open
henry0816191 wants to merge 3 commits into
developfrom
feat/override-settings
Open

Add override_settings() context manager for test singleton overrides#75
henry0816191 wants to merge 3 commits into
developfrom
feat/override-settings

Conversation

@henry0816191

@henry0816191 henry0816191 commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add override_settings(**kwargs) to temporarily override fields on the module-level settings singleton in tests and integration scenarios.
  • Mutate the existing Settings instance in place so all from paperscout.config import settings importers see changes (reassignment would not propagate to already-imported bindings).
  • Snapshot and restore on exit, including when the with block raises; nested overrides are supported.
  • Document the mechanism in the config.py module docstring and clarify in conftest.py that _PAPERSCOUT_TESTING is import-time only for Slack validation bypass.
  • Add unit tests for apply/restore/exception/nesting/validation/importer visibility.
  • Migrate test_download_uses_wg21_index_timeout_from_settings to demonstrate real usage without cfg=make_test_settings(...).

Test plan

  • uv run pytest tests/test_config.py::TestOverrideSettings -q
  • uv run pytest tests/test_sources.py::TestWG21Index::test_download_uses_wg21_index_timeout_from_settings -q
  • uv run pytest tests/ -q --cov=paperscout --cov-fail-under=90 (382 passed, 93% coverage)

Summary by CodeRabbit

Release Notes

  • New Features

    • Added an override_settings context manager for temporary runtime configuration overrides that automatically restore afterward, with validation of override keys and values.
    • Supports nested overrides and prevents concurrent non-LIFO override usage.
  • Tests

    • Added/extended tests to verify restoration after exceptions, correct nested behavior, rejection of unknown fields, and visibility of overrides to other importers.
    • Updated a source download timeout test to use override_settings.
  • Documentation

    • Clarified that configuration settings are process-wide and how runtime overrides affect live reads.

@henry0816191 henry0816191 self-assigned this Jun 22, 2026
@henry0816191 henry0816191 requested a review from wpak-ai as a code owner June 22, 2026 19:33
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a07f548c-60be-4143-bc58-368494d272a6

📥 Commits

Reviewing files that changed from the base of the PR and between 0eff94b and 7424b34.

📒 Files selected for processing (2)
  • src/paperscout/config.py
  • tests/test_config.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_config.py
  • src/paperscout/config.py

📝 Walkthrough

Walkthrough

Adds override_settings(**kwargs) as a context manager in src/paperscout/config.py that temporarily mutates the settings singleton in place, using a _override_stack for nested call support and restoring the snapshot on exit. The module docstring is expanded, tests are added in test_config.py, and the existing WG21 timeout test in test_sources.py is migrated to use the new API.

Changes

override_settings context manager

Layer / File(s) Summary
override_settings implementation and module docstring
src/paperscout/config.py
Module docstring expanded to document the settings singleton and test override mechanism. Adds contextmanager, Iterator, Any imports, and thread/asyncio context detection helpers. Introduces module-level _override_stack list and the override_settings context manager that validates keys against Settings.model_fields, snapshots current values via model_dump(), mutates the singleton in place for the context duration, and restores on exit with nested override support and concurrency-violation detection.
Tests and migration
tests/test_config.py, tests/conftest.py, tests/test_sources.py
Reformats test_config.py imports into a parenthesized multi-line list and extends TestOverrideSettings with multiple test methods covering within-block override visibility, post-block restoration, exception-safe restore, nested overrides, unknown-field TypeError, singleton identity, and concurrent-thread RuntimeError detection. Expands conftest.py comment to document import-time behavior and reference override_settings. Adds override_settings import and migrates test_download_uses_wg21_index_timeout_from_settings from make_test_settings/cfg= construction to override_settings(wg21_index_timeout_s=42.0).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • Singleton override context manager (3pt) #73: This PR directly implements the override_settings() context manager requested in that issue, including snapshot/restore logic, nested override support, LIFO concurrency detection, and the documented test pattern.

Possibly related PRs

  • cppalliance/paperscout#23: The wg21_index_timeout_s timeout behavior introduced in PR #23 is the subject of the migrated test in test_sources.py that now uses override_settings instead of explicit config construction.

Suggested reviewers

  • wpak-ai

Poem

🐇 Hop into context, tweak a field or two,
The singleton bends — but just while I'm in view.
A snapshot preserved, a stack to keep track,
When I exit the block, everything snaps back.
No monkey-patching needed, the settings obey! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding an override_settings() context manager for test singleton overrides, which is the primary objective of the PR.
Description check ✅ Passed The description includes a comprehensive Summary section detailing the changes and objectives, completed Test plan with checkmarks showing tests run and coverage results, but lacks explicit Related issues section.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/override-settings

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/paperscout/config.py`:
- Around line 224-252: The override_settings function uses a simple list-based
stack (_override_stack) which is not safe for concurrent or overlapping
contexts, as snapshots could be restored out of order causing state leaks. Add
an owner guard mechanism by storing context identifiers (such as thread ID or
async task ID) alongside each snapshot in _override_stack, then in the finally
block verify that the popped entry matches the current context before
restoring—raising a RuntimeError if a non-LIFO pattern is detected where the
stack owner doesn't match the current execution context.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 23f2d788-a420-4757-adaa-db30af948a9b

📥 Commits

Reviewing files that changed from the base of the PR and between e56e8e2 and 66d7cd2.

📒 Files selected for processing (4)
  • src/paperscout/config.py
  • tests/conftest.py
  • tests/test_config.py
  • tests/test_sources.py

Comment thread src/paperscout/config.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/paperscout/config.py (1)

233-233: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject cross-owner overrides before mutating global settings.

The stack check happens only in finally, so a second thread/task can still enter and overwrite the singleton while the first override is active. If the second exits first, no error is raised; if the first exits first, it raises without restoring and the later restore can leave the first override value behind. Gate entry with a lock and reject active frames owned by another execution context.

Proposed hardening: serialize stack access and reject cross-owner overlap on entry
 _override_stack: list[_OverrideFrame] = []
+_override_lock = threading.RLock()
@@
-    frame = _OverrideFrame(snapshot=settings.model_dump(), owner=_execution_context())
-    _override_stack.append(frame)
+    owner = _execution_context()
+    with _override_lock:
+        if _override_stack and _override_stack[-1].owner != owner:
+            raise RuntimeError(
+                "override_settings: concurrent override detected; "
+                "nested overrides must use the same thread/task"
+            )
+        frame = _OverrideFrame(snapshot=settings.model_dump(), owner=owner)
+        _override_stack.append(frame)
     try:
         for key, value in kwargs.items():
             setattr(settings, key, value)
         yield settings
     finally:
         current = _execution_context()
-        if frame.owner != current:
-            raise RuntimeError(
-                "override_settings: owner context changed during override "
-                f"(entered as {frame.owner!r}, exiting as {current!r})"
-            )
-        if not _override_stack or _override_stack[-1] is not frame:
-            if frame in _override_stack:
-                _override_stack.remove(frame)
-            raise RuntimeError(
-                "override_settings: non-LIFO or concurrent override detected; "
-                "nested overrides must exit in reverse order on the same thread/task"
-            )
-        _override_stack.pop()
-        _restore_snapshot(frame.snapshot)
+        with _override_lock:
+            if frame.owner != current:
+                if _override_stack and _override_stack[-1] is frame:
+                    _override_stack.pop()
+                    _restore_snapshot(frame.snapshot)
+                raise RuntimeError(
+                    "override_settings: owner context changed during override "
+                    f"(entered as {frame.owner!r}, exiting as {current!r})"
+                )
+            if not _override_stack or _override_stack[-1] is not frame:
+                if frame in _override_stack:
+                    _override_stack.remove(frame)
+                raise RuntimeError(
+                    "override_settings: non-LIFO or concurrent override detected; "
+                    "nested overrides must exit in reverse order on the same thread/task"
+                )
+            _override_stack.pop()
+            _restore_snapshot(frame.snapshot)

Also applies to: 271-292

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/paperscout/config.py` at line 233, The global _override_stack variable
has a race condition where multiple threads or tasks can interfere with each
other because concurrent access is not synchronized and ownership validation
happens too late in the finally block. Add a threading lock to serialize all
access to _override_stack and implement ownership checking at the entry point of
override operations (before pushing to the stack) rather than only in the
finally block. When entering an override context, acquire the lock and verify
that no active frames in the stack are owned by a different execution context.
If a conflict is detected, reject the override immediately. This prevents the
scenario where a second execution context can overwrite or corrupt the stack
state while a first override is still active.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/paperscout/config.py`:
- Around line 271-276: The code directly uses setattr to apply override values
to the settings object without validation, which bypasses Pydantic's constraints
since the model does not have validate_assignment enabled, allowing invalid
values like negative timeouts to be assigned. Instead of iterating through
kwargs and using setattr directly, create a validated copy of the settings by
calling settings.model_validate on the merged configuration (combining the
current model_dump with the overrides from kwargs), then apply only the
validated result back to the settings object to ensure all constrained fields
like wg21_index_timeout_s are properly validated before mutation.

In `@tests/test_config.py`:
- Around line 147-182: The test_override_settings_rejects_concurrent_threads
test is non-deterministic because both threads start immediately without
ensuring thread A enters its override_settings context first. Modify the test to
wait for the b_inside event to be set by thread B before actually starting
thread B (start thread A first, then wait for b_inside to be signaled, then
start thread B). Additionally, verify that both t_a.join() and t_b.join()
actually complete within their timeout (check that they return True) before
clearing the config_module._override_stack to ensure the threads have fully
finished execution.

---

Duplicate comments:
In `@src/paperscout/config.py`:
- Line 233: The global _override_stack variable has a race condition where
multiple threads or tasks can interfere with each other because concurrent
access is not synchronized and ownership validation happens too late in the
finally block. Add a threading lock to serialize all access to _override_stack
and implement ownership checking at the entry point of override operations
(before pushing to the stack) rather than only in the finally block. When
entering an override context, acquire the lock and verify that no active frames
in the stack are owned by a different execution context. If a conflict is
detected, reject the override immediately. This prevents the scenario where a
second execution context can overwrite or corrupt the stack state while a first
override is still active.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4ee3da17-3d27-42fa-a173-e976d982943d

📥 Commits

Reviewing files that changed from the base of the PR and between 66d7cd2 and 0eff94b.

📒 Files selected for processing (2)
  • src/paperscout/config.py
  • tests/test_config.py

Comment thread src/paperscout/config.py
Comment thread tests/test_config.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant