• 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
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