Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of the lib3to6 project
2# https://github.com/mbarkhau/lib3to6
3#
4# Copyright (c) 2019-2021 Manuel Barkhau (mbarkhau@gmail.com) - MIT License
5# SPDX-License-Identifier: MIT
6import ast
7import typing as typ
8import logging
10from . import common
11from . import checker_base as cb
13# TODO (mb 2020-05-28):
14# instead of functools.singledispatch
15# from singledispatch import singledispatch
16# https://pypi.org/project/singledispatch/
17#
18# instead of functools.lru_cache
19# from backports import functools_lru_cache
20# https://pypi.org/project/backports.functools-lru-cache/
21#
23logger = logging.getLogger(__name__)
26class ModuleVersionInfo(typ.NamedTuple):
28 available_since : str
29 backport_module : typ.Optional[str]
30 backport_package: typ.Optional[str]
33def parse_version(version: str) -> typ.Any:
34 # pylint: disable=import-outside-toplevel; lazy import to speed up --help
35 import pkg_resources
37 return pkg_resources.parse_version(version)
40MAYBE_UNUSABLE_MODULES = {
41 # case 1 (simple). Always error because no backport available
42 'asyncio': ModuleVersionInfo("3.4", None, None),
43 'zipapp' : ModuleVersionInfo("3.5", None, None),
44 # case 2 (simple). Always error because modules have different names and only
45 # backport should be used
46 'csv' : ModuleVersionInfo("3.0", "backports.csv" , "backports.csv"),
47 'selectors' : ModuleVersionInfo("3.4", "selectors2" , "selectors2"),
48 'pathlib' : ModuleVersionInfo("3.4", "pathlib2" , "pathlib2"),
49 "importlib.resources": ModuleVersionInfo("3.7", "importlib_resources", "importlib_resources"),
50 'inspect' : ModuleVersionInfo("3.6", "inspect2" , "inspect2"),
51 # case 3 (hard). Modules have the same name.
52 # - By default, only logger.warning if these are modules are imported.
53 # - If opt-in via '--install-requires' option or
54 # 'install_requires' argument of 'lib3to6.fix', check that they
55 # have been explicitly whitelisted.
56 'lzma' : ModuleVersionInfo("3.3", "lzma" , "backports.lzma"),
57 'ipaddress' : ModuleVersionInfo("3.4", "ipaddress" , "py2-ipaddress"),
58 'enum' : ModuleVersionInfo("3.4", "enum" , "enum34"),
59 'typing' : ModuleVersionInfo("3.5", 'typing' , 'typing'),
60 'secrets' : ModuleVersionInfo("3.6", "secrets" , "python2-secrets"),
61 'statistics' : ModuleVersionInfo("3.4", "statistics" , "statistics"),
62 'dataclasses': ModuleVersionInfo("3.7", "dataclasses", "dataclasses"),
63 'contextvars': ModuleVersionInfo("3.7", "contextvars", "contextvars"),
64}
67def _iter_module_names(node: ast.AST) -> typ.Iterable[str]:
68 if isinstance(node, ast.Import):
69 for alias in node.names:
70 yield alias.name
71 elif isinstance(node, ast.ImportFrom):
72 mname = node.module
73 if mname:
74 yield mname
77def _iter_maybe_unusable_modules(node: ast.AST) -> typ.Iterable[typ.Tuple[str, ModuleVersionInfo]]:
78 for mname in _iter_module_names(node):
79 vnfo = MAYBE_UNUSABLE_MODULES.get(mname)
80 if vnfo:
81 yield (mname, vnfo)
84class NoUnusableImportsChecker(cb.CheckerBase):
85 # NOTE (mb 2020-05-28): The (apparent double negation) naming of this does
86 # make sense; the name "OnlyUsableImportsChecker" would not be better,
87 # because this doesn't check all imports ever in existence, it only checks
88 # that unusable imports are not used at the top level.
90 # NOTE (mb 2020-05-28): This checker only checks top level imports.
91 # This allows for the common idom to work without errors.
92 #
93 # try:
94 # import newmodule
95 # except ImportError:
96 # improt backport_module as newmodule
98 def __call__(self, ctx: common.BuildContext, tree: ast.Module) -> None:
99 # NOTE (mb 2020-05-28):
100 # - Strict mode fails hard
101 # - Only raises an error if an unusable module is not used
102 # - Only warns about backported modules after
103 # opt-in to this check (by using the fix(install_requires) parameter).
104 # - Existing systems will have to update
105 # their config for this check to work and we don't want to
106 # break them.
108 install_requires: typ.Optional[set[str]] = ctx.cfg.install_requires
110 target_version = ctx.cfg.target_version
111 for node in ast.iter_child_nodes(tree):
112 for mname, vnfo in _iter_maybe_unusable_modules(node):
113 if parse_version(target_version) >= parse_version(vnfo.available_since):
114 # target supports the newer name
115 continue
117 bppkg = vnfo.backport_package
119 # if the backport has a different name, then there is no
120 # excuse not to use it -> hard error
121 is_backport_name_same = mname == vnfo.backport_module
123 is_whitelisted = (
124 is_backport_name_same
125 and install_requires is not None
126 and bppkg in install_requires
127 )
128 if is_whitelisted:
129 continue
131 # From here, we either error or at least show a warning.
133 # if there is no backport, then the import can obviously only
134 # be using the stdlib module -> hard error
135 is_backported = vnfo.backport_package is not None
136 is_strict_mode = install_requires is not None
137 is_hard_error = not is_backported or is_strict_mode or not is_backport_name_same
139 vnfo_msg = (
140 f"This module is available since Python {vnfo.available_since}, "
141 f"but you configured target_version='{target_version}'."
142 )
144 if is_hard_error:
145 errmsg = f"Prohibited import '{mname}'. {vnfo_msg}"
146 if bppkg:
147 errmsg += f" Use 'https://pypi.org/project/{bppkg}' instead."
148 else:
149 errmsg += " No backported for this package is known."
151 raise common.CheckError(errmsg, node)
152 else:
153 lineno = common.get_node_lineno(node)
154 loc = f"{ctx.filepath}@{lineno}"
155 logger.warning(f"{loc}: Use of import '{mname}'. {vnfo_msg}")