Skip to content

Commit 2ed493b

Browse files
committed
Added list paramter conversion and better autocomplete
1 parent 2788a59 commit 2ed493b

File tree

2 files changed

+190
-12
lines changed

2 files changed

+190
-12
lines changed

easybuild/cli/__init__.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
1+
import os
2+
13
try:
24
import rich_click as click
5+
import click as original_click
36
except ImportError:
47
import click
8+
import click as original_click
59

610
try:
711
from rich.traceback import install
812
except ImportError:
913
pass
1014
else:
11-
install(suppress=[click])
15+
install(suppress=[
16+
click, original_click
17+
])
1218

1319
from .options import EasyBuildCliOption
1420

1521
from easybuild.main import main_with_hooks
1622

23+
1724
@click.command()
1825
@EasyBuildCliOption.apply_options
1926
@click.pass_context
@@ -27,9 +34,21 @@ def eb(ctx, other_args):
2734
if value:
2835
args.append(f"--{key}")
2936
else:
30-
if value and value != EasyBuildCliOption.OPTIONS_MAP[key].default:
31-
if isinstance(value, (list, tuple)):
32-
value = ','.join(value)
37+
opt = EasyBuildCliOption.OPTIONS_MAP[key]
38+
if value and value != opt.default:
39+
if isinstance(value, (list, tuple)) and value:
40+
if isinstance(value[0], list):
41+
value = sum(value, [])
42+
if 'path' in opt.type:
43+
delim = os.pathsep
44+
elif 'str' in opt.type:
45+
delim = ','
46+
elif 'url' in opt.type:
47+
delim = '|'
48+
else:
49+
raise ValueError(f"Unsupported type for {key}: {opt.type}")
50+
value = delim.join(value)
51+
print(f"--Adding {key}={value} to args")
3352
args.append(f"--{key}={value}")
3453

3554
args.extend(other_args)

easybuild/cli/options/__init__.py

Lines changed: 167 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1+
# import logging
12
import os
23

34
from typing import Callable, Any
45
from dataclasses import dataclass
56

7+
from click.shell_completion import CompletionItem
8+
from easybuild.tools.options import EasyBuildOptions
9+
610
opt_group = {}
711
try:
812
import rich_click as click
913
except ImportError:
1014
import click
1115
else:
1216
opt_group = click.rich_click.OPTION_GROUPS
17+
opt_group.clear() # Clear existing groups to avoid conflicts
1318

14-
from easybuild.tools.options import EasyBuildOptions
15-
16-
DEBUG_EASYBUILD_OPTIONS = os.environ.get('DEBUG_EASYBUILD_OPTIONS', '').lower() in ('1', 'true', 'yes', 'y')
1719

1820
class OptionExtracter(EasyBuildOptions):
1921
def __init__(self, *args, **kwargs):
@@ -24,9 +26,69 @@ def add_group_parser(self, opt_dict, descr, *args, prefix='', **kwargs):
2426
super().add_group_parser(opt_dict, descr, *args, prefix=prefix, **kwargs)
2527
self._option_dicts[descr[0]] = (prefix, opt_dict)
2628

29+
2730
extracter = OptionExtracter(go_args=[])
2831

2932

