Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .azure-pipelines/templates/azdev_setup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ steps:
# clone azure-cli
git clone -q --single-branch -b dev https://github.com/Azure/azure-cli.git ../azure-cli
python -m pip install -U pip
pip install azdev
# azdev 0.2.11b1 (decouples extension metadata from wheel==0.30.0) tho it is not on PyPI yet.
# Pinned to a specific azure-cli-dev-tools commit (not @dev) for reproducible builds
# until the pre-release is published; then switch this back to:
# pip install --upgrade "azdev==0.2.11b1"
pip install --upgrade "git+https://github.com/Azure/azure-cli-dev-tools.git@49390f30c6a822620d2fbdf88889c4445d22666f#egg=azdev"
azdev --version
azdev setup -c $CLI_REPO_PATH -r $CLI_EXT_REPO_PATH --debug
# Installing setuptools with a version higher than 70.0.0 will not generate metadata.json
pip install setuptools==70.0.0
pip list -v
az --version
displayName: 'azdev setup'
Expand Down
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This checklist is used to make sure that common guidelines for a pull request ar
### General Guidelines

- [ ] Have you run `azdev style <YOUR_EXT>` locally? (`pip install azdev` required)
- [ ] Have you run `python scripts/ci/test_index.py -q` locally? (`pip install wheel==0.30.0` required)
- [ ] Have you run `python scripts/ci/test_index.py -q` locally? (`azdev` required; see `.azure-pipelines/templates/azdev_setup.yml` for the install command until `azdev==0.2.11b1` is on PyPI)
- [ ] My extension version conforms to the [Extension version schema](https://github.com/Azure/azure-cli/blob/release/doc/extensions/versioning_guidelines.md)

For new extensions:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ celerybeat-schedule

# virtualenv
.venv
/.venv*/
.env
.python-version
venv/
Expand Down
9 changes: 6 additions & 3 deletions azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ jobs:
- bash: |
#!/usr/bin/env bash
set -ev
pip install wheel==0.30.0 requests packaging setuptools
# test_index.py imports scripts/ci/util.py, which reads wheel metadata via azdev's
# pkginfo-based metadata module (decoupled from wheel==0.30.0), so azdev must be
# installed here. Install from the merged azure-cli-dev-tools source until azdev
# 0.2.11b1 is published to PyPI, then switch this to: pip install --upgrade "azdev==0.2.11b1"
pip install --upgrade "git+https://github.com/Azure/azure-cli-dev-tools.git@49390f30c6a822620d2fbdf88889c4445d22666f#egg=azdev"
pip install requests packaging setuptools
export CI="ADO"
python ./scripts/ci/test_index.py -v
displayName: "Verify Extensions Index"
Expand All @@ -106,8 +111,6 @@ jobs:
inputs:
versionSpec: '$(python.version)'
- template: .azure-pipelines/templates/azdev_setup.yml
- bash: pip install wheel==0.30.0
displayName: 'Install wheel==0.30.0'
- bash: |
#!/usr/bin/env bash
set -ev
Expand Down
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
markers =
e2e_packaging: End-to-end packaging tests for extension lifecycle and metadata behavior.
smoke: Fast smoke checks for harness readiness.
slow: Tests that build and install extensions and can take longer to run.
161 changes: 138 additions & 23 deletions scripts/ci/test_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import print_function

import copy
import glob
import hashlib
import json
Expand All @@ -20,8 +21,6 @@

from packaging import version
from util import SRC_PATH
from wheel.install import WHEEL_INFO_RE

from util import get_ext_metadata, get_whl_from_url, get_index_data


Expand All @@ -39,6 +38,108 @@ def get_sha256sum(a_file):
return sha256.hexdigest()


# Cosmetic packaging-format fields that the legacy wheel==0.30.0 metadata path
# emitted but that carry no extension behaviour (they describe the *build tool*
# and METADATA serialization, not the extension). The pkginfo-based path no
# longer reproduces them, so they are ignored when comparing generated metadata
# against the published index. Kept in sync with the tests/e2e/packaging
# metadata parity allowlist.
_METADATA_NOISE_TOP_LEVEL = ('generator', 'metadata_version', 'test_requires', 'license_file',
'description_content_type', 'project_url')


def _canonical_requirement(raw):
"""Reduce a requirement string to its semantic identity.

``run_requires`` in the published index is serialized inconsistently across
the years of tooling that produced it: a dependency may appear as
``"jinja2 (~=2.10)"`` (PEP 314 form), ``"jinja2~=2.10"`` (PEP 508 form), or
both (wheel==0.30.0 emitted each requirement twice). These are all the *same*
dependency. Canonicalize to ``name + specifier (+ marker)`` so that only a
genuine dependency change (added/removed package, different version pin) is
treated as a difference -- spelling, parentheses and ordering are not.
"""
text = raw.replace('(', '').replace(')', '').strip()
try:
from packaging.requirements import Requirement
req = Requirement(text)
extras = ''.join(sorted('[{}]'.format(e) for e in req.extras))
marker = ' ; {}'.format(req.marker) if req.marker else ''
return '{}{}{}{}'.format(req.name.lower(), extras, str(req.specifier), marker)
except Exception: # pragma: no cover - non-PEP-508 string, compare verbatim
return raw.strip()


def _is_extra_requirement(raw):
"""True if a requirement is gated on an extra (e.g. ``; extra == "linting"``)."""
try:
from packaging.requirements import Requirement
marker = Requirement(raw.replace('(', '').replace(')', '').strip()).marker
return marker is not None and 'extra' in str(marker)
except Exception: # pragma: no cover - non-PEP-508 string
return 'extra' in (raw or '')


def _canonical_run_requires(run_requires):
"""Flatten ``run_requires`` to a sorted set of canonical *runtime* requirements.

Optional dependencies declared under an extra (e.g. a "linting" group) are
excluded: ``az extension add`` never installs extras, and wheel==0.30.0 and
the pkginfo path serialize them inconsistently (separate ``extra`` blocks
and/or ``; extra == "x"`` markers, sometimes both). Non-extra environment
markers (e.g. ``python_version``) are preserved so a genuinely conditional
runtime dependency is not conflated with an unconditional one.
"""
canon = set()
for entry in run_requires or []:
if not isinstance(entry, dict):
continue
# Skip whole optional-extra dependency groups.
if entry.get('extra'):
continue
condition = entry.get('environment') or ''
if 'extra' in condition:
continue
for raw in entry.get('requires') or []:
if _is_extra_requirement(raw):
continue
req = _canonical_requirement(raw)
canon.add('{} ; {}'.format(req, condition) if condition else req)
return sorted(canon)


def _without_metadata_noise(metadata):
cleaned = copy.deepcopy(metadata)
for key in _METADATA_NOISE_TOP_LEVEL:
cleaned.pop(key, None)

cleaned.pop('extensions', None)
# `extras` lists optional dependency groups (e.g. "linting") that
# `az extension add` never installs, and that wheel==0.30.0 and the pkginfo
# path populate inconsistently. Ignore it; the real runtime dependency set
# is still compared via run_requires below.
cleaned.pop('extras', None)

for key in ('run_requires', 'classifiers'):
if not cleaned.get(key):
cleaned.pop(key, None)
# Compare dependencies by their semantic identity, not their (historically
# inconsistent) string serialization. A real runtime dependency change
# still fails.
if isinstance(cleaned.get('run_requires'), list):
canonical = _canonical_run_requires(cleaned['run_requires'])
if canonical:
cleaned['run_requires'] = canonical
else:
cleaned.pop('run_requires', None)
# Leading/trailing whitespace in string values is a serialization artifact
# (e.g. a trailing space in a license string), never semantically meaningful.
for key, value in list(cleaned.items()):
if isinstance(value, str):
cleaned[key] = value.strip()
return cleaned


def check_min_version(extension_name, metadata):
if 'azext.minCliCoreVersion' not in metadata:
try:
Expand Down Expand Up @@ -85,14 +186,13 @@ def test_extension_filenames(self):
"Extension name mismatch in extensions['{}']. "
"Found an extension in the list with name "
"{}".format(ext_name, item['metadata']['name']))
# Due to https://github.com/pypa/wheel/issues/235 we prevent whls built with 0.31.0 or greater.
# 0.29.0, 0.30.0 are the two previous versions before that release.
parsed_filename = WHEEL_INFO_RE(item['filename'])
p = parsed_filename.groupdict()
self.assertTrue(p.get('name'), "Can't get name for {}".format(item['filename']))
built_wheel = p.get('abi') == 'none' and p.get('plat') == 'any'
self.assertTrue(built_wheel,
"{} of {} not platform independent wheel. "
# Extensions must be published as platform-independent wheels,
# i.e. the filename ends in ``-none-any.whl`` (ABI tag ``none``,
# platform tag ``any``). This was previously derived from
# ``wheel.install.WHEEL_INFO_RE``; it is asserted directly here
# so the check no longer depends on the ``wheel`` package.
self.assertTrue(item['filename'].endswith('-none-any.whl'),
"{} of {} is not a platform-independent wheel. "
"It should end in -none-any.whl".format(item['filename'], ext_name))

def test_extension_url_filename(self):
Expand Down Expand Up @@ -158,6 +258,20 @@ def test_metadata(self):
'log-analytics': '0.2.1'
}

