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