1 from glob import glob
2 from distutils.util import convert_path
3 import distutils.command.build_py as orig
4 import os
5 import fnmatch
6 import textwrap
7 import io
8 import distutils.errors
9 import itertools
10 import stat
11 from setuptools.extern.more_itertools import unique_everseen
12 
13 
14 def make_writable(target):
15     os.chmod(target, os.stat(target).st_mode | stat.S_IWRITE)
16 
17 
18 class build_py(orig.build_py):
19     """Enhanced 'build_py' command that includes data files with packages
20 
21     The data files are specified via a 'package_data' argument to 'setup()'.
22     See 'setuptools.dist.Distribution' for more details.
23 
24     Also, this version of the 'build_py' command allows you to specify both
25     'py_modules' and 'packages' in the same setup operation.
26     """
27 
28     def finalize_options(self):
29         orig.build_py.finalize_options(self)
30         self.package_data = self.distribution.package_data
31         self.exclude_package_data = self.distribution.exclude_package_data or {}
32         if 'data_files' in self.__dict__:
33             del self.__dict__['data_files']
34         self.__updated_files = []
35 
36     def run(self):
37         """Build modules, packages, and copy data files to build directory"""
38         if not self.py_modules and not self.packages:
39             return
40 
41         if self.py_modules:
42             self.build_modules()
43 
44         if self.packages:
45             self.build_packages()
46             self.build_package_data()
47 
48         # Only compile actual .py files, using our base class' idea of what our
49         # output files are.
50         self.byte_compile(orig.build_py.get_outputs(self, include_bytecode=0))
51 
52     def __getattr__(self, attr):
53         "lazily compute data files"
54         if attr == 'data_files':
55             self.data_files = self._get_data_files()
56             return self.data_files
57         return orig.build_py.__getattr__(self, attr)
58 
59     def build_module(self, module, module_file, package):
60         outfile, copied = orig.build_py.build_module(self, module, module_file, package)
61         if copied:
62             self.__updated_files.append(outfile)
63         return outfile, copied
64 
65     def _get_data_files(self):
66         """Generate list of '(package,src_dir,build_dir,filenames)' tuples"""
67         self.analyze_manifest()
68         return list(map(self._get_pkg_data_files, self.packages or ()))
69 
70     def get_data_files_without_manifest(self):
71         """
72         Generate list of ``(package,src_dir,build_dir,filenames)`` tuples,
73         but without triggering any attempt to analyze or build the manifest.
74         """
75         # Prevent eventual errors from unset `manifest_files`
76         # (that would otherwise be set by `analyze_manifest`)
77         self.__dict__.setdefault('manifest_files', {})
78         return list(map(self._get_pkg_data_files, self.packages or ()))
79 
80     def _get_pkg_data_files(self, package):
81         # Locate package source directory
82         src_dir = self.get_package_dir(package)
83 
84         # Compute package build directory
85         build_dir = os.path.join(*([self.build_lib] + package.split('.')))
86 
87         # Strip directory from globbed filenames
88         filenames = [
89             os.path.relpath(file, src_dir)
90             for file in self.find_data_files(package, src_dir)
91         ]
92         return package, src_dir, build_dir, filenames
93 
94     def find_data_files(self, package, src_dir):
95         """Return filenames for package's data files in 'src_dir'"""
96         patterns = self._get_platform_patterns(
97             self.package_data,
98             package,
99             src_dir,
100         )
101         globs_expanded = map(glob, patterns)
102         # flatten the expanded globs into an iterable of matches
103         globs_matches = itertools.chain.from_iterable(globs_expanded)
104         glob_files = filter(os.path.isfile, globs_matches)
105         files = itertools.chain(
106             self.manifest_files.get(package, []),
107             glob_files,
108         )
109         return self.exclude_data_files(package, src_dir, files)
110 
111     def build_package_data(self):
112         """Copy data files into build directory"""
113         for package, src_dir, build_dir, filenames in self.data_files:
114             for filename in filenames:
115                 target = os.path.join(build_dir, filename)
116                 self.mkpath(os.path.dirname(target))
117                 srcfile = os.path.join(src_dir, filename)
118                 outf, copied = self.copy_file(srcfile, target)
119                 make_writable(target)
120                 srcfile = os.path.abspath(srcfile)
121 
122     def analyze_manifest(self):
123         self.manifest_files = mf = {}
124         if not self.distribution.include_package_data:
125             return
126         src_dirs = {}
127         for package in self.packages or ():
128             # Locate package source directory
129             src_dirs[assert_relative(self.get_package_dir(package))] = package
130 
131         self.run_command('egg_info')
132         ei_cmd = self.get_finalized_command('egg_info')
133         for path in ei_cmd.filelist.files:
134             d, f = os.path.split(assert_relative(path))
135             prev = None
136             oldf = f
137             while d and d != prev and d not in src_dirs:
138                 prev = d
139                 d, df = os.path.split(d)
140                 f = os.path.join(df, f)
141             if d in src_dirs:
142                 if path.endswith('.py') and f == oldf:
143                     continue  # it's a module, not data
144                 mf.setdefault(src_dirs[d], []).append(path)
145 
146     def get_data_files(self):
147         pass  # Lazily compute data files in _get_data_files() function.
148 
149     def check_package(self, package, package_dir):
150         """Check namespace packages' __init__ for declare_namespace"""
151         try:
152             return self.packages_checked[package]
153         except KeyError:
154             pass
155 
156         init_py = orig.build_py.check_package(self, package, package_dir)
157         self.packages_checked[package] = init_py
158 
159         if not init_py or not self.distribution.namespace_packages:
160             return init_py
161 
162         for pkg in self.distribution.namespace_packages:
163             if pkg == package or pkg.startswith(package + '.'):
164                 break
165         else:
166             return init_py
167 
168         with io.open(init_py, 'rb') as f:
169             contents = f.read()
170         if b'declare_namespace' not in contents:
171             raise distutils.errors.DistutilsError(
172                 "Namespace package problem: %s is a namespace package, but "
173                 "its\n__init__.py does not call declare_namespace()! Please "
174                 'fix it.\n(See the setuptools manual under '
175                 '"Namespace Packages" for details.)\n"' % (package,)
176             )
177         return init_py
178 
179     def initialize_options(self):
180         self.packages_checked = {}
181         orig.build_py.initialize_options(self)
182 
183     def get_package_dir(self, package):
184         res = orig.build_py.get_package_dir(self, package)
185         if self.distribution.src_root is not None:
186             return os.path.join(self.distribution.src_root, res)
187         return res
188 
189     def exclude_data_files(self, package, src_dir, files):
190         """Filter filenames for package's data files in 'src_dir'"""
191         files = list(files)
192         patterns = self._get_platform_patterns(
193             self.exclude_package_data,
194             package,
195             src_dir,
196         )
197         match_groups = (fnmatch.filter(files, pattern) for pattern in patterns)
198         # flatten the groups of matches into an iterable of matches
199         matches = itertools.chain.from_iterable(match_groups)
200         bad = set(matches)
201         keepers = (fn for fn in files if fn not in bad)
202         # ditch dupes
203         return list(unique_everseen(keepers))
204 
205     @staticmethod
206     def _get_platform_patterns(spec, package, src_dir):
207         """
208         yield platform-specific path patterns (suitable for glob
209         or fn_match) from a glob-based spec (such as
210         self.package_data or self.exclude_package_data)
211         matching package in src_dir.
212         """
213         raw_patterns = itertools.chain(
214             spec.get('', []),
215             spec.get(package, []),
216         )
217         return (
218             # Each pattern has to be converted to a platform-specific path
219             os.path.join(src_dir, convert_path(pattern))
220             for pattern in raw_patterns
221         )
222 
223 
224 def assert_relative(path):
225     if not os.path.isabs(path):
226         return path
227     from distutils.errors import DistutilsSetupError
228 
229     msg = (
230         textwrap.dedent(
231             """
232         Error: setup script specifies an absolute path:
233 
234             %s
235 
236         setup() arguments must *always* be /-separated paths relative to the
237         setup.py directory, *never* absolute paths.
238         """
239         ).lstrip()
240         % path
241     )
242     raise DistutilsSetupError(msg)
243