# Extensions whose latest published wheel metadata is known to diverge
# from its index entry for historical reasons unrelated to the
# wheel->pkginfo tooling change (e.g. a hand-edited/older index field).
metadata_comparison_thresholds = {
# azure-cli-ml is deprecated; its 1.5.0 wheel ships an
# azext_metadata.json without azext.maxCliCoreVersion, while the
# index entry records 2.29.2.
'azure-cli-ml': '1.5.0',
# stack-hci-vm's published index entry (<= 1.14.5) predates
# run_requires capture: the wheel declares runtime deps (kubernetes,
# pyyaml, websocket-client) that the stored index entry omits.
'stack-hci-vm': '1.14.5',
}

extensions_dir = tempfile.mkdtemp()
for ext_name, exts in self.index['extensions'].items():
# only test the latest version
Expand Down Expand Up @@ -196,19 +310,20 @@ def test_metadata(self):
else:
raise ex

# Due to https://github.com/pypa/wheel/issues/195 we prevent whls built with 0.31.0 or greater.
# 0.29.0, 0.30.0 are the two previous versions before that release.
supported_generators = ['bdist_wheel (0.29.0)', 'bdist_wheel (0.30.0)']
self.assertIn(metadata.get('generator'), supported_generators,
"{}: 'generator' should be one of {}. "
"Build the extension with a different version of the 'wheel' package "
"(e.g. `pip install wheel==0.30.0`). "
"This is due to https://github.com/pypa/wheel/issues/195".format(ext_name,
supported_generators))
self.assertDictEqual(metadata, item['metadata'],
"Metadata for {} in index doesn't match the expected of: \n"
"{}".format(item['filename'], json.dumps(metadata, indent=2, sort_keys=True,
separators=(',', ': '))))
# The pkginfo-based metadata path intentionally omits
# wheel==0.30.0-specific fields (e.g. `generator`, `document_names`).
# Ignore those known, benign differences and compare the rest.
try:
self.assertDictEqual(_without_metadata_noise(metadata),
_without_metadata_noise(item['metadata']),
"Metadata for {} in index doesn't match the expected of: \n"
"{}".format(item['filename'], json.dumps(metadata, indent=2, sort_keys=True,
separators=(',', ': '))))
except AssertionError as ex:
threshold_version = metadata_comparison_thresholds.get(ext_name)
if threshold_version and version.parse(ext_version) <= version.parse(threshold_version):
continue
raise ex

