Skip to content

Commit 4d2f2c2

Browse files
committed
Merge branch 'master' into feature/pep517-metadata
2 parents e05adbb + 95a187b commit 4d2f2c2

File tree

11 files changed

+303
-82
lines changed

11 files changed

+303
-82
lines changed

.gitlab-ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ coverage:
2222
paths:
2323
- coverage.xml
2424

25+
benchmark:
26+
script:
27+
- tox -e perf
28+
2529
diffcov:
2630
script:
2731
- tox -e py27-diffcov,py35-diffcov,py36-diffcov,py37-diffcov,py38-diffcov

importlib_metadata/__init__.py

Lines changed: 86 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import operator
1111
import functools
1212
import itertools
13+
import posixpath
1314
import collections
1415

1516
from ._compat import (
@@ -23,11 +24,12 @@
2324
NotADirectoryError,
2425
PermissionError,
2526
pathlib,
26-
PYPY_OPEN_BUG,
2727
ModuleNotFoundError,
2828
MetaPathFinder,
2929
email_message_from_string,
3030
PyPy_repr,
31+
unique_ordered,
32+
str,
3133
)
3234
from importlib import import_module
3335
from itertools import starmap
@@ -95,6 +97,16 @@ def load(self):
9597
attrs = filter(None, (match.group('attr') or '').split('.'))
9698
return functools.reduce(getattr, attrs, module)
9799