33+
class DelimitedPathList(click.Path):
34+
"""Custom Click parameter type for delimited lists."""
35+
name = 'pathlist'
36+
37+
def __init__(self, *args, delimiter=',', resolve_full: bool = True, **kwargs):
38+
super().__init__(*args, **kwargs)
39+
self.delimiter = delimiter
40+
self.resolve_full = resolve_full
41+
42+
def convert(self, value, param, ctx):
43+
if not isinstance(value, str):
44+
raise click.BadParameter(f"Expected a comma-separated string, got {value}")
45+
res = value.split(self.delimiter)
46+
if self.resolve_full:
47+
res = [os.path.abspath(v) for v in res]
48+
return res
49+
50+
def shell_complete(self, ctx, param, incomplete):
51+
others, last = ([''] + incomplete.rsplit(self.delimiter, 1))[-2:]
52+
# logging.warning(f"Shell completion for delimited path list: others={others}, last={last}")
53+
dir_path, prefix = os.path.split(last)
54+
dir_path = dir_path or '.'
55+
# logging.warning(f"Shell completion for delimited path list: dir_path={dir_path}, prefix={prefix}")
56+
possibles = []
57+
for path in os.listdir(dir_path):
58+
if not path.startswith(prefix):
59+
continue
60+
full_path = os.path.join(dir_path, path)
61+
if os.path.isdir(full_path):
62+
if self.dir_okay:
63+
possibles.append(full_path)
64+
possibles.append(full_path + os.sep)
65+
elif os.path.isfile(full_path):
66+
if self.file_okay:
67+
possibles.append(full_path)
68+
start = f'{others}{self.delimiter}' if others else ''
69+
res = [CompletionItem(f"{start}{path}") for path in possibles]
70+
# logging.warning(f"Shell completion for delimited path list: res={possibles}")
71+
return res
72+
73+
74+
class DelimitedString(click.ParamType):
75+
"""Custom Click parameter type for delimited strings."""
76+
name = 'strlist'
77+
78+
def __init__(self, *args, delimiter=',', **kwargs):
79+
super().__init__(*args, **kwargs)
80+
self.delimiter = delimiter
81+
82+
def convert(self, value, param, ctx):
83+
if isinstance(value, str):
84+
return value.split(self.delimiter)
85+
raise click.BadParameter(f"Expected a string or a comma-separated string, got {value}")
86+
87+
def shell_complete(self, ctx, param, incomplete):
88+
last = incomplete.rsplit(self.delimiter, 1)[-1]
89+
return super().shell_complete(ctx, param, last)
90+
91+
3092
@dataclass
3193
class OptionData:
3294
name: str
@@ -55,17 +117,112 @@ def to_click_option_dec(self):
55117

56118
kwargs = {
57119
'help': self.description,
58-
# 'help': '123',
59120
'default': self.default,
60121
'show_default': True,
122+
'type': None
61123
}
62124

