Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docs/cli/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,10 @@ Additional configuration in `pyproject.toml`:
- `tool.usethis.sonarqube.exclusions` (list of strings, default `[]`) — sets `sonar.exclusions`.
- `tool.coverage.xml.output` (string, required) — sets `sonar.python.coverage.reportPaths`.

Supported options:

- `--output-file` to write the output to a file instead of stdout.
Comment on lines +478 to +480
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

This doc update implies --output-file is only supported for usethis show sonarqube, but the PR adds it to all usethis show subcommands (backend, name, sonarqube). The reference should either document --output-file as a shared usethis show option or list it under each usethis show <subcommand> that supports it, to avoid misleading users.

Suggested change
Supported options:
- `--output-file` to write the output to a file instead of stdout.
Common `usethis show` options:
- `--output-file` to write the output of any `usethis show` subcommand to a file instead of stdout.

Copilot uses AI. Check for mistakes.

## `usethis browse pypi <package>`

Display or open the PyPI landing page associated with another project.
Expand Down
30 changes: 24 additions & 6 deletions src/usethis/_core/show.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
from __future__ import annotations

from typing import TYPE_CHECKING

from usethis._backend.dispatch import get_backend
from usethis._console import plain_print
from usethis._integrations.project.name import get_project_name
from usethis._integrations.sonarqube.config import get_sonar_project_properties

if TYPE_CHECKING:
from pathlib import Path


def show_backend(*, output_file: Path | None = None) -> None:
_output(get_backend().value, output_file=output_file)


def show_backend() -> None:
plain_print(get_backend().value)
def show_name(*, output_file: Path | None = None) -> None:
_output(get_project_name(), output_file=output_file)


def show_name() -> None:
plain_print(get_project_name())
def show_sonarqube_config(
*, project_key: str | None = None, output_file: Path | None = None
) -> None:
_output(
get_sonar_project_properties(project_key=project_key), output_file=output_file
)


def show_sonarqube_config(*, project_key: str | None = None) -> None:
plain_print(get_sonar_project_properties(project_key=project_key))
def _output(content: str, *, output_file: Path | None = None) -> None:
if output_file is not None:
if not content.endswith("\n"):
content += "\n"
output_file.write_text(content, encoding="utf-8")
Comment on lines +30 to +34
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

_output() writes directly via Path.write_text(). If the target path is unwritable / parent dir missing / path is a directory, this will raise an OSError that won't be caught by the Typer interface (which only catches UsethisError), likely resulting in a traceback. Consider catching OSError here (or in the interface) and re-raising a UsethisError with a clear message that includes the output path.

Copilot uses AI. Check for mistakes.
else:
plain_print(content)
13 changes: 9 additions & 4 deletions src/usethis/_ui/interface/show.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from pathlib import Path

import typer

from usethis._config import usethis_config
from usethis._ui.options import offline_opt, quiet_opt
from usethis._ui.options import offline_opt, output_file_opt, quiet_opt

