From: Kyle Bowman Date: Fri, 16 Feb 2024 01:49:51 +0000 (-0500) Subject: Seed with infra. X-Git-Tag: v0.1.0~14 X-Git-Url: https://git.rocketbowman.com/?a=commitdiff_plain;h=76408e7019cd489001a7968892cd1c0b13b422d2;p=proto.git Seed with infra. --- diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2be7a05 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "proto" +version = "0.0.1" +authors = [ + { name="Kyle Bowman", email="kylebowman14@gmail.com" }, +] +description = "TODO" +readme = "README.md" +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.scripts] +proto = "proto:main" + +[project.urls] +Homepage = "None" +Issues = "None" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5064a6f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +iniconfig==2.0.0 +packaging==23.2 +pluggy==1.4.0 +# Editable Git install (proto==0.0.1) with either a deleted local remote or invalid URI: +# 'git@10.5.1.242:/srv/git/proto.git' +-e /home/kyle/projects/proto +pytest==8.0.0 diff --git a/specs.md b/specs.md new file mode 100644 index 0000000..cefaab9 --- /dev/null +++ b/specs.md @@ -0,0 +1,107 @@ +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 diff --git a/src/proto/__init__.py b/src/proto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/proto/prototype.py b/src/proto/prototype.py new file mode 100644 index 0000000..118b972 --- /dev/null +++ b/src/proto/prototype.py @@ -0,0 +1,65 @@ +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 + def __init__(self, fn: Callable): + self.name = fn.__name__ + self.run = fn + self.types = get_types(fn) + self.defaults = get_defaults(fn) + # self.parser = get_parser(fn) + # self.doc = Turn docstring into parser help + + @property + def parser(self)-> argparse.ArgumentParser: + pass + + 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 diff --git a/src/proto/utils.py b/src/proto/utils.py new file mode 100644 index 0000000..a73a031 --- /dev/null +++ b/src/proto/utils.py @@ -0,0 +1,30 @@ +from typing import Callable +import inspect + +def get_defaults(fn: Callable)->dict: + """ Returns a dictionary of parameter names and defaults. """ + sig = inspect.signature(fn) + defaults = {} + for name, prm in sig.parameters.items(): + # ASSUME: __init__ is defined with "self". It's a Pythonic standard to use self, but it's convention, not rule. Beware. + if name == "self": + continue + if prm.default == inspect._empty: + defaults[name] = None + else: + defaults[name] = prm.default + return defaults + +def get_types(fn: Callable)->dict[type]: + """ Returns a dictionary of parameter names and types. """ + sig = inspect.signature(fn) + types = {} + for name, prm in sig.parameters.items(): + # ASSUME: __init__ is defined with "self". It's a Pythonic standard to use self, but it's convention, not rule. Beware. + if name == "self": + continue + if prm.annotation == inspect._empty: + types[name] = None + else: + types[name] = prm.annotation + return types \ No newline at end of file diff --git a/tests/test_proto.py b/tests/test_proto.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..1610bf9 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,51 @@ +from typing import Optional +import proto.utils + +############## +# Begin Data # -- signature testing +############## +def fn(string, num)->str: + return f"String: {string} \n Integer: {num} \n" + +def fn_annotated(string: str, num: int)->str: + return f"String: {string} \n Integer: {num} \n" + +def fn_optional(string: Optional[str], num: Optional[int] = 42)->str: + return f"String: {string} \n Integer: {num} \n" + +def fn_default(string: str="spam", num: int = 42)->str: + return f"String: {string} \n Integer: {num} \n" + +############### +# Begin Tests # +############### +def test_get_defaults(): + defaults = proto.utils.get_defaults(fn_default) + assert defaults['string'] == "spam" + assert defaults['num'] == 42 + +def test_get_defaults_unspecified(): + defaults = proto.utils.get_defaults(fn) + assert defaults['string'] is None + assert defaults['num'] is None + +def test_get_types(): + types = proto.utils.get_types(fn_annotated) + assert types['string'] == str + assert types['num'] == int + +def test_get_types_unspecified(): + types = proto.utils.get_types(fn) + assert types['string'] is None + assert types['num'] is None + +def test_get_types_optional(): + types = proto.utils.get_types(fn_optional) + assert types['string'] == Optional[str] + assert types['num'] == Optional[int] + +def test_validating_optional(): + types = proto.utils.get_types(fn_optional) + defaults = proto.utils.get_defaults(fn_optional) + assert isinstance(defaults['string'],types['string']) + assert isinstance(defaults['num'],types['num'])