]> git.rocketbowman.com Git - proto.git/commitdiff
Add usage documentation. v0.1.0
authorKyle Bowman <kyle+github@rocketbowman.com>
Sun, 24 Mar 2024 23:11:18 +0000 (19:11 -0400)
committerKyle Bowman <kyle+github@rocketbowman.com>
Sun, 24 Mar 2024 23:11:18 +0000 (19:11 -0400)
README.md
develop.md
src/proto/command.py

index 5815efc6d9e6b950ea1314c759c680734e8edf6b..8a9ada74add46fb4ca94117e26eba3d4944c5e55 100644 (file)
--- a/README.md
+++ b/README.md
 # Proto Overview
 
-The proto library simplifies the creation of command line applications by 
-inferring parser options from type signatures.
+The `proto` library simplifies the creation of command line applications by 
+inferring parser options from type signatures from functions.
 
 ## Design Philosophy
 
-The core proto design belief is that a CLI app writer should focus on writing 
-functions, not interfaces. The interface you create matters. But so does the library that you use to create that interface. You shouldn't have to bend to idiosyncratic command line library to produce a conventional CLI app. Especially when much of it is boilerplate. From that core belief manifests these design principles:
+The governing design belief is that a CLI app writer should focus on writing 
+functions, not interfaces. The interface you create matters. But so does the 
+library that you use to create that interface. You shouldn't have to yield to
+idiosyncratic CLI library practices to produce a conventional CLI app.
+Especially when much of that info is already contained in the functions 
+themselves. With that in mind, we develop the `proto` library with the following principles:
 
-1. Proto treats functions as first class citizens. (Really `Callable`s).  
-2. (To do) Proto does not modify your function. (E.g. the decorator returns the function unchanged.)
-3. Proto respects your type annotations to a fault. 
-
-Unrelated, but important:
-1. Keep the library thin. CLI apps shouldn't be weighty. Avoid using third party tools in this library.
-2. Let users extend proto from within their app where they need to. (Example: to create a custom type from the command line, register a `get_argspecs()` function.)
+1. `Proto` treats functions as first class citizens. (Really, any `Callable`.) 
+2. `Proto` does not require you to change your function. 
+3. `Proto` respects your type annotations to a fault. 
 
+Unrelated, but important design principles:
+1. Any weight or dependencies that we use are inherited by your app. To keep that to a minimum, we avoid using libraries that are not already included in the Python Standard Library.
+2. We try to account for the most common command line parsing scenarios. But, if we tried to anticipate too many scenarios, we will eventually force app writers into a round peg/square hole situation. To keep `proto` flexible, we let users extend `proto` from within their app where they need to. For example, app writers can create a custom argument type from the command line by register a function to `get_argspecs()` function.
 
 ### About "To a Fault"
 
-Type annotations are a contract. Proto will abide by the contract. 
-If you want proto to work well, you must abide by the contract that you define
-If you specify, for example, `Optional[int]`, proto guarantees that, when 
+Type annotations are a voluntary contract. The `proto` library abides by the contract. 
+To get the most out of `proto`, you should abide by that contract as well
+If you specify, for example, `Optional[int]`, `proto` guarantees that, when 
 parsed, it will return either an `int` or `None`. It is your responsibility 
 to handle the both cases in your function.
 
 # Usage
