• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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