Skip to content

Fix Storage.path clobber at import time (#6061) - #157

Draft
evnchn wants to merge 2 commits into
mainfrom
fix/storage-path-import-regression
Draft

Fix Storage.path clobber at import time (#6061)#157
evnchn wants to merge 2 commits into
mainfrom
fix/storage-path-import-regression

Conversation

@evnchn

@evnchn evnchn commented May 20, 2026

Copy link
Copy Markdown
Owner

Motivation

Fixes zauberzeug#6061. PR zauberzeug#5960 introduced a module-level Storage.path = None assignment in nicegui/testing/general_fixtures.py intended as a "not configured yet" sentinel for pytest. But that line executes on plain import nicegui.testing — not just under pytest — so any production app whose import graph reaches the testing module has its Storage.path clobbered to None.

Concretely, NiceGUI's own docs site does this: website/documentation/content/screen_documentation.py:2 does from nicegui.testing import Screen, which transitively imports general_fixtures, which sets Storage.path = None. The next app.storage.user access then crashes in Storage._create_persistent_dict at nicegui/storage.py:98:

TypeError: unsupported operand type(s) for /: 'NoneType' and 'str'

Empirical repro (pre-fix):

>>> from nicegui.storage import Storage
>>> Storage.path
PosixPath('/.../.nicegui')
>>> from nicegui.testing import Screen
>>> Storage.path
None

Implementation

Stop mutating Storage.path at module-level. The tempdir creation now happens only inside pytest_configure, and its cleanup is registered via atexit.register(shutil.rmtree, ..., ignore_errors=True) — the same pattern already used in screen_plugin.py for DOWNLOAD_DIR. This lets us drop pytest_unconfigure entirely (and its three re-exports in plugin.py, user_plugin.py, screen_plugin.py).

Net change in general_fixtures.py: +1 import, -1 module-level assignment, -1 hook function, -1 sentinel variable (after design iteration; see below).

A regression test is added in tests/test_lazy_imports.py (subprocess pattern matches existing test_module_access_does_not_import_others):

def test_importing_nicegui_testing_does_not_clobber_storage_path():
    result = subprocess.run(['python3', '-c', dedent('''\
        from nicegui.storage import Storage
        orig = Storage.path
        from nicegui.testing import Screen, User  # noqa: F401
        assert Storage.path == orig, ...
    ''')], capture_output=True, text=True, timeout=30, check=False)
    assert result.returncode == 0, result.stderr

Verified the test fails on pre-fix code (stash/unstash) and passes after — not vacuous.

Design discussion: why atexit instead of a `_storage_configured` boolean + `pytest_unconfigure`?

The first commit on this branch used a separate module-level _storage_configured: bool as the sentinel and kept a symmetric pytest_unconfigure hook for cleanup. The second commit replaces that with atexit.register and drops the hook.

Alternatives considered for replacing the broken Storage.path is None sentinel:

  1. Module-level _storage_configured boolean — clean, but adds a new mutable module-level variable.
  2. Capture _INITIAL_STORAGE_PATH = Storage.path at import, compare in pytest_configure — still a new variable, and fragile if a user has set Storage.path themselves before pytest runs.
  3. Class attribute on Storage — leaks pytest concerns into production class. Rejected.
  4. pytest.StashKey — proper pytest idiom but still introduces a module-level StashKey instance, same effective scope as a bool.
  5. Function attribute (pytest_configure._called) — poor discoverability. Rejected.
  6. Drop the guard, use atexit.register for cleanup — zero new module-level variables, matches existing screen_plugin.py pattern. Chosen.

Trade-off vs the boolean + pytest_unconfigure approach:

  • For one-shot pytest invocations (CI, normal dev runs): identical behavior.
  • For in-process repeat usage (IDE "re-run tests", pytest --looponfail, some pytest-xdist setups): cleanup is deferred to process exit instead of pytest session end. Leaked tempdirs accumulate in the system temp dir until the Python process dies. The directories are small (empty or a few JSON files) and OS-GC'd eventually.

Why the dual-plugin re-entry guard wasn't necessary:
The previous "is already configured" check guarded against pytest_plugins = ['nicegui.testing.plugin', 'nicegui.testing.user_plugin'] both being loaded, which would call _general_pytest_configure twice. NiceGUI's own tests/conftest.py loads only nicegui.testing.plugin (which already re-exports user-plugin fixtures), and the dual-load is redundant by design — it would cause fixture redefinition warnings. Without the guard, the worst case is one extra tempfile.mkdtemp call whose result is immediately shadowed; the orphaned dir is still rmtree'd at exit via its own atexit registration.

I'm happy to revert to the boolean approach if you prefer the deterministic per-session cleanup.

Validation

  • pytest tests/test_storage.py tests/test_lazy_imports.py tests/test_main_file_marker.py tests/test_user_simulation.py → 102 passed / 2 xfailed (pre-existing).
  • ruff check, pylint, mypy on changed files → clean.

Progress

  • The PR title is a short phrase starting with a verb like "Add ...", "Fix ...", "Update ...", "Remove ...", etc.
  • The implementation is complete.
  • This PR does not address a security issue.
  • Pytests have been added (regression test in tests/test_lazy_imports.py).
  • Documentation has been added/updated or is not necessary.
  • No breaking changes to the public API.

Draft against evnchn/nicegui:main for review/finetune before upstreaming to zauberzeug/nicegui. Two commits are currently on the branch; squash on merge if preferred.

evnchn and others added 2 commits May 20, 2026 21:12
PR zauberzeug#5960 set `Storage.path = None` at module top of `general_fixtures.py`
as the "not configured yet" sentinel. That assignment runs on plain
`import nicegui.testing` (not just under pytest), so any production app
whose import graph reaches the testing module — e.g. the docs site via
`from nicegui.testing import Screen` in `screen_documentation.py` — has
its `Storage.path` clobbered to `None`. The next `app.storage.user`
access then crashes in `Storage._create_persistent_dict`:

    TypeError: unsupported operand type(s) for /: 'NoneType' and 'str'

Fix: replace the overloaded `Storage.path is None` sentinel with a
separate module-level `_storage_configured` boolean. `Storage.path`
keeps its class-level default on plain import and is only assigned
inside `pytest_configure`. Re-entry guard semantics are preserved for
the case where `plugin.py` and `user_plugin.py` both load and both
call `_general_pytest_configure`.

Regression test added in `tests/test_lazy_imports.py`: subprocess
imports `nicegui.testing` and asserts `Storage.path` is unchanged.
Fails on the pre-fix code, passes after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the module-level boolean sentinel introduced in the previous
commit with an `atexit.register(shutil.rmtree, ..., ignore_errors=True)`
call inside `pytest_configure`. This matches the existing pattern for
`DOWNLOAD_DIR` in `screen_plugin.py` and lets us drop `pytest_unconfigure`
entirely (along with its three re-exports in plugin.py, user_plugin.py,
screen_plugin.py).

Trade-off vs the boolean approach: cleanup is deferred to process exit
instead of pytest session end. For one-shot pytest invocations (CI,
normal dev runs) the behavior is identical. For in-process repeat usage
(IDE test re-runs, `pytest --looponfail`) leaked tempdirs accumulate in
the system temp dir until process exit — small files, OS-GC'd
eventually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

Regression: Storage.path = None at import time breaks production apps importing nicegui.testing

1 participant