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