Skip to content

Commit 49390f3

Browse files
authored
Pre-release 0.2.11b1: decouple extension metadata from wheel==0.30.0 (#549)
* feat: decouple extension metadata generation from legacy wheel; update version to 0.2.11b1 * feat: update HISTORY.rst and tests for extension metadata extraction improvements * refactor: remove outdated golden comparison comments in MetadataModuleTestCase * feat: add compatibility options for pip editable installs across code generation and extension modules
1 parent efc8370 commit 49390f3

10 files changed

Lines changed: 555 additions & 19 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
env/
2+
/.venv*/
23
.vs/
34
src/aztool.egg-info/
45
src/aztool/__pycache__/__init__.cpython-36.pyc

HISTORY.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
33
Release History
44
===============
5+
0.2.11b1
6+
++++++++
7+
* Extract extension metadata generation logic and decouple from ``wheel==0.30.0``; read wheel ``METADATA`` via ``pkginfo`` instead of the legacy ``metadata.json`` artifact. Drops the ``wheel==0.30.0`` and ``setuptools==70.0.0`` pins. (#521)
8+
59
0.2.11
610
++++++
711
* `azdev extension add/remove`: Invalidate command index after installing or removing extensions.

azdev/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
# license information.
55
# -----------------------------------------------------------------------------
66

7-
__VERSION__ = '0.2.10'
7+
__VERSION__ = '0.2.11b1'

azdev/operations/code_gen.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
logger = get_logger(__name__)
2020

21+
_PIP_EDITABLE_OPTS = "--config-settings editable_mode=compat"
22+
2123
_MODULE_ROOT_PATH = os.path.join('src', 'azure-cli', 'azure', 'cli', 'command_modules')
2224

2325

@@ -297,6 +299,9 @@ def _create_package(prefix, repo_path, is_ext, name='test', display_name=None, d
297299
_generate_files(env, kwargs, test_files, dest_path)
298300

299301
if is_ext:
300-
result = pip_cmd('install -e {}'.format(new_package_path), "Installing `{}{}`...".format(prefix, name))
302+
result = pip_cmd(
303+
'install -e {} {}'.format(new_package_path, _PIP_EDITABLE_OPTS),
304+
"Installing `{}{}`...".format(prefix, name),
305+
)
301306
if result.error:
302307
raise result.error # pylint: disable=raising-bad-type

azdev/operations/extensions/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
logger = get_logger(__name__)
2323

24+
_PIP_EDITABLE_OPTS = "--config-settings editable_mode=compat"
25+
2426
# These are the index files cleared by CommandIndex().invalidate() in azure-cli-core.
2527
# Refer: azure-cli-core/azure/cli/core/__init__.py
2628
_COMMAND_INDEX_FILES = (
@@ -70,7 +72,10 @@ def add_extension(extensions):
7072
raise CLIError('extension(s) not found: {}'.format(' '.join(extensions)))
7173

7274
for path in paths_to_add:
73-
result = pip_cmd('install -e {}'.format(path), "Adding extension '{}'...".format(path))
75+
result = pip_cmd(
76+
'install -e {} {}'.format(path, _PIP_EDITABLE_OPTS),
77+
"Adding extension '{}'...".format(path),
78+
)
7479
if result.error:
7580
raise result.error # pylint: disable=raising-bad-type
7681

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
6+
"""
7+
Extension metadata extraction.
8+
9+
Replaces the legacy wheel-0.30.0 ``metadata.json`` read path with a
10+
``pkginfo``-based reader of the spec-compliant ``METADATA`` file inside each
11+
extension wheel, merged with the extension's ``azext_metadata.json``.
12+
13+
Used by ``azdev.operations.extensions.util.get_ext_metadata`` to build the
14+
entries stored in ``index.json``.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
import re
21+
from pathlib import Path
22+
from typing import Any, Dict, List, Optional, Tuple
23+
24+
25+
# Splits a requirement string into (name, spec). Accepts both shapes that may
26+
# appear in METADATA Requires-Dist:
27+
# * PEP 508 form: "oras==0.1.30" (modern setuptools / wheel)
28+
# * PEP 314 form: "oras (==0.1.30)" (older setuptools, wheel 0.30.0)
29+
# Either spec form is captured into a single, normalized spec string.
30+
_REQ_SPLIT_RE = re.compile(
31+
r"^\s*(?P<name>[A-Za-z0-9_.\-]+)\s*"
32+
r"(?:\(\s*(?P<paren_spec>[^)]+?)\s*\)|(?P<bare_spec>[<>=!~].*?))?\s*$"
33+
)
34+
35+
36+
def _get_extension_modname(ext_dir: Path) -> str:
37+
pos = [d.name for d in ext_dir.iterdir() if d.is_dir() and d.name.startswith("azext_")]
38+
if len(pos) != 1:
39+
raise AssertionError(
40+
"Expected exactly one azext_* module in {}, found: {}".format(ext_dir, pos)
41+
)
42+
return pos[0]
43+
44+
45+
def read_azext_metadata(ext_dir: Path) -> Dict[str, Any]:
46+
modname = _get_extension_modname(ext_dir)
47+
path = ext_dir / modname / "azext_metadata.json"
48+
if not path.is_file():
49+
return {}
50+
with path.open(encoding="utf-8") as fh:
51+
return json.load(fh)
52+
53+
54+
def pkginfo_to_dict(ext_file) -> Dict[str, Any]:
55+
"""Build an index.json-shaped metadata dict from a wheel file.
56+
57+
This replaces the legacy ``metadata.json`` read path (which only existed
58+
in wheels produced by ``wheel==0.30.0``) with a ``pkginfo.Wheel`` based
59+
reader of the spec-defined ``METADATA`` file. Used by
60+
``azdev.operations.extensions.util.get_ext_metadata``.
61+
"""
62+
return merge_to_index_metadata(read_pkginfo(Path(str(ext_file))), {})
63+
64+
65+
def read_pkginfo(wheel_path: Path) -> Dict[str, Any]:
66+
"""Read spec-defined wheel metadata via pkginfo.Wheel."""
67+
import pkginfo
68+
69+
whl = pkginfo.Wheel(str(wheel_path))
70+
return {
71+
"name": whl.name,
72+
"version": whl.version,
73+
"summary": whl.summary,
74+
"description": whl.description,
75+
"description_content_type": whl.description_content_type,
76+
"license": whl.license,
77+
"classifiers": list(whl.classifiers or []),
78+
"requires_dist": list(whl.requires_dist or []),
79+
"requires_python": whl.requires_python,
80+
"author": whl.author,
81+
"author_email": whl.author_email,
82+
"home_page": whl.home_page,
83+
"project_urls": list(whl.project_urls or []),
84+
"metadata_version": whl.metadata_version,
85+
"keywords": whl.keywords,
86+
}
87+
88+
89+
def _coerce_run_requires(requires_dist: List[str]) -> List[Dict[str, Any]]:
90+
"""Approximate the legacy `run_requires` block produced by wheel 0.30.0.
91+
92+
Wheel 0.30.0 emitted each requirement in two forms inside `run_requires`:
93+
* the PEP 314 / PEP 345 form: ``"oras (==0.1.30)"`` (name space then
94+
version specifier wrapped in parentheses), and
95+
* the canonical PEP 508 form: ``"oras==0.1.30"`` (no space, no parens).
96+
97+
It also sorted entries alphabetically by package name (this is observable in
98+
`src/index.json`: every `run_requires` block is name-sorted regardless of
99+
`install_requires` order in `setup.py`).
100+
101+
Modern wheel metadata (`METADATA` Requires-Dist) only carries PEP 508 and
102+
preserves source order, so we reproduce both transformations here.
103+
"""
104+
if not requires_dist:
105+
return []
106+
107+
parsed: List[Tuple[str, Optional[str], str]] = []
108+
seen: set = set()
109+
for req in requires_dist:
110+
canonical = req.strip()
111+
match = _REQ_SPLIT_RE.match(canonical)
112+
if match:
113+
name = match.group("name")
114+
spec = match.group("paren_spec") or match.group("bare_spec")
115+
spec = spec.strip() if spec else None
116+
else:
117+
name, spec = canonical, None
118+
# Older setuptools (e.g. 70.0.0) writes Requires-Dist twice per
119+
# package in METADATA -- once as "name (spec)" and once as
120+
# "name==spec". Modern setuptools writes only the canonical PEP 508
121+
# form. Deduplicate on (lowercase name, normalized spec) so the
122+
# doubling step below produces the same output regardless of which
123+
# setuptools generated the wheel.
124+
key = (name.lower(), (spec or "").replace(" ", ""))
125+
if key in seen:
126+
continue
127+
seen.add(key)
128+
parsed.append((name, spec, canonical))
129+
130+
parsed.sort(key=lambda t: t[0].lower())
131+
132+
doubled: List[str] = []
133+
for name, spec, canonical in parsed:
134+
if spec:
135+
doubled.append("{} ({})".format(name, spec))
136+
doubled.append("{}{}".format(name, spec))
137+
else:
138+
doubled.append(canonical)
139+
doubled.append(canonical)
140+
return [{"requires": doubled}]
141+
142+
143+
def _coerce_project_urls(project_urls: List[str], home_page: Optional[str]) -> Dict[str, str]:
144+
out: Dict[str, str] = {}
145+
if home_page:
146+
out["Home"] = home_page
147+
for entry in project_urls or []:
148+
if "," in entry:
149+
label, url = entry.split(",", 1)
150+
out[label.strip()] = url.strip()
151+
return out
152+
153+
154+
def _coerce_contacts(author: Optional[str], author_email: Optional[str]) -> List[Dict[str, str]]:
155+
if not author and not author_email:
156+
return []
157+
contact: Dict[str, str] = {"role": "author"}
158+
if author:
159+
contact["name"] = author
160+
if author_email:
161+
contact["email"] = author_email
162+
return [contact]
163+
164+
165+
def merge_to_index_metadata(pkg: Dict[str, Any], azext: Dict[str, Any]) -> Dict[str, Any]:
166+
"""Merge `pkginfo` output and `azext_metadata.json` into the index.json shape.
167+
168+
Precedence (highest first): azext_metadata > pkginfo > derived defaults.
169+
"""
170+
metadata: Dict[str, Any] = {}
171+
172+
metadata["name"] = pkg.get("name")
173+
metadata["version"] = pkg.get("version")
174+
metadata["summary"] = pkg.get("summary")
175+
metadata["license"] = pkg.get("license")
176+
metadata["metadata_version"] = pkg.get("metadata_version")
177+
metadata["classifiers"] = pkg.get("classifiers") or []
178+
metadata["extras"] = []
179+
metadata["run_requires"] = _coerce_run_requires(pkg.get("requires_dist") or [])
180+
metadata["requires_python"] = pkg.get("requires_python")
181+
metadata["description_content_type"] = pkg.get("description_content_type")
182+
183+
contacts = _coerce_contacts(pkg.get("author"), pkg.get("author_email"))
184+
project_urls = _coerce_project_urls(pkg.get("project_urls") or [], pkg.get("home_page"))
185+
details: Dict[str, Any] = {}
186+
if contacts:
187+
details["contacts"] = contacts
188+
if project_urls:
189+
details["project_urls"] = project_urls
190+
if details:
191+
metadata["extensions"] = {"python.details": details}
192+
193+
metadata.update(azext)
194+
195+
return {k: v for k, v in metadata.items() if v is not None}

azdev/operations/extensions/util.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from knack.util import CLIError
1313

1414
from azdev.utilities import EXTENSION_PREFIX
15+
from azdev.operations.extensions.metadata import pkginfo_to_dict
1516

1617

1718
WHEEL_INFO_RE = re.compile(
@@ -45,7 +46,9 @@ def _get_azext_metadata(ext_dir):
4546

4647
def get_ext_metadata(ext_dir, ext_file, ext_name):
4748
# Modification of https://github.com/Azure/azure-cli/blob/dev/src/azure-cli-core/azure/cli/core/extension.py#L89
48-
WHL_METADATA_FILENAME = 'metadata.json'
49+
# Read spec-defined wheel metadata via pkginfo so we don't depend on the
50+
# legacy wheel-0.30.0 only ``metadata.json`` artifact.
51+
generated_metadata = pkginfo_to_dict(ext_file)
4952
with zipfile.ZipFile(ext_file, 'r') as zip_ref:
5053
zip_ref.extractall(ext_dir)
5154
metadata = {}
@@ -56,10 +59,7 @@ def get_ext_metadata(ext_dir, ext_file, ext_name):
5659
for dist_info_dirname in dist_info_dirs:
5760
parsed_dist_info_dir = WHEEL_INFO_RE(dist_info_dirname)
5861
if parsed_dist_info_dir and parsed_dist_info_dir.groupdict().get('name') == ext_name.replace('-', '_'):
59-
whl_metadata_filepath = os.path.join(ext_dir, dist_info_dirname, WHL_METADATA_FILENAME)
60-
if os.path.isfile(whl_metadata_filepath):
61-
with open(whl_metadata_filepath) as f:
62-
metadata.update(json.load(f))
62+
metadata.update(generated_metadata)
6363
return metadata
6464

6565

azdev/operations/setup.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
logger = get_logger(__name__)
2323

24+
_PIP_EDITABLE_OPTS = "--config-settings editable_mode=compat"
25+
2426

2527
def _check_path(path, file_name):
2628
""" Ensures the file_name is provided in the supplied path. """
@@ -49,7 +51,7 @@ def _install_extensions(ext_paths):
4951

5052
# install specified extensions
5153
for path in ext_paths or []:
52-
result = pip_cmd('install -e {}'.format(path), "Adding extension '{}'...".format(path))
54+
result = pip_cmd('install -e {} {}'.format(path, _PIP_EDITABLE_OPTS), "Adding extension '{}'...".format(path))
5355
if result.error:
5456
raise result.error # pylint: disable=raising-bad-type
5557

@@ -90,46 +92,46 @@ def _install_cli(cli_path, deps=None):
9092
# Resolve dependencies from setup.py files.
9193
# command modules have dependency on azure-cli-core so install this first
9294
pip_cmd(
93-
"install -e {}".format(os.path.join(cli_src, 'azure-cli-telemetry')),
95+
"install -e {} {}".format(os.path.join(cli_src, 'azure-cli-telemetry'), _PIP_EDITABLE_OPTS),
9496
"Installing `azure-cli-telemetry`..."
9597
)
9698
pip_cmd(
97-
"install -e {}".format(os.path.join(cli_src, 'azure-cli-core')),
99+
"install -e {} {}".format(os.path.join(cli_src, 'azure-cli-core'), _PIP_EDITABLE_OPTS),
98100
"Installing `azure-cli-core`..."
99101
)
100102

101103
# azure cli has dependencies on the above packages so install this one last
102104
pip_cmd(
103-
"install -e {}".format(os.path.join(cli_src, 'azure-cli')),
105+
"install -e {} {}".format(os.path.join(cli_src, 'azure-cli'), _PIP_EDITABLE_OPTS),
104106
"Installing `azure-cli`..."
105107
)
106108

107109
pip_cmd(
108-
"install -e {}".format(os.path.join(cli_src, 'azure-cli-testsdk')),
110+
"install -e {} {}".format(os.path.join(cli_src, 'azure-cli-testsdk'), _PIP_EDITABLE_OPTS),
109111
"Installing `azure-cli-testsdk`..."
110112
)
111113
else:
112114
# First install packages without dependencies,
113115
# then resolve dependencies from requirements.*.txt file.
114116
pip_cmd(
115-
"install -e {} --no-deps".format(os.path.join(cli_src, 'azure-cli-telemetry')),
117+
"install -e {} --no-deps {}".format(os.path.join(cli_src, 'azure-cli-telemetry'), _PIP_EDITABLE_OPTS),
116118
"Installing `azure-cli-telemetry`..."
117119
)
118120
pip_cmd(
119-
"install -e {} --no-deps".format(os.path.join(cli_src, 'azure-cli-core')),
121+
"install -e {} --no-deps {}".format(os.path.join(cli_src, 'azure-cli-core'), _PIP_EDITABLE_OPTS),
120122
"Installing `azure-cli-core`..."
121123
)
122124

123125
pip_cmd(
124-
"install -e {} --no-deps".format(os.path.join(cli_src, 'azure-cli')),
126+
"install -e {} --no-deps {}".format(os.path.join(cli_src, 'azure-cli'), _PIP_EDITABLE_OPTS),
125127
"Installing `azure-cli`..."
126128
)
127129

128130
# The dependencies of testsdk are not in requirements.txt as this package is not needed by the
129131
# azure-cli package for running commands.
130132
# Here we need to install with dependencies for azdev test.
131133
pip_cmd(
132-
"install -e {}".format(os.path.join(cli_src, 'azure-cli-testsdk')),
134+
"install -e {} {}".format(os.path.join(cli_src, 'azure-cli-testsdk'), _PIP_EDITABLE_OPTS),
133135
"Installing `azure-cli-testsdk`..."
134136
)
135137
import platform

0 commit comments

Comments
 (0)