File size: 5,411 Bytes
53dbcc1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
"""Load Cyclopts App objects from Python scripts."""

import importlib.util
import sys
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from cyclopts import App

from cyclopts.command_spec import CommandSpec


@contextmanager
def _suppress_app_execution():
    """Temporarily disable App.__call__ to prevent execution during module loading.

    This context manager replaces App.__call__ with a no-op function, allowing
    scripts that call app() at module level to be imported without executing.
    """
    from cyclopts import App

    original_call = App.__call__

    def _dummy_call(self, *args, **kwargs):
        """No-op replacement for App.__call__ during module loading."""
        return None

    try:
        App.__call__ = _dummy_call
        yield
    finally:
        App.__call__ = original_call


def load_app_from_script(script: str | Path) -> tuple["App", str]:
    """Load a Cyclopts App object from a Python script.

    Parameters
    ----------
    script : str | Path
        Python script path, optionally with ``':app_object'`` notation to specify
        the :class:`App` object. If not specified, will search for :class:`App`
        objects in the script's global namespace.

    Returns
    -------
    tuple[App, str]
        The loaded :class:`App` object and its name.

    Raises
    ------
    SystemExit
        If the script cannot be loaded, no App objects are found, or multiple
        App objects exist without specification.
    """
    # Avoid circular import
    from cyclopts import App

    # Parse the script path and optional app object
    app_name = None
    script_str = str(script)
    if ":" in script_str:
        # Split on the last colon
        script_path_str, potential_app_name = script_str.rsplit(":", 1)
        # Only treat it as an app name if it looks like a Python identifier
        # (no path separators), otherwise it may be part of a Windows path like C:\path\to\file.py
        if potential_app_name and not any(sep in potential_app_name for sep in ["/", "\\"]):
            # Looks like an app name
            app_name = potential_app_name
            script_path = Path(script_path_str)
        else:
            # It's part of the path (e.g., Windows drive letter)
            script_path = Path(script)
    else:
        script_path = Path(script)

    script_path = script_path.resolve()

    if not script_path.exists():
        print(f"Error: Script '{script_path}' not found.", file=sys.stderr)
        sys.exit(1)

    if not script_path.suffix == ".py":
        print(f"Error: '{script_path}' is not a Python file.", file=sys.stderr)
        sys.exit(1)

    # Load the module
    spec = importlib.util.spec_from_file_location("__cyclopts_doc_module", script_path)
    if spec is None or spec.loader is None:
        print(f"Error: Could not load module from '{script_path}'.", file=sys.stderr)
        sys.exit(1)

    module = importlib.util.module_from_spec(spec)
    sys.modules["__cyclopts_doc_module"] = module

    with _suppress_app_execution():
        spec.loader.exec_module(module)

    # Find the App object
    if app_name:
        # User specified the app object name
        if not hasattr(module, app_name):
            print(f"Error: No object named '{app_name}' found in '{script_path}'.", file=sys.stderr)
            sys.exit(1)
        app_obj = getattr(module, app_name)
        if not isinstance(app_obj, App):
            print(f"Error: '{app_name}' is not a Cyclopts App object.", file=sys.stderr)
            sys.exit(1)
        return app_obj, app_name
    else:
        # Heuristic: find App objects in the module's global namespace
        app_objects = []
        for name in dir(module):
            if not name.startswith("_"):  # Skip private/protected names
                obj = getattr(module, name)
                if isinstance(obj, App):
                    app_objects.append((name, obj))

        if not app_objects:
            print(f"Error: No Cyclopts App objects found in '{script_path}'.", file=sys.stderr)
            sys.exit(1)

        if len(app_objects) > 1:
            # Filter out Apps that are registered as commands to other Apps
            # Skip CommandSpec - those are lazy imports from other modules, not apps from this file
            registered_apps = []
            for _, app in app_objects:
                if hasattr(app, "_commands"):
                    # Only include direct App references; CommandSpec entries can't point to apps in this file
                    registered_apps.extend(cmd for cmd in app._commands.values() if not isinstance(cmd, CommandSpec))

            # Keep only Apps that are not registered to others
            filtered_apps = [(name, app) for name, app in app_objects if app not in registered_apps]

            if filtered_apps:
                app_objects = filtered_apps

            if len(app_objects) > 1:
                names = ", ".join(name for name, _ in app_objects)
                script_str = str(script) if isinstance(script, Path) else script
                print(
                    f"Error: Multiple App objects found: {names}. Please specify one using '{script_str}:app_name'.",
                    file=sys.stderr,
                )
                sys.exit(1)

        name, app_obj = app_objects[0]
        return app_obj, name