Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions tests/test_atomic_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
from pathlib import Path

import pytest
import typer

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Worth a look · consistency · tests/test_atomic_file.py:6 · PR title/diff inconsistency

What: PR title emphasizes topics that do not appear in changed paths or added code (destinations, failures, replacing, writes).

Why it matters: Align the message or the diff. (scanner:commit_consistency)

Do this: Either fix it in this PR, or reply here explaining why the current behavior is intentional.

Evidence: title='Fix atomic writes replacing destinations after command failures'; missing_in_diff=['destinations', 'failures', 'replacing', 'writes']

from typer.testing import CliRunner

from . import atomic_write_example as mod

runner = CliRunner()


def test_atomic_write(tmp_path: Path) -> None:
original_content = "existing-content\n"
Expand Down Expand Up @@ -89,6 +93,41 @@ def test_atomic_api(tmp_path: Path) -> None:
assert output_file.read_text(encoding="utf-8") == "atomic-api-done\n"


@pytest.mark.parametrize("lazy", [True, False], ids=["lazy", "eager"])
@pytest.mark.parametrize(
"destination_exists", [True, False], ids=["existing", "missing"]
)
def test_atomic_write_callback_failure(
tmp_path: Path, lazy: bool, destination_exists: bool
) -> None:
original_content = "existing-content\n"
output_file = tmp_path / "atomic-failure-target.txt"
if destination_exists:
output_file.write_text(original_content, encoding="utf-8")
initial_entries = set(tmp_path.iterdir())

app = typer.Typer()

@app.command()
def write_atomic_failure(
config: typer.FileTextWrite = typer.Option(..., atomic=True, lazy=lazy),
) -> None:
config.write("partial-content\n")
config.flush()
raise RuntimeError("callback failed")

result = runner.invoke(app, [f"--config={output_file}"])

assert result.exit_code == 1
assert isinstance(result.exception, RuntimeError)
assert str(result.exception) == "callback failed"
if destination_exists:
assert output_file.read_text(encoding="utf-8") == original_content
else:
assert not output_file.exists()
assert set(tmp_path.iterdir()) == initial_entries


@pytest.mark.parametrize(
("command_name", "expected_message"),
[
Expand Down
8 changes: 7 additions & 1 deletion typer/_click/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,13 @@ def close(self, delete: bool = False) -> None:
if self.closed:
return # pragma: no cover
self._f.close()
os.replace(self._tmp_filename, self._real_filename)
if delete:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should fix · reliability · typer/_click/_compat.py:419 · Potential resource leak on os.replace failure

What: The close method uses os.replace without ensuring the temporary file is cleaned up if the replacement fails (e.g., cross-device link error or permission issue).
Why it matters: Leaving orphaned temporary files in the filesystem can lead to disk space exhaustion or security issues if sensitive data is left in a predictable location.
Do this: Wrap os.replace in a try/finally block to ensure os.unlink(self._tmp_filename) is called if the replacement fails.

Suggestion:

Suggested change
if delete:
try:
os.replace(self._tmp_filename, self._real_filename)
finally:
if os.path.exists(self._tmp_filename):
os.unlink(self._tmp_filename)

Callers that may care:

  • typer/testing.py:33__init__super().__init__()
  • typer/testing.py:65__init__super().__init__(buffer, **kwargs)
  • tests/test_prepare_release.py:34__init__new_content = update_version_file(content, "0.26.3", Path("typer/__init__.py"))
  • tests/test_prepare_release.py:43__init__update_version_file(content, "0.26.2", Path("typer/__init__.py"))
  • pyproject.toml:92__init__version = { source = "file", path = "typer/__init__.py" }

try:
os.unlink(self._tmp_filename)
except OSError: # pragma: no cover
pass
else:
os.replace(self._tmp_filename, self._real_filename)
self.closed = True

def __getattr__(self, name: str) -> Any:
Expand Down
10 changes: 8 additions & 2 deletions typer/_click/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,10 @@ def convert(
)

if ctx is not None:
ctx.call_on_close(lf.close_intelligently)
if self.atomic:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Worth a look · design · typer/_click/types.py:542 · No behavior risk: api_signature

What: No behavior risk: api_signature

Why it matters: The changes to File.convert and _AtomicFile logic are internal implementation details of how resources are managed within the Click context, and do not alter the public API signature of File or ParamType.

Do this: Either fix it in this PR, or reply here explaining why the current behavior is intentional.

Evidence: typer/_click/types.py:542

Callers that may care:

  • typer/testing.py:33__init__super().__init__()
  • typer/testing.py:65__init__super().__init__(buffer, **kwargs)
  • tests/test_prepare_release.py:34__init__new_content = update_version_file(content, "0.26.3", Path("typer/__init__.py"))
  • tests/test_prepare_release.py:43__init__update_version_file(content, "0.26.2", Path("typer/__init__.py"))
  • pyproject.toml:92__init__version = { source = "file", path = "typer/__init__.py" }

ctx.with_resource(lf)
else:
ctx.call_on_close(lf.close_intelligently)

return cast("IO[Any]", lf)

Expand All @@ -554,7 +557,10 @@ def convert(
# type is used with prompts.
if ctx is not None:
if should_close:
ctx.call_on_close(safecall(f.close))
if self.atomic:
ctx.with_resource(f)
else:
ctx.call_on_close(safecall(f.close))
else:
ctx.call_on_close(safecall(f.flush))

Expand Down
5 changes: 4 additions & 1 deletion typer/_click/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ def __exit__(
exc_value: BaseException | None,
tb: TracebackType | None,
) -> None:
self.close_intelligently()
if self.atomic and self.should_close and self._f is not None:
self._f.__exit__(exc_type, exc_value, tb)
else:
self.close_intelligently()

def __iter__(self) -> Iterator[AnyStr]:
self.open()
Expand Down
Loading