Skip to content

Commit

Permalink
Print help akin to argparser (#39)
Browse files Browse the repository at this point in the history
* Adding help print. This strips the first lines (as long as they are single newline split) as the class help and then uses Google docstring format for Attributes to define the help strings for parameters. Simple regex to match parameter to docstring. Print styling similar to stdlib argpaser.print_help() for ease of 1:1 replacement. @spock annotated classes print just fine. class enums are missing that should probably be printed since the @spock class references it as a type

* added help for top level enums within a spock annotated class. bit of abstraction and cleanup.

* fixed repr on Tuple based on length restriction

* support for finding nested Enums via some sys.module magic and simple regex cleaning

* fixed bug in enum of spock classes forwarding the wrong type to the attr class

* final bug squashes
  • Loading branch information
ncilfone authored Mar 18, 2021
1 parent 3477a18 commit f2b7b21
Show file tree
Hide file tree
Showing 10 changed files with 426 additions and 81 deletions.
72 changes: 59 additions & 13 deletions spock/backend/attr/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@

"""Handles the building/saving of the configurations from the Spock config classes"""

import sys
import attr
from enum import EnumMeta
import re
import sys
from warnings import warn
from spock.backend.base import BaseBuilder


Expand All @@ -32,22 +35,65 @@ def __init__(self, *args, configs=None, create_save_path=False, desc='', no_cmd_
if not attr.has(arg):
raise TypeError('*arg inputs to ConfigArgBuilder must all be class instances with attrs attributes')

def print_usage_and_exit(self, msg=None, sys_exit=True):
print('USAGE:')
print(f' {sys.argv[0]} -c [--config] config1 [config2, config3, ...]')
print('CONFIG:')
def print_usage_and_exit(self, msg=None, sys_exit=True, exit_code=1):
print(f'usage: {sys.argv[0]} -c [--config] config1 [config2, config3, ...]')
print(f'\n{self._desc if self._desc != "" else ""}\n')
print('configuration(s):\n')
self._handle_help_info()
if msg is not None:
print(msg)
if sys_exit:
sys.exit(exit_code)

def _handle_help_info(self):
# List to catch Enum classes and handle post spock wrapped attr classes
enum_list = []
for attrs_class in self.input_classes:
print(' ' + attrs_class.__name__ + ':')
# Split the docs into class docs and any attribute docs
class_doc, attr_docs = self._split_docs(attrs_class)
print(' ' + attrs_class.__name__ + f' ({class_doc})')
# Keep a running info_dict of all the attribute level info
info_dict = {}
for val in attrs_class.__attrs_attrs__:
type_string = val.metadata['base']
# If the type is an enum we need to handle it outside of this attr loop
# Match the style of nested enums and return a string of module.name notation
if isinstance(val.type, EnumMeta):
enum_list.append(f'{val.type.__module__}.{val.type.__name__}')
# if there is a type (implied Iterable) -- check it for nested Enums
nested_enums = self._extract_enum_types(val.metadata['type']) if 'type' in val.metadata else []
if len(nested_enums) > 0:
enum_list.extend(nested_enums)
# Grab the base or type info depending on what is provided
type_string = repr(val.metadata['type']) if 'type' in val.metadata else val.metadata['base']
# Regex out the typing info if present
type_string = re.sub(r'typing.', '', type_string)
# Regex out any nested_enums that have module path information
for enum_val in nested_enums:
split_enum = f"{'.'.join(enum_val.split('.')[:-1])}."
type_string = re.sub(split_enum, '', type_string)
# Regex the string to see if it matches any Enums in the __main__ module space
# for val in sys.modules
# Construct the type with the metadata
if 'optional' in val.metadata:
type_string = "Optional[{0}]".format(type_string)
print(f' {val.name}: {type_string}')
if msg is not None:
print(msg)
if sys_exit:
sys.exit(1)
type_string = f"Optional[{type_string}]"
info_dict.update(self._match_attribute_docs(val.name, attr_docs, type_string, val.default))
self._handle_attributes_print(info_dict=info_dict)
# Convert the enum list to a set to remove dupes and then back to a list so it is iterable
enum_list = list(set(enum_list))
# Iterate any Enum type classes
for enum in enum_list:
enum = self._get_enum_from_sys_modules(enum)
# Split the docs into class docs and any attribute docs
class_doc, attr_docs = self._split_docs(enum)
print(' ' + enum.__name__ + f' ({class_doc})')
info_dict = {}
for val in enum:
info_dict.update(self._match_attribute_docs(
attr_name=val.name,
attr_docs=attr_docs,
attr_type_str=type(val.value).__name__
))
self._handle_attributes_print(info_dict=info_dict)

def _handle_arguments(self, args, class_obj):
attr_name = class_obj.__name__
Expand Down
5 changes: 4 additions & 1 deletion spock/backend/attr/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ def spock_attr(cls):
else:
default = None
attrs_dict.update({k: katra(typed=v, default=default)})
# For each class we dynamically create we need to register it within the system modules for pickle to work
# Dynamically make an attr class
obj = attr.make_class(name=cls.__name__, bases=bases, attrs=attrs_dict, kw_only=True, frozen=True)
# For each class we dynamically create we need to register it within the system modules for pickle to work
setattr(sys.modules['spock'].backend.attr.config, obj.__name__, obj)
# Swap the __doc__ string from cls to obj
obj.__doc__ = cls.__doc__
return obj
14 changes: 9 additions & 5 deletions spock/backend/attr/typed.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ def __new__(cls, x):
def _extract_base_type(typed):
"""Extracts the name of the type from a _GenericAlias
Assumes that the derived types are only of length 1 as the __args__ are [0] recursed... this is not true for
tuples
*Args*:
typed: the type of the parameter
Expand Down Expand Up @@ -200,11 +203,11 @@ def _enum_base_katra(typed, base_type, allowed, default=None, optional=False):
if default is not None:
x = attr.ib(
validator=[attr.validators.instance_of(base_type), attr.validators.in_(allowed)],
default=default, type=base_type, metadata={'base': typed.__name__})
default=default, type=typed, metadata={'base': typed.__name__})
elif optional:
x = attr.ib(
validator=attr.validators.optional([attr.validators.instance_of(base_type), attr.validators.in_(allowed)]),
default=default, type=base_type, metadata={'base': typed.__name__})
default=default, type=typed, metadata={'base': typed.__name__, 'optional': True})
else:
x = attr.ib(validator=[attr.validators.instance_of(base_type), attr.validators.in_(allowed)], type=typed,
metadata={'base': typed.__name__})
Expand Down Expand Up @@ -256,13 +259,14 @@ def _enum_class_katra(typed, allowed, default=None, optional=False):
"""
if default is not None:
x = attr.ib(
validator=[partial(_in_type, options=allowed)], default=default, metadata={'base': typed.__name__})
validator=[partial(_in_type, options=allowed)], default=default, type=typed,
metadata={'base': typed.__name__})
elif optional:
x = attr.ib(
validator=attr.validators.optional([partial(_in_type, options=allowed)]),
default=default, metadata={'base': typed.__name__})
default=default, type=typed, metadata={'base': typed.__name__, 'optional': True})
else:
x = attr.ib(validator=[partial(_in_type, options=allowed)], metadata={'base': typed.__name__})
x = attr.ib(validator=[partial(_in_type, options=allowed)], type=typed, metadata={'base': typed.__name__})
return x


