From cadf80b98d5e53461823811de279db842f6fb04e Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Fri, 13 Feb 2026 14:23:41 -0500 Subject: [PATCH 1/2] Remove setuptools runtime dependency from Pyramid Vendor the minimal subset of pkg_resources into pyramid._pkg_resources (310 lines, from setuptools 80.x, MIT license) to eliminate the setuptools runtime dependency while preserving the asset override architecture (OverrideProvider, register_loader_type, DefaultProvider). Add pyramid.compat_resources as the public API for downstream Pylons packages that participate in the override system. - pyramid._pkg_resources: vendored provider chain - pyramid.compat_resources: public re-exports for ecosystem - setup.py: remove setuptools from install_requires - asset.py, path.py, static.py, config/assets.py: import swaps - docs/conf.py: use importlib.metadata for version detection - tests/test__pkg_resources.py: 67 tests, 97.66% coverage --- docs/conf.py | 4 +- setup.py | 1 - src/pyramid/_pkg_resources.py | 310 +++++++++++++++ src/pyramid/asset.py | 5 +- src/pyramid/compat_resources.py | 46 +++ src/pyramid/config/assets.py | 44 +-- src/pyramid/path.py | 6 +- src/pyramid/static.py | 2 +- tests/test__pkg_resources.py | 661 ++++++++++++++++++++++++++++++++ 9 files changed, 1048 insertions(+), 31 deletions(-) create mode 100644 src/pyramid/_pkg_resources.py create mode 100644 src/pyramid/compat_resources.py create mode 100644 tests/test__pkg_resources.py diff --git a/docs/conf.py b/docs/conf.py index 43bdc6321..370db0b17 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ warnings.simplefilter('ignore', DeprecationWarning) -import pkg_resources +from importlib.metadata import version as pkg_version import pylons_sphinx_themes # skip raw nodes @@ -98,7 +98,7 @@ def nothing(*arg): # other places throughout the built documents. # # The short X.Y version. -version = pkg_resources.get_distribution('pyramid').version +version = pkg_version('pyramid') # The full version, including alpha/beta/rc tags. release = version diff --git a/setup.py b/setup.py index 08e44d2fa..1c6faa2ce 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,6 @@ def readfile(name): 'hupper >= 1.5', # ignore_files support 'plaster', 'plaster_pastedeploy', - 'setuptools', 'translationstring >= 0.4', # py3 compat 'venusian >= 1.0', # ``ignore`` 'webob >= 1.8.3', # Accept.parse_offer diff --git a/src/pyramid/_pkg_resources.py b/src/pyramid/_pkg_resources.py new file mode 100644 index 000000000..5863870af --- /dev/null +++ b/src/pyramid/_pkg_resources.py @@ -0,0 +1,310 @@ +""" +Vendored subset of setuptools pkg_resources for Pyramid's asset system. + +This module contains the minimal set of pkg_resources functionality needed +by Pyramid for asset resolution, asset overrides, and the provider system. +It eliminates the runtime dependency on setuptools, which removed +pkg_resources entirely in version 82.0.0. + +Extracted from setuptools 80.x (pkg_resources/__init__.py). + +License (MIT): + Copyright Jason R. Coombs + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + +Not included (not needed by Pyramid): + - EntryPoint, Distribution, WorkingSet, Requirement + - EggProvider intermediate class (flattened into DefaultProvider) + - Egg/zip extraction (get_cache_path, extraction_error, etc.) + - Metadata methods (has_metadata, get_metadata, etc.) + - All extern imports (packaging, platformdirs, etc.) +""" + +import importlib.machinery as importlib_machinery +import inspect +import io +import ntpath +import os +import posixpath +import sys +import warnings + +# --- Provider factory registry --- + +_provider_factories = {} + + +def register_loader_type(loader_type, provider_factory): + """Register `provider_factory` to make providers for `loader_type` + + `loader_type` is the type or class of a PEP 302 ``module.__loader__``, + and `provider_factory` is a function that, passed a *module* object, + returns an ``IResourceProvider`` for that module. + """ + _provider_factories[loader_type] = provider_factory + + +def _always_object(classes): + """ + Ensure object appears in the mro even + for old-style classes. + """ + if object not in classes: + return classes + (object,) + return classes + + +def _find_adapter(registry, ob): + """Return an adapter factory for `ob` from `registry`""" + types = _always_object( + inspect.getmro(getattr(ob, '__class__', type(ob))) + ) + for t in types: + if t in registry: + return registry[t] + + +def get_provider(moduleOrReq): + """Return an IResourceProvider for the named module or requirement""" + try: + module = sys.modules[moduleOrReq] + except KeyError: + __import__(moduleOrReq) + module = sys.modules[moduleOrReq] + loader = getattr(module, '__loader__', None) + return _find_adapter(_provider_factories, loader)(module) + + +# --- Resource manager --- + + +class ResourceManager: + """Manage resource extraction and packages""" + + def __init__(self): + self.cached_files = {} + + def resource_exists(self, package_or_requirement, resource_name): + """Does the named resource exist?""" + return get_provider(package_or_requirement).has_resource( + resource_name + ) + + def resource_isdir(self, package_or_requirement, resource_name): + """Is the named resource an existing directory?""" + return get_provider(package_or_requirement).resource_isdir( + resource_name + ) + + def resource_filename(self, package_or_requirement, resource_name): + """Return a true filesystem path for specified resource""" + return get_provider(package_or_requirement).get_resource_filename( + self, resource_name + ) + + def resource_stream(self, package_or_requirement, resource_name): + """Return a readable file-like object for specified resource""" + return get_provider(package_or_requirement).get_resource_stream( + self, resource_name + ) + + def resource_string(self, package_or_requirement, resource_name): + """Return specified resource as a string""" + return get_provider(package_or_requirement).get_resource_string( + self, resource_name + ) + + def resource_listdir(self, package_or_requirement, resource_name): + """List the contents of the named resource directory""" + return get_provider(package_or_requirement).resource_listdir( + resource_name + ) + + +_manager = ResourceManager() + + +# --- Module-level convenience functions --- + + +def resource_exists(package_or_requirement, resource_name): + return _manager.resource_exists(package_or_requirement, resource_name) + + +def resource_isdir(package_or_requirement, resource_name): + return _manager.resource_isdir(package_or_requirement, resource_name) + + +def resource_filename(package_or_requirement, resource_name): + return _manager.resource_filename(package_or_requirement, resource_name) + + +def resource_stream(package_or_requirement, resource_name): + return _manager.resource_stream(package_or_requirement, resource_name) + + +def resource_string(package_or_requirement, resource_name): + return _manager.resource_string(package_or_requirement, resource_name) + + +def resource_listdir(package_or_requirement, resource_name): + return _manager.resource_listdir(package_or_requirement, resource_name) + + +# --- Providers --- + + +def _issue_warning(*args, **kw): + level = 1 + g = globals() + try: + while sys._getframe(level).f_globals is g: + level += 1 + except ValueError: + pass + warnings.warn(stacklevel=level + 1, *args, **kw) + + +class NullProvider: + """Try to implement resources and metadata for arbitrary PEP 302 + loaders""" + + egg_name = None + egg_info = None + loader = None + + def __init__(self, module): + self.loader = getattr(module, '__loader__', None) + self.module_path = os.path.dirname( + getattr(module, '__file__', '') or '' + ) + + def get_resource_filename(self, manager, resource_name): + return self._fn(self.module_path, resource_name) + + def get_resource_stream(self, manager, resource_name): + return io.BytesIO( + self.get_resource_string(manager, resource_name) + ) + + def get_resource_string(self, manager, resource_name): + return self._get(self._fn(self.module_path, resource_name)) + + def has_resource(self, resource_name): + return self._has(self._fn(self.module_path, resource_name)) + + def resource_isdir(self, resource_name): + return self._isdir(self._fn(self.module_path, resource_name)) + + def resource_listdir(self, resource_name): + return self._listdir(self._fn(self.module_path, resource_name)) + + def _fn(self, base, resource_name): + self._validate_resource_path(resource_name) + if resource_name: + return os.path.join(base, *resource_name.split('/')) + return base + + @staticmethod + def _validate_resource_path(path): + """ + Validate the resource paths according to the docs. + https://setuptools.pypa.io/en/latest/pkg_resources.html#basic-resource-access + """ + invalid = ( + os.path.pardir in path.split(posixpath.sep) + or posixpath.isabs(path) + or ntpath.isabs(path) + ) + if not invalid: + return + + msg = ( + "Use of .. or absolute path in a resource path is not allowed." + ) + + # Aggressively disallow Windows absolute paths + if ntpath.isabs(path) and not posixpath.isabs(path): + raise ValueError(msg) + + # for compatibility, warn; in future + # raise ValueError(msg) + _issue_warning( + msg[:-1] + " and will raise exceptions in a future release.", + DeprecationWarning, + ) + + def _has(self, path): + raise NotImplementedError( + "Can't perform this operation for unregistered loader type" + ) + + def _isdir(self, path): + raise NotImplementedError( + "Can't perform this operation for unregistered loader type" + ) + + def _listdir(self, path): + raise NotImplementedError( + "Can't perform this operation for unregistered loader type" + ) + + def _get(self, path): + if hasattr(self.loader, 'get_data'): + return self.loader.get_data(path) + raise NotImplementedError( + "Can't perform this operation for loaders without 'get_data()'" + ) + + +class DefaultProvider(NullProvider): + """Provides access to package resources in the filesystem""" + + def _has(self, path): + return os.path.exists(path) + + def _isdir(self, path): + return os.path.isdir(path) + + def _listdir(self, path): + return os.listdir(path) + + def get_resource_stream(self, manager, resource_name): + return open(self._fn(self.module_path, resource_name), 'rb') + + def _get(self, path): + with open(path, 'rb') as stream: + return stream.read() + + @classmethod + def _register(cls): + loader_names = ( + 'SourceFileLoader', + 'SourcelessFileLoader', + ) + for name in loader_names: + loader_cls = getattr(importlib_machinery, name, type(None)) + register_loader_type(loader_cls, cls) + + +# --- Auto-registration --- + +register_loader_type(object, NullProvider) +DefaultProvider._register() diff --git a/src/pyramid/asset.py b/src/pyramid/asset.py index bdf90ef5e..deace0d55 100644 --- a/src/pyramid/asset.py +++ b/src/pyramid/asset.py @@ -1,5 +1,6 @@ import os -import pkg_resources + +from pyramid import _pkg_resources from pyramid.path import package_name, package_path @@ -40,4 +41,4 @@ def abspath_from_asset_spec(spec, pname='__main__'): pname, filename = resolve_asset_spec(spec, pname) if pname is None: return filename - return pkg_resources.resource_filename(pname, filename) + return _pkg_resources.resource_filename(pname, filename) diff --git a/src/pyramid/compat_resources.py b/src/pyramid/compat_resources.py new file mode 100644 index 000000000..fc8b1089f --- /dev/null +++ b/src/pyramid/compat_resources.py @@ -0,0 +1,46 @@ +""" +Public resource API for the Pylons ecosystem. + +This module provides override-aware resource access functions that work +without setuptools. It is the migration target for all Pylons packages +that previously imported from ``pkg_resources``. + +These functions respect Pyramid's asset override system +(:meth:`pyramid.config.Configurator.override_asset`). Using +``importlib.resources`` directly would bypass overrides. + +Usage:: + + from pyramid.compat_resources import resource_filename + path = resource_filename('mypackage', 'templates/foo.pt') + +.. versionadded:: 2.1 +""" + +from pyramid._pkg_resources import ( + DefaultProvider, + NullProvider, + ResourceManager, + get_provider, + register_loader_type, + resource_exists, + resource_filename, + resource_isdir, + resource_listdir, + resource_stream, + resource_string, +) + +__all__ = [ + 'resource_exists', + 'resource_filename', + 'resource_isdir', + 'resource_listdir', + 'resource_stream', + 'resource_string', + 'get_provider', + 'register_loader_type', + 'DefaultProvider', + 'NullProvider', + 'ResourceManager', +] diff --git a/src/pyramid/config/assets.py b/src/pyramid/config/assets.py index 6f2ddbe4a..675947ee6 100644 --- a/src/pyramid/config/assets.py +++ b/src/pyramid/config/assets.py @@ -1,17 +1,17 @@ import os -import pkg_resources import sys from zope.interface import implementer +from pyramid import _pkg_resources from pyramid.config.actions import action_method from pyramid.exceptions import ConfigurationError from pyramid.interfaces import PHASE1_CONFIG, IPackageOverrides from pyramid.threadlocal import get_current_registry -class OverrideProvider(pkg_resources.DefaultProvider): +class OverrideProvider(_pkg_resources.DefaultProvider): def __init__(self, module): - pkg_resources.DefaultProvider.__init__(self, module) + _pkg_resources.DefaultProvider.__init__(self, module) self.module_name = module.__name__ def _get_overrides(self): @@ -29,7 +29,7 @@ def get_resource_filename(self, manager, resource_name): filename = overrides.get_filename(resource_name) if filename is not None: return filename - return pkg_resources.DefaultProvider.get_resource_filename( + return _pkg_resources.DefaultProvider.get_resource_filename( self, manager, resource_name ) @@ -40,7 +40,7 @@ def get_resource_stream(self, manager, resource_name): stream = overrides.get_stream(resource_name) if stream is not None: return stream - return pkg_resources.DefaultProvider.get_resource_stream( + return _pkg_resources.DefaultProvider.get_resource_stream( self, manager, resource_name ) @@ -51,7 +51,7 @@ def get_resource_string(self, manager, resource_name): string = overrides.get_string(resource_name) if string is not None: return string - return pkg_resources.DefaultProvider.get_resource_string( + return _pkg_resources.DefaultProvider.get_resource_string( self, manager, resource_name ) @@ -61,7 +61,7 @@ def has_resource(self, resource_name): result = overrides.has_resource(resource_name) if result is not None: return result - return pkg_resources.DefaultProvider.has_resource(self, resource_name) + return _pkg_resources.DefaultProvider.has_resource(self, resource_name) def resource_isdir(self, resource_name): overrides = self._get_overrides() @@ -69,7 +69,7 @@ def resource_isdir(self, resource_name): result = overrides.isdir(resource_name) if result is not None: return result - return pkg_resources.DefaultProvider.resource_isdir( + return _pkg_resources.DefaultProvider.resource_isdir( self, resource_name ) @@ -79,15 +79,15 @@ def resource_listdir(self, resource_name): result = overrides.listdir(resource_name) if result is not None: return result - return pkg_resources.DefaultProvider.resource_listdir( + return _pkg_resources.DefaultProvider.resource_listdir( self, resource_name ) @implementer(IPackageOverrides) class PackageOverrides: - # pkg_resources arg in kw args below for testing - def __init__(self, package, pkg_resources=pkg_resources): + # _pkg_resources arg in kw args below for testing + def __init__(self, package, pkg_resources=_pkg_resources): loader = self._real_loader = getattr(package, '__loader__', None) if isinstance(loader, self.__class__): self._real_loader = None @@ -225,33 +225,33 @@ def get_path(self, resource_name): def get_filename(self, resource_name): path = self.get_path(resource_name) - if pkg_resources.resource_exists(self.pkg_name, path): - return pkg_resources.resource_filename(self.pkg_name, path) + if _pkg_resources.resource_exists(self.pkg_name, path): + return _pkg_resources.resource_filename(self.pkg_name, path) def get_stream(self, resource_name): path = self.get_path(resource_name) - if pkg_resources.resource_exists(self.pkg_name, path): - return pkg_resources.resource_stream(self.pkg_name, path) + if _pkg_resources.resource_exists(self.pkg_name, path): + return _pkg_resources.resource_stream(self.pkg_name, path) def get_string(self, resource_name): path = self.get_path(resource_name) - if pkg_resources.resource_exists(self.pkg_name, path): - return pkg_resources.resource_string(self.pkg_name, path) + if _pkg_resources.resource_exists(self.pkg_name, path): + return _pkg_resources.resource_string(self.pkg_name, path) def exists(self, resource_name): path = self.get_path(resource_name) - if pkg_resources.resource_exists(self.pkg_name, path): + if _pkg_resources.resource_exists(self.pkg_name, path): return True def isdir(self, resource_name): path = self.get_path(resource_name) - if pkg_resources.resource_exists(self.pkg_name, path): - return pkg_resources.resource_isdir(self.pkg_name, path) + if _pkg_resources.resource_exists(self.pkg_name, path): + return _pkg_resources.resource_isdir(self.pkg_name, path) def listdir(self, resource_name): path = self.get_path(resource_name) - if pkg_resources.resource_exists(self.pkg_name, path): - return pkg_resources.resource_listdir(self.pkg_name, path) + if _pkg_resources.resource_exists(self.pkg_name, path): + return _pkg_resources.resource_listdir(self.pkg_name, path) class FSAssetSource: diff --git a/src/pyramid/path.py b/src/pyramid/path.py index 160d97abe..76f625184 100644 --- a/src/pyramid/path.py +++ b/src/pyramid/path.py @@ -2,7 +2,7 @@ from importlib import import_module from importlib.machinery import SOURCE_SUFFIXES import os -import pkg_resources +from pyramid import _pkg_resources import sys from zope.interface import implementer @@ -70,7 +70,7 @@ def package_path(package): # the result prefix = getattr(package, '__abspath__', None) if prefix is None: - prefix = pkg_resources.resource_filename(package.__name__, '') + prefix = _pkg_resources.resource_filename(package.__name__, '') # pkg_resources doesn't care whether we feed it a package # name or a module name within the package, the result # will be the same: a directory name to the package itself @@ -400,7 +400,7 @@ def _zope_dottedname_style(self, value, package): @implementer(IAssetDescriptor) class PkgResourcesAssetDescriptor: - pkg_resources = pkg_resources + pkg_resources = _pkg_resources def __init__(self, pkg_name, path): self.pkg_name = pkg_name diff --git a/src/pyramid/static.py b/src/pyramid/static.py index 4091bbfbc..65fe67439 100644 --- a/src/pyramid/static.py +++ b/src/pyramid/static.py @@ -3,7 +3,7 @@ import mimetypes import os from os.path import exists, getmtime, getsize, isdir, join, normcase, normpath -from pkg_resources import resource_exists, resource_filename, resource_isdir +from pyramid._pkg_resources import resource_exists, resource_filename, resource_isdir import warnings from pyramid.asset import abspath_from_asset_spec, resolve_asset_spec diff --git a/tests/test__pkg_resources.py b/tests/test__pkg_resources.py new file mode 100644 index 000000000..251413e5c --- /dev/null +++ b/tests/test__pkg_resources.py @@ -0,0 +1,661 @@ +import importlib +import os +import types +import unittest + + +class TestRegisterLoaderType(unittest.TestCase): + def _callFUT(self, loader_type, provider_factory): + from pyramid._pkg_resources import register_loader_type + + return register_loader_type(loader_type, provider_factory) + + def test_it(self): + from pyramid._pkg_resources import _provider_factories + + class DummyLoader: + pass + + class DummyProvider: + pass + + self._callFUT(DummyLoader, DummyProvider) + self.assertIs(_provider_factories[DummyLoader], DummyProvider) + # cleanup + del _provider_factories[DummyLoader] + + def test_overwrite(self): + from pyramid._pkg_resources import _provider_factories + + class DummyLoader: + pass + + class Provider1: + pass + + class Provider2: + pass + + self._callFUT(DummyLoader, Provider1) + self._callFUT(DummyLoader, Provider2) + self.assertIs(_provider_factories[DummyLoader], Provider2) + del _provider_factories[DummyLoader] + + +class TestAlwaysObject(unittest.TestCase): + def _callFUT(self, classes): + from pyramid._pkg_resources import _always_object + + return _always_object(classes) + + def test_object_not_present(self): + result = self._callFUT((int, str)) + self.assertIn(object, result) + self.assertEqual(result, (int, str, object)) + + def test_object_already_present(self): + classes = (int, object, str) + result = self._callFUT(classes) + self.assertIs(result, classes) + + +class TestFindAdapter(unittest.TestCase): + def _callFUT(self, registry, ob): + from pyramid._pkg_resources import _find_adapter + + return _find_adapter(registry, ob) + + def test_direct_match(self): + class MyLoader: + pass + + registry = {MyLoader: 'found'} + result = self._callFUT(registry, MyLoader()) + self.assertEqual(result, 'found') + + def test_mro_traversal(self): + class Base: + pass + + class Child(Base): + pass + + registry = {Base: 'found_base'} + result = self._callFUT(registry, Child()) + self.assertEqual(result, 'found_base') + + def test_object_fallback(self): + class Unknown: + pass + + registry = {object: 'fallback'} + result = self._callFUT(registry, Unknown()) + self.assertEqual(result, 'fallback') + + def test_no_match_returns_none(self): + class Unknown: + pass + + registry = {} + result = self._callFUT(registry, Unknown()) + self.assertIsNone(result) + + +class TestGetProvider(unittest.TestCase): + def _callFUT(self, moduleOrReq): + from pyramid._pkg_resources import get_provider + + return get_provider(moduleOrReq) + + def test_returns_provider_for_imported_module(self): + from pyramid._pkg_resources import DefaultProvider + + provider = self._callFUT('pyramid') + self.assertIsInstance(provider, DefaultProvider) + + def test_auto_imports_missing_module(self): + import sys + + # Use a module we know exists but may not be imported yet + name = 'pyramid._pkg_resources' + if name in sys.modules: + provider = self._callFUT(name) + self.assertIsNotNone(provider) + else: + provider = self._callFUT(name) + self.assertIn(name, sys.modules) + self.assertIsNotNone(provider) + + def test_raises_for_nonexistent_module(self): + self.assertRaises(ImportError, self._callFUT, 'nonexistent_xyz_pkg') + + +class TestResourceManager(unittest.TestCase): + def _makeOne(self): + from pyramid._pkg_resources import ResourceManager + + return ResourceManager() + + def test_resource_exists(self): + mgr = self._makeOne() + self.assertTrue(mgr.resource_exists('pyramid', 'path.py')) + self.assertFalse(mgr.resource_exists('pyramid', 'nonexistent.xyz')) + + def test_resource_filename(self): + mgr = self._makeOne() + result = mgr.resource_filename('pyramid', 'path.py') + self.assertTrue(result.endswith('path.py')) + self.assertTrue(os.path.isfile(result)) + + def test_resource_isdir(self): + mgr = self._makeOne() + self.assertTrue(mgr.resource_isdir('pyramid', '')) + self.assertFalse(mgr.resource_isdir('pyramid', 'path.py')) + + def test_resource_stream(self): + mgr = self._makeOne() + stream = mgr.resource_stream('pyramid', 'path.py') + try: + data = stream.read() + self.assertIn(b'def package_path', data) + finally: + stream.close() + + def test_resource_string(self): + mgr = self._makeOne() + data = mgr.resource_string('pyramid', 'path.py') + self.assertIn(b'def package_path', data) + + def test_resource_listdir(self): + mgr = self._makeOne() + listing = mgr.resource_listdir('pyramid', '') + self.assertIn('path.py', listing) + self.assertIn('config', listing) + + +class TestNullProvider(unittest.TestCase): + def _getTargetClass(self): + from pyramid._pkg_resources import NullProvider + + return NullProvider + + def _makeOne(self, module=None): + if module is None: + module = types.ModuleType('dummy') + module.__file__ = '/fake/path/dummy.py' + return self._getTargetClass()(module) + + def test_init_sets_loader(self): + module = types.ModuleType('dummy') + module.__file__ = '/fake/path/dummy.py' + module.__loader__ = 'a_loader' + provider = self._makeOne(module) + self.assertEqual(provider.loader, 'a_loader') + + def test_init_sets_module_path(self): + module = types.ModuleType('dummy') + module.__file__ = '/fake/path/dummy.py' + provider = self._makeOne(module) + self.assertEqual(provider.module_path, '/fake/path') + + def test_init_no_file(self): + module = types.ModuleType('dummy') + # no __file__ attribute + provider = self._getTargetClass()(module) + self.assertEqual(provider.module_path, '') + + def test_fn_joins_paths(self): + provider = self._makeOne() + result = provider._fn('/base/dir', 'sub/file.txt') + self.assertEqual(result, os.path.join('/base/dir', 'sub', 'file.txt')) + + def test_fn_empty_resource(self): + provider = self._makeOne() + result = provider._fn('/base/dir', '') + self.assertEqual(result, '/base/dir') + + def test_validate_resource_path_valid(self): + # Should not raise + self._getTargetClass()._validate_resource_path('foo/bar.txt') + + def test_validate_resource_path_dotdot_warns(self): + import warnings + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + self._getTargetClass()._validate_resource_path('../foo/bar.txt') + self.assertEqual(len(w), 1) + self.assertIn('absolute path', str(w[0].message)) + + def test_validate_resource_path_windows_absolute_raises(self): + self.assertRaises( + ValueError, + self._getTargetClass()._validate_resource_path, + r'C:\foo\bar.txt', + ) + + def test_validate_resource_path_blank_ok(self): + # Should not raise + self._getTargetClass()._validate_resource_path('') + + def test_has_resource_raises(self): + provider = self._makeOne() + self.assertRaises(NotImplementedError, provider._has, '/some/path') + + def test_isdir_raises(self): + provider = self._makeOne() + self.assertRaises(NotImplementedError, provider._isdir, '/some/path') + + def test_listdir_raises(self): + provider = self._makeOne() + self.assertRaises( + NotImplementedError, provider._listdir, '/some/path' + ) + + def test_get_resource_filename(self): + module = types.ModuleType('dummy') + module.__file__ = '/fake/path/dummy.py' + provider = self._makeOne(module) + result = provider.get_resource_filename(None, 'sub/file.txt') + self.assertEqual( + result, os.path.join('/fake/path', 'sub', 'file.txt') + ) + + def test_get_resource_string_with_loader(self): + module = types.ModuleType('dummy') + module.__file__ = '/fake/path/dummy.py' + + class DummyLoader: + def get_data(self, path): + return b'some data' + + module.__loader__ = DummyLoader() + provider = self._makeOne(module) + result = provider.get_resource_string(None, 'file.txt') + self.assertEqual(result, b'some data') + + def test_get_resource_stream_wraps_string(self): + module = types.ModuleType('dummy') + module.__file__ = '/fake/path/dummy.py' + + class DummyLoader: + def get_data(self, path): + return b'stream data' + + module.__loader__ = DummyLoader() + provider = self._makeOne(module) + stream = provider.get_resource_stream(None, 'file.txt') + self.assertEqual(stream.read(), b'stream data') + + def test_get_no_loader_get_data_raises(self): + module = types.ModuleType('dummy') + module.__file__ = '/fake/path/dummy.py' + module.__loader__ = object() # no get_data method + provider = self._makeOne(module) + self.assertRaises( + NotImplementedError, provider._get, '/some/path' + ) + + +class TestDefaultProvider(unittest.TestCase): + def _getTargetClass(self): + from pyramid._pkg_resources import DefaultProvider + + return DefaultProvider + + def _makeOne(self, module=None): + if module is None: + import pyramid + + module = pyramid + return self._getTargetClass()(module) + + def test_has_existing_path(self): + provider = self._makeOne() + import pyramid + + module_dir = os.path.dirname(pyramid.__file__) + self.assertTrue(provider._has(os.path.join(module_dir, 'path.py'))) + + def test_has_nonexistent_path(self): + provider = self._makeOne() + self.assertFalse(provider._has('/nonexistent/path/file.xyz')) + + def test_isdir(self): + provider = self._makeOne() + import pyramid + + module_dir = os.path.dirname(pyramid.__file__) + self.assertTrue(provider._isdir(module_dir)) + self.assertFalse( + provider._isdir(os.path.join(module_dir, 'path.py')) + ) + + def test_listdir(self): + provider = self._makeOne() + import pyramid + + module_dir = os.path.dirname(pyramid.__file__) + listing = provider._listdir(module_dir) + self.assertIn('path.py', listing) + + def test_get_resource_stream(self): + provider = self._makeOne() + stream = provider.get_resource_stream(None, 'path.py') + try: + data = stream.read() + self.assertIn(b'def package_path', data) + finally: + stream.close() + + def test_get(self): + provider = self._makeOne() + import pyramid + + filepath = os.path.join( + os.path.dirname(pyramid.__file__), 'path.py' + ) + data = provider._get(filepath) + self.assertIn(b'def package_path', data) + + def test_register(self): + from pyramid._pkg_resources import _provider_factories + + import importlib.machinery + + cls = self._getTargetClass() + # SourceFileLoader should already be registered + self.assertIs( + _provider_factories[importlib.machinery.SourceFileLoader], cls + ) + + +class TestDefaultProviderRegistration(unittest.TestCase): + def test_object_registered_as_null_provider(self): + from pyramid._pkg_resources import NullProvider, _provider_factories + + self.assertIs(_provider_factories[object], NullProvider) + + def test_source_file_loader_registered(self): + import importlib.machinery + + from pyramid._pkg_resources import ( + DefaultProvider, + _provider_factories, + ) + + self.assertIs( + _provider_factories[importlib.machinery.SourceFileLoader], + DefaultProvider, + ) + + def test_sourceless_file_loader_registered(self): + import importlib.machinery + + from pyramid._pkg_resources import ( + DefaultProvider, + _provider_factories, + ) + + self.assertIs( + _provider_factories[importlib.machinery.SourcelessFileLoader], + DefaultProvider, + ) + + +class TestModuleLevelFunctions(unittest.TestCase): + def test_resource_exists(self): + from pyramid._pkg_resources import resource_exists + + self.assertTrue(resource_exists('pyramid', 'path.py')) + self.assertFalse(resource_exists('pyramid', 'nonexistent.xyz')) + + def test_resource_filename(self): + from pyramid._pkg_resources import resource_filename + + result = resource_filename('pyramid', 'path.py') + self.assertTrue(result.endswith('path.py')) + self.assertTrue(os.path.isfile(result)) + + def test_resource_isdir(self): + from pyramid._pkg_resources import resource_isdir + + self.assertTrue(resource_isdir('pyramid', '')) + self.assertFalse(resource_isdir('pyramid', 'path.py')) + + def test_resource_stream(self): + from pyramid._pkg_resources import resource_stream + + stream = resource_stream('pyramid', 'path.py') + try: + data = stream.read() + self.assertIn(b'def package_path', data) + finally: + stream.close() + + def test_resource_string(self): + from pyramid._pkg_resources import resource_string + + data = resource_string('pyramid', 'path.py') + self.assertIn(b'def package_path', data) + + def test_resource_listdir(self): + from pyramid._pkg_resources import resource_listdir + + listing = resource_listdir('pyramid', '') + self.assertIn('path.py', listing) + self.assertIn('config', listing) + + +class TestResourceAccessOnRealPackage(unittest.TestCase): + """Integration tests using the pyramid package itself.""" + + def test_resource_exists_real_file(self): + from pyramid._pkg_resources import resource_exists + + self.assertTrue(resource_exists('pyramid', 'path.py')) + + def test_resource_exists_nonexistent(self): + from pyramid._pkg_resources import resource_exists + + self.assertFalse(resource_exists('pyramid', 'does_not_exist.xyz')) + + def test_resource_filename_returns_real_path(self): + from pyramid._pkg_resources import resource_filename + + path = resource_filename('pyramid', 'path.py') + self.assertTrue(os.path.isfile(path)) + + def test_resource_isdir_on_package(self): + from pyramid._pkg_resources import resource_isdir + + self.assertTrue(resource_isdir('pyramid', '')) + + def test_resource_isdir_on_file(self): + from pyramid._pkg_resources import resource_isdir + + self.assertFalse(resource_isdir('pyramid', 'path.py')) + + def test_resource_isdir_on_subpackage(self): + from pyramid._pkg_resources import resource_isdir + + self.assertTrue(resource_isdir('pyramid', 'config')) + + +class TestProviderOverrideChain(unittest.TestCase): + """Test that registering a custom provider intercepts lookups.""" + + def test_custom_provider_intercepts(self): + from pyramid._pkg_resources import ( + NullProvider, + _provider_factories, + get_provider, + register_loader_type, + ) + + class CustomLoader: + pass + + class CustomProvider(NullProvider): + custom_flag = True + + def _has(self, path): + return True + + register_loader_type(CustomLoader, CustomProvider) + + # Create a module with this loader + module = types.ModuleType('_test_custom_module') + module.__file__ = '/fake/custom.py' + module.__loader__ = CustomLoader() + import sys + + sys.modules['_test_custom_module'] = module + + try: + provider = get_provider('_test_custom_module') + self.assertIsInstance(provider, CustomProvider) + self.assertTrue(provider.custom_flag) + finally: + del sys.modules['_test_custom_module'] + del _provider_factories[CustomLoader] + + +class TestAssetOverrideWithVendoredPkgResources(unittest.TestCase): + """Integration test: full config.override_asset() flow.""" + + def setUp(self): + from pyramid.testing import cleanUp + + cleanUp() + + def tearDown(self): + from pyramid.testing import cleanUp + + cleanUp() + + def test_override_asset_flow(self): + from pyramid.config import Configurator + + config = Configurator(autocommit=True) + # Override one package's templates with another's + config.override_asset( + 'tests.test_config.pkgs.asset:templates/foo.pt', + 'tests.test_config.pkgs.asset.subpackage:templates/bar.pt', + ) + # If we get here without error, the vendored _pkg_resources is + # working with the override system + from pyramid.interfaces import IPackageOverrides + + overrides = config.registry.queryUtility( + IPackageOverrides, name='tests.test_config.pkgs.asset' + ) + self.assertIsNotNone(overrides) + + +class TestPkgResourcesAssetDescriptorWithVendoredModule(unittest.TestCase): + """Test that PkgResourcesAssetDescriptor uses vendored module.""" + + def test_abspath(self): + from pyramid.path import PkgResourcesAssetDescriptor + + desc = PkgResourcesAssetDescriptor('pyramid', 'path.py') + abspath = desc.abspath() + self.assertTrue(os.path.isfile(abspath)) + self.assertTrue(abspath.endswith('path.py')) + + def test_exists(self): + from pyramid.path import PkgResourcesAssetDescriptor + + desc = PkgResourcesAssetDescriptor('pyramid', 'path.py') + self.assertTrue(desc.exists()) + + def test_isdir(self): + from pyramid.path import PkgResourcesAssetDescriptor + + desc = PkgResourcesAssetDescriptor('pyramid', '') + self.assertTrue(desc.isdir()) + + def test_stream(self): + from pyramid.path import PkgResourcesAssetDescriptor + + desc = PkgResourcesAssetDescriptor('pyramid', 'path.py') + stream = desc.stream() + try: + data = stream.read() + self.assertIn(b'def package_path', data) + finally: + stream.close() + + +class TestNoSetuptoolsImport(unittest.TestCase): + """Verify importing pyramid does not pull in setuptools pkg_resources.""" + + def test_no_setuptools_pkg_resources_import(self): + import subprocess + import sys + + result = subprocess.run( + [ + sys.executable, + '-c', + 'import pyramid; import sys; ' + 'assert "pkg_resources" not in sys.modules, ' + '"pkg_resources still imported!"; ' + 'print("clean")', + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('clean', result.stdout) + + +class TestBackwardCompatibility(unittest.TestCase): + """Verify the vendored module provides the same API surface.""" + + def test_has_register_loader_type(self): + from pyramid._pkg_resources import register_loader_type + + self.assertTrue(callable(register_loader_type)) + + def test_has_get_provider(self): + from pyramid._pkg_resources import get_provider + + self.assertTrue(callable(get_provider)) + + def test_has_resource_functions(self): + from pyramid._pkg_resources import ( + resource_exists, + resource_filename, + resource_isdir, + resource_listdir, + resource_stream, + resource_string, + ) + + for fn in [ + resource_exists, + resource_filename, + resource_isdir, + resource_listdir, + resource_stream, + resource_string, + ]: + self.assertTrue(callable(fn)) + + def test_has_provider_classes(self): + from pyramid._pkg_resources import DefaultProvider, NullProvider + + self.assertTrue(issubclass(DefaultProvider, NullProvider)) + + def test_has_resource_manager(self): + from pyramid._pkg_resources import ResourceManager + + mgr = ResourceManager() + self.assertTrue(hasattr(mgr, 'resource_exists')) + self.assertTrue(hasattr(mgr, 'resource_filename')) + self.assertTrue(hasattr(mgr, 'resource_isdir')) + self.assertTrue(hasattr(mgr, 'resource_stream')) + self.assertTrue(hasattr(mgr, 'resource_string')) + self.assertTrue(hasattr(mgr, 'resource_listdir')) From e9e2191e49703a64be6aedebedf0028459f9ff30 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Tue, 24 Feb 2026 11:19:03 -0500 Subject: [PATCH 2/2] fix import ordering: move local imports below stdlib/third-party --- src/pyramid/path.py | 3 ++- src/pyramid/static.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pyramid/path.py b/src/pyramid/path.py index 76f625184..3711762c5 100644 --- a/src/pyramid/path.py +++ b/src/pyramid/path.py @@ -2,10 +2,11 @@ from importlib import import_module from importlib.machinery import SOURCE_SUFFIXES import os -from pyramid import _pkg_resources import sys + from zope.interface import implementer +from pyramid import _pkg_resources from pyramid.interfaces import IAssetDescriptor init_names = ['__init__%s' % x for x in SOURCE_SUFFIXES] diff --git a/src/pyramid/static.py b/src/pyramid/static.py index 65fe67439..384705f4d 100644 --- a/src/pyramid/static.py +++ b/src/pyramid/static.py @@ -3,9 +3,9 @@ import mimetypes import os from os.path import exists, getmtime, getsize, isdir, join, normcase, normpath -from pyramid._pkg_resources import resource_exists, resource_filename, resource_isdir import warnings +from pyramid._pkg_resources import resource_exists, resource_filename, resource_isdir from pyramid.asset import abspath_from_asset_spec, resolve_asset_spec from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound from pyramid.path import caller_package