shutil.rmtree(extensions_dir)

Expand Down
33 changes: 9 additions & 24 deletions scripts/ci/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import logging
import os
import re
import shlex
import json
import zipfile
Expand All @@ -14,14 +13,6 @@

logger = logging.getLogger(__name__)

# copy from wheel==0.30.0
WHEEL_INFO_RE = re.compile(
r"""^(?P<namever>(?P<name>.+?)(-(?P<ver>\d.+?))?)
((-(?P<build>\d.*?))?-(?P<pyver>.+?)-(?P<abi>.+?)-(?P<plat>.+?)
\.whl|\.dist-info)$""",
re.VERBOSE).match


def get_repo_root():
current_dir = os.path.dirname(os.path.abspath(__file__))
while not os.path.exists(os.path.join(current_dir, 'CONTRIBUTING.rst')):
Expand Down Expand Up @@ -54,27 +45,21 @@ def _get_azext_metadata(ext_dir):

def get_ext_metadata(ext_dir, ext_file, ext_name):
# Modification of https://github.com/Azure/azure-cli/blob/dev/src/azure-cli-core/azure/cli/core/extension.py#L89
WHL_METADATA_FILENAME = 'metadata.json'
zip_ref = zipfile.ZipFile(ext_file, 'r')
zip_ref.extractall(ext_dir)
zip_ref.close()
# Read spec-defined wheel metadata via pkginfo so we don't depend on the
# legacy wheel-0.30.0 only ``metadata.json`` artifact. Imported lazily so
# that modules importing util for unrelated helpers (SRC_PATH, get_repo_root,
# get_index_data, ...) do not require azdev to be installed.
from azdev.operations.extensions.metadata import pkginfo_to_dict
generated_metadata = pkginfo_to_dict(ext_file)
with zipfile.ZipFile(ext_file, 'r') as zip_ref:
zip_ref.extractall(ext_dir)
metadata = {}
dist_info_dirs = [f for f in os.listdir(ext_dir) if f.endswith('.dist-info')]

