diff --git a/tests/test_main.py b/tests/test_main.py index 51ff3fc..55b13fe 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,6 +3,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import click from click.testing import CliRunner from versifier.__main__ import ( @@ -17,11 +18,13 @@ def test_context_init(self) -> None: root_path="/root", config_path="pyproject.toml", poetry_path="poetry", + uv_path="uv", nuitka_path="nuitka3", ) assert ctx.root_path == "/root" assert ctx.config_path == "pyproject.toml" assert ctx.poetry_path == "poetry" + assert ctx.uv_path == "uv" assert ctx.nuitka_path == "nuitka3" def test_context_poetry_property(self) -> None: @@ -29,6 +32,7 @@ def test_context_poetry_property(self) -> None: root_path="/root", config_path="pyproject.toml", poetry_path="/custom/poetry", + uv_path="uv", nuitka_path="nuitka3", ) poetry = ctx.poetry @@ -39,6 +43,7 @@ def test_context_compiler_property(self) -> None: root_path="/root", config_path="pyproject.toml", poetry_path="poetry", + uv_path="uv", nuitka_path="/custom/nuitka3", ) compiler = ctx.compiler @@ -56,6 +61,7 @@ def test_context_config_property(self) -> None: root_path=td, config_path="pyproject.toml", poetry_path="poetry", + uv_path="uv", nuitka_path="nuitka3", ) config = ctx.config @@ -68,10 +74,100 @@ def test_context_root_dir_property(self) -> None: root_path="/root/path", config_path="pyproject.toml", poetry_path="poetry", + uv_path="uv", nuitka_path="nuitka3", ) assert ctx.root_dir == Path("/root/path") + def test_context_uv_property(self) -> None: + ctx = Context( + root_path="/root", + config_path="pyproject.toml", + poetry_path="poetry", + uv_path="/custom/uv", + nuitka_path="nuitka3", + ) + uv = ctx.uv + assert uv.uv_path == "/custom/uv" + + def test_context_package_manager_uv(self) -> None: + with tempfile.TemporaryDirectory() as td: + Path(td, "uv.lock").write_text("") + original_dir = os.getcwd() + try: + os.chdir(td) + ctx = Context( + root_path=td, + config_path="pyproject.toml", + poetry_path="poetry", + uv_path="uv", + nuitka_path="nuitka3", + ) + from versifier.uv import Uv + + assert isinstance(ctx.package_manager, Uv) + finally: + os.chdir(original_dir) + + def test_context_package_manager_poetry(self) -> None: + with tempfile.TemporaryDirectory() as td: + Path(td, "poetry.lock").write_text("") + original_dir = os.getcwd() + try: + os.chdir(td) + ctx = Context( + root_path=td, + config_path="pyproject.toml", + poetry_path="poetry", + uv_path="uv", + nuitka_path="nuitka3", + ) + from versifier.poetry import Poetry + + assert isinstance(ctx.package_manager, Poetry) + finally: + os.chdir(original_dir) + + def test_context_package_manager_none(self) -> None: + with tempfile.TemporaryDirectory() as td: + original_dir = os.getcwd() + try: + os.chdir(td) + ctx = Context( + root_path=td, + config_path="pyproject.toml", + poetry_path="poetry", + uv_path="uv", + nuitka_path="nuitka3", + ) + import click + import pytest + + with pytest.raises(click.UsageError, match="No uv.lock or poetry.lock found"): + ctx.package_manager + finally: + os.chdir(original_dir) + + def test_context_package_manager_prefers_uv(self) -> None: + with tempfile.TemporaryDirectory() as td: + Path(td, "uv.lock").write_text("") + Path(td, "poetry.lock").write_text("") + original_dir = os.getcwd() + try: + os.chdir(td) + ctx = Context( + root_path=td, + config_path="pyproject.toml", + poetry_path="poetry", + uv_path="uv", + nuitka_path="nuitka3", + ) + from versifier.uv import Uv + + assert isinstance(ctx.package_manager, Uv) + finally: + os.chdir(original_dir) + def test_context_wrapper(self) -> None: @Context.wrapper def test_func(ctx: Context, extra_arg: str) -> str: @@ -214,6 +310,7 @@ def test_extract_private_packages(self, mock_extractor_class: MagicMock) -> None runner = CliRunner() with runner.isolated_filesystem(): Path("pyproject.toml").write_text("[tool.poetry]\nname = 'test'\n") + Path("poetry.lock").write_text("") result = runner.invoke( cli, @@ -235,6 +332,7 @@ def test_extract_private_packages_no_packages(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): Path("pyproject.toml").write_text("[tool.poetry]\nname = 'test'\n") + Path("poetry.lock").write_text("") result = runner.invoke( cli, @@ -258,6 +356,7 @@ def test_extract_private_packages_from_config(self, mock_extractor_class: MagicM Path("pyproject.toml").write_text( "[tool.poetry]\nname = 'test'\n\n[tool.versifier]\nprivate_packages = ['pkg1']\n" ) + Path("poetry.lock").write_text("") result = runner.invoke( cli, @@ -335,6 +434,7 @@ def test_obfuscate_private_packages( runner = CliRunner() with runner.isolated_filesystem(): Path("pyproject.toml").write_text("[tool.poetry]\nname = 'test'\n") + Path("poetry.lock").write_text("") result = runner.invoke( cli, @@ -355,6 +455,7 @@ def test_obfuscate_private_packages_no_packages(self) -> None: runner = CliRunner() with runner.isolated_filesystem(): Path("pyproject.toml").write_text("[tool.poetry]\nname = 'test'\n") + Path("poetry.lock").write_text("") result = runner.invoke( cli, @@ -383,6 +484,7 @@ def test_obfuscate_private_packages_from_config( Path("pyproject.toml").write_text( "[tool.poetry]\nname = 'test'\n\n[tool.versifier]\nprivate_packages = ['pkg1']\n" ) + Path("poetry.lock").write_text("") result = runner.invoke( cli, @@ -401,3 +503,207 @@ def test_command_details(self) -> None: assert result.exit_code == 0 assert "requirements-to-poetry" in result.output assert "poetry-to-requirements" in result.output + assert "requirements-to-uv" in result.output + assert "uv-to-requirements" in result.output + + @patch("versifier.__main__.core.DependencyManager") + def test_requirements_to_uv(self, mock_manager_class: MagicMock) -> None: + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + runner = CliRunner() + with runner.isolated_filesystem(): + Path("requirements.txt").write_text("requests==2.28.0\n") + Path("pyproject.toml").write_text("[project]\nname = 'test'\n") + Path("uv.lock").write_text("") + + result = runner.invoke( + cli, + [ + "requirements-to-uv", + "-R", + "requirements.txt", + "--add-only", + ], + ) + + if result.exit_code != 0: + print(result.output) + assert result.exit_code == 0 + + @patch("versifier.__main__.core.DependencyManager") + def test_requirements_to_uv_init(self, mock_manager_class: MagicMock) -> None: + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + runner = CliRunner() + with runner.isolated_filesystem(): + Path("requirements.txt").write_text("requests==2.28.0\n") + Path("pyproject.toml").write_text("[project]\nname = 'test'\n") + + with patch("versifier.__main__.Uv") as mock_uv_class: + mock_uv = MagicMock() + mock_uv_class.return_value = mock_uv + + runner.invoke( + cli, + [ + "requirements-to-uv", + "-R", + "requirements.txt", + ], + ) + + mock_uv.init_if_needed.assert_called_once() + + @patch("versifier.__main__.core.DependencyExporter") + def test_uv_to_requirements(self, mock_exporter_class: MagicMock) -> None: + mock_exporter = MagicMock() + mock_exporter_class.return_value = mock_exporter + + runner = CliRunner() + with runner.isolated_filesystem(): + Path("pyproject.toml").write_text("[project]\nname = 'test'\n") + + result = runner.invoke( + cli, + [ + "uv-to-requirements", + "-o", + "output.txt", + ], + ) + + if result.exit_code != 0: + print(result.output) + assert result.exit_code == 0 + + @patch("versifier.__main__.core.DependencyExporter") + def test_uv_to_requirements_stdout(self, mock_exporter_class: MagicMock) -> None: + mock_exporter = MagicMock() + mock_exporter_class.return_value = mock_exporter + + runner = CliRunner() + with runner.isolated_filesystem(): + Path("pyproject.toml").write_text("[project]\nname = 'test'\n") + + result = runner.invoke( + cli, + [ + "uv-to-requirements", + "-o", + "", + ], + ) + + if result.exit_code != 0: + print(result.output) + assert result.exit_code == 0 + + @patch("versifier.__main__.core.DependencyExporter") + def test_uv_to_requirements_with_private_packages_from_config( + self, mock_exporter_class: MagicMock + ) -> None: + mock_exporter = MagicMock() + mock_exporter_class.return_value = mock_exporter + + runner = CliRunner() + with runner.isolated_filesystem(): + Path("pyproject.toml").write_text( + "[project]\nname = 'test'\n\n[tool.versifier]\nprivate_packages = ['pkg1']\n" + ) + + result = runner.invoke( + cli, + [ + "uv-to-requirements", + "-o", + "output.txt", + ], + ) + + assert result.exit_code == 0 + + @patch("versifier.__main__.core.PackageExtractor") + def test_extract_private_packages_with_uv(self, mock_extractor_class: MagicMock) -> None: + mock_extractor = MagicMock() + mock_extractor_class.return_value = mock_extractor + + runner = CliRunner() + with runner.isolated_filesystem(): + Path("pyproject.toml").write_text("[project]\nname = 'test'\n") + Path("uv.lock").write_text("") + + result = runner.invoke( + cli, + [ + "extract-private-packages", + "-o", + "output", + "-P", + "pkg1", + ], + ) + + if result.exit_code != 0: + print(result.output) + assert result.exit_code == 0 + mock_extractor.extract_packages.assert_called_once() + + @patch("versifier.__main__.core.PackageObfuscator") + def test_obfuscate_project_dirs_with_uv(self, mock_obfuscator_class: MagicMock) -> None: + mock_obfuscator = MagicMock() + mock_obfuscator_class.return_value = mock_obfuscator + + runner = CliRunner() + with runner.isolated_filesystem(): + Path("pyproject.toml").write_text("[project]\nname = 'test'\n") + Path("uv.lock").write_text("") + os.makedirs("subdir") + os.makedirs("subdir/pkg") + Path("subdir/pkg/__init__.py").write_text("") + + result = runner.invoke( + cli, + [ + "obfuscate-project-dirs", + "-o", + "output", + "-d", + "subdir", + ], + ) + + if result.exit_code != 0: + print(result.output) + assert result.exit_code == 0 + + @patch("versifier.__main__.core.PackageObfuscator") + @patch("versifier.__main__.core.PackageExtractor") + def test_obfuscate_private_packages_with_uv( + self, mock_extractor_class: MagicMock, mock_obfuscator_class: MagicMock + ) -> None: + mock_extractor = MagicMock() + mock_extractor_class.return_value = mock_extractor + mock_obfuscator = MagicMock() + mock_obfuscator_class.return_value = mock_obfuscator + + runner = CliRunner() + with runner.isolated_filesystem(): + Path("pyproject.toml").write_text("[project]\nname = 'test'\n") + Path("uv.lock").write_text("") + + result = runner.invoke( + cli, + [ + "obfuscate-private-packages", + "-o", + "output", + "-P", + "pkg1", + ], + ) + + if result.exit_code != 0: + print(result.output) + assert result.exit_code == 0 diff --git a/tests/test_uv.py b/tests/test_uv.py new file mode 100644 index 0000000..81b3261 --- /dev/null +++ b/tests/test_uv.py @@ -0,0 +1,187 @@ +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +from versifier.uv import Uv + + +class TestUv: + def test_init(self) -> None: + uv = Uv() + assert uv.uv_path == "uv" + + def test_init_with_custom_path(self) -> None: + uv = Uv(uv_path="/custom/uv") + assert uv.uv_path == "/custom/uv" + + @patch("versifier.uv.check_call") + def test_add_packages(self, mock_check_call: MagicMock) -> None: + uv = Uv() + uv.add_packages(["requests", "flask"]) + mock_check_call.assert_called_once() + args = mock_check_call.call_args[0][0] + assert "uv" in args + assert "add" in args + assert "--no-sync" in args + assert "requests" in args + assert "flask" in args + + @patch("versifier.uv.check_call") + def test_add_packages_dev(self, mock_check_call: MagicMock) -> None: + uv = Uv() + uv.add_packages(["pytest"], is_dev=True) + mock_check_call.assert_called_once() + args = mock_check_call.call_args[0][0] + assert "--dev" in args + + @patch("versifier.uv.check_call") + def test_add_packages_no_lock(self, mock_check_call: MagicMock) -> None: + uv = Uv() + uv.add_packages(["requests"], lock_only=False) + mock_check_call.assert_called_once() + args = mock_check_call.call_args[0][0] + assert "--no-sync" not in args + + @patch("versifier.uv.check_call") + def test_install(self, mock_check_call: MagicMock) -> None: + uv = Uv() + uv.install() + mock_check_call.assert_called_once() + args = mock_check_call.call_args[0][0] + assert "uv" in args + assert "sync" in args + assert "--no-dev" in args + + @patch("versifier.uv.check_call") + def test_install_with_dev(self, mock_check_call: MagicMock) -> None: + uv = Uv() + uv.install(include_dev_requirements=True) + mock_check_call.assert_called_once() + args = mock_check_call.call_args[0][0] + assert "--no-dev" not in args + + @patch("versifier.uv.check_call") + def test_install_with_extras(self, mock_check_call: MagicMock) -> None: + uv = Uv() + uv.install(extra_requirements=["extra1", "extra2"]) + mock_check_call.assert_called_once() + args = mock_check_call.call_args[0][0] + assert "--extra=extra1" in args + assert "--extra=extra2" in args + + @patch("versifier.uv.check_call") + def test_run_command(self, mock_check_call: MagicMock) -> None: + uv = Uv() + uv.run_command(["pip", "install", "requests"]) + mock_check_call.assert_called_once() + args = mock_check_call.call_args[0][0] + assert args == ["uv", "run", "pip", "install", "requests"] + + @patch("versifier.uv.check_call") + def test_init_if_needed_when_lock_exists(self, mock_check_call: MagicMock) -> None: + with tempfile.TemporaryDirectory() as td: + lock_path = Path(td) / "uv.lock" + lock_path.write_text("") + original_dir = os.getcwd() + try: + os.chdir(td) + uv = Uv() + uv.init_if_needed() + mock_check_call.assert_not_called() + finally: + os.chdir(original_dir) + + @patch("versifier.uv.check_call") + def test_init_if_needed_when_lock_not_exists(self, mock_check_call: MagicMock) -> None: + with tempfile.TemporaryDirectory() as td: + original_dir = os.getcwd() + try: + os.chdir(td) + uv = Uv() + uv.init_if_needed() + mock_check_call.assert_called_once() + args = mock_check_call.call_args[0][0] + assert "uv" in args + assert "lock" in args + finally: + os.chdir(original_dir) + + @patch("versifier.uv.check_call") + def test_export_requirements(self, mock_check_call: MagicMock) -> None: + with tempfile.TemporaryDirectory() as td: + original_dir = os.getcwd() + try: + os.chdir(td) + + def side_effect(commands: list) -> None: + for cmd in commands: + if cmd.startswith("--output-file="): + req_path = cmd.split("=", 1)[1] + Path(req_path).write_text("requests==2.28.0\n") + break + + mock_check_call.side_effect = side_effect + + uv = Uv() + rf = uv.export_requirements() + assert rf is not None + args = mock_check_call.call_args[0][0] + assert "--no-hashes" in args + assert "--no-dev" in args + assert "--with-credentials" not in args + finally: + os.chdir(original_dir) + + @patch("versifier.uv.check_call") + def test_export_requirements_with_options(self, mock_check_call: MagicMock) -> None: + with tempfile.TemporaryDirectory() as td: + original_dir = os.getcwd() + try: + os.chdir(td) + + def side_effect(commands: list) -> None: + for cmd in commands: + if cmd.startswith("--output-file="): + req_path = cmd.split("=", 1)[1] + Path(req_path).write_text("requests==2.28.0\n") + break + + mock_check_call.side_effect = side_effect + + uv = Uv() + rf = uv.export_requirements( + include_dev_requirements=True, + extra_requirements=["extra1"], + with_credentials=True, + ) + assert rf is not None + args = mock_check_call.call_args[0][0] + assert "--no-dev" not in args + assert "--extra=extra1" in args + assert "--with-credentials" in args + finally: + os.chdir(original_dir) + + @patch("versifier.uv.logger") + @patch("versifier.uv.check_call") + def test_export_requirements_with_credentials_warns(self, mock_check_call: MagicMock, mock_logger: MagicMock) -> None: + with tempfile.TemporaryDirectory() as td: + original_dir = os.getcwd() + try: + os.chdir(td) + + def side_effect(commands: list) -> None: + for cmd in commands: + if cmd.startswith("--output-file="): + req_path = cmd.split("=", 1)[1] + Path(req_path).write_text("requests==2.28.0\n") + break + + mock_check_call.side_effect = side_effect + + uv = Uv() + uv.export_requirements(with_credentials=True) + mock_logger.warning.assert_called_once() + finally: + os.chdir(original_dir) diff --git a/versifier/__main__.py b/versifier/__main__.py index e3d6fc4..4e652b9 100644 --- a/versifier/__main__.py +++ b/versifier/__main__.py @@ -12,7 +12,9 @@ from .compiler import Compiler, Cython, Nuitka3, SmartCompiler from .config import Config +from .core import PackageManager from .poetry import Poetry +from .uv import Uv logger = logging.getLogger(__name__) @@ -22,12 +24,25 @@ class Context: root_path: str config_path: str poetry_path: str + uv_path: str nuitka_path: str @property def poetry(self) -> Poetry: return Poetry(self.poetry_path) + @property + def uv(self) -> Uv: + return Uv(self.uv_path) + + @property + def package_manager(self) -> PackageManager: + if os.path.exists("uv.lock"): + return self.uv + if os.path.exists("poetry.lock"): + return self.poetry + raise click.UsageError("No uv.lock or poetry.lock found. Cannot auto-detect package manager.") + @property def compiler(self) -> Compiler: return SmartCompiler([Cython(), Nuitka3(self.nuitka_path)]) @@ -45,6 +60,7 @@ def wrapper(cls, func: Callable) -> Callable: @click.option("-c", "--config", default="pyproject.toml", help="config file") @click.option("-r", "--root", default=".", help="root dir") @click.option("--poetry-path", default="poetry", help="path to poetry") + @click.option("--uv-path", default="uv", help="path to uv") @click.option("--nuitka-path", default="nuitka3", help="path to nuitka3") @click.option("--log-level", default="INFO", help="log level") @functools.wraps(func) @@ -52,6 +68,7 @@ def wrapped( config: str, root: str, poetry_path: str, + uv_path: str, nuitka_path: str, log_level: str, *args: Any, @@ -64,6 +81,7 @@ def wrapped( root_path=root, config_path=config, poetry_path=poetry_path, + uv_path=uv_path, nuitka_path=nuitka_path, ) func(ctx=ctx, *args, **kwargs) @@ -165,7 +183,7 @@ def extract_private_packages( raise click.UsageError("No private packages found") os.makedirs(output, exist_ok=True) - ext = core.PackageExtractor(ctx.poetry) + ext = core.PackageExtractor(ctx.package_manager) ext.extract_packages( output_dir=output, packages=private_packages, @@ -224,7 +242,7 @@ def obfuscate_private_packages( os.makedirs(output, exist_ok=True) with TemporaryDirectory() as td: - extractor = core.PackageExtractor(ctx.poetry) + extractor = core.PackageExtractor(ctx.package_manager) extractor.extract_packages( output_dir=td, packages=private_packages, @@ -239,6 +257,73 @@ def obfuscate_private_packages( ) +@cli.command(help="convert requirements to uv") +@click.option("-R", "--requirements", multiple=True, default=[], help="requirements files") +@click.option("-d", "--dev-requirements", multiple=True, default=[], help="dev requirements files") +@click.option("-e", "--exclude", multiple=True, default=[], help="exclude packages") +@click.option("--add-only", is_flag=True, help="add only") +@Context.wrapper +def requirements_to_uv( + ctx: Context, + requirements: List[str], + dev_requirements: List[str], + exclude: List[str], + add_only: bool, +) -> None: + uv = ctx.uv + if not add_only: + uv.init_if_needed() + + ext = core.DependencyManager(poetry=uv) + ext.add_from_requirements_txt( + requirements, + dev_requirements, + exclude, + ) + + +@cli.command(help="convert uv to requirements") +@click.option("-o", "--output", default="requirements.txt", help="output file") +@click.option("--exclude-specifiers", is_flag=True, help="exclude specifiers") +@click.option("--include-comments", is_flag=True, help="include comments") +@click.option("-d", "--include-dev-requirements", is_flag=True, help="include dev requirements") +@click.option("-E", "--extra-requirements", multiple=True, default=[], help="extra requirements") +@click.option("-m", "--markers", multiple=True, default=[], help="markers") +@click.option("-P", "--private-packages", multiple=True, default=[], help="private packages") +@Context.wrapper +def uv_to_requirements( + ctx: Context, + output: str, + exclude_specifiers: bool, + include_comments: bool, + include_dev_requirements: bool, + extra_requirements: List[str], + markers: List[str], + private_packages: List[str], +) -> None: + conf = ctx.config + if not private_packages: + private_packages = conf.get_private_packages() or [] + + ext = core.DependencyExporter(poetry=ctx.uv) + fn = partial( + ext.export_to_requirements_txt, + include_specifiers=not exclude_specifiers, + include_comments=include_comments, + exclude=private_packages, + include_dev_requirements=include_dev_requirements, + extra_requirements=extra_requirements, + markers=markers, + ) + + if output == "": + fn(callback=print) + + else: + with open(output, "w") as f: + fn(callback=lambda line: f.write(line + "\n")) + + @cli.command(help="show command help details") @click.pass_context def command_details(ctx: click.Context) -> None: diff --git a/versifier/core.py b/versifier/core.py index 0a2abe0..5c13577 100644 --- a/versifier/core.py +++ b/versifier/core.py @@ -5,18 +5,21 @@ from dataclasses import dataclass from itertools import chain from tempfile import TemporaryDirectory -from typing import Any, Iterable, List, Optional, Set +from typing import Any, Iterable, List, Optional, Set, Union from .compiler import Compiler from .poetry import Poetry, RequirementsFile from .stub import PackageStubGenerator +from .uv import Uv + +PackageManager = Union[Poetry, Uv] logger = logging.getLogger(__name__) @dataclass class DependencyManager: - poetry: Poetry + poetry: PackageManager def _merge_requirements(self, requirements: List[str], exclude: Iterable[str] = ()) -> Set[str]: results: Set[str] = set() @@ -51,7 +54,7 @@ def add_from_requirements_txt( @dataclass class DependencyExporter: - poetry: Poetry + poetry: PackageManager def export_to_requirements_txt( self, @@ -83,7 +86,7 @@ def export_to_requirements_txt( @dataclass class PackageExtractor: - poetry: Poetry + poetry: PackageManager def _do_clean_directory(self, path: str, exclude_file_patterns: Iterable[str]) -> None: for root, dirs, files in os.walk(path): diff --git a/versifier/uv.py b/versifier/uv.py new file mode 100644 index 0000000..72dfb6a --- /dev/null +++ b/versifier/uv.py @@ -0,0 +1,83 @@ +import logging +import os +from dataclasses import dataclass +from subprocess import check_call +from tempfile import TemporaryDirectory +from typing import Iterable, List, Optional + +from .poetry import RequirementsFile + +logger = logging.getLogger(__name__) + + +@dataclass +class Uv: + uv_path: str = "uv" + + def add_packages(self, packages: Iterable[str], is_dev: bool = False, lock_only: bool = True) -> None: + commands = [self.uv_path, "add"] + + if is_dev: + commands.append("--dev") + + if lock_only: + commands.append("--no-sync") + + commands.extend(packages) + check_call(commands) + + def export_requirements( + self, + include_dev_requirements: bool = False, + extra_requirements: Optional[Iterable[str]] = None, + with_credentials: bool = False, + ) -> RequirementsFile: + if with_credentials: + logger.warning("uv export does not support embedding credentials; with_credentials will be ignored") + + with TemporaryDirectory() as td: + requirement_path = os.path.join(td, "requirements.txt") + + commands = [ + self.uv_path, + "export", + "--no-hashes", + f"--output-file={requirement_path}", + ] + + if not include_dev_requirements: + commands.append("--no-dev") + + if extra_requirements: + commands.extend(f"--extra={i}" for i in extra_requirements) + + if with_credentials: + commands.append("--with-credentials") + + check_call(commands) + rf = RequirementsFile.from_file(requirement_path) + + return rf + + def install( + self, include_dev_requirements: bool = False, extra_requirements: Optional[Iterable[str]] = None + ) -> None: + commands = [self.uv_path, "sync"] + + if not include_dev_requirements: + commands.append("--no-dev") + + if extra_requirements: + commands.extend(f"--extra={i}" for i in extra_requirements) + + check_call(commands) + + def run_command(self, args: List[str]) -> None: + commands = [self.uv_path, "run"] + commands.extend(args) + check_call(commands) + + def init_if_needed(self) -> None: + if os.path.exists("uv.lock"): + return + check_call([self.uv_path, "lock"])