Expand Down
179 changes: 178 additions & 1 deletion spock/backend/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
from abc import ABC
from abc import abstractmethod
import argparse
from attr import NOTHING
from enum import EnumMeta
import os
from pathlib import Path
import re
import sys
from uuid import uuid1
import yaml
Expand Down Expand Up @@ -181,15 +184,18 @@ class BaseBuilder(ABC): # pylint: disable=too-few-public-methods
_create_save_path: boolean to make the path to save to
_desc: description for the arg parser
_no_cmd_line: flag to force no command line reads
_max_indent: maximum to indent between help prints
save_path: list of path(s) to save the configs to
"""
def __init__(self, *args, configs=None, create_save_path=False, desc='', no_cmd_line=False, **kwargs):
def __init__(self, *args, configs=None, create_save_path=False, desc='', no_cmd_line=False,
max_indent=4, **kwargs):
self.input_classes = args
self._configs = configs
self._create_save_path = create_save_path
self._desc = desc
self._no_cmd_line = no_cmd_line
self._max_indent = max_indent
self.save_path = None

@abstractmethod
Expand All @@ -206,6 +212,19 @@ def print_usage_and_exit(self, msg=None, sys_exit=True):
"""

@abstractmethod
def _handle_help_info(self):
"""Handles walking through classes to get help info
For each class this function will search __doc__ and attempt to pull out help information for both the class
itself and each attribute within the class
*Returns*:
None
"""