63-
if self.default is False or self.default is True:
64-
kwargs['is_flag'] = True
65-
66-
if isinstance(self.default, (list, tuple)):
125+
if self.type in ['strlist', 'strtuple']:
126+
kwargs['type'] = DelimitedString(delimiter=',')
67127
kwargs['multiple'] = True
128+
elif self.type in ['pathlist', 'pathtuple']:
129+
# kwargs['type'] = DelimitedPathList(delimiter=os.pathsep)
130+
kwargs['type'] = DelimitedPathList(delimiter=',')
131+
kwargs['multiple'] = True
132+
elif self.type in ['urllist', 'urltuple']:
133+
kwargs['type'] = DelimitedString(delimiter='|')
134+
kwargs['multiple'] = True
135+
elif self.type == 'choice':
136+
if self.lst is None:
137+
raise ValueError(f"Choice type requires a list of choices for option {self.name}")
138+
kwargs['type'] = click.Choice(self.lst, case_sensitive=False)
139+
elif self.type in ['int', int]:
140+
kwargs['type'] = click.INT
141+
elif self.type in ['float', float]:
142+
kwargs['type'] = click.FLOAT
143+
elif self.type in ['str', str]:
68144
kwargs['type'] = click.STRING
145+
elif self.type is None:
146+
if self.default is False or self.default is True:
147+
kwargs['is_flag'] = True
148+
kwargs['type'] = click.BOOL
149+
elif isinstance(self.default, (list, tuple)):
150+
kwargs['multiple'] = True
151+
kwargs['type'] = click.STRING
152+
153+
# if kwargs['type'] is None:
154+
# print(f"Warning: No type specified for option {self.name}, defaulting to STRING")
155+
156+
# actions = set()
157+
# for opt in EasyBuildCliOption.OPTIONS:
158+
# actions.add(opt.action)
159+
# print(f"Registered {len(EasyBuildCliOption.OPTIONS)} options with actions: {actions}")
160+
# # Registered 296 options with actions: {
161+
# # 'store_infolog', 'add_flex', 'append', 'add', 'store_true', 'store_debuglog', 'store_or_None',
162+
# # 'store_warninglog', 'store', 'extend', 'regex'
163+
# # }
164+
165+
# Actions:
166+
# - shorthelp : hook for shortend help messages
167+
# - confighelp : hook for configfile-style help messages
168+
# - store_debuglog : turns on fancylogger debugloglevel
169+
# - also: 'store_infolog', 'store_warninglog'
170+
# - add : add value to default (result is default + value)
171+
# - add_first : add default to value (result is value + default)
172+
# - extend : alias for add with strlist type
173+
# - type must support + (__add__) and one of negate (__neg__) or slicing (__getslice__)
174+
# - add_flex : similar to add / add_first, but replaces the first "empty" element with the default
175+
# - the empty element is dependent of the type
176+
# - for {str,path}{list,tuple} this is the empty string
177+
# - types must support the index method to determine the location of the "empty" element
178+
# - the replacement uses +
179+
# - e.g. a strlist type with value "0,,1"` and default [3,4] and action add_flex will
180+
# use the empty string '' as "empty" element, and will result in [0,3,4,1] (not [0,[3,4],1])
181+
# (but also a strlist with value "" and default [3,4] will result in [3,4];
182+
# so you can't set an empty list with add_flex)
183+
# - date : convert into datetime.date
184+
# - datetime : convert into datetime.datetime
185+
# - regex: compile str in regexp
186+
# - store_or_None
187+
# - set default to None if no option passed,
188+
# - set to default if option without value passed,
189+
# - set to value if option with value passed
190+
191+
# Types:
192+
# - strlist, strtuple : convert comma-separated string in a list resp. tuple of strings
193+
# - pathlist, pathtuple : using os.pathsep, convert pathsep-separated string in a list resp. tuple of strings
194+
# - the path separator is OS-dependent
195+
# - urllist, urltuple: convert string seperated by '|' to a list resp. tuple of strings
196+
197+
# def take_action(self, action, dest, opt, value, values, parser):
198+
# if action == "store":
199+
# setattr(values, dest, value)
200+
# elif action == "store_const":
201+
# setattr(values, dest, self.const)
202+
# elif action == "store_true":
203+
# setattr(values, dest, True)
204+
# elif action == "store_false":
205+
# setattr(values, dest, False)
206+
# elif action == "append":
207+
# values.ensure_value(dest, []).append(value)
208+
# elif action == "append_const":
209+
# values.ensure_value(dest, []).append(self.const)
210+
# elif action == "count":
211+
# setattr(values, dest, values.ensure_value(dest, 0) + 1)
212+
# elif action == "callback":
213+
# args = self.callback_args or ()
214+
# kwargs = self.callback_kwargs or {}
215+
# self.callback(self, opt, value, parser, *args, **kwargs)
216+
# elif action == "help":
217+
# parser.print_help()
218+
# parser.exit()
219+
# elif action == "version":
220+
# parser.print_version()
221+
# parser.exit()
222+
# else:
223+
# raise ValueError("unknown action %r" % self.action)
224+
225+
# return 1
69226

70227
return click.option(
71228
*decls,
@@ -81,6 +238,7 @@ def register_hidden_param(ctx, param, value):
81238
ctx.hidden_params = {}
82239
ctx.hidden_params[param.name] = value
83240

241+
84242
class EasyBuildCliOption():
85243
OPTIONS: list[OptionData] = []
86244
OPTIONS_MAP: dict[str, OptionData] = {}
@@ -144,6 +302,7 @@ def register_option(cls, group: str, name: str, data: tuple, prefix: str = '') -
144302
cls.OPTIONS_MAP[name] = opt
145303
cls.OPTIONS.append(opt)
146304

305+
147306
for grp, dct in extracter._option_dicts.items():
148307
prefix, dct = dct
149308
if dct is None:

0 commit comments

Comments
 (0)