From 1628ff7e808f036c10c16096b1f681adf1f6e009 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:41:24 +0000 Subject: [PATCH 01/10] Add uv support: requirements-to-uv, uv-to-requirements, auto-detection in existing commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: 刘奕聪 --- tests/test_main.py | 304 ++++++++++++++++++++++++++++++++++++++++++ tests/test_uv.py | 154 +++++++++++++++++++++ versifier/__main__.py | 89 ++++++++++++- versifier/core.py | 11 +- versifier/uv.py | 74 ++++++++++ 5 files changed, 626 insertions(+), 6 deletions(-) create mode 100644 tests/test_uv.py create mode 100644 versifier/uv.py diff --git a/tests/test_main.py b/tests/test_main.py index 51ff3fc..0501976 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -17,11 +17,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 +31,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 +42,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 +60,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 +73,99 @@ 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 pytest + + with pytest.raises(Exception): + 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 +308,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 +330,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 +354,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 +432,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 +453,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 +482,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 +501,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..183802e --- /dev/null +++ b/tests/test_uv.py @@ -0,0 +1,154 @@ +import os +import tempfile +from pathlib import Path +from typing import Optional +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 + + @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 + 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 + 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..2217236 --- /dev/null +++ b/versifier/uv.py @@ -0,0 +1,74 @@ +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: + 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) + + 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 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"]) From d5a5d9ae287597e393c489a875df06fcbf6c7081 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:06:35 +0000 Subject: [PATCH 02/10] Initial plan From 32f803d6d161b25e291b4fb058e3c45e362fa559 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:06:58 +0000 Subject: [PATCH 03/10] Initial plan From 3107ad67dfc210b02c869192cae17a3baea4e249 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:07:13 +0000 Subject: [PATCH 04/10] Initial plan From f4c6eddc47dcbc34e568450b14ac4edd5a4e0615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E5=A5=95=E8=81=AA?= Date: Sun, 8 Feb 2026 02:07:15 +0800 Subject: [PATCH 05/10] Update tests/test_uv.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/test_uv.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_uv.py b/tests/test_uv.py index 183802e..fc37e2f 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -1,7 +1,6 @@ import os import tempfile from pathlib import Path -from typing import Optional from unittest.mock import MagicMock, patch from versifier.uv import Uv From 17adcedc723999679d111ae44df25e5264302a5d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:08:25 +0000 Subject: [PATCH 06/10] Address PR review: warn on with_credentials, honor include_dev_requirements in install(), use specific click.UsageError in test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: 刘奕聪 --- tests/test_main.py | 3 ++- tests/test_uv.py | 32 ++++++++++++++++++++++++++++++++ versifier/uv.py | 6 ++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index 0501976..e196075 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -139,9 +139,10 @@ def test_context_package_manager_none(self) -> None: uv_path="uv", nuitka_path="nuitka3", ) + import click import pytest - with pytest.raises(Exception): + with pytest.raises(click.UsageError, match="No uv.lock or poetry.lock found"): ctx.package_manager finally: os.chdir(original_dir) diff --git a/tests/test_uv.py b/tests/test_uv.py index 183802e..6fdd3fa 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -52,6 +52,15 @@ def test_install(self, mock_check_call: MagicMock) -> None: 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: @@ -152,3 +161,26 @@ def side_effect(commands: list) -> None: assert "--extra=extra1" 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/uv.py b/versifier/uv.py index 2217236..68d683f 100644 --- a/versifier/uv.py +++ b/versifier/uv.py @@ -32,6 +32,9 @@ def export_requirements( 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") @@ -58,6 +61,9 @@ def install( ) -> 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) From 2b7b082ee28833415ce5f4bad5c1e146c20eed66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:09:04 +0000 Subject: [PATCH 07/10] Implement --with-credentials flag support in UV export_requirements Co-authored-by: MrLYC <6391488+MrLYC@users.noreply.github.com> --- tests/test_uv.py | 1 + versifier/uv.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/tests/test_uv.py b/tests/test_uv.py index 183802e..db5a8bb 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -150,5 +150,6 @@ def side_effect(commands: list) -> 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) diff --git a/versifier/uv.py b/versifier/uv.py index 2217236..706e2e1 100644 --- a/versifier/uv.py +++ b/versifier/uv.py @@ -48,6 +48,9 @@ def export_requirements( 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) From c1f888c31960e7ad9db30ee920a0038cfca71d2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:09:05 +0000 Subject: [PATCH 08/10] Update test to assert specific click.UsageError with message verification Co-authored-by: MrLYC <6391488+MrLYC@users.noreply.github.com> --- tests/test_main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index 0501976..b1ba0fc 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 ( @@ -141,7 +142,7 @@ def test_context_package_manager_none(self) -> None: ) import pytest - with pytest.raises(Exception): + with pytest.raises(click.UsageError, match="No uv.lock or poetry.lock found"): ctx.package_manager finally: os.chdir(original_dir) From f058d721a1c394296dab1d41dcae4e46206e8311 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:09:06 +0000 Subject: [PATCH 09/10] Honor include_dev_requirements flag in Uv.install() Co-authored-by: MrLYC <6391488+MrLYC@users.noreply.github.com> --- tests/test_uv.py | 9 +++++++++ versifier/uv.py | 3 +++ 2 files changed, 12 insertions(+) diff --git a/tests/test_uv.py b/tests/test_uv.py index 183802e..4caead0 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -52,6 +52,15 @@ def test_install(self, mock_check_call: MagicMock) -> None: 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: diff --git a/versifier/uv.py b/versifier/uv.py index 2217236..374f2a1 100644 --- a/versifier/uv.py +++ b/versifier/uv.py @@ -58,6 +58,9 @@ def install( ) -> 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) From ce80563fd80221ce8228dd0879fe7abf74f6c9eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:09:41 +0000 Subject: [PATCH 10/10] Add test for with_credentials=False case Co-authored-by: MrLYC <6391488+MrLYC@users.noreply.github.com> --- tests/test_uv.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_uv.py b/tests/test_uv.py index db5a8bb..b6a5410 100644 --- a/tests/test_uv.py +++ b/tests/test_uv.py @@ -121,6 +121,7 @@ def side_effect(commands: list) -> 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)