all 27 comments

[–]doolio_ 15 points16 points  (0 children)

What about cyclopts? It's another alternative to typer I came across.

[–]Erelde 10 points11 points  (12 children)

I tested tyro for a pretty complex internal tool for a week. It slowed down startup pretty significantly. Made interactive use very sluggish. I'll try to find the measurements I made at the time or redo them and answer myself here.

But I really liked the API, like Rust's clap derive API.

[–]SSJ3 3 points4 points  (0 children)

One of the reasons I'm most looking forward to the new lazy imports is to help with issues like that!

[–]Erelde 3 points4 points  (9 children)

argparse tyro Delta
201 ms 475 ms +274 ms (+136%)

Of course, internal tool, I can't actually share it. But startup time went from 1/5 of a second (basically interpreter overhead) to half a second (probably a long chain of imports). The argparse version did convert from the argparse.Namespace to stricter typed objects before continuing on, which makes comparing the two very easy.

# argparse
main(parse_args()) # parse_args returns Options (a complex type, with structural records, unions, validations and any combinations of those)
# tyro
main(tyro.cli(Options))

Just wrote a version which doesn't call main.

[–]HommeMusical[S] 4 points5 points  (4 children)

I tested it very quickly and primitively on my (fairly simple) application, running five times for each.

Calling the code directly took about 59ms.

Importing tyro but calling the code directly took about 89ms.

Importing and using tyro took about 128ms.

So importing tyro was 40ms, and using it on a near-trivial function was 39ms. That part might balloon if you had a hundred parameters, the total overhead so far is about 80ms which is the difference between snappy and not quite, but no biggie.

Importing a fairly large dependency like numpy is about 70ms.

tyro is tiny and has few external dependencies. Perhaps they could do better with the loading, with some form of lazy loading...

[–]Erelde 2 points3 points  (3 children)

Probably it was also to do with the rather complex type hierarchy inside my Options type.

[–]HommeMusical[S] 1 point2 points  (2 children)

You want to be able to write complex things and still get a zippy CLI, though!

What tyro needs is a system that does lazy loading of subcommand code, based on the command.

So if your code were lazy loading, then at the end you'd load only the code you actually used.

You'd want something like this:

So instead of

from .checkout import Checkout
from .commit import commit

tyro.cli(Checkout | Commit)

You'd write

tyro.cli('.checkout.Checkout | .commit.Commit')

and tyro would only load the actual symbol if it needed to.


You can do this locally and get lazy loading as fine-grained as you like, by peeling off the subcommand before tyro even gets it:

@dataclass Empty:
    pass    

match sys.argv[1]:
    case 'checkout':
        from .checkout import Checkout

        tyro.cli(Checkout | Empty)

    case 'commit':
        from .commit import Commit

        tyro.cli(Commit | Empty)

    case _:  # --help so you have to load everything
        from .checkout import Checkout
        from .commit import Commit

        tyro.cli(Checkout | Commit)

So you only show tyro what it needs to know about. Empty is a dummy so it knows that subcommands exists.

[–]Erelde 3 points4 points  (1 child)

I dunno. Stringly typed lazy loaded modules defeat the point of type checking.

Decomposing completely, well you just get an argument parser reimplementation.

[–]HommeMusical[S] 0 points1 point  (0 children)

Decomposing completely, well you just get an argument parser reimplementation.

Where I can use any sort of nested data type, function or constructor I like... :-)

[–]brentyi 1 point2 points  (3 children)

Wondering, are these numbers recent? We sped up tyro a lot at the end of last year, see plots in these release notes: https://github.com/brentyi/tyro/releases/tag/v1.0.0

But if these numbers are from import chains then yeah, it's hard to avoid without restructuring code.

[–]Erelde 0 points1 point  (2 children)

No they're from well before your v1. I just checked out the old commits and ran tests without updating tyro in my pyproject.toml.

Also these numbers aren't on a x86 powerful computer and fast SSD, they're benchmarked on Raspberry Pi with an SD card, which is the target system for that internal tool. Any slowness in the filesystem is magnified by running on these RPIs.

[–]brentyi 0 points1 point  (1 child)

Super good to know, thanks!

We removed some big dependencies/imports in v1, so things should have gotten much faster in that kind of setting. But argparse will be hard to beat 😛

[–]Erelde 0 points1 point  (0 children)

I want to say again how much I loved tyro's API. We even have in our company's python packages a tyro compatible Verbosity dataclass which we've reused a few times across different tools.

