Skip to content

Commit 1ef2e32

Browse files
authored
Enforce Conda pyproject.toml metadata in verifywhl (#45012)
* enforce pyproject * minor * use logger instead of logging * dedupe error msg * check for section field * only query pypi once * simplify conda config checking * format * minor fix * ignore yanked versions * format * update tests
1 parent d960879 commit 1ef2e32

3 files changed

Lines changed: 149 additions & 28 deletions

File tree

eng/tools/azure-sdk-tools/azpysdk/verify_whl.py

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import argparse
2-
import logging
32
import os
43
import sys
54
import glob
@@ -59,12 +58,16 @@ def extract_whl(dist_dir, version):
5958

6059

6160
def verify_whl_root_directory(
62-
dist_dir: str, expected_top_level_module: str, parsed_pkg: ParsedSetup, executable: str
61+
dist_dir: str,
62+
expected_top_level_module: str,
63+
parsed_pkg: ParsedSetup,
64+
executable: str,
65+
pypi_versions: Optional[List[str]] = None,
6366
) -> bool:
6467
# Verify metadata compatibility with prior version
6568
version: str = parsed_pkg.version
6669
metadata: Dict[str, Any] = extract_package_metadata(get_path_to_zip(dist_dir, version))
67-
prior_version = get_prior_version(parsed_pkg.name, version)
70+
prior_version = get_prior_version(parsed_pkg.name, version, pypi_versions=pypi_versions)
6871
if prior_version:
6972
if not verify_prior_version_metadata(parsed_pkg.name, prior_version, metadata, executable):
7073
return False
@@ -77,7 +80,7 @@ def verify_whl_root_directory(
7780
non_azure_folders = [d for d in root_folders if d != expected_top_level_module and not d.endswith(".dist-info")]
7881

7982
if non_azure_folders:
80-
logging.error(
83+
logger.error(
8184
"whl has following incorrect directory at root level [%s]",
8285
non_azure_folders,
8386
)
@@ -99,10 +102,52 @@ def should_verify_package(package_name):
99102
return package_name not in EXCLUDED_PACKAGES and "nspkg" not in package_name and "-mgmt" not in package_name
100103

101104

102-
def get_prior_version(package_name: str, current_version: str) -> Optional[str]:
103-
"""Get prior stable version if it exists, otherwise get prior preview version, else return None."""
105+
def has_stable_version_on_pypi(all_versions: List[str]) -> bool:
106+
"""Check if the package has any stable (non-prerelease) version on PyPI."""
104107
try:
105-
all_versions = retrieve_versions_from_pypi(package_name)
108+
stable_versions = [pv for v in all_versions if not (pv := Version(v)).is_prerelease and pv > Version("0.0.0")]
109+
return len(stable_versions) > 0
110+
except Exception:
111+
return False
112+
113+
114+
def verify_conda_section(
115+
package_dir: str, package_name: str, parsed_pkg: ParsedSetup, pypi_versions: List[str]
116+
) -> bool:
117+
"""Verify that packages with stable versions on PyPI have [tool.azure-sdk-conda] section in pyproject.toml."""
118+
if not has_stable_version_on_pypi(pypi_versions):
119+
logger.info(f"Package {package_name} has no stable version on PyPI, skipping conda section check")
120+
return True
121+
122+
pyproject_path = os.path.join(package_dir, "pyproject.toml")
123+
if not os.path.exists(pyproject_path):
124+
logger.error(f"Package {package_name} has a stable version on PyPI but is missing pyproject.toml")
125+
return False
126+
127+
config = parsed_pkg.get_conda_config()
128+
if not config:
129+
logger.error(
130+
f"Package {package_name} has a stable version on PyPI but is missing "
131+
"[tool.azure-sdk-conda] section in pyproject.toml. This section is required to "
132+
"specify if the package should be released individually or bundled to Conda."
133+
)
134+
return False
135+
elif "in_bundle" not in config:
136+
logger.error(f"[tool.azure-sdk-conda] section in pyproject.toml is missing required field `in_bundle`.")
137+
return False
138+
logger.info(f"Verified conda section for package {package_name}")
139+
return True
140+
141+
142+
def get_prior_version(
143+
package_name: str, current_version: str, pypi_versions: Optional[List[str]] = None
144+
) -> Optional[str]:
145+
"""Get prior stable version if it exists, otherwise get prior preview version, else return None.
146+
147+
pypi_versions can be optionally passed in to avoid redundant PyPI calls
148+
"""
149+
try:
150+
all_versions = pypi_versions if pypi_versions is not None else retrieve_versions_from_pypi(package_name)
106151
current_ver = Version(current_version)
107152
prior_versions = [Version(v) for v in all_versions if Version(v) < current_ver]
108153
if not prior_versions:
@@ -179,7 +224,7 @@ def verify_metadata_compatibility(current_metadata: Dict[str, Any], prior_metada
179224
repo_urls = ["homepage", "repository"]
180225
current_keys_lower = {k.lower() for k in current_metadata.keys()}
181226
if not any(key in current_keys_lower for key in repo_urls):
182-
logging.error(f"Current metadata must contain at least one of: {repo_urls}")
227+
logger.error(f"Current metadata must contain at least one of: {repo_urls}")
183228
return False
184229

185230
if not prior_metadata:
@@ -192,7 +237,7 @@ def verify_metadata_compatibility(current_metadata: Dict[str, Any], prior_metada
192237
is_compatible = prior_keys_filtered.issubset(current_keys)
193238
if not is_compatible:
194239
missing_keys = prior_keys_filtered - current_keys
195-
logging.error("Metadata compatibility failed. Missing keys: %s", missing_keys)
240+
logger.error("Metadata compatibility failed. Missing keys: %s", missing_keys)
196241
return is_compatible
197242

198243

@@ -250,11 +295,19 @@ def run(self, args: argparse.Namespace) -> int:
250295
)
251296

252297
if should_verify_package(package_name):
298+
pypi_versions = retrieve_versions_from_pypi(package_name)
299+
253300
logger.info(f"Verifying whl for package: {package_name}")
254-
if verify_whl_root_directory(staging_directory, top_level_module, parsed, executable):
301+
if verify_whl_root_directory(
302+
staging_directory, top_level_module, parsed, executable, pypi_versions=pypi_versions
303+
):
255304
logger.info(f"Verified whl for package {package_name}")
256305
else:
257306
logger.error(f"Failed to verify whl for package {package_name}")
258307
results.append(1)
259308

309+
# Verify conda section for packages with stable versions on PyPI
310+
if not verify_conda_section(package_dir, package_name, parsed, pypi_versions=pypi_versions):
311+
results.append(1)
312+
260313
return max(results) if results else 0

eng/tools/azure-sdk-tools/pypi_tools/pypi.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,11 @@ def get_ordered_versions(self, package_name, filter_by_compatibility=False) -> L
6161
project = self.project(package_name)
6262

6363
versions: List[Version] = []
64-
for package_version in project["releases"].keys():
64+
for package_version, files in project["releases"].items():
6565
try:
66+
# Skip yanked versions (no files or all files yanked)
67+
if not files or all(f.get("yanked", False) for f in files):
68+
continue
6669
versions.append(parse(package_version))
6770
except InvalidVersion as e:
6871
logging.warn(f"Invalid version {package_version} for package {package_name}")

eng/tools/azure-sdk-tools/tests/test_metadata_verification.py

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,14 @@
1313
import glob
1414
import sys
1515

16+
from unittest.mock import MagicMock
1617
from ci_tools.parsing import ParsedSetup
17-
18-
# Import the functions we want to test from the verify modules
19-
tox_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "tox"))
20-
if tox_path not in sys.path:
21-
sys.path.append(tox_path)
22-
23-
# Also add the azure-sdk-tools path so pypi_tools can be imported
24-
tools_path = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
25-
if tools_path not in sys.path:
26-
sys.path.append(tools_path)
27-
28-
from verify_whl import verify_whl_root_directory
29-
from verify_sdist import verify_sdist
18+
from azpysdk.verify_whl import (
19+
verify_whl_root_directory,
20+
has_stable_version_on_pypi,
21+
verify_conda_section,
22+
)
23+
from azpysdk.verify_sdist import verify_sdist_helper
3024

3125
# Test scenario paths
3226
scenarios_folder = os.path.join(os.path.dirname(__file__), "integration", "scenarios")
@@ -99,9 +93,13 @@ def test_verify_valid_metadata_passes(package_type, scenario_name, scenario_path
9993
# Run the appropriate verification function
10094
if package_type == "wheel":
10195
expected_module = parsed_pkg.namespace.split(".")[0] if parsed_pkg.namespace else "azure"
102-
result = verify_whl_root_directory(os.path.dirname(package_path), expected_module, parsed_pkg)
96+
result = verify_whl_root_directory(
97+
os.path.dirname(package_path), expected_module, parsed_pkg, sys.executable, pypi_versions=[]
98+
)
10399
else:
104-
result = verify_sdist(actual_scenario_path, os.path.dirname(package_path), parsed_pkg)
100+
result = verify_sdist_helper(
101+
actual_scenario_path, os.path.dirname(package_path), parsed_pkg, sys.executable
102+
)
105103

106104
# The valid metadata should pass verification (return True)
107105
assert result is True, f"verify_{package_type} should return True for valid {scenario_name} metadata scenario"
@@ -142,9 +140,13 @@ def test_verify_invalid_metadata_fails(package_type, scenario_name, scenario_pat
142140
with caplog.at_level("ERROR"):
143141
if package_type == "wheel":
144142
expected_module = parsed_pkg.namespace.split(".")[0] if parsed_pkg.namespace else "azure"
145-
result = verify_whl_root_directory(os.path.dirname(package_path), expected_module, parsed_pkg)
143+
result = verify_whl_root_directory(
144+
os.path.dirname(package_path), expected_module, parsed_pkg, sys.executable, pypi_versions=None
145+
)
146146
else:
147-
result = verify_sdist(actual_scenario_path, os.path.dirname(package_path), parsed_pkg)
147+
result = verify_sdist_helper(
148+
actual_scenario_path, os.path.dirname(package_path), parsed_pkg, sys.executable
149+
)
148150

149151
# The invalid metadata should fail verification (return False)
150152
assert (
@@ -173,3 +175,66 @@ def test_verify_invalid_metadata_fails(package_type, scenario_name, scenario_pat
173175
# Cleanup dist directory
174176
if os.path.exists(dist_dir):
175177
shutil.rmtree(dist_dir)
178+
179+
180+
# ======================= has_stable_version_on_pypi tests =======================
181+
182+
183+
def test_has_stable_version_on_pypi_with_stable():
184+
"""Returns True when at least one stable version exists."""
185+
assert has_stable_version_on_pypi(["1.0.0", "2.0.0b1", "0.1.0"]) is True
186+
187+
188+
def test_has_stable_version_on_pypi_only_previews():
189+
"""Returns False when all versions are pre-releases."""
190+
assert has_stable_version_on_pypi(["1.0.0b1", "2.0.0a3", "0.1.0rc1"]) is False
191+
192+
193+
def test_has_stable_version_on_pypi_empty():
194+
"""Returns False for an empty version list."""
195+
assert has_stable_version_on_pypi([]) is False
196+
197+
198+
def test_has_stable_version_on_pypi_only_zero():
199+
"""Returns False when the only stable version is 0.0.0."""
200+
assert has_stable_version_on_pypi(["0.0.0"]) is False
201+
202+
203+
# ======================= verify_conda_section tests =======================
204+
205+
206+
def test_verify_conda_section_skips_when_no_stable_version():
207+
"""Should return True (pass) when there are no stable versions on PyPI."""
208+
parsed_pkg = MagicMock()
209+
result = verify_conda_section("/fake/path", "pkg", parsed_pkg, pypi_versions=["1.0.0b1"])
210+
assert result is True
211+
212+
213+
def test_verify_conda_section_fails_missing_conda_section(tmp_path):
214+
"""Should fail when pyproject.toml exists but has no [tool.azure-sdk-conda] section."""
215+
pyproject = tmp_path / "pyproject.toml"
216+
pyproject.write_text("[project]\nname = 'pkg'\n")
217+
parsed_pkg = MagicMock()
218+
parsed_pkg.get_conda_config.return_value = None
219+
result = verify_conda_section(str(tmp_path), "pkg", parsed_pkg, pypi_versions=["1.0.0"])
220+
assert result is False
221+
222+
223+
def test_verify_conda_section_fails_missing_in_bundle(tmp_path):
224+
"""Should fail when [tool.azure-sdk-conda] exists but 'in_bundle' key is missing."""
225+
pyproject = tmp_path / "pyproject.toml"
226+
pyproject.write_text("[tool.azure-sdk-conda]\nsome_other_key = true\n")
227+
parsed_pkg = MagicMock()
228+
parsed_pkg.get_conda_config.return_value = {"some_other_key": True}
229+
result = verify_conda_section(str(tmp_path), "pkg", parsed_pkg, pypi_versions=["1.0.0"])
230+
assert result is False
231+
232+
233+
def test_verify_conda_section_passes_with_valid_config(tmp_path):
234+
"""Should pass when [tool.azure-sdk-conda] has 'in_bundle' defined."""
235+
pyproject = tmp_path / "pyproject.toml"
236+
pyproject.write_text("[tool.azure-sdk-conda]\nin_bundle = true\n")
237+
parsed_pkg = MagicMock()
238+
parsed_pkg.get_conda_config.return_value = {"in_bundle": True}
239+
result = verify_conda_section(str(tmp_path), "pkg", parsed_pkg, pypi_versions=["1.0.0"])
240+
assert result is True

0 commit comments

Comments
 (0)