Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

print argument string #302

Open
vhdirk opened this issue Jan 17, 2025 · 3 comments
Open

print argument string #302

vhdirk opened this issue Jan 17, 2025 · 3 comments

Comments

@vhdirk
Copy link

vhdirk commented Jan 17, 2025

So, not content with how pytest uses argparse while there are clearly much better alternatives around, I am trying to hack cyclopts into conftest.py :).

No, this isn't a feature request at all :)

This is what I came up with:

@dataclass
class CliParams:
    images: Path
    device: DeviceOptions


def cli_args(args: Annotated[CliParams, Parameter(name="*")]) -> CliParams:
    return args

def pytest_configure(config):
    app = App()
    app.default(cli_args)
    command, bound, _, _ = app.parse_known_args(sys.argv[1:])

    args = command(*bound.args, **bound.kwargs)

    setattr(pytest, "_cli_args", args)


@pytest.fixture
def cli(request) -> CliParams:
    return getattr(pytest, "_cli_args")

It's not pretty to say the least, but at this point I don't really care :)
What bugs me is that pytest --help obviously does not print these with its help message.

An alternative is a launcher script that uses cyclopts and then passes the remainder of the arguments to pytest.main(...). This has other drawbacks. Mainly that you have to remember you can't just run pytest.

Now what would be absolutely ugly ideal is to serialize what cyclopts can parse and shove it into pytest_addoption. This is a terrible idea. That makes it fun 😄 . Any ideas on how I could hack this together?

@BrianPugh
Copy link
Owner

you could call app.assemble_argument_collection(), which returns an ArgumentCollection object. An argument collection is basically a fully resolved description of the set of arguments that the default_command function could take.

You could then iterate over it, maybe something like:

def pytest_addoption(parser):
    app = App()
    app.default(cli_args)
    argument_collection = app.assemble_argument_collection()
    for argument in argument_collection:
        parser.addoption(argument.name, ...)  # will have to figure out how to translate the other fields.

Perhaps concatenating helps might be easier. You can make your own help-handler:

@app.command(name=("--help", "-h")):
def custom_help():
    # TODO: somehow pass in pytest's parser and print that help here too.
    app.help_print(sys.argv[1:])

@vhdirk
Copy link
Author

vhdirk commented Jan 17, 2025

Ooh, splendid. pytest adds the options to their own help, so this might be just what I'm looking for. thanks!

@vhdirk
Copy link
Author

vhdirk commented Jan 17, 2025

As ugly as this is, I'm just going to leave this here for future internet travellers. It's ugly and hackish and no-one should use this. But for the simple things it seems to work.

Parameters in nested dataclasses don't work yet.
I think I could even do away with that silly empty function and just annotate the class itself. And then, on the surface, it would look pretty clean 😄 . Just don't pull the rug.

_ScopeName = Literal["session", "package", "module", "class", "function"]

@dataclass
class CliParams:
    images: Path
    device: DeviceOptions,



def cyclopts_fixture(
    description: str = "",
    scope: _ScopeName | Callable[[str, pytest.Config], _ScopeName] = "function",
    params: Iterable[object] | None = None,
    autouse: bool = False,
    ids: Sequence[object | None] | Callable[[Any], object | None] | None = None,
    name: str | None = None,
):
    def _cli_args(func: Callable) -> Callable:
        fname = name if name is not None else func.__name__
        type_hints = get_type_hints(func)
        return_type = type_hints.get("return", None)
        app = App()
        app.default(return_type)

        cli_args_app = getattr(pytest, "_cli_args_app", {})
        cli_args_app[fname] = {"app": app, "description": description}

        setattr(pytest, f"_cli_args_app", cli_args_app)

        @wraps(func)
        def wrapper(*args, **kwargs):
            cli_args = getattr(pytest, "_cli_args", {}).get(fname, None)
            return cli_args

        return pytest.fixture(wrapper, scope=scope, params=params, autouse=autouse, ids=ids, name=name)

    return _cli_args


def flatten_arguments(root: Argument) -> list[Argument]:
    if root.children:
        return root.children

    return [root]


def unique(args: list[Argument]) -> list[Argument]:
    res: dict[str, Argument] = {}

    for arg in args:
        res[arg.name] = arg
    return list(res.values())


def pytest_addoption(parser: pytest.Parser):
    for name, appdef in getattr(pytest, "_cli_args_app", {}).items():
        app = appdef["app"]
        description = appdef["description"]

        group = parser.getgroup(name, description)
        args: list[Argument] = []
        argument_collection = app.assemble_argument_collection()
        for argument in argument_collection:
            args.extend(flatten_arguments(argument))

        for arg in unique(args):
            dest = arg.name.replace(".", "_").strip("-")
            group.addoption(arg.name, dest=dest, type=arg.hint, help=arg.parameter.help)


def pytest_configure(config: pytest.Config):
    for name, appdef in getattr(pytest, "_cli_args_app", {}).items():
        app = appdef["app"]
        _, bound, _, _ = app.parse_known_args(sys.argv)

        args = bound.args[0]

        cli_args = getattr(pytest, "_cli_args", {})
        cli_args[name] = args

        setattr(pytest, f"_cli_args", cli_args)


@cyclopts_fixture()
def cli(request) -> CliParams: ...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants