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
7import os
8import re
9import sys
10import shutil
11import typing as typ
12import hashlib
13import tempfile
14import warnings
16import pathlib2 as pl
17import setuptools.dist
18import setuptools.command.build_py as _build_py
20from . import common
21from . import transpile
23ENV_PATH = str(pl.Path(sys.executable).parent.parent)
26PYTHON_TAG_PREFIXES = {
27 'py': "Generic Python",
28 'cp': "CPython",
29 'ip': "IronPython",
30 'pp': "PyPy",
31 'jy': "Jython",
32}
35CACHE_DIR = pl.Path(tempfile.gettempdir()) / ".lib3to6_cache"
38def eval_build_config(**kwargs) -> common.BuildConfig:
39 target_version = kwargs.get('target_version', transpile.DEFAULT_TARGET_VERSION)
40 _install_requires = kwargs.get('install_requires', None)
41 cache_enabled = kwargs.get('cache_enabled', True)
42 default_mode = kwargs.get('default_mode', 'enabled')
44 install_requires: common.InstallRequires
45 if _install_requires is None:
46 install_requires = None
47 elif isinstance(_install_requires, str):
48 install_requires = set(_install_requires.split())
49 elif isinstance(_install_requires, list):
50 install_requires = set(_install_requires)
51 else:
52 raise TypeError(f"Invalid argument for install_requires: {type(_install_requires)}")
54 if install_requires:
55 # Remove version specs. We only handle the bare requirement
56 # and assume the maintainer knows what they're doing wrt.
57 # the appropriate versions.
58 install_requires = {re.split(r"[\^\~<>=;]", req.strip())[0] for req in install_requires}
60 return common.BuildConfig(
61 target_version=target_version,
62 cache_enabled=cache_enabled,
63 default_mode=default_mode,
64 fixers="",
65 checkers="",
66 install_requires=install_requires,
67 )
70def _ignore_tmp_files(src: str, names: typ.List[str]) -> typ.List[str]:
71 if isinstance(src, str):
72 src_str = src
73 else:
74 # https://bugs.python.org/issue39390
75 if isinstance(src, os.DirEntry):
76 src_str = src.path
77 else:
78 src_str = str(src)
80 if src_str.startswith("build") or src_str.startswith("./build"):
81 return names
82 if src_str.endswith(".egg-info"):
83 return names
84 if src_str.endswith("dist"):
85 return names
86 if src_str.endswith("__pycache__"):
87 return names
89 return [name for name in names if name.endswith(".pyc")]
92def init_build_package_dir(local_package_dir: common.PackageDir) -> common.PackageDir:
93 output_dir = pl.Path("build") / "lib3to6_out"
94 output_dir.mkdir(parents=True, exist_ok=True)
96 build_package_dir: common.PackageDir = {}
98 for package, src_package_dir in local_package_dir.items():
99 is_abs_path = pl.Path(src_package_dir) == pl.Path(src_package_dir).absolute()
100 if is_abs_path:
101 raise Exception(f"package_dir must use relative paths, got '{src_package_dir}'")
103 build_package_subdir = output_dir / src_package_dir
105 # TODO (mb 2018-08-25): As an optimization, we could
106 # restrict deletion to files that we manipulate, in
107 # other words, to *.py files.
108 if build_package_subdir.exists():
109 shutil.rmtree(build_package_subdir)
111 shutil.copytree(src_package_dir, str(build_package_subdir), ignore=_ignore_tmp_files)
112 build_package_dir[package] = str(build_package_subdir)
114 return build_package_dir
117def transpile_path(cfg: common.BuildConfig, filepath: pl.Path) -> pl.Path:
118 with open(filepath, mode="rb") as fobj:
119 module_source_data = fobj.read()
121 filehash = hashlib.sha1()
122 filehash.update(str(cfg).encode("utf-8"))
123 filehash.update(module_source_data)
125 cache_path = CACHE_DIR / (filehash.hexdigest() + ".py")
127 if cfg.cache_enabled and cache_path.exists():
128 return cache_path
130 # NOTE (mb 2020-09-01): not cache_enabled -> always update cache
131 ctx = common.BuildContext(cfg, str(filepath))
132 try:
133 fixed_module_source_data = transpile.transpile_module_data(ctx, module_source_data)
134 except common.CheckError as err:
135 loc = str(filepath)
136 if err.lineno >= 0:
137 loc += "@" + str(err.lineno)
139 err.args = (loc + " - " + err.args[0],) + err.args[1:]
140 raise
142 with open(cache_path, mode="wb") as fobj:
143 fobj.write(fixed_module_source_data)
145 return cache_path
148def build_package(cfg: common.BuildConfig, package: str, build_dir: str) -> None:
149 # pylint:disable=unused-argument ; `package` is part of the public api now
150 for root, _dirs, files in os.walk(build_dir):
151 for filename in files:
152 filepath = pl.Path(root) / filename
153 if filepath.suffix == ".py":
154 transpiled_path = transpile_path(cfg, filepath)
155 # overwrite original with transpiled
156 shutil.copy(transpiled_path, filepath)
159def build_packages(cfg: common.BuildConfig, build_package_dir: common.PackageDir) -> None:
160 CACHE_DIR.mkdir(exist_ok=True)
162 for package, build_dir in build_package_dir.items():
163 build_package(cfg, package, build_dir)
166def fix(
167 package_dir : common.PackageDir = None,
168 target_version : str = transpile.DEFAULT_TARGET_VERSION,
169 install_requires: typ.List[str] = None,
170 default_mode : str = 'enabled',
171) -> common.PackageDir:
172 msg = "Depricated: lib3to6.fix(). See https://github.com/mbarkhau/lib3to6#Deprications"
173 warnings.warn(msg, DeprecationWarning)
175 if package_dir is None:
176 package_dir = {"": "."}
178 build_package_dir = init_build_package_dir(package_dir)
179 build_cfg = eval_build_config(
180 target_version=target_version,
181 install_requires=install_requires,
182 default_mode=default_mode,
183 )
184 build_packages(build_cfg, build_package_dir)
185 return build_package_dir
188class build_py(_build_py.build_py):
189 # pylint: disable=invalid-name ; following the convention of setuptools
191 def _get_outputs(self) -> typ.List[str]:
192 outputs = _build_py.orig.build_py.get_outputs(self, include_bytecode=0) # type: ignore[attr-defined]
193 return typ.cast(typ.List[str], outputs)
195 def run_3to6(self) -> None:
196 outputs = self._get_outputs()
197 dist = self.distribution
198 pyreq = dist.python_requires
200 preq_match = isinstance(pyreq, str) and re.match(r">=([0-9]+\.[0-9]+)", pyreq)
201 if preq_match:
202 target_version = preq_match.group(1)
203 else:
204 raise ValueError('lib3to6: missing python_requires=">=X.Y" in setup.py')
206 # pylint: disable=protected-access
207 install_requires = sorted(dist._lib3to6_install_requires)
208 build_cfg = eval_build_config(
209 target_version=target_version,
210 install_requires=install_requires,
211 default_mode=getattr(dist, 'lib3to6_default_mode', 'enabled'),
212 )
214 CACHE_DIR.mkdir(exist_ok=True)
215 for output in outputs:
216 if output.endswith(".py"):
217 transpiled_path = transpile_path(build_cfg, pl.Path(output))
218 shutil.copy(transpiled_path, output)
220 def run(self) -> None:
221 """Build modules, packages, and copy data files to build directory"""
222 if not self.py_modules and not self.packages:
223 return
225 if self.py_modules:
226 self.build_modules()
228 if self.packages:
229 self.build_packages()
230 self.build_package_data()
232 if hasattr(self, 'run_2to3'):
233 self.run_2to3(self.__updated_files, False)
234 self.run_2to3(self.__updated_files, True)
235 self.run_2to3(self.__doctests_2to3, True)
237 self.run_3to6()
239 # Only compile actual .py files, using our base class' idea of what our
240 # output files are.
241 self.byte_compile(self._get_outputs())
244class Distribution(setuptools.dist.Distribution):
245 def __init__(self, attrs=None) -> None:
246 # NOTE (mb 2021-08-20): Distutils removes all requirements
247 # that are not needed for the current python version. We
248 # need the original requirements for validation, so we
249 # capture them here.
250 self._lib3to6_install_requires = attrs.get('install_requires')
251 super().__init__(attrs)
253 def get_command_class(self, command: str) -> typ.Any:
254 if command in self.cmdclass:
255 return self.cmdclass[command]
256 elif command == 'build_py':
257 self.cmdclass[command] = build_py
258 return build_py
259 else:
260 return super().get_command_class(command) # type: ignore[no-untyped-call]