baenv

Manage ballistica execution environment.

This module is used to set up and/or check the global Python environment before running a ballistica app. This includes things such as paths, logging, and app-dirs. Because these things are global in nature, this should be done before any ballistica modules are imported.

This module can also be exec'ed directly to set up a default environment and then run the app.

Ballistica can be used without explicitly configuring the environment in order to integrate it in arbitrary Python environments, but this may cause some features to be disabled or behave differently than expected.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Manage ballistica execution environment.
  4
  5This module is used to set up and/or check the global Python environment
  6before running a ballistica app. This includes things such as paths,
  7logging, and app-dirs. Because these things are global in nature, this
  8should be done before any ballistica modules are imported.
  9
 10This module can also be exec'ed directly to set up a default environment
 11and then run the app.
 12
 13Ballistica can be used without explicitly configuring the environment in
 14order to integrate it in arbitrary Python environments, but this may
 15cause some features to be disabled or behave differently than expected.
 16"""
 17from __future__ import annotations
 18
 19import os
 20import sys
 21import time
 22import logging
 23from pathlib import Path
 24from dataclasses import dataclass
 25from typing import TYPE_CHECKING
 26import __main__
 27
 28if TYPE_CHECKING:
 29    from typing import Any
 30
 31    from efro.logging import LogHandler
 32
 33# IMPORTANT - It is likely (and in some cases expected) that this
 34# module's code will be exec'ed multiple times. This is because it is
 35# the job of this module to set up Python paths for an engine run, and
 36# that may involve modifying sys.path in such a way that this module
 37# resolves to a different path afterwards (for example from
 38# /abs/path/to/ba_data/scripts/babase.py to ba_data/scripts/babase.py).
 39# This can result in the next import of baenv loading us from our 'new'
 40# location, which may or may not actually be the same file on disk as
 41# the last load. Either way, however, multiple execs will happen in some
 42# form.
 43#
 44# To handle that situation gracefully, we need to do a few things:
 45#
 46# - First, we need to store any mutable global state in the __main__
 47#   module; not in ourself. This way, alternate versions of ourself will
 48#   still know if we already ran configure/etc.
 49#
 50# - Second, we should avoid the use of isinstance and similar calls for
 51#   our types. An EnvConfig we create would technically be a different
 52#   type than an EnvConfig created by an alternate baenv.
 53
 54# Build number and version of the ballistica binary we expect to be
 55# using.
 56TARGET_BALLISTICA_BUILD = 22110
 57TARGET_BALLISTICA_VERSION = '1.7.37'
 58
 59
 60@dataclass
 61class EnvConfig:
 62    """Final config values we provide to the engine."""
 63
 64    # Where app config/state data lives.
 65    config_dir: str
 66
 67    # Directory containing ba_data and any other platform-specific data.
 68    data_dir: str
 69
 70    # Where the app's built-in Python stuff lives.
 71    app_python_dir: str | None
 72
 73    # Where the app's built-in Python stuff lives in the default case.
 74    standard_app_python_dir: str
 75
 76    # Where the app's bundled third party Python stuff lives.
 77    site_python_dir: str | None
 78
 79    # Custom Python provided by the user (mods).
 80    user_python_dir: str | None
 81
 82    # We have a mechanism allowing app scripts to be overridden by
 83    # placing a specially named directory in a user-scripts dir.
 84    # This is true if that is enabled.
 85    is_user_app_python_dir: bool
 86
 87    # Our fancy app log handler. This handles feeding logs, stdout, and
 88    # stderr into the engine so they show up on in-app consoles, etc.
 89    log_handler: LogHandler | None
 90
 91    # Initial data from the config.json file in the config dir. The
 92    # config file is parsed by
 93    initial_app_config: Any
 94
 95    # Timestamp when we first started doing stuff.
 96    launch_time: float
 97
 98
 99@dataclass
100class _EnvGlobals:
101    """Globals related to baenv's operation.
102
103    We store this in __main__ instead of in our own module because it
104    is likely that multiple versions of our module will be spun up
105    and we want a single set of globals (see notes at top of our module
106    code).
107    """
108
109    config: EnvConfig | None = None
110    called_configure: bool = False
111    paths_set_failed: bool = False
112    modular_main_called: bool = False
113
114    @classmethod
115    def get(cls) -> _EnvGlobals:
116        """Create/return our singleton."""
117        name = '_baenv_globals'
118        envglobals: _EnvGlobals | None = getattr(__main__, name, None)
119        if envglobals is None:
120            envglobals = _EnvGlobals()
121            setattr(__main__, name, envglobals)
122        return envglobals
123
124
125def did_paths_set_fail() -> bool:
126    """Did we try to set paths and fail?"""
127    return _EnvGlobals.get().paths_set_failed
128
129
130def config_exists() -> bool:
131    """Has a config been created?"""
132
133    return _EnvGlobals.get().config is not None
134
135
136def get_config() -> EnvConfig:
137    """Return the active config, creating a default if none exists."""
138    envglobals = _EnvGlobals.get()
139
140    # If configure() has not been explicitly called, set up a
141    # minimally-intrusive default config. We want Ballistica to default
142    # to being a good citizen when imported into alien environments and
143    # not blow away logging or otherwise muck with stuff. All official
144    # paths to run Ballistica apps should be explicitly calling
145    # configure() first to get a full featured setup.
146    if not envglobals.called_configure:
147        configure(setup_logging=False)
148
149    config = envglobals.config
150    if config is None:
151        raise RuntimeError(
152            'baenv.configure() has been called but no config exists;'
153            ' perhaps it errored?'
154        )
155    return config
156
157
158def configure(
159    *,
160    config_dir: str | None = None,
161    data_dir: str | None = None,
162    user_python_dir: str | None = None,
163    app_python_dir: str | None = None,
164    site_python_dir: str | None = None,
165    contains_python_dist: bool = False,
166    setup_logging: bool = True,
167) -> None:
168    """Set up the environment for running a Ballistica app.
169
170    This includes things such as Python path wrangling and app directory
171    creation. This must be called before any actual Ballistica modules
172    are imported; the environment is locked in as soon as that happens.
173    """
174
175    # Measure when we start doing this stuff. We plug this in to show
176    # relative times in our log timestamp displays and also pass this to
177    # the engine to do the same there.
178    launch_time = time.time()
179
180    envglobals = _EnvGlobals.get()
181
182    # Keep track of whether we've been *called*, not whether a config
183    # has been created. Otherwise its possible to get multiple
184    # overlapping configure calls going.
185    if envglobals.called_configure:
186        raise RuntimeError(
187            'baenv.configure() has already been called;'
188            ' it can only be called once.'
189        )
190    envglobals.called_configure = True
191
192    # The very first thing we do is setup Python paths (while also
193    # calculating some engine paths). This code needs to be bulletproof
194    # since we have no logging yet at this point. We used to set up
195    # logging first, but this way logging stuff will get loaded from its
196    # proper final path (otherwise we might wind up using two different
197    # versions of efro.logging in a single engine run).
198    (
199        user_python_dir,
200        app_python_dir,
201        site_python_dir,
202        data_dir,
203        config_dir,
204        standard_app_python_dir,
205        is_user_app_python_dir,
206    ) = _setup_paths(
207        user_python_dir,
208        app_python_dir,
209        site_python_dir,
210        data_dir,
211        config_dir,
212    )
213
214    # Set up our log-handler and pipe Python's stdout/stderr into it.
215    # Later, once the engine comes up, the handler will feed its logs
216    # (including cached history) to the os-specific output location.
217    # This means anything printed or logged at this point forward should
218    # be visible on all platforms.
219    log_handler = _create_log_handler(launch_time) if setup_logging else None
220
221    # Load the raw app-config dict.
222    app_config = _read_app_config(os.path.join(config_dir, 'config.json'))
223
224    # Set logging levels to stored values or defaults.
225    if setup_logging:
226        _set_log_levels(app_config)
227
228    # We want to always be run in UTF-8 mode; complain if we're not.
229    if sys.flags.utf8_mode != 1:
230        logging.warning(
231            "Python's UTF-8 mode is not set. Running Ballistica without"
232            ' it may lead to errors.'
233        )
234
235    # Attempt to create dirs that we'll write stuff to.
236    _setup_dirs(config_dir, user_python_dir)
237
238    # Get ssl working if needed so we can use https and all that.
239    _setup_certs(contains_python_dist)
240
241    # This is now the active config.
242    envglobals.config = EnvConfig(
243        config_dir=config_dir,
244        data_dir=data_dir,
245        user_python_dir=user_python_dir,
246        app_python_dir=app_python_dir,
247        standard_app_python_dir=standard_app_python_dir,
248        site_python_dir=site_python_dir,
249        log_handler=log_handler,
250        is_user_app_python_dir=is_user_app_python_dir,
251        initial_app_config=app_config,
252        launch_time=launch_time,
253    )
254
255
256def _read_app_config(config_file_path: str) -> dict:
257    """Read the app config."""
258    import json
259
260    config: dict | Any
261    config_contents = ''
262    try:
263        if os.path.exists(config_file_path):
264            with open(config_file_path, encoding='utf-8') as infile:
265                config_contents = infile.read()
266            config = json.loads(config_contents)
267            if not isinstance(config, dict):
268                raise RuntimeError('Got non-dict for config root.')
269        else:
270            config = {}
271
272    except Exception:
273        logging.exception(
274            "Error reading config file '%s'.\n"
275            "Backing up broken config to'%s.broken'.",
276            config_file_path,
277            config_file_path,
278        )
279
280        try:
281            import shutil
282
283            shutil.copyfile(config_file_path, config_file_path + '.broken')
284        except Exception:
285            logging.exception('Error copying broken config.')
286        config = {}
287
288    return config
289
290
291def _calc_data_dir(data_dir: str | None) -> str:
292    if data_dir is None:
293        # To calc default data_dir, we assume this module was imported
294        # from that dir's ba_data/python subdir.
295        assert Path(__file__).parts[-3:-1] == ('ba_data', 'python')
296        data_dir_path = Path(__file__).parents[2]
297
298        # Prefer tidy relative paths like './ba_data' if possible so
299        # that things like stack traces are easier to read. For best
300        # results, platforms where CWD doesn't matter can chdir to where
301        # ba_data lives before calling configure().
302        #
303        # NOTE: If there's ever a case where the user is chdir'ing at
304        # runtime we might want an option to use only abs paths here.
305        cwd_path = Path.cwd()
306        data_dir = str(
307            data_dir_path.relative_to(cwd_path)
308            if data_dir_path.is_relative_to(cwd_path)
309            else data_dir_path
310        )
311    return data_dir
312
313
314def _create_log_handler(launch_time: float) -> LogHandler:
315    from efro.logging import setup_logging, LogLevel
316
317    log_handler = setup_logging(
318        log_path=None,
319        level=LogLevel.INFO,
320        log_stdout_stderr=True,
321        cache_size_limit=1024 * 1024,
322        launch_time=launch_time,
323    )
324    return log_handler
325
326
327def _set_log_levels(app_config: dict) -> None:
328
329    from bacommon.logging import get_base_logger_control_config_client
330    from bacommon.loggercontrol import LoggerControlConfig
331
332    try:
333        config = app_config.get('Log Levels', None)
334
335        if config is None:
336            get_base_logger_control_config_client().apply()
337            return
338
339        # Make sure data is expected types/values since this is user
340        # editable.
341        valid_levels = {
342            logging.NOTSET,
343            logging.DEBUG,
344            logging.INFO,
345            logging.WARNING,
346            logging.ERROR,
347            logging.CRITICAL,
348        }
349        for logname, loglevel in config.items():
350            if (
351                not isinstance(logname, str)
352                or not logname
353                or not isinstance(loglevel, int)
354                or not loglevel in valid_levels
355            ):
356                raise ValueError("Invalid 'Log Levels' data read from config.")
357
358        get_base_logger_control_config_client().apply_diff(
359            LoggerControlConfig(levels=config)
360        ).apply()
361
362    except Exception:
363        logging.exception('Error setting log levels.')
364
365
366def _setup_certs(contains_python_dist: bool) -> None:
367    # In situations where we're bringing our own Python, let's also
368    # provide our own root certs so ssl works. We can consider
369    # overriding this in particular embedded cases if we can verify that
370    # system certs are working. We also allow forcing this via an env
371    # var if the user desires.
372    if (
373        contains_python_dist
374        or os.environ.get('BA_USE_BUNDLED_ROOT_CERTS') == '1'
375    ):
376        import certifi
377
378        # Let both OpenSSL and requests (if present) know to use this.
379        os.environ['SSL_CERT_FILE'] = os.environ['REQUESTS_CA_BUNDLE'] = (
380            certifi.where()
381        )
382
383
384def _setup_paths(
385    user_python_dir: str | None,
386    app_python_dir: str | None,
387    site_python_dir: str | None,
388    data_dir: str | None,
389    config_dir: str | None,
390) -> tuple[str | None, str | None, str | None, str, str, str, bool]:
391    # First a few paths we can ALWAYS calculate since they don't affect
392    # Python imports:
393
394    envglobals = _EnvGlobals.get()
395
396    data_dir = _calc_data_dir(data_dir)
397
398    # Default config-dir is simply ~/.ballisticakit
399    if config_dir is None:
400        config_dir = str(Path(Path.home(), '.ballisticakit'))
401
402    # Standard app-python-dir is simply ba_data/python under data-dir.
403    standard_app_python_dir = str(Path(data_dir, 'ba_data', 'python'))
404
405    # Whether the final app-dir we're returning is a custom user-owned one.
406    is_user_app_python_dir = False
407
408    # If _babase has already been imported, there's not much we can do
409    # at this point aside from complain and inform for next time.
410    if '_babase' in sys.modules:
411        app_python_dir = user_python_dir = site_python_dir = None
412
413        # We don't actually complain yet here; we simply take note that
414        # we weren't able to set paths. Then we complain if/when the app
415        # is started. This way, non-app uses of babase won't be filled
416        # with unnecessary warnings.
417        envglobals.paths_set_failed = True
418
419    else:
420        # Ok; _babase hasn't been imported yet, so we can muck with
421        # Python paths.
422
423        if app_python_dir is None:
424            app_python_dir = standard_app_python_dir
425
426        # Likewise site-python-dir defaults to ba_data/python-site-packages.
427        if site_python_dir is None:
428            site_python_dir = str(
429                Path(data_dir, 'ba_data', 'python-site-packages')
430            )
431
432        # By default, user-python-dir is simply 'mods' under config-dir.
433        if user_python_dir is None:
434            user_python_dir = str(Path(config_dir, 'mods'))
435
436        # Wherever our user_python_dir is, if we find a sys/FOO_BAR dir
437        # under it where FOO matches our version and BAR matches our
438        # build number, use that as our app_python_dir. This allows
439        # modding built-in stuff on platforms where there is no write
440        # access to said built-in stuff.
441        check_dir = Path(
442            user_python_dir,
443            'sys',
444            f'{TARGET_BALLISTICA_VERSION}_{TARGET_BALLISTICA_BUILD}',
445        )
446        try:
447            if check_dir.is_dir():
448                app_python_dir = str(check_dir)
449                is_user_app_python_dir = True
450        except PermissionError:
451            logging.warning(
452                "PermissionError checking user-app-python-dir path '%s'.",
453                check_dir,
454            )
455
456        # Ok, now apply these to sys.path.
457
458        # First off, strip out any instances of the path containing this
459        # module. We will *probably* be re-adding the same path in a
460        # moment so this keeps things cleaner. Though hmm should we
461        # leave it in there in cases where we *don't* re-add the same
462        # path?...
463        our_parent_path = Path(__file__).parent.resolve()
464        oldpaths: list[str] = [
465            p for p in sys.path if Path(p).resolve() != our_parent_path
466        ]
467
468        # Let's place mods first (so users can override whatever they
469        # want) followed by our app scripts and lastly our bundled site
470        # stuff.
471
472        # One could make the argument that at least our bundled app &
473        # site stuff should be placed at the end so actual local site
474        # stuff could override it. That could be a good thing or a bad
475        # thing. Maybe we could add an option for that, but for now I'm
476        # prioritizing our stuff to give as consistent an environment as
477        # possible.
478        ourpaths = [user_python_dir, app_python_dir, site_python_dir]
479
480        # Special case: our modular builds will have a 'python-dylib'
481        # dir alongside the 'python' scripts dir which contains our
482        # binary Python modules. If we see that, add it to the path also.
483        # Not sure if we'd ever have a need to customize this path.
484        dylibdir = f'{app_python_dir}-dylib'
485        if os.path.exists(dylibdir):
486            ourpaths.append(dylibdir)
487
488        sys.path = ourpaths + oldpaths
489
490    return (
491        user_python_dir,
492        app_python_dir,
493        site_python_dir,
494        data_dir,
495        config_dir,
496        standard_app_python_dir,
497        is_user_app_python_dir,
498    )
499
500
501def _setup_dirs(config_dir: str | None, user_python_dir: str | None) -> None:
502    create_dirs: list[tuple[str, str | None]] = [
503        ('config', config_dir),
504        ('user_python', user_python_dir),
505    ]
506    for cdirname, cdir in create_dirs:
507        if cdir is not None:
508            try:
509                os.makedirs(cdir, exist_ok=True)
510            except Exception:
511                # Not the end of the world if we can't make these dirs.
512                logging.warning(
513                    "Unable to create %s dir at '%s'.", cdirname, cdir
514                )
515
516
517def extract_arg(args: list[str], names: list[str], is_dir: bool) -> str | None:
518    """Given a list of args and an arg name, returns a value.
519
520    The arg flag and value are removed from the arg list. We also check
521    to make sure the path exists.
522
523    raises CleanErrors on any problems.
524    """
525    from efro.error import CleanError
526
527    count = sum(args.count(n) for n in names)
528    if not count:
529        return None
530
531    if count > 1:
532        raise CleanError(f'Arg {names} passed multiple times.')
533
534    for name in names:
535        if name not in args:
536            continue
537        argindex = args.index(name)
538        if argindex + 1 >= len(args):
539            raise CleanError(f'No value passed after {name} arg.')
540
541        val = args[argindex + 1]
542        del args[argindex : argindex + 2]
543
544        if is_dir and not os.path.isdir(val):
545            namepretty = names[0].removeprefix('--')
546            raise CleanError(
547                f"Provided {namepretty} path '{val}' is not a directory."
548            )
549        return val
550
551    raise RuntimeError(f'Expected arg name not found from {names}')
552
553
554def _modular_main() -> None:
555    from efro.error import CleanError
556
557    # Fundamentally, running a Ballistica app consists of the following:
558    # import baenv; baenv.configure(); import babase; babase.app.run()
559    #
560    # First baenv sets up things like Python paths the way the engine
561    # needs them, and then we import and run the engine.
562    #
563    # Below we're doing a slightly fancier version of that. Namely, we
564    # do some processing of command line args to allow overriding of
565    # paths or running explicit commands or whatever else. Our goal is
566    # that this modular form of the app should be basically
567    # indistinguishable from the monolithic form when used from the
568    # command line.
569
570    try:
571        # Take note that we're running via modular-main. The native
572        # layer can key off this to know whether it should apply
573        # sys.argv or not.
574        _EnvGlobals.get().modular_main_called = True
575
576        # Deal with a few key things here ourself before even running
577        # configure.
578
579        # The extract_arg stuff below modifies this so we work with a
580        # copy.
581        args = sys.argv.copy()
582
583        # NOTE: We need to keep these arg long/short arg versions synced
584        # to those in core_config.cc. That code parses these same args
585        # (even if it doesn't handle them in our case) and will complain
586        # if unrecognized args come through.
587
588        # Our -c arg basically mirrors Python's -c arg. If we get that,
589        # simply exec it and return; no engine stuff.
590        command = extract_arg(args, ['--command', '-c'], is_dir=False)
591        if command is not None:
592            exec(command)  # pylint: disable=exec-used
593            return
594
595        config_dir = extract_arg(args, ['--config-dir', '-C'], is_dir=True)
596        data_dir = extract_arg(args, ['--data-dir', '-d'], is_dir=True)
597        mods_dir = extract_arg(args, ['--mods-dir', '-m'], is_dir=True)
598
599        # We run configure() BEFORE importing babase. (part of its job
600        # is to wrangle paths which can affect where babase and
601        # everything else gets loaded from).
602        configure(
603            config_dir=config_dir,
604            data_dir=data_dir,
605            user_python_dir=mods_dir,
606        )
607
608        import babase
609
610        # The engine will have parsed and processed all other args as
611        # part of the above import. If there were errors or args such as
612        # --help which should lead to us immediately returning, do so.
613        code = babase.get_immediate_return_code()
614        if code is not None:
615            sys.exit(code)
616
617        # Aaaand we're off!
618        babase.app.run()
619
620    # Code wanting us to die with a clean error message instead of an
621    # ugly stack trace can raise one of these.
622    except CleanError as clean_exc:
623        clean_exc.pretty_print()
624        sys.exit(1)
625
626
627# Exec'ing this module directly will do a standard app run.
628if __name__ == '__main__':
629    _modular_main()
TARGET_BALLISTICA_BUILD = 22110
TARGET_BALLISTICA_VERSION = '1.7.37'
@dataclass
class EnvConfig:
61@dataclass
62class EnvConfig:
63    """Final config values we provide to the engine."""
64
65    # Where app config/state data lives.
66    config_dir: str
67
68    # Directory containing ba_data and any other platform-specific data.
69    data_dir: str
70
71    # Where the app's built-in Python stuff lives.
72    app_python_dir: str | None
73
74    # Where the app's built-in Python stuff lives in the default case.
75    standard_app_python_dir: str
76
77    # Where the app's bundled third party Python stuff lives.
78    site_python_dir: str | None
79
80    # Custom Python provided by the user (mods).
81    user_python_dir: str | None
82
83    # We have a mechanism allowing app scripts to be overridden by
84    # placing a specially named directory in a user-scripts dir.
85    # This is true if that is enabled.
86    is_user_app_python_dir: bool
87
88    # Our fancy app log handler. This handles feeding logs, stdout, and
89    # stderr into the engine so they show up on in-app consoles, etc.
90    log_handler: LogHandler | None
91
92    # Initial data from the config.json file in the config dir. The
93    # config file is parsed by
94    initial_app_config: Any
95
96    # Timestamp when we first started doing stuff.
97    launch_time: float

Final config values we provide to the engine.

EnvConfig( config_dir: str, data_dir: str, app_python_dir: str | None, standard_app_python_dir: str, site_python_dir: str | None, user_python_dir: str | None, is_user_app_python_dir: bool, log_handler: efro.logging.LogHandler | None, initial_app_config: Any, launch_time: float)
config_dir: str
data_dir: str
app_python_dir: str | None
standard_app_python_dir: str
site_python_dir: str | None
user_python_dir: str | None
is_user_app_python_dir: bool
log_handler: efro.logging.LogHandler | None
initial_app_config: Any
launch_time: float
def did_paths_set_fail() -> bool:
126def did_paths_set_fail() -> bool:
127    """Did we try to set paths and fail?"""
128    return _EnvGlobals.get().paths_set_failed

Did we try to set paths and fail?

def config_exists() -> bool:
131def config_exists() -> bool:
132    """Has a config been created?"""
133
134    return _EnvGlobals.get().config is not None

Has a config been created?

def get_config() -> EnvConfig:
137def get_config() -> EnvConfig:
138    """Return the active config, creating a default if none exists."""
139    envglobals = _EnvGlobals.get()
140
141    # If configure() has not been explicitly called, set up a
142    # minimally-intrusive default config. We want Ballistica to default
143    # to being a good citizen when imported into alien environments and
144    # not blow away logging or otherwise muck with stuff. All official
145    # paths to run Ballistica apps should be explicitly calling
146    # configure() first to get a full featured setup.
147    if not envglobals.called_configure:
148        configure(setup_logging=False)
149
150    config = envglobals.config
151    if config is None:
152        raise RuntimeError(
153            'baenv.configure() has been called but no config exists;'
154            ' perhaps it errored?'
155        )
156    return config

Return the active config, creating a default if none exists.

def configure( *, config_dir: str | None = None, data_dir: str | None = None, user_python_dir: str | None = None, app_python_dir: str | None = None, site_python_dir: str | None = None, contains_python_dist: bool = False, setup_logging: bool = True) -> None:
159def configure(
160    *,
161    config_dir: str | None = None,
162    data_dir: str | None = None,
163    user_python_dir: str | None = None,
164    app_python_dir: str | None = None,
165    site_python_dir: str | None = None,
166    contains_python_dist: bool = False,
167    setup_logging: bool = True,
168) -> None:
169    """Set up the environment for running a Ballistica app.
170
171    This includes things such as Python path wrangling and app directory
172    creation. This must be called before any actual Ballistica modules
173    are imported; the environment is locked in as soon as that happens.
174    """
175
176    # Measure when we start doing this stuff. We plug this in to show
177    # relative times in our log timestamp displays and also pass this to
178    # the engine to do the same there.
179    launch_time = time.time()
180
181    envglobals = _EnvGlobals.get()
182
183    # Keep track of whether we've been *called*, not whether a config
184    # has been created. Otherwise its possible to get multiple
185    # overlapping configure calls going.
186    if envglobals.called_configure:
187        raise RuntimeError(
188            'baenv.configure() has already been called;'
189            ' it can only be called once.'
190        )
191    envglobals.called_configure = True
192
193    # The very first thing we do is setup Python paths (while also
194    # calculating some engine paths). This code needs to be bulletproof
195    # since we have no logging yet at this point. We used to set up
196    # logging first, but this way logging stuff will get loaded from its
197    # proper final path (otherwise we might wind up using two different
198    # versions of efro.logging in a single engine run).
199    (
200        user_python_dir,
201        app_python_dir,
202        site_python_dir,
203        data_dir,
204        config_dir,
205        standard_app_python_dir,
206        is_user_app_python_dir,
207    ) = _setup_paths(
208        user_python_dir,
209        app_python_dir,
210        site_python_dir,
211        data_dir,
212        config_dir,
213    )
214
215    # Set up our log-handler and pipe Python's stdout/stderr into it.
216    # Later, once the engine comes up, the handler will feed its logs
217    # (including cached history) to the os-specific output location.
218    # This means anything printed or logged at this point forward should
219    # be visible on all platforms.
220    log_handler = _create_log_handler(launch_time) if setup_logging else None
221
222    # Load the raw app-config dict.
223    app_config = _read_app_config(os.path.join(config_dir, 'config.json'))
224
225    # Set logging levels to stored values or defaults.
226    if setup_logging:
227        _set_log_levels(app_config)
228
229    # We want to always be run in UTF-8 mode; complain if we're not.
230    if sys.flags.utf8_mode != 1:
231        logging.warning(
232            "Python's UTF-8 mode is not set. Running Ballistica without"
233            ' it may lead to errors.'
234        )
235
236    # Attempt to create dirs that we'll write stuff to.
237    _setup_dirs(config_dir, user_python_dir)
238
239    # Get ssl working if needed so we can use https and all that.
240    _setup_certs(contains_python_dist)
241
242    # This is now the active config.
243    envglobals.config = EnvConfig(
244        config_dir=config_dir,
245        data_dir=data_dir,
246        user_python_dir=user_python_dir,
247        app_python_dir=app_python_dir,
248        standard_app_python_dir=standard_app_python_dir,
249        site_python_dir=site_python_dir,
250        log_handler=log_handler,
251        is_user_app_python_dir=is_user_app_python_dir,
252        initial_app_config=app_config,
253        launch_time=launch_time,
254    )

Set up the environment for running a Ballistica app.

This includes things such as Python path wrangling and app directory creation. This must be called before any actual Ballistica modules are imported; the environment is locked in as soon as that happens.

def extract_arg(args: list[str], names: list[str], is_dir: bool) -> str | None:
518def extract_arg(args: list[str], names: list[str], is_dir: bool) -> str | None:
519    """Given a list of args and an arg name, returns a value.
520
521    The arg flag and value are removed from the arg list. We also check
522    to make sure the path exists.
523
524    raises CleanErrors on any problems.
525    """
526    from efro.error import CleanError
527
528    count = sum(args.count(n) for n in names)
529    if not count:
530        return None
531
532    if count > 1:
533        raise CleanError(f'Arg {names} passed multiple times.')
534
535    for name in names:
536        if name not in args:
537            continue
538        argindex = args.index(name)
539        if argindex + 1 >= len(args):
540            raise CleanError(f'No value passed after {name} arg.')
541
542        val = args[argindex + 1]
543        del args[argindex : argindex + 2]
544
545        if is_dir and not os.path.isdir(val):
546            namepretty = names[0].removeprefix('--')
547            raise CleanError(
548                f"Provided {namepretty} path '{val}' is not a directory."
549            )
550        return val
551
552    raise RuntimeError(f'Expected arg name not found from {names}')

Given a list of args and an arg name, returns a value.

The arg flag and value are removed from the arg list. We also check to make sure the path exists.

raises CleanErrors on any problems.