@abstractmethod
def _handle_arguments(self, args, class_obj):
"""Handles all argument mapping
Expand Down Expand Up @@ -481,6 +500,164 @@ def _get_from_kwargs(args, configs):
raise TypeError('configs kwarg must be of type list')
return args

@staticmethod
def _find_attribute_idx(newline_split_docs):
"""Finds the possible split between the header and Attribute annotations
*Args*:
newline_split_docs:
Returns:
idx: -1 if none or the idx of Attributes
"""
for idx, val in enumerate(newline_split_docs):
re_check = re.search(r'(?i)Attribute?s?:', val)
if re_check is not None:
return idx
return -1

def _split_docs(self, obj):
"""Possibly splits head class doc string from attribute docstrings
Attempts to find the first contiguous line within the Google style docstring to use as the class docstring.
Splits the docs base on the Attributes tag if present.
*Args*:
obj: class object to rip info from
*Returns*:
class_doc: class docstring if present or blank str
attr_doc: list of attribute doc strings
"""
if obj.__doc__ is not None:
# Split by new line
newline_split_docs = obj.__doc__.split('\n')
# Cleanup l/t whitespace
newline_split_docs = [val.strip() for val in newline_split_docs]
else:
newline_split_docs = []
# Find the break between the class docs and the Attribute section -- if this returns -1 then there is no
# Attributes section
attr_idx = self._find_attribute_idx(newline_split_docs)
head_docs = newline_split_docs[:attr_idx] if attr_idx != -1 else newline_split_docs
attr_docs = newline_split_docs[attr_idx:] if attr_idx != -1 else []
# Grab only the first contiguous line as everything else will probably be too verbose (e.g. the
# mid-level docstring that has detailed descriptions
class_doc = ''
for idx, val in enumerate(head_docs):
class_doc += f' {val}'
if idx + 1 != len(head_docs) and head_docs[idx + 1] == '':
break
# Clean up any l/t whitespace
class_doc = class_doc.strip()
return class_doc, attr_docs

@staticmethod
def _match_attribute_docs(attr_name, attr_docs, attr_type_str, attr_default=NOTHING):
"""Matches class attributes with attribute docstrings via regex
*Args*:
attr_name: attribute name
attr_docs: list of attribute docstrings
attr_type_str: str representation of the attribute type
attr_default: str representation of a possible default value
*Returns*:
dictionary of packed attribute information
"""
# Regex match each value
a_str = None
for a_doc in attr_docs:
match_re = re.search(r'(?i)^' + attr_name + '?:', a_doc)
# Find only the first match -- if more than one than ignore
if match_re:
a_str = a_doc[match_re.end():].strip()
return {attr_name: {
'type': attr_type_str,
'desc': a_str if a_str is not None else "",
'default': "(default: " + repr(attr_default) + ")" if type(attr_default).__name__ != '_Nothing'
else "",
'len': {'name': len(attr_name), 'type': len(attr_type_str)}
}}

def _handle_attributes_print(self, info_dict):
"""Prints attribute information in an argparser style format
*Args*:
info_dict: packed attribute info dictionary to print
"""
# Figure out indents
max_param_length = max([len(k) for k in info_dict.keys()])
max_type_length = max([v['len']['type'] for v in info_dict.values()])
# Print akin to the argparser
for k, v in info_dict.items():
print(f' {k}' + (' ' * (max_param_length - v["len"]["name"] + self._max_indent)) +
f'{v["type"]}' + (' ' * (max_type_length - v["len"]["type"] + self._max_indent)) +
f'{v["desc"]} {v["default"]}')
# Blank for spacing :-/
print('')

def _extract_enum_types(self, typed):
"""Takes a high level type and recursively extracts any enum types
*Args*:
typed: highest level type
*Returns*:
return_list: list of nums (dot notation of module_path.enum_name)
"""
return_list = []
if hasattr(typed, '__args__'):
for val in typed.__args__:
recurse_return = self._extract_enum_types(val)
if isinstance(recurse_return, list):
return_list.extend(recurse_return)
else:
return_list.append(self._extract_enum_types(val))
elif isinstance(typed, EnumMeta):
return f'{typed.__module__}.{typed.__name__}'
return return_list

@staticmethod
def _get_enum_from_sys_modules(enum_name):
"""Gets the enum class from a dot notation name
*Args*:
enum_name: dot notation enum name
*Returns*:
module: enum class
"""
# Split on dot notation
split_string = enum_name.split('.')
module = None
for idx, val in enumerate(split_string):
# idx = 0 will always be a call to the sys.modules dict
if idx == 0:
module = sys.modules[val]
# all other idx are paths along the module that need to be traversed
# idx = -1 will always be the final Enum object name we want to grab (final getattr call)
else:
module = getattr(module, val)
return module


class BasePayload(ABC): # pylint: disable=too-few-public-methods
"""Handles building the payload for config file(s)
Expand Down
3 changes: 2 additions & 1 deletion spock/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ def _get_payload(self):
"""
args = self._get_config_paths()
if args.help:
self._builder_obj.print_usage_and_exit()
# Call sys exit with a clean code as this is the help call which is not unexpected behavior
self._builder_obj.print_usage_and_exit(sys_exit=True, exit_code=0)
payload = {}
dependencies = {'paths': [], 'rel_paths': [], 'roots': []}
for configs in args.config:
Expand Down
Loading

0 comments on commit f2b7b21

Please sign in to comment.