Skip to content

Commit 86a7315

Browse files
Remove Upper Constraint for agent hosting package (#147)
* Remove Upper Constraint for agent hosting package * Remove Upper Constraint for agent hosting package * Remove Upper Constraint for agent hosting package * Remove Upper Constraint for agent hosting package * Address Copilot PR review comments: fix version constraint parsing
1 parent 56e2831 commit 86a7315

File tree

5 files changed

+263
-12
lines changed

5 files changed

+263
-12
lines changed

libraries/microsoft-agents-a365-observability-hosting/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ classifiers = [
2525
license = {text = "MIT"}
2626
keywords = ["observability", "telemetry", "tracing", "opentelemetry", "monitoring", "ai", "agents", "hosting"]
2727
dependencies = [
28-
"microsoft-agents-hosting-core >= 0.4.0, < 0.6.0",
28+
"microsoft-agents-hosting-core >= 0.4.0",
2929
"microsoft-agents-a365-observability-core >= 0.0.0",
3030
"opentelemetry-api >= 1.36.0",
3131
]

libraries/microsoft-agents-a365-tooling-extensions-agentframework/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ classifiers = [
2424
license = {text = "MIT"}
2525
dependencies = [
2626
"microsoft-agents-a365-tooling >= 0.0.0",
27-
"microsoft-agents-hosting-core >= 0.4.0, < 0.6.0",
27+
"microsoft-agents-hosting-core >= 0.4.0",
2828
"agent-framework-azure-ai >= 1.0.0b251114",
2929
"azure-identity >= 1.12.0",
3030
"typing-extensions >= 4.0.0",

libraries/microsoft-agents-a365-tooling/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ license = {text = "MIT"}
2525
dependencies = [
2626
"pydantic >= 2.0.0",
2727
"typing-extensions >= 4.0.0",
28-
"microsoft-agents-hosting-core >= 0.4.0, < 0.6.0",
28+
"microsoft-agents-hosting-core >= 0.4.0",
2929
]
3030

3131
[project.urls]
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""
5+
Tests for validating dependency version constraints across all packages.
6+
7+
This test ensures that upper bound version constraints (e.g., < 0.6.0) on dependencies
8+
do not become incompatible with the latest published versions of those packages.
9+
10+
Background: We encountered an issue where microsoft-agents-a365-tooling had a constraint
11+
`microsoft-agents-hosting-core >= 0.4.0, < 0.6.0` but the samples used 0.7.0, causing
12+
the package resolver to silently pick older versions instead of the latest.
13+
"""
14+
15+
import re
16+
import tomllib
17+
from pathlib import Path
18+
19+
import pytest
20+
21+
# Packages in this repo that should be checked for version constraint issues
22+
INTERNAL_PACKAGES = {
23+
"microsoft-agents-a365-tooling",
24+
"microsoft-agents-a365-tooling-extensions-openai",
25+
"microsoft-agents-a365-tooling-extensions-agentframework",
26+
"microsoft-agents-a365-tooling-extensions-semantickernel",
27+
"microsoft-agents-a365-tooling-extensions-azureaifoundry",
28+
"microsoft-agents-a365-observability-core",
29+
"microsoft-agents-a365-observability-extensions-openai",
30+
"microsoft-agents-a365-observability-extensions-agent-framework",
31+
"microsoft-agents-a365-observability-extensions-semantickernel",
32+
"microsoft-agents-a365-runtime",
33+
"microsoft-agents-a365-notifications",
34+
}
35+
36+
# Known external packages where we should be careful about upper bounds
37+
EXTERNAL_PACKAGES_TO_CHECK = {
38+
"microsoft-agents-hosting-core",
39+
"microsoft-agents-hosting-aiohttp",
40+
"microsoft-agents-authentication-msal",
41+
"microsoft-agents-activity",
42+
}
43+
44+
45+
def get_repo_root() -> Path:
46+
"""Get the root directory of the Agent365-python repository."""
47+
current = Path(__file__).resolve()
48+
# Navigate up to find the repo root (contains 'libraries' folder)
49+
for parent in current.parents:
50+
if (parent / "libraries").is_dir():
51+
return parent
52+
raise RuntimeError("Could not find repository root")
53+
54+
55+
def find_all_pyproject_files() -> list[Path]:
56+
"""Find all pyproject.toml files in the libraries directory."""
57+
repo_root = get_repo_root()
58+
libraries_dir = repo_root / "libraries"
59+
return list(libraries_dir.glob("**/pyproject.toml"))
60+
61+
62+
def parse_version_constraint(constraint: str) -> dict:
63+
"""
64+
Parse a version constraint string and extract bounds.
65+
66+
Examples:
67+
">= 0.4.0, < 0.6.0" -> {"lower": "0.4.0", "upper": "0.6.0", "upper_inclusive": False}
68+
">= 0.4.0" -> {"lower": "0.4.0", "upper": None}
69+
70+
Note: Supports version numbers with any number of parts (e.g., "1.0", "1.0.0", "1.0.0.0").
71+
"""
72+
result = {"lower": None, "upper": None, "upper_inclusive": False, "raw": constraint}
73+
74+
# Match upper bound patterns: < X.Y or <= X.Y.Z (any dotted numeric version)
75+
upper_match = re.search(r"(<\s*=?)\s*(\d+(?:\.\d+)*)", constraint)
76+
if upper_match:
77+
result["upper"] = upper_match.group(2)
78+
result["upper_inclusive"] = upper_match.group(1).replace(" ", "") == "<="
79+
80+
# Match lower bound patterns: >= X.Y or > X.Y.Z (any dotted numeric version)
81+
lower_match = re.search(r">=?\s*(\d+(?:\.\d+)*)", constraint)
82+
if lower_match:
83+
result["lower"] = lower_match.group(1)
84+
85+
return result
86+
87+
88+
def version_tuple(version: str) -> tuple:
89+
"""Convert version string to tuple for comparison."""
90+
# Handle pre-release versions like "0.2.1.dev2"
91+
base_version = version.split(".dev")[0].split("a")[0].split("b")[0].split("rc")[0]
92+
parts = base_version.split(".")
93+
return tuple(int(p) for p in parts)
94+
95+
96+
def is_version_compatible(version: str, upper_bound: str, inclusive: bool = False) -> bool:
97+
"""Check if a version is compatible with an upper bound constraint."""
98+
version_t = version_tuple(version)
99+
upper_t = version_tuple(upper_bound)
100+
101+
if inclusive:
102+
return version_t <= upper_t
103+
return version_t < upper_t
104+
105+
106+
def get_dependencies_with_upper_bounds(pyproject_path: Path) -> list[dict]:
107+
"""
108+
Extract dependencies that have upper bound constraints.
109+
110+
Returns a list of dicts with:
111+
- package: package name
112+
- constraint: parsed constraint info
113+
- file: path to pyproject.toml
114+
"""
115+
with open(pyproject_path, "rb") as f:
116+
data = tomllib.load(f)
117+
118+
dependencies = data.get("project", {}).get("dependencies", [])
119+
results = []
120+
121+
for dep in dependencies:
122+
# Parse dependency string: "package-name >= 1.0.0, < 2.0.0"
123+
match = re.match(r"^([\w\-]+)\s*(.*)$", dep.strip())
124+
if not match:
125+
continue
126+
127+
package_name = match.group(1)
128+
constraint_str = match.group(2).strip()
129+
130+
if not constraint_str:
131+
continue
132+
133+
constraint = parse_version_constraint(constraint_str)
134+
135+
if constraint["upper"]:
136+
results.append({
137+
"package": package_name,
138+
"constraint": constraint,
139+
"file": pyproject_path,
140+
})
141+
142+
return results
143+
144+
145+
class TestDependencyConstraints:
146+
"""Tests for dependency version constraints."""
147+
148+
def test_no_restrictive_upper_bounds_on_external_packages(self):
149+
"""
150+
Ensure we don't have overly restrictive upper bounds on external packages.
151+
152+
Upper bounds like `< 0.6.0` can cause issues when the external package
153+
releases a newer version (e.g., 0.7.0) that our samples depend on.
154+
This causes the resolver to silently pick older versions of our packages.
155+
"""
156+
pyproject_files = find_all_pyproject_files()
157+
issues = []
158+
159+
for pyproject_path in pyproject_files:
160+
deps_with_upper = get_dependencies_with_upper_bounds(pyproject_path)
161+
162+
for dep in deps_with_upper:
163+
package = dep["package"]
164+
165+
# Check if this is an external package we should monitor
166+
if package in EXTERNAL_PACKAGES_TO_CHECK:
167+
constraint = dep["constraint"]
168+
relative_path = pyproject_path.relative_to(get_repo_root())
169+
170+
issues.append(
171+
f" - {relative_path}: '{package}' has upper bound constraint "
172+
f"'{constraint['raw']}'. This may cause resolver issues when "
173+
f"newer versions are released."
174+
)
175+
176+
if issues:
177+
pytest.fail(
178+
"Found dependencies with upper bound constraints that may cause issues:\n"
179+
+ "\n".join(issues)
180+
+ "\n\nConsider removing upper bounds or using a more permissive constraint. "
181+
"Upper bounds on external packages can cause our packages to be downgraded "
182+
"when newer versions of the external package are released."
183+
)
184+
185+
def test_internal_package_constraints_are_flexible(self):
186+
"""
187+
Ensure internal packages don't have restrictive upper bounds on each other.
188+
189+
We want internal packages to be able to evolve together without
190+
version constraint conflicts.
191+
"""
192+
pyproject_files = find_all_pyproject_files()
193+
issues = []
194+
195+
for pyproject_path in pyproject_files:
196+
deps_with_upper = get_dependencies_with_upper_bounds(pyproject_path)
197+
198+
for dep in deps_with_upper:
199+
package = dep["package"]
200+
201+
# Check if this is an internal package
202+
if package in INTERNAL_PACKAGES:
203+
constraint = dep["constraint"]
204+
relative_path = pyproject_path.relative_to(get_repo_root())
205+
206+
issues.append(
207+
f" - {relative_path}: '{package}' has upper bound constraint "
208+
f"'{constraint['raw']}'. Internal packages should not have "
209+
"upper bounds on each other."
210+
)
211+
212+
if issues:
213+
pytest.fail(
214+
"Found internal packages with upper bound constraints:\n"
215+
+ "\n".join(issues)
216+
+ "\n\nInternal packages should use '>= X.Y.Z' without upper bounds "
217+
"to allow them to evolve together."
218+
)
219+
220+
def test_parse_version_constraint(self):
221+
"""Test the version constraint parser."""
222+
# Test with upper and lower bounds
223+
result = parse_version_constraint(">= 0.4.0, < 0.6.0")
224+
assert result["lower"] == "0.4.0"
225+
assert result["upper"] == "0.6.0"
226+
assert result["upper_inclusive"] is False
227+
228+
# Test with only lower bound
229+
result = parse_version_constraint(">= 1.0.0")
230+
assert result["lower"] == "1.0.0"
231+
assert result["upper"] is None
232+
233+
# Test with inclusive upper bound
234+
result = parse_version_constraint(">= 2.0.0, <= 3.0.0")
235+
assert result["lower"] == "2.0.0"
236+
assert result["upper"] == "3.0.0"
237+
assert result["upper_inclusive"] is True
238+
239+
def test_version_compatibility_check(self):
240+
"""Test version compatibility checking."""
241+
# 0.7.0 is NOT compatible with < 0.6.0
242+
assert is_version_compatible("0.7.0", "0.6.0", inclusive=False) is False
243+
244+
# 0.5.9 IS compatible with < 0.6.0
245+
assert is_version_compatible("0.5.9", "0.6.0", inclusive=False) is True
246+
247+
# 0.6.0 IS compatible with <= 0.6.0
248+
assert is_version_compatible("0.6.0", "0.6.0", inclusive=True) is True
249+
250+
# 0.6.0 is NOT compatible with < 0.6.0
251+
assert is_version_compatible("0.6.0", "0.6.0", inclusive=False) is False

uv.lock

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)