Skip to content

Commit e17306c

Browse files
committed
Add support for usage alongside Gooey
1 parent 3b40893 commit e17306c

File tree

3 files changed

+116
-29
lines changed

3 files changed

+116
-29
lines changed

README.md

+33
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,47 @@ $ FORCE_TOOEY=1 python tooey_example.py val1 --named-choices 1 3 | sort
6666
```
6767

6868

69+
## Using alongside Gooey
70+
It can be useful to decorate methods with both `@Tooey` and `@Gooey` so that scripts can be run flexibly depending on context.
71+
To avoid conflicts, if both decorators are present for a single method, Tooey makes sure that only one of them is active.
72+
Which one is chosen depends on the order in which you add their decorators, with the decorator closest to the function taking priority:
73+
74+
```python
75+
@Gooey
76+
@Tooey
77+
def main_tooey():
78+
# Here Tooey is activated and Gooey is ignored
79+
# To force Gooey to be used instead, pass the `--ignore-tooey` command line option
80+
[...]
81+
82+
@Tooey
83+
@Gooey
84+
def main_gooey():
85+
# Here Gooey is activated and Tooey is ignored
86+
# To force Tooey to be used instead, pass the `--ignore-gooey` command line option
87+
[...]
88+
```
89+
90+
Regardless of decorator order, you can always use the command line parameters `--ignore-tooey` and `--ignore-gooey` to switch behaviour, as outlined in the example above.
91+
If Gooey is present (and not ignored) it will take precedence over the the `--force-tooey` parameter.
92+
Please note that due to the nature of Gooey's interaction with command line arguments, complex scripts with multiple Gooey decorators or unusual configurations may not be fully compatibile with this approach, and it is advisable to test your script when using both Tooey and Gooey simultaneously.
93+
94+
6995
## Testing
7096
To run the Tooey tests and generate a coverage report, first clone this repository and open the `tests` directory in a terminal, then:
7197

7298
```console
99+
python -m pip install gooey
73100
python -m coverage run -m unittest
74101
python -m coverage html --include '*/tooey/*' --omit '*test*'
75102
```
76103

77104

105+
## Inspirations and alternatives
106+
- [Gooey](https://github.com/chriskiehl/Gooey) adds a GUI interface to (almost) any script
107+
- [GooeyWrapper](https://github.com/skeenp/gooeywrapper) extends Gooey to make switching between the command line and Gooey a little more seamless
108+
- [Click](https://click.palletsprojects.com/en/8.1.x/options/#prompting) supports command line options that auto-prompt when missing
109+
110+
78111
## License
79112
[Apache 2.0](https://github.com/simonrob/tooey/blob/main/LICENSE)

tooey/__version__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
__title__ = 'tooey'
2-
__version__ = '0.3.0'
2+
__version__ = '0.4.0'
33
__description__ = 'Automatically turn script arguments into an interactive terminal interface'
44
__author__ = 'Simon Robinson'
55
__author_email__ = '[email protected]'

tooey/tooey.py

+82-28
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55
import argparse
66
import contextlib
7+
import copy
78
import functools
89
import os
910
import sys
@@ -31,50 +32,102 @@
3132
_YES_CHOICES = ('y', 'yes')
3233
_YES_CHOICES_STRING = ' / '.join(_YES_CHOICES)
3334

35+
_GOOEY_IGNORE_COMMAND = '--ignore-gooey'
36+
3437

3538
# noinspection PyPep8Naming
3639
def Tooey(f=None):
40+
global_config = None
41+
if 'gooey' in sys.modules:
42+
# when Gooey is present we need to be able to parse arguments earlier in order to disable it if necessary, and
43+
# with no ability to detect parent decorators, must assume importing Gooey means using it for *this* function
44+
# note: the need to handle this in the root Tooey definition means that it will be run at launch, before the
45+
# wrapped function is actually called (and multiple times if there are multiple `@Tooey` decorators used)
46+
config_parser = argparse.ArgumentParser(add_help=False)
47+
config_parser.add_argument('--ignore-tooey', action='store_true')
48+
config_parser.add_argument('--force-tooey', action='store_true')
49+
global_config, remaining_argv = config_parser.parse_known_args()
50+
global_config.ignore_tooey, global_config.force_tooey = check_environment(global_config.ignore_tooey,
51+
global_config.force_tooey)
52+
53+
# TODO: improvable without breaking Gooey integration? E.g., via a new decorator that applies Tooey *and* Gooey?
54+
sys.argv = [sys.argv[0]] + remaining_argv
55+
56+
if not global_config.ignore_tooey:
57+
with contextlib.suppress(IndexError):
58+
if _GOOEY_IGNORE_COMMAND not in sys.argv:
59+
sys.argv.append(_GOOEY_IGNORE_COMMAND)
60+
3761
@functools.wraps(f)
3862
def wrapper(*args, **kwargs):
39-
ArgumentParser.original_parse_args = ArgumentParser.parse_args
40-
ArgumentParser.original_error = ArgumentParser.error
41-
ArgumentParser.original_error_message = None
63+
ArgumentParser.tooey_original_parse_args = ArgumentParser.parse_args
64+
ArgumentParser.tooey_original_error = ArgumentParser.error
65+
ArgumentParser.tooey_global_config = global_config
4266

4367
ArgumentParser.parse_args = parse_args
4468
ArgumentParser.error = error
4569

46-
result = f(*args, **kwargs)
70+
if 'gooey' in sys.modules and not global_config.ignore_tooey: # undo our Gooey modification
71+
with contextlib.suppress(IndexError):
72+
if sys.argv[-1] == _GOOEY_IGNORE_COMMAND:
73+
sys.argv.pop()
4774

48-
ArgumentParser.parse_args = ArgumentParser.original_parse_args
49-
ArgumentParser.error = ArgumentParser.original_error
75+
result = f(*args, **kwargs)
5076

51-
del ArgumentParser.original_parse_args
52-
del ArgumentParser.original_error
53-
del ArgumentParser.original_error_message
77+
ArgumentParser.parse_args = ArgumentParser.tooey_original_parse_args
78+
ArgumentParser.error = ArgumentParser.tooey_original_error
5479

5580
return result
5681

5782
return wrapper
5883

5984

60-
def parse_args(self, args=None, namespace=None):
61-
with contextlib.suppress(argparse.ArgumentError): # ignore if these have already been defined by the base function
62-
self.add_argument('--ignore-tooey', action='store_true', help=argparse.SUPPRESS)
63-
self.add_argument('--force-tooey', action='store_true', help=argparse.SUPPRESS)
64-
parsed_args = self.original_parse_args(args, namespace)
65-
66-
# handle environment variables and tooey-related arguments - if both are set, do not proceed with Tooey
67-
ignore_tooey = parsed_args.ignore_tooey or os.environ.get('IGNORE_TOOEY')
68-
force_tooey = parsed_args.force_tooey or os.environ.get('FORCE_TOOEY')
85+
def check_environment(ignore_tooey, force_tooey):
86+
ignore_tooey = os.environ.get('IGNORE_TOOEY') or ignore_tooey
87+
force_tooey = os.environ.get('FORCE_TOOEY') or force_tooey
6988
if ignore_tooey and force_tooey:
7089
force_tooey = False
71-
del parsed_args.__dict__['ignore_tooey']
72-
del parsed_args.__dict__['force_tooey']
73-
self._actions = [a for a in self._actions if a.dest not in ('ignore_tooey', 'force_tooey')]
90+
return ignore_tooey, force_tooey
7491

75-
if (not sys.stdout.isatty() or ignore_tooey) and not force_tooey:
76-
if self.original_error_message:
77-
self.original_error(self.original_error_message)
92+
93+
def safe_get_namespace_boolean(namespaces, key):
94+
for namespace in namespaces: # for when we don't know if a store_true argument is actually present
95+
if key in namespace.__dict__:
96+
return namespace.__dict__[key]
97+
return False
98+
99+
100+
def parse_args(self, args=None, namespace=None):
101+
self.tooey_original_error_message = None
102+
if hasattr(self, 'tooey_global_config') and self.tooey_global_config:
103+
self.tooey_config = copy.deepcopy(self.tooey_global_config)
104+
else:
105+
self.tooey_config = argparse.Namespace()
106+
107+
if 'gooey' not in sys.modules or args is not None:
108+
# called on a specified list rather than sys.argv - note: if Gooey supported this (which it currently doesn't),
109+
# calling in this way would show up in the Gooey UI - perhaps not fixable until Gooey is patched?
110+
with contextlib.suppress(argparse.ArgumentError):
111+
self.add_argument('--ignore-tooey', action='store_true', help=argparse.SUPPRESS)
112+
self.add_argument('--force-tooey', action='store_true', help=argparse.SUPPRESS)
113+
114+
parsed_args = self.tooey_original_parse_args(args, namespace)
115+
116+
# re-check environment variables - they could be set inside the patched function itself (e.g. in our own tests...)
117+
self.tooey_config.ignore_tooey, self.tooey_config.force_tooey = check_environment(
118+
safe_get_namespace_boolean([self.tooey_config, parsed_args], 'ignore_tooey'),
119+
safe_get_namespace_boolean([self.tooey_config, parsed_args], 'force_tooey'))
120+
121+
if 'gooey' not in sys.modules or args is not None:
122+
internal_args = ('ignore_tooey', 'force_tooey')
123+
for arg in internal_args:
124+
if arg in parsed_args.__dict__:
125+
del parsed_args.__dict__[arg] # TODO: if these weren't defined by us, they'll now be missing...
126+
self._actions = [a for a in self._actions if a.dest not in internal_args]
127+
128+
if (not sys.stdout.isatty() or self.tooey_config.ignore_tooey) and not self.tooey_config.force_tooey:
129+
if self.tooey_original_error_message:
130+
self.tooey_original_error(self.tooey_original_error_message)
78131
return parsed_args
79132

80133
print(_SEPARATOR)
@@ -123,8 +176,9 @@ def parse_args(self, args=None, namespace=None):
123176
except KeyboardInterrupt:
124177
print('\n\nTooey interactive mode interrupted - continuing script')
125178
print(_SEPARATOR)
126-
if self.original_error_message:
127-
self.original_error(self.original_error_message)
179+
if self.tooey_original_error_message:
180+
# TODO: continue script execution instead if inputs so far have addressed the original error?
181+
self.tooey_original_error(self.tooey_original_error_message)
128182

129183

130184
def get_input(prompt='', strip=False):
@@ -135,7 +189,7 @@ def get_input(prompt='', strip=False):
135189
if strip:
136190
response = response.strip()
137191
if 'unittest' in sys.modules:
138-
print(response)
192+
print(response) # it is useful to be able to see the actual input when testing
139193
return response
140194

141195

@@ -282,4 +336,4 @@ def _parse_store_action(action, append=False):
282336

283337
# ArgumentParser's exit_on_error argument was added in Python 3.9; we support below this so override rather than catch
284338
def error(self, message):
285-
self.original_error_message = message # to be used on failure/cancellation
339+
self.tooey_original_error_message = message # to be used on failure/cancellation

0 commit comments

Comments
 (0)