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