azext_metadata = _get_azext_metadata(ext_dir)

if not azext_metadata:
raise ValueError('azext_metadata.json for Extension "{}" Metadata is missing'.format(ext_name))

metadata.update(azext_metadata)

for dist_info_dirname in dist_info_dirs:
parsed_dist_info_dir = WHEEL_INFO_RE(dist_info_dirname)
if parsed_dist_info_dir and parsed_dist_info_dir.groupdict().get('name') == ext_name.replace('-', '_'):
whl_metadata_filepath = os.path.join(ext_dir, dist_info_dirname, WHL_METADATA_FILENAME)
if os.path.isfile(whl_metadata_filepath):
with open(whl_metadata_filepath) as f:
metadata.update(json.load(f))
metadata.update(generated_metadata)
Comment on lines 58 to +62
return metadata


Expand Down
Empty file added tests/__init__.py
Empty file.
Empty file added tests/e2e/__init__.py
Empty file.
Empty file added tests/e2e/packaging/__init__.py
Empty file.
32 changes: 32 additions & 0 deletions tests/e2e/packaging/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

from __future__ import annotations

from pathlib import Path

import pytest

from .fixtures import load_fixtures


@pytest.fixture(scope="session")
def repo_root() -> Path:
return Path(__file__).resolve().parents[3]


@pytest.fixture(scope="session")
def fixture_manifest(repo_root: Path) -> Path:
return repo_root / "tests" / "e2e" / "packaging" / "fixtures" / "manifest.json"


@pytest.fixture(scope="session")
def packaging_fixtures(repo_root: Path, fixture_manifest: Path):
return load_fixtures(repo_root=repo_root, manifest_path=fixture_manifest)


@pytest.fixture
def temp_extension_dir(tmp_path):
return tmp_path / "azext"
39 changes: 39 additions & 0 deletions tests/e2e/packaging/fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path
from typing import List


@dataclass(frozen=True)
class PackagingFixture:
name: str
path: Path
kind: str
requires_build_isolation_off: bool


def load_fixtures(repo_root: Path, manifest_path: Path, include_disabled: bool = False) -> List[PackagingFixture]:
data = json.loads(manifest_path.read_text(encoding="utf-8"))

fixtures: List[PackagingFixture] = []
for raw in data.get("fixtures", []):
if not include_disabled and not raw.get("enabled", False):
continue

fixtures.append(
PackagingFixture(
name=raw["name"],
path=repo_root / raw["path"],
kind=raw.get("kind", "unspecified"),
requires_build_isolation_off=raw.get("requires_build_isolation_off", False),
)
)

return fixtures
Loading
Loading