+There are a few ways that you can use the `proto` library based on your design 
+preferences. Each case requires that you define your function using type 
+annotations.
+
+1. Use the `@command` decorator on your function definition.
+2. Use the `command` function. (Equivalent to using `Command` class.)
+3. Ignore `Command`. Use the `get_parser` utility function to define an `argparse.ArgumentParser` from a function. Then parse args as you normally would.
+
+## Using the @command Decorator
+This approach is simple. Technically it turns your functions into `Command`s. They still behave like functions. But if you are stacking decorators or it just makes you squeamish to change the type, look ahead to the next section.
+
+Define your function:
+
+``` python
+def fun(path: Optional[Path] = None):
+    if path is None:
+        contents = sys.stdin        
+    else:
+        contents
+    sys.stdout.write(contents)
+```
+
+
+Decorate it with `@command`:
+
+``` python
+@command
+def fun(path: Optional[Path] = None):
+    if path is None:
+        contents = sys.stdin        
+    else:
+        contents
+    sys.stdout.write(contents)
+```
+
+Now, `fun` is an instance of the `Command` class. But it still behaves like a function and that behavior is unchanged. (Albeit, this example is very CLI-oriented.)
+
+``` python
+>>>type(fun) 
+<class 'proto.command.Command'>
+```
+
+Since `fun` is a `Command`, it has a parser that you can put into a main file like this.
+
+``` python
+# Filename: main.py
+if __name__ == "__main__":
+    fun.parse() # Get args from command line. 
+    fun.run() # Invoke the function with command line args.
+```
+
+``` bash
+>echo "boo yah" | python main.py
+'boo yah'
+>python main.py --path=hello.txt
+'Hello, World!'
+```
+
+## Using the command Function
+If you prefer to keep your interface separate from your library functions, you can try this pattern.
+
+``` python
+# library.py
+def fun(path: Optional[Path] = None):
+    ... # Same definition
+```
+
+``` python
+# main.py
+from proto import command
+from library import fun
+
+cmd = command(fun)
+if __name__ == "__main__":
+    cmd.parse()
+    cmd.run()
+```
+
+When you import `fun` from `library.py` it is always a function. But you can launch from `main.py` for command-line benefits. It's the same as if you had used the `@command` decorator.
+
+``` bash
+>echo "boo yah" | python main.py
+'boo yah'
+>python main.py --path=hello.txt
+'Hello, World!'
+```
+
+## Using the get_parser Function
+Maybe you want to marshall arguments yourself, but you want to take advantage of the signature inference. You can use the `get_parser()` function to create an `argparse.ArgumentParser` from the type annotations. Then, parse arguments like normal.
+
+``` python
+# Filename: library.py
+def fun(path: Optional[Path] = None):
+    ... # Same definition
+```
+
+``` python
+# Filename: main.py
+from proto import get_parser
+from library import fun
+
+parser=get_parser(fun)
+
+if __name__ == "__main__":
+    args = parser.parse_args()
+    do_something(args.path)
+```
+
+## Parsing a Custom Data Type
+If you create a custom data type or want to handle a data type in a specific
+way, you can create your own implmentation of `get_argspecs`.
+
+Internally, we call `parser.add_argument()` a lot. To let cli app writers customize what's added to the `add_argument()` function, we expose `get_argspecs` as a hook for custom code.
+
+The `get_argspec()` function takes an annotated type and returns a `dict` that matches the options in the `add_argument()` function of the `argparse` library. The `get_argspecs()` function is implemented as a singledispatch function. That means that when it is called (internally), it looks at the type of the first argument and looks for a matching function that has been registered and runs that.
+
+For example. We might define a `get_argspecs()` implementation for a Boolean like this:
+
+``` python
+# Filename: main.py
+from proto import get_argspecs
+
+@get_argspecs.register
+def bool_argspecs(annotation: bool)->dict:
+    argspecs = {'action': store_true} 
+    return argspecs
+
+@command
+def echo(string: str, verbose: bool = False)
+    if not verbose:
+        return string
+    else: 
+        return f"No, really {string}."
+```
+
+Then, you can call the command as follows:
+
+``` bash
+>python main.py --verbose "the cake is a lie."
+'No, really the cake is a lie.'
+```
+
+# Roadmap
+
+## Add Subcommands
+Enable app writers to register functions as subcommands to a main parser.
+
+For example, define subcommands as follows:
+
+``` python
+# Filename: main.py
+@command(parser=main)
+def fun1(string: Optional[str] = None):
+    ... 
+
+@command(parser=main)
+def fun2():
+    ...
+
+if __name__ == "__main__":
+    main.parse()
+    main.run()
+```
+
+From the command line:
+
+``` bash
+python main.py fun1 --string "spam"
+```
+
+## Facilitate Managing Standard Streams
+
+Right now, if you want to read from STDIN or write to STDOUT, you must write them directly in your function. For example:
+
+``` python
+def fun(path: Optional[Path] = None):
+    if path is None:
+        contents = sys.stdin        
+    else:
+        with open(path, "r") as fp:
+            contents = fp.read()
+    results = do_something(contents)
+    sys.stdout.write(results)
+```
+
+But most library functions don't need STDIN. So including them is just a different kind of boilerplate. Wouldn't it be nicer to abstract the "if path is None" pattern?
+
+``` python
+@command
+def fun(path: Optional[Path] = None)
+    contents = read_from(path, default=sys.stdin)
+    results = do_someting(contents)
+    sys.stdout.write(results)
+```
+
+Furthermore, wouldn't it be nice for a command line app to automatically reroute returns to STDOUT?
+
+``` python
+@command
+def fun(path: Optional[Path]=None)
+    contents = read_from(path, default=sys.stdin)
+    return do_something(contents)
+```
+
+## Documenting command line arguments.
+
+Currently, there is no argument-specific help for a parser created by inference. I don't have a good solution. I'd like to leverage function docstrings.
 
