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