Skip to content

Commit c51c40a

Browse files
Support --output-file for usethis tool requirements.txt (#1794)
* Initial plan * feat: support --output-file for requirements.txt Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> * feat: support --output-file/-o for usethis tool requirements.txt Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/c78a8061-abb1-4491-b541-40694fdca9b8 Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> * fix: remove -o short flag from --output-file option Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/1133fc0b-56ca-4400-9288-3ee99d692d49 Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> * test: improve unit test coverage for requirements_txt tool to 100% Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/a033d52d-72c8-49a4-9950-00dfebd175ad Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> * fix: add None guard for repo.hooks to fix basedpyright reportOptionalSubscript Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/7845b976-228c-443b-be4d-84ff9739e479 Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> * docs: document --output-file option for usethis tool requirements.txt in reference.md Agent-Logs-Url: https://github.com/usethis-python/usethis-python/sessions/2dafdbc6-cd87-4d06-bcf4-ac95b89fd468 Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nathanjmcdougall <18602289+nathanjmcdougall@users.noreply.github.com>
1 parent aae2f71 commit c51c40a

10 files changed

Lines changed: 354 additions & 33 deletions

File tree

docs/cli/reference.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,10 @@ Supported options:
309309
- `uv` to use the [uv](https://docs.astral.sh/uv) package manager
310310
- `none` to not use a package manager backend and display messages for some operations.
311311

312+
For `usethis tool requirements.txt`, in addition to the above options, you can also specify:
313+
314+
- `--output-file` to specify the output file path (default: `requirements.txt`)
315+
312316
For `usethis tool ruff`, in addition to the above options, you can also specify:
313317

314318
- `--linter` to add or remove specifically the linter component of Ruff (default; or `--no-linter` to opt-out)

src/usethis/_core/tool.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -334,15 +334,17 @@ def use_pytest(
334334
tool.remove_managed_files()
335335

336336

337-
def use_requirements_txt(*, remove: bool = False, how: bool = False) -> None:
337+
def use_requirements_txt(
338+
*, remove: bool = False, how: bool = False, output_file: str = "requirements.txt"
339+
) -> None:
338340
"""Add and configure a requirements.txt file exported from the uv lockfile."""
339-
tool = RequirementsTxtTool()
341+
tool = RequirementsTxtTool(output_file=output_file)
340342

341343
if how:
342344
tool.print_how_to_use()
343345
return
344346

345-
path = usethis_config.cpd() / "requirements.txt"
347+
path = usethis_config.cpd() / output_file
346348

347349
if not remove:
348350
backend = get_backend()
@@ -362,7 +364,7 @@ def use_requirements_txt(*, remove: bool = False, how: bool = False) -> None:
362364
tool.print_how_to_use()
363365
return
364366

365-
_generate_requirements_txt()
367+
_generate_requirements_txt(output_file=output_file)
366368

367369
tool.print_how_to_use()
368370
else:
@@ -373,36 +375,36 @@ def use_requirements_txt(*, remove: bool = False, how: bool = False) -> None:
373375
tool.remove_managed_files()
374376

375377

376-
def _generate_requirements_txt() -> None:
378+
def _generate_requirements_txt(*, output_file: str = "requirements.txt") -> None:
377379
backend = get_backend()
378380
if backend is BackendEnum.uv:
379381
if not (usethis_config.cpd() / "pyproject.toml").exists():
380-
write_simple_requirements_txt()
382+
write_simple_requirements_txt(output_file=output_file)
381383
elif not usethis_config.frozen:
382384
ensure_uv_lock()
383-
tick_print("Writing 'requirements.txt'.")
385+
tick_print(f"Writing '{output_file}'.")
384386
call_uv_subprocess(
385387
[
386388
"export",
387389
"--frozen",
388-
"--output-file=requirements.txt",
390+
f"--output-file={output_file}",
389391
],
390392
change_toml=False,
391393
)
392394
elif backend is BackendEnum.poetry:
393395
# Poetry uses poetry export for requirements.txt generation
394-
write_simple_requirements_txt()
396+
write_simple_requirements_txt(output_file=output_file)
395397
elif backend is BackendEnum.none:
396398
# Simply dump the dependencies list to requirements.txt
397399
if usethis_config.backend is BackendEnum.auto:
398400
info_print(
399-
"Generating 'requirements.txt' with un-pinned, abstract dependencies."
401+
f"Generating '{output_file}' with un-pinned, abstract dependencies."
400402
)
401403
info_print(
402404
"Consider installing 'uv' for pinned, cross-platform, full requirements files."
403405
)
404406

405-
write_simple_requirements_txt()
407+
write_simple_requirements_txt(output_file=output_file)
406408
else:
407409
assert_never(backend)
408410

src/usethis/_init.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,15 @@ def _regularize_package_name(project_name: str) -> str:
106106
return project_name.lower()
107107

108108

109-
def write_simple_requirements_txt() -> None:
109+
def write_simple_requirements_txt(*, output_file: str = "requirements.txt") -> None:
110110
r"""Write a simple requirements.txt file with -e . and any project dependencies.
111111
112112
This is used when we don't have a lock file or when using backend=none.
113113
Always writes at least "-e .\n", and appends any dependencies found in
114114
pyproject.toml if they exist.
115115
"""
116-
name = "requirements.txt"
117-
tick_print(f"Writing '{name}'.")
118-
path = usethis_config.cpd() / name
116+
tick_print(f"Writing '{output_file}'.")
117+
path = usethis_config.cpd() / output_file
119118
with open(path, "w", encoding="utf-8") as f:
120119
# Always write -e . first
121120
f.write("-e .\n")

src/usethis/_tool/impl/base/requirements_txt.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,26 @@ class RequirementsTxtTool(RequirementsTxtToolSpec, Tool):
2020
def print_how_to_use(self) -> None:
2121
install_method = self.get_install_method()
2222
backend = get_backend()
23+
name = self._output_file
2324
if install_method == "pre-commit":
2425
if backend is BackendEnum.uv:
2526
how_print(
26-
"Run 'uv run pre-commit run -a uv-export' to write 'requirements.txt'."
27+
f"Run 'uv run pre-commit run -a uv-export' to write '{name}'."
2728
)
2829
elif backend in (BackendEnum.poetry, BackendEnum.none):
29-
how_print(
30-
"Run 'pre-commit run -a uv-export' to write 'requirements.txt'."
31-
)
30+
how_print(f"Run 'pre-commit run -a uv-export' to write '{name}'.")
3231
else:
3332
assert_never(backend)
3433
elif install_method == "devdep" or install_method is None:
3534
if backend is BackendEnum.uv:
36-
how_print(
37-
"Run 'uv export -o=requirements.txt' to write 'requirements.txt'."
38-
)
35+
how_print(f"Run 'uv export -o={name}' to write '{name}'.")
3936
elif backend in (BackendEnum.poetry, BackendEnum.none):
40-
if not (usethis_config.cpd() / "requirements.txt").exists():
41-
how_print(
42-
"Run 'usethis tool requirements.txt' to write 'requirements.txt'."
43-
)
37+
if not (usethis_config.cpd() / name).exists():
38+
if name == "requirements.txt":
39+
cmd = "usethis tool requirements.txt"
40+
else:
41+
cmd = f"usethis tool requirements.txt --output-file={name}"
42+
how_print(f"Run '{cmd}' to write '{name}'.")
4443
else:
4544
assert_never(backend)
4645
else:

src/usethis/_tool/impl/spec/requirements_txt.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,22 @@
1717
from usethis._types.backend import BackendEnum
1818

1919
_UV_PRE_COMMIT_REPO = "https://github.com/astral-sh/uv-pre-commit"
20+
_DEFAULT_OUTPUT_FILE = "requirements.txt"
2021

2122

2223
class RequirementsTxtToolSpec(ToolSpec):
24+
def __init__(self, *, output_file: str = _DEFAULT_OUTPUT_FILE) -> None:
25+
super().__init__()
26+
self._output_file: str = output_file
27+
2328
@final
2429
@property
2530
@override
2631
def meta(self) -> ToolMeta:
2732
return ToolMeta(
2833
name="requirements.txt",
2934
url="https://pip.pypa.io/en/stable/reference/requirements-file-format/",
30-
managed_files=[Path("requirements.txt")],
35+
managed_files=[Path(self._output_file)],
3136
)
3237

3338
@override
@@ -36,15 +41,18 @@ def pre_commit_config(self) -> PreCommitConfig:
3641
backend = get_backend()
3742

3843
if backend is BackendEnum.uv:
44+
if self._output_file != _DEFAULT_OUTPUT_FILE:
45+
hook_def = pre_commit_schema.HookDefinition(
46+
id="uv-export",
47+
args=[f"--output-file={self._output_file}"],
48+
)
49+
else:
50+
hook_def = pre_commit_schema.HookDefinition(id="uv-export")
3951
return PreCommitConfig.from_single_repo(
4052
pre_commit_schema.UriRepo(
4153
repo=_UV_PRE_COMMIT_REPO,
4254
rev=FALLBACK_UV_VERSION,
43-
hooks=[
44-
pre_commit_schema.HookDefinition(
45-
id="uv-export",
46-
)
47-
],
55+
hooks=[hook_def],
4856
),
4957
requires_venv=False,
5058
)

src/usethis/_ui/interface/tool.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
offline_opt,
2121
quiet_opt,
2222
remove_opt,
23+
requirements_txt_output_file_opt,
2324
)
2425

2526
if TYPE_CHECKING:
@@ -327,6 +328,7 @@ def requirements_txt(
327328
frozen: bool = frozen_opt,
328329
backend: BackendEnum = backend_opt,
329330
no_hook: bool = no_hook_opt,
331+
output_file: str = requirements_txt_output_file_opt,
330332
) -> None:
331333
"""Use a requirements.txt file exported from the uv lockfile."""
332334
from usethis._config_file import files_manager
@@ -342,7 +344,7 @@ def requirements_txt(
342344
),
343345
files_manager(),
344346
):
345-
_run_tool(use_requirements_txt, remove=remove, how=how)
347+
_run_tool(use_requirements_txt, remove=remove, how=how, output_file=output_file)
346348

347349

348350
@app.command(

src/usethis/_ui/options.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@
123123
help="Write output to this file instead of stdout.",
124124
)
125125

126+
# requirements.txt command options
127+
requirements_txt_output_file_opt = typer.Option(
128+
"requirements.txt",
129+
"--output-file",
130+
help="The name of the output requirements file.",
131+
)
132+
126133
# ruff command options
127134
linter_opt = typer.Option(
128135
True,

tests/usethis/_core/test_core_tool.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3148,6 +3148,70 @@ def test_doesnt_create_pyproject_toml(self, tmp_path: Path):
31483148
# Assert
31493149
assert not (tmp_path / "pyproject.toml").exists()
31503150

3151+
class TestOutputFile:
3152+
def test_custom_output_file_creates_correct_file(
3153+
self, tmp_path: Path, capfd: pytest.CaptureFixture[str]
3154+
):
3155+
# Act
3156+
with (
3157+
change_cwd(tmp_path),
3158+
PyprojectTOMLManager(),
3159+
usethis_config.set(backend=BackendEnum.uv),
3160+
):
3161+
use_requirements_txt(output_file="constraints.txt")
3162+
3163+
# Assert
3164+
assert (tmp_path / "constraints.txt").exists()
3165+
assert not (tmp_path / "requirements.txt").exists()
3166+
out, _ = capfd.readouterr()
3167+
assert "Writing 'constraints.txt'." in out
3168+
assert "uv export -o=constraints.txt" in out
3169+
3170+
def test_custom_output_file_how(
3171+
self, tmp_path: Path, capfd: pytest.CaptureFixture[str]
3172+
):
3173+
# Act
3174+
with (
3175+
change_cwd(tmp_path),
3176+
PyprojectTOMLManager(),
3177+
usethis_config.set(backend=BackendEnum.uv),
3178+
):
3179+
use_requirements_txt(how=True, output_file="constraints.txt")
3180+
3181+
# Assert
3182+
out, _ = capfd.readouterr()
3183+
assert (
3184+
out
3185+
== "☐ Run 'uv export -o=constraints.txt' to write 'constraints.txt'.\n"
3186+
)
3187+
3188+
def test_custom_output_file_remove(self, tmp_path: Path):
3189+
# Arrange
3190+
(tmp_path / "constraints.txt").touch()
3191+
3192+
# Act
3193+
with change_cwd(tmp_path), PyprojectTOMLManager():
3194+
use_requirements_txt(remove=True, output_file="constraints.txt")
3195+
3196+
# Assert
3197+
assert not (tmp_path / "constraints.txt").exists()
3198+
3199+
def test_default_output_file_is_requirements_txt(
3200+
self, tmp_path: Path, capfd: pytest.CaptureFixture[str]
3201+
):
3202+
# Act
3203+
with (
3204+
change_cwd(tmp_path),
3205+
PyprojectTOMLManager(),
3206+
usethis_config.set(backend=BackendEnum.none),
3207+
):
3208+
use_requirements_txt()
3209+
3210+
# Assert
3211+
assert (tmp_path / "requirements.txt").exists()
3212+
out, _ = capfd.readouterr()
3213+
assert "Writing 'requirements.txt'." in out
3214+
31513215

31523216
class TestRuff:
31533217
class TestAdd:

0 commit comments

Comments
 (0)