Skip to content

Commit 9a43158

Browse files
committed
Added helper for retrieving a parser from a tool
1 parent a2a8c03 commit 9a43158

5 files changed

Lines changed: 114 additions & 69 deletions

File tree

src/tinyscript/helpers/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .layout import *
2121
from .licenses import *
2222
from .notify import *
23+
from .parser import *
2324
from .password import *
2425
from .path import *
2526
from .termsize import *
@@ -41,6 +42,7 @@
4142
from .layout import __features__ as _layout
4243
from .licenses import __features__ as _lic
4344
from .notify import __features__ as _notify
45+
from .parser import __features__ as _parser
4446
from .password import __features__ as _pswd
4547
from .path import __features__ as _path
4648
from .termsize import __features__ as _tsize
@@ -49,7 +51,7 @@
4951

5052

5153
__helpers__ = _attack + _common + _data + _dec + _dict + _docs + _expr + _fexec + _inputs + _layout + _lic + _notify + \
52-
_path + _pswd + _tsize + _text + _to
54+
_parser + _path + _pswd + _tsize + _text + _to
5355

5456
ts = ModuleType("ts", """
5557
Tinyscript helpers

src/tinyscript/helpers/parser.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# -*- coding: UTF-8 -*-
2+
"""Utility functions for retrieving a parser and subparsers from a tool.
3+
4+
"""
5+
from shutil import which
6+
7+
from .path import Path, PythonPath, TempPath
8+
from ..argreparse import ArgumentParser, ProxyArgumentParser
9+
10+
11+
__all__ = __features__ = ["get_parser", "get_parsers"]
12+
13+
14+
def get_parser(tool, logger=None, **kwargs):
15+
return [p for p in get_parsers(tool, logger=logger, **kwargs).values() if isinstance(p, ArgumentParser) and \
16+
not hasattr(p, "_parent")][0]
17+
18+
19+
def get_parsers(tool, logger=None, **kwargs):
20+
tmp = TempPath(length=16)
21+
if isinstance(tool, str):
22+
tool = Path(which(tool), expand=True)
23+
# copy the target tool to modify it so that its parser tree can be retrieved
24+
ntool = tool.copy(tmp.joinpath(f"_{tool.basename}.py"))
25+
ntool.write_text(ntool.read_text().replace("if __name__ == '__main__':", f"{kwargs.get('cond', '')}\ndef main():") \
26+
.replace("if __name__ == \"__main__\":", "def main():") \
27+
.replace("initialize(", "return parser\n initialize(") \
28+
.rstrip("\n") + "\n\nif __name__ == '__main__':\n main()\n")
29+
ntool.chmod(0o755)
30+
# populate the real parser and add information arguments
31+
try:
32+
__parsers = {PythonPath(ntool).module.main(): ArgumentParser(**kwargs)}
33+
except Exception as e:
34+
if logger:
35+
logger.critical(f"Parser retrieval failed for tool: {tool.basename}")
36+
logger.error(f"Source ({ntool}):\n{ntool.read_text()}")
37+
logger.exception(e)
38+
from sys import exit
39+
exit(1)
40+
# now import the populated list of parser calls from within the tinyscript.parser module
41+
from tinyscript.argreparse import parser_calls
42+
global parser_calls
43+
# proxy parser to real parser recursive conversion function
44+
def __proxy_to_real_parser(value):
45+
""" Source: tinyscript.parser """
46+
if isinstance(value, ProxyArgumentParser):
47+
return __parsers[value]
48+
elif isinstance(value, (list, tuple)):
49+
return [__proxy_to_real_parser(_) for _ in value]
50+
return value
51+
# now iterate over the registered calls
52+
pairs = []
53+
for proxy_parser, method, args, kwargs, proxy_subparser in parser_calls:
54+
kw_category = kwargs.get('category')
55+
real_parser = __parsers[proxy_parser]
56+
args = (__proxy_to_real_parser(v) for v in args)
57+
kwargs = {k: __proxy_to_real_parser(v) for k, v in kwargs.items()}
58+
# NB: when initializing a subparser, 'category' kwarg gets popped
59+
real_subparser = getattr(real_parser, method)(*args, **kwargs)
60+
if real_subparser is not None:
61+
__parsers[proxy_subparser] = real_subparser
62+
if not isinstance(real_subparser, str):
63+
real_subparser._parent = real_parser
64+
real_subparser.category = kw_category # reattach category
65+
tmp.remove()
66+
ArgumentParser.reset()
67+
return __parsers
68+

src/tinyscript/parser.py

Lines changed: 19 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import os
88
import re
99
import sys
10-
from inspect import currentframe, getmembers, isfunction, ismethod
10+
from inspect import getmembers, isfunction, ismethod
1111
from os.path import basename, splitext
1212

1313
from .features.handlers import _hooks
@@ -26,8 +26,6 @@
2626
BANNER_FONT = None
2727
BANNER_STYLE = {}
2828

29-
parser_calls = [] # will be populated by calls to ProxyArgumentParser
30-
3129

3230
def _save_config(glob):
3331
cf = glob['args'].write_config
@@ -82,38 +80,23 @@ def initialize(add_banner=False,
8280
:param report_func: report generation function
8381
:param autocomplete: add autocompletion with argcomplete
8482
"""
85-
global parser, parser_calls
83+
global parser
84+
tokens = kwargs.get('command', sys.argv)
8685
# handle backward-compatibility arguments
8786
exit_at_interrupt = kwargs.pop('exit_at_interrupt', None)
8887
if len(kwargs) > 0:
8988
raise TypeError("Unexpected keyword-arguments (%s)" % ", ".join(kwargs.keys()))
90-
# get caller's frame
91-
frame = currentframe().f_back
9289
# walk the stack until a frame containing a known object is found
93-
glob = {}
94-
while frame:
95-
if isinstance(frame.f_globals.get('parser'), ProxyArgumentParser):
96-
glob = frame.f_globals
97-
# search for dunders
98-
for d in DUNDERS:
99-
f = frame
100-
while f and (d not in f.f_globals.keys() or f.f_globals[d] is None):
101-
f = f.f_back
102-
try:
103-
glob[d] = f.f_globals[d]
104-
except (AttributeError, KeyError):
105-
pass
106-
break
107-
frame = frame.f_back
90+
glob = get_tool_globals()
10891
if any(glob.get(k) is not None for k in ["NOTIFICATION_ICONS_PATH", "NOTIFICATION_LEVEL", "NOTIFICATION_TIMEOUT"]):
10992
add_notify = True
11093
add = {'config': add_config, 'demo': add_demo, 'interact': add_interact, 'notify': add_notify,
11194
'progress': add_progress, 'step': add_step, 'time': add_time, 'version': add_version, 'wizard': add_wizard,
11295
'help': True, 'usage': True}
113-
p = ArgumentParser(glob)
96+
p = ArgumentParser(command=tokens)
11497
# 1) handle action when no input argument is given
11598
add['demo'] = add['demo'] and p.examples
116-
noarg = len(sys.argv) == 1
99+
noarg = len(tokens) == 1
117100
if noarg and noargs_action:
118101
if noargs_action not in add.keys():
119102
raise ValueError(gt("Bad action when no args (should be one of: {})").format('|'.join(add.keys())))
@@ -141,6 +124,7 @@ def __proxy_to_real_parser(value):
141124
return [__proxy_to_real_parser(_) for _ in value]
142125
return value
143126
# now iterate over the registered calls
127+
from .argreparse import parser_calls
144128
for proxy_parser, method, args, kwargs, proxy_subparser in parser_calls:
145129
real_parser = __parsers[proxy_parser]
146130
args = (__proxy_to_real_parser(v) for v in args)
@@ -150,7 +134,6 @@ def __proxy_to_real_parser(value):
150134
__parsers[proxy_subparser] = real_subparser
151135
# this allows to ensure that another call to initialize(...) will have a clean list of calls and an empty _config
152136
# attribute
153-
parser_calls = []
154137
ArgumentParser.reset()
155138
i = p.add_argument_group("extra arguments")
156139
# configure documentation formatting
@@ -162,13 +145,13 @@ def __proxy_to_real_parser(value):
162145
note=gt("this overrides other arguments"))
163146
c.add_argument("-w", "--write-config", metavar="INI", help=gt("write args to a config file"))
164147
if noarg and noargs_action == "config":
165-
sys.argv[1:] = [opt, "config.ini"]
148+
tokens[1:] = [opt, "config.ini"]
166149
# demonstration feature, for executing an example amongst these defined in __examples__, useful for observing what
167150
# the tool does
168151
if add['demo']:
169152
opt = i.add_argument("--demo", action="demo", prefix="play", help=gt("demonstrate a random example"))
170153
if noarg and noargs_action == "demo":
171-
sys.argv[1:] = [opt]
154+
tokens[1:] = [opt]
172155
# help feature, for displaying classical or extended help about the tool
173156
if add['help']:
174157
if glob.get('__details__'):
@@ -180,9 +163,9 @@ def __proxy_to_real_parser(value):
180163
else:
181164
opt = i.add_argument("-h", "--help", action="help", help=gt("show this help message and exit"))
182165
if noarg and noargs_action == "help":
183-
sys.argv[1:] = ["--help"]
166+
tokens[1:] = ["--help"]
184167
elif noarg and noargs_action == "usage":
185-
sys.argv[1:] = ["DISPLAY_USAGE"]
168+
tokens[1:] = ["DISPLAY_USAGE"]
186169
# interaction mode feature, for interacting with the tool during its execution, useful for debugging
187170
if add['interact']:
188171
j = p.add_argument_group("interaction arguments")
@@ -193,25 +176,25 @@ def __proxy_to_real_parser(value):
193176
j.add_argument("--port", default=12345, type=port_number, prefix="remote",
194177
help=gt("remote interacting port"))
195178
if noarg and noargs_action == "interact":
196-
sys.argv[1:] = [opt]
179+
tokens[1:] = [opt]
197180
set_interact_items(glob)
198181
# notification feature, for displaying notifications during the execution
199182
if add['notify']:
200183
opt = i.add_argument("-n", "--notify", action="store_true", suffix="mode", help=gt("notify mode"))
201184
if noarg and noargs_action == "notify":
202-
sys.argv[1:] = [opt]
185+
tokens[1:] = [opt]
203186
set_notify_items(glob)
204187
# progress mode feature, for displaying a progress bar during the execution
205188
if add['progress']:
206189
opt = i.add_argument("-p", "--progress", action="store_true", suffix="mode", help=gt("progress mode"))
207190
if noarg and noargs_action == "progress":
208-
sys.argv[1:] = [opt]
191+
tokens[1:] = [opt]
209192
set_progress_items(glob)
210193
# stepping mode feature, for stepping within the tool during its execution, especially useful for debugging
211194
if add['step']:
212195
opt = i.add_argument("--step", action="store_true", last=True, suffix="mode", help=gt("stepping mode"))
213196
if noarg and noargs_action == "step":
214-
sys.argv[1:] = [opt]
197+
tokens[1:] = [opt]
215198
set_step_items(glob)
216199
# timing mode feature, for measuring time along the execution of the tool
217200
if add['time']:
@@ -221,15 +204,15 @@ def __proxy_to_real_parser(value):
221204
b.add_argument("--timings", action='store_true', last=True, suffix="mode",
222205
help=gt("display time stats during execution"))
223206
if noarg and noargs_action == "time":
224-
sys.argv[1:] = [opt]
207+
tokens[1:] = [opt]
225208
# version feature, for displaying the version from __version__
226209
if add['version']:
227210
version = glob['__version__'] if '__version__' in glob else None
228211
if version is not None:
229212
opt = i.add_argument("--version", action='version', prefix="show", version=version,
230213
help=gt("show program's version number and exit"))
231214
if noarg and noargs_action == "version":
232-
sys.argv[1:] = [opt]
215+
tokens[1:] = [opt]
233216
# verbosity feature, for displaying debugging messages, with the possibility to handle multi-level verbosity
234217
if multi_level_debug:
235218
i.add_argument("-v", dest="verbose", default=0, action="count", suffix="mode", cancel=True, last=True,
@@ -240,7 +223,7 @@ def __proxy_to_real_parser(value):
240223
if add['wizard']:
241224
opt = i.add_argument("-w", "--wizard", action="wizard", prefix="start", help=gt("start a wizard"))
242225
if noarg and noargs_action == "wizard":
243-
sys.argv[1:] = [opt]
226+
tokens[1:] = [opt]
244227
# reporting feature, for making a reporting with the results of the tool at the end of its execution
245228
if report_func is not None:
246229
if not isfunction(report_func):
@@ -282,7 +265,7 @@ def __proxy_to_real_parser(value):
282265
# 3) if sudo required, restart the script
283266
if sudo and not is_admin():
284267
exe = ["runas", "/env", "/user:Administrator"] if WINDOWS else ["sudo", "-E"]
285-
os.execvp(["sudo", "runas"][WINDOWS], exe + [sys.executable] + sys.argv)
268+
os.execvp(["sudo", "runas"][WINDOWS], exe + [sys.executable] + tokens)
286269
# 4) configure logging and get the main logger
287270
a = glob['args']
288271
configure_logger(glob, multi_level_debug,
@@ -340,36 +323,5 @@ def __at_exit():
340323
globals()['AT_EXIT_SET'] = False
341324

342325

343-
class ProxyArgumentParser(object):
344-
"""
345-
Proxy class for collecting added arguments before initialization.
346-
"""
347-
def __getattr__(self, name):
348-
""" Each time a method is called, return __collect to make it capture the input arguments and keyword-arguments
349-
if it exists in the original parser class. """
350-
self.__call = name
351-
return self.__collect
352-
353-
def __collect(self, *args, **kwargs):
354-
""" Capture the input arguments and keyword-arguments of the currently called method, appending a proxy
355-
subparser in case it should be used for mutually exclusive groups or subparsers. """
356-
subparser = ProxyArgumentParser()
357-
parser_calls.append((self, self.__call, args, kwargs, subparser))
358-
del self.__call
359-
return subparser
360-
361-
def __enter__(self):
362-
return self
363-
364-
def __exit__(self, exc_type, exc_value, exc_traceback):
365-
pass
366-
367-
@staticmethod
368-
def reset():
369-
global parser_calls
370-
parser_calls = []
371-
ArgumentParser.reset()
372-
373-
374326
parser = ProxyArgumentParser()
375327

tests/test_helpers_parser.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# -*- coding: UTF-8 -*-
2+
"""Root module's __conf__.py tests.
3+
4+
"""
5+
from tinyscript.helpers import get_parser, Path
6+
from tinyscript.template import new, TARGETS, TEMPLATE
7+
8+
from utils import *
9+
10+
11+
class TestConf(TestCase):
12+
def test_parser_retrieval(self):
13+
script = TEMPLATE.format(base="", target="").replace("# TODO: add arguments", "parser.add_argument('test')")
14+
with open(".test-script.py", 'wt') as f:
15+
f.write(script)
16+
p = get_parser(Path(".test-script.py"))
17+
subparsers = p.add_subparsers(dest="command")
18+
test = subparsers.add_parser("subtest", aliases=["test2"], help="test", parents=[p])
19+
test.add_argument("--test")
20+
self.assertTrue(hasattr(p, "tokens"))
21+

tests/utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from time import sleep
1313
from tinyscript.helpers.constants import WINDOWS
1414
from tinyscript.helpers.decorators import failsafe
15+
from tinyscript.argreparse import ArgumentParser
1516
from unittest import TestCase
1617
try:
1718
from unittest.mock import patch as mock_patch
@@ -24,7 +25,7 @@
2425
"tmpf", "FakeLogRecord", "FakeNamespace", "TestCase", "_FakeParserAction", "FIXTURES", "WINDOWS"]
2526

2627

27-
FIXTURES = {
28+
FIXTURES = ArgumentParser._globals_dict = {
2829
'__author__': "John Doe",
2930
'__contributors__': [
3031
{'author': "James McAdams", 'email': "j.mcadams@hotmail.com"},
@@ -34,6 +35,7 @@
3435
'__copyright__': "test",
3536
'__credits__': "Thanks to Bob for his contribution",
3637
'__doc__': "test tool",
38+
'__details__': "some more information",
3739
'__email__': "john.doe@example.com",
3840
'__examples__': ["-v"],
3941
'__license__': "agpl-v3.0",

0 commit comments

Comments
 (0)