1import re 2import pickle 3import unittest 4import warnings 5import importlib 6import importlib.metadata 7import contextlib 8from test.support import os_helper 9 10try: 11 import pyfakefs.fake_filesystem_unittest as ffs 12except ImportError: 13 from .stubs import fake_filesystem_unittest as ffs 14 15from . import fixtures 16from ._context import suppress 17from ._path import Symlink 18from importlib.metadata import ( 19 Distribution, 20 EntryPoint, 21 PackageNotFoundError, 22 _unique, 23 distributions, 24 entry_points, 25 metadata, 26 packages_distributions, 27 version, 28) 29 30 31@contextlib.contextmanager 32def suppress_known_deprecation(): 33 with warnings.catch_warnings(record=True) as ctx: 34 warnings.simplefilter('default', category=DeprecationWarning) 35 yield ctx 36 37 38class BasicTests(fixtures.DistInfoPkg, unittest.TestCase): 39 version_pattern = r'\d+\.\d+(\.\d)?' 40 41 def test_retrieves_version_of_self(self): 42 dist = Distribution.from_name('distinfo-pkg') 43 assert isinstance(dist.version, str) 44 assert re.match(self.version_pattern, dist.version) 45 46 def test_for_name_does_not_exist(self): 47 with self.assertRaises(PackageNotFoundError): 48 Distribution.from_name('does-not-exist') 49 50 def test_package_not_found_mentions_metadata(self): 51 """ 52 When a package is not found, that could indicate that the 53 package is not installed or that it is installed without 54 metadata. Ensure the exception mentions metadata to help 55 guide users toward the cause. See #124. 56 """ 57 with self.assertRaises(PackageNotFoundError) as ctx: 58 Distribution.from_name('does-not-exist') 59 60 assert "metadata" in str(ctx.exception) 61 62 # expected to fail until ABC is enforced 63 @suppress(AssertionError) 64 @suppress_known_deprecation() 65 def test_abc_enforced(self): 66 with self.assertRaises(TypeError): 67 type('DistributionSubclass', (Distribution,), {})() 68 69 @fixtures.parameterize( 70 dict(name=None), 71 dict(name=''), 72 ) 73 def test_invalid_inputs_to_from_name(self, name): 74 with self.assertRaises(Exception): 75 Distribution.from_name(name) 76 77 78class ImportTests(fixtures.DistInfoPkg, unittest.TestCase): 79 def test_import_nonexistent_module(self): 80 # Ensure that the MetadataPathFinder does not crash an import of a 81 # non-existent module. 82 with self.assertRaises(ImportError): 83 importlib.import_module('does_not_exist') 84 85 def test_resolve(self): 86 ep = entry_points(group='entries')['main'] 87 self.assertEqual(ep.load().__name__, "main") 88 89 def test_entrypoint_with_colon_in_name(self): 90 ep = entry_points(group='entries')['ns:sub'] 91 self.assertEqual(ep.value, 'mod:main') 92 93 def test_resolve_without_attr(self): 94 ep = EntryPoint( 95 name='ep', 96 value='importlib.metadata', 97 group='grp', 98 ) 99 assert ep.load() is importlib.metadata 100 101 102class NameNormalizationTests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): 103 @staticmethod 104 def make_pkg(name): 105 """ 106 Create minimal metadata for a dist-info package with 107 the indicated name on the file system. 108 """ 109 return { 110 f'{name}.dist-info': { 111 'METADATA': 'VERSION: 1.0\n', 112 }, 113 } 114 115 def test_dashes_in_dist_name_found_as_underscores(self): 116 """ 117 For a package with a dash in the name, the dist-info metadata 118 uses underscores in the name. Ensure the metadata loads. 119 """ 120 fixtures.build_files(self.make_pkg('my_pkg'), self.site_dir) 121 assert version('my-pkg') == '1.0' 122 123 def test_dist_name_found_as_any_case(self): 124 """ 125 Ensure the metadata loads when queried with any case. 126 """ 127 pkg_name = 'CherryPy' 128 fixtures.build_files(self.make_pkg(pkg_name), self.site_dir) 129 assert version(pkg_name) == '1.0' 130 assert version(pkg_name.lower()) == '1.0' 131 assert version(pkg_name.upper()) == '1.0' 132 133 def test_unique_distributions(self): 134 """ 135 Two distributions varying only by non-normalized name on 136 the file system should resolve as the same. 137 """ 138 fixtures.build_files(self.make_pkg('abc'), self.site_dir) 139 before = list(_unique(distributions())) 140 141 alt_site_dir = self.fixtures.enter_context(fixtures.tempdir()) 142 self.fixtures.enter_context(self.add_sys_path(alt_site_dir)) 143 fixtures.build_files(self.make_pkg('ABC'), alt_site_dir) 144 after = list(_unique(distributions())) 145 146 assert len(after) == len(before) 147 148 149class NonASCIITests(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): 150 @staticmethod 151 def pkg_with_non_ascii_description(site_dir): 152 """ 153 Create minimal metadata for a package with non-ASCII in 154 the description. 155 """ 156 contents = { 157 'portend.dist-info': { 158 'METADATA': 'Description: pôrˈtend', 159 }, 160 } 161 fixtures.build_files(contents, site_dir) 162 return 'portend' 163 164 @staticmethod 165 def pkg_with_non_ascii_description_egg_info(site_dir): 166 """ 167 Create minimal metadata for an egg-info package with 168 non-ASCII in the description. 169 """ 170 contents = { 171 'portend.dist-info': { 172 'METADATA': """ 173 Name: portend 174 175 pôrˈtend""", 176 }, 177 } 178 fixtures.build_files(contents, site_dir) 179 return 'portend' 180 181 def test_metadata_loads(self): 182 pkg_name = self.pkg_with_non_ascii_description(self.site_dir) 183 meta = metadata(pkg_name) 184 assert meta['Description'] == 'pôrˈtend' 185 186 def test_metadata_loads_egg_info(self): 187 pkg_name = self.pkg_with_non_ascii_description_egg_info(self.site_dir) 188 meta = metadata(pkg_name) 189 assert meta['Description'] == 'pôrˈtend' 190 191 192class DiscoveryTests( 193 fixtures.EggInfoPkg, 194 fixtures.EggInfoPkgPipInstalledNoToplevel, 195 fixtures.EggInfoPkgPipInstalledNoModules, 196 fixtures.EggInfoPkgSourcesFallback, 197 fixtures.DistInfoPkg, 198 unittest.TestCase, 199): 200 def test_package_discovery(self): 201 dists = list(distributions()) 202 assert all(isinstance(dist, Distribution) for dist in dists) 203 assert any(dist.metadata['Name'] == 'egginfo-pkg' for dist in dists) 204 assert any(dist.metadata['Name'] == 'egg_with_module-pkg' for dist in dists) 205 assert any(dist.metadata['Name'] == 'egg_with_no_modules-pkg' for dist in dists) 206 assert any(dist.metadata['Name'] == 'sources_fallback-pkg' for dist in dists) 207 assert any(dist.metadata['Name'] == 'distinfo-pkg' for dist in dists) 208 209 def test_invalid_usage(self): 210 with self.assertRaises(ValueError): 211 list(distributions(context='something', name='else')) 212 213 def test_interleaved_discovery(self): 214 """ 215 Ensure interleaved searches are safe. 216 217 When the search is cached, it is possible for searches to be 218 interleaved, so make sure those use-cases are safe. 219 220 Ref #293 221 """ 222 dists = distributions() 223 next(dists) 224 version('egginfo-pkg') 225 next(dists) 226 227 228class DirectoryTest(fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase): 229 def test_egg_info(self): 230 # make an `EGG-INFO` directory that's unrelated 231 self.site_dir.joinpath('EGG-INFO').mkdir() 232 # used to crash with `IsADirectoryError` 233 with self.assertRaises(PackageNotFoundError): 234 version('unknown-package') 235 236 def test_egg(self): 237 egg = self.site_dir.joinpath('foo-3.6.egg') 238 egg.mkdir() 239 with self.add_sys_path(egg): 240 with self.assertRaises(PackageNotFoundError): 241 version('foo') 242 243 244class MissingSysPath(fixtures.OnSysPath, unittest.TestCase): 245 site_dir = '/does-not-exist' 246 247 def test_discovery(self): 248 """ 249 Discovering distributions should succeed even if 250 there is an invalid path on sys.path. 251 """ 252 importlib.metadata.distributions() 253 254 255class InaccessibleSysPath(fixtures.OnSysPath, ffs.TestCase): 256 site_dir = '/access-denied' 257 258 def setUp(self): 259 super().setUp() 260 self.setUpPyfakefs() 261 self.fs.create_dir(self.site_dir, perm_bits=000) 262 263 def test_discovery(self): 264 """ 265 Discovering distributions should succeed even if 266 there is an invalid path on sys.path. 267 """ 268 list(importlib.metadata.distributions()) 269 270 271class TestEntryPoints(unittest.TestCase): 272 def __init__(self, *args): 273 super().__init__(*args) 274 self.ep = importlib.metadata.EntryPoint( 275 name='name', value='value', group='group' 276 ) 277 278 def test_entry_point_pickleable(self): 279 revived = pickle.loads(pickle.dumps(self.ep)) 280 assert revived == self.ep 281 282 def test_positional_args(self): 283 """ 284 Capture legacy (namedtuple) construction, discouraged. 285 """ 286 EntryPoint('name', 'value', 'group') 287 288 def test_immutable(self): 289 """EntryPoints should be immutable""" 290 with self.assertRaises(AttributeError): 291 self.ep.name = 'badactor' 292 293 def test_repr(self): 294 assert 'EntryPoint' in repr(self.ep) 295 assert 'name=' in repr(self.ep) 296 assert "'name'" in repr(self.ep) 297 298 def test_hashable(self): 299 """EntryPoints should be hashable""" 300 hash(self.ep) 301 302 def test_module(self): 303 assert self.ep.module == 'value' 304 305 def test_attr(self): 306 assert self.ep.attr is None 307 308 def test_sortable(self): 309 """ 310 EntryPoint objects are sortable, but result is undefined. 311 """ 312 sorted([ 313 EntryPoint(name='b', value='val', group='group'), 314 EntryPoint(name='a', value='val', group='group'), 315 ]) 316 317 318class FileSystem( 319 fixtures.OnSysPath, fixtures.SiteDir, fixtures.FileBuilder, unittest.TestCase 320): 321 def test_unicode_dir_on_sys_path(self): 322 """ 323 Ensure a Unicode subdirectory of a directory on sys.path 324 does not crash. 325 """ 326 fixtures.build_files( 327 {self.unicode_filename(): {}}, 328 prefix=self.site_dir, 329 ) 330 list(distributions()) 331 332 333class PackagesDistributionsPrebuiltTest(fixtures.ZipFixtures, unittest.TestCase): 334 def test_packages_distributions_example(self): 335 self._fixture_on_path('example-21.12-py3-none-any.whl') 336 assert packages_distributions()['example'] == ['example'] 337 338 def test_packages_distributions_example2(self): 339 """ 340 Test packages_distributions on a wheel built 341 by trampolim. 342 """ 343 self._fixture_on_path('example2-1.0.0-py3-none-any.whl') 344 assert packages_distributions()['example2'] == ['example2'] 345 346 347class PackagesDistributionsTest( 348 fixtures.OnSysPath, fixtures.SiteDir, unittest.TestCase 349): 350 def test_packages_distributions_neither_toplevel_nor_files(self): 351 """ 352 Test a package built without 'top-level.txt' or a file list. 353 """ 354 fixtures.build_files( 355 { 356 'trim_example-1.0.0.dist-info': { 357 'METADATA': """ 358 Name: trim_example 359 Version: 1.0.0 360 """, 361 } 362 }, 363 prefix=self.site_dir, 364 ) 365 packages_distributions() 366 367 def test_packages_distributions_all_module_types(self): 368 """ 369 Test top-level modules detected on a package without 'top-level.txt'. 370 """ 371 suffixes = importlib.machinery.all_suffixes() 372 metadata = dict( 373 METADATA=""" 374 Name: all_distributions 375 Version: 1.0.0 376 """, 377 ) 378 files = { 379 'all_distributions-1.0.0.dist-info': metadata, 380 } 381 for i, suffix in enumerate(suffixes): 382 files.update({ 383 f'importable-name {i}{suffix}': '', 384 f'in_namespace_{i}': { 385 f'mod{suffix}': '', 386 }, 387 f'in_package_{i}': { 388 '__init__.py': '', 389 f'mod{suffix}': '', 390 }, 391 }) 392 metadata.update(RECORD=fixtures.build_record(files)) 393 fixtures.build_files(files, prefix=self.site_dir) 394 395 distributions = packages_distributions() 396 397 for i in range(len(suffixes)): 398 assert distributions[f'importable-name {i}'] == ['all_distributions'] 399 assert distributions[f'in_namespace_{i}'] == ['all_distributions'] 400 assert distributions[f'in_package_{i}'] == ['all_distributions'] 401 402 assert not any(name.endswith('.dist-info') for name in distributions) 403 404 @os_helper.skip_unless_symlink 405 def test_packages_distributions_symlinked_top_level(self) -> None: 406 """ 407 Distribution is resolvable from a simple top-level symlink in RECORD. 408 See #452. 409 """ 410 411 files: fixtures.FilesSpec = { 412 "symlinked_pkg-1.0.0.dist-info": { 413 "METADATA": """ 414 Name: symlinked-pkg 415 Version: 1.0.0 416 """, 417 "RECORD": "symlinked,,\n", 418 }, 419 ".symlink.target": {}, 420 "symlinked": Symlink(".symlink.target"), 421 } 422 423 fixtures.build_files(files, self.site_dir) 424 assert packages_distributions()['symlinked'] == ['symlinked-pkg'] 425 426 427class PackagesDistributionsEggTest( 428 fixtures.EggInfoPkg, 429 fixtures.EggInfoPkgPipInstalledNoToplevel, 430 fixtures.EggInfoPkgPipInstalledNoModules, 431 fixtures.EggInfoPkgSourcesFallback, 432 unittest.TestCase, 433): 434 def test_packages_distributions_on_eggs(self): 435 """ 436 Test old-style egg packages with a variation of 'top_level.txt', 437 'SOURCES.txt', and 'installed-files.txt', available. 438 """ 439 distributions = packages_distributions() 440 441 def import_names_from_package(package_name): 442 return { 443 import_name 444 for import_name, package_names in distributions.items() 445 if package_name in package_names 446 } 447 448 # egginfo-pkg declares one import ('mod') via top_level.txt 449 assert import_names_from_package('egginfo-pkg') == {'mod'} 450 451 # egg_with_module-pkg has one import ('egg_with_module') inferred from 452 # installed-files.txt (top_level.txt is missing) 453 assert import_names_from_package('egg_with_module-pkg') == {'egg_with_module'} 454 455 # egg_with_no_modules-pkg should not be associated with any import names 456 # (top_level.txt is empty, and installed-files.txt has no .py files) 457 assert import_names_from_package('egg_with_no_modules-pkg') == set() 458 459 # sources_fallback-pkg has one import ('sources_fallback') inferred from 460 # SOURCES.txt (top_level.txt and installed-files.txt is missing) 461 assert import_names_from_package('sources_fallback-pkg') == {'sources_fallback'} 462 463 464class EditableDistributionTest(fixtures.DistInfoPkgEditable, unittest.TestCase): 465 def test_origin(self): 466 dist = Distribution.from_name('distinfo-pkg') 467 assert dist.origin.url.endswith('.whl') 468 assert dist.origin.archive_info.hashes.sha256 469