Skip to content
Open
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
4 changes: 4 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Release History
===============
0.2.10
++++++
* `azdev extension add/remove`: Invalidate CLI command index (`commandIndex.json`) after installing or removing extensions to prevent stale index from causing test failures in CI (#9716).

0.2.9
+++++
* `azdev latest-index`: Add `generate` and `verify` commands to manage Azure CLI packaged latest indices (`commandIndex.latest.json`, `helpIndex.latest.json`) with CI-friendly verify exit behavior.
Expand Down
2 changes: 1 addition & 1 deletion azdev/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
# license information.
# -----------------------------------------------------------------------------

__VERSION__ = '0.2.9'
__VERSION__ = '0.2.10'
31 changes: 30 additions & 1 deletion azdev/operations/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,36 @@

from azdev.utilities import (
cmd, py_cmd, pip_cmd, display, get_ext_repo_paths, find_files, get_azure_config, get_azdev_config,
require_azure_cli, heading, subheading, EXTENSION_PREFIX)
get_azure_config_dir, require_azure_cli, heading, subheading, EXTENSION_PREFIX)
from .version_upgrade import VersionUpgradeMod

logger = get_logger(__name__)

# These are the index files cleared by CommandIndex().invalidate() in azure-cli-core.
# Refer: azure-cli-core/azure/cli/core/__init__.py
_COMMAND_INDEX_FILES = (
'commandIndex.json',
'extensionIndex.json',
'helpIndex.json',
'extensionHelpIndex.json',
)


def _invalidate_command_index():
"""Delete the CLI command index files so they are regenerated on next invocation.

This mirrors the behavior of ``CommandIndex().invalidate()`` in azure-cli-core but
works without requiring a fully initialized CLI session.
"""
azure_config_dir = get_azure_config_dir()
for filename in _COMMAND_INDEX_FILES:
filepath = os.path.join(azure_config_dir, filename)
try:
os.remove(filepath)
logger.debug("Deleted command index file: %s", filepath)
except OSError:
pass


def add_extension(extensions):

Expand Down Expand Up @@ -49,6 +74,8 @@ def add_extension(extensions):
if result.error:
raise result.error # pylint: disable=raising-bad-type

_invalidate_command_index()


def remove_extension(extensions):

Expand Down Expand Up @@ -83,6 +110,8 @@ def remove_extension(extensions):
display("Removing '{}'...".format(path_to_remove))
shutil.rmtree(path_to_remove)

_invalidate_command_index()


def _get_installed_dev_extensions(dev_sources):
from glob import glob
Expand Down
81 changes: 81 additions & 0 deletions azdev/operations/tests/test_extension_index_invalidation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# -----------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -----------------------------------------------------------------------------

import os
import tempfile
import unittest
from unittest.mock import patch

from azdev.operations.extensions import _invalidate_command_index, _COMMAND_INDEX_FILES


class TestInvalidateCommandIndex(unittest.TestCase):

def test_deletes_all_index_files(self):
"""All four command index files should be deleted when they exist."""
with tempfile.TemporaryDirectory() as tmpdir:
for filename in _COMMAND_INDEX_FILES:
filepath = os.path.join(tmpdir, filename)
with open(filepath, 'w') as f:
f.write('{}')

with patch('azdev.operations.extensions.get_azure_config_dir', return_value=tmpdir):
_invalidate_command_index()

for filename in _COMMAND_INDEX_FILES:
self.assertFalse(os.path.exists(os.path.join(tmpdir, filename)),
f'{filename} should have been deleted')

def test_handles_missing_files_gracefully(self):
"""Should not raise when index files do not exist."""
with tempfile.TemporaryDirectory() as tmpdir:
with patch('azdev.operations.extensions.get_azure_config_dir', return_value=tmpdir):
_invalidate_command_index() # should not raise

def test_handles_partial_files(self):
"""Should delete existing files and skip missing ones without error."""
with tempfile.TemporaryDirectory() as tmpdir:
existing = _COMMAND_INDEX_FILES[0]
with open(os.path.join(tmpdir, existing), 'w') as f:
f.write('{}')

with patch('azdev.operations.extensions.get_azure_config_dir', return_value=tmpdir):
_invalidate_command_index()

self.assertFalse(os.path.exists(os.path.join(tmpdir, existing)))

@patch('azdev.operations.extensions._invalidate_command_index')
@patch('azdev.operations.extensions.pip_cmd')
@patch('azdev.operations.extensions.find_files', return_value=['/repo/src/my-ext/setup.py'])
@patch('azdev.operations.extensions.get_ext_repo_paths', return_value=['/repo'])
def test_add_extension_calls_invalidate(self, _mock_paths, _mock_find, mock_pip, mock_invalidate):
"""add_extension should call _invalidate_command_index after installing."""
from unittest.mock import MagicMock
mock_pip.return_value = MagicMock(error=None)

from azdev.operations.extensions import add_extension
add_extension(['my-ext'])

mock_invalidate.assert_called_once()

@patch('azdev.operations.extensions._invalidate_command_index')
@patch('azdev.operations.extensions.pip_cmd')
@patch('azdev.operations.extensions.display')
@patch('azdev.operations.extensions.find_files', return_value=['/repo/src/my-ext/my_ext.egg-info'])
@patch('azdev.operations.extensions.get_ext_repo_paths', return_value=['/repo'])
def test_remove_extension_calls_invalidate(
self, _mock_paths, _mock_find, _mock_display,
_mock_pip, mock_invalidate):
"""remove_extension should call _invalidate_command_index after removing."""
with patch('os.listdir', return_value=[]):
from azdev.operations.extensions import remove_extension
remove_extension(['my-ext'])

mock_invalidate.assert_called_once()


if __name__ == '__main__':
unittest.main()