From ac4a855572d8a538ebffb23e765bf55b22bd2009 Mon Sep 17 00:00:00 2001 From: Dhanyal Tag Date: Wed, 15 Apr 2026 21:00:05 +0000 Subject: [PATCH 1/5] fix: replace pkg_resources with importlib.resources/metadata for setuptools 82 compat --- setup.py | 1 + src/rpdk/core/data_loaders.py | 15 ++++-- src/rpdk/core/plugin_registry.py | 35 +++++++------- tests/functional_importlib_compat.py | 68 ++++++++++++++++++++++++++++ tests/test_data_loaders.py | 16 +++++-- tests/test_plugin_registry.py | 2 +- tests/utils.py | 16 +++---- 7 files changed, 117 insertions(+), 36 deletions(-) create mode 100644 tests/functional_importlib_compat.py diff --git a/setup.py b/setup.py index 50d07257..081c2fd6 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,7 @@ def find_version(*file_paths): "cfn_flip>=1.2.3", "nested-lookup", "botocore>=1.31.17", + "importlib_resources>=5.0;python_version<'3.9'", ], entry_points={ "console_scripts": ["cfn-cli = rpdk.core.cli:main", "cfn = rpdk.core.cli:main"] diff --git a/src/rpdk/core/data_loaders.py b/src/rpdk/core/data_loaders.py index cedf230c..730386fb 100644 --- a/src/rpdk/core/data_loaders.py +++ b/src/rpdk/core/data_loaders.py @@ -6,7 +6,10 @@ from io import TextIOWrapper from pathlib import Path -import pkg_resources +try: + from importlib.resources import files as importlib_resources_files +except ImportError: # Python < 3.9: importlib.resources.files() added in 3.9 + from importlib_resources import files as importlib_resources_files import referencing import referencing.exceptions import yaml @@ -38,7 +41,9 @@ def resource_stream(package_name, resource_name, encoding="utf-8"): Decoding errors raise :exc:`ValueError`. :term:`universal newlines` are enabled. Can be used in a ``with`` statement. """ - f = pkg_resources.resource_stream(package_name, resource_name) + import sys + pkg = sys.modules[package_name].__spec__.parent or package_name + f = importlib_resources_files(pkg).joinpath(resource_name).open("rb") return TextIOWrapper(f, encoding=encoding) @@ -55,8 +60,10 @@ def resource_yaml(package_name, resource_name): def copy_resource(package_name, resource_name, out_path): - with pkg_resources.resource_stream( - package_name, resource_name + import sys + pkg = sys.modules[package_name].__spec__.parent or package_name + with importlib_resources_files(pkg).joinpath(resource_name).open( + "rb" ) as fsrc, out_path.open("wb") as fdst: shutil.copyfileobj(fsrc, fdst) diff --git a/src/rpdk/core/plugin_registry.py b/src/rpdk/core/plugin_registry.py index a5a4a238..ac23418d 100644 --- a/src/rpdk/core/plugin_registry.py +++ b/src/rpdk/core/plugin_registry.py @@ -1,35 +1,32 @@ -import pkg_resources +try: + from importlib.metadata import entry_points as importlib_entry_points +except ImportError: # Python < 3.8 + from importlib_metadata import entry_points as importlib_entry_points + + +def _iter_entry_points(group): + eps = importlib_entry_points() + if hasattr(eps, "select"): # Python 3.12+ + return eps.select(group=group) + return eps.get(group, []) + PLUGIN_REGISTRY = { entry_point.name: entry_point.load - for entry_point in pkg_resources.iter_entry_points("rpdk.v1.languages") + for entry_point in _iter_entry_points("rpdk.v1.languages") } def get_plugin_choices(): - plugin_choices = [ - entry_point.name - for entry_point in pkg_resources.iter_entry_points("rpdk.v1.languages") - ] - return sorted(set(plugin_choices)) + return sorted({ep.name for ep in _iter_entry_points("rpdk.v1.languages")}) def get_parsers(): - parsers = { - entry_point.name: entry_point.load - for entry_point in pkg_resources.iter_entry_points("rpdk.v1.parsers") - } - - return parsers + return {ep.name: ep.load for ep in _iter_entry_points("rpdk.v1.parsers")} def get_extensions(): - extensions = { - entry_point.name: entry_point.load - for entry_point in pkg_resources.iter_entry_points("rpdk.v1.extensions") - } - - return extensions + return {ep.name: ep.load for ep in _iter_entry_points("rpdk.v1.extensions")} def load_plugin(language): diff --git a/tests/functional_importlib_compat.py b/tests/functional_importlib_compat.py new file mode 100644 index 00000000..215659d4 --- /dev/null +++ b/tests/functional_importlib_compat.py @@ -0,0 +1,68 @@ +""" +Integration test: validates that data_loaders and plugin_registry work correctly +without pkg_resources, using only importlib.resources / importlib.metadata. + +Run with: + pytest tests/functional_importlib_compat.py -v +""" +import sys +import importlib +import pytest + + +def test_no_pkg_resources_imported_by_data_loaders(): + """data_loaders must not import pkg_resources at all.""" + # Force reimport to catch top-level imports + if "rpdk.core.data_loaders" in sys.modules: + del sys.modules["rpdk.core.data_loaders"] + + import unittest.mock as mock + with mock.patch.dict("sys.modules", {"pkg_resources": None}): + # Should not raise ModuleNotFoundError + import rpdk.core.data_loaders # noqa: F401 + + +def test_no_pkg_resources_imported_by_plugin_registry(): + """plugin_registry must not import pkg_resources at all.""" + if "rpdk.core.plugin_registry" in sys.modules: + del sys.modules["rpdk.core.plugin_registry"] + + import unittest.mock as mock + with mock.patch.dict("sys.modules", {"pkg_resources": None}): + import rpdk.core.plugin_registry # noqa: F401 + + +def test_resource_json_loads_real_schema(): + """resource_json must load an actual bundled schema file end-to-end.""" + from rpdk.core.data_loaders import resource_json + schema = resource_json( + "rpdk.core", "data/schema/provider.definition.schema.v1.json" + ) + assert "$schema" in schema or "properties" in schema + + +def test_resource_stream_returns_readable_content(): + """resource_stream must return a readable text stream for a bundled file.""" + from rpdk.core.data_loaders import resource_stream + with resource_stream( + "rpdk.core", "data/schema/provider.definition.schema.v1.json" + ) as f: + content = f.read() + assert len(content) > 0 + assert "$schema" in content or "properties" in content + + +def test_plugin_registry_get_plugin_choices_does_not_raise(): + """get_plugin_choices must not raise even with no plugins installed.""" + from rpdk.core.plugin_registry import get_plugin_choices + choices = get_plugin_choices() + assert isinstance(choices, list) + + +def test_importlib_resources_files_available(): + """Verify the compat shim resolves correctly on this Python version.""" + if sys.version_info >= (3, 9): + from importlib.resources import files + else: + from importlib_resources import files + assert callable(files) diff --git a/tests/test_data_loaders.py b/tests/test_data_loaders.py index fa16be6b..382afb61 100644 --- a/tests/test_data_loaders.py +++ b/tests/test_data_loaders.py @@ -6,7 +6,7 @@ from io import BytesIO, StringIO from pathlib import Path from subprocess import check_output -from unittest.mock import ANY, create_autospec, patch +from unittest.mock import ANY, Mock, create_autospec, patch import pytest import yaml @@ -620,10 +620,18 @@ def plugin(): def mock_pkg_resource_stream(bytes_in, func=resource_stream): resource_name = "data/test.utf-8" - target = "rpdk.core.data_loaders.pkg_resources.resource_stream" - with patch(target, autospec=True, return_value=BytesIO(bytes_in)) as mock_stream: + target = "rpdk.core.data_loaders.importlib_resources_files" + mock_open = Mock(return_value=BytesIO(bytes_in)) + mock_path = Mock() + mock_path.open = mock_open + mock_joinpath = Mock(return_value=mock_path) + mock_files = Mock() + mock_files.joinpath = mock_joinpath + with patch(target, return_value=mock_files) as mock_stream: f = func(__name__, resource_name) - mock_stream.assert_called_once_with(__name__, resource_name) + mock_stream.assert_called_once_with("tests") + mock_joinpath.assert_called_once_with(resource_name) + mock_open.assert_called_once_with("rb") return f diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index f3145778..9f211aef 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -18,7 +18,7 @@ def test_get_extensions(): mock_entrypoint_2 = Mock() patch_iter_entry_points = patch( - "rpdk.core.plugin_registry.pkg_resources.iter_entry_points" + "rpdk.core.plugin_registry._iter_entry_points" ) with patch_iter_entry_points as mock_iter_entry_points: mock_iter_entry_points.return_value = [mock_entrypoint_1, mock_entrypoint_2] diff --git a/tests/utils.py b/tests/utils.py index 6f4ae7bb..2fa8e8ef 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,7 +5,8 @@ from random import sample from unittest.mock import Mock, patch -import pkg_resources +import importlib.metadata +from unittest.mock import patch as _patch from rpdk.core.project import Project @@ -74,14 +75,13 @@ def chdir(path): def add_dummy_language_plugin(): - distribution = pkg_resources.Distribution(__file__) - entry_point = pkg_resources.EntryPoint.parse( - "dummy = rpdk.dummy:DummyLanguagePlugin", dist=distribution + ep = importlib.metadata.EntryPoint( + name="dummy", value="rpdk.dummy:DummyLanguagePlugin", group="rpdk.v1.languages" ) - distribution._ep_map = { # pylint: disable=protected-access - "rpdk.v1.languages": {"dummy": entry_point} - } - pkg_resources.working_set.add(distribution) + _patch( + "rpdk.core.plugin_registry._iter_entry_points", + side_effect=lambda group: [ep] if group == "rpdk.v1.languages" else [], + ).start() def get_mock_project(): From 71b73077ece6cacb6ca3f816ac8def5ca40c5d3d Mon Sep 17 00:00:00 2001 From: Dhanyal Tag Date: Fri, 17 Apr 2026 14:31:58 +0000 Subject: [PATCH 2/5] fix: update known_third_party in setup.cfg for importlib migration --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 2b398f64..7d3f34fb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,7 +29,7 @@ include_trailing_comma = true combine_as_imports = True force_grid_wrap = 0 known_first_party = rpdk -known_third_party = boto3,botocore,cfn_tools,cfnlint,colorama,docker,hypothesis,jinja2,jsonpatch,jsonschema,nested_lookup,ordered_set,pkg_resources,pytest,pytest_localserver,referencing,requests,setuptools,yaml +known_third_party = boto3,botocore,cfn_tools,cfnlint,colorama,docker,hypothesis,importlib_metadata,importlib_resources,jinja2,jsonpatch,jsonschema,nested_lookup,ordered_set,pytest,pytest_localserver,referencing,requests,setuptools,yaml [tool:pytest] # can't do anything about 3rd part modules, so don't spam us From b322028a85dc4d103f10e2fa09d4db1b6af0f087 Mon Sep 17 00:00:00 2001 From: Dhanyal Tag Date: Fri, 17 Apr 2026 16:20:35 +0000 Subject: [PATCH 3/5] fix: apply pre-commit linting fixes (isort, black, unused imports) --- setup.cfg | 2 +- src/rpdk/core/data_loaders.py | 2 ++ tests/functional_importlib_compat.py | 16 ++++++++++------ tests/test_plugin_registry.py | 4 +--- tests/utils.py | 6 ++---- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/setup.cfg b/setup.cfg index 7d3f34fb..67dd0912 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,7 +29,7 @@ include_trailing_comma = true combine_as_imports = True force_grid_wrap = 0 known_first_party = rpdk -known_third_party = boto3,botocore,cfn_tools,cfnlint,colorama,docker,hypothesis,importlib_metadata,importlib_resources,jinja2,jsonpatch,jsonschema,nested_lookup,ordered_set,pytest,pytest_localserver,referencing,requests,setuptools,yaml +known_third_party = boto3,botocore,cfn_tools,cfnlint,colorama,docker,hypothesis,jinja2,jsonpatch,jsonschema,nested_lookup,ordered_set,pytest,pytest_localserver,referencing,requests,setuptools,yaml [tool:pytest] # can't do anything about 3rd part modules, so don't spam us diff --git a/src/rpdk/core/data_loaders.py b/src/rpdk/core/data_loaders.py index 730386fb..2b97e3af 100644 --- a/src/rpdk/core/data_loaders.py +++ b/src/rpdk/core/data_loaders.py @@ -42,6 +42,7 @@ def resource_stream(package_name, resource_name, encoding="utf-8"): are enabled. Can be used in a ``with`` statement. """ import sys + pkg = sys.modules[package_name].__spec__.parent or package_name f = importlib_resources_files(pkg).joinpath(resource_name).open("rb") return TextIOWrapper(f, encoding=encoding) @@ -61,6 +62,7 @@ def resource_yaml(package_name, resource_name): def copy_resource(package_name, resource_name, out_path): import sys + pkg = sys.modules[package_name].__spec__.parent or package_name with importlib_resources_files(pkg).joinpath(resource_name).open( "rb" diff --git a/tests/functional_importlib_compat.py b/tests/functional_importlib_compat.py index 215659d4..8c3f7fc6 100644 --- a/tests/functional_importlib_compat.py +++ b/tests/functional_importlib_compat.py @@ -5,36 +5,38 @@ Run with: pytest tests/functional_importlib_compat.py -v """ +# pylint: disable=import-outside-toplevel import sys -import importlib -import pytest def test_no_pkg_resources_imported_by_data_loaders(): """data_loaders must not import pkg_resources at all.""" + from unittest import mock + # Force reimport to catch top-level imports if "rpdk.core.data_loaders" in sys.modules: del sys.modules["rpdk.core.data_loaders"] - import unittest.mock as mock with mock.patch.dict("sys.modules", {"pkg_resources": None}): # Should not raise ModuleNotFoundError - import rpdk.core.data_loaders # noqa: F401 + __import__("rpdk.core.data_loaders") def test_no_pkg_resources_imported_by_plugin_registry(): """plugin_registry must not import pkg_resources at all.""" + from unittest import mock + if "rpdk.core.plugin_registry" in sys.modules: del sys.modules["rpdk.core.plugin_registry"] - import unittest.mock as mock with mock.patch.dict("sys.modules", {"pkg_resources": None}): - import rpdk.core.plugin_registry # noqa: F401 + __import__("rpdk.core.plugin_registry") def test_resource_json_loads_real_schema(): """resource_json must load an actual bundled schema file end-to-end.""" from rpdk.core.data_loaders import resource_json + schema = resource_json( "rpdk.core", "data/schema/provider.definition.schema.v1.json" ) @@ -44,6 +46,7 @@ def test_resource_json_loads_real_schema(): def test_resource_stream_returns_readable_content(): """resource_stream must return a readable text stream for a bundled file.""" from rpdk.core.data_loaders import resource_stream + with resource_stream( "rpdk.core", "data/schema/provider.definition.schema.v1.json" ) as f: @@ -55,6 +58,7 @@ def test_resource_stream_returns_readable_content(): def test_plugin_registry_get_plugin_choices_does_not_raise(): """get_plugin_choices must not raise even with no plugins installed.""" from rpdk.core.plugin_registry import get_plugin_choices + choices = get_plugin_choices() assert isinstance(choices, list) diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index 9f211aef..7a3008bb 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -17,9 +17,7 @@ def test_get_extensions(): mock_entrypoint_1 = Mock() mock_entrypoint_2 = Mock() - patch_iter_entry_points = patch( - "rpdk.core.plugin_registry._iter_entry_points" - ) + patch_iter_entry_points = patch("rpdk.core.plugin_registry._iter_entry_points") with patch_iter_entry_points as mock_iter_entry_points: mock_iter_entry_points.return_value = [mock_entrypoint_1, mock_entrypoint_2] diff --git a/tests/utils.py b/tests/utils.py index 2fa8e8ef..61d3180a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,12 +1,10 @@ +import importlib.metadata import os from contextlib import contextmanager from io import BytesIO from pathlib import Path from random import sample -from unittest.mock import Mock, patch - -import importlib.metadata -from unittest.mock import patch as _patch +from unittest.mock import Mock, patch, patch as _patch from rpdk.core.project import Project From 2af62f9294635d8b276cf49daaf38433be709034 Mon Sep 17 00:00:00 2001 From: Dhanyal Tag Date: Fri, 17 Apr 2026 16:33:46 +0000 Subject: [PATCH 4/5] fix: move sys import to top-level, drop patch alias in utils --- src/rpdk/core/data_loaders.py | 5 +---- tests/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/rpdk/core/data_loaders.py b/src/rpdk/core/data_loaders.py index 2b97e3af..298a2f60 100644 --- a/src/rpdk/core/data_loaders.py +++ b/src/rpdk/core/data_loaders.py @@ -3,6 +3,7 @@ import os import re import shutil +import sys from io import TextIOWrapper from pathlib import Path @@ -41,8 +42,6 @@ def resource_stream(package_name, resource_name, encoding="utf-8"): Decoding errors raise :exc:`ValueError`. :term:`universal newlines` are enabled. Can be used in a ``with`` statement. """ - import sys - pkg = sys.modules[package_name].__spec__.parent or package_name f = importlib_resources_files(pkg).joinpath(resource_name).open("rb") return TextIOWrapper(f, encoding=encoding) @@ -61,8 +60,6 @@ def resource_yaml(package_name, resource_name): def copy_resource(package_name, resource_name, out_path): - import sys - pkg = sys.modules[package_name].__spec__.parent or package_name with importlib_resources_files(pkg).joinpath(resource_name).open( "rb" diff --git a/tests/utils.py b/tests/utils.py index 61d3180a..dd89b34e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,7 +4,7 @@ from io import BytesIO from pathlib import Path from random import sample -from unittest.mock import Mock, patch, patch as _patch +from unittest.mock import Mock, patch from rpdk.core.project import Project @@ -76,7 +76,7 @@ def add_dummy_language_plugin(): ep = importlib.metadata.EntryPoint( name="dummy", value="rpdk.dummy:DummyLanguagePlugin", group="rpdk.v1.languages" ) - _patch( + patch( "rpdk.core.plugin_registry._iter_entry_points", side_effect=lambda group: [ep] if group == "rpdk.v1.languages" else [], ).start() From 3b4d43bdf0c39881df04082ea5126bf8ec2d4694 Mon Sep 17 00:00:00 2001 From: Dhanyal Tag Date: Fri, 17 Apr 2026 21:12:48 +0000 Subject: [PATCH 5/5] fix: use rpdk.core package in resource_json call for importlib.resources compat --- tests/fragments/test_generator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fragments/test_generator.py b/tests/fragments/test_generator.py index 9296af28..027845a2 100644 --- a/tests/fragments/test_generator.py +++ b/tests/fragments/test_generator.py @@ -373,7 +373,7 @@ def test_overwrite_doesnt_exist(template_fragment, tmpdir): def __make_resource_validator(base_uri=None, timeout=TIMEOUT_IN_SECONDS): schema = resource_json( - __name__, - "../../src/rpdk/core/data/schema/provider.definition.schema.modules.v1.json", + "rpdk.core", + "data/schema/provider.definition.schema.modules.v1.json", ) return make_validator(schema)