app = typer.Typer(
help="Show information about the current project.", add_completion=False
Expand All @@ -19,6 +21,7 @@
def backend(
offline: bool = offline_opt,
quiet: bool = quiet_opt,
output_file: Path | None = output_file_opt,
) -> None:
from usethis._config_file import files_manager
from usethis._console import err_print
Expand All @@ -27,7 +30,7 @@ def backend(

with usethis_config.set(offline=offline, quiet=quiet), files_manager():
try:
show_backend()
show_backend(output_file=output_file)
except UsethisError as err:
err_print(err)
raise typer.Exit(code=1) from None
Expand All @@ -37,6 +40,7 @@ def backend(
def name(
offline: bool = offline_opt,
quiet: bool = quiet_opt,
output_file: Path | None = output_file_opt,
) -> None:
from usethis._config_file import files_manager
from usethis._console import err_print
Expand All @@ -45,7 +49,7 @@ def name(

with usethis_config.set(offline=offline, quiet=quiet), files_manager():
try:
show_name()
show_name(output_file=output_file)
except UsethisError as err:
err_print(err)
raise typer.Exit(code=1) from None
Expand All @@ -59,6 +63,7 @@ def sonarqube(
offline: bool = offline_opt,
quiet: bool = quiet_opt,
project_key: str | None = project_key_opt,
output_file: Path | None = output_file_opt,
) -> None:
from usethis._config_file import files_manager
from usethis._console import err_print
Expand All @@ -67,7 +72,7 @@ def sonarqube(

with usethis_config.set(offline=offline, quiet=quiet), files_manager():
try:
show_sonarqube_config(project_key=project_key)
show_sonarqube_config(project_key=project_key, output_file=output_file)
except UsethisError as err:
err_print(err)
raise typer.Exit(code=1) from None
7 changes: 7 additions & 0 deletions src/usethis/_ui/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@
# status command options
status_arg = typer.Argument(default=..., help="Docstring style to enforce.")

# show command options
output_file_opt = typer.Option(
None,
"--output-file",
help="Write output to this file instead of stdout.",
)

# ruff command options
linter_opt = typer.Option(
True,
Expand Down
94 changes: 94 additions & 0 deletions tests/usethis/_ui/interface/test_show.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ def test_none_backend(self, tmp_path: Path):
assert result.exit_code == 0, result.output
assert result.output == "none\n"

def test_output_file(self, tmp_path: Path):
# Arrange
(tmp_path / "uv.lock").touch()
output_file = tmp_path / "backend.txt"

# Act
runner = CliRunner()
with change_cwd(tmp_path):
result = runner.invoke_safe(
app, ["backend", "--output-file", str(output_file)]
)

# Assert
assert result.exit_code == 0, result.output
assert output_file.read_text(encoding="utf-8") == "uv\n"
Comment on lines +41 to +49
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

The --output-file option is documented as writing output to a file instead of stdout, but this test only asserts the file contents. Adding an assertion that result.output is empty (and doing the same for the other test_output_file cases in this file) would better lock in the intended behavior and prevent accidental future regressions that write to both stdout and the file.

Copilot uses AI. Check for mistakes.


class TestName:
def test_output(self, tmp_path: Path):
Expand Down Expand Up @@ -60,6 +76,23 @@ def test_invalid_pyproject(self, tmp_path: Path):
# Assert
assert result.exit_code == 1, result.output

def test_output_file(self, tmp_path: Path):
# Arrange
path = tmp_path / "fun"
path.mkdir()
output_file = path / "name.txt"

# Act
runner = CliRunner()
with change_cwd(path):
result = runner.invoke_safe(
app, ["name", "--output-file", str(output_file)]
)

# Assert
assert result.exit_code == 0, result.output
assert output_file.read_text(encoding="utf-8") == "fun\n"


class TestSonarqube:
def test_runs(self, tmp_path: Path):
Expand Down Expand Up @@ -162,3 +195,64 @@ def test_invalid_pyproject(self, tmp_path: Path):

# Assert
assert result.exit_code == 1, result.output

def test_output_file(self, tmp_path: Path):
# Arrange
(tmp_path / "pyproject.toml").write_text(
"""
[tool.usethis.sonarqube]
project-key = "fun"

[tool.coverage.xml.output]
"""
)
output_file = tmp_path / "sonar-project.properties"

# Act
runner = CliRunner()
with change_cwd(tmp_path):
result = runner.invoke_safe(
app, ["sonarqube", "--output-file", str(output_file)]
)

# Assert
assert result.exit_code == 0, result.output
content = output_file.read_text(encoding="utf-8")
assert "sonar.projectKey=fun" in content

def test_output_file_not_detected_as_existing(self, tmp_path: Path):
"""Using --output-file avoids the redirect problem.

When using shell redirect (`> file`), the file is created empty before
the command runs, which causes sonarqube to read that empty file.
With --output-file, the file is written after generation.
"""
# Arrange
(tmp_path / "pyproject.toml").write_text(
"""
[tool.usethis.sonarqube]
project-key = "fun"

[tool.coverage.xml.output]
"""
)
# Simulate what happens with shell redirect: an empty file pre-exists
output_file = tmp_path / "sonar-project.properties"
output_file.write_text("", encoding="utf-8")

Comment on lines +223 to +242
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

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

This test is described as simulating the shell redirect issue, but pre-creating an empty sonar-project.properties file makes get_sonar_project_properties() return that empty file by design. That means the test doesn't actually validate that --output-file avoids the redirect problem (i.e., writing after generation when the file did not exist at generation time), and the current assertion (content != "") can pass even if the command only writes a newline. Consider removing this test or rewriting it to cover the intended scenario (e.g., ensure the output file does not exist before invocation and assert the written file contains expected generated properties).

Copilot uses AI. Check for mistakes.
# Act
# Despite sonar-project.properties existing (empty), --output-file
# still causes the config to be read from that file (by design of
# get_sonar_project_properties), then overwrites it with that content.
runner = CliRunner()
with change_cwd(tmp_path):
result = runner.invoke_safe(
app, ["sonarqube", "--output-file", str(output_file)]
)

# Assert
assert result.exit_code == 0, result.output
content = output_file.read_text(encoding="utf-8")
# With --output-file, the file is written after content generation,
# so even if it was previously empty, it will have the content now
assert content != ""
Loading