]>
Commit | Line | Data |
---|---|---|
53e6db90 DC |
1 | """setuptools.command.bdist_egg |
2 | ||
3 | Build .egg distributions""" | |
4 | ||
5 | from distutils.dir_util import remove_tree, mkpath | |
6 | from distutils import log | |
7 | from types import CodeType | |
8 | import sys | |
9 | import os | |
10 | import re | |
11 | import textwrap | |
12 | import marshal | |
13 | ||
14 | from pkg_resources import get_build_platform, Distribution | |
15 | from setuptools.extension import Library | |
16 | from setuptools import Command | |
17 | from .._path import ensure_directory | |
18 | ||
19 | from sysconfig import get_path, get_python_version | |
20 | ||
21 | ||
22 | def _get_purelib(): | |
23 | return get_path("purelib") | |
24 | ||
25 | ||
26 | def strip_module(filename): | |
27 | if '.' in filename: | |
28 | filename = os.path.splitext(filename)[0] | |
29 | if filename.endswith('module'): | |
30 | filename = filename[:-6] | |
31 | return filename | |
32 | ||
33 | ||
34 | def sorted_walk(dir): | |
35 | """Do os.walk in a reproducible way, | |
36 | independent of indeterministic filesystem readdir order | |
37 | """ | |
38 | for base, dirs, files in os.walk(dir): | |
39 | dirs.sort() | |
40 | files.sort() | |
41 | yield base, dirs, files | |
42 | ||
43 | ||
44 | def write_stub(resource, pyfile): | |
45 | _stub_template = textwrap.dedent(""" | |
46 | def __bootstrap__(): | |
47 | global __bootstrap__, __loader__, __file__ | |
48 | import sys, pkg_resources, importlib.util | |
49 | __file__ = pkg_resources.resource_filename(__name__, %r) | |
50 | __loader__ = None; del __bootstrap__, __loader__ | |
51 | spec = importlib.util.spec_from_file_location(__name__,__file__) | |
52 | mod = importlib.util.module_from_spec(spec) | |
53 | spec.loader.exec_module(mod) | |
54 | __bootstrap__() | |
55 | """).lstrip() | |
56 | with open(pyfile, 'w') as f: | |
57 | f.write(_stub_template % resource) | |
58 | ||
59 | ||
60 | class bdist_egg(Command): | |
61 | description = "create an \"egg\" distribution" | |
62 | ||
63 | user_options = [ | |
64 | ('bdist-dir=', 'b', | |
65 | "temporary directory for creating the distribution"), | |
66 | ('plat-name=', 'p', "platform name to embed in generated filenames " | |
67 | "(default: %s)" % get_build_platform()), | |
68 | ('exclude-source-files', None, | |
69 | "remove all .py files from the generated egg"), | |
70 | ('keep-temp', 'k', | |
71 | "keep the pseudo-installation tree around after " + | |
72 | "creating the distribution archive"), | |
73 | ('dist-dir=', 'd', | |
74 | "directory to put final built distributions in"), | |
75 | ('skip-build', None, | |
76 | "skip rebuilding everything (for testing/debugging)"), | |
77 | ] | |
78 | ||
79 | boolean_options = [ | |
80 | 'keep-temp', 'skip-build', 'exclude-source-files' | |
81 | ] | |
82 | ||
83 | def initialize_options(self): | |
84 | self.bdist_dir = None | |
85 | self.plat_name = None | |
86 | self.keep_temp = 0 | |
87 | self.dist_dir = None | |
88 | self.skip_build = 0 | |
89 | self.egg_output = None | |
90 | self.exclude_source_files = None | |
91 | ||
92 | def finalize_options(self): | |
93 | ei_cmd = self.ei_cmd = self.get_finalized_command("egg_info") | |
94 | self.egg_info = ei_cmd.egg_info | |
95 | ||
96 | if self.bdist_dir is None: | |
97 | bdist_base = self.get_finalized_command('bdist').bdist_base | |
98 | self.bdist_dir = os.path.join(bdist_base, 'egg') | |
99 | ||
100 | if self.plat_name is None: | |
101 | self.plat_name = get_build_platform() | |
102 | ||
103 | self.set_undefined_options('bdist', ('dist_dir', 'dist_dir')) | |
104 | ||
105 | if self.egg_output is None: | |
106 | ||
107 | # Compute filename of the output egg | |
108 | basename = Distribution( | |
109 | None, None, ei_cmd.egg_name, ei_cmd.egg_version, | |
110 | get_python_version(), | |
111 | self.distribution.has_ext_modules() and self.plat_name | |
112 | ).egg_name() | |
113 | ||
114 | self.egg_output = os.path.join(self.dist_dir, basename + '.egg') | |
115 | ||
116 | def do_install_data(self): | |
117 | # Hack for packages that install data to install's --install-lib | |
118 | self.get_finalized_command('install').install_lib = self.bdist_dir | |
119 | ||
120 | site_packages = os.path.normcase(os.path.realpath(_get_purelib())) | |
121 | old, self.distribution.data_files = self.distribution.data_files, [] | |
122 | ||
123 | for item in old: | |
124 | if isinstance(item, tuple) and len(item) == 2: | |
125 | if os.path.isabs(item[0]): | |
126 | realpath = os.path.realpath(item[0]) | |
127 | normalized = os.path.normcase(realpath) | |
128 | if normalized == site_packages or normalized.startswith( | |
129 | site_packages + os.sep | |
130 | ): | |
131 | item = realpath[len(site_packages) + 1:], item[1] | |
132 | # XXX else: raise ??? | |
133 | self.distribution.data_files.append(item) | |
134 | ||
135 | try: | |
136 | log.info("installing package data to %s", self.bdist_dir) | |
137 | self.call_command('install_data', force=0, root=None) | |
138 | finally: | |
139 | self.distribution.data_files = old | |
140 | ||
141 | def get_outputs(self): | |
142 | return [self.egg_output] | |
143 | ||
144 | def call_command(self, cmdname, **kw): | |
145 | """Invoke reinitialized command `cmdname` with keyword args""" | |
146 | for dirname in INSTALL_DIRECTORY_ATTRS: | |
147 | kw.setdefault(dirname, self.bdist_dir) | |
148 | kw.setdefault('skip_build', self.skip_build) | |
149 | kw.setdefault('dry_run', self.dry_run) | |
150 | cmd = self.reinitialize_command(cmdname, **kw) | |
151 | self.run_command(cmdname) | |
152 | return cmd | |
153 | ||
154 | def run(self): # noqa: C901 # is too complex (14) # FIXME | |
155 | # Generate metadata first | |
156 | self.run_command("egg_info") | |
157 | # We run install_lib before install_data, because some data hacks | |
158 | # pull their data path from the install_lib command. | |
159 | log.info("installing library code to %s", self.bdist_dir) | |
160 | instcmd = self.get_finalized_command('install') | |
161 | old_root = instcmd.root | |
162 | instcmd.root = None | |
163 | if self.distribution.has_c_libraries() and not self.skip_build: | |
164 | self.run_command('build_clib') | |
165 | cmd = self.call_command('install_lib', warn_dir=0) | |
166 | instcmd.root = old_root | |
167 | ||
168 | all_outputs, ext_outputs = self.get_ext_outputs() | |
169 | self.stubs = [] | |
170 | to_compile = [] | |
171 | for (p, ext_name) in enumerate(ext_outputs): | |
172 | filename, ext = os.path.splitext(ext_name) | |
173 | pyfile = os.path.join(self.bdist_dir, strip_module(filename) + | |
174 | '.py') | |
175 | self.stubs.append(pyfile) | |
176 | log.info("creating stub loader for %s", ext_name) | |
177 | if not self.dry_run: | |
178 | write_stub(os.path.basename(ext_name), pyfile) | |
179 | to_compile.append(pyfile) | |
180 | ext_outputs[p] = ext_name.replace(os.sep, '/') | |
181 | ||
182 | if to_compile: | |
183 | cmd.byte_compile(to_compile) | |
184 | if self.distribution.data_files: | |
185 | self.do_install_data() | |
186 | ||
187 | # Make the EGG-INFO directory | |
188 | archive_root = self.bdist_dir | |
189 | egg_info = os.path.join(archive_root, 'EGG-INFO') | |
190 | self.mkpath(egg_info) | |
191 | if self.distribution.scripts: | |
192 | script_dir = os.path.join(egg_info, 'scripts') | |
193 | log.info("installing scripts to %s", script_dir) | |
194 | self.call_command('install_scripts', install_dir=script_dir, | |
195 | no_ep=1) | |
196 | ||
197 | self.copy_metadata_to(egg_info) | |
198 | native_libs = os.path.join(egg_info, "native_libs.txt") | |
199 | if all_outputs: | |
200 | log.info("writing %s", native_libs) | |
201 | if not self.dry_run: | |
202 | ensure_directory(native_libs) | |
203 | libs_file = open(native_libs, 'wt') | |
204 | libs_file.write('\n'.join(all_outputs)) | |
205 | libs_file.write('\n') | |
206 | libs_file.close() | |
207 | elif os.path.isfile(native_libs): | |
208 | log.info("removing %s", native_libs) | |
209 | if not self.dry_run: | |
210 | os.unlink(native_libs) | |
211 | ||
212 | write_safety_flag( | |
213 | os.path.join(archive_root, 'EGG-INFO'), self.zip_safe() | |
214 | ) | |
215 | ||
216 | if os.path.exists(os.path.join(self.egg_info, 'depends.txt')): | |
217 | log.warn( | |
218 | "WARNING: 'depends.txt' will not be used by setuptools 0.6!\n" | |
219 | "Use the install_requires/extras_require setup() args instead." | |
220 | ) | |
221 | ||
222 | if self.exclude_source_files: | |
223 | self.zap_pyfiles() | |
224 | ||
225 | # Make the archive | |
226 | make_zipfile(self.egg_output, archive_root, verbose=self.verbose, | |
227 | dry_run=self.dry_run, mode=self.gen_header()) | |
228 | if not self.keep_temp: | |
229 | remove_tree(self.bdist_dir, dry_run=self.dry_run) | |
230 | ||
231 | # Add to 'Distribution.dist_files' so that the "upload" command works | |
232 | getattr(self.distribution, 'dist_files', []).append( | |
233 | ('bdist_egg', get_python_version(), self.egg_output)) | |
234 | ||
235 | def zap_pyfiles(self): | |
236 | log.info("Removing .py files from temporary directory") | |
237 | for base, dirs, files in walk_egg(self.bdist_dir): | |
238 | for name in files: | |
239 | path = os.path.join(base, name) | |
240 | ||
241 | if name.endswith('.py'): | |
242 | log.debug("Deleting %s", path) | |
243 | os.unlink(path) | |
244 | ||
245 | if base.endswith('__pycache__'): | |
246 | path_old = path | |
247 | ||
248 | pattern = r'(?P<name>.+)\.(?P<magic>[^.]+)\.pyc' | |
249 | m = re.match(pattern, name) | |
250 | path_new = os.path.join( | |
251 | base, os.pardir, m.group('name') + '.pyc') | |
252 | log.info( | |
253 | "Renaming file from [%s] to [%s]" | |
254 | % (path_old, path_new)) | |
255 | try: | |
256 | os.remove(path_new) | |
257 | except OSError: | |
258 | pass | |
259 | os.rename(path_old, path_new) | |
260 | ||
261 | def zip_safe(self): | |
262 | safe = getattr(self.distribution, 'zip_safe', None) | |
263 | if safe is not None: | |
264 | return safe | |
265 | log.warn("zip_safe flag not set; analyzing archive contents...") | |
266 | return analyze_egg(self.bdist_dir, self.stubs) | |
267 | ||
268 | def gen_header(self): | |
269 | return 'w' | |
270 | ||
271 | def copy_metadata_to(self, target_dir): | |
272 | "Copy metadata (egg info) to the target_dir" | |
273 | # normalize the path (so that a forward-slash in egg_info will | |
274 | # match using startswith below) | |
275 | norm_egg_info = os.path.normpath(self.egg_info) | |
276 | prefix = os.path.join(norm_egg_info, '') | |
277 | for path in self.ei_cmd.filelist.files: | |
278 | if path.startswith(prefix): | |
279 | target = os.path.join(target_dir, path[len(prefix):]) | |
280 | ensure_directory(target) | |
281 | self.copy_file(path, target) | |
282 | ||
283 | def get_ext_outputs(self): | |
284 | """Get a list of relative paths to C extensions in the output distro""" | |
285 | ||
286 | all_outputs = [] | |
287 | ext_outputs = [] | |
288 | ||
289 | paths = {self.bdist_dir: ''} | |
290 | for base, dirs, files in sorted_walk(self.bdist_dir): | |
291 | for filename in files: | |
292 | if os.path.splitext(filename)[1].lower() in NATIVE_EXTENSIONS: | |
293 | all_outputs.append(paths[base] + filename) | |
294 | for filename in dirs: | |
295 | paths[os.path.join(base, filename)] = (paths[base] + | |
296 | filename + '/') | |
297 | ||
298 | if self.distribution.has_ext_modules(): | |
299 | build_cmd = self.get_finalized_command('build_ext') | |
300 | for ext in build_cmd.extensions: | |
301 | if isinstance(ext, Library): | |
302 | continue | |
303 | fullname = build_cmd.get_ext_fullname(ext.name) | |
304 | filename = build_cmd.get_ext_filename(fullname) | |
305 | if not os.path.basename(filename).startswith('dl-'): | |
306 | if os.path.exists(os.path.join(self.bdist_dir, filename)): | |
307 | ext_outputs.append(filename) | |
308 | ||
309 | return all_outputs, ext_outputs | |
310 | ||
311 | ||
312 | NATIVE_EXTENSIONS = dict.fromkeys('.dll .so .dylib .pyd'.split()) | |
313 | ||
314 | ||
315 | def walk_egg(egg_dir): | |
316 | """Walk an unpacked egg's contents, skipping the metadata directory""" | |
317 | walker = sorted_walk(egg_dir) | |
318 | base, dirs, files = next(walker) | |
319 | if 'EGG-INFO' in dirs: | |
320 | dirs.remove('EGG-INFO') | |
321 | yield base, dirs, files | |
322 | for bdf in walker: | |
323 | yield bdf | |
324 | ||
325 | ||
326 | def analyze_egg(egg_dir, stubs): | |
327 | # check for existing flag in EGG-INFO | |
328 | for flag, fn in safety_flags.items(): | |
329 | if os.path.exists(os.path.join(egg_dir, 'EGG-INFO', fn)): | |
330 | return flag | |
331 | if not can_scan(): | |
332 | return False | |
333 | safe = True | |
334 | for base, dirs, files in walk_egg(egg_dir): | |
335 | for name in files: | |
336 | if name.endswith('.py') or name.endswith('.pyw'): | |
337 | continue | |
338 | elif name.endswith('.pyc') or name.endswith('.pyo'): | |
339 | # always scan, even if we already know we're not safe | |
340 | safe = scan_module(egg_dir, base, name, stubs) and safe | |
341 | return safe | |
342 | ||
343 | ||
344 | def write_safety_flag(egg_dir, safe): | |
345 | # Write or remove zip safety flag file(s) | |
346 | for flag, fn in safety_flags.items(): | |
347 | fn = os.path.join(egg_dir, fn) | |
348 | if os.path.exists(fn): | |
349 | if safe is None or bool(safe) != flag: | |
350 | os.unlink(fn) | |
351 | elif safe is not None and bool(safe) == flag: | |
352 | f = open(fn, 'wt') | |
353 | f.write('\n') | |
354 | f.close() | |
355 | ||
356 | ||
357 | safety_flags = { | |
358 | True: 'zip-safe', | |
359 | False: 'not-zip-safe', | |
360 | } | |
361 | ||
362 | ||
363 | def scan_module(egg_dir, base, name, stubs): | |
364 | """Check whether module possibly uses unsafe-for-zipfile stuff""" | |
365 | ||
366 | filename = os.path.join(base, name) | |
367 | if filename[:-1] in stubs: | |
368 | return True # Extension module | |
369 | pkg = base[len(egg_dir) + 1:].replace(os.sep, '.') | |
370 | module = pkg + (pkg and '.' or '') + os.path.splitext(name)[0] | |
371 | if sys.version_info < (3, 7): | |
372 | skip = 12 # skip magic & date & file size | |
373 | else: | |
374 | skip = 16 # skip magic & reserved? & date & file size | |
375 | f = open(filename, 'rb') | |
376 | f.read(skip) | |
377 | code = marshal.load(f) | |
378 | f.close() | |
379 | safe = True | |
380 | symbols = dict.fromkeys(iter_symbols(code)) | |
381 | for bad in ['__file__', '__path__']: | |
382 | if bad in symbols: | |
383 | log.warn("%s: module references %s", module, bad) | |
384 | safe = False | |
385 | if 'inspect' in symbols: | |
386 | for bad in [ | |
387 | 'getsource', 'getabsfile', 'getsourcefile', 'getfile' | |
388 | 'getsourcelines', 'findsource', 'getcomments', 'getframeinfo', | |
389 | 'getinnerframes', 'getouterframes', 'stack', 'trace' | |
390 | ]: | |
391 | if bad in symbols: | |
392 | log.warn("%s: module MAY be using inspect.%s", module, bad) | |
393 | safe = False | |
394 | return safe | |
395 | ||
396 | ||
397 | def iter_symbols(code): | |
398 | """Yield names and strings used by `code` and its nested code objects""" | |
399 | for name in code.co_names: | |
400 | yield name | |
401 | for const in code.co_consts: | |
402 | if isinstance(const, str): | |
403 | yield const | |
404 | elif isinstance(const, CodeType): | |
405 | for name in iter_symbols(const): | |
406 | yield name | |
407 | ||
408 | ||
409 | def can_scan(): | |
410 | if not sys.platform.startswith('java') and sys.platform != 'cli': | |
411 | # CPython, PyPy, etc. | |
412 | return True | |
413 | log.warn("Unable to analyze compiled code on this platform.") | |
414 | log.warn("Please ask the author to include a 'zip_safe'" | |
415 | " setting (either True or False) in the package's setup.py") | |
416 | ||
417 | ||
418 | # Attribute names of options for commands that might need to be convinced to | |
419 | # install to the egg build directory | |
420 | ||
421 | INSTALL_DIRECTORY_ATTRS = [ | |
422 | 'install_lib', 'install_dir', 'install_data', 'install_base' | |
423 | ] | |
424 | ||
425 | ||
426 | def make_zipfile(zip_filename, base_dir, verbose=0, dry_run=0, compress=True, | |
427 | mode='w'): | |
428 | """Create a zip file from all the files under 'base_dir'. The output | |
429 | zip file will be named 'base_dir' + ".zip". Uses either the "zipfile" | |
430 | Python module (if available) or the InfoZIP "zip" utility (if installed | |
431 | and found on the default search path). If neither tool is available, | |
432 | raises DistutilsExecError. Returns the name of the output zip file. | |
433 | """ | |
434 | import zipfile | |
435 | ||
436 | mkpath(os.path.dirname(zip_filename), dry_run=dry_run) | |
437 | log.info("creating '%s' and adding '%s' to it", zip_filename, base_dir) | |
438 | ||
439 | def visit(z, dirname, names): | |
440 | for name in names: | |
441 | path = os.path.normpath(os.path.join(dirname, name)) | |
442 | if os.path.isfile(path): | |
443 | p = path[len(base_dir) + 1:] | |
444 | if not dry_run: | |
445 | z.write(path, p) | |
446 | log.debug("adding '%s'", p) | |
447 | ||
448 | compression = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED | |
449 | if not dry_run: | |
450 | z = zipfile.ZipFile(zip_filename, mode, compression=compression) | |
451 | for dirname, dirs, files in sorted_walk(base_dir): | |
452 | visit(z, dirname, files) | |
453 | z.close() | |
454 | else: | |
455 | for dirname, dirs, files in sorted_walk(base_dir): | |
456 | visit(None, dirname, files) | |
457 | return zip_filename |