-1. Define your function with type annotations.
-2. Decorate the function with `@command`.
-3. Use `fn.parse()` to load arguments.
-4. Use `fn.run()` to invoke the command.
+## Creature comforts
 
-Here is an idea of how it might look when done:
+* Let users define command aliases.
+* Let users define argument validation. (E.g. You cannot specify both X and Y.)
+* Let users serialize command line arguments into a JSON file for replayability.
+* Let users automatically import and register `Command`s so they don't have to update main.py for each new Command.
 
 ``` python
 @command(aliases=['alias1', 'alias2'], help="help message")
index 357a7c5ed79ba45bb38026cd1794b83a975ee976..4f048dd1a8f884b2cb02bbcd10278b4596bd4f61 100644 (file)
@@ -29,7 +29,7 @@ Search by using `git grep "# [A-Z]*:"`.
 
 # Testing
 
-## Utils.py
+## Infer.py
 
 * Each function in `utils.py` operates on `Callable` 
 * They gets information from the `Callable`'s signature
@@ -49,7 +49,7 @@ Let's ensure that each function works on:
 The most typical case is probably a function with types and defaults specified 
 in the signature.
 
-## Proto.py
+## Command.py
 
 Use `args=['list', 'of', 'arguments']` to supply command line arguments.
 
@@ -228,6 +228,25 @@ def long_name(*args, **kwargs):
     pass
 ```
 
+### (?) IO:STD/STDOUT Convenience
+I think the "If X is None: X = sys.stdin.read()" trope is going to be common. 
+Same for "if Y is None: sys.stdout.write()". It would be nice to abstract that out of the function definition.
+
+Idea: 
+
+* Write functions with normal `return` statements.
+* When creating command, add option to wrap with IO convenience behavior (probably make this default)
+* Also for `Optional[path]` consider `contents=read_from(path)` which handles stdin.
+* Maybe have `cow_says.run()` define CLI behavior and but normal `cow_says()` defines library behavior.
+
+``` python
+@command(stdout=True)
+def cow_says(sound=Optional[str])->str:
+    return "Moo!" if None else sound
+```
+
+
+
 ### Validation Ideas:
 1. Define a predicate data type: Callable[Any, bool]
 2. Define a validate decorator for Cmd objects
@@ -274,16 +293,16 @@ Idea Two: Define helper functions around idea Zero
 * Promote making useful test functions available for REPL based development.
 
 ### (?) Aggregate Commands 
-    We need a way to fetch Cmds from their modules and register with CLI.
-    Like `instances = fetch_subclasses(Cmd)`
-    Make generic? fetch(subclasses, Cmd)
+We need a way to fetch Cmds from their modules and register with CLI.
+Like `instances = fetch_subclasses(Cmd)`
+Make generic? fetch(subclasses, Cmd)
 
 ### (?) Serializing Ideas        
-    cmd.to_json() - packs command and args into json
-    add a to_json Cmd? 
-    add by default to MainCLI() parser? 
+cmd.to_json() - packs command and args into json
+add a to_json Cmd? 
+add by default to MainCLI() parser? 
 
-    => Mirror with from_json
+=> Mirror with from_json
 
 ### (?) Autoimport flexibility
 Enhancement to MainCLI - add a param that enables users to define 
index 29bfba9ab5e5e8ed5f51e3841f897d84a5dbcfa6..0c80c7dd9a70e991ef4b783c5350450c4c6e6737 100644 (file)
@@ -14,7 +14,11 @@ class Command:
         self.required, self.defaults = get_defaults(fn)
         self.args   = []
         self.kwargs = {}
-
+    
+    def __call__(self, *args, **kwargs):
+        kwargs = self.defaults | kwargs
+        return self._run(*args, **kwargs)
     def parse(self, args: Optional[list[str]] = None):
         """ Parse the list to populate args and kwargs attributes.
         If no list is specified, sys.argv is used. """