diff --git a/connect/cli/plugins/project/extension/constants.py b/connect/cli/plugins/project/extension/constants.py index a2557d9..b67a030 100644 --- a/connect/cli/plugins/project/extension/constants.py +++ b/connect/cli/plugins/project/extension/constants.py @@ -2,6 +2,7 @@ PYPI_EXTENSION_RUNNER_URL = 'https://pypi.org/pypi/connect-extension-runner/json' +PYPI_EXTENSION_RUNNER_RELEASE_URL = 'https://pypi.org/pypi/connect-extension-runner/{version}/json' PRE_COMMIT_HOOK = """#! /bin/sh diff --git a/connect/cli/plugins/project/extension/helpers.py b/connect/cli/plugins/project/extension/helpers.py index 2d98837..3d7c25a 100644 --- a/connect/cli/plugins/project/extension/helpers.py +++ b/connect/cli/plugins/project/extension/helpers.py @@ -12,6 +12,7 @@ from connect.cli.core.terminal import console from connect.cli.plugins.project.extension.utils import ( get_event_definitions, + get_pypi_runner_eaas_core_version, get_pypi_runner_version, get_pypi_runner_version_by_connect_version, initialize_git_repository, @@ -79,11 +80,13 @@ def bootstrap_extension_project( # noqa: CCR001 if not answers: raise ClickException('Aborted by user input') + runner_version = get_pypi_runner_version() ctx = { 'statuses_by_event': statuses_by_event[answers['extension_type']], 'interactive': [], 'current_major_version': get_version().split('.')[0], - 'runner_version': get_pypi_runner_version(), + 'runner_version': runner_version, + 'connect_eaas_core_version': get_pypi_runner_eaas_core_version(runner_version), } for var, answer in answers.items(): diff --git a/connect/cli/plugins/project/extension/templates/bootstrap/${project_slug}/pyproject.toml.j2 b/connect/cli/plugins/project/extension/templates/bootstrap/${project_slug}/pyproject.toml.j2 index 85784b3..f95e029 100644 --- a/connect/cli/plugins/project/extension/templates/bootstrap/${project_slug}/pyproject.toml.j2 +++ b/connect/cli/plugins/project/extension/templates/bootstrap/${project_slug}/pyproject.toml.j2 @@ -24,27 +24,26 @@ readme = "./README.md" {%- endif %} [tool.poetry.dependencies] -python = ">=3.8,<4" -connect-eaas-core = ">=30" +python = ">=3.9,<3.13" +connect-eaas-core = "{{ connect_eaas_core_version }}" [tool.poetry.dev-dependencies] -pytest = ">=6.1.2,<8" -pytest-cov = ">=2.10.1,<5" +pytest = ">=8,<9" +pytest-cov = ">=2.10.1,<7" pytest-mock = "^3.3.1" -mock = { version = "^4.0.3", markers = "python_version < '3.8'" } -coverage = {extras = ["toml"], version = ">=5.3,<7"} -flake8 = ">=3.8,<6" -flake8-bugbear = ">=20,<23" +coverage = {extras = ["toml"], version = ">=5.3,<8"} +flake8 = ">=6" +flake8-bugbear = ">=24" flake8-cognitive-complexity = "^0.1" -flake8-commas = "~2.0" +flake8-commas = "~4" flake8-future-import = "~0.4" flake8-import-order = "~0.18" -flake8-broken-line = ">=0.3,<0.7" -flake8-comprehensions = "^3.3.1" +flake8-broken-line = ">=1.0" +flake8-comprehensions = "^3.10" flake8-debugger = "^4.0.0" -flake8-eradicate = "^1.0.0" +flake8-eradicate = "^1.3.0" flake8-string-format = "^0.3.0" -pytest-asyncio = "^0.15.1" +pytest-asyncio = ">=0.23" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/connect/cli/plugins/project/extension/utils.py b/connect/cli/plugins/project/extension/utils.py index 7a58248..0b9cb12 100644 --- a/connect/cli/plugins/project/extension/utils.py +++ b/connect/cli/plugins/project/extension/utils.py @@ -5,15 +5,15 @@ import requests from click import ClickException from connect.client import ClientError +from packaging.requirements import Requirement -from connect.cli import get_version from connect.cli.core.utils import ( get_connect_version, get_last_version_by_major, - sort_and_filter_tags, ) from connect.cli.plugins.project.extension.constants import ( PRE_COMMIT_HOOK, + PYPI_EXTENSION_RUNNER_RELEASE_URL, PYPI_EXTENSION_RUNNER_URL, ) @@ -31,11 +31,29 @@ def get_pypi_runner_version(): raise ClickException( f'We can not retrieve the current connect-extension-runner version from {PYPI_EXTENSION_RUNNER_URL}.', ) - content = res.json() - tags = sort_and_filter_tags(content['releases'], get_version().split('.', 1)[0]) - if tags: - return tags.popitem()[0] - return content['info']['version'] + return res.json()['info']['version'] + + +def get_pypi_runner_eaas_core_version(runner_version): + """Return the connect-eaas-core version specifier required by the given runner. + + The bootstrapped extension must pin the same connect-eaas-core the runner image + ships, otherwise `poetry update` resolves an incompatible version and the + extension fails to run. + """ + url = PYPI_EXTENSION_RUNNER_RELEASE_URL.format(version=runner_version) + res = requests.get(url) + if res.status_code != 200: + raise ClickException( + f'We can not retrieve the connect-extension-runner {runner_version} metadata from {url}.', + ) + for dist in res.json()['info'].get('requires_dist') or []: + requirement = Requirement(dist) + if requirement.name == 'connect-eaas-core': + return str(requirement.specifier) + raise ClickException( + f'connect-extension-runner {runner_version} does not declare a connect-eaas-core dependency.', + ) def get_pypi_runner_version_by_connect_version(): diff --git a/tests/plugins/project/test_extension_helpers.py b/tests/plugins/project/test_extension_helpers.py index a8e285f..c4bc517 100644 --- a/tests/plugins/project/test_extension_helpers.py +++ b/tests/plugins/project/test_extension_helpers.py @@ -49,6 +49,10 @@ def test_bootstrap_extension_project_background( 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_version', return_value=runner_version, ) + mocker.patch( + 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_eaas_core_version', + return_value='>=37.4,<38', + ) config_vendor.load(config_dir='/tmp') mocked_responses.add( @@ -127,6 +131,8 @@ def test_bootstrap_extension_project_background( pyproject_toml = toml.load(os.path.join(tmpdir, data['project_slug'], 'pyproject.toml')) + assert pyproject_toml['tool']['poetry']['dependencies']['connect-eaas-core'] == '>=37.4,<38' + ext_entrypoint = pyproject_toml['tool']['poetry']['plugins']['connect.eaas.ext'] assert ext_entrypoint == { 'eventsapp': f"{data['package_name']}.events:{classname_prefix}EventsApplication", @@ -224,6 +230,10 @@ def test_bootstrap_extension_project_interactive( 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_version', return_value=runner_version, ) + mocker.patch( + 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_eaas_core_version', + return_value='>=37.4,<38', + ) config_vendor.load(config_dir='/tmp') mocked_responses.add( @@ -296,6 +306,8 @@ def test_bootstrap_extension_project_interactive( pyproject_toml = toml.load(os.path.join(tmpdir, data['project_slug'], 'pyproject.toml')) + assert pyproject_toml['tool']['poetry']['dependencies']['connect-eaas-core'] == '>=37.4,<38' + ext_entrypoint = pyproject_toml['tool']['poetry']['plugins']['connect.eaas.ext'] assert ext_entrypoint == { 'eventsapp': f"{data['package_name']}.events:{classname_prefix}EventsApplication", @@ -377,6 +389,10 @@ def test_bootstrap_extension_project_multiaccount( 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_version', return_value=runner_version, ) + mocker.patch( + 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_eaas_core_version', + return_value='>=37.4,<38', + ) config_vendor.load(config_dir='/tmp') mocked_responses.add( @@ -527,6 +543,10 @@ def test_bootstrap_extension_project_webapp( 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_version', return_value=runner_version, ) + mocker.patch( + 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_eaas_core_version', + return_value='>=37.4,<38', + ) config_provider.load(config_dir='/tmp') mocked_responses.add( @@ -688,6 +708,10 @@ def test_bootstrap_extension_project_tfnapp( 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_version', return_value=runner_version, ) + mocker.patch( + 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_eaas_core_version', + return_value='>=37.4,<38', + ) config_provider.load(config_dir='/tmp') mocked_responses.add( @@ -893,7 +917,6 @@ def test_bootstrap_extension_project_if_destination_exists(mocker): def test_bump_runner_version(mocker, mocked_responses, capsys): - mocker.patch('connect.cli.plugins.project.extension.utils.get_version', return_value='0.5') mocked_responses.add( 'GET', DEFAULT_ENDPOINT, @@ -1045,6 +1068,10 @@ def test_validate_extension_project(mocker, faker, mocked_responses, config_vend 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_version', return_value=runner_version, ) + mocker.patch( + 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_eaas_core_version', + return_value='>=37.4,<38', + ) config_vendor.load(config_dir='/tmp') mocked_responses.add( @@ -1114,6 +1141,10 @@ def test_validate_extension_project_error_exit( 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_version', side_effect=runner_version, ) + mocker.patch( + 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_eaas_core_version', + return_value='>=37.4,<38', + ) config_vendor.load(config_dir='/tmp') mocked_responses.add( @@ -1184,6 +1215,10 @@ def test_bootstrap_extension_project_corrupted_load_answers( 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_version', return_value=runner_version, ) + mocker.patch( + 'connect.cli.plugins.project.extension.helpers.get_pypi_runner_eaas_core_version', + return_value='>=37.4,<38', + ) config_provider.load(config_dir='/tmp') mocked_responses.add( diff --git a/tests/plugins/project/test_extension_utils.py b/tests/plugins/project/test_extension_utils.py index ffaa232..2678e8d 100644 --- a/tests/plugins/project/test_extension_utils.py +++ b/tests/plugins/project/test_extension_utils.py @@ -2,7 +2,10 @@ from click import ClickException from connect.client import ClientError -from connect.cli.plugins.project.extension.constants import PYPI_EXTENSION_RUNNER_URL +from connect.cli.plugins.project.extension.constants import ( + PYPI_EXTENSION_RUNNER_RELEASE_URL, + PYPI_EXTENSION_RUNNER_URL, +) from connect.cli.plugins.project.extension.utils import ( check_eventsapp_feature_not_selected, check_extension_events_applicable, @@ -14,37 +17,32 @@ get_default_application_types, get_event_definitions, get_interactive_events, + get_pypi_runner_eaas_core_version, get_pypi_runner_version, ) -def test_get_pypi_runner_version(mocker, mocked_responses): - mocker.patch( - 'connect.cli.plugins.project.extension.utils.get_version', - return_value='25.0', - ) +def test_get_pypi_runner_version(mocked_responses): + """The bootstrap must pin the latest published runner, not one tied to the CLI major. + + The CLI major (26) diverged from the runner line (43), so filtering runner tags by + CLI major returned a stale, incompatible image. Confidence: a fresh extension always + gets the current runner. + """ mocked_responses.add( method='GET', url=PYPI_EXTENSION_RUNNER_URL, json={ - 'releases': { - '26.0': {}, - '25.1': {}, - '25.0': {}, - '24.5': {}, - }, + 'info': {'version': '43.0'}, + 'releases': {'43.0': {}, '42.0': {}, '26.44': {}}, }, status=200, ) - assert get_pypi_runner_version() == '25.1' + assert get_pypi_runner_version() == '43.0' -def test_get_pypi_runner_version_http_error(mocker, mocked_responses): - mocker.patch( - 'connect.cli.plugins.project.extension.utils.get_version', - return_value='25.0', - ) +def test_get_pypi_runner_version_http_error(mocked_responses): mocked_responses.add( method='GET', url=PYPI_EXTENSION_RUNNER_URL, @@ -56,22 +54,58 @@ def test_get_pypi_runner_version_http_error(mocker, mocked_responses): assert 'We can not retrieve the current connect-extension-runner version' in str(ve.value) -def test_get_pypi_runner_version_no_releases(mocker, mocked_responses): - mocker.patch( - 'connect.cli.plugins.project.extension.utils.get_version', - return_value='25.0', - ) +def test_get_pypi_runner_eaas_core_version(mocked_responses): + """The extension must pin the connect-eaas-core the runner image ships. + + If the generated pyproject allows a newer connect-eaas-core than the runner supports, + `poetry update` resolves an incompatible version and the extension fails to run. + Confidence: the runner↔eaas-core pair is always coherent, so the extension boots. + """ mocked_responses.add( method='GET', - url=PYPI_EXTENSION_RUNNER_URL, + url=PYPI_EXTENSION_RUNNER_RELEASE_URL.format(version='43.0'), status=200, json={ - 'info': {'version': '26.0'}, - 'releases': {}, + 'info': { + 'requires_dist': [ + 'logzio-python-handler>=3.1', + 'connect-eaas-core<38,>=37.4', + ], + }, }, ) - assert get_pypi_runner_version() == '26.0' + assert get_pypi_runner_eaas_core_version('43.0') == '<38,>=37.4' + + +def test_get_pypi_runner_eaas_core_version_http_error(mocked_responses): + mocked_responses.add( + method='GET', + url=PYPI_EXTENSION_RUNNER_RELEASE_URL.format(version='43.0'), + status=500, + ) + with pytest.raises(ClickException) as ve: + get_pypi_runner_eaas_core_version('43.0') + + assert 'We can not retrieve the connect-extension-runner 43.0 metadata' in str(ve.value) + + +def test_get_pypi_runner_eaas_core_version_missing_dependency(mocked_responses): + """A runner that omits connect-eaas-core must fail loudly, not silently mis-pin. + + Confidence: we never generate a pyproject with an empty/invalid eaas-core constraint. + """ + mocked_responses.add( + method='GET', + url=PYPI_EXTENSION_RUNNER_RELEASE_URL.format(version='43.0'), + status=200, + json={'info': {'requires_dist': ['logzio-python-handler>=3.1']}}, + ) + + with pytest.raises(ClickException) as ve: + get_pypi_runner_eaas_core_version('43.0') + + assert 'does not declare a connect-eaas-core dependency' in str(ve.value) def test_get_event_definitions_client_error(mocker):