diff --git a/tests/assets/cli/func_other_name_with_import.py b/tests/assets/cli/func_other_name_with_import.py new file mode 100644 index 0000000000..08b9cb197e --- /dev/null +++ b/tests/assets/cli/func_other_name_with_import.py @@ -0,0 +1,7 @@ +from pathlib import Path + +app = "not a Typer app" + + +def some_function(name: str = "World"): + print(f"Hello {name} from {Path('.').name or 'here'}") diff --git a/tests/assets/cli/multi_app_any_name.py b/tests/assets/cli/multi_app_any_name.py new file mode 100644 index 0000000000..d4e561abce --- /dev/null +++ b/tests/assets/cli/multi_app_any_name.py @@ -0,0 +1,11 @@ +import typer + +zeta = typer.Typer() + + +@zeta.command() +def hello(): + print("zeta") + + +alpha = typer.Typer() diff --git a/tests/test_cli/test_app_order.py b/tests/test_cli/test_app_order.py new file mode 100644 index 0000000000..f4409f3ada --- /dev/null +++ b/tests/test_cli/test_app_order.py @@ -0,0 +1,33 @@ +import os +import subprocess +import sys + + +def run_with_seed(asset: str, seed: int, *args: str) -> str: + env = {**os.environ, "PYTHONHASHSEED": str(seed)} + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", "-m", "typer", asset, "run", *args], + capture_output=True, + encoding="utf-8", + env=env, + ) + return result.stdout + + +def test_first_app_in_file_is_used_for_any_seed(): + outputs = { + run_with_seed("tests/assets/cli/multi_app_any_name.py", seed) + for seed in range(8) + } + assert outputs == {"zeta\n"} + + +def test_first_function_in_file_is_used_not_an_import(): + outputs = { + run_with_seed( + "tests/assets/cli/func_other_name_with_import.py", seed, "--name", "Camila" + ) + for seed in range(8) + } + assert len(outputs) == 1 + assert "Hello Camila" in outputs.pop() diff --git a/typer/cli.py b/typer/cli.py index 98f9f4ec77..f5df4cd0da 100644 --- a/typer/cli.py +++ b/typer/cli.py @@ -1,4 +1,5 @@ import importlib.util +import inspect import re import sys from pathlib import Path @@ -86,18 +87,19 @@ def get_typer_from_module(module: Any) -> typer.Typer | None: sub_app = typer.Typer() sub_app.command()(func_obj) return sub_app - # Iterate and get a default object to use as CLI - local_names = dir(module) - local_names_set = set(local_names) + # Iterate and get a default object to use as CLI, in the order the names + # were defined in the file + local_names = [name for name in vars(module) if not name.startswith("__")] # Try to get a default Typer app for name in default_app_names: - if name in local_names_set: - obj = getattr(module, name, None) - if isinstance(obj, typer.Typer): - return obj - # Try to get any Typer app - for name in local_names_set - set(default_app_names): - obj = getattr(module, name) + obj = getattr(module, name, None) + if isinstance(obj, typer.Typer): + return obj + # Try to get the first Typer app + for name in local_names: + if name in default_app_names: + continue + obj = getattr(module, name, None) if isinstance(obj, typer.Typer): return obj # Try to get a default function @@ -107,10 +109,12 @@ def get_typer_from_module(module: Any) -> typer.Typer | None: sub_app = typer.Typer() sub_app.command()(func_obj) return sub_app - # Try to get any func app - for func_name in local_names_set - set(default_func_names): - func_obj = getattr(module, func_name) - if callable(func_obj): + # Try to get the first function defined in the file + for func_name in local_names: + if func_name in default_func_names: + continue + func_obj = getattr(module, func_name, None) + if inspect.isfunction(func_obj) and func_obj.__module__ == module.__name__: sub_app = typer.Typer() sub_app.command()(func_obj) return sub_app