diff --git a/pyproject.toml b/pyproject.toml index 266bd2e..7112f3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dynamic = [ ] dependencies = [ "packaging>=24.2", + "tomli>=2.2.1; python_version<'3.11'", "tox>=4.26,<5", "typing-extensions>=4.12.2; python_version<'3.10'", "uv>=0.5.31,<1", @@ -66,7 +67,7 @@ test = [ "pytest-cov>=6", "pytest-mock>=3.14", ] -type = [ "mypy==1.15", { include-group = "test" } ] +type = [ "mypy==1.15", "types-setuptools>=80.9.0.20250801", { include-group = "test" } ] lint = [ "pre-commit-uv>=4.1.4" ] pkg-meta = [ "check-wheel-contents>=0.6.1", "twine>=6.1", "uv>=0.5.31" ] diff --git a/src/tox_uv/_installer.py b/src/tox_uv/_installer.py index a4ce87b..56d32de 100644 --- a/src/tox_uv/_installer.py +++ b/src/tox_uv/_installer.py @@ -3,11 +3,17 @@ from __future__ import annotations import logging +import sys from collections import defaultdict from collections.abc import Sequence +from functools import cached_property from itertools import chain from typing import TYPE_CHECKING, Any, Final +if sys.version_info >= (3, 11): # pragma: no cover (py311+) + import tomllib +else: # pragma: no cover (py311+) + import tomli as tomllib from packaging.requirements import Requirement from packaging.utils import parse_sdist_filename, parse_wheel_filename from tox.config.types import Command @@ -101,6 +107,17 @@ def install(self, arguments: Any, section: str, of_type: str) -> None: # noqa: _LOGGER.warning("uv cannot install %r", arguments) # pragma: no cover raise SystemExit(1) # pragma: no cover + @cached_property + def _sourced_pkg_names(self) -> set[str]: + pyproject_file = self._env.conf._conf.src_path.parent / "pyproject.toml" # noqa: SLF001 + if not pyproject_file.exists(): # pragma: no cover + return set() + with pyproject_file.open("rb") as file_handler: + pyproject = tomllib.load(file_handler) + + sources = pyproject.get("tool", {}).get("uv", {}).get("sources", {}) + return {key for key, val in sources.items() if val.get("workspace", False)} + def _install_list_of_deps( # noqa: C901, PLR0912 self, arguments: Sequence[ @@ -114,12 +131,20 @@ def _install_list_of_deps( # noqa: C901, PLR0912 if isinstance(arg, Requirement): # pragma: no branch groups["req"].append(str(arg)) # pragma: no cover elif isinstance(arg, (WheelPackage, SdistPackage, EditablePackage)): - groups["req"].extend(str(i) for i in arg.deps) + for pkg in arg.deps: + if ( + isinstance(pkg, Requirement) + and pkg.name in self._sourced_pkg_names + and "." not in groups["uv_editable"] + ): + groups["uv_editable"].append(".") + continue + groups["req"].append(str(pkg)) parser = parse_sdist_filename if isinstance(arg, SdistPackage) else parse_wheel_filename name, *_ = parser(arg.path.name) groups["pkg"].append(f"{name}@{arg.path}") elif isinstance(arg, EditableLegacyPackage): - groups["req"].extend(str(i) for i in arg.deps) + groups["req"].extend(str(pkg) for pkg in arg.deps) groups["dev_pkg"].append(str(arg.path)) elif isinstance(arg, UvPackage): extras_suffix = f"[{','.join(arg.extras)}]" if arg.extras else "" diff --git a/tests/conftest.py b/tests/conftest.py index ed5c923..b768868 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,6 +28,16 @@ def demo_pkg_setuptools(root: Path) -> Path: return root / "demo_pkg_setuptools" +@pytest.fixture(scope="session") +def demo_pkg_workspace(root: Path) -> Path: + return root / "demo_pkg_workspace" + + +@pytest.fixture(scope="session") +def demo_pkg_no_pyproject(root: Path) -> Path: + return root / "demo_pkg_no_pyproject" + + @pytest.fixture(scope="session") def demo_pkg_inline(root: Path) -> Path: return root / "demo_pkg_inline" diff --git a/tests/demo_pkg_no_pyproject/setup.cfg b/tests/demo_pkg_no_pyproject/setup.cfg new file mode 100644 index 0000000..3950347 --- /dev/null +++ b/tests/demo_pkg_no_pyproject/setup.cfg @@ -0,0 +1,8 @@ +[metadata] +name=demo-pkg +version=0.0.1 + +[options] + +[bdist_wheel] +universal=1 diff --git a/tests/demo_pkg_no_pyproject/setup.py b/tests/demo_pkg_no_pyproject/setup.py new file mode 100644 index 0000000..8e987d1 --- /dev/null +++ b/tests/demo_pkg_no_pyproject/setup.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from setuptools import setup + +setup(name="demo-pkg", package_dir={"": "src"}) diff --git a/tests/demo_pkg_no_pyproject/src/demo_pkg/__init__.py b/tests/demo_pkg_no_pyproject/src/demo_pkg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/demo_pkg_workspace/README.md b/tests/demo_pkg_workspace/README.md new file mode 100644 index 0000000..e69de29 diff --git a/tests/demo_pkg_workspace/packages/demo_foo/README.md b/tests/demo_pkg_workspace/packages/demo_foo/README.md new file mode 100644 index 0000000..e69de29 diff --git a/tests/demo_pkg_workspace/packages/demo_foo/pyproject.toml b/tests/demo_pkg_workspace/packages/demo_foo/pyproject.toml new file mode 100644 index 0000000..65a95f3 --- /dev/null +++ b/tests/demo_pkg_workspace/packages/demo_foo/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +build-backend = "uv_build" +requires = [ "uv-build>=0.8.9,<0.9" ] + +[project] +name = "demo-foo" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +authors = [ { name = "Sorin Sbarnea", email = "sorin.sbarnea@gmail.com" } ] +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ "demo-root" ] diff --git a/tests/demo_pkg_workspace/packages/demo_foo/src/demo_foo/__init__.py b/tests/demo_pkg_workspace/packages/demo_foo/src/demo_foo/__init__.py new file mode 100644 index 0000000..62b1de5 --- /dev/null +++ b/tests/demo_pkg_workspace/packages/demo_foo/src/demo_foo/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +def hello() -> str: + return "Hello from demo-foo!" diff --git a/tests/demo_pkg_workspace/packages/demo_foo/src/demo_foo/py.typed b/tests/demo_pkg_workspace/packages/demo_foo/src/demo_foo/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/demo_pkg_workspace/pyproject.toml b/tests/demo_pkg_workspace/pyproject.toml new file mode 100644 index 0000000..b8f466c --- /dev/null +++ b/tests/demo_pkg_workspace/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +build-backend = "uv_build" +requires = [ "uv-build>=0.8.9,<0.9" ] + +[project] +name = "demo-root" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +# typing-extensions is not really used but we include it for testing the +# branch coverage for deps that are not sourced. +dependencies = [ "demo-foo", "typing-extensions" ] + +[tool.uv.sources] +demo-foo = { workspace = true } +demo-root = { workspace = true } + +[tool.uv.workspace] +members = [ "packages/*" ] diff --git a/tests/demo_pkg_workspace/src/demo_root/__init__.py b/tests/demo_pkg_workspace/src/demo_root/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/demo_pkg_workspace/src/demo_root/main.py b/tests/demo_pkg_workspace/src/demo_root/main.py new file mode 100644 index 0000000..fec68a3 --- /dev/null +++ b/tests/demo_pkg_workspace/src/demo_root/main.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +def main() -> None: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/test_tox_uv_package.py b/tests/test_tox_uv_package.py index f91d52c..ce63109 100644 --- a/tests/test_tox_uv_package.py +++ b/tests/test_tox_uv_package.py @@ -54,3 +54,31 @@ def test_uv_package_requirements(tox_project: ToxProjectCreator) -> None: project = tox_project({"tox.ini": "[testenv]\npackage=skip\ndeps=-r demo.txt", "demo.txt": "tomli"}) result = project.run("-vv") result.assert_success() + + +def test_uv_package_workspace(tox_project: ToxProjectCreator, demo_pkg_workspace: Path) -> None: + """Tests ability to install uv workspace projects.""" + ini = f""" + [testenv] + + [testenv:.pkg] + uv_seed = true + {"deps = wheel" if sys.version_info >= (3, 12) else ""} + """ + project = tox_project({"tox.ini": ini}, base=demo_pkg_workspace) + result = project.run() + result.assert_success() + + +def test_uv_package_no_pyproject(tox_project: ToxProjectCreator, demo_pkg_no_pyproject: Path) -> None: + """Tests ability to install uv workspace projects.""" + ini = f""" + [testenv] + + [testenv:.pkg] + uv_seed = true + {"deps = wheel" if sys.version_info >= (3, 12) else ""} + """ + project = tox_project({"tox.ini": ini}, base=demo_pkg_no_pyproject) + result = project.run() + result.assert_success()