from collections.abc import Sequence from contextlib import contextmanager from itertools import chain from typing import TYPE_CHECKING, Any, TypeVar, cast, overload from cyclopts.group_extractors import inverse_groups_from_app from cyclopts.parameter import Parameter if TYPE_CHECKING: from cyclopts.core import App V = TypeVar("V") class AppStack: def __init__(self, app): # the ``stack`` is guaranteed to have the self-referencing app at the top of the stack. self.stack: list[list[App]] = [[app]] # Stack of overrides passed to parse_args/call that should be propagated self.overrides_stack: list[dict[str, Any]] = [{}] @contextmanager def __call__(self, apps: Sequence["App"] | Sequence[str], overrides: dict[str, Any] | None = None): # set `overrides` default-values with current overrides so that they properly propagate down the call-stack. overrides = self.overrides | (overrides or {}) self.overrides_stack.append(overrides or {}) if not apps: try: yield finally: self.overrides_stack.pop() return # Convert strings to Apps if needed if isinstance(apps[0], str): str_apps = cast(Sequence[str], apps) _, apps_tuple, _ = self.stack[0][0].parse_commands(str_apps, include_parent_meta=True) resolved_apps: list[App] = list(apps_tuple) else: resolved_apps = cast(list["App"], list(apps)) del apps if not resolved_apps: try: yield finally: self.overrides_stack.pop() return so_far = [] app_ids = {id(app) for app in resolved_apps} for app in resolved_apps: if app._meta_parent is None: # Do not include the prior meta-app. while so_far and so_far[-1]._meta_parent is not None: so_far.pop() so_far.append(app) app.app_stack.stack.append(so_far.copy()) # Also push the overrides onto this app's stack app.app_stack.overrides_stack.append(overrides or {}) # Also traverse the app's meta app meta_app = app while (meta_app := meta_app._meta) is not None: if id(meta_app) in app_ids: # It will be handled conventionally continue meta_subapps = so_far.copy() meta_subapps.append(meta_app) meta_app.app_stack.stack.append(meta_subapps) # Also push the overrides onto the meta app's stack meta_app.app_stack.overrides_stack.append(overrides or {}) try: yield finally: for app in resolved_apps: app.app_stack.stack.pop() app.app_stack.overrides_stack.pop() # Also pop from meta apps meta_app = app while (meta_app := meta_app._meta) is not None: if id(meta_app) in app_ids: continue meta_app.app_stack.stack.pop() meta_app.app_stack.overrides_stack.pop() # Pop overrides from stack self.overrides_stack.pop() @property def overrides(self) -> dict: out = {} for overrides_frame in reversed(self.overrides_stack): for key, value in overrides_frame.items(): if value is not None: out.setdefault(key, value) return out @property def default_parameter(self) -> Parameter: """default_parameter has special resolution since it needs to include the command groups in the derivation.""" cparams = [] for child_app in chain.from_iterable(self.stack): if child_app._meta_parent: continue cparams.extend([group.default_parameter for group in child_app.app_stack.command_groups]) cparams.append(child_app.default_parameter) return Parameter.combine(*cparams) @property def current_frame(self) -> list["App"]: if not self.stack: raise ValueError return self.stack[-1] @overload def resolve(self, attribute: str) -> Any: ... @overload def resolve(self, attribute: str, override: V) -> V: ... @overload def resolve(self, attribute: str, override: V | None, fallback: V) -> V: ... @overload def resolve(self, attribute: str, override: V | None = None, *, fallback: V) -> V: ... def resolve(self, attribute: str, override: V | None = None, fallback: V | None = None) -> V | None: """Resolve an attribute from the App hierarchy.""" if override is not None: return override # Check if we have a stored override from parent invocations (most recent first) for overrides_frame in reversed(self.overrides_stack): if attribute in overrides_frame: value = overrides_frame[attribute] if value is not None: return value # `reversed` so that "closer" apps have higher priority. for app in reversed(list(chain.from_iterable(self.stack))): result = getattr(app, attribute) if result is not None: return result # Check parenting meta app(s) meta_app = app while (meta_app := meta_app._meta_parent) is not None: result = getattr(meta_app, attribute) if result is not None: return result return fallback @property def command_groups(self) -> list: command_app = self.current_frame[-1] try: current_app: App | None = self.current_frame[-2] except IndexError: current_app = None while current_app is not None: try: return next(x for x in inverse_groups_from_app(current_app) if x[0] is command_app)[1] except StopIteration: current_app = current_app._meta_parent return []