/
help.py
308 lines (258 loc) · 10.7 KB
/
help.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# -*- coding: utf-8 -*-
# Copyright FMR LLC <opensource@fidelity.com>
# SPDX-License-Identifier: Apache-2.0
"""Handles all ops for assembling and pretty printing help info"""
import re
import sys
from enum import EnumMeta
from typing import Callable, Union
from attr import NOTHING
def find_attribute_idx(newline_split_docs):
"""Finds the possible split between the header and Attribute annotations
Args:
newline_split_docs: new line split text
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(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 = 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()
if len(class_doc) > 0:
class_doc = f"-- {class_doc}"
return class_doc, attr_docs
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(info_dict, max_indent: int):
"""Prints attribute information in an argparser style format
Args:
info_dict: packed attribute info dictionary to print
max_indent: max indent for pretty print of help
"""
# Add check for an empty dict (corresponds to any empty spock class)
if len(info_dict) > 0:
# 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"] + max_indent))
+ f'{v["type"]}'
+ (" " * (max_type_length - v["len"]["type"] + max_indent))
+ f'{v["desc"]} {v["default"]}'
)
# Blank for spacing :-/
print("")
def get_type_string(val, nested_others):
"""Gets the type of the attr val as a string
Args:
val: current attr being processed
nested_others: list of nested others to deal with that might have module path info in the string
Returns:
type_string: type of the attr as a str
"""
# Grab the base or type info depending on what is provided
if "type" in val.metadata:
type_string = repr(val.metadata["type"])
elif "base" in val.metadata:
type_string = val.metadata["base"]
elif hasattr(val.type, "__name__"):
type_string = val.type.__name__
else:
type_string = str(val.type)
# Regex out the typing info if present
type_string = re.sub(r"typing.", "", type_string)
# Regex out any nested_others that have module path information
for other_val in nested_others:
split_other = f"{'.'.join(other_val.split('.')[:-1])}."
type_string = re.sub(split_other, "", type_string)
# Regex the string to see if it matches any Enums in the __main__ module space
# Construct the type with the metadata
if "optional" in val.metadata:
type_string = f"Optional[{type_string}]"
return type_string
def get_from_sys_modules(cls_name):
"""Gets the class from a dot notation name
Args:
cls_name: dot notation enum name
Returns:
module: enum class
"""
# Split on dot notation
split_string = cls_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
def handle_help_main(
input_classes: list, module_name: str, extract_fnc: Callable, max_indent: int
):
"""Handles the print of the main class types
Args:
input_classes: current set of input classes
module_name: module name to match
extract_fnc: function that gets the nested lists within classes
max_indent: max indent for pretty print of help
Returns:
other_list: extended list of other classes/enums to process
"""
# List to catch Enums and classes and handle post spock wrapped attr classes
other_list = []
covered_set = set()
for attrs_class in input_classes:
# Split the docs into class docs and any attribute docs
class_doc, attr_docs = 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__:
# 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):
other_list.append(f"{val.type.__module__}.{val.type.__name__}")
# if there is a type (implied Iterable) -- check it for nested Enums or classes
nested_others = extract_fnc(val, module_name)
if len(nested_others) > 0:
other_list.extend(nested_others)
# Get the type represented as a string
type_string = get_type_string(val, nested_others)
info_dict.update(
match_attribute_docs(val.name, attr_docs, type_string, val.default)
)
# Add to covered so we don't print help twice in the case of some recursive nesting
covered_set.add(f"{attrs_class.__module__}.{attrs_class.__name__}")
handle_attributes_print(info_dict=info_dict, max_indent=max_indent)
# Convert the enum list to a set to remove dupes and then back to a list so it is iterable -- set diff to not
# repeat
return list(set(other_list) - covered_set)
def handle_help_enums(
other_list: list, module_name: str, extract_fnc: Callable, max_indent: int
):
"""Handles any extra enums from non main args
Args:
other_list: extended list of other classes/enums to process
module_name: module name to match
extract_fnc: function that gets the nested lists within classes
max_indent: max indent for pretty print of help
Returns:
None
"""
# Iterate any Enum type classes
for other in other_list:
# if it's longer than 2 then it's an embedded Spock class
if ".".join(other.split(".")[:-1]) == module_name:
class_type = get_from_sys_modules(other)
# Invoke recursive call for the class
attrs_help(
[class_type],
module_name,
extract_fnc=extract_fnc,
max_indent=max_indent,
)
# Fall back to enum style
else:
enum = get_from_sys_modules(other)
# Split the docs into class docs and any attribute docs
class_doc, attr_docs = split_docs(enum)
print(" " + enum.__name__ + f" ({class_doc})")
info_dict = {}
for val in enum:
info_dict.update(
match_attribute_docs(
attr_name=val.name,
attr_docs=attr_docs,
attr_type_str=type(val.value).__name__,
)
)
handle_attributes_print(info_dict=info_dict, max_indent=max_indent)
def attrs_help(
input_classes: Union[list, tuple],
module_name: str,
extract_fnc: Callable,
max_indent: int,
) -> None:
"""Handles walking through a list 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. If it finds a repeated class in a iterable object it will
recursively call self to handle information
Args:
input_classes: list of attr classes
module_name: name of module to match
extract_fnc: function that gets the nested lists within classes
max_indent: max indent for pretty print of help
Returns:
None
"""
# Handle the main loop
other_list = handle_help_main(input_classes, module_name, extract_fnc, max_indent)
handle_help_enums(
other_list=other_list,
module_name=module_name,
extract_fnc=extract_fnc,
max_indent=max_indent,
)