100+
@property
101+
def module(self):
102+
match = self.pattern.match(self.value)
103+
return match.group('module')
104+
105+
@property
106+
def attr(self):
107+
match = self.pattern.match(self.value)
108+
return match.group('attr')
109+
98110
@property
99111
def extras(self):
100112
match = self.pattern.match(self.value)
@@ -400,10 +412,6 @@ def path(self):
400412
"""
401413
return vars(self).get('path', sys.path)
402414

403-
@property
404-
def pattern(self):
405-
return '.*' if self.name is None else re.escape(self.name)
406-
407415
@abc.abstractmethod
408416
def find_distributions(self, context=Context()):
409417
"""
@@ -415,6 +423,75 @@ def find_distributions(self, context=Context()):
415423
"""
416424

417425

426+
class FastPath:
427+
"""
428+
Micro-optimized class for searching a path for
429+
children.
430+
"""
431+
432+
def __init__(self, root):
433+
self.root = str(root)
434+
self.base = os.path.basename(self.root).lower()
435+
436+
def joinpath(self, child):
437+
return pathlib.Path(self.root, child)
438+
439+
def children(self):
440+
with suppress(Exception):
441+
return os.listdir(self.root or '')
442+
with suppress(Exception):
443+
return self.zip_children()
444+
return []
445+
446+
def zip_children(self):
447+
zip_path = zipp.Path(self.root)
448+
names = zip_path.root.namelist()
449+
self.joinpath = zip_path.joinpath
450+
451+
return unique_ordered(
452+
child.split(posixpath.sep, 1)[0]
453+
for child in names
454+
)
455+
456+
def is_egg(self, search):
457+
base = self.base
458+
return (
459+
base == search.versionless_egg_name
460+
or base.startswith(search.prefix)
461+
and base.endswith('.egg'))
462+
463+
def search(self, name):
464+
for child in self.children():
465+
n_low = child.lower()
466+
if (n_low in name.exact_matches
467+
or n_low.startswith(name.prefix)
468+
and n_low.endswith(name.suffixes)
469+
# legacy case:
470+
or self.is_egg(name) and n_low == 'egg-info'):
471+
yield self.joinpath(child)
472+
473+
474+
class Prepared:
475+
"""
476+
A prepared search for metadata on a possibly-named package.
477+
"""
478+
normalized = ''
479+
prefix = ''
480+
suffixes = '.dist-info', '.egg-info'
481+
exact_matches = [''][:0]
482+
versionless_egg_name = ''
483+
484+
def __init__(self, name):
485+
self.name = name
486+
if name is None:
487+
return
488+
self.normalized = name.lower().replace('-', '_')
489+
self.prefix = self.normalized + '-'
490+
self.exact_matches = [
491+
self.normalized + suffix for suffix in self.suffixes]
492+
self.versionless_egg_name = self.normalized + '.egg'
493+
494+
418495
@install
419496
class MetadataPathFinder(NullFinder, DistributionFinder):
420497
"""A degenerate finder for distribution packages on the file system.
@@ -432,45 +509,17 @@ def find_distributions(self, context=DistributionFinder.Context()):
432509
(or all names if ``None`` indicated) along the paths in the list
433510
of directories ``context.path``.
434511
"""
435-
found = self._search_paths(context.pattern, context.path)
512+
found = self._search_paths(context.name, context.path)
436513
return map(PathDistribution, found)
437514

438515
@classmethod
439-
def _search_paths(cls, pattern, paths):
516+
def _search_paths(cls, name, paths):
440517
"""Find metadata directories in paths heuristically."""
441518
return itertools.chain.from_iterable(
442-
cls._search_path(path, pattern)
443-
for path in map(cls._switch_path, paths)
519+
path.search(Prepared(name))
520+
for path in map(FastPath, paths)
444521
)
445522

446-
@staticmethod
447-
def _switch_path(path):
448-
if not PYPY_OPEN_BUG or os.path.isfile(path): # pragma: no branch
449-
with suppress(Exception):
450-
return zipp.Path(path)
451-
return pathlib.Path(path)
452-
453-
@classmethod
454-
def _matches_info(cls, normalized, item):
455-
template = r'{pattern}(-.*)?\.(dist|egg)-info'
456-
manifest = template.format(pattern=normalized)
457-
return re.match(manifest, item.name, flags=re.IGNORECASE)
458-
459-
@classmethod
460-
def _matches_legacy(cls, normalized, item):
461-
template = r'{pattern}-.*\.egg[\\/]EGG-INFO'
462-
manifest = template.format(pattern=normalized)
463-
return re.search(manifest, str(item), flags=re.IGNORECASE)
464-
465-
@classmethod
466-
def _search_path(cls, root, pattern):
467-
if not root.is_dir():
468-
return ()
469-
normalized = pattern.replace('-', '_')
470-
return (item for item in root.iterdir()
471-
if cls._matches_info(normalized, item)
472-
or cls._matches_legacy(normalized, item))
473-
474523

475524
class PathDistribution(Distribution):
476525
def __init__(self, path):

importlib_metadata/_compat.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from __future__ import absolute_import
1+
from __future__ import absolute_import, unicode_literals
22

33
import io
44
import abc
@@ -9,21 +9,27 @@
99
if sys.version_info > (3,): # pragma: nocover
1010
import builtins
1111
from configparser import ConfigParser
12-
from contextlib import suppress
12+
import contextlib
1313
FileNotFoundError = builtins.FileNotFoundError
1414
IsADirectoryError = builtins.IsADirectoryError
1515
NotADirectoryError = builtins.NotADirectoryError
1616
PermissionError = builtins.PermissionError
1717
map = builtins.map
18+
from itertools import filterfalse
1819
else: # pragma: nocover
1920
from backports.configparser import ConfigParser
2021
from itertools import imap as map # type: ignore
21-
from contextlib2 import suppress # noqa
22+
from itertools import ifilterfalse as filterfalse
23+
import contextlib2 as contextlib
2224
FileNotFoundError = IOError, OSError
2325
IsADirectoryError = IOError, OSError
2426
NotADirectoryError = IOError, OSError
2527
PermissionError = IOError, OSError
2628

29+
str = type('')
30+
31+
suppress = contextlib.suppress
32+
2733
if sys.version_info > (3, 5): # pragma: nocover
2834
import pathlib
2935
else: # pragma: nocover
@@ -73,7 +79,7 @@ def disable_stdlib_finder():
7379
"""
7480
def matches(finder):
7581
return (
76-
finder.__module__ == '_frozen_importlib_external'
82+
getattr(finder, '__module__', None) == '_frozen_importlib_external'
7783
and hasattr(finder, 'find_distributions')
7884
)
7985
for finder in filter(matches, sys.meta_path): # pragma: nocover
@@ -111,9 +117,6 @@ def py2_message_from_string(text): # nocoverpy3
111117
email.message_from_string
112118
)
113119

114-
# https://bitbucket.org/pypy/pypy/issues/3021/ioopen-directory-leaks-a-file-descriptor
115-
PYPY_OPEN_BUG = getattr(sys, 'pypy_version_info', (9, 9, 9))[:3] <= (7, 1, 1)
116-
117120

118121
class PyPy_repr:
119122
"""
@@ -132,3 +135,18 @@ def make_param(name):
132135
if affected: # pragma: nocover
133136
__repr__ = __compat_repr__
134137
del affected
138+
139+
140+
# from itertools recipes
141+
def unique_everseen(iterable): # pragma: nocover
142+
"List unique elements, preserving order. Remember all elements ever seen."
143+
seen = set()
144+
seen_add = seen.add
145+
146+
for element in filterfalse(seen.__contains__, iterable):
147+
seen_add(element)
148+
yield element
149+
150+
151+
unique_ordered = (
152+
unique_everseen if sys.version_info < (3, 7) else dict.fromkeys)

importlib_metadata/docs/changelog.rst

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,51 @@
22
importlib_metadata NEWS
33
=========================
44

5+
v1.6.1
6+
======
7+
8+
* Ensure inputs to FastPath are Unicode. Closes #121.
9+
* Tests now rely on ``importlib.resources.files`` (and
10+
backport) instead of the older ``path`` function.
11+
12+
v1.6.0
13+
======
14+
15+
* Added ``module`` and ``attr`` attributes to ``EntryPoint``
16+
17+
v1.5.2
18+
======
19+
20+
* Fix redundant entries from ``FastPath.zip_children``.
21+
Closes #117.
22+
23+
v1.5.1
24+
======
25+
26+
* Improve reliability and consistency of compatibility
27+
imports for contextlib and pathlib when running tests.
28+
Closes #116.
29+
30+
v1.5.0
31+
======
32+
33+
* Additional performance optimizations in FastPath now
34+
saves an additional 20% on a typical call.
35+
* Correct for issue where PyOxidizer finder has no
36+
``__module__`` attribute. Closes #110.
37+
38+
v1.4.0
39+
======
40+
41+
* Through careful optimization, ``distribution()`` is
42+
3-4x faster. Thanks to Antony Lee for the
43+
contribution. Closes #95.
44+
45+
* When searching through ``sys.path``, if any error
46+
occurs attempting to list a path entry, that entry
47+
is skipped, making the system much more lenient
48+
to errors. Closes #94.
49+
550
v1.3.0
651
======
752

importlib_metadata/docs/using.rst

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ Entry points
7070
The ``entry_points()`` function returns a dictionary of all entry points,
7171
keyed by group. Entry points are represented by ``EntryPoint`` instances;
7272
each ``EntryPoint`` has a ``.name``, ``.group``, and ``.value`` attributes and
73-
a ``.load()`` method to resolve the value::
73+
a ``.load()`` method to resolve the value. There are also ``.module``,
74+
``.attr``, and ``.extras`` attributes for getting the components of the
75+
``.value`` attribute::
7476

7577
>>> eps = entry_points()
7678
>>> list(eps)
@@ -79,6 +81,12 @@ a ``.load()`` method to resolve the value::
7981
>>> wheel = [ep for ep in scripts if ep.name == 'wheel'][0]
8082
>>> wheel
8183
EntryPoint(name='wheel', value='wheel.cli:main', group='console_scripts')
84+
>>> wheel.module
85+
'wheel.cli'
86+
>>> wheel.attr
87+
'main'
88+
>>> wheel.extras
89+
[]
8290
>>> main = wheel.load()
8391
>>> main
8492
<function main at 0x103528488>
@@ -87,7 +95,7 @@ The ``group`` and ``name`` are arbitrary values defined by the package author
8795
and usually a client will wish to resolve all entry points for a particular
8896
group. Read `the setuptools docs
8997
<https://setuptools.readthedocs.io/en/latest/setuptools.html#dynamic-discovery-of-services-and-plugins>`_
90-
for more information on entrypoints, their definition, and usage.
98+
for more information on entry points, their definition, and usage.
9199

92100

93101
.. _metadata:

0 commit comments

Comments
 (0)