diff --git a/.agents/skills/usethis-python-test/SKILL.md b/.agents/skills/usethis-python-test/SKILL.md index 0af052a3..8053a6e8 100644 --- a/.agents/skills/usethis-python-test/SKILL.md +++ b/.agents/skills/usethis-python-test/SKILL.md @@ -4,7 +4,7 @@ description: General guidelines for writing tests in the usethis project, includ compatibility: usethis, Python, pytest license: MIT metadata: - version: "1.0" + version: "1.1" --- # Python Test Guidelines @@ -52,3 +52,44 @@ Use the minimum depth needed to clearly communicate the test's context. Avoid ne ### No docstrings on test classes or functions Test classes and test functions should not have docstrings. The class and function names should be descriptive enough to communicate what is being tested. + +## Using `files_manager` in tests + +The `files_manager()` context manager defers all configuration file writes until the context exits. This has important implications for how tests are structured, especially when subprocess calls are involved. + +### When to exit `files_manager` before a subprocess + +Any subprocess that reads configuration files from the filesystem (e.g. `ruff`, `pytest`, `pre-commit`, `deptry`, `codespell`) will **not** see in-memory changes made inside a `files_manager()` context. You must exit the context (flush writes to disk) before running the subprocess. + +```python +# Correct: exit files_manager before the subprocess reads config from disk +with change_cwd(tmp_path), files_manager(): + use_ruff() + +call_uv_subprocess(["run", "ruff", "check", "."], change_toml=False) +``` + +```python +# Wrong: subprocess runs inside the context, but config hasn't been flushed yet +with change_cwd(tmp_path), files_manager(): + use_ruff() + call_uv_subprocess(["run", "ruff", "check", "."], change_toml=False) + # ruff may not see the configuration written by use_ruff() +``` + +### When it is safe to stay inside the same context + +Multiple usethis function calls that operate through `FileManager`-based access (not subprocesses) can safely share a single `files_manager()` context. They see each other's uncommitted in-memory changes via `get()` and `commit()`. + +```python +# Safe: both functions use FileManager access, no subprocess involved +with change_cwd(tmp_path), files_manager(): + use_ruff() + use_deptry() + # Both tools' config changes are visible to each other in memory +``` + +### Rule of thumb + +- **FileManager-only operations** (e.g. `use_*` functions, `get_deps_from_group`, assertions on config state): safe to combine in one context. +- **Subprocess calls** (e.g. `call_uv_subprocess`, `subprocess.run`, `call_subprocess`): require an atomic write first, so exit the `files_manager` context before running them. diff --git a/src/usethis/_config_file.py b/src/usethis/_config_file.py index 3f0e1010..68062834 100644 --- a/src/usethis/_config_file.py +++ b/src/usethis/_config_file.py @@ -22,7 +22,24 @@ @contextlib.contextmanager def files_manager() -> Iterator[None]: - """Context manager that opens all configuration file managers for coordinated I/O.""" + """Context manager that opens all configuration file managers for coordinated I/O. + + On entry, this context manager locks every known configuration file manager. Each + manager uses deferred (lazy) reads: a file is only read from disk the first time it + is accessed via `get()`. In-memory changes made with `commit()` are immediately + visible to other operations within the same context, even before they are written to + disk. + + On exit, all modified files are written (flushed) to disk atomically. Files that were + only read but never modified are not rewritten. + + Because writes are deferred until context exit, any operation that reads configuration + files via the filesystem (e.g. a subprocess such as `ruff`, `pytest`, or + `pre-commit`) will not see in-memory changes until the context manager has exited and + the files have been flushed. If you need to run a subprocess that depends on + configuration written by functions inside this context, exit the context first and + then run the subprocess. + """ with ( PyprojectTOMLManager(), SetupCFGManager(), diff --git a/src/usethis/_file/manager.py b/src/usethis/_file/manager.py index 1d287668..c2e88477 100644 --- a/src/usethis/_file/manager.py +++ b/src/usethis/_file/manager.py @@ -44,6 +44,20 @@ class FileManager(Generic[DocumentT], metaclass=ABCMeta): This class implements the Command Pattern, encapsulating file operations. It defers writing changes to the file until the context is exited, ensuring that file I/O operations are performed efficiently and only when necessary. + + Lifecycle: + 1. **Enter** (`__enter__`): The file is locked. No disk I/O occurs yet. + 2. **Read** (`get`): The file is lazily read from disk on first access. Subsequent + calls return the in-memory copy. + 3. **Write** (`commit`): Changes are stored in memory and the file is marked dirty. + The changes are immediately visible to other code that calls `get()` on the + same manager within the same context, but they are *not* yet on disk. + 4. **Exit** (`__exit__`): All dirty files are flushed (written) to disk atomically. + + Because writes are deferred, subprocesses that read the managed file from the + filesystem will not see uncommitted in-memory changes. Exit the context manager (or + call `write_file()` explicitly) before invoking a subprocess that depends on the + file's on-disk content. """ # https://github.com/python/mypy/issues/5144