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..9bb9618 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_requirements, get_pypi_runner_version, get_pypi_runner_version_by_connect_version, initialize_git_repository, @@ -79,11 +80,15 @@ def bootstrap_extension_project( # noqa: CCR001 if not answers: raise ClickException('Aborted by user input') + runner_version = get_pypi_runner_version() + connect_eaas_core_version, python_version = get_pypi_runner_requirements(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': connect_eaas_core_version, + 'python_version': python_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..45b36f0 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 = "{{ python_version }}" +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..93dd9a0 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,35 @@ 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_requirements(runner_version): + """Return the (connect-eaas-core, python) version specifiers required by the given runner. + + The bootstrapped extension must pin the same connect-eaas-core and python range + 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}.', + ) + info = res.json()['info'] + requires_python = info.get('requires_python') + if not requires_python: + raise ClickException( + f'connect-extension-runner {runner_version} does not declare a python requirement.', + ) + for dist in info.get('requires_dist') or []: + requirement = Requirement(dist) + if requirement.name == 'connect-eaas-core': + return str(requirement.specifier), requires_python + 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..2b925d0 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_requirements', + return_value=('<38,>=37.4', '<3.13,>=3.9'), + ) config_vendor.load(config_dir='/tmp') mocked_responses.add( @@ -127,6 +131,9 @@ 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'] == '<38,>=37.4' + assert pyproject_toml['tool']['poetry']['dependencies']['python'] == '<3.13,>=3.9' + ext_entrypoint = pyproject_toml['tool']['poetry']['plugins']['connect.eaas.ext'] assert ext_entrypoint == { 'eventsapp': f"{data['package_name']}.events:{classname_prefix}EventsApplication", @@ -224,6 +231,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_requirements', + return_value=('<38,>=37.4', '<3.13,>=3.9'), + ) config_vendor.load(config_dir='/tmp') mocked_responses.add( @@ -296,6 +307,9 @@ 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'] == '<38,>=37.4' + assert pyproject_toml['tool']['poetry']['dependencies']['python'] == '<3.13,>=3.9' + ext_entrypoint = pyproject_toml['tool']['poetry']['plugins']['connect.eaas.ext'] assert ext_entrypoint == { 'eventsapp': f"{data['package_name']}.events:{classname_prefix}EventsApplication", @@ -377,6 +391,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_requirements', + return_value=('<38,>=37.4', '<3.13,>=3.9'), + ) config_vendor.load(config_dir='/tmp') mocked_responses.add( @@ -527,6 +545,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_requirements', + return_value=('<38,>=37.4', '<3.13,>=3.9'), + ) config_provider.load(config_dir='/tmp') mocked_responses.add( @@ -688,6 +710,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_requirements', + return_value=('<38,>=37.4', '<3.13,>=3.9'), + ) config_provider.load(config_dir='/tmp') mocked_responses.add( @@ -893,7 +919,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 +1070,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_requirements', + return_value=('<38,>=37.4', '<3.13,>=3.9'), + ) config_vendor.load(config_dir='/tmp') mocked_responses.add( @@ -1114,6 +1143,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_requirements', + return_value=('<38,>=37.4', '<3.13,>=3.9'), + ) config_vendor.load(config_dir='/tmp') mocked_responses.add( @@ -1184,6 +1217,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_requirements', + return_value=('<38,>=37.4', '<3.13,>=3.9'), + ) 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..b4917e7 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_requirements, 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,80 @@ 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_requirements(mocked_responses): + """The extension must pin the connect-eaas-core and python range the runner ships. + + If the generated pyproject allows versions the runner does not support, + `poetry update` resolves an incompatible version and the extension fails to run. + Confidence: the runner↔eaas-core↔python triple is always coherent, so the + extension boots. + """ + mocked_responses.add( + method='GET', + url=PYPI_EXTENSION_RUNNER_RELEASE_URL.format(version='43.0'), + status=200, + json={ + 'info': { + 'requires_python': '<3.13,>=3.9', + 'requires_dist': [ + 'logzio-python-handler>=3.1', + 'connect-eaas-core<38,>=37.4', + ], + }, + }, ) + + assert get_pypi_runner_requirements('43.0') == ('<38,>=37.4', '<3.13,>=3.9') + + +def test_get_pypi_runner_requirements_http_error(mocked_responses): mocked_responses.add( method='GET', - url=PYPI_EXTENSION_RUNNER_URL, + url=PYPI_EXTENSION_RUNNER_RELEASE_URL.format(version='43.0'), + status=500, + ) + with pytest.raises(ClickException) as ve: + get_pypi_runner_requirements('43.0') + + assert 'We can not retrieve the connect-extension-runner 43.0 metadata' in str(ve.value) + + +def test_get_pypi_runner_requirements_missing_python(mocked_responses): + """A runner that omits requires_python must fail loudly, not render an empty pin.""" + mocked_responses.add( + method='GET', + url=PYPI_EXTENSION_RUNNER_RELEASE_URL.format(version='43.0'), + status=200, + json={'info': {'requires_dist': ['connect-eaas-core<38,>=37.4']}}, + ) + + with pytest.raises(ClickException) as ve: + get_pypi_runner_requirements('43.0') + + assert 'does not declare a python requirement' in str(ve.value) + + +def test_get_pypi_runner_requirements_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': {'version': '26.0'}, - 'releases': {}, + 'info': { + 'requires_python': '<3.13,>=3.9', + 'requires_dist': ['logzio-python-handler>=3.1'], + }, }, ) - assert get_pypi_runner_version() == '26.0' + with pytest.raises(ClickException) as ve: + get_pypi_runner_requirements('43.0') + + assert 'does not declare a connect-eaas-core dependency' in str(ve.value) def test_get_event_definitions_client_error(mocker):