1from glob import glob
2from distutils.util import convert_path
3import distutils.command.build_py as orig
4import os
5import fnmatch
6import textwrap
7import io
8import distutils.errors
9import itertools
10import stat
11from setuptools.extern.more_itertools import unique_everseen
12
13
14def make_writable(target):
15    os.chmod(target, os.stat(target).st_mode | stat.S_IWRITE)
16
17
18class 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
224def 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