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