Mojopt: a structopt-like cli option parser

First off, the path for much of this reflection work was blazed by @bgreni in EmberJson.

Synopsis

mojopt is a CLI option parser that uses struct definitions to parse the CLI options.

Install

From source:

pixi add --git "https://github.com/sstadick/mojopt.git" mojopt && pixi install

Example

An example project is set up here.

Simple

from mojopt.command import MojOpt, Commandable
from mojopt.default import reflection_default
from mojopt.deserialize import Opt


@fieldwise_init
struct Example(Commandable, Defaultable, Movable, Writable):
    var example: Opt[String, help="Just an example string", short="e", default_value=["foobar"]]
    var number: Opt[Int, help="Just a number", long="num", short="n", defaultable=True]

    def __init__(out self):
        self = reflection_default[Self]()

    @staticmethod
    def description() -> String:
        return "Just an example program."

    def run(self) raises:
        print(self)


def main() raises:
    MojOpt[Example]().run()
> simple --help
Just an example program.
Options:
  -e), --example <EXAMPLE> [default: `foobar`]
          Just an example string
  -n), --num <NUM> [default: `0`]
          Just a number

Getting Started

from mojopt.command import Commandable, MojOpt
from mojopt.default import reflection_default
from mojopt.deserialize import MojOptDeserializable, Opt


@fieldwise_init
struct Args(Commandable, Defaultable, MojOptDeserializable, Writable):
    comptime name = "args"
    """Commandables default to the struct Name, but that can be overridden."""

    var first_name: Opt[String, help="The users first name", long="first-name", short="f"]
    """Opt is a translucent type that supplies metadata to the parser."""

    var last_name: String
    """You don't have to use Opt, though.

    Bare options work just fine, but don't have a help description.
    """

    var special_number: Opt[
        Int, help="Super special number.", long="special", short="s", default_value=["42"]
    ]
    """Default values can be supplied via the `default_value` parameter.

    You can also set `defaultable=True` to use the default value of the type instead.
    If both are set, the `default_value` takes priority.

    `default_value` works for multi-opts as well.
    For example, if this was a `List[Int]` instead, the default could be `["4", "2"]`.
    On the cli that would be passed by setting the option twice `-s 4 -s 2`.
    """

    var languages: Opt[List[String], help="The languages the user speaks", is_arg=True]
    """Positional args are supported.

    Only one "fully consuming" positional arg is allowed per command.
    Additionally, fixed size positional arguments are supported such as Tuple and InlineArray.
    """

    fn __init__(out self):
        self = reflection_default[Self]()

    @staticmethod
    fn description() -> String:
        return """A small example program.
        
        This program demonstrates how to use the Opt type, as well as Commandable.
        """

    fn run(self) raises:
        print(self)
        # Note that for Opt types you must access the inner type via `.value`
        print(self.first_name.value)


def main() raises:
    # Commandable structs can be passed to MojoOpt, which will dispatch based on the first argument.
    # If there is only one command, that is treated as main, but will also still work as a subcomand.
    # i.e. `./getting_started args --help` and `./getting_started --help` both work here
    MojOpt[Args]().run()

> getting_started --help
A small example program.

This program demonstrates how to use the Opt type, as well as Commandable.

Arguments:
  [LANGUAGES]...
          The languages the user speaks
Options:
  -f), --first-name <FIRST-NAME>
          The users first name
  --last_name <LAST_NAME>
  -s), --special <SPECIAL> [default: `42`]
          Super special numbers

Subcommands

from mojopt.command import MojOpt, Commandable
from mojopt.default import reflection_default
from mojopt.deserialize import MojOptDeserializable, Opt
from mojopt.parser import Parser


@fieldwise_init
struct GetLanguages(Commandable, Defaultable, MojOptDeserializable, Writable):
    var first_name: Opt[String, help="First name", defaultable=True]
    var last_name: Opt[String, help="Last name", default_value=["Mojo"]]
    var languages: Opt[List[String], is_arg=True, help="Languages spoken"]

    fn __init__(out self):
        self = reflection_default[Self]()

    @staticmethod
    fn description() -> String:
        return "List the languages spoken."

    def run(self) raises:
        print(self)


@fieldwise_init
struct GetSports(Commandable, Defaultable, MojOptDeserializable, Writable):
    var first_name: Opt[String, help="First name", long="firstname", short="f"]
    var last_name: Opt[String, help="Last name", long="lastname", short="l"]
    var sports: Opt[List[String], is_arg=True, help="Sports played"]

    fn __init__(out self):
        self = reflection_default[Self]()

    @staticmethod
    fn description() -> String:
        return "List the sports played."

    def run(self) raises:
        print(self)


@fieldwise_init
struct Example(Commandable, Defaultable, MojOptDeserializable, Writable):
    var example: String
    var number: Int

    fn __init__(out self):
        self = reflection_default[Self]()

    @staticmethod
    fn description() -> String:
        return "Options and args done't have to be Opts!"

    def run(self) raises:
        print(self)


def main() raises:
    var toolkit_description = """A contrived example of using multiple subcommands.

    Note that if just one subcommand is given it will be treated as a "main" and can be
    launched either by running the program with no subcommand specified, or by specifying
    subcommand name."""
    MojOpt[GetLanguages, GetSports, Example]().run(toolkit_description=toolkit_description)

> subcommands --help
A contrived example of using multiple subcommands.

Note that if just one subcommand is given it will be treated as a "main" and can be
launched either by running the program with no subcommand specified, or by specifying
subcommand name.

Commands:
  GetLanguages:
          List the languages spoken.
  GetSports:
          List the sports played.
  Example:
          Options and args done't have to be Opts
> subcommands GetSports --help
List the sports played.
Arguments:
  [SPORTS]...
          Sports played
Options:
  -f), --firstname <FIRSTNAME>
          First name
  -l), --lastname <LASTNAME>
          Last name
8 Likes

Congrats! I love how clean it is!

1 Like

How hard would it be to change the formatting to match gnu conventions more closely? There are a bunch of tools that can take a standard --help and convert that into a bash completion script but I think they would fail on this.

Other than that, this is fantastic to have! I love seeing more of the parts of Rust that I love come over to Mojo.

1 Like

I think that should be very doable, and would be really nice to support, I’ll be looking into it!

This is cool and elegant! The struct-based approach with compile-time reflection is really clean. :grinning_face:

1 Like

v0.2.0 should support some basic help parsers.

Example new format:

A small example program.

This program demonstrates how to use the Opt type, as well as Commandable.

Arguments:
  [LANGUAGES]... [Required]
    The languages the user speaks
Options:
  -f, --first-name FIRST-NAME [Required]
    The users first name

  --last-name LAST_NAME [Required]

  -s, --special SPECIAL [default: `42`]
    Super special number.

Checked with compdef _gnu_generic which works fine as long as there are no subcommands. Longer term I’d love to add the generation like clap has, but this at least makes things usable in the short term.

Fantastic, thank you!

1 Like