summaryrefslogtreecommitdiff
path: root/bootstrap.py
blob: 035a38eca691ca7255a4ee4bda0eee6da0813119 (plain)
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
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
#!/usr/bin/env python3

import functools
import importlib
import logging
import os
import sys
from inspect import stack
from typing import List

import config
import logging_utils
from argparse_utils import ActionNoYes

# This module is commonly used by others in here and should avoid
# taking any unnecessary dependencies back on them.


logger = logging.getLogger(__name__)

args = config.add_commandline_args(
    f'Bootstrap ({__file__})',
    'Args related to python program bootstrapper and Swiss army knife',
)
args.add_argument(
    '--debug_unhandled_exceptions',
    action=ActionNoYes,
    default=False,
    help='Break into pdb on top level unhandled exceptions.',
)
args.add_argument(
    '--show_random_seed',
    action=ActionNoYes,
    default=False,
    help='Should we display (and log.debug) the global random seed?',
)
args.add_argument(
    '--set_random_seed',
    type=int,
    nargs=1,
    default=None,
    metavar='SEED_INT',
    help='Override the global random seed with a particular number.',
)
args.add_argument(
    '--dump_all_objects',
    action=ActionNoYes,
    default=False,
    help='Should we dump the Python import tree before main?',
)
args.add_argument(
    '--audit_import_events',
    action=ActionNoYes,
    default=False,
    help='Should we audit all import events?',
)
args.add_argument(
    '--run_profiler',
    action=ActionNoYes,
    default=False,
    help='Should we run cProfile on this code?',
)
args.add_argument(
    '--trace_memory',
    action=ActionNoYes,
    default=False,
    help='Should we record/report on memory utilization?',
)

original_hook = sys.excepthook


def handle_uncaught_exception(exc_type, exc_value, exc_tb):
    """
    Top-level exception handler for exceptions that make it past any exception
    handlers in the python code being run.  Logs the error and stacktrace then
    maybe attaches a debugger.

    """
    global original_hook
    msg = f'Unhandled top level exception {exc_type}'
    logger.exception(msg)
    print(msg, file=sys.stderr)
    if issubclass(exc_type, KeyboardInterrupt):
        sys.__excepthook__(exc_type, exc_value, exc_tb)
        return
    else:
        if not sys.stderr.isatty() or not sys.stdin.isatty():
            # stdin or stderr is redirected, just do the normal thing
            original_hook(exc_type, exc_value, exc_tb)
        else:
            # a terminal is attached and stderr is not redirected, maybe debug.
            import traceback

            traceback.print_exception(exc_type, exc_value, exc_tb)
            if config.config['debug_unhandled_exceptions']:
                import pdb

                logger.info("Invoking the debugger...")
                pdb.pm()
            else:
                original_hook(exc_type, exc_value, exc_tb)


class ImportInterceptor(importlib.abc.MetaPathFinder):
    def __init__(self):
        import collect.trie

        self.module_by_filename_cache = {}
        self.repopulate_modules_by_filename()
        self.tree = collect.trie.Trie()
        self.tree_node_by_module = {}

    def repopulate_modules_by_filename(self):
        self.module_by_filename_cache.clear()
        for mod in sys.modules:
            if hasattr(sys.modules[mod], '__file__'):
                fname = getattr(sys.modules[mod], '__file__')
            else:
                fname = 'unknown'
            self.module_by_filename_cache[fname] = mod

    def should_ignore_filename(self, filename: str) -> bool:
        return 'importlib' in filename or 'six.py' in filename

    def find_module(self, fullname, path):
        raise Exception(
            "This method has been deprecated since Python 3.4, please upgrade."
        )

    def find_spec(self, loaded_module, path=None, target=None):
        s = stack()
        for x in range(3, len(s)):
            filename = s[x].filename
            if self.should_ignore_filename(filename):
                continue

            loading_function = s[x].function
            if filename in self.module_by_filename_cache:
                loading_module = self.module_by_filename_cache[filename]
            else:
                self.repopulate_modules_by_filename()
                loading_module = self.module_by_filename_cache.get(filename, 'unknown')

            path = self.tree_node_by_module.get(loading_module, [])
            path.extend([loaded_module])
            self.tree.insert(path)
            self.tree_node_by_module[loading_module] = path

            msg = f'*** Import {loaded_module} from {filename}:{s[x].lineno} in {loading_module}::{loading_function}'
            logger.debug(msg)
            print(msg)
            return
        msg = f'*** Import {loaded_module} from ?????'
        logger.debug(msg)
        print(msg)

    def invalidate_caches(self):
        pass

    def find_importer(self, module: str):
        if module in self.tree_node_by_module:
            node = self.tree_node_by_module[module]
            return node
        return []


