xref: /aosp_15_r20/external/fonttools/setup.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1#! /usr/bin/env python3
2
3from __future__ import print_function
4import io
5import sys
6import os
7from os.path import isfile, join as pjoin
8from glob import glob
9from setuptools import setup, find_packages, Command, Extension
10from setuptools.command.build_ext import build_ext as _build_ext
11from distutils import log
12from distutils.util import convert_path
13import subprocess as sp
14import contextlib
15import re
16
17# Force distutils to use py_compile.compile() function with 'doraise' argument
18# set to True, in order to raise an exception on compilation errors
19import py_compile
20
21orig_py_compile = py_compile.compile
22
23
24def doraise_py_compile(file, cfile=None, dfile=None, doraise=False):
25    orig_py_compile(file, cfile=cfile, dfile=dfile, doraise=True)
26
27
28py_compile.compile = doraise_py_compile
29
30setup_requires = []
31
32if {"bdist_wheel"}.intersection(sys.argv):
33    setup_requires.append("wheel")
34
35if {"release"}.intersection(sys.argv):
36    setup_requires.append("bump2version")
37
38try:
39    __import__("cython")
40except ImportError:
41    has_cython = False
42else:
43    has_cython = True
44
45env_with_cython = os.environ.get("FONTTOOLS_WITH_CYTHON")
46with_cython = (
47    True
48    if env_with_cython in {"1", "true", "yes"}
49    else False if env_with_cython in {"0", "false", "no"} else None
50)
51# --with-cython/--without-cython options override environment variables
52opt_with_cython = {"--with-cython"}.intersection(sys.argv)
53opt_without_cython = {"--without-cython"}.intersection(sys.argv)
54if opt_with_cython and opt_without_cython:
55    sys.exit(
56        "error: the options '--with-cython' and '--without-cython' are "
57        "mutually exclusive"
58    )
59elif opt_with_cython:
60    sys.argv.remove("--with-cython")
61    with_cython = True
62elif opt_without_cython:
63    sys.argv.remove("--without-cython")
64    with_cython = False
65
66if with_cython and not has_cython:
67    setup_requires.append("cython")
68
69ext_modules = []
70if with_cython is True or (with_cython is None and has_cython):
71    ext_modules.append(
72        Extension("fontTools.cu2qu.cu2qu", ["Lib/fontTools/cu2qu/cu2qu.py"]),
73    )
74    ext_modules.append(
75        Extension("fontTools.qu2cu.qu2cu", ["Lib/fontTools/qu2cu/qu2cu.py"]),
76    )
77    ext_modules.append(
78        Extension("fontTools.misc.bezierTools", ["Lib/fontTools/misc/bezierTools.py"]),
79    )
80    ext_modules.append(
81        Extension("fontTools.pens.momentsPen", ["Lib/fontTools/pens/momentsPen.py"]),
82    )
83    ext_modules.append(
84        Extension("fontTools.varLib.iup", ["Lib/fontTools/varLib/iup.py"]),
85    )
86    ext_modules.append(
87        Extension("fontTools.feaLib.lexer", ["Lib/fontTools/feaLib/lexer.py"]),
88    )
89
90extras_require = {
91    # for fontTools.ufoLib: to read/write UFO fonts
92    "ufo": [
93        "fs >= 2.2.0, < 3",
94    ],
95    # for fontTools.misc.etree and fontTools.misc.plistlib: use lxml to
96    # read/write XML files (faster/safer than built-in ElementTree)
97    "lxml": [
98        "lxml >= 4.0",
99    ],
100    # for fontTools.sfnt and fontTools.woff2: to compress/uncompress
101    # WOFF 1.0 and WOFF 2.0 webfonts.
102    "woff": [
103        "brotli >= 1.0.1; platform_python_implementation == 'CPython'",
104        "brotlicffi >= 0.8.0; platform_python_implementation != 'CPython'",
105        "zopfli >= 0.1.4",
106    ],
107    # for fontTools.unicode and fontTools.unicodedata: to use the latest version
108    # of the Unicode Character Database instead of the built-in unicodedata
109    # which varies between python versions and may be outdated.
110    "unicode": [
111        ("unicodedata2 >= 15.1.0; python_version <= '3.12'"),
112    ],
113    # for graphite type tables in ttLib/tables (Silf, Glat, Gloc)
114    "graphite": ["lz4 >= 1.7.4.2"],
115    # for fontTools.interpolatable: to solve the "minimum weight perfect
116    # matching problem in bipartite graphs" (aka Assignment problem)
117    "interpolatable": [
118        # use pure-python alternative on pypy
119        "scipy; platform_python_implementation != 'PyPy'",
120        "munkres; platform_python_implementation == 'PyPy'",
121        # to output PDF or HTML reports. NOTE: wheels are only available for
122        # windows currently, other platforms will need to build from source.
123        "pycairo",
124    ],
125    # for fontTools.varLib.plot, to visualize DesignSpaceDocument and resulting
126    # VariationModel
127    "plot": [
128        # TODO: figure out the minimum version of matplotlib that we need
129        "matplotlib",
130    ],
131    # for fontTools.misc.symfont, module for symbolic font statistics analysis
132    "symfont": [
133        "sympy",
134    ],
135    # To get file creator and type of Macintosh PostScript Type 1 fonts (macOS only)
136    "type1": [
137        "xattr; sys_platform == 'darwin'",
138    ],
139    # for fontTools.ttLib.removeOverlaps, to remove overlaps in TTF fonts
140    "pathops": [
141        "skia-pathops >= 0.5.0",
142    ],
143    # for packing GSUB/GPOS tables with Harfbuzz repacker
144    "repacker": [
145        "uharfbuzz >= 0.23.0",
146    ],
147}
148# use a special 'all' key as shorthand to includes all the extra dependencies
149extras_require["all"] = sum(extras_require.values(), [])
150
151
152# Trove classifiers for PyPI
153classifiers = {
154    "classifiers": [
155        "Development Status :: 5 - Production/Stable",
156        "Environment :: Console",
157        "Environment :: Other Environment",
158        "Intended Audience :: Developers",
159        "Intended Audience :: End Users/Desktop",
160        "License :: OSI Approved :: MIT License",
161        "Natural Language :: English",
162        "Operating System :: OS Independent",
163        "Programming Language :: Python",
164        "Programming Language :: Python :: 3.8",
165        "Programming Language :: Python :: 3.9",
166        "Programming Language :: Python :: 3.10",
167        "Programming Language :: Python :: 3.11",
168        "Programming Language :: Python :: 3.12",
169        "Programming Language :: Python :: 3",
170        "Topic :: Text Processing :: Fonts",
171        "Topic :: Multimedia :: Graphics",
172        "Topic :: Multimedia :: Graphics :: Graphics Conversion",
173    ]
174}
175
176
177# concatenate README.rst and NEWS.rest into long_description so they are
178# displayed on the FontTols project page on PyPI
179with io.open("README.rst", "r", encoding="utf-8") as readme:
180    long_description = readme.read()
181long_description += "\nChangelog\n~~~~~~~~~\n\n"
182with io.open("NEWS.rst", "r", encoding="utf-8") as changelog:
183    long_description += changelog.read()
184
185
186@contextlib.contextmanager
187def capture_logger(name):
188    """Context manager to capture a logger output with a StringIO stream."""
189    import logging
190
191    logger = logging.getLogger(name)
192    try:
193        import StringIO
194
195        stream = StringIO.StringIO()
196    except ImportError:
197        stream = io.StringIO()
198    handler = logging.StreamHandler(stream)
199    logger.addHandler(handler)
200    try:
201        yield stream
202    finally:
203        logger.removeHandler(handler)
204
205
206class release(Command):
207    """
208    Tag a new release with a single command, using the 'bumpversion' tool
209    to update all the version strings in the source code.
210    The version scheme conforms to 'SemVer' and PEP 440 specifications.
211
212    Firstly, the pre-release '.devN' suffix is dropped to signal that this is
213    a stable release. If '--major' or '--minor' options are passed, the
214    the first or second 'semver' digit is also incremented. Major is usually
215    for backward-incompatible API changes, while minor is used when adding
216    new backward-compatible functionalities. No options imply 'patch' or bug-fix
217    release.
218
219    A new header is also added to the changelog file ("NEWS.rst"), containing
220    the new version string and the current 'YYYY-MM-DD' date.
221
222    All changes are committed, and an annotated git tag is generated. With the
223    --sign option, the tag is GPG-signed with the user's default key.
224
225    Finally, the 'patch' part of the version string is bumped again, and a
226    pre-release suffix '.dev0' is appended to mark the opening of a new
227    development cycle.
228
229    Links:
230    - http://semver.org/
231    - https://www.python.org/dev/peps/pep-0440/
232    - https://github.com/c4urself/bump2version
233    """
234
235    description = "update version strings for release"
236
237    user_options = [
238        ("major", None, "bump the first digit (incompatible API changes)"),
239        ("minor", None, "bump the second digit (new backward-compatible features)"),
240        ("sign", "s", "make a GPG-signed tag, using the default key"),
241        ("allow-dirty", None, "don't abort if working directory is dirty"),
242    ]
243
244    changelog_name = "NEWS.rst"
245    version_RE = re.compile(r"^[0-9]+\.[0-9]+")
246    date_fmt = "%Y-%m-%d"
247    header_fmt = "%s (released %s)"
248    commit_message = "Release {new_version}"
249    tag_name = "{new_version}"
250    version_files = [
251        "setup.cfg",
252        "setup.py",
253        "Lib/fontTools/__init__.py",
254    ]
255
256    def initialize_options(self):
257        self.minor = False
258        self.major = False
259        self.sign = False
260        self.allow_dirty = False
261
262    def finalize_options(self):
263        if all([self.major, self.minor]):
264            from distutils.errors import DistutilsOptionError
265
266            raise DistutilsOptionError("--major/--minor are mutually exclusive")
267        self.part = "major" if self.major else "minor" if self.minor else None
268
269    def run(self):
270        if self.part is not None:
271            log.info("bumping '%s' version" % self.part)
272            self.bumpversion(self.part, commit=False)
273            release_version = self.bumpversion(
274                "release", commit=False, allow_dirty=True
275            )
276        else:
277            log.info("stripping pre-release suffix")
278            release_version = self.bumpversion("release")
279        log.info("  version = %s" % release_version)
280
281        changes = self.format_changelog(release_version)
282
283        self.git_commit(release_version)
284        self.git_tag(release_version, changes, self.sign)
285
286        log.info("bumping 'patch' version and pre-release suffix")
287        next_dev_version = self.bumpversion("patch", commit=True)
288        log.info("  version = %s" % next_dev_version)
289
290    def git_commit(self, version):
291        """Stage and commit all relevant version files, and format the commit
292        message with specified 'version' string.
293        """
294        files = self.version_files + [self.changelog_name]
295
296        log.info("committing changes")
297        for f in files:
298            log.info("  %s" % f)
299        if self.dry_run:
300            return
301        sp.check_call(["git", "add"] + files)
302        msg = self.commit_message.format(new_version=version)
303        sp.check_call(["git", "commit", "-m", msg], stdout=sp.PIPE)
304
305    def git_tag(self, version, message, sign=False):
306        """Create annotated git tag with given 'version' and 'message'.
307        Optionally 'sign' the tag with the user's GPG key.
308        """
309        log.info(
310            "creating %s git tag '%s'" % ("signed" if sign else "annotated", version)
311        )
312        if self.dry_run:
313            return
314        # create an annotated (or signed) tag from the new version
315        tag_opt = "-s" if sign else "-a"
316        tag_name = self.tag_name.format(new_version=version)
317        proc = sp.Popen(["git", "tag", tag_opt, "-F", "-", tag_name], stdin=sp.PIPE)
318        # use the latest changes from the changelog file as the tag message
319        tag_message = "%s\n\n%s" % (tag_name, message)
320        proc.communicate(tag_message.encode("utf-8"))
321        if proc.returncode != 0:
322            sys.exit(proc.returncode)
323
324    def bumpversion(self, part, commit=False, message=None, allow_dirty=None):
325        """Run bumpversion.main() with the specified arguments, and return the
326        new computed version string (cf. 'bumpversion --help' for more info)
327        """
328        import bumpversion.cli
329
330        args = (
331            (["--verbose"] if self.verbose > 1 else [])
332            + (["--dry-run"] if self.dry_run else [])
333            + (["--allow-dirty"] if (allow_dirty or self.allow_dirty) else [])
334            + (["--commit"] if commit else ["--no-commit"])
335            + (["--message", message] if message is not None else [])
336            + ["--list", part]
337        )
338        log.debug("$ bumpversion %s" % " ".join(a.replace(" ", "\\ ") for a in args))
339
340        with capture_logger("bumpversion.list") as out:
341            bumpversion.cli.main(args)
342
343        last_line = out.getvalue().splitlines()[-1]
344        new_version = last_line.replace("new_version=", "")
345        return new_version
346
347    def format_changelog(self, version):
348        """Write new header at beginning of changelog file with the specified
349        'version' and the current date.
350        Return the changelog content for the current release.
351        """
352        from datetime import datetime
353
354        log.info("formatting changelog")
355
356        changes = []
357        with io.open(self.changelog_name, "r+", encoding="utf-8") as f:
358            for ln in f:
359                if self.version_RE.match(ln):
360                    break
361                else:
362                    changes.append(ln)
363            if not self.dry_run:
364                f.seek(0)
365                content = f.read()
366                date = datetime.today().strftime(self.date_fmt)
367                f.seek(0)
368                header = self.header_fmt % (version, date)
369                f.write(header + "\n" + "-" * len(header) + "\n\n" + content)
370
371        return "".join(changes)
372
373
374def find_data_files(manpath="share/man"):
375    """Find FontTools's data_files (just man pages at this point).
376
377    By default, we install man pages to "share/man" directory relative to the
378    base installation directory for data_files. The latter can be changed with
379    the --install-data option of 'setup.py install' sub-command.
380
381    E.g., if the data files installation directory is "/usr", the default man
382    page installation directory will be "/usr/share/man".
383
384    You can override this via the $FONTTOOLS_MANPATH environment variable.
385
386    E.g., on some BSD systems man pages are installed to 'man' instead of
387    'share/man'; you can export $FONTTOOLS_MANPATH variable just before
388    installing:
389
390    $ FONTTOOLS_MANPATH="man" pip install -v .
391        [...]
392        running install_data
393        copying Doc/man/ttx.1 -> /usr/man/man1
394
395    When installing from PyPI, for this variable to have effect you need to
396    force pip to install from the source distribution instead of the wheel
397    package (otherwise setup.py is not run), by using the --no-binary option:
398
399    $ FONTTOOLS_MANPATH="man" pip install --no-binary=fonttools fonttools
400
401    Note that you can only override the base man path, i.e. without the
402    section number (man1, man3, etc.). The latter is always implied to be 1,
403    for "general commands".
404    """
405
406    # get base installation directory for man pages
407    manpagebase = os.environ.get("FONTTOOLS_MANPATH", convert_path(manpath))
408    # all our man pages go to section 1
409    manpagedir = pjoin(manpagebase, "man1")
410
411    manpages = [f for f in glob(pjoin("Doc", "man", "man1", "*.1")) if isfile(f)]
412
413    data_files = [(manpagedir, manpages)]
414    return data_files
415
416
417class cython_build_ext(_build_ext):
418    """Compile *.pyx source files to *.c using cythonize if Cython is
419    installed and there is a working C compiler, else fall back to pure python dist.
420    """
421
422    def finalize_options(self):
423        from Cython.Build import cythonize
424
425        # optionally enable line tracing for test coverage support
426        linetrace = os.environ.get("CYTHON_TRACE") == "1"
427
428        self.distribution.ext_modules[:] = cythonize(
429            self.distribution.ext_modules,
430            force=linetrace or self.force,
431            annotate=os.environ.get("CYTHON_ANNOTATE") == "1",
432            quiet=not self.verbose,
433            compiler_directives={
434                "linetrace": linetrace,
435                "language_level": 3,
436                "embedsignature": True,
437            },
438        )
439
440        _build_ext.finalize_options(self)
441
442    def build_extensions(self):
443        try:
444            _build_ext.build_extensions(self)
445        except Exception as e:
446            if with_cython:
447                raise
448            from distutils.errors import DistutilsModuleError
449
450            # optional compilation failed: we delete 'ext_modules' and make sure
451            # the generated wheel is 'pure'
452            del self.distribution.ext_modules[:]
453            try:
454                bdist_wheel = self.get_finalized_command("bdist_wheel")
455            except DistutilsModuleError:
456                # 'bdist_wheel' command not available as wheel is not installed
457                pass
458            else:
459                bdist_wheel.root_is_pure = True
460            log.error("error: building extensions failed: %s" % e)
461
462
463cmdclass = {"release": release}
464
465if ext_modules:
466    cmdclass["build_ext"] = cython_build_ext
467
468
469setup_params = dict(
470    name="fonttools",
471    version="4.49.0",
472    description="Tools to manipulate font files",
473    author="Just van Rossum",
474    author_email="[email protected]",
475    maintainer="Behdad Esfahbod",
476    maintainer_email="[email protected]",
477    url="http://github.com/fonttools/fonttools",
478    license="MIT",
479    platforms=["Any"],
480    python_requires=">=3.8",
481    long_description=long_description,
482    package_dir={"": "Lib"},
483    packages=find_packages("Lib"),
484    include_package_data=True,
485    data_files=find_data_files(),
486    ext_modules=ext_modules,
487    setup_requires=setup_requires,
488    extras_require=extras_require,
489    entry_points={
490        "console_scripts": [
491            "fonttools = fontTools.__main__:main",
492            "ttx = fontTools.ttx:main",
493            "pyftsubset = fontTools.subset:main",
494            "pyftmerge = fontTools.merge:main",
495        ]
496    },
497    cmdclass=cmdclass,
498    **classifiers,
499)
500
501
502if __name__ == "__main__":
503    setup(**setup_params)
504