Source code for app.engine.all_engine_strategies

"""Strategy enumeration backed by the explicit registry.

The legacy implementation walked ``app/engine`` with :func:`os.listdir`
(CWD-dependent), imported every module, picked classes by "first letter
uppercase", and mutated the list while iterating. It also could not load
strategies from external packages.

This module now:

1. Auto-discovers every module inside the :mod:`app.engine` package using
   :func:`pkgutil.iter_modules` against the package's resolved ``__path__``
   (independent of the current working directory), so newly added strategy
   files are picked up without editing this file.
2. Imports each module, which triggers ``@register_strategy`` and registers
   the class in :mod:`app.engine.strategy_registry`. Modules that don't
   register anything (helpers, base classes) simply contribute nothing.
3. Returns instances of every class currently in the registry. Third-party
   strategies declared via the ``game.strategies`` entry point are loaded
   lazily by the registry.
"""

from __future__ import annotations

import importlib
import logging
import pkgutil

from app.engine.check_base_strategy_class import check_class_methods_and_variables
from app.engine.strategy_registry import registered_strategies

_log = logging.getLogger(__name__)

_PACKAGE_NAME = "app.engine"

# Modules in app.engine that are infrastructure, not strategies. They are
# imported as a side-effect of normal usage; we skip them during discovery
# to avoid pointless re-imports and to make the scan auditable.
_DISCOVERY_SKIP: frozenset[str] = frozenset(
    {
        "base_strategy",
        "check_base_strategy_class",
        "strategy_registry",
        "all_engine_strategies",
    }
)

_discovery_done = False


def _discover_strategy_modules() -> None:
    """Import every strategy module in :mod:`app.engine`.

    Discovery is safe-by-construction:

    * Uses the package's own ``__path__`` (not a CWD-relative string), so it
      works regardless of where the process was launched from.
    * Restricted to direct children of :mod:`app.engine`; we don't walk into
      arbitrary subpackages.
    * Skips private modules (leading underscore) and the infrastructure
      modules listed in :data:`_DISCOVERY_SKIP`.
    * Import errors are logged and isolated: one broken strategy file cannot
      take down the whole engine.
    """
    global _discovery_done
    if _discovery_done:
        return
    _discovery_done = True

    package = importlib.import_module(_PACKAGE_NAME)
    package_path = getattr(package, "__path__", None)
    if package_path is None:
        _log.error("Package %s has no __path__; skipping discovery", _PACKAGE_NAME)
        return

    for module_info in pkgutil.iter_modules(package_path):
        name = module_info.name
        if module_info.ispkg:
            continue
        if name.startswith("_"):
            continue
        if name in _DISCOVERY_SKIP:
            continue
        full_name = f"{_PACKAGE_NAME}.{name}"
        try:
            importlib.import_module(full_name)
        except Exception as exc:
            _log.error("Failed importing strategy module %s: %s", full_name, exc)


[docs] def all_engine_strategies() -> list: """Return instances of every registered, validated strategy class. Each instance has an ``id`` attribute set to the registered strategy id (the public identifier persisted on games and exposed by the API). :return: List of strategy instances, one per registered class. Classes that fail :func:`check_class_methods_and_variables` are skipped. :rtype: list """ _discover_strategy_modules() instances: list = [] for strategy_id, cls in registered_strategies().items(): if not check_class_methods_and_variables(cls): _log.warning( "Strategy %s (%s) failed validation; skipping", strategy_id, cls.__name__, ) continue instance = cls() instance.id = strategy_id instances.append(instance) return instances