]> git.rocketbowman.com Git - proto.git/commitdiff
Seed with infra.
authorKyle Bowman <kylebowman14@gmail.com>
Fri, 16 Feb 2024 01:49:51 +0000 (20:49 -0500)
committerKyle Bowman <kylebowman14@gmail.com>
Sat, 17 Feb 2024 15:54:16 +0000 (10:54 -0500)
pyproject.toml [new file with mode: 0644]
requirements.txt [new file with mode: 0644]
specs.md [new file with mode: 0644]
src/proto/__init__.py [new file with mode: 0644]
src/proto/prototype.py [new file with mode: 0644]
src/proto/utils.py [new file with mode: 0644]
tests/test_proto.py [new file with mode: 0644]
tests/test_utils.py [new file with mode: 0644]

diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644 (file)
index 0000000..2be7a05
--- /dev/null
@@ -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 (file)
index 0000000..5064a6f
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..e69de29
diff --git a/src/proto/prototype.py b/src/proto/prototype.py
new file mode 100644 (file)
index 0000000..118b972
--- /dev/null
@@ -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 (file)
index 0000000..a73a031
--- /dev/null
@@ -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 (file)
index 0000000..e69de29
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644 (file)
index 0000000..1610bf9
--- /dev/null
@@ -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'])