• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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