Source code for mini_arcade.modules.settings

"""
Settings module for mini-arcade.

Example settings files can be found under `examples/settings/` in the monorepo, and
are loaded automatically when running examples using
`mini-arcade run --example <example_id>`.

```python
    print("Loaded settings:")
    shared = Settings(SettingsArgs(scope="example", required=False))
    print(shared.as_dict())
    engine_config_basics = Settings(
        SettingsArgs(
            scope="example",
            name="config/engine_config_basics",
            required=False,
        )
    )
    print(engine_config_basics.as_dict())
    backend_swap = Settings(
        SettingsArgs(
            scope="example",
            name="config/backend_swap",
            required=False,
        )
    )
    print(backend_swap.as_dict())
    s_asteroids = Settings(
        SettingsArgs(scope="game", name="asteroids", required=False)
    )
    print(s_asteroids.as_dict())
    s_space = Settings(
        SettingsArgs(scope="game", name="space-invaders", required=False)
    )
    print(s_space.as_dict())
    s_deja = Settings(
        SettingsArgs(scope="game", name="deja-bounce", required=False)
    )
    print(s_deja.as_dict())
```
"""

from __future__ import annotations

import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import yaml


[docs] @dataclass class SettingsArgs: """ Arguments for loading settings. """ config_path: str | Path | None = None scope: str | None = None name: str | None = None required: bool = True force_reload: bool = False
[docs] class Settings: """ Settings reader with optional profile scoping. Supported sources: - explicit config_path - MINI_ARCADE_CONFIG_PATH env var - monorepo game-local settings under `games/<game_id>/settings/` - monorepo example settings under `examples/settings/` - repo-level defaults under `settings/settings.yml|yaml` Profile convention under monorepo root: - games: `games/<game_id>/settings/settings.yml|yaml` - examples: `examples/settings/<example_id>.yml|yaml` - shared examples: `examples/settings/settings.yml|yaml` - default: `settings/settings.yml|yaml` """ _instances: dict[tuple[str | None, str | None, str | None], "Settings"] = ( {} ) _settings: dict[str, Any] _config_path: Path | None _scope: str | None _name: str | None _TOKEN_RE = re.compile(r"\$\{([A-Za-z0-9_.-]+)\}") @staticmethod def _coerce_args( args: SettingsArgs | None = None, **kwargs: Any, ) -> SettingsArgs: if args is None: return SettingsArgs(**kwargs) if not isinstance(args, SettingsArgs): raise TypeError( "Settings expects a SettingsArgs instance or kwargs" ) if kwargs: raise TypeError( "Settings does not accept both a SettingsArgs instance and kwargs" ) return args def __new__( cls, args: SettingsArgs | None = None, **kwargs: Any, ): args = cls._coerce_args(args, **kwargs) key = ( str(args.config_path) if args.config_path is not None else None, args.scope, args.name, ) if args.force_reload or key not in cls._instances: inst = super().__new__(cls) inst._settings = {} inst._config_path = None inst._scope = args.scope inst._name = args.name inst._load_settings( config_path=args.config_path, scope=args.scope, name=args.name, required=args.required, ) cls._instances[key] = inst return cls._instances[key] def __init__( self, args: SettingsArgs | None = None, **kwargs: Any, ) -> None: # ``__new__`` performs all loading and caching; keep ``__init__`` as a # compatibility no-op so callers can use either SettingsArgs or kwargs. self._coerce_args(args, **kwargs) @staticmethod def _is_repo_root(path: Path) -> bool: return ( (path / "pyproject.toml").exists() and (path / "packages").exists() and (path / "examples").exists() ) @classmethod def _find_repo_root(cls) -> Path | None: starts = [Path.cwd().resolve(), Path(__file__).resolve()] for start in starts: current = start if start.is_dir() else start.parent for candidate in (current, *current.parents): if cls._is_repo_root(candidate): return candidate return None @staticmethod def _name_path(name: str | None) -> Path: if not name: return Path("shared") clean = str(name).strip("/\\") if not clean: return Path("shared") return Path(*clean.replace("\\", "/").split("/")) @classmethod def _profile_candidates( cls, *, repo_root: Path, scope: str | None, name: str | None, ) -> list[Path]: if scope == "game": if not name: return [] base = ( repo_root / "games" / cls._name_path(name) / "settings" / "settings" ) return [base.with_suffix(".yml"), base.with_suffix(".yaml")] if scope == "example": settings_root = repo_root / "examples" / "settings" if name is None: base = settings_root / "settings" else: base = settings_root / cls._name_path(name) return [base.with_suffix(".yml"), base.with_suffix(".yaml")] # default repo-level settings file base = repo_root / "settings" / "settings" return [ base.with_suffix(".yml"), base.with_suffix(".yaml"), ] @classmethod def _default_candidates( cls, *, scope: str | None = None, name: str | None = None, ) -> list[Path]: candidates: list[Path] = [] env_path = os.getenv("MINI_ARCADE_CONFIG_PATH") if env_path: candidates.append(Path(env_path).expanduser()) repo_root = cls._find_repo_root() if repo_root is not None: # examples can load shared defaults and overlay one specific profile. if scope == "example" and name is not None: candidates.extend( cls._profile_candidates( repo_root=repo_root, scope="example", name=None, ) ) candidates.extend( cls._profile_candidates( repo_root=repo_root, scope="example", name=name, ) ) elif scope == "game" and name is not None: candidates.extend( cls._profile_candidates( repo_root=repo_root, scope="game", name=name, ) ) else: candidates.extend( cls._profile_candidates( repo_root=repo_root, scope=scope, name=None, ) ) return candidates @staticmethod def _first_existing(candidates: list[Path]) -> Path | None: for candidate in candidates: if candidate.exists(): return candidate.resolve() return None def _resolve_config_paths( self, config_path: str | Path | None = None, *, scope: str | None = None, name: str | None = None, ) -> list[Path]: if config_path is not None: resolved = Path(config_path).expanduser() if not resolved.is_absolute(): resolved = (Path.cwd() / resolved).resolve() return [resolved] env_path = os.getenv("MINI_ARCADE_CONFIG_PATH") if env_path: return [Path(env_path).expanduser().resolve()] repo_root = self._find_repo_root() if repo_root is None: return [] if scope == "example": shared = self._first_existing( self._profile_candidates( repo_root=repo_root, scope="example", name=None, ) ) if name is None: return [shared] if shared is not None else [] specific = self._first_existing( self._profile_candidates( repo_root=repo_root, scope="example", name=name, ) ) paths: list[Path] = [] if shared is not None: paths.append(shared) if specific is not None: paths.append(specific) return paths target = self._first_existing( self._profile_candidates( repo_root=repo_root, scope=scope, name=name, ) ) return [target] if target is not None else [] def _load_settings( self, *, config_path: str | Path | None = None, scope: str | None = None, name: str | None = None, required: bool = True, ) -> None: resolved_paths = self._resolve_config_paths( config_path, scope=scope, name=name, ) if not resolved_paths: searched = "\n".join( f"- {p}" for p in self._default_candidates(scope=scope, name=name) ) if required: raise FileNotFoundError( "No settings file found. Searched:\n" f"{searched}" ) self._settings = {} self._config_path = None return merged: dict[str, Any] = {} for resolved in resolved_paths: if not resolved.exists(): if required: raise FileNotFoundError( f"Config file not found at {resolved}" ) self._settings = {} self._config_path = None return with open(resolved, "r", encoding="utf-8") as file: data = yaml.safe_load(file) or {} if not isinstance(data, dict): raise ValueError( f"Config at {resolved} must be a mapping/dict at root." ) merged = self._deep_merge_dicts(merged, data) self._settings = merged self._config_path = resolved_paths[-1]
[docs] @classmethod def for_game( cls, game_id: str, *, required: bool = False, force_reload: bool = False, ) -> "Settings": """ Load settings profile for one game. """ return cls( SettingsArgs( scope="game", name=game_id, required=required, force_reload=force_reload, ) )
[docs] @classmethod def for_example( cls, example_id: str | None = None, *, required: bool = False, force_reload: bool = False, ) -> "Settings": """ Load settings profile for one example. If example_id is None, loads shared example settings profile. """ return cls( SettingsArgs( scope="example", name=example_id, required=required, force_reload=force_reload, ) )
@property def config_path(self) -> Path | None: """ Path to currently loaded settings file. """ return self._config_path
[docs] def get(self, key_: str, default: Any = None) -> Any: """ Get nested key using dot notation. """ data: Any = self._settings for part in key_.split("."): if not isinstance(data, dict): return default data = data.get(part, default) if data is default: return default return data
[docs] def section(self, key_: str) -> dict[str, Any]: """ Return one section as dict. """ data = self.get(key_, {}) return data if isinstance(data, dict) else {}
[docs] def project_root(self) -> Path: """ Resolve project root for this settings profile. Priority: 1) project.root / paths.project_root key in yaml 2) inferred from scope/name: - game -> <repo>/games/<game_id> - example -> <repo>/examples/catalog/<example_id> 3) repo root 4) cwd """ configured = self.get("project.root") or self.get("paths.project_root") if configured: raw = str(configured).strip() expanded = self._expand_tokens( raw, tokens=self._base_token_values() ) p = Path(expanded) if p.is_absolute(): return p.resolve() repo_root = self._find_repo_root() if repo_root is not None: return (repo_root / p).resolve() return (Path.cwd() / p).resolve() return self._inferred_project_root()
[docs] def assets_root(self) -> Path: """ Resolve assets root for this settings profile. """ configured = self.get("project.assets_root") or self.get( "paths.assets_root" ) if configured: raw = str(configured).strip() expanded = self._expand_tokens( raw, tokens=self._project_token_values(), ) p = Path(expanded) if p.is_absolute(): return p.resolve() return (self.project_root() / p).resolve() return (self.project_root() / "assets").resolve()
def _token_values(self) -> dict[str, str]: tokens = self._base_token_values() tokens["project_root"] = str(self.project_root()) tokens["assets_root"] = str(self.assets_root()) return tokens def _base_token_values(self) -> dict[str, str]: repo = self._find_repo_root() settings_dir = ( self._config_path.parent if self._config_path else Path.cwd() ) tokens = { "cwd": str(Path.cwd().resolve()), "settings_dir": str(settings_dir.resolve()), } if repo is not None: tokens["repo_root"] = str(repo.resolve()) return tokens def _project_token_values(self) -> dict[str, str]: tokens = self._base_token_values() tokens["project_root"] = str(self.project_root()) return tokens def _inferred_project_root(self) -> Path: repo_root = self._find_repo_root() if repo_root is None: return Path.cwd().resolve() if self._scope == "game" and self._name: return (repo_root / "games" / str(self._name)).resolve() if self._scope == "example" and self._name: rel = str(self._name).replace("\\", "/").strip("/") return (repo_root / "examples" / "catalog" / rel).resolve() return repo_root.resolve() def _expand_tokens( self, value: str, *, tokens: dict[str, str] | None = None ) -> str: if tokens is None: tokens = self._token_values() def _replace(match: re.Match[str]) -> str: token = match.group(1) return tokens.get(token, match.group(0)) expanded = self._TOKEN_RE.sub(_replace, value) return os.path.expandvars(os.path.expanduser(expanded))
[docs] def resolve_path( self, path_value: str | Path, *, default_to_cwd: bool = False ) -> Path: """ Resolve one path using token/env expansion and project-root defaults. Supported placeholders: - ${repo_root} - ${project_root} - ${assets_root} - ${settings_dir} - ${cwd} """ raw = str(path_value).strip() expanded = self._expand_tokens(raw) p = Path(expanded) if p.is_absolute(): return p.resolve() if default_to_cwd: return (Path.cwd() / p).resolve() return (self.project_root() / p).resolve()
[docs] def resolve_asset_path(self, path_value: str | Path) -> Path: """ Resolve one asset path relative to assets root when not absolute. """ raw = str(path_value).strip() expanded = self._expand_tokens(raw) p = Path(expanded) if p.is_absolute(): return p.resolve() return (self.assets_root() / p).resolve()
@staticmethod def _deep_copy_dict(data: dict[str, Any]) -> dict[str, Any]: copied: dict[str, Any] = {} for key, value in data.items(): if isinstance(value, dict): copied[key] = Settings._deep_copy_dict(value) elif isinstance(value, list): copied[key] = [ ( Settings._deep_copy_dict(item) if isinstance(item, dict) else item ) for item in value ] else: copied[key] = value return copied @staticmethod def _deep_merge_dicts( base: dict[str, Any], override: dict[str, Any] ) -> dict[str, Any]: out = Settings._deep_copy_dict(base) for key, value in override.items(): if isinstance(value, dict) and isinstance(out.get(key), dict): out[key] = Settings._deep_merge_dicts(out[key], value) elif isinstance(value, dict): out[key] = Settings._deep_copy_dict(value) elif isinstance(value, list): out[key] = [ ( Settings._deep_copy_dict(item) if isinstance(item, dict) else item ) for item in value ] else: out[key] = value return out def _expand_tokens_in_value(self, value: Any) -> Any: if isinstance(value, dict): return { key: self._expand_tokens_in_value(item) for key, item in value.items() } if isinstance(value, list): return [self._expand_tokens_in_value(item) for item in value] if isinstance(value, str): return self._expand_tokens(value) return value
[docs] def engine_config_defaults(self) -> dict[str, Any]: """ Engine settings defaults aligned to `mini_arcade_core.engine.game_config.EngineConfig`. """ src = self.section("engine_config") virtual = src.get("virtual_resolution", (800, 600)) if isinstance(virtual, (list, tuple)) and len(virtual) == 2: virtual_resolution = (int(virtual[0]), int(virtual[1])) else: virtual_resolution = (800, 600) postfx_src = src.get("postfx", {}) if isinstance(src, dict) else {} if not isinstance(postfx_src, dict): postfx_src = {} active = postfx_src.get("active", []) if not isinstance(active, list): active = [] return { "fps": int(src.get("fps", 60)), "virtual_resolution": virtual_resolution, "enable_profiler": bool(src.get("enable_profiler", False)), "postfx": { "enabled": bool(postfx_src.get("enabled", True)), "active": [str(item) for item in active], }, }
[docs] def scene_defaults(self) -> dict[str, Any]: """ Scene bootstrap defaults aligned to `mini_arcade_core.engine.game_config.SceneConfig`. """ src = self.section("scene") if not isinstance(src, dict): src = {} scene_registry = src.get("scene_registry", {}) if not isinstance(scene_registry, dict): scene_registry = {} discover_packages = scene_registry.get("discover_packages", []) if not isinstance(discover_packages, list): discover_packages = [] initial_scene = src.get("initial_scene", "main") return { "initial_scene": str(initial_scene), "discover_packages": [ str(item) for item in discover_packages if isinstance(item, str) ], }
[docs] def gameplay_defaults(self) -> dict[str, Any]: """ Gameplay settings defaults consumed by runtime gameplay settings. """ src = self.section("gameplay") if not isinstance(src, dict): return {} out: dict[str, Any] = {} difficulty = src.get("difficulty") if isinstance(difficulty, str): out["difficulty"] = {"level": difficulty} elif isinstance(difficulty, dict): level = difficulty.get("level", difficulty.get("default")) if level is not None: out["difficulty"] = {"level": str(level)} controls = src.get("controls") if isinstance(controls, dict): out["controls"] = self._deep_copy_dict(controls) debug_overlay = src.get("debug_overlay") if isinstance(debug_overlay, dict): out["debug_overlay"] = self._deep_copy_dict(debug_overlay) elif isinstance(debug_overlay, bool): out["debug_overlay"] = bool(debug_overlay) scenes = src.get("scenes") if isinstance(scenes, dict): out["scenes"] = self._deep_copy_dict(scenes) return self._expand_tokens_in_value(out)
[docs] def backend_defaults( self, *, resolve_paths: bool = False ) -> dict[str, Any]: """ Backend settings defaults for backend-specific settings builders. """ backend = self.section("backend") if not resolve_paths: return backend out = self._deep_copy_dict(backend) fonts = out.get("fonts") if isinstance(fonts, dict): path_ = fonts.get("path") if isinstance(path_, str): fonts["path"] = str(self.resolve_path(path_)) elif isinstance(fonts, list): for item in fonts: if not isinstance(item, dict): continue path_ = item.get("path") if isinstance(path_, str): item["path"] = str(self.resolve_path(path_)) audio = out.get("audio") if isinstance(audio, dict): sounds = audio.get("sounds") if isinstance(sounds, dict): for sound_id, sound_path in list(sounds.items()): if isinstance(sound_path, str): sounds[sound_id] = str( self.resolve_asset_path(sound_path) ) return out
[docs] def as_dict(self) -> dict[str, Any]: """ Return full settings dictionary. """ return dict(self._settings)
# Lazy-by-default global settings object (no exception if file is missing). settings = Settings(SettingsArgs(required=False))