From fd9d6b2f0e14f4b241b79d99d46196959edc2f0b Mon Sep 17 00:00:00 2001 From: Kyle Bowman Date: Sat, 17 Feb 2024 15:29:49 -0500 Subject: [PATCH] Make skeleton of essential public interface. --- design.md | 143 +++++++++++++++++++++++++++++++++++++++++ findings.md | 48 ++++++++++++++ specs.md | 125 ++++++----------------------------- src/proto/__init__.py | 3 + src/proto/prototype.py | 70 ++++++-------------- tests/test_proto.py | 24 +++++++ 6 files changed, 257 insertions(+), 156 deletions(-) create mode 100644 design.md create mode 100644 findings.md diff --git a/design.md b/design.md new file mode 100644 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 index 0000000..a8414a5 --- /dev/null +++ b/findings.md @@ -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 diff --git a/specs.md b/specs.md index cefaab9..8e7ea0f 100644 --- a/specs.md +++ b/specs.md @@ -1,107 +1,18 @@ -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 diff --git a/src/proto/__init__.py b/src/proto/__init__.py index e69de29..85846f2 100644 --- a/src/proto/__init__.py +++ b/src/proto/__init__.py @@ -0,0 +1,3 @@ +from proto.prototype import Command, command + +__all__ = ['Command', 'command'] \ No newline at end of file diff --git a/src/proto/prototype.py b/src/proto/prototype.py index 118b972..278bd4e 100644 --- a/src/proto/prototype.py +++ b/src/proto/prototype.py @@ -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 diff --git a/tests/test_proto.py b/tests/test_proto.py index e69de29..7c3e2ea 100644 --- a/tests/test_proto.py +++ b/tests/test_proto.py @@ -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 -- 2.39.5