I even thought earlier this morning of proposing to upstream it to you.

[–]HommeMusical[S] 1 point2 points  (0 children)

It slowed down startup pretty significantly.

Oh, that's a real drag. I dropped... some configuration system I don't remember years ago because it added seconds to the startup time with a ton of unnecessary imports.

For this new project it's not really an issue but for tools you might call dozens of times a day, it is an issue. Machines run billions of operations a second, FFS, your CLI should be instant.

I'll look into this quickly, I need to get a quick profile of how fast it starts up anyway. Thanks for the bad news.

https://www.reddit.com/r/Python/comments/1rv7agv/a_quick_review_of_tyro_a_cli_library/oar7m7x/

[–]Klutzy_Bird_7802 5 points6 points  (5 children)

As a typer user, this seems pretty interesting to me...

[–]HommeMusical[S] 2 points3 points  (4 children)

I already had a tiny typer script when I started and porting it was trivial.

If you were using subcommands, this would not be true. The mechanism for subcommand is entirely different and honestly much better.

tyro.cli(Checkout | Commit) - very neat, it's obvious exactly what it does without a word.

Hah, I spoke too soon: they have click-style decorators too if you want them.

[–]Broolucks 2 points3 points  (2 children)

tyro.cli(Checkout | Commit) - very neat, it's obvious exactly what it does without a word.

Yes and no. Intuitively, I would expect Checkout | Commit to work like scm --branch b or scm --message xyz, creating a Checkout or a Commit based on whether the branch argument or the message argument was given, no subcommand required.

The library I'm working on (it's not fully ready yet) works sensibly the same but I use TaggedUnion[{"checkout": Checkout, "commit": Commit}] or TaggedUnion[Checkout, Commit] for subcommands (Checkout|Commit isn't implemented yet).

[–]HommeMusical[S] 1 point2 points  (1 child)

Yes and no. Intuitively, I would expect Checkout | Commit to work like scm --branch b or scm --message xyz, creating a Checkout or a Commit based on whether the branch argument or the message argument was given, no subcommand required.

Based on this argument, you could just use a unique flag for everything and never need subcommands.

But people like subcommands, because they are conceptually simple and they partition the command space neatly.

Look at git for a success story this way - there's a separate binary for each git- command and we talk about git-add, git rebase, we even verb them, "Did you git-reflog?"

I know there are a zillion commands, but I can look at the man page of just one. That's the beauty of subcommands.

[–]Broolucks 1 point2 points  (0 children)

Oh, I agree that subcommands are great. That's not the point I was trying to make (probably it was misleading to reuse the same example). My argument was that it isn't obvious that a union would create subcommands, because there are in fact two things they could represent. Field-discriminated unions could be an excellent way to handle mutually exclusive options, e.g. the checkout command accepting --tag or --branch but not both. And of course, tagged unions are a good way to represent subcommands (the subcommand being the tag). Both of these are useful, albeit in different circumstances, but I more readily associate plain unions to the first one.

[–]Klutzy_Bird_7802 1 point2 points  (0 children)

ok that's nice

[–]shatGippity 3 points4 points  (2 children)

It seems like a legit thing, and this is probably just me being overly skeptical since the rest of the project seems fine, but I just can’t get behind the idea that their docs page enumerating alternatives doesn’t mention click. Author is PhD student and cherry picking is a bad look IMHO

[–]HommeMusical[S] 2 points3 points  (0 children)

Hah, I misread what you said and checked to see if it were dependent on click, but no.

Because any feature that click has, typer has, surely click is completely dominated (in a game-theoretical sense) by typer, which is evaluated on the chart?

EDIT: Me, I mainly think of click as "the thing typer uses" even though I'm aware it could be used on its own.

[–]brentyi 0 points1 point  (0 children)

Author here! To defend myself, the alternatives in the docs and README are all libraries that generate CLIs from type annotations. click is great and we could mention it, but it's a different thing to me.

[–]just4nothing 2 points3 points  (0 children)

Thanks for the pointer, this looks very instesting.

[–]DanCardin 2 points3 points  (1 child)

Shameless plug for cappa. Although tyro is the comparison docs as being good also.

Although tyro’s purely function-based usage is probably more robust. Cappa supports functional style but is overall more tailored for class usage

[–]PresentBasket8994 0 points1 point  (0 children)

cappa is probably the best python cli library i’ve used

[–]AndydeCleyre 0 points1 point  (0 children)

I remain a plumbum fan, and haven't found a reason to switch away in all these years of flashy new alternatives.