Boil: the source

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
#!/usr/bin/env python3

"""
Boil build utility.
"""

import argparse
import configparser
import subprocess
import sys
from itertools import chain
import os
import re


import noodles
from noodles.display import NCDisplay, DumbDisplay


def find_files(path, ext):
    """Find all files in `path` with extension `ext`. Returns an
    iterator giving tuples (folder, (files...)).

    :param path:
        search path
    :param ext:
        extension of files to find"""
    for folder, _, files in os.walk(path):
        for f in files:
            if f[-len(ext):] == ext:
                yield (folder, f)


def is_out_of_date(f, deps):
    """Check if file `f` needs to be updated. Returns True if any
    of the dependencies are newer than `f`.

    :param f:
        file to check
    :param deps:
        dependencies"""
    if not os.path.exists(f):
        return True

    f_stat = os.stat(f)

    for d in deps:
        d_stat = os.stat(d)

        if d_stat.st_mtime_ns > f_stat.st_mtime_ns:
            return True

    return False


def dependencies(source_file, config):
    """Find dependencies of source file.

    :param source_file:
        source file
    :param config:
        boil configuration"""
    cmm = subprocess.run(
        [config['cc'], '-MM', source_file] + config['cflags'].split(),
        stdout=subprocess.PIPE, universal_newlines=True)
    deps = re.sub('^.*: ', '', cmm.stdout) \
        .replace('\\', '').replace('\n', '').split()

    return deps


def object_filename(srcdir, filename, config):
    """Create the object filename.

    :param srcdir:
        directory of source file
    :param filename:
        filename of source
    :param config:
        boil configuration"""
    target_dir = os.path.join(config['objdir'], srcdir)

    # if target directory doesn't exists, create it
    # flag exists_ok=True for concurrency reasons
    if not os.path.exists(target_dir):
        os.makedirs(target_dir, exist_ok=True)

    # construct name of object file
    basename = os.path.splitext(filename)[0]
    return os.path.join(target_dir, basename + '.o')


@noodles.schedule_hint(display="  Compiling {source_file} ... ",
                       confirm=True)
@noodles.maybe
def compile_source(source_file, object_file, config):
    """Compile a single source file."""
    p = subprocess.run(
        [config['cc'], '-c'] + config['cflags'].split() +
        [source_file, '-o', object_file],
        stderr=subprocess.PIPE, universal_newlines=True)
    p.check_returncode()

    return object_file


def get_object_file(src_dir, src_file, config):
    """Ensures existence of up-to-date object file.

    :param src_dir:
        source directory
    :param src_file:
        source file
    :param config:
        boil configuration"""
    obj_path = object_filename(src_dir, src_file, config)
    src_path = os.path.join(src_dir, src_file)

    deps = dependencies(src_path, config)
    if is_out_of_date(obj_path, deps):
        return compile_source(src_path, obj_path, config)
    else:
        return obj_path


@noodles.schedule_hint(display="  Linking ... ",
                       confirm=True)
@noodles.maybe
def link(object_files, config):
    """Link object files to executable."""
    p = subprocess.run(
        [config['cc']] + object_files + ['-o', config['target']] +
        config['ldflags'].split(),
        stderr=subprocess.PIPE, universal_newlines=True)
    p.check_returncode()

    return config['target']


@noodles.schedule_hint(display="{msg}")
def message(msg, value=None):
    """Just print a message and pass on ``value``."""
    return value


@noodles.schedule
def get_target(obj_files, config):
    """Ensures that target is up-to-date.

    :param obj_files:
        list of object files
    :param config:
        boil configuration"""
    if any(noodles.failed(obj) for obj in obj_files):
        return Report(
            'failed',
            failures=[obj for obj in obj_files if noodles.failed(obj)])

    if is_out_of_date(config['target'], obj_files):
        return report_from_result(link(obj_files, config))
    else:
        return report_from_result('nothing-to-do')


@noodles.schedule
def report_from_result(result):
    """Assemble report from a result."""
    if noodles.failed(result):
        return Report('failed', failures=[result])
    else:
        return Report('success', result=result)


class Report:
    """Contains status report of compile process."""
    def __init__(self, status, result=None, failures=None):
        self.status = status
        self.result = result
        self.failures = failures

    def __str__(self):
        line = '\033[31m' + '─' * 80 + '\033[m'

        def format_failure(failure):
            """Print a failure nicely."""
            return str(failure) + '\n' + line + '\n' + \
                failure.exception.stderr + line + '\n'

        if self.status == 'failed':
            return '\n\n'.join(map(format_failure, self.failures))
        else:
            return self.status


@noodles.schedule_hint(display="Building target {config[target]}")
def make_target(config):
    """Make a target. First compiles the source files, then
    links the object files to create an executable.

    :param config:
        boil configuration"""
    dirs = [config['srcdir']] + [
        os.path.normpath(os.path.join(config['srcdir'], d))
        for d in config['modules'].split()
    ]

    files = chain(
        *(find_files(d, config['ext'])
          for d in dirs))

    object_files = noodles.gather_all(
        get_object_file(src_dir, src_file, config)
        for src_dir, src_file in files)

    return get_target(object_files, config)


def read_config(filename):
    """Read configuration from `filename` and convert it to a nested dict.

    :param filename:
        name of an .ini file to read

    :returns:
        dictionary."""
    reader = configparser.ConfigParser(
        interpolation=configparser.ExtendedInterpolation())
    reader.read(filename)

    return {k: dict(reader[k]) for k in reader.sections()}


def try_to_run(cmd, err_prefix):
    """Run a subprocess. Exit if subprocess fails."""
    process = subprocess.run(
        cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
        universal_newlines=True)
    try:
        process.check_returncode()
        return process.stdout
    except subprocess.CalledProcessError as exc:
        print(err_prefix, exc.stderr)
        sys.exit(1)


if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description="Compile software. Configuration is in 'boil.ini'.")
    parser.add_argument(
        '-j', dest='n_threads', type=int, default=1,
        help='number of threads to run simultaneously.')
    parser.add_argument(
        '-dumb', default=False, action='store_true',
        help='print info without special term codes.')
    parser.add_argument(
        'target', type=str,
        help='target to build, give \'list\' to list targets.')
    args = parser.parse_args(sys.argv[1:])

    if not os.path.exists('boil.ini'):
        print("No boil.ini in current directory.")
        sys.exit(1)

    bconfig = read_config('boil.ini')

    if 'generic' not in bconfig:
        print("Error: Configuration has no 'generic' section.")
        sys.exit(1)

    if args.target == 'list':
        targets = list(bconfig.keys())
        targets.remove('generic')
        print("Possible targets: ", ', '.join(targets))
        sys.exit(0)

    if 'command' in bconfig[args.target]:
        os.system(bconfig[args.target]['command'])

    else:
        target_config = bconfig['generic']
        target_config.update(bconfig[args.target])

        if 'libraries' in target_config:
            a = try_to_run(
                ['pkg-config', '--libs'] + target_config['libraries'].split(),
                err_prefix="Error running pkg-config: ")

            target_config['ldflags'] += ' ' + a

            a = try_to_run(
                ['pkg-config', '--cflags'] +
                target_config['libraries'].split(),
                err_prefix="Error running pkg-config: ")

            target_config['cflags'] += ' ' + a

        work = make_target(target_config)
        display_type = DumbDisplay if args.dumb else NCDisplay
        with display_type() as display:
            report = noodles.run_logging(work, args.n_threads, display)
        print(report)