1# Copyright (c) 2020, 2021, Oracle and/or its affiliates.
2#
3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License, version 2.0, as
5# published by the Free Software Foundation.
6#
7# This program is also distributed with certain software (including
8# but not limited to OpenSSL) that is licensed under separate terms,
9# as designated in a particular file or component or in included license
10# documentation.  The authors of MySQL hereby grant you an
11# additional permission to link the program and your derivative works
12# with the separately licensed software that they have included with
13# MySQL.
14#
15# Without limiting anything contained in the foregoing, this file,
16# which is part of MySQL Connector/Python, is also subject to the
17# Universal FOSS Exception, version 1.0, a copy of which can be found at
18# http://oss.oracle.com/licenses/universal-foss-exception.
19#
20# This program is distributed in the hope that it will be useful, but
21# WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
23# See the GNU General Public License, version 2.0, for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program; if not, write to the Free Software Foundation, Inc.,
27# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301  USA
28
29"""Implements the Distutils commands for creating Debian packages."""
30
31import os
32import re
33import subprocess
34import sys
35
36from datetime import datetime
37
38from distutils.file_util import copy_file, move_file
39
40from . import BaseCommand, EDITION, VERSION, VERSION_EXTRA
41from .utils import unarchive_targz, linux_distribution
42
43
44DEBIAN_ROOT = os.path.join("cpydist", "data", "deb")
45DPKG_MAKER = "dpkg-buildpackage"
46LINUX_DIST = linux_distribution()
47VERSION_TEXT_SHORT = "{0}.{1}.{2}".format(*VERSION[0:3])
48
49GPL_LIC_TEXT = """\
50This is a release of MySQL Connector/Python, Oracle's dual-
51 license Python Driver for MySQL. For the avoidance of
52 doubt, this particular copy of the software is released
53 under the version 2 of the GNU General Public License.
54 MySQL Connector/Python is brought to you by Oracle.
55"""
56
57class DistDeb(BaseCommand):
58    """Create a Debian distribution."""
59
60    description = "create a Debian distribution"
61    debian_files = [
62        "changelog",
63        "compat",
64        "control",
65        "copyright",
66        "docs",
67        "mysql-connector-python-py3.postinst",
68        "mysql-connector-python-py3.postrm",
69        "mysql-connector-python.postinst",
70        "mysql-connector-python.postrm",
71        "rules",
72    ]
73    user_options = BaseCommand.user_options + [
74        ("dist-dir=", "d",
75         "directory to put final built distributions in"),
76        ("platform=", "p",
77         "name of the platform in resulting files "
78         "(default '%s')" % LINUX_DIST[0].lower()),
79        ("sign", None,
80         "sign the Debian package"),
81    ]
82    boolean_options = BaseCommand.boolean_options + ["sign"]
83
84    dist_dir = None
85    with_cext = False
86
87    def initialize_options(self):
88        """Initialize the options."""
89        BaseCommand.initialize_options(self)
90
91        self.platform = LINUX_DIST[0].lower()
92        if "debian" in self.platform:
93            # For Debian we only use the first part of the version, Ubuntu two
94            self.platform_version = LINUX_DIST[1].split(".", 2)[0]
95        else:
96            self.platform_version = ".".join(LINUX_DIST[1].split(".", 2)[0:2])
97        self.sign = False
98        self.debian_support_dir = DEBIAN_ROOT
99        self.edition = EDITION
100        self.codename = linux_distribution()[2].lower()
101        self.version_extra = "-{0}".format(VERSION_EXTRA) \
102            if VERSION_EXTRA else ""
103
104    def finalize_options(self):
105        """Finalize the options."""
106        BaseCommand.finalize_options(self)
107
108        cmd_build = self.get_finalized_command("build")
109        self.build_base = cmd_build.build_base
110        if not self.dist_dir:
111            self.dist_dir = "dist"
112        self.with_cext = any((self.with_mysql_capi,
113                              self.with_protobuf_include_dir,
114                              self.with_protobuf_lib_dir, self.with_protoc))
115
116    @property
117    def _have_python3(self):
118        """Check whether this distribution has Python 3 support."""
119        try:
120            devnull = open(os.devnull, "w")
121            subprocess.Popen(["py3versions"],
122                             stdin=devnull,
123                             stdout=devnull,
124                             stderr=devnull)
125        except OSError:
126            return False
127
128        return True
129
130    def _get_orig_name(self):
131        """Return name for tarball according to Debian's policies."""
132        return "%(name)s%(label)s_%(version)s%(version_extra)s.orig" % {
133            "name": self.distribution.get_name(),
134            "label": "-{}".format(self.label) if self.label else "",
135            "version": self.distribution.get_version(),
136            "version_extra": self.version_extra
137        }
138
139    def _get_changes(self):
140        """Get changes from CHANGES.txt."""
141        log_lines = []
142        found_version = False
143        found_items = False
144        with open("CHANGES.txt", "r") as fp:
145            for line in fp.readlines():
146                line = line.rstrip()
147                if line.endswith(VERSION_TEXT_SHORT):
148                    found_version = True
149                if not line.strip() and found_items:
150                    break
151                elif found_version and line.startswith("- "):
152                    log_lines.append(" " * 2 + "* " + line[2:])
153                    found_items = True
154
155        return log_lines
156
157    def _populate_debian(self):
158        """Copy and make files ready in the debian/ folder."""
159        for afile in self.debian_files:
160            copy_file(os.path.join(self.debian_support_dir, afile),
161                      self.debian_base)
162
163        copy_file(os.path.join(self.debian_support_dir, "source", "format"),
164                  os.path.join(self.debian_base, "source"))
165
166        # Update the version and log in the Debian changelog
167        changelog_file = os.path.join(self.debian_base, "changelog")
168        with open(changelog_file, "r") as fp:
169            changelog = fp.readlines()
170        self.log.info("changing changelog '%s' version and log",
171                      changelog_file)
172
173        log_lines = self._get_changes()
174        if not log_lines:
175            self.log.error("Failed reading change history from CHANGES.txt")
176            log_lines.append("  * (change history missing)")
177
178        new_changelog = []
179        first_line = True
180        regex = re.compile(r".*\((\d+\.\d+.\d+-1)\).*")
181        for line in changelog:
182            line = line.rstrip()
183            match = regex.match(line)
184            if match:
185                version = match.groups()[0]
186                line = line.replace(version,
187                                    "{0}.{1}.{2}-1".format(*VERSION[0:3]))
188            if first_line:
189                if self.codename == "":
190                    proc = subprocess.Popen(["lsb_release", "-c"],
191                                            stdout=subprocess.PIPE,
192                                            stderr=subprocess.STDOUT)
193                    codename = proc.stdout.read().split()[-1]
194                    self.codename = codename.decode() \
195                        if sys.version_info[0] == 3 else codename
196                if self.label:
197                    line = line.replace(
198                        "mysql-connector-python",
199                        "mysql-connector-python-{}".format(self.label))
200                line = line.replace("UNRELEASED", self.codename)
201                line = line.replace("-1",
202                                    "{version_extra}-1{platform}{version}"
203                                    .format(platform=self.platform,
204                                            version=self.platform_version,
205                                            version_extra=self.version_extra))
206                first_line = False
207            if "* Changes here." in line:
208                for change in log_lines:
209                    new_changelog.append(change)
210            elif line.startswith(" --") and "@" in line:
211                utcnow = datetime.utcnow().strftime(
212                    "%a, %d %b %Y %H:%M:%S +0000")
213                line = re.sub(r"( -- .* <.*@.*>  ).*", r"\1" + utcnow, line)
214                new_changelog.append(line + "\n")
215            else:
216                new_changelog.append(line)
217
218        with open(changelog_file, "w") as changelog:
219            changelog.write("\n".join(new_changelog))
220
221        control_file = os.path.join(self.debian_base, "control")
222        if self.label:
223            # Update the Source, Package and Conflicts fields
224            # in control file, if self.label is present
225            with open(control_file, "r") as fp:
226                control = fp.readlines()
227
228            self.log.info("changing control '%s' Source, Package and Conflicts fields",
229                          control_file)
230
231            new_control = []
232            add_label_regex = re.compile(r"^((?:Source|Package): mysql-connector-python)")
233            remove_label_regex = re.compile("^(Conflicts: .*?)-{}".format(self.label))
234            for line in control:
235                line = line.rstrip()
236
237                match = add_label_regex.match(line)
238                if match:
239                    line = add_label_regex.sub(r"\1-{}".format(self.label), line)
240
241                match = remove_label_regex.match(line)
242                if match:
243                    line = remove_label_regex.sub(r"\1", line)
244
245                new_control.append(line)
246
247            with open(control_file, "w") as fp:
248                fp.write("\n".join(new_control))
249
250        lic_text = GPL_LIC_TEXT
251
252        if self.byte_code_only:
253            copyright_file = os.path.join(self.debian_base, "copyright")
254            self.log.info("Reading license text from copyright file ({})"
255                          "".format(copyright_file))
256            with open(copyright_file, "r") as fp:
257                # Skip to line just before the text we want to copy
258                while True:
259                    line = fp.readline()
260                    if not line:
261                        break
262                    if line.startswith("License: Commercial"):
263                        # Read the rest of the text
264                        lic_text = fp.read()
265
266        with open(control_file, "r") as fp:
267            control_text = fp.read()
268
269        self.log.info("Updating license text in control file")
270        new_control = re.sub(r"@LICENSE@", lic_text, control_text)
271        with open(control_file, "w") as fp:
272            fp.write(new_control)
273
274    def _prepare(self, tarball=None, base=None):
275        """Prepare Debian files."""
276        # Rename tarball to conform Debian's Policy
277        if tarball:
278            self.orig_tarball = os.path.join(
279                os.path.dirname(tarball),
280                self._get_orig_name()) + ".tar.gz"
281            move_file(tarball, self.orig_tarball)
282
283            unarchive_targz(self.orig_tarball)
284            self.debian_base = os.path.join(
285                tarball.replace(".tar.gz", ""), "debian")
286        elif base:
287            self.debian_base = os.path.join(base, "debian")
288
289        self.mkpath(self.debian_base)
290        self.mkpath(os.path.join(self.debian_base, "source"))
291        self._populate_debian()
292
293    def _make_dpkg(self):
294        """Create Debian package in the source distribution folder."""
295        self.log.info("creating Debian package using '%s'", DPKG_MAKER)
296
297        orig_pwd = os.getcwd()
298        os.chdir(os.path.join(self.build_base,
299                 self.distribution.get_fullname()))
300        cmd = [DPKG_MAKER, "-uc"]
301
302        if not self.sign:
303            cmd.append("-us")
304
305        success = True
306        env = os.environ.copy()
307        env["MYSQL_CAPI"] = self.with_mysql_capi or ""
308        env["OPENSSL_INCLUDE_DIR"] = self.with_openssl_include_dir or ""
309        env["OPENSSL_LIB_DIR"] = self.with_openssl_lib_dir or ""
310        env["MYSQLXPB_PROTOBUF_INCLUDE_DIR"] = \
311            self.with_protobuf_include_dir or ""
312        env["MYSQLXPB_PROTOBUF_LIB_DIR"] = self.with_protobuf_lib_dir or ""
313        env["MYSQLXPB_PROTOC"] = self.with_protoc or ""
314        env["WITH_CEXT"] = "1" if self.with_cext else ""
315        env["EXTRA_COMPILE_ARGS"] = self.extra_compile_args or ""
316        env["EXTRA_LINK_ARGS"] = self.extra_link_args or ""
317        env["LABEL"] = self.label if self.label else "0"
318        env["BYTE_CODE_ONLY"] = "1" if self.byte_code_only else ""
319        proc = subprocess.Popen(cmd,
320                                stdout=subprocess.PIPE,
321                                stderr=subprocess.PIPE,
322                                universal_newlines=True,
323                                env=env)
324        stdout, stderr = proc.communicate()
325        for line in stdout.split("\n"):
326            if self.debug:
327                self.log.info(line)
328            if "error:" in line or "E: " in line:
329                if not self.debug:
330                    self.log.info(line)
331                success = False
332
333        if stderr:
334            for line in stderr.split("\n"):
335                if self.debug:
336                    self.log.info(line)
337                if "error:" in line or "E: " in line:
338                    if not self.debug:
339                        self.log.info(line)
340                    success = False
341
342        os.chdir(orig_pwd)
343        return success
344
345    def _move_to_dist(self):
346        """Move *.deb files to dist/ (dist_dir) folder."""
347        for base, dirs, files in os.walk(self.build_base):
348            for filename in files:
349                if "-py3" in filename and not self._have_python3:
350                    continue
351                if not self.with_mysql_capi and "cext" in filename:
352                    continue
353                if filename.endswith(".deb"):
354                    filepath = os.path.join(base, filename)
355                    copy_file(filepath, self.dist_dir)
356
357    def run(self):
358        """Run the command."""
359        self.mkpath(self.dist_dir)
360
361        sdist = self.reinitialize_command("sdist")
362        sdist.dist_dir = self.build_base
363        sdist.formats = ["gztar"]
364        sdist.label = self.label
365        sdist.ensure_finalized()
366        sdist.run()
367
368        self._prepare(sdist.archive_files[0])
369        success = self._make_dpkg()
370
371        if not success:
372            self.log.error("Building Debian package failed")
373        else:
374            self._move_to_dist()
375
376        self.remove_temp()
377