diff --git a/libraries/microsoft-agents-a365-notifications/pyproject.toml b/libraries/microsoft-agents-a365-notifications/pyproject.toml index c92a32bd..be8511a1 100644 --- a/libraries/microsoft-agents-a365-notifications/pyproject.toml +++ b/libraries/microsoft-agents-a365-notifications/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-notifications" diff --git a/libraries/microsoft-agents-a365-observability-core/pyproject.toml b/libraries/microsoft-agents-a365-observability-core/pyproject.toml index 98d2e399..37740bf1 100644 --- a/libraries/microsoft-agents-a365-observability-core/pyproject.toml +++ b/libraries/microsoft-agents-a365-observability-core/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-observability-core" diff --git a/libraries/microsoft-agents-a365-observability-extensions-agentframework/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-agentframework/pyproject.toml index fa981bcc..45335204 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-agentframework/pyproject.toml +++ b/libraries/microsoft-agents-a365-observability-extensions-agentframework/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-observability-extensions-agent-framework" diff --git a/libraries/microsoft-agents-a365-observability-extensions-langchain/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-langchain/pyproject.toml index a8fd150a..4f8cbce4 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-langchain/pyproject.toml +++ b/libraries/microsoft-agents-a365-observability-extensions-langchain/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-observability-extensions-langchain" diff --git a/libraries/microsoft-agents-a365-observability-extensions-openai/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-openai/pyproject.toml index c1097a69..5c7a368e 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-openai/pyproject.toml +++ b/libraries/microsoft-agents-a365-observability-extensions-openai/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-observability-extensions-openai" diff --git a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/pyproject.toml b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/pyproject.toml index d03204de..ffaadcd7 100644 --- a/libraries/microsoft-agents-a365-observability-extensions-semantickernel/pyproject.toml +++ b/libraries/microsoft-agents-a365-observability-extensions-semantickernel/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-observability-extensions-semantic-kernel" diff --git a/libraries/microsoft-agents-a365-observability-hosting/pyproject.toml b/libraries/microsoft-agents-a365-observability-hosting/pyproject.toml index 7604ade0..3b1aa46b 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/pyproject.toml +++ b/libraries/microsoft-agents-a365-observability-hosting/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-observability-hosting" diff --git a/libraries/microsoft-agents-a365-runtime/pyproject.toml b/libraries/microsoft-agents-a365-runtime/pyproject.toml index 9b7d46b7..8b3a739d 100644 --- a/libraries/microsoft-agents-a365-runtime/pyproject.toml +++ b/libraries/microsoft-agents-a365-runtime/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-runtime" diff --git a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/pyproject.toml b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/pyproject.toml index 613ba11b..f0b6e125 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-agentframework/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling-extensions-agentframework/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-tooling-extensions-agentframework" diff --git a/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/pyproject.toml b/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/pyproject.toml index 2924c0be..31c1dafe 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling-extensions-azureaifoundry/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-tooling-extensions-azureaifoundry" diff --git a/libraries/microsoft-agents-a365-tooling-extensions-googleadk/pyproject.toml b/libraries/microsoft-agents-a365-tooling-extensions-googleadk/pyproject.toml index ddf312da..3af61f81 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-googleadk/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling-extensions-googleadk/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-tooling-extensions-googleadk" diff --git a/libraries/microsoft-agents-a365-tooling-extensions-openai/pyproject.toml b/libraries/microsoft-agents-a365-tooling-extensions-openai/pyproject.toml index 9ad1cb6d..8c23fc27 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-openai/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling-extensions-openai/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-tooling-extensions-openai" diff --git a/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/pyproject.toml b/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/pyproject.toml index d8fdd059..b2264bb5 100644 --- a/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling-extensions-semantickernel/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-tooling-extensions-semantickernel" diff --git a/libraries/microsoft-agents-a365-tooling/pyproject.toml b/libraries/microsoft-agents-a365-tooling/pyproject.toml index 525f9804..f96287f1 100644 --- a/libraries/microsoft-agents-a365-tooling/pyproject.toml +++ b/libraries/microsoft-agents-a365-tooling/pyproject.toml @@ -1,6 +1,7 @@ [build-system] -requires = ["setuptools>=68", "wheel", "tzdata"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit", "packaging"] +build-backend = "build_backend" +backend-path = ["../../versioning/helper"] [project] name = "microsoft-agents-a365-tooling" diff --git a/pyproject.toml b/pyproject.toml index 6987ca26..5f3cc03d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,8 @@ dev-dependencies = [ "pytest-asyncio", "pytest-mock", "ruff", + "tomlkit", + "setuptools", "tox>=4.0", "tox-uv>=1.0", "python-dotenv", diff --git a/tests/test_build_backend.py b/tests/test_build_backend.py new file mode 100644 index 00000000..e5adf637 --- /dev/null +++ b/tests/test_build_backend.py @@ -0,0 +1,524 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Tests for the custom build backend and setup_utils constraint injection. + +Validates that: +- _apply_constraints rewrites bare dependencies with version constraints +- _apply_constraints processes [project.optional-dependencies] as well +- Restoration correctly brings back original content +- Dependencies with existing constraints are left unchanged +- Internal deps get pinned to the build version +- External deps get the centralized root constraint +- Bare external deps with no root constraint emit a warning +- _find_root_pyproject walks up the directory tree correctly +- _parse_root_constraints parses constraint-dependencies from root +""" + +from __future__ import annotations + +import sys +import textwrap +from pathlib import Path + +import pytest + +# Add versioning/helper to sys.path so we can import setup_utils and build_backend +_helper_dir = str(Path(__file__).resolve().parent.parent / "versioning" / "helper") +if _helper_dir not in sys.path: + sys.path.insert(0, _helper_dir) + +from setup_utils import _find_root_pyproject, _parse_root_constraints # noqa: E402 + + +class TestFindRootPyproject: + """Tests for _find_root_pyproject walk-up logic.""" + + def test_finds_root_from_library_dir(self, tmp_path: Path) -> None: + """Walking up from a nested library dir should find the root.""" + # Create root pyproject.toml with workspace marker + root_toml = tmp_path / "pyproject.toml" + root_toml.write_text( + textwrap.dedent("""\ + [tool.uv.workspace] + members = ["libraries/pkg-a"] + + [tool.uv] + constraint-dependencies = ["pydantic >= 2.0.0"] + """), + encoding="utf-8", + ) + + # Create nested library directory + lib_dir = tmp_path / "libraries" / "pkg-a" + lib_dir.mkdir(parents=True) + pkg_toml = lib_dir / "pyproject.toml" + pkg_toml.write_text("[project]\nname = 'pkg-a'\n", encoding="utf-8") + + result = _find_root_pyproject(pkg_toml) + assert result is not None + assert result == root_toml + + def test_returns_none_when_no_root(self, tmp_path: Path) -> None: + """Returns None when no root pyproject.toml with workspace marker exists.""" + child_dir = tmp_path / "some" / "nested" / "dir" + child_dir.mkdir(parents=True) + # Create a pyproject.toml without workspace markers + (tmp_path / "pyproject.toml").write_text( + "[project]\nname = 'no-workspace'\n", + encoding="utf-8", + ) + + result = _find_root_pyproject(child_dir) + assert result is None + + def test_works_from_file_path(self, tmp_path: Path) -> None: + """Works when given a file path instead of a directory.""" + root_toml = tmp_path / "pyproject.toml" + root_toml.write_text( + '[tool.uv]\nconstraint-dependencies = ["httpx >= 0.27.0"]\n', + encoding="utf-8", + ) + + sub = tmp_path / "sub" + sub.mkdir() + some_file = sub / "pyproject.toml" + some_file.write_text("[project]\nname = 'sub'\n", encoding="utf-8") + + result = _find_root_pyproject(some_file) + assert result is not None + assert result == root_toml + + +class TestParseRootConstraints: + """Tests for _parse_root_constraints.""" + + def test_parses_constraints(self, tmp_path: Path) -> None: + """Correctly parses constraint-dependencies from root.""" + root_toml = tmp_path / "pyproject.toml" + root_toml.write_text( + textwrap.dedent("""\ + [tool.uv.workspace] + members = ["libraries/*"] + + [tool.uv] + constraint-dependencies = [ + "pydantic >= 2.0.0", + "opentelemetry-api >= 1.36.0", + "typing-extensions >= 4.0.0", + ] + """), + encoding="utf-8", + ) + + lib_dir = tmp_path / "libraries" / "pkg" + lib_dir.mkdir(parents=True) + + constraints = _parse_root_constraints(lib_dir) + assert constraints["pydantic"] == "pydantic >= 2.0.0" + assert constraints["opentelemetry-api"] == "opentelemetry-api >= 1.36.0" + assert constraints["typing-extensions"] == "typing-extensions >= 4.0.0" + + def test_normalizes_package_names(self, tmp_path: Path) -> None: + """Underscores in package names are normalized to hyphens.""" + root_toml = tmp_path / "pyproject.toml" + root_toml.write_text( + textwrap.dedent("""\ + [tool.uv] + workspace = {} + constraint-dependencies = [ + "some_package >= 1.0.0", + ] + """), + encoding="utf-8", + ) + + constraints = _parse_root_constraints(tmp_path) + assert "some-package" in constraints + + def test_returns_empty_when_no_root(self, tmp_path: Path) -> None: + """Returns empty dict when root not found.""" + isolated_dir = tmp_path / "nowhere" + isolated_dir.mkdir() + constraints = _parse_root_constraints(isolated_dir) + assert constraints == {} + + +import tomlkit # noqa: E402 +from setup_utils import _has_version_constraint, get_dynamic_dependencies # noqa: E402 + + +class TestApplyConstraints: + """Tests for build_backend._apply_constraints.""" + + @pytest.fixture(autouse=True) + def _setup_root(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Create a minimal monorepo root and package for each test.""" + self.root_dir = tmp_path / "repo" + self.root_dir.mkdir() + + # Root pyproject.toml with constraint-dependencies + root_toml = self.root_dir / "pyproject.toml" + root_toml.write_text( + textwrap.dedent("""\ + [tool.uv.workspace] + members = ["libraries/*"] + + [tool.uv] + constraint-dependencies = [ + "pydantic >= 2.0.0", + "opentelemetry-api >= 1.36.0", + "typing-extensions >= 4.0.0", + "aiohttp >= 3.8.0", + ] + """), + encoding="utf-8", + ) + + # Package directory + self.pkg_dir = self.root_dir / "libraries" / "pkg-a" + self.pkg_dir.mkdir(parents=True) + + # Set AGENT365_PYTHON_SDK_PACKAGE_VERSION for internal dep pinning + monkeypatch.setenv("AGENT365_PYTHON_SDK_PACKAGE_VERSION", "1.2.3") + + # Import build_backend (needs to be done after sys.path setup) + import build_backend + + self.build_backend = build_backend + + def _write_pkg_toml(self, content: str) -> Path: + """Write a pyproject.toml in the package dir and return its path.""" + pkg_toml = self.pkg_dir / "pyproject.toml" + pkg_toml.write_text(textwrap.dedent(content), encoding="utf-8") + return pkg_toml + + def test_applies_constraints_to_bare_external_deps(self) -> None: + """Bare external deps get the root constraint.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = [ + "pydantic", + "typing-extensions", + ] + """) + + original = self.build_backend._apply_constraints(pkg_toml) + assert original is not None # Changes were made + + doc = tomlkit.parse(pkg_toml.read_text(encoding="utf-8")) + deps = list(doc["project"]["dependencies"]) + assert "pydantic >= 2.0.0" in deps + assert "typing-extensions >= 4.0.0" in deps + + def test_pins_internal_deps_to_build_version(self) -> None: + """Internal microsoft-agents-a365-* deps get pinned to == build version.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = [ + "microsoft-agents-a365-runtime", + "pydantic", + ] + """) + + self.build_backend._apply_constraints(pkg_toml) + + doc = tomlkit.parse(pkg_toml.read_text(encoding="utf-8")) + deps = list(doc["project"]["dependencies"]) + assert "microsoft-agents-a365-runtime == 1.2.3" in deps + assert "pydantic >= 2.0.0" in deps + + def test_skips_deps_with_existing_constraints(self) -> None: + """Deps that already have version constraints are left unchanged.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = [ + "pydantic >= 1.0.0", + "typing-extensions", + ] + """) + + self.build_backend._apply_constraints(pkg_toml) + + doc = tomlkit.parse(pkg_toml.read_text(encoding="utf-8")) + deps = list(doc["project"]["dependencies"]) + # pydantic should keep its original constraint, not be overridden + assert "pydantic >= 1.0.0" in deps + assert "typing-extensions >= 4.0.0" in deps + + def test_returns_none_when_no_changes_needed(self) -> None: + """Returns None when all deps already have constraints.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = [ + "pydantic >= 2.0.0", + ] + """) + + result = self.build_backend._apply_constraints(pkg_toml) + assert result is None + + def test_restore_brings_back_original(self) -> None: + """_restore correctly restores the original content.""" + content = textwrap.dedent("""\ + [project] + name = "pkg-a" + dependencies = [ + "pydantic", + ] + """) + pkg_toml = self._write_pkg_toml(content) + + original = self.build_backend._apply_constraints(pkg_toml) + assert original is not None + + # File should now have constraints + modified = pkg_toml.read_text(encoding="utf-8") + assert "pydantic >= 2.0.0" in modified + + # Restore + self.build_backend._restore(pkg_toml, original) + restored = pkg_toml.read_text(encoding="utf-8") + assert restored == content + + def test_processes_optional_dependencies(self) -> None: + """Also applies constraints to [project.optional-dependencies] groups.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = [ + "pydantic", + ] + + [project.optional-dependencies] + azure = [ + "aiohttp", + ] + """) + + self.build_backend._apply_constraints(pkg_toml) + + doc = tomlkit.parse(pkg_toml.read_text(encoding="utf-8")) + azure_deps = list(doc["project"]["optional-dependencies"]["azure"]) + assert "aiohttp >= 3.8.0" in azure_deps + + def test_warns_on_bare_dep_with_no_constraint(self, capsys: pytest.CaptureFixture) -> None: + """Emits a warning for bare deps that have no matching root constraint.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = [ + "unknown-package", + ] + """) + + self.build_backend._apply_constraints(pkg_toml) + + captured = capsys.readouterr() + assert "Warning" in captured.err + assert "unknown-package" in captured.err + + def test_preserves_comments_and_formatting(self) -> None: + """tomlkit preserves comments and formatting in the TOML file.""" + content = textwrap.dedent("""\ + # Package configuration + [project] + name = "pkg-a" + # Runtime dependencies + dependencies = [ + "pydantic", # Data validation + "typing-extensions", + ] + """) + pkg_toml = self.pkg_dir / "pyproject.toml" + pkg_toml.write_text(content, encoding="utf-8") + + self.build_backend._apply_constraints(pkg_toml) + + modified = pkg_toml.read_text(encoding="utf-8") + # Comments should be preserved + assert "# Package configuration" in modified + assert "# Runtime dependencies" in modified + + def test_handles_no_project_section(self) -> None: + """Returns None if there is no [project] section.""" + pkg_toml = self._write_pkg_toml("""\ + [build-system] + requires = ["setuptools"] + """) + + result = self.build_backend._apply_constraints(pkg_toml) + assert result is None + + def test_handles_no_dependencies(self) -> None: + """Returns None if [project] has no dependencies.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + """) + + result = self.build_backend._apply_constraints(pkg_toml) + assert result is None + + def test_preserves_environment_markers(self) -> None: + """Deps with environment markers but no version spec get constraints applied.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = [ + 'aiohttp ; python_version < "3.13"', + ] + """) + + self.build_backend._apply_constraints(pkg_toml) + + doc = tomlkit.parse(pkg_toml.read_text(encoding="utf-8")) + deps = list(doc["project"]["dependencies"]) + # Should get the root constraint AND preserve the marker + assert len(deps) == 1 + assert "aiohttp >= 3.8.0" in deps[0] + assert "python_version" in deps[0] + + def test_marker_only_dep_not_treated_as_constrained(self) -> None: + """A dep with only a marker (no version spec) should not be skipped.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = [ + 'pydantic ; sys_platform == "linux"', + ] + """) + + original = self.build_backend._apply_constraints(pkg_toml) + # Should be treated as bare dep and get root constraint applied + assert original is not None + + doc = tomlkit.parse(pkg_toml.read_text(encoding="utf-8")) + deps = list(doc["project"]["dependencies"]) + assert "pydantic >= 2.0.0" in deps[0] + + +class TestHasVersionConstraint: + """Tests for _has_version_constraint with packaging.requirements.Requirement.""" + + def test_bare_dep(self) -> None: + assert _has_version_constraint("pydantic") is False + + def test_dep_with_specifier(self) -> None: + assert _has_version_constraint("pydantic >= 2.0.0") is True + + def test_dep_with_exact(self) -> None: + assert _has_version_constraint("pydantic == 2.1.0") is True + + def test_dep_with_compatible(self) -> None: + assert _has_version_constraint("pydantic ~= 2.0") is True + + def test_dep_with_marker_only(self) -> None: + """Marker-only deps should NOT be treated as version-constrained.""" + assert _has_version_constraint('pydantic ; python_version < "3.12"') is False + + def test_dep_with_marker_and_specifier(self) -> None: + """Deps with both specifier and marker should be constrained.""" + assert _has_version_constraint('pydantic >= 2.0 ; python_version >= "3.9"') is True + + def test_dep_with_extras(self) -> None: + """Extras alone don't count as a version constraint.""" + assert _has_version_constraint("httpx[http2]") is False + + def test_dep_with_extras_and_specifier(self) -> None: + assert _has_version_constraint("httpx[http2] >= 0.27.0") is True + + +class TestGetDynamicDependencies: + """Tests for get_dynamic_dependencies with robust Requirement parsing.""" + + @pytest.fixture(autouse=True) + def _setup_root(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Create a minimal monorepo root for get_dynamic_dependencies tests.""" + self.root_dir = tmp_path / "repo" + self.root_dir.mkdir() + + root_toml = self.root_dir / "pyproject.toml" + root_toml.write_text( + textwrap.dedent("""\ + [tool.uv.workspace] + members = ["libraries/*"] + + [tool.uv] + constraint-dependencies = [ + "pydantic >= 2.0.0", + "aiohttp >= 3.8.0", + ] + """), + encoding="utf-8", + ) + + self.pkg_dir = self.root_dir / "libraries" / "pkg-a" + self.pkg_dir.mkdir(parents=True) + + monkeypatch.setenv("AGENT365_PYTHON_SDK_PACKAGE_VERSION", "1.2.3") + + def _write_pkg_toml(self, deps_toml: str) -> Path: + """Write a package pyproject.toml and return its path.""" + pkg_toml = self.pkg_dir / "pyproject.toml" + pkg_toml.write_text(textwrap.dedent(deps_toml), encoding="utf-8") + return pkg_toml + + def test_external_bare_dep_gets_constraint(self) -> None: + """Bare external deps get root constraint applied.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = ["pydantic"] + """) + result = get_dynamic_dependencies(pyproject_path=str(pkg_toml)) + assert result == ["pydantic >= 2.0.0"] + + def test_external_dep_with_marker_preserves_marker(self) -> None: + """External dep with marker gets constraint + marker preserved.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = ['pydantic ; python_version >= "3.9"'] + """) + result = get_dynamic_dependencies(pyproject_path=str(pkg_toml)) + assert len(result) == 1 + assert "pydantic >= 2.0.0" in result[0] + assert "python_version" in result[0] + + def test_external_dep_no_root_constraint_kept_as_is(self) -> None: + """External dep with no matching root constraint stays unchanged.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = ["unknown-package"] + """) + result = get_dynamic_dependencies(pyproject_path=str(pkg_toml)) + assert result == ["unknown-package"] + + def test_internal_dep_gets_pinned(self) -> None: + """Internal microsoft-agents-a365-* deps get pinned to build version.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = ["microsoft-agents-a365-runtime"] + """) + result = get_dynamic_dependencies(pyproject_path=str(pkg_toml)) + assert result == ["microsoft-agents-a365-runtime >= 1.2.3"] + + def test_internal_dep_with_marker(self) -> None: + """Internal dep with marker gets pinned and marker preserved.""" + pkg_toml = self._write_pkg_toml("""\ + [project] + name = "pkg-a" + dependencies = ['microsoft-agents-a365-runtime ; sys_platform == "linux"'] + """) + result = get_dynamic_dependencies(pyproject_path=str(pkg_toml)) + assert len(result) == 1 + assert "microsoft-agents-a365-runtime >= 1.2.3" in result[0] + assert "sys_platform" in result[0] diff --git a/uv.lock b/uv.lock index b407fb4d..e9ab05b5 100644 --- a/uv.lock +++ b/uv.lock @@ -76,6 +76,8 @@ dev = [ { name = "pytest-mock" }, { name = "python-dotenv" }, { name = "ruff" }, + { name = "setuptools" }, + { name = "tomlkit" }, { name = "tox", specifier = ">=4.0" }, { name = "tox-uv", specifier = ">=1.0" }, ] @@ -1489,7 +1491,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, - { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" }, { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, @@ -1498,7 +1499,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, - { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, @@ -1507,7 +1507,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, @@ -1516,7 +1515,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, @@ -1525,7 +1523,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, @@ -4552,6 +4549,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/65/84af473bb095a3c02273caca44c4aaa1506b32bcd28d4299a0efd9254379/semantic_kernel-1.41.0-py3-none-any.whl", hash = "sha256:cfd782eb2738a18f9bd7e8793fe9d2451a3d17267d5c93a3081162280ebd826d", size = 920361, upload-time = "2026-03-13T09:02:25.082Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + [[package]] name = "shapely" version = "2.1.2" @@ -4745,6 +4751,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + [[package]] name = "tox" version = "4.51.0" diff --git a/versioning/helper/build_backend.py b/versioning/helper/build_backend.py new file mode 100644 index 00000000..e92451b9 --- /dev/null +++ b/versioning/helper/build_backend.py @@ -0,0 +1,196 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +Custom build backend that wraps setuptools.build_meta to inject centralized +version constraints into published wheel metadata. + +Note: backend-path references a directory outside the package. This is intentional +for monorepo wheel builds. Individual sdist publishing is not supported; packages +are published as wheels only from CI. + +Usage in package pyproject.toml: + [build-system] + requires = ["setuptools>=68", "wheel", "tzdata", "tomlkit"] + build-backend = "build_backend" + backend-path = ["../../versioning/helper"] +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import tomlkit +from setup_utils import _parse_root_constraints +from setuptools.build_meta import ( + build_editable, + build_sdist, + build_wheel, + get_requires_for_build_editable, + get_requires_for_build_sdist, + get_requires_for_build_wheel, + prepare_metadata_for_build_editable, + prepare_metadata_for_build_wheel, +) + +__all__ = [ + "build_editable", + "build_sdist", + "build_wheel", + "get_requires_for_build_editable", + "get_requires_for_build_sdist", + "get_requires_for_build_wheel", + "prepare_metadata_for_build_editable", + "prepare_metadata_for_build_wheel", +] + + +def _get_package_version() -> str: + """Get the package version from the environment variable set by CI/CD.""" + return os.environ.get("AGENT365_PYTHON_SDK_PACKAGE_VERSION", "0.0.0") + + +def _apply_constraints_to_list( + deps: tomlkit.items.Array, + root_constraints: dict[str, str], + package_version: str, +) -> bool: + """ + Apply version constraints to a tomlkit dependency array in-place. + + Returns True if any changes were made. + """ + from packaging.requirements import Requirement + + changed = False + for i, dep in enumerate(deps): + if not isinstance(dep, str): + continue + stripped = dep.strip() + + # Parse the requirement to correctly handle environment markers + # e.g. 'pkg; python_version < "3.12"' should not be treated as constrained + try: + req = Requirement(stripped) + name = req.name + has_specifier = bool(req.specifier) + marker_suffix = f" ; {req.marker}" if req.marker else "" + except Exception: + # Fallback: split off markers manually + base, sep, marker_rest = stripped.partition(";") + name = base.strip() + has_specifier = False + marker_suffix = f";{marker_rest}" if sep else "" + + # Skip deps that already have version constraints + if has_specifier: + continue + + if name.startswith("microsoft-agents-a365-"): + # Pin internal deps to exact build version + deps[i] = f"{name} == {package_version}{marker_suffix}" + changed = True + else: + # Apply root constraint for external deps + normalized = name.lower().replace("_", "-") + if normalized in root_constraints: + deps[i] = f"{root_constraints[normalized]}{marker_suffix}" + changed = True + else: + print( + f"Warning: No constraint found for bare dependency '{name}'. " + f"It will be published without a version constraint.", + file=sys.stderr, + ) + return changed + + +def _apply_constraints(pyproject_path: Path) -> str | None: + """ + Rewrite pyproject.toml to include version constraints on all bare deps. + + Uses tomlkit for safe TOML round-tripping that preserves comments, + formatting, and handles multiline arrays and alternate quoting styles. + + - Internal deps (microsoft-agents-a365-*) get pinned to == current build version + - External deps get the centralized constraint from root pyproject.toml + - Both [project].dependencies and [project.optional-dependencies] are processed + + Returns the original content so it can be restored, or None if no changes needed. + """ + original = pyproject_path.read_text(encoding="utf-8") + doc = tomlkit.parse(original) + + project = doc.get("project") + if project is None: + return None + + root_constraints = _parse_root_constraints(pyproject_path) + package_version = _get_package_version() + + changed = False + + # Process [project].dependencies + deps = project.get("dependencies") + if deps is not None: + changed |= _apply_constraints_to_list(deps, root_constraints, package_version) + + # Process [project.optional-dependencies] + opt_deps = project.get("optional-dependencies") + if opt_deps is not None: + for group_name in opt_deps: + group = opt_deps[group_name] + changed |= _apply_constraints_to_list(group, root_constraints, package_version) + + if not changed: + return None + + pyproject_path.write_text(tomlkit.dumps(doc), encoding="utf-8") + return original + + +def _restore(pyproject_path: Path, original: str | None) -> None: + """Restore the original pyproject.toml content.""" + if original is not None: + pyproject_path.write_text(original, encoding="utf-8") + + +# Override build_wheel to inject constraints +_orig_build_wheel = build_wheel + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + pyproject = Path("pyproject.toml") + original = _apply_constraints(pyproject) + try: + return _orig_build_wheel(wheel_directory, config_settings, metadata_directory) + finally: + _restore(pyproject, original) + + +# Override build_sdist to inject constraints +_orig_build_sdist = build_sdist + + +def build_sdist(sdist_directory, config_settings=None): + pyproject = Path("pyproject.toml") + original = _apply_constraints(pyproject) + try: + return _orig_build_sdist(sdist_directory, config_settings) + finally: + _restore(pyproject, original) + + +# Override prepare_metadata_for_build_wheel to inject constraints +_orig_prepare_metadata = prepare_metadata_for_build_wheel + + +def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None): + pyproject = Path("pyproject.toml") + original = _apply_constraints(pyproject) + try: + return _orig_prepare_metadata(metadata_directory, config_settings) + finally: + _restore(pyproject, original) diff --git a/versioning/helper/setup_utils.py b/versioning/helper/setup_utils.py index 5c007f68..9a38251e 100644 --- a/versioning/helper/setup_utils.py +++ b/versioning/helper/setup_utils.py @@ -8,7 +8,9 @@ at build time, ensuring all packages in the monorepo use the exact same version. """ +import re from os import environ +from pathlib import Path def get_package_version() -> str: @@ -101,6 +103,120 @@ def get_next_major_version(base_version: str) -> str: return base_version +def _find_root_pyproject(start_path: Path | None = None) -> Path | None: + """ + Walk up from start_path to find the monorepo root pyproject.toml. + + The root is identified by having [tool.uv.workspace] or a + constraint-dependencies key under [tool.uv]. + + Args: + start_path: A path to start walking up from (e.g. a package's pyproject.toml). + If None, starts from the current working directory. + + Returns: + Path to the root pyproject.toml, or None if not found. + """ + try: + import tomllib + except ImportError: + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: + return None + + if start_path is None: + start_path = Path.cwd() + else: + start_path = Path(start_path).resolve() + + # If start_path is a file, begin from its parent directory + if start_path.is_file(): + start_path = start_path.parent + + for parent in [start_path] + list(start_path.parents): + candidate = parent / "pyproject.toml" + if not candidate.exists(): + continue + try: + with open(candidate, "rb") as f: + data = tomllib.load(f) + uv_cfg = data.get("tool", {}).get("uv", {}) + if "workspace" in uv_cfg or "constraint-dependencies" in uv_cfg: + return candidate + except Exception: + continue + return None + + +def _parse_root_constraints(start_path: Path | None = None) -> dict[str, str]: + """ + Parse constraint-dependencies from the monorepo root pyproject.toml. + + Walks up from start_path to find the root, then parses constraints. + + Args: + start_path: A path to start walking up from (e.g. a package's pyproject.toml). + If None, starts from the current working directory. + + Returns: + A dict mapping normalized package names to their full constraint strings. + Example: {"semantic-kernel": "semantic-kernel >= 1.39.3"} + """ + try: + import tomllib + except ImportError: + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: + return {} + + root_pyproject_path = _find_root_pyproject(start_path) + if root_pyproject_path is None: + return {} + + try: + with open(root_pyproject_path, "rb") as f: + root_data = tomllib.load(f) + except (FileNotFoundError, PermissionError): + return {} + + from packaging.requirements import Requirement + + constraints_list = root_data.get("tool", {}).get("uv", {}).get("constraint-dependencies", []) + constraints: dict[str, str] = {} + for entry in constraints_list: + if not isinstance(entry, str): + continue + try: + req = Requirement(entry) + normalized = req.name.lower().replace("_", "-") + except Exception: + # Fallback for entries that packaging can't parse + pkg_name = re.split(r"\s*[<>=!~]", entry, maxsplit=1)[0].strip() + normalized = pkg_name.lower().replace("_", "-") + constraints[normalized] = entry + return constraints + + +def _has_version_constraint(dep: str) -> bool: + """Check if a dependency string already includes a version constraint. + + Uses packaging.requirements.Requirement for robust parsing that correctly + ignores environment markers (e.g. ``; python_version < '3.12'``). + """ + from packaging.requirements import Requirement + + try: + req = Requirement(dep) + return bool(req.specifier) + except Exception: + # If packaging can't parse it, fall back to simple heuristic + # on the portion before any marker. + base = dep.split(";", 1)[0].strip() + return bool(re.search(r"[<>=!~]", base)) + + def get_dynamic_dependencies( pyproject_path: str = "pyproject.toml", use_exact_match: bool = False, @@ -126,7 +242,9 @@ def get_dynamic_dependencies( Example: == 0.1.0.dev5 - Forces exact version match - External packages keep their original version constraints. + External packages without version constraints get the centralized constraint + from the root pyproject.toml constraint-dependencies, ensuring published packages + enforce minimum versions for security and compatibility. Args: pyproject_path: Path to the pyproject.toml file (default: "pyproject.toml") @@ -204,6 +322,13 @@ def get_dynamic_dependencies( file=sys.stderr, ) + # Load centralized constraints from root pyproject.toml so that published + # packages enforce the same minimum versions used during development. + # Uses walk-up approach to find the root, independent of directory depth. + root_constraints = _parse_root_constraints(Path(pyproject_path).resolve()) + + from packaging.requirements import Requirement + # Update internal package versions dynamically updated_dependencies = [] for dep in dependencies: @@ -215,22 +340,39 @@ def get_dynamic_dependencies( ) continue - if dep.startswith("microsoft-agents-a365-"): - # Extract package name (everything before >=, ==, or other operators) - pkg_name = dep.split(">=")[0].split("==")[0].split("<")[0].strip() - + # Parse with packaging.requirements.Requirement for robust handling + # of version specifiers, extras, and environment markers. + try: + req = Requirement(dep) + pkg_name = req.name + has_specifier = bool(req.specifier) + marker_suffix = f" ; {req.marker}" if req.marker else "" + except Exception: + # Fallback for unparseable entries + pkg_name = dep.split(">=")[0].split("==")[0].split("<")[0].split(";")[0].strip() + has_specifier = _has_version_constraint(dep) + base_part, sep, marker_rest = dep.partition(";") + marker_suffix = f";{marker_rest}" if sep else "" + + if pkg_name.startswith("microsoft-agents-a365-"): if use_exact_match: - # Exact match: == current_version - updated_dependencies.append(f"{pkg_name} == {package_version}") + updated_dependencies.append(f"{pkg_name} == {package_version}{marker_suffix}") elif use_compatible_release: - # Compatible release: >= base_version, < next_major next_major = get_next_major_version(base_version) - updated_dependencies.append(f"{pkg_name} >= {base_version}, < {next_major}") + updated_dependencies.append( + f"{pkg_name} >= {base_version}, < {next_major}{marker_suffix}" + ) + else: + updated_dependencies.append(f"{pkg_name} >= {base_version}{marker_suffix}") + elif not has_specifier: + # External dep with no version constraint — apply root constraint if available + normalized = pkg_name.lower().replace("_", "-") + if normalized in root_constraints: + updated_dependencies.append(f"{root_constraints[normalized]}{marker_suffix}") else: - # Minimum version (default): >= base_version - updated_dependencies.append(f"{pkg_name} >= {base_version}") + updated_dependencies.append(dep) else: - # Keep external dependencies as-is + # External dependency already has a version constraint — keep as-is updated_dependencies.append(dep) return updated_dependencies