1#!/usr/local/bin/python3.8
2
3# Copyright 2013-2016 the openage authors. See copying.md for legal info.
4
5"""
6openage autocancer-like cmake frontend.
7
8Together with the Makefile, ./configure provides an autotools-like build
9experience. For more info, see --help and doc/buildsystem.
10"""
11
12import argparse
13import os
14import shlex
15import shutil
16import subprocess
17import sys
18
19if sys.version_info < (3, 4):
20    print("openage requires Python 3.4 or higher")
21    exit(1)
22
23
24# argparsing
25DESCRIPTION = """./configure is a convenience script:
26it creates the build directory,  symlinks it,
27and invokes cmake for an out-of-source build.
28
29Nobody is stopping you from skipping ./configure and our Makefile,
30and using CMake directly (e.g. when packaging, or using an IDE).
31For your convenience, ./configure even prints the direct CMake invocation!"""
32
33EPILOG = """environment variables like CXX, CXXFLAGS, LDFLAGS are honored, \
34but overwritten by command-line arguments."""
35
36
37def getenv(*varnames, default=""):
38    """
39    fetches an environment variable.
40    tries all given varnames until it finds an existing one.
41    if none fits, returns default.
42    """
43    for var in varnames:
44        if var in os.environ:
45            return os.environ[var]
46
47    return default
48
49
50def getenv_bool(varname):
51    """
52    fetches a "boolean" environment variable.
53    """
54    value = os.environ.get(varname)
55    if isinstance(value, str):
56        if value.lower() in {"0", "false", "no", "off", "n"}:
57            value = False
58
59    return bool(value)
60
61
62# available optional features
63# this defines the default activation for those:
64# if_available: enable if it was found
65# True:         enable feature
66# False:        disable feature
67# This 3-state activation allows distros to control the features definitively
68# but independent compilations may still have autodetection.
69OPTIONS = {
70    "backtrace": "if_available",
71    "inotify": "if_available",
72    "gperftools-tcmalloc": False,
73    "gperftools-profiler": "if_available",
74}
75
76
77def features(args, parser):
78    """
79    Enable or disable optional features.
80    If a feature is not explicitly enabled/disabled,
81    the defaults below will be used.
82    """
83
84
85    def sanitize_option_name(option):
86        """ Check if the given feature exists """
87        if option not in OPTIONS:
88            parser.error("unknown feature: '{}'.\n"
89                         "available features:\n   {}".format(
90                             option, '\n   '.join(OPTIONS)))
91
92    options = OPTIONS.copy()
93
94    if args.with_:
95        for arg in args.with_:
96            sanitize_option_name(arg)
97            options[arg] = True
98
99    if args.without:
100        for arg in args.without:
101            sanitize_option_name(arg)
102            options[arg] = False
103
104    return options
105
106
107def build_type(args):
108    """ Set the cmake build type """
109    mode = args.mode
110    if mode == 'debug':
111        ret = 'Debug'
112    elif mode == 'release':
113        ret = 'Release'
114
115    return {
116        "build_type": ret
117    }
118
119
120def get_compiler(args, parser):
121    """
122    Compute the compiler executable name
123    """
124
125    # determine compiler binaries from args.compiler
126    if args.compiler:
127        # map alias -> actual compiler
128        aliases = {
129            "clang": "clang++",
130            "gcc": "g++",
131        }
132
133        cxx = args.compiler
134
135        # try to replace aliases
136        if cxx in aliases:
137            cxx = aliases[cxx]
138
139    else:
140        # CXX has not been specified
141        if sys.platform.startswith('darwin'):
142            cxx = 'clang++'
143        else:
144            # default to gnu compiler suite
145            cxx = 'g++'
146
147    # test whether the specified compiler actually exists
148    if not shutil.which(cxx):
149        parser.error('could not find c++ compiler executable: %s' % cxx)
150
151    return {
152        "cxx_compiler": cxx,
153        "cxx_flags": args.flags,
154        "exe_linker_flags": args.ldflags,
155        "module_linker_flags": args.ldflags,
156        "shared_linker_flags": args.ldflags,
157    }
158
159
160def get_install_prefixes(args):
161    """
162    Determine the install prefix configuration.
163    """
164
165    ret = {
166        "install_prefix": args.prefix,
167    }
168
169    if args.py_prefix is not None:
170        ret["py_install_prefix"] = args.py_prefix
171
172    return ret
173
174
175def bindir_creation(args, defines):
176    """
177    configuration for the sanitizer addons for gcc and clang.
178    """
179
180    def sanitize_for_filename(txt, fallback='-'):
181        """
182        sanitizes a string for safe usage in a filename
183        """
184
185        def yieldsanitizedchars():
186            """ generator for sanitizing the output folder name """
187            # False if the previous char was regular.
188            fallingback = True
189            for char in txt:
190                if char == fallback and fallingback:
191                    fallingback = False
192                elif char.isalnum() or char in "+-_,":
193                    fallingback = False
194                    yield char
195                elif not fallingback:
196                    fallingback = True
197                    yield fallback
198
199        return "".join(yieldsanitizedchars())
200
201    bindir = ".bin/%s-%s-%s" % (
202        sanitize_for_filename(defines["cxx_compiler"]),
203        sanitize_for_filename(args.mode),
204        sanitize_for_filename("-O%s -sanitize=%s" % (
205            args.optimize, args.sanitize)))
206
207    if not args.dry_run:
208        os.makedirs(bindir, exist_ok=True)
209
210
211    def forcesymlink(linkto, name):
212        """ similar in function to ln -sf """
213        if args.dry_run:
214            return
215
216        try:
217            os.unlink(name)
218        except FileNotFoundError:
219            pass
220
221        os.symlink(linkto, name)
222
223    # create the build dir and symlink it to 'bin'
224    forcesymlink(bindir, 'bin')
225
226    return bindir
227
228
229def invoke_cmake(args, bindir, defines, options):
230    """
231    run cmake.
232    """
233
234    # the project root directory contains this configure file.
235    project_root = os.path.dirname(os.path.realpath(__file__))
236
237    # calculate cmake invocation from defines dict
238    invocation = [args.cmake_binary]
239    maxkeylen = max(len(k) for k in defines)
240    for key, val in sorted(defines.items()):
241        print('%s | %s' % (key.rjust(maxkeylen), val))
242
243        if key in {'cxx_compiler',}:
244            # work around this cmake 'feature':
245            # when run in an existing build directory, if CXX is given,
246            # all other arguments are ignored... this is retarded.
247            if os.path.exists(os.path.join(bindir, 'CMakeCache.txt')):
248                continue
249
250        invocation.append('-DCMAKE_%s=%s' % (key.upper(), shlex.quote(val)))
251
252    if args.ninja:
253        invocation.extend(['-G', 'Ninja'])
254
255    if args.ccache:
256        invocation.append('-DENABLE_CCACHE=ON')
257
258    if args.clang_tidy:
259        invocation.append('-DENABLE_CLANG_TIDY=ON')
260
261    if args.download_nyan:
262        invocation.append("-DDOWNLOAD_NYAN=YES")
263
264    cxx_options = dict()
265    cxx_options["CXX_OPTIMIZATION_LEVEL"] = args.optimize
266    cxx_options["CXX_SANITIZE_MODE"] = args.sanitize
267    cxx_options["CXX_SANITIZE_FATAL"] = args.sanitize_fatal
268    for key, val in sorted(cxx_options.items()):
269        invocation.append('-D%s=%s' % (key, val))
270
271    print("\nconfig options:\n")
272
273    maxkeylen = max(len(k) for k in options)
274    for key, val in sorted(options.items()):
275        print('%s | %s' % (key.rjust(maxkeylen), val))
276
277        invocation.append('-DWANT_%s=%s' % (
278            key.upper().replace('-', '_'), val))
279
280    for raw_cmake_arg in args.raw_cmake_args:
281        if raw_cmake_arg == "--":
282            continue
283
284        invocation.append(raw_cmake_arg)
285
286    invocation.append('--')
287    invocation.append(project_root)
288
289    # look for traces of an in-source build
290
291    if os.path.isfile('CMakeCache.txt'):
292        print("\nwarning: found traces of an in-source build.")
293        print("CMakeCache.txt was deleted to make building possible.")
294        print("run 'make cleaninsourcebuild' to fully wipe the traces.")
295        os.remove('CMakeCache.txt')
296
297    # switch to build directory
298    print('\nbindir:\n%s/\n' % os.path.join(project_root, bindir))
299    if not args.dry_run:
300        os.chdir(bindir)
301
302    # invoke cmake
303    try:
304        print('invocation:\n%s\n' % ' '.join(invocation))
305        if args.dry_run:
306            exit(0)
307        else:
308            print("(now running cmake:)\n")
309            exit(subprocess.call(invocation))
310    except FileNotFoundError:
311        print("cmake was not found")
312        exit(1)
313
314
315def main(args, parser):
316    """
317    Compose the cmake invocation.
318    Basically does what many distro package managers do as well.
319    """
320
321    try:
322        subprocess.call(['cowsay', '--', DESCRIPTION])
323        print("")
324    except (FileNotFoundError, PermissionError):
325        print(DESCRIPTION)
326        print("")
327
328    defines = {}
329
330    options = features(args, parser)
331    defines.update(build_type(args))
332    defines.update(get_compiler(args, parser))
333    defines.update(get_install_prefixes(args))
334
335    bindir = bindir_creation(args, defines)
336    invoke_cmake(args, bindir, defines, options)
337
338
339def parse_args():
340    """ argument parsing """
341
342    cli = argparse.ArgumentParser(
343        description=DESCRIPTION,
344        epilog=EPILOG,
345        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
346
347    cli.add_argument("--mode", "-m",
348                     choices=["debug", "release"],
349                     default=getenv("BUILDMODE", default="debug"),
350                     help="controls cmake build mode")
351    cli.add_argument("--optimize", "-O",
352                     choices=["auto", "0", "1", "g", "2", "3", "max"],
353                     default=getenv("OPTIMIZE", default="auto"),
354                     help=("controls optimization-related flags. " +
355                           "is set according to mode if 'auto'. " +
356                           "conflicts with --flags"))
357    cli.add_argument("--sanitize",
358                     choices=["none", "yes", "mem", "thread"],
359                     default=getenv("SANITIZER", default="none"),
360                     help=("enable one of those (run-time) code sanitizers."
361                           "'yes' enables the address and "
362                           "undefined sanitizers."))
363    cli.add_argument("--sanitize-fatal", action='store_true',
364                     default=getenv_bool("SANITIZER_FATAL"),
365                     help="With --sanitize, stop execution on first problem.")
366    cli.add_argument("--compiler", "-c",
367                     default=getenv("CXX"),
368                     help="c++ compiler executable, default=$ENV[CXX]")
369    cli.add_argument("--with", action='append', dest='with_', metavar='OPTION',
370                     help="enable optional functionality. "
371                          "for a list of available features, "
372                          "use --list-options")
373    cli.add_argument("--without", action='append', metavar='OPTION',
374                     help="disable optional functionality. "
375                          "for a list of available features, "
376                          "use --list-options")
377    cli.add_argument("--list-options", action="store_true",
378                     help="list available optional feature switches")
379    cli.add_argument("--flags", "-f",
380                     default=getenv("CXXFLAGS", "CCFLAGS", "CFLAGS"),
381                     help="compiler flags")
382    cli.add_argument("--ldflags", "-l",
383                     default=getenv("LDFLAGS"),
384                     help="linker flags")
385    cli.add_argument("--prefix", "-p", default="/usr/local",
386                     help="installation directory prefix")
387    cli.add_argument("--py-prefix", default=None,
388                     help="python module installation directory prefix")
389    cli.add_argument("--dry-run", action='store_true',
390                     help="just print the cmake invocation without callint it")
391    cli.add_argument("--cmake-binary", default="cmake",
392                     help="path to the cmake binary")
393    cli.add_argument("--ninja", action="store_true",
394                     help="use ninja instead of GNU make")
395    cli.add_argument("--ccache", action="store_true",
396                     help="activate using the ccache compiler cache")
397    cli.add_argument("--clang-tidy", action="store_true",
398                     help="emit clang-tidy analysis messages")
399    cli.add_argument("--download-nyan", action="store_true",
400                     help="enable automatic download of the nyan project")
401
402    # arguments after -- are used as raw cmake args
403    cli.add_argument('raw_cmake_args', nargs=argparse.REMAINDER, default=[],
404                     help="all args after ' -- ' are passed directly to cmake")
405
406    args = cli.parse_args()
407
408    if args.sanitize == 'none' and args.sanitize_fatal:
409        cli.error('--sanitize-fatal only valid with --sanitize')
410
411    if args.list_options:
412        header = "{} | Default state".format("Optional features:".ljust(25))
413        print("{}\n{}".format(header, "-" * len(header)))
414        for option, state in sorted(OPTIONS.items()):
415            state_str = (state if not isinstance(state, bool)
416                         else ("on" if state else "off"))
417            print("{} | {}".format(option.ljust(25), state_str))
418        exit(0)
419
420    return args, cli
421
422
423if __name__ == "__main__":
424    main(*parse_args())
425