]> git.rocketbowman.com Git - proto.git/commitdiff
Make skeleton of essential public interface.
authorKyle Bowman <kyle+github@rocketbowman.com>
Sat, 17 Feb 2024 20:29:49 +0000 (15:29 -0500)
committerKyle Bowman <kyle+github@rocketbowman.com>
Sat, 17 Feb 2024 20:34:28 +0000 (15:34 -0500)
design.md [new file with mode: 0644]
findings.md [new file with mode: 0644]
specs.md
src/proto/__init__.py
src/proto/prototype.py
tests/test_proto.py

diff --git a/design.md b/design.md
new file mode 100644 (file)
index 0000000..d5fa954
--- /dev/null
+++ b/design.md
@@ -0,0 +1,143 @@
+# Interface Design Goals
+* Make a nice interface that is built on and compliant with argparse
+* No dependencies outside stdlib
+
+## Core Ideas
+* Use a command decorator to turn a function into a command.
+* Leverage the function signature to autopopulate command info.
+
+Example: 
+``` python
+@command(aliases=['alias1', alias2], help="help message")
+def function(arg1: type1 = default1, arg2: type2 = default 2, ...)
+    definition
+```
+
+## MainCLI Overview
+* We want to support, but not mandate subparsers.
+* The `MainCLI` class provides the top level parser and automatically registers 
+commands and subparsers.
+* By default, `@command` adds the function to the `MainCLI`, but users can also
+designate a function for use by a subparser.
+* Commands are parsed and dispatched by one of two means:
+    * initializing `MainCLI`, invoking `MainCLI().parse()` to get the command, 
+    then invoking `Command.run()`.
+    * using a convenience `dispatch()` method that carries out the above steps for you.
+
+### Parser Construction: Designating a function for a subparser
+* Unless you need nesting; avoid mentioning an explicit parser.
+* If you need subparsing, add `subparser=cmd_parser` to the `@command` decorator.
+
+Example:
+``` python
+parser=ArgParser(...)
+render_subparser=parser.add_subparser()
+@command(subparser=render_subparser, ...)
+def function(arg1: type1 = default1, arg2: type2 = default 2, ...)
+    definition
+```
+
+Ruled out: Invoke the command decorator as part of a subparser
+
+Example:
+``` python
+parser=MyArgParser(...)
+render_subparser = parser.add_subparser(...)
+@render_subparser.command(...)
+def function(arg1: type1 = default1, arg2: type2 = default 2, ...)
+    definition
+```
+
+We are ruling this out because it requires extending `argparse.ArgParser`
+which violates our "comply with `argparse`" guideline.
+
+### Parser Construction: Adding a subparser to the library entry point
+
+Add a `MainCLI` class that automatically registers declared commands.
+
+Example:
+``` python
+cli = MainCLI()
+for cmd in commands:
+    cli.register_subparser(cmd)
+```
+
+Create helper functions that operate on `ArgumentParser`
+
+Example: 
+``` python
+parser = ArgumentParser()
+subparser = make_subparser(cmd)
+register_subparser(parser, subparser)
+```
+
+### Parser Dispatch: Parsing and Dispatching 
+* Invoke `MainCLI` methods.
+``` python 
+if __name__ == "__main__":
+    cli = MainCLI()
+    cmd = cli.parse()
+    cmd.run()
+```
+
+* Invoke convenience wrapper
+``` python
+if __name__ == "__main__":
+    dispatch_cli()
+```
+
+# Possible Enhancements 
+
+## (?) Distribute a CLI testing library
+* Shell out?
+* Mock sys.argv?
+* Don't bother?
+
+## Validation Ideas:
+1. Define a predicate data type: Callable[Any, bool]
+2. Define a validate decorator for Cmd objects
+    * Allow for multiple validators and use any(list[bool])
+    * To what extent can/should I "Parse, don't validate"?
+3. (?) Define a library of useful arg validating predicates:
+    * Consider creating argument groups?
+        * specify X or specify Y and Z?
+    * zero_or_one_of(**args)
+    * one_or_more_of(**args)
+    * zero_or_more_of(**args)
+
+``` python
+@function.validate
+def is_valid(arg1, arg2):
+    return True if X else False
+```
+
+## (?) 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)
+
+## (?) Serializing Ideas        
+    cmd.to_json() - packs command and args into json
+    add a to_json Cmd? 
+    add by default to MainCLI() parser? 
+
+    => Mirror with from_json
+
+## (?) Help documentation
+This dealas with two things:
+* How to manually specify command help?
+* How to automatically generate some kind of help?
+
+Idea Zero: Minimal support; use existing getters/setters
+
+Idea One: Define as dictionary and pass unpacked to @command.
+    dct = dict(help="prints a message to the screen")
+    @command(**dct)
+    def print(msg: Optional[str]=None):
+        return msg
+
+Idea Two: Define helper functions around idea Zero
+
+## (?) Autoimport flexibility
+Enhancement to MainCLI - add a param that enables users to define 
+the command search space.jK:W
\ No newline at end of file
diff --git a/findings.md b/findings.md
new file mode 100644 (file)
index 0000000..a8414a5
--- /dev/null
@@ -0,0 +1,48 @@
+# Findings from experiments
+
+## About Parsers
+* Subparsers are of same type as parser (`argparse.ArgumentParser`)
+* Corollary: You can nest subparsers indefinitely
+
+## About Decorators
+* You can define @instance.method wrappers
+* This might be useful to do lower level argparse stuff, like setting help info.
+
+Example:
+With `Command.wrap` defined as:
+``` python
+# Part of class Command:
+def wrap(self, fn: Callable, *args, **kwargs):
+    return str(fn(*args, **kwargs)).upper()
+```
+
+You can enhance the function decorate the functino `new_name` :
+``` python
+@test.wrap
+def new_name():
+    return "kyle"
+```
+
+Invoke it as follows:
+``` python
+if __name__ == "__main__":
+    print(new_name) # "KYLE"
+```
+
+# About MainCLI
+Purpose: 
+1. Search for other commands to register with it
+2. Serve as main parser - Build the parser
+3. Parse and dispatch to the command
+
+Some additional notes:
+* Commands are *instances* of Cmd in this framework;
+they are not subclasses
+* Idiomatically, to get instances, register each instance
+with the class at initialization.
+* To get instances, they must be initialized (at runtime),
+meaning that Cmd() (or the decorator) must have been run
+* Each function that defines a command must be imported 
+*prior* to being able to be registered by MainCLI()
+* For now, let's define a module registering standard.
+(Enhancement) Look into a look-before-import mechanism (pluggy?)
\ No newline at end of file
index cefaab98d1a31286cca2bb2968016b09b7374363..8e7ea0f430f7339af966018d807c63a594a8d282 100644 (file)
--- a/specs.md
+++ b/specs.md
-NOTE: Subparser is of same type as parser => indefinite nesting
-NOTE: You can define @instance.method wrappers
-
-# Philosophy 
-* Make a nice interface that is built on and compliant with argparse
-* No dependencies outside stdlib
-
-## REQURIED ##
-### Core Idea;
-* Idea: Use a command decorator to mark a function as a command
-
-``` python
-@command(aliases=['alias1', alias2], help="help message")
-def function(arg1: type1 = default1, arg2: type2 = default 2, ...)
-    definition
-```
-
-### Subparser Ideas:
-* Idea Zero: Unless you need nesting; avoid an explicit parser.
-* Idea One: Implement command decorator as part of parser object
-
-``` python
-parser=MyArgParser(...)
-render_subparser = parser.add_subparser(...)
-@render_subparser.command(...)
-def function(arg1: type1 = default1, arg2: type2 = default 2, ...)
-    definition
-```
-
-* Idea Two: Add to command decorator an optional subparser
-
-``` python
-    parser=ArgParser(...)
-    render_subparser=parser.add_subparser()
-    @command(subparser=render_subparser, ...)
-    def function(arg1: type1 = default1, arg2: type2 = default 2, ...)
-        definition
-
-### Compose main parser
-Idea One: Custom MainCLI that does everything
-    cli = MainCLI()
-    for cmd in commands:
-        cli.register_subparser(cmd)
-
-Idea Two: Helper functions that operate on ArgumentParse
-    parser = ArgumentParser()
-    subparser = make_subparser(cmd)
-    register_subparser(parser, subparser)
-
-Probably both - Idea Two so people can use existing ArgParsers and 
-Idea one for convenience
-
-### Parse and Dispatch Ideas
-
-Idea One: Add a quirk: MainCLI(), but afterwards it works nicely.
-    cli = MainCLI()
-    cmd = cli.parse()
-    cmd.run()
-
-Idea Two: The above, but also wrap it all into one command.
-    dispatch_cli()
-
-Idea Three: Can/should I make an easy interface w/o introducing MainCLI()?
-    Call into Cmd itself?
-
-###### OPTIONAL ########
-### Validation Ideas:
-1. Define a predicate data type: Callable[Any, bool]
-2. Define a validate decorator for Cmd objects
-    * Allow for multiple validators and use any(list[bool])
-    * To what extent can/should I "Parse, don't validate"?
-3. (?) Define a library of useful arg validating predicates:
-    * Consider creating argument groups?
-        * specify X or specify Y and Z?
-    * zero_or_one_of(**args)
-    * one_or_more_of(**args)
-    * zero_or_more_of(**args)
-
-    @function.validate
-    def is_valid(arg1, arg2):
-        return True if X else False
-
-### (?) 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)
-
-### (?) Serializing Ideas        
-    cmd.to_json() - packs command and args into json
-    add a to_json Cmd? 
-    add by default to MainCLI() parser? 
-
-    => Mirror with from_json
-
-### (?) Help documentation
-
-Idea Zero: Minimal support; use existing getters/setters
-
-Idea One: Define as dictionary and pass unpacked to @command.
-    dct = dict(help="prints a message to the screen")
-    @command(**dct)
-    def print(msg: Optional[str]=None):
-        return msg
-
-Idea Two: Define helper functions around idea Zero
-
-"""
\ No newline at end of file
+## Stories
+1. Create `Command` object and `@command` decorator.
+  * What data does `Command` require? (Look at argparse requirements)
+  * What methods does `Command` require? 
+    * get_parser(fn: Callable)->argparse.ArgParser
+    * get_types(fn: Callable)->dict[type]
+    * get_defaults(fn: Callable)->dict
+    * init(fn: Callable, ?)->Self
+    * run()->Any:
+  * What additional info doese `@command` need?
+  * Tests:
+    * Check each builder method happy and sad paths.
+    * Check `Command.run()` output.
+    * Maybe shell out at least once.
+2. Add subparser arg to `@command` decorator (and `Command`?). 
+3. Add `MainCLI()` (and dispatch convenience method.)
+  * Add `Command` registering here.
+4. Look into enhancements
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..85846f29e48d5d296434c0d6afdf8c5ff2e75286 100644 (file)
@@ -0,0 +1,3 @@
+from proto.prototype import Command, command
+
+__all__ = ['Command', 'command']
\ No newline at end of file
index 118b972452bd541e92a80b6a70c7136d868d63e3..278bd4ef80fb2941ebcfc1a88535987d45df1d19 100644 (file)
@@ -2,40 +2,18 @@ import argparse
 from typing import Optional, Callable
 from proto.utils import get_defaults, get_types
 
-# Purpose: 
-# 1. Search for other commands to register with it
-    # (Enhancement) - param to define search space
-# 2. Serve as main parser - Build the parser
-# 3. Parse and dispatch to the command
-class MainCLI():
-    # NOTE: Commands are *instances* of Cmd in this framework;
-    # they are not subclasses
-    # NOTE: Idiomatically, to get instances, register each instance
-    # with the class at initialization.
-    # NOTE: To get instances, they must be initialized (at runtime),
-    # meaning that Cmd() (or the decorator) must have been run
-    # NOTE: Each function that defines a command must be imported 
-    # *prior* to being able to be registered by MainCLI()
-    # NOTE: For now, let's define a module registering standard.
-    # (Enhancement) Look into a look-before-import mechanism (pluggy?)
-    
-    #self.parser =  
-    #self.commands = get_subclasses(Command)
-
-    # (Register parser and register w/ command dict if necessary)
-    #for cmd in self.commands:
-    #    self.register_command(cmd)
-    pass 
-
-class Cmd:
-    # ALT: self.__call__ = fn instead of self.run = fn
+# NOTE: Currently, Command has no way to specify fn arguments.
+# NOTE: Args for a command are going to come from CLI.
+# Which means there will be a parser.parse_arguments() call.
+# NOTE: To test a Command whose function takes arguments, we 
+# will need to mock parser.parse_arguments.
+class Command:
     def __init__(self, fn: Callable):
         self.name = fn.__name__
-        self.run = fn           
-        self.types = get_types(fn)
-        self.defaults = get_defaults(fn)
+        # self.types = get_types(fn)
+        # self.defaults = get_defaults(fn)
+        # (Property?) self.run = fn           
         # self.parser = get_parser(fn)
-        # self.doc = Turn docstring into parser help
 
     @property
     def parser(self)-> argparse.ArgumentParser:
@@ -44,22 +22,16 @@ class Cmd:
     def run(self):
         pass
 
-    # TODO: Modify this to modify lower level argparse stuff
-    def wrap(self, fn: Callable, *args, **kwargs):
-        return str(fn(*args, **kwargs)).upper()
-
-
 def command(fn: Callable, aliases: list = [], help: Optional[str] = None):
-    # fn.__name__
-    return Cmd(fn)
-
-@command
-def test(arg1: str = "hello"):
-    return arg1
-
-@test.wrap
-def new_name():
-    return "kyle"
-
-if __name__ == "__main__":
-    print(new_name)
\ No newline at end of file
+    return Command(fn)
+
+#class MainCLI():
+#
+#    # Signature?
+#    def __init__(self):
+#        # self.parser = # default parser
+#        # self.commands = get_commands()
+#        pass
+#
+#    def parse(self)->Command:
+#        pass
\ No newline at end of file
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..7c3e2ea388503aff66a76da26c4650b8f84f183d 100644 (file)
@@ -0,0 +1,24 @@
+import pytest
+from proto import command, Command
+from typing import Protocol
+
+
+class Stringable(Protocol):
+
+    def __str__(self)->str:
+        ...
+
+def echo(arg: Stringable)->str:
+    return "Command" + str(arg)
+
+@command
+def echo_decorated(arg: Stringable)->str:
+    return "@command" + str(arg)
+
+def test_attributes():
+    cmd = Command(echo)
+    assert cmd.name == "echo"
+
+def test_attributes_2():
+    cmd = echo_decorated
+    assert cmd.name == "echo_decorated"
\ No newline at end of file