# Audit import events?  Note: this runs early in the lifetime of the
# process (assuming that import bootstrap happens early); config has
# (probably) not yet been loaded or parsed the commandline.  Also,
# some things have probably already been imported while we weren't
# watching so this information may be incomplete.
#
# Also note: move bootstrap up in the global import list to catch
# more import events and have a more complete record.
import_interceptor = None
for arg in sys.argv:
    if arg == '--audit_import_events':
        import_interceptor = ImportInterceptor()
        sys.meta_path.insert(0, import_interceptor)


def dump_all_objects() -> None:
    global import_interceptor
    messages = {}
    all_modules = sys.modules
    for obj in object.__subclasses__():
        if not hasattr(obj, '__name__'):
            continue
        klass = obj.__name__
        if not hasattr(obj, '__module__'):
            continue
        class_mod_name = obj.__module__
        if class_mod_name in all_modules:
            mod = all_modules[class_mod_name]
            if not hasattr(mod, '__name__'):
                mod_name = class_mod_name
            else:
                mod_name = mod.__name__
            if hasattr(mod, '__file__'):
                mod_file = mod.__file__
            else:
                mod_file = 'unknown'
            if import_interceptor is not None:
                import_path = import_interceptor.find_importer(mod_name)
            else:
                import_path = 'unknown'
            msg = f'{class_mod_name}::{klass} ({mod_file})'
            if import_path != 'unknown' and len(import_path) > 0:
                msg += f' imported by {import_path}'
            messages[f'{class_mod_name}::{klass}'] = msg
    for x in sorted(messages.keys()):
        logger.debug(messages[x])
        print(messages[x])


def initialize(entry_point):
    """
    Remember to initialize config, initialize logging, set/log a random
    seed, etc... before running main.

    """

    @functools.wraps(entry_point)
    def initialize_wrapper(*args, **kwargs):
        # Hook top level unhandled exceptions, maybe invoke debugger.
        if sys.excepthook == sys.__excepthook__:
            sys.excepthook = handle_uncaught_exception

        # Try to figure out the name of the program entry point.  Then
        # parse configuration (based on cmdline flags, environment vars
        # etc...)
        if (
            '__globals__' in entry_point.__dict__
            and '__file__' in entry_point.__globals__
        ):
            config.parse(entry_point.__globals__['__file__'])
        else:
            config.parse(None)

        if config.config['trace_memory']:
            import tracemalloc

            tracemalloc.start()

        # Initialize logging... and log some remembered messages from
        # config module.
        logging_utils.initialize_logging(logging.getLogger())
        config.late_logging()

        # Maybe log some info about the python interpreter itself.
        logger.debug(
            f'Platform: {sys.platform}, maxint=0x{sys.maxsize:x}, byteorder={sys.byteorder}'
        )
        logger.debug(f'Python interpreter version: {sys.version}')
        logger.debug(f'Python implementation: {sys.implementation}')
        logger.debug(f'Python C API version: {sys.api_version}')
        logger.debug(f'Python path: {sys.path}')

        # Allow programs that don't bother to override the random seed
        # to be replayed via the commandline.
        import random

        random_seed = config.config['set_random_seed']
        if random_seed is not None:
            random_seed = random_seed[0]
        else:
            random_seed = int.from_bytes(os.urandom(4), 'little')

        if config.config['show_random_seed']:
            msg = f'Global random seed is: {random_seed}'
            logger.debug(msg)
            print(msg)
        random.seed(random_seed)

        # Do it, invoke the user's code.  Pay attention to how long it takes.
        logger.debug(f'Starting {entry_point.__name__} (program entry point)')
        ret = None
        import stopwatch

        if config.config['run_profiler']:
            import cProfile
            from pstats import SortKey

            with stopwatch.Timer() as t:
                cProfile.runctx(
                    "ret = entry_point(*args, **kwargs)",
                    globals(),
                    locals(),
                    None,
                    SortKey.CUMULATIVE,
                )
        else:
            with stopwatch.Timer() as t:
                ret = entry_point(*args, **kwargs)

        logger.debug(f'{entry_point.__name__} (program entry point) returned {ret}.')

        if config.config['trace_memory']:
            snapshot = tracemalloc.take_snapshot()
            top_stats = snapshot.statistics('lineno')
            print()
            print("--trace_memory's top 10 memory using files:")
            for stat in top_stats[:10]:
                print(stat)

        if config.config['dump_all_objects']:
            dump_all_objects()

        if config.config['audit_import_events']:
            global import_interceptor
            if import_interceptor is not None:
                print(import_interceptor.tree)

        walltime = t()
        (utime, stime, cutime, cstime, elapsed_time) = os.times()
        logger.debug(
            '\n'
            f'user: {utime}s\n'
            f'system: {stime}s\n'
            f'child user: {cutime}s\n'
            f'child system: {cstime}s\n'
            f'machine uptime: {elapsed_time}s\n'
            f'walltime: {walltime}s'
        )

        # If it doesn't return cleanly, call attention to the return value.
        if ret is not None and ret != 0:
            logger.error(f'Exit {ret}')
        else:
            logger.debug(f'Exit {ret}')
        sys.exit(ret)

    return initialize_wrapper