tef

bad poster & mediocre photographer

  • they/them

in my last frothing posts, i talked about how there really isn't much of a difference between writing webapps and desktop apps anymore. it's not just because of things like electron, but because webapp toolkits and desktop toolkits are converging on a set of ideas.

so you might ask: what about command line apps?


to recap, i suggested that gui frameworks are all trending towards a common approach to the problem, that draws from both the browser and traditional ui approaches:

  • a gui application is modelled as a series of pages that a user can navigate between
  • a page holds a dom of components, or a tree of widgets, which describe ui element behaviour and offer properties and fire events
  • dom components can be described in a declarative way, either in text or with a tool
  • the application has a notion of state, as well as dom properties, which can be chained up together, and changing one thing recalculates the rest
  • the app uses immediate rendering: it just builds the output it wants, each time, rather than trying to just update the output piecemeal for each potential change

in some ways, it's a very entity-component-system approach to handling application state, and frankly, it's not a bad way to write applications. there's no callback soup. there's only one canonical version of state, and updates to the state cause the whole dom to be rebuilt, rather than carefully updated each time.

so the question is: would any of this help out for command line tooling? it might help us to think about a more concrete application, so let's imagine a zip-file like tool 2zip

ordinarily, a tool might be structured something like this:

  • there's a main function
  • it parses every possible command line and argument
  • it then invokes whatever function was required
  • the function shits all over stderr and stdexit
  • and the main function exits without indicating an error

on the other hand, we could structure it something like this

  • the main function transforms the command line into a request
  • a request contains something like a verb, a path, and a set of options, so 2zip unpack foo.bar might be transformed into `Request(path='unpack', args=[[None, 'foo.bar']])``
  • the request is sent into the app
  • the app uses a router to work out which function handles that type of command
  • the app invokes the command, capturing the output
  • a built in pager displays the contents to the screen

this "request-response" like architecture is very close to the first ui toolkit trend i mentioned, breaking an application down into different pages that can be opened by the user. you can think of the request object as being a bit of a url, if it helps

it might seem like more work, but in practice, this is actually a really nice way to write a command line tool. let's imagine a bit harder:

r = Router()
a = App(options, router)

@r.on("unpack", "unzip", "extract", "decompress")
def unpack(app, filename: FilePath):
   .... do some decompression ...
   return Result("{n} files decompressed"}

here, every command is it's own top level function. there's no building up an argument parser library, the command type signature is enough information. there's even enough information to generate tab completion code for the app.

the nicest thing? the pager. you can think of it as bolting a less onto the side of the app, letting you scroll through output too big to fit in the terminal. we can eve go further, let these commands return markdown. then the generic pager could display it for us, with all the formatting and tables we crave. now the pager is not too far off a browser, so let's go one step further. the pager could reflow text when you resize the terminal, without losing your place.

the nice thing here is that all of your display, output code, is in the one place, and what's better is that most of it is provided for you. splitting a cli app into a backend, with a router, and a frontend, with a pager, does lead to a real nice separation of concerns.

... and you don't really need to imagine this: i already did all of this, for a laugh. i was so damn tired of resizing a man page only to have to exit and restart the viewer, that i wrote a whole goddam tty text reflow engine for a markdown variant.

so the answer is yes: you can write a cli app to be more like a webapp, or borrow elements from ui toolkits, and the result is actually quite nice, even for a proof of concept—and i've barely touched on what is possible.

if the pager is already doing reflow, it could also handle links and forms, much like browsers do. imagine typing git commit and instead of "usage: git commit [29 flags ommitted]", you got a form back, with all the options you could select, along with explanations, and you could just run the command without having to copy and paste the flags out of the manpage.

anyway

i've kinda fallen in love with this approach to writing command line apps, and i can't see myself going back to argparse or optparse ever again, well, not for any of the code i have fun writing


You must log in to comment.

in reply to @tef's post:

the function shits all over stderr and stdexit

Very funny and accurate. I am guilty of this like so many others. It's just too easy to write print("Encountered an error: {}"); sys.exit(1).

and i can't see myself going back to argparse or optparse ever again

This is still somewhat needed to convert sys.argv to a Request object, right? Otherwise, you have to roll your own parser which is its own !FUN! (in the dwarf fortress sense).

I love this approach. I've been toying with how to structure the CLI for my current active project splint, and you've raised a lot of good points. Do you have any "real world" code examples to demonstrate this?

isn't "it invokes whatever function is required" already routing? in argparse that would even dispatch to an entire subparser

there's nothing stopping you from divining argument syntax from function arguments either, but as soon as you add more than one thing that could've been a short arg it's going to suck

it's more "treat command line like a url" and "send it into router" and "router invokes command"

over "build up a heirarchical parser model that returns the name of the thing you should invoke"