|
| 1 | +import itertools |
| 2 | +import functools |
| 3 | +import contextlib |
| 4 | + |
| 5 | +from setuptools.extern.packaging.requirements import Requirement |
| 6 | +from setuptools.extern.packaging.version import Version |
| 7 | +from setuptools.extern.more_itertools import always_iterable |
| 8 | +from setuptools.extern.jaraco.context import suppress |
| 9 | +from setuptools.extern.jaraco.functools import apply |
| 10 | + |
| 11 | +from ._compat import metadata, repair_extras |
| 12 | + |
| 13 | + |
| 14 | +def resolve(req: Requirement) -> metadata.Distribution: |
| 15 | + """ |
| 16 | + Resolve the requirement to its distribution. |
| 17 | +
|
| 18 | + Ignore exception detail for Python 3.9 compatibility. |
| 19 | +
|
| 20 | + >>> resolve(Requirement('pytest<3')) # doctest: +IGNORE_EXCEPTION_DETAIL |
| 21 | + Traceback (most recent call last): |
| 22 | + ... |
| 23 | + importlib.metadata.PackageNotFoundError: No package metadata was found for pytest<3 |
| 24 | + """ |
| 25 | + dist = metadata.distribution(req.name) |
| 26 | + if not req.specifier.contains(Version(dist.version), prereleases=True): |
| 27 | + raise metadata.PackageNotFoundError(str(req)) |
| 28 | + dist.extras = req.extras # type: ignore |
| 29 | + return dist |
| 30 | + |
| 31 | + |
| 32 | +@apply(bool) |
| 33 | +@suppress(metadata.PackageNotFoundError) |
| 34 | +def is_satisfied(req: Requirement): |
| 35 | + return resolve(req) |
| 36 | + |
| 37 | + |
| 38 | +unsatisfied = functools.partial(itertools.filterfalse, is_satisfied) |
| 39 | + |
| 40 | + |
| 41 | +class NullMarker: |
| 42 | + @classmethod |
| 43 | + def wrap(cls, req: Requirement): |
| 44 | + return req.marker or cls() |
| 45 | + |
| 46 | + def evaluate(self, *args, **kwargs): |
| 47 | + return True |
| 48 | + |
| 49 | + |
| 50 | +def find_direct_dependencies(dist, extras=None): |
| 51 | + """ |
| 52 | + Find direct, declared dependencies for dist. |
| 53 | + """ |
| 54 | + simple = ( |
| 55 | + req |
| 56 | + for req in map(Requirement, always_iterable(dist.requires)) |
| 57 | + if NullMarker.wrap(req).evaluate(dict(extra=None)) |
| 58 | + ) |
| 59 | + extra_deps = ( |
| 60 | + req |
| 61 | + for req in map(Requirement, always_iterable(dist.requires)) |
| 62 | + for extra in always_iterable(getattr(dist, 'extras', extras)) |
| 63 | + if NullMarker.wrap(req).evaluate(dict(extra=extra)) |
| 64 | + ) |
| 65 | + return itertools.chain(simple, extra_deps) |
| 66 | + |
| 67 | + |
| 68 | +def traverse(items, visit): |
| 69 | + """ |
| 70 | + Given an iterable of items, traverse the items. |
| 71 | +
|
| 72 | + For each item, visit is called to return any additional items |
| 73 | + to include in the traversal. |
| 74 | + """ |
| 75 | + while True: |
| 76 | + try: |
| 77 | + item = next(items) |
| 78 | + except StopIteration: |
| 79 | + return |
| 80 | + yield item |
| 81 | + items = itertools.chain(items, visit(item)) |
| 82 | + |
| 83 | + |
| 84 | +def find_req_dependencies(req): |
| 85 | + with contextlib.suppress(metadata.PackageNotFoundError): |
| 86 | + dist = resolve(req) |
| 87 | + yield from find_direct_dependencies(dist) |
| 88 | + |
| 89 | + |
| 90 | +def find_dependencies(dist, extras=None): |
| 91 | + """ |
| 92 | + Find all reachable dependencies for dist. |
| 93 | +
|
| 94 | + dist is an importlib.metadata.Distribution (or similar). |
| 95 | + TODO: create a suitable protocol for type hint. |
| 96 | +
|
| 97 | + >>> deps = find_dependencies(resolve(Requirement('nspektr'))) |
| 98 | + >>> all(isinstance(dep, Requirement) for dep in deps) |
| 99 | + True |
| 100 | + >>> not any('pytest' in str(dep) for dep in deps) |
| 101 | + True |
| 102 | + >>> test_deps = find_dependencies(resolve(Requirement('nspektr[testing]'))) |
| 103 | + >>> any('pytest' in str(dep) for dep in test_deps) |
| 104 | + True |
| 105 | + """ |
| 106 | + |
| 107 | + def visit(req, seen=set()): |
| 108 | + if req in seen: |
| 109 | + return () |
| 110 | + seen.add(req) |
| 111 | + return find_req_dependencies(req) |
| 112 | + |
| 113 | + return traverse(find_direct_dependencies(dist, extras), visit) |
| 114 | + |
| 115 | + |
| 116 | +class Unresolved(Exception): |
| 117 | + def __iter__(self): |
| 118 | + return iter(self.args[0]) |
| 119 | + |
| 120 | + |
| 121 | +def missing(ep): |
| 122 | + """ |
| 123 | + Generate the unresolved dependencies (if any) of ep. |
| 124 | + """ |
| 125 | + return unsatisfied(find_dependencies(ep.dist, repair_extras(ep.extras))) |
| 126 | + |
| 127 | + |
| 128 | +def check(ep): |
| 129 | + """ |
| 130 | + >>> ep, = metadata.entry_points(group='console_scripts', name='pip') |
| 131 | + >>> check(ep) |
| 132 | + >>> dist = metadata.distribution('nspektr') |
| 133 | +
|
| 134 | + Since 'docs' extras are not installed, requesting them should fail. |
| 135 | +
|
| 136 | + >>> ep = metadata.EntryPoint( |
| 137 | + ... group=None, name=None, value='nspektr [docs]')._for(dist) |
| 138 | + >>> check(ep) |
| 139 | + Traceback (most recent call last): |
| 140 | + ... |
| 141 | + nspektr.Unresolved: [...] |
| 142 | + """ |
| 143 | + missed = list(missing(ep)) |
| 144 | + if missed: |
| 145 | + raise Unresolved(missed) |
0 commit comments