mirror of
https://github.com/RYDE-WORK/ballistica.git
synced 2026-01-21 06:19:33 +08:00
420 lines
15 KiB
Python
420 lines
15 KiB
Python
# Released under the MIT License. See LICENSE for details.
|
|
#
|
|
"""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, debug-modes, garbage-collection settings, and signal handling.
|
|
Because these things are global in nature, this should be done before
|
|
any ballistica modules are imported.
|
|
|
|
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.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import signal
|
|
import logging
|
|
from pathlib import Path
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from efro.log import setup_logging, LogLevel
|
|
|
|
if TYPE_CHECKING:
|
|
from typing import Any
|
|
|
|
from efro.log import LogEntry, LogHandler
|
|
|
|
# Build number and version of the ballistica binary we expect to be
|
|
# using.
|
|
TARGET_BALLISTICA_BUILD = 21027
|
|
TARGET_BALLISTICA_VERSION = '1.7.20'
|
|
|
|
_g_env_config: EnvConfig | None = None
|
|
_g_babase_imported = False # pylint: disable=invalid-name
|
|
_g_babase_app_started = False # pylint: disable=invalid-name
|
|
_g_paths_set_failed = False # pylint: disable=invalid-name
|
|
|
|
|
|
@dataclass
|
|
class EnvConfig:
|
|
"""Final settings put together by the configure call."""
|
|
|
|
config_dir: str
|
|
data_dir: str
|
|
user_python_dir: str | None
|
|
app_python_dir: str | None
|
|
site_python_dir: str | None
|
|
log_handler: LogHandler | None
|
|
|
|
|
|
def config_exists() -> bool:
|
|
"""Has a config been created?"""
|
|
return _g_env_config is not None
|
|
|
|
|
|
def get_config() -> EnvConfig:
|
|
"""Return the active env-config. Creates default if none exists."""
|
|
if _g_env_config is None:
|
|
configure()
|
|
assert _g_env_config is not None
|
|
return _g_env_config
|
|
|
|
|
|
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,
|
|
) -> None:
|
|
"""Set up the Python environment for running a ballistica app.
|
|
|
|
This includes things such as Python paths and log redirection. For
|
|
that reason, this should be called before any other ballistica
|
|
modules are imported, since it may make changes to sys.path,
|
|
affecting where those modules get loaded from.
|
|
"""
|
|
# pylint: disable=too-many-branches
|
|
# pylint: disable=too-many-locals
|
|
# pylint: disable=too-many-statements
|
|
|
|
global _g_env_config # pylint: disable=global-statement, invalid-name
|
|
if _g_env_config is not None:
|
|
raise RuntimeError('An EnvConfig has already been created.')
|
|
|
|
# The very first thing we do is set up our logging system and feed
|
|
# Python's stdout/stderr into it. Then we can at least debug problems
|
|
# on systems where native stdout/stderr is not easily accessible
|
|
# such as Android.
|
|
log_handler = setup_logging(
|
|
log_path=None,
|
|
level=LogLevel.DEBUG,
|
|
suppress_non_root_debug=True,
|
|
log_stdout_stderr=True,
|
|
cache_size_limit=1024 * 1024,
|
|
)
|
|
|
|
# Sanity check: we should always be run in UTF-8 mode.
|
|
if sys.flags.utf8_mode != 1:
|
|
logging.warning(
|
|
"Python's UTF-8 mode is not set. Running ballistica without"
|
|
' it may lead to errors.'
|
|
)
|
|
|
|
# Now do paths. We want to set stuff up so that engine modules,
|
|
# mods, etc. are pulled from predictable places.
|
|
cwd_path = Path.cwd()
|
|
|
|
# A few paths we can ALWAYS calculate since they don't affect Python
|
|
# imports:
|
|
|
|
# Default data_dir assumes this module was imported from its
|
|
# ba_data/python subdir.
|
|
if data_dir is None:
|
|
assert Path(__file__).parts[-3:-1] == ('ba_data', 'python')
|
|
data_dir_path = Path(__file__).parents[2]
|
|
# Prefer tidy relative paths like '.' if possible.
|
|
data_dir = str(
|
|
data_dir_path.relative_to(cwd_path)
|
|
if data_dir_path.is_relative_to(cwd_path)
|
|
else data_dir_path
|
|
)
|
|
|
|
# Default config-dir is simply ~/.ballisticakit
|
|
if config_dir is None:
|
|
config_dir = str(Path(Path.home(), '.ballisticakit'))
|
|
|
|
# Ok now Python paths.
|
|
|
|
# If _babase has already been imported, there's not much we can do
|
|
# at this point aside from complain and inform for next time.
|
|
if _g_babase_imported:
|
|
app_python_dir = user_python_dir = site_python_dir = None
|
|
|
|
# We don't actually complain yet here; we simply take note
|
|
# that we weren't able to set paths. Then we complain if/when
|
|
# the app is started. This way, non-app uses of babase won't be
|
|
# filled with unnecessary warnings.
|
|
global _g_paths_set_failed # pylint: disable=global-statement
|
|
_g_paths_set_failed = True
|
|
|
|
else:
|
|
# Ok; _babase hasn't been imported yet so we can muck with
|
|
# Python paths.
|
|
|
|
# By default, app-python-dir is simply ba_data/python under
|
|
# data-dir.
|
|
if app_python_dir is None:
|
|
app_python_dir = str(Path(data_dir, 'ba_data', 'python'))
|
|
|
|
# Likewise site-python-dir defaults to ba_data/python-site-packages.
|
|
if site_python_dir is None:
|
|
site_python_dir = str(
|
|
Path(data_dir, 'ba_data', 'python-site-packages')
|
|
)
|
|
|
|
# By default, user-python-dir is simply 'mods' under config-dir.
|
|
if user_python_dir is None:
|
|
user_python_dir = str(Path(config_dir, 'mods'))
|
|
|
|
# Ok, now add these to sys.path.
|
|
|
|
# First off, strip out any instances of the path containing this
|
|
# module. We will probably be re-adding the same path in a
|
|
# moment but its technically possible that we won't be (if
|
|
# app_python_dir is overridden to somewhere else, etc.)
|
|
our_parent_path = Path(__file__).parent.resolve()
|
|
paths: list[str] = [
|
|
p for p in sys.path if Path(p).resolve() != our_parent_path
|
|
]
|
|
# Let's lookup mods first (so users can do whatever they want).
|
|
# and then our bundled scripts last (don't want bundled
|
|
# site-package stuff overwriting system versions)
|
|
paths.insert(0, user_python_dir)
|
|
paths.append(app_python_dir)
|
|
paths.append(site_python_dir)
|
|
sys.path = paths
|
|
|
|
# Attempt to create the dirs that we'll write stuff to. Not the end
|
|
# of the world if we fail though.
|
|
create_dirs: list[tuple[str, str | None]] = [
|
|
('config', config_dir),
|
|
('user_python', user_python_dir),
|
|
]
|
|
for cdirname, cdir in create_dirs:
|
|
if cdir is not None:
|
|
try:
|
|
os.makedirs(cdir, exist_ok=True)
|
|
except Exception:
|
|
logging.warning(
|
|
"Unable to create %s dir at '%s'.", cdirname, cdir
|
|
)
|
|
|
|
_g_env_config = EnvConfig(
|
|
config_dir=config_dir,
|
|
data_dir=data_dir,
|
|
user_python_dir=user_python_dir,
|
|
app_python_dir=app_python_dir,
|
|
site_python_dir=site_python_dir,
|
|
log_handler=log_handler,
|
|
)
|
|
|
|
# In embedded situations (when we're providing our own Python) let's
|
|
# also provide our own root certs so ssl works. We can consider
|
|
# overriding this in particular embedded cases if we can verify that
|
|
# system certs are working. (We also allow forcing this via an env
|
|
# var if the user desires)
|
|
if (
|
|
contains_python_dist
|
|
or os.environ.get('BA_USE_BUNDLED_ROOT_CERTS') == '1'
|
|
):
|
|
import certifi
|
|
|
|
# Let both OpenSSL and requests (if present) know to use this.
|
|
os.environ['SSL_CERT_FILE'] = os.environ[
|
|
'REQUESTS_CA_BUNDLE'
|
|
] = certifi.where()
|
|
|
|
|
|
def on_babase_import() -> None:
|
|
"""Should be called just after _babase is imported.
|
|
|
|
Sets up logging and issue warnings if anything in the running
|
|
_babase environment seems wonky. Many significant environment
|
|
modifications such as interrupt handling do not happen until
|
|
on_babase_start_app(). This allows bits of _babase to be used under
|
|
existing environments without messing things up too badly.
|
|
"""
|
|
import _babase
|
|
|
|
global _g_babase_imported # pylint: disable=global-statement
|
|
|
|
assert not _g_babase_imported
|
|
_g_babase_imported = True
|
|
|
|
# If we have a log_handler set up, wire it up to feed _babase its output.
|
|
envconfig = get_config()
|
|
if envconfig.log_handler is not None:
|
|
_feed_logs_to_babase(envconfig.log_handler)
|
|
|
|
env = _babase.pre_env()
|
|
|
|
# Give a soft warning if we're being used with a different binary
|
|
# version than we were built for.
|
|
running_build: int = env['build_number']
|
|
if running_build != TARGET_BALLISTICA_BUILD:
|
|
logging.warning(
|
|
'These scripts are meant to be used with'
|
|
' Ballistica build %d, but you are running build %d.'
|
|
" This might cause problems. Module path: '%s'.",
|
|
TARGET_BALLISTICA_BUILD,
|
|
running_build,
|
|
__file__,
|
|
)
|
|
|
|
debug_build = env['debug_build']
|
|
|
|
# We expect dev_mode on in debug builds and off otherwise;
|
|
# make noise if that's not the case.
|
|
if debug_build != sys.flags.dev_mode:
|
|
logging.warning(
|
|
'Mismatch in ballistica debug_build %s'
|
|
' and sys.flags.dev_mode %s; this may cause problems.',
|
|
debug_build,
|
|
sys.flags.dev_mode,
|
|
)
|
|
|
|
|
|
def on_babase_start_app() -> None:
|
|
"""Called when ballistica's babase module is spinning up an app."""
|
|
import gc
|
|
import _babase
|
|
|
|
global _g_babase_app_started # pylint: disable=global-statement
|
|
|
|
_g_babase_app_started = True
|
|
|
|
assert _g_babase_imported
|
|
assert config_exists()
|
|
|
|
# If we were unable to set paths earlier, complain now.
|
|
if _g_paths_set_failed:
|
|
logging.warning(
|
|
'Ballistica Python paths have not been set. This may cause'
|
|
' problems. To ensure paths are set, run baenv.configure()'
|
|
' before importing any ballistica modules.'
|
|
)
|
|
|
|
# Set up interrupt-signal handling.
|
|
|
|
# Note: I've found we need to set up our C signal handling AFTER
|
|
# we've told Python to disable its own; otherwise (on Mac at least)
|
|
# it wipes out our existing C handler.
|
|
signal.signal(signal.SIGINT, signal.SIG_DFL) # Do default handling.
|
|
_babase.setup_sigint()
|
|
|
|
# Turn off fancy-pants cyclic garbage-collection. We run it only at
|
|
# explicit times to avoid random hitches and keep things more
|
|
# deterministic. Non-reference-looped objects will still get cleaned
|
|
# up immediately, so we should try to structure things to avoid
|
|
# reference loops (just like Swift, ObjC, etc).
|
|
|
|
# FIXME - move this to Python bootstrapping code. or perhaps disable
|
|
# it completely since we've got more bg stuff happening now?...
|
|
# (but put safeguards in place to time/minimize gc pauses).
|
|
gc.disable()
|
|
|
|
# pylint: disable=c-extension-no-member
|
|
if not TYPE_CHECKING:
|
|
import __main__
|
|
|
|
# Clear out the standard quit/exit messages since they don't
|
|
# work in our embedded situation (should revisit this once we're
|
|
# usable from a standard interpreter). Note that these don't
|
|
# exist in the first place for our monolithic builds which don't
|
|
# use site.py.
|
|
for attr in ('quit', 'exit'):
|
|
if hasattr(__main__.__builtins__, attr):
|
|
delattr(__main__.__builtins__, attr)
|
|
|
|
# Also replace standard interactive help with our simplified
|
|
# non-blocking one which is more friendly to cloud/in-app console
|
|
# situations.
|
|
__main__.__builtins__.help = _CustomHelper()
|
|
|
|
# On Windows I'm seeing the following error creating asyncio loops
|
|
# in background threads with the default proactor setup:
|
|
|
|
# ValueError: set_wakeup_fd only works in main thread of the main
|
|
# interpreter.
|
|
|
|
# So let's explicitly request selector loops. Interestingly this
|
|
# error only started showing up once I moved Python init to the main
|
|
# thread; previously the various asyncio bg thread loops were
|
|
# working fine (maybe something caused them to default to selector
|
|
# in that case?..
|
|
if sys.platform == 'win32':
|
|
import asyncio
|
|
|
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
|
|
|
|
class _CustomHelper:
|
|
"""Replacement 'help' that behaves better for our setup."""
|
|
|
|
def __repr__(self) -> str:
|
|
return 'Type help(object) for help about object.'
|
|
|
|
def __call__(self, *args: Any, **kwds: Any) -> Any:
|
|
# We get an ugly error importing pydoc on our embedded platforms
|
|
# due to _sysconfigdata_xxx.py not being present (but then
|
|
# things mostly work). Let's get the ugly error out of the way
|
|
# explicitly.
|
|
|
|
# FIXME: we shouldn't be seeing this error anymore. Should
|
|
# revisit this.
|
|
import sysconfig
|
|
|
|
try:
|
|
# This errors once but seems to run cleanly after, so let's
|
|
# get the error out of the way.
|
|
sysconfig.get_path('stdlib')
|
|
except ModuleNotFoundError:
|
|
pass
|
|
|
|
import pydoc
|
|
|
|
# Disable pager and interactive help since neither works well
|
|
# with our funky multi-threaded setup or in-game/cloud consoles.
|
|
# Let's just do simple text dumps.
|
|
pydoc.pager = pydoc.plainpager
|
|
if not args and not kwds:
|
|
print(
|
|
'Interactive help is not available in this environment.\n'
|
|
'Type help(object) for help about object.'
|
|
)
|
|
return None
|
|
return pydoc.help(*args, **kwds)
|
|
|
|
|
|
def _feed_logs_to_babase(log_handler: LogHandler) -> None:
|
|
"""Route log/print output to internal ballistica console/etc."""
|
|
import _babase
|
|
|
|
def _on_log(entry: LogEntry) -> None:
|
|
# Forward this along to the engine to display in the in-app
|
|
# console, in the Android log, etc.
|
|
_babase.display_log(
|
|
name=entry.name,
|
|
level=entry.level.name,
|
|
message=entry.message,
|
|
)
|
|
|
|
# We also want to feed some logs to the old v1-cloud-log system.
|
|
# Let's go with anything warning or higher as well as the
|
|
# stdout/stderr log messages that babase.app.log_handler creates
|
|
# for us. We should retire or upgrade this system at some point.
|
|
if entry.level.value >= LogLevel.WARNING.value or entry.name in (
|
|
'stdout',
|
|
'stderr',
|
|
):
|
|
_babase.v1_cloud_log(entry.message)
|
|
|
|
# Add our callback and also feed it all entries already in the
|
|
# cache. This will feed the engine any logs that happened between
|
|
# baenv.configure() and now.
|
|
|
|
# FIXME: while this works for now, the downside is that these
|
|
# callbacks fire in a bg thread so certain things like android
|
|
# logging will be delayed compared to code that uses native logging
|
|
# calls directly. Perhaps we should add some sort of 'immediate'
|
|
# callback option to better handle such cases (similar to the
|
|
# immediate echofile stderr print that already occurs).
|
|
log_handler.add_callback(_on_log, feed_existing_logs=True)
|