argbind-dbraun 0.5.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argbind/__init__.py +21 -0
- argbind/argbind.py +798 -0
- argbind/py.typed +0 -0
- argbind_dbraun-0.5.2.dist-info/METADATA +341 -0
- argbind_dbraun-0.5.2.dist-info/RECORD +8 -0
- argbind_dbraun-0.5.2.dist-info/WHEEL +5 -0
- argbind_dbraun-0.5.2.dist-info/licenses/LICENSE.md +22 -0
- argbind_dbraun-0.5.2.dist-info/top_level.txt +1 -0
argbind/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .argbind import (
|
|
2
|
+
bind,
|
|
3
|
+
bind_module,
|
|
4
|
+
build_parser,
|
|
5
|
+
dump_args,
|
|
6
|
+
get_used_args,
|
|
7
|
+
load_args,
|
|
8
|
+
parse_args,
|
|
9
|
+
scope,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"bind",
|
|
14
|
+
"bind_module",
|
|
15
|
+
"build_parser",
|
|
16
|
+
"parse_args",
|
|
17
|
+
"dump_args",
|
|
18
|
+
"load_args",
|
|
19
|
+
"get_used_args",
|
|
20
|
+
"scope",
|
|
21
|
+
]
|
argbind/argbind.py
ADDED
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import ast
|
|
3
|
+
import dataclasses
|
|
4
|
+
import inspect
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import textwrap
|
|
8
|
+
import types
|
|
9
|
+
import warnings
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from functools import wraps
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal, Union, get_args, get_origin
|
|
14
|
+
|
|
15
|
+
import docstring_parser
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
PARSE_FUNCS = {}
|
|
19
|
+
ARGS = {}
|
|
20
|
+
USED_ARGS = {}
|
|
21
|
+
PATTERN = None
|
|
22
|
+
DEBUG = False
|
|
23
|
+
HELP_WIDTH = 60
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# A dataclass field with a default_factory shows this singleton as its __init__
|
|
27
|
+
# default; capture it via public API (rather than importing a private name) so it
|
|
28
|
+
# can be filtered out before serialization. See dump_args.
|
|
29
|
+
@dataclasses.dataclass
|
|
30
|
+
class _FactoryProbe:
|
|
31
|
+
x: list = dataclasses.field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_DEFAULT_FACTORY_SENTINEL = (
|
|
35
|
+
inspect.signature(_FactoryProbe.__init__).parameters["x"].default
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@contextmanager
|
|
40
|
+
def scope(parsed_args, pattern=""):
|
|
41
|
+
"""
|
|
42
|
+
Context manager to put parsed arguments into
|
|
43
|
+
a state.
|
|
44
|
+
"""
|
|
45
|
+
parsed_args = parsed_args.copy()
|
|
46
|
+
remove_keys = []
|
|
47
|
+
matched = {}
|
|
48
|
+
|
|
49
|
+
global ARGS
|
|
50
|
+
global PATTERN
|
|
51
|
+
|
|
52
|
+
old_args = ARGS
|
|
53
|
+
old_pattern = PATTERN
|
|
54
|
+
|
|
55
|
+
for key in parsed_args:
|
|
56
|
+
if "/" in key:
|
|
57
|
+
if key.split("/")[0] == pattern:
|
|
58
|
+
matched[key.split("/")[-1]] = parsed_args[key]
|
|
59
|
+
remove_keys.append(key)
|
|
60
|
+
|
|
61
|
+
parsed_args.update(matched)
|
|
62
|
+
for key in remove_keys:
|
|
63
|
+
parsed_args.pop(key)
|
|
64
|
+
ARGS = parsed_args
|
|
65
|
+
PATTERN = pattern
|
|
66
|
+
yield
|
|
67
|
+
|
|
68
|
+
ARGS = old_args
|
|
69
|
+
PATTERN = old_pattern
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _format_func_debug(func_name, func_kwargs, scope=None):
|
|
73
|
+
formatted = [f"{func_name}("]
|
|
74
|
+
if scope is not None:
|
|
75
|
+
formatted.append(f" # scope = {scope}")
|
|
76
|
+
for key, val in func_kwargs.items():
|
|
77
|
+
formatted.append(f" {key} : {type(val).__name__} = {val}")
|
|
78
|
+
formatted.append(")")
|
|
79
|
+
return "\n".join(formatted)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def bind(
|
|
83
|
+
*args, without_prefix=False, positional=False, group: Union[list, str] = "default"
|
|
84
|
+
):
|
|
85
|
+
"""Binds a functions arguments so that it looks up argument
|
|
86
|
+
values in a dictionary scoped by ArgBind.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
args : List[str] or [fn or Object] + List[str], optional
|
|
91
|
+
List of patterns to bind the function under. If the first item
|
|
92
|
+
in the list is a function or Object, then the function is bound
|
|
93
|
+
here (e.g. decorate is called on the first argument). Otherwise,
|
|
94
|
+
it is treated is a decorator.
|
|
95
|
+
without_prefix : bool, optional
|
|
96
|
+
Whether or not to bind without the function name as the prefix.
|
|
97
|
+
If True, the functions arguments will be available at "arg_name"
|
|
98
|
+
rather than "func_name.arg_name", by default False
|
|
99
|
+
positional : bool, optional
|
|
100
|
+
Arguments that are not keyword arguments are not bound by default. If
|
|
101
|
+
this is True, then the arguments will be bound as positional arguments
|
|
102
|
+
in some order, by default False
|
|
103
|
+
group : list or str, optional
|
|
104
|
+
Group or list of groups to assign this function to. ``build_parser``
|
|
105
|
+
and ``parse_args`` can then build a parser for only a subset of bound
|
|
106
|
+
functions by group, by default "default".
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
if args and not isinstance(args[0], str):
|
|
110
|
+
bound_fn_or_cls = args[0]
|
|
111
|
+
patterns = args[1:] if len(args) > 1 else []
|
|
112
|
+
else:
|
|
113
|
+
bound_fn_or_cls = None
|
|
114
|
+
patterns = args
|
|
115
|
+
|
|
116
|
+
if positional and patterns:
|
|
117
|
+
warnings.warn(
|
|
118
|
+
f"Combining positional arguments with scoping patterns is not allowed. Removing scoping patterns {patterns}. \n"
|
|
119
|
+
"See https://github.com/pseeth/argbind/tree/main/examples/hello_world#argbind-with-positional-arguments"
|
|
120
|
+
)
|
|
121
|
+
patterns = []
|
|
122
|
+
|
|
123
|
+
if isinstance(group, str):
|
|
124
|
+
group = [group]
|
|
125
|
+
|
|
126
|
+
def decorator(object_or_func):
|
|
127
|
+
func = object_or_func
|
|
128
|
+
prefix = func.__qualname__ # get prefix before a potential monkey patch below changes it to a superclass's prefix
|
|
129
|
+
is_class = inspect.isclass(func)
|
|
130
|
+
if is_class:
|
|
131
|
+
# If the class has no __init__ method, find the __init__ method from the closest superclass
|
|
132
|
+
# that defines one. Then monkey patch that __init__ onto the class.
|
|
133
|
+
if "__init__" not in func.__dict__:
|
|
134
|
+
for base in func.__mro__[1:]:
|
|
135
|
+
if "__init__" in base.__dict__:
|
|
136
|
+
func.__init__ = base.__init__ # monkey patch
|
|
137
|
+
break
|
|
138
|
+
func = getattr(func, "__init__")
|
|
139
|
+
|
|
140
|
+
if "__init__" in prefix:
|
|
141
|
+
prefix = prefix.split(".")[0]
|
|
142
|
+
|
|
143
|
+
# Check if function is bound already. If it is, just re-wrap it,
|
|
144
|
+
# instead of wrapping the function twice.
|
|
145
|
+
if prefix in PARSE_FUNCS:
|
|
146
|
+
func = PARSE_FUNCS[prefix][0]
|
|
147
|
+
else:
|
|
148
|
+
PARSE_FUNCS[prefix] = (func, patterns, without_prefix, positional, group)
|
|
149
|
+
|
|
150
|
+
@wraps(func)
|
|
151
|
+
def cmd_func(*args, **kwargs):
|
|
152
|
+
parameters = list(inspect.signature(func).parameters.items())
|
|
153
|
+
|
|
154
|
+
cmd_kwargs = {}
|
|
155
|
+
pos_kwargs = {parameters[i][0]: arg for i, arg in enumerate(args)}
|
|
156
|
+
|
|
157
|
+
for key, param in parameters:
|
|
158
|
+
arg_val = param.default
|
|
159
|
+
if arg_val is not inspect.Parameter.empty or positional:
|
|
160
|
+
arg_name = f"{prefix}.{key}" if not without_prefix else f"{key}"
|
|
161
|
+
if arg_name in ARGS and key not in kwargs:
|
|
162
|
+
val = ARGS[arg_name]
|
|
163
|
+
if key in pos_kwargs:
|
|
164
|
+
val = pos_kwargs[key]
|
|
165
|
+
# Cast value to the expected type (important for YAML-loaded values)
|
|
166
|
+
val = _cast_value(val, param.annotation)
|
|
167
|
+
cmd_kwargs[key] = val
|
|
168
|
+
use_key = arg_name
|
|
169
|
+
if PATTERN:
|
|
170
|
+
use_key = f"{PATTERN}/{use_key}"
|
|
171
|
+
USED_ARGS[use_key] = val
|
|
172
|
+
|
|
173
|
+
kwargs.update(cmd_kwargs)
|
|
174
|
+
cmd_args = []
|
|
175
|
+
for i, arg in enumerate(args):
|
|
176
|
+
key = parameters[i][0]
|
|
177
|
+
if key not in kwargs:
|
|
178
|
+
cmd_args.append(arg)
|
|
179
|
+
|
|
180
|
+
# Ensure dictionary order is in parameter order
|
|
181
|
+
kwargs = {k: kwargs[k] for k, _ in parameters if k in kwargs}
|
|
182
|
+
|
|
183
|
+
if "args.debug" not in ARGS:
|
|
184
|
+
ARGS["args.debug"] = False
|
|
185
|
+
if ARGS["args.debug"] or DEBUG:
|
|
186
|
+
if PATTERN:
|
|
187
|
+
scope = PATTERN
|
|
188
|
+
else:
|
|
189
|
+
scope = None
|
|
190
|
+
print(_format_func_debug(prefix, kwargs, scope))
|
|
191
|
+
return func(*cmd_args, **kwargs)
|
|
192
|
+
|
|
193
|
+
if is_class:
|
|
194
|
+
setattr(object_or_func, "__init__", cmd_func)
|
|
195
|
+
cmd_func = object_or_func
|
|
196
|
+
|
|
197
|
+
return cmd_func
|
|
198
|
+
|
|
199
|
+
if bound_fn_or_cls is None:
|
|
200
|
+
return decorator
|
|
201
|
+
else:
|
|
202
|
+
return decorator(bound_fn_or_cls)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class bind_module:
|
|
206
|
+
def __init__(self, module, *scopes, filter_fn=lambda fn: True, **kwargs):
|
|
207
|
+
"""Binds every function/class in a specified module. The output
|
|
208
|
+
class is a bound version of the original module, with the
|
|
209
|
+
attributes in the same place.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
module : ModuleType
|
|
214
|
+
Module or object whose attributes to bind.
|
|
215
|
+
scopes : List[str] or [fn or Object] + List[str], optional
|
|
216
|
+
List of patterns to bind the function under.
|
|
217
|
+
filter_fn : Callable, optional
|
|
218
|
+
A function that takes in the function that is to be bound, and
|
|
219
|
+
returns a boolean whether it should be bound.
|
|
220
|
+
Defaults to always True, no matter what the function is.
|
|
221
|
+
kwargs : keyword arguments, optional
|
|
222
|
+
Keyword arguments to the bind function.
|
|
223
|
+
|
|
224
|
+
"""
|
|
225
|
+
for fn_name in dir(module):
|
|
226
|
+
fn = getattr(module, fn_name)
|
|
227
|
+
if not isinstance(fn, type(sys)) and hasattr(fn, "__qualname__"):
|
|
228
|
+
if filter_fn(fn):
|
|
229
|
+
bound_fn = bind(fn, *scopes, **kwargs)
|
|
230
|
+
setattr(self, fn_name, bound_fn)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def get_used_args():
|
|
234
|
+
"""
|
|
235
|
+
Gets the args that have been used so far
|
|
236
|
+
by the script (e.g. their function they target
|
|
237
|
+
was actually called).
|
|
238
|
+
"""
|
|
239
|
+
return USED_ARGS
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def dump_args(args, output_path):
|
|
243
|
+
"""
|
|
244
|
+
Dumps the provided arguments to a
|
|
245
|
+
file.
|
|
246
|
+
"""
|
|
247
|
+
path = Path(output_path)
|
|
248
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
249
|
+
|
|
250
|
+
# Drop the dataclasses default_factory sentinel; it is not serializable.
|
|
251
|
+
filtered_args = {}
|
|
252
|
+
for key, value in args.items():
|
|
253
|
+
if value is not _DEFAULT_FACTORY_SENTINEL:
|
|
254
|
+
filtered_args[key] = value
|
|
255
|
+
|
|
256
|
+
with open(path, "w") as f:
|
|
257
|
+
yaml.Dumper.ignore_aliases = lambda *args: True
|
|
258
|
+
x = yaml.dump(filtered_args, Dumper=yaml.Dumper)
|
|
259
|
+
prev_line = None
|
|
260
|
+
output = []
|
|
261
|
+
for line in x.split("\n"):
|
|
262
|
+
cur_line = line.split(".")[0].strip()
|
|
263
|
+
if not cur_line.startswith("-"):
|
|
264
|
+
if cur_line != prev_line and prev_line:
|
|
265
|
+
line = f"\n{line}"
|
|
266
|
+
prev_line = line.split(".")[0].strip()
|
|
267
|
+
output.append(line)
|
|
268
|
+
f.write("\n".join(output))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def load_args(input_path_or_stream):
|
|
272
|
+
"""
|
|
273
|
+
Loads arguments from a given input path or file stream, if
|
|
274
|
+
the file is already open.
|
|
275
|
+
"""
|
|
276
|
+
if isinstance(input_path_or_stream, (str, Path)):
|
|
277
|
+
path = Path(input_path_or_stream)
|
|
278
|
+
# Resolve $include relative to THIS file's directory (not the process CWD), so a
|
|
279
|
+
# config and its include tree load identically regardless of where it's invoked
|
|
280
|
+
# from — including when installed read-only in site-packages.
|
|
281
|
+
base_dir = path.resolve().parent
|
|
282
|
+
with path.open("r") as f:
|
|
283
|
+
data = yaml.load(f, Loader=yaml.Loader)
|
|
284
|
+
else:
|
|
285
|
+
base_dir = None
|
|
286
|
+
data = yaml.load(input_path_or_stream, Loader=yaml.Loader)
|
|
287
|
+
|
|
288
|
+
if "$include" in data:
|
|
289
|
+
include_files = data.pop("$include")
|
|
290
|
+
include_args = {}
|
|
291
|
+
for include_file in include_files:
|
|
292
|
+
# Prefer file-relative resolution (CWD-independent). Fall back to the legacy
|
|
293
|
+
# CWD-relative path if the file-relative one doesn't exist, so configs that
|
|
294
|
+
# still write includes relative to the run directory keep working.
|
|
295
|
+
if base_dir is not None and not Path(include_file).is_absolute():
|
|
296
|
+
file_relative = base_dir / include_file
|
|
297
|
+
if file_relative.exists():
|
|
298
|
+
include_file = file_relative
|
|
299
|
+
include_args.update(load_args(include_file))
|
|
300
|
+
include_args.update(data)
|
|
301
|
+
data = include_args
|
|
302
|
+
|
|
303
|
+
_vars = os.environ.copy()
|
|
304
|
+
if "$vars" in data:
|
|
305
|
+
_vars.update(data.pop("$vars"))
|
|
306
|
+
|
|
307
|
+
for key, val in data.items():
|
|
308
|
+
# Check if string starts with $.
|
|
309
|
+
if isinstance(val, str):
|
|
310
|
+
if val.startswith("$"):
|
|
311
|
+
lookup = val[1:]
|
|
312
|
+
if lookup in _vars:
|
|
313
|
+
data[key] = _vars[lookup]
|
|
314
|
+
|
|
315
|
+
elif isinstance(val, list):
|
|
316
|
+
new_list = []
|
|
317
|
+
for subval in val:
|
|
318
|
+
if isinstance(subval, str) and subval.startswith("$"):
|
|
319
|
+
lookup = subval[1:]
|
|
320
|
+
if lookup in _vars:
|
|
321
|
+
new_list.append(_vars[lookup])
|
|
322
|
+
else:
|
|
323
|
+
new_list.append(subval)
|
|
324
|
+
else:
|
|
325
|
+
new_list.append(subval)
|
|
326
|
+
data[key] = new_list
|
|
327
|
+
|
|
328
|
+
if "args.debug" not in data:
|
|
329
|
+
data["args.debug"] = DEBUG
|
|
330
|
+
return data
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class str_to_list:
|
|
334
|
+
def __init__(self, _type):
|
|
335
|
+
self._type = _type
|
|
336
|
+
|
|
337
|
+
def __call__(self, values):
|
|
338
|
+
_values = values.split(" ")
|
|
339
|
+
_values = [self._type(v) for v in _values]
|
|
340
|
+
return _values
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class str_to_tuple:
|
|
344
|
+
def __init__(self, _type_list):
|
|
345
|
+
self._type_list = _type_list
|
|
346
|
+
|
|
347
|
+
def __call__(self, values):
|
|
348
|
+
_values = values.split(" ")
|
|
349
|
+
if len(self._type_list) == 2 and self._type_list[1] is Ellipsis:
|
|
350
|
+
# Variable-length tuple[X, ...]: every element has the same type
|
|
351
|
+
return tuple(self._type_list[0](v) for v in _values)
|
|
352
|
+
_values = [self._type_list[i](v) for i, v in enumerate(_values)]
|
|
353
|
+
return tuple(_values)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class str_to_dict:
|
|
357
|
+
def __init__(self):
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
def _guess_type(self, s):
|
|
361
|
+
try:
|
|
362
|
+
value = ast.literal_eval(s)
|
|
363
|
+
except ValueError:
|
|
364
|
+
return s
|
|
365
|
+
else:
|
|
366
|
+
return value
|
|
367
|
+
|
|
368
|
+
def __call__(self, values):
|
|
369
|
+
values = values.split(" ")
|
|
370
|
+
_values = {}
|
|
371
|
+
|
|
372
|
+
for elem in values:
|
|
373
|
+
key, val = elem.split("=", 1)
|
|
374
|
+
key = self._guess_type(key)
|
|
375
|
+
val = self._guess_type(val)
|
|
376
|
+
_values[key] = val
|
|
377
|
+
|
|
378
|
+
return _values
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class str_to_bool:
|
|
382
|
+
def __init__(self):
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
def __call__(self, value):
|
|
386
|
+
"""Convert string or int to bool.
|
|
387
|
+
|
|
388
|
+
Accepts: 0, 1, 'true', 'false', 'True', 'False'
|
|
389
|
+
"""
|
|
390
|
+
if isinstance(value, bool):
|
|
391
|
+
return value
|
|
392
|
+
if isinstance(value, int):
|
|
393
|
+
return bool(value)
|
|
394
|
+
if value.lower() in ("true", "1"):
|
|
395
|
+
return True
|
|
396
|
+
if value.lower() in ("false", "0"):
|
|
397
|
+
return False
|
|
398
|
+
raise ValueError(f"Cannot convert {value} to bool")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# PEP 604 unions (X | None) have origin types.UnionType rather than
|
|
402
|
+
# typing.Union on Python 3.11-3.13 (the two are unified in 3.14).
|
|
403
|
+
_UNION_ORIGINS = tuple({Union, types.UnionType})
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _unwrap_optional(arg_type):
|
|
407
|
+
"""Unwrap Optional[X] or X | None to X, return None if not Optional.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
arg_type: Type annotation to check
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
The inner type if arg_type is Optional[X] / X | None, otherwise None
|
|
414
|
+
"""
|
|
415
|
+
origin = get_origin(arg_type)
|
|
416
|
+
if origin in _UNION_ORIGINS:
|
|
417
|
+
args = get_args(arg_type)
|
|
418
|
+
# Check if this is Optional[X] (i.e., Union[X, None])
|
|
419
|
+
if len(args) == 2 and type(None) in args:
|
|
420
|
+
# Return the non-None type
|
|
421
|
+
return args[0] if args[1] is type(None) else args[1]
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _unwrap_literal(arg_type):
|
|
426
|
+
"""Unwrap Literal[v1, v2, ...] to its allowed values, return None if not Literal.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
arg_type: Type annotation to check
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Tuple of allowed values if arg_type is Literal[...], otherwise None
|
|
433
|
+
"""
|
|
434
|
+
if get_origin(arg_type) is Literal:
|
|
435
|
+
return get_args(arg_type)
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _cast_value(value, target_type):
|
|
440
|
+
"""Cast a value to the target type if needed.
|
|
441
|
+
|
|
442
|
+
This is used when loading values from YAML files to ensure they match
|
|
443
|
+
the expected type from function signatures.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
value: The value to cast
|
|
447
|
+
target_type: The target type annotation
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
The value cast to the target type, or the original value if already correct type
|
|
451
|
+
"""
|
|
452
|
+
# If target_type is not specified, return as-is
|
|
453
|
+
if target_type is inspect.Parameter.empty:
|
|
454
|
+
return value
|
|
455
|
+
|
|
456
|
+
# Unwrap Optional[X] to X if needed
|
|
457
|
+
unwrapped = _unwrap_optional(target_type)
|
|
458
|
+
if unwrapped is not None:
|
|
459
|
+
target_type = unwrapped
|
|
460
|
+
|
|
461
|
+
# Handle None values
|
|
462
|
+
if value is None:
|
|
463
|
+
return value
|
|
464
|
+
|
|
465
|
+
# Handle Literal[v1, v2, ...] — cast to the type of the first allowed value
|
|
466
|
+
literal_values = _unwrap_literal(target_type)
|
|
467
|
+
if literal_values is not None:
|
|
468
|
+
val_type = type(literal_values[0])
|
|
469
|
+
try:
|
|
470
|
+
value = val_type(value)
|
|
471
|
+
except (ValueError, TypeError):
|
|
472
|
+
pass
|
|
473
|
+
if value not in literal_values:
|
|
474
|
+
raise ValueError(
|
|
475
|
+
f"invalid value {value!r} - must be one of {list(literal_values)}"
|
|
476
|
+
)
|
|
477
|
+
return value
|
|
478
|
+
|
|
479
|
+
# Fast path: if the value is already the right type, return it. Parameterized
|
|
480
|
+
# generics like list[int] are not valid second arguments to isinstance(), but
|
|
481
|
+
# isinstance(generic, type) is False for them on Python 3.11+, so the guard
|
|
482
|
+
# skips them safely.
|
|
483
|
+
if isinstance(target_type, type) and isinstance(value, target_type):
|
|
484
|
+
return value
|
|
485
|
+
|
|
486
|
+
# Try to cast to the target type
|
|
487
|
+
try:
|
|
488
|
+
# For bool, use str_to_bool converter to handle string inputs correctly
|
|
489
|
+
if target_type is bool:
|
|
490
|
+
converter = str_to_bool()
|
|
491
|
+
return converter(value)
|
|
492
|
+
# For other basic types (int, float, str), use the type directly
|
|
493
|
+
elif target_type in (int, float, str):
|
|
494
|
+
return target_type(value)
|
|
495
|
+
# For other types (including generics), return as-is and let Python handle it
|
|
496
|
+
return value
|
|
497
|
+
except (ValueError, TypeError):
|
|
498
|
+
# If casting fails, return the original value
|
|
499
|
+
return value
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def build_parser(group: Union[list, str] = "default"):
|
|
503
|
+
"""Builds the argument parser from all the bound functions.
|
|
504
|
+
|
|
505
|
+
Returns
|
|
506
|
+
-------
|
|
507
|
+
ArgumentParser
|
|
508
|
+
Argument parser built by ArgBind.
|
|
509
|
+
"""
|
|
510
|
+
p = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter)
|
|
511
|
+
|
|
512
|
+
p.add_argument(
|
|
513
|
+
"--args.save",
|
|
514
|
+
type=str,
|
|
515
|
+
required=False,
|
|
516
|
+
help="Path to save all arguments used to run script to.",
|
|
517
|
+
)
|
|
518
|
+
p.add_argument(
|
|
519
|
+
"--args.load",
|
|
520
|
+
type=str,
|
|
521
|
+
required=False,
|
|
522
|
+
help="Path to load arguments from, stored as a .yml file.",
|
|
523
|
+
)
|
|
524
|
+
p.add_argument(
|
|
525
|
+
"--args.debug",
|
|
526
|
+
type=int,
|
|
527
|
+
required=False,
|
|
528
|
+
default=0,
|
|
529
|
+
help="Print arguments as they are passed to each function.",
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
if isinstance(group, str):
|
|
533
|
+
group = [group]
|
|
534
|
+
if "default" not in group:
|
|
535
|
+
group.append("default")
|
|
536
|
+
# Add kwargs from function to parser
|
|
537
|
+
for prefix in PARSE_FUNCS:
|
|
538
|
+
func, patterns, without_prefix, positional, fn_group = PARSE_FUNCS[prefix]
|
|
539
|
+
if not set(fn_group) & set(group):
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
# Resolve string annotations produced by ``from __future__ import
|
|
543
|
+
# annotations`` (PEP 563). ``eval_str=True`` evaluates them in the
|
|
544
|
+
# function's own namespace; if a name can't be resolved at runtime (e.g. a
|
|
545
|
+
# ``TYPE_CHECKING``-only import), fall back to the raw, possibly stringized
|
|
546
|
+
# annotations and handle the leftover strings per-parameter below.
|
|
547
|
+
try:
|
|
548
|
+
sig = inspect.signature(func, eval_str=True)
|
|
549
|
+
except Exception:
|
|
550
|
+
sig = inspect.signature(func)
|
|
551
|
+
|
|
552
|
+
docstring = docstring_parser.parse(func.__doc__)
|
|
553
|
+
parameter_help = docstring.params
|
|
554
|
+
parameter_help = {x.arg_name: x.description for x in parameter_help}
|
|
555
|
+
|
|
556
|
+
f = p.add_argument_group(
|
|
557
|
+
title=f"Generated arguments for function {prefix}",
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def _get_arg_names(key, is_kwarg):
|
|
561
|
+
arg_names = []
|
|
562
|
+
|
|
563
|
+
prepend = "--" if is_kwarg else ""
|
|
564
|
+
if without_prefix:
|
|
565
|
+
arg_name = prepend + f"PATTERN/{key}"
|
|
566
|
+
else:
|
|
567
|
+
arg_name = prepend + f"PATTERN/{prefix}.{key}"
|
|
568
|
+
|
|
569
|
+
arg_names.append(arg_name.replace("PATTERN/", ""))
|
|
570
|
+
|
|
571
|
+
if patterns is not None:
|
|
572
|
+
for p in patterns:
|
|
573
|
+
arg_names.append(arg_name.replace("PATTERN", p))
|
|
574
|
+
return arg_names
|
|
575
|
+
|
|
576
|
+
for key, val in sig.parameters.items():
|
|
577
|
+
arg_val = val.default
|
|
578
|
+
arg_type = val.annotation
|
|
579
|
+
is_kwarg = arg_val is not inspect.Parameter.empty
|
|
580
|
+
|
|
581
|
+
if arg_type is inspect.Parameter.empty and is_kwarg:
|
|
582
|
+
arg_type = type(arg_val)
|
|
583
|
+
elif isinstance(arg_type, str) and is_kwarg and arg_val is not None:
|
|
584
|
+
# An unresolved PEP 563 annotation (the eval_str fallback above
|
|
585
|
+
# kept it as a string): infer the type from the default value.
|
|
586
|
+
arg_type = type(arg_val)
|
|
587
|
+
|
|
588
|
+
if is_kwarg or positional:
|
|
589
|
+
arg_names = _get_arg_names(key, is_kwarg)
|
|
590
|
+
arg_help = {}
|
|
591
|
+
help_text = ""
|
|
592
|
+
if key in parameter_help:
|
|
593
|
+
# A documented parameter may have no description text (None).
|
|
594
|
+
help_text = textwrap.fill(
|
|
595
|
+
parameter_help[key] or "", width=HELP_WIDTH
|
|
596
|
+
)
|
|
597
|
+
arg_help[arg_names[0]] = help_text
|
|
598
|
+
if len(arg_names) > 1:
|
|
599
|
+
for pattern_arg_name in arg_names[1:]:
|
|
600
|
+
arg_help[pattern_arg_name] = argparse.SUPPRESS
|
|
601
|
+
|
|
602
|
+
for arg_name in arg_names:
|
|
603
|
+
inner_types = [str, int, float, bool]
|
|
604
|
+
|
|
605
|
+
# Unwrap Optional[X] / X | None to X
|
|
606
|
+
unwrapped_type = _unwrap_optional(arg_type)
|
|
607
|
+
is_optional = unwrapped_type is not None
|
|
608
|
+
effective_type = unwrapped_type if is_optional else arg_type
|
|
609
|
+
# Origin of generic aliases: list for list[X]/List[X],
|
|
610
|
+
# dict for dict[K, V]/Dict[K, V], etc. None for plain types.
|
|
611
|
+
origin = get_origin(effective_type)
|
|
612
|
+
|
|
613
|
+
if isinstance(effective_type, str):
|
|
614
|
+
# An unresolved PEP 563 annotation with no informative
|
|
615
|
+
# default to infer from: no CLI converter can be built, so
|
|
616
|
+
# leave it YAML-configurable only rather than crashing
|
|
617
|
+
# build_parser (matches the unsupported-union behavior).
|
|
618
|
+
continue
|
|
619
|
+
|
|
620
|
+
if effective_type is bool:
|
|
621
|
+
# For bool with a default, support both flag and value syntax:
|
|
622
|
+
# --Example.on -> True (uses const)
|
|
623
|
+
# --Example.on=1 -> True (uses type converter)
|
|
624
|
+
# --Example.on=0 -> False (uses type converter)
|
|
625
|
+
# (nothing) -> default value
|
|
626
|
+
if is_optional or arg_val is not inspect.Parameter.empty:
|
|
627
|
+
f.add_argument(
|
|
628
|
+
arg_name,
|
|
629
|
+
type=str_to_bool(),
|
|
630
|
+
nargs="?",
|
|
631
|
+
const=True,
|
|
632
|
+
default=arg_val,
|
|
633
|
+
help=arg_help[arg_name],
|
|
634
|
+
)
|
|
635
|
+
else:
|
|
636
|
+
# For bool without a default, use store_true action
|
|
637
|
+
f.add_argument(
|
|
638
|
+
arg_name, action="store_true", help=arg_help[arg_name]
|
|
639
|
+
)
|
|
640
|
+
elif effective_type is list or origin is list:
|
|
641
|
+
# Covers list, List, list[X], and List[X]. Bare
|
|
642
|
+
# list defaults to str elements.
|
|
643
|
+
type_args = get_args(effective_type)
|
|
644
|
+
_type = type_args[0] if type_args else str
|
|
645
|
+
if _type in inner_types:
|
|
646
|
+
f.add_argument(
|
|
647
|
+
arg_name,
|
|
648
|
+
type=str_to_list(_type),
|
|
649
|
+
default=arg_val,
|
|
650
|
+
help=arg_help[arg_name],
|
|
651
|
+
)
|
|
652
|
+
# Lists of other element types cannot be parsed from
|
|
653
|
+
# the command line; they stay configurable via YAML.
|
|
654
|
+
elif effective_type is dict or origin is dict:
|
|
655
|
+
# Covers dict, Dict, dict[K, V], and Dict[K, V].
|
|
656
|
+
# Value types are guessed with ast.literal_eval.
|
|
657
|
+
f.add_argument(
|
|
658
|
+
arg_name,
|
|
659
|
+
type=str_to_dict(),
|
|
660
|
+
default=arg_val,
|
|
661
|
+
help=arg_help[arg_name],
|
|
662
|
+
)
|
|
663
|
+
elif _unwrap_literal(effective_type) is not None:
|
|
664
|
+
literal_values = _unwrap_literal(effective_type)
|
|
665
|
+
val_type = type(literal_values[0])
|
|
666
|
+
f.add_argument(
|
|
667
|
+
arg_name,
|
|
668
|
+
type=val_type,
|
|
669
|
+
choices=literal_values,
|
|
670
|
+
default=arg_val,
|
|
671
|
+
help=arg_help[arg_name],
|
|
672
|
+
)
|
|
673
|
+
elif origin is tuple:
|
|
674
|
+
_type_list = get_args(effective_type)
|
|
675
|
+
f.add_argument(
|
|
676
|
+
arg_name,
|
|
677
|
+
type=str_to_tuple(_type_list),
|
|
678
|
+
default=arg_val,
|
|
679
|
+
help=arg_help[arg_name],
|
|
680
|
+
)
|
|
681
|
+
elif origin in _UNION_ORIGINS:
|
|
682
|
+
# Union of several real types, e.g.
|
|
683
|
+
# str | os.PathLike | None. A command-line value is
|
|
684
|
+
# already a str, so if str is a member, pass it
|
|
685
|
+
# through unchanged. Unions without str cannot be
|
|
686
|
+
# parsed from the command line and stay configurable
|
|
687
|
+
# via YAML.
|
|
688
|
+
if str in get_args(effective_type):
|
|
689
|
+
f.add_argument(
|
|
690
|
+
arg_name,
|
|
691
|
+
type=str,
|
|
692
|
+
default=arg_val,
|
|
693
|
+
help=arg_help[arg_name],
|
|
694
|
+
)
|
|
695
|
+
elif origin is not None:
|
|
696
|
+
# Other generics (Mapping[K, V], custom generics,
|
|
697
|
+
# ...) cannot be parsed from the command line; they
|
|
698
|
+
# stay configurable via YAML.
|
|
699
|
+
pass
|
|
700
|
+
elif callable(effective_type):
|
|
701
|
+
# A plain, callable type (int, float, str, or a custom
|
|
702
|
+
# converter): argparse calls it on the raw string value.
|
|
703
|
+
f.add_argument(
|
|
704
|
+
arg_name,
|
|
705
|
+
type=effective_type,
|
|
706
|
+
default=arg_val,
|
|
707
|
+
help=arg_help[arg_name],
|
|
708
|
+
)
|
|
709
|
+
else:
|
|
710
|
+
# Not a recognized generic and not callable: the
|
|
711
|
+
# annotation is not a usable type (e.g. `x: None` or a
|
|
712
|
+
# non-type value), so no argument can be built for it.
|
|
713
|
+
raise RuntimeError(
|
|
714
|
+
f"argbind cannot bind {prefix}.{key}: its annotation "
|
|
715
|
+
f"{effective_type!r} is not a callable type or a "
|
|
716
|
+
f"supported generic."
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
desc = docstring.short_description
|
|
720
|
+
if desc is None:
|
|
721
|
+
desc = ""
|
|
722
|
+
|
|
723
|
+
if patterns:
|
|
724
|
+
# Use the last parameter as the example; fall back to a placeholder
|
|
725
|
+
# for a parameter-less function (sig.parameters is empty).
|
|
726
|
+
param_keys = list(sig.parameters)
|
|
727
|
+
example_key = param_keys[-1] if param_keys else "arg"
|
|
728
|
+
if not without_prefix:
|
|
729
|
+
scope_pattern = f"--{patterns[0]}/{prefix}.{example_key}"
|
|
730
|
+
else:
|
|
731
|
+
scope_pattern = f"--{patterns[0]}/{example_key}"
|
|
732
|
+
|
|
733
|
+
desc += (
|
|
734
|
+
f" Additional scope patterns: {', '.join(list(patterns))}. "
|
|
735
|
+
"Use these by prefacing any of the args below with one "
|
|
736
|
+
"of these patterns. For example: "
|
|
737
|
+
f"{scope_pattern} VALUE."
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
desc = textwrap.fill(desc, width=HELP_WIDTH)
|
|
741
|
+
f.description = desc
|
|
742
|
+
|
|
743
|
+
return p
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def parse_args(p=None, group: Union[list, str] = "default"):
|
|
747
|
+
"""
|
|
748
|
+
Parses the command line and returns a dictionary.
|
|
749
|
+
Builds the argument parser if p is None.
|
|
750
|
+
"""
|
|
751
|
+
p = build_parser(group=group) if p is None else p
|
|
752
|
+
used_args = [
|
|
753
|
+
x.replace("--", "").split("=")[0] for x in sys.argv if x.startswith("--")
|
|
754
|
+
]
|
|
755
|
+
used_args.extend(["args.save", "args.load"])
|
|
756
|
+
|
|
757
|
+
known, unknown = p.parse_known_args()
|
|
758
|
+
args = vars(known)
|
|
759
|
+
args["args.unknown"] = unknown
|
|
760
|
+
load_args_path = args.pop("args.load")
|
|
761
|
+
save_args_path = args.pop("args.save")
|
|
762
|
+
debug_args = args.pop("args.debug")
|
|
763
|
+
|
|
764
|
+
pattern_keys = [key for key in args if "/" in key]
|
|
765
|
+
top_level_args = [key for key in args if "/" not in key]
|
|
766
|
+
|
|
767
|
+
# If the top-level arguments were altered but the scoped ones were not,
|
|
768
|
+
# change the scoped ones to match the top-level (inherit from top-level).
|
|
769
|
+
args |= {
|
|
770
|
+
key: args[key.split("/")[1]] for key in pattern_keys if key not in used_args
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if load_args_path:
|
|
774
|
+
loaded_args = load_args(load_args_path)
|
|
775
|
+
# Overwrite defaults with loaded arguments, except those from the command line.
|
|
776
|
+
args |= {k: v for k, v in loaded_args.items() if k not in used_args}
|
|
777
|
+
for key in pattern_keys:
|
|
778
|
+
pattern, arg_name = key.split("/")
|
|
779
|
+
if key not in loaded_args and key not in used_args:
|
|
780
|
+
if arg_name in loaded_args:
|
|
781
|
+
args[key] = args[arg_name]
|
|
782
|
+
|
|
783
|
+
for key in top_level_args:
|
|
784
|
+
if key in used_args:
|
|
785
|
+
for pattern_key in pattern_keys:
|
|
786
|
+
pattern, arg_name = pattern_key.split("/")
|
|
787
|
+
if key == arg_name and pattern_key not in used_args:
|
|
788
|
+
args[pattern_key] = args[key]
|
|
789
|
+
|
|
790
|
+
if save_args_path:
|
|
791
|
+
dump_args(args, save_args_path)
|
|
792
|
+
|
|
793
|
+
# Put them back in case the script wants to use them
|
|
794
|
+
args["args.load"] = load_args_path
|
|
795
|
+
args["args.save"] = save_args_path
|
|
796
|
+
args["args.debug"] = debug_args
|
|
797
|
+
|
|
798
|
+
return args
|
argbind/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: argbind-dbraun
|
|
3
|
+
Version: 0.5.2
|
|
4
|
+
Summary: Simple way to bind function arguments to the command line. An extended fork of pseeth/argbind with new features (imported as `argbind`).
|
|
5
|
+
Author-email: Prem Seetharaman <prem@descript.com>
|
|
6
|
+
Maintainer-email: David Braun <braun@ccrma.stanford.edu>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/DBraun/argbind/
|
|
9
|
+
Project-URL: Repository, https://github.com/DBraun/argbind/
|
|
10
|
+
Project-URL: Original project, https://github.com/pseeth/argbind/
|
|
11
|
+
Keywords: command-line,configuration,yaml,argument,parsing
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Operating System :: MacOS
|
|
20
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE.md
|
|
24
|
+
Requires-Dist: pyyaml
|
|
25
|
+
Requires-Dist: docstring-parser
|
|
26
|
+
Provides-Extra: tests
|
|
27
|
+
Requires-Dist: pytest; extra == "tests"
|
|
28
|
+
Requires-Dist: pytest-cov; extra == "tests"
|
|
29
|
+
Provides-Extra: lint
|
|
30
|
+
Requires-Dist: ruff; extra == "lint"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# ArgBind
|
|
34
|
+
|
|
35
|
+
**Build CLIs via docstrings and type annotations, with YAML support.**
|
|
36
|
+
|
|
37
|
+
[](https://github.com/DBraun/argbind/actions/workflows/tests.yml)
|
|
38
|
+
[](https://pypi.org/project/argbind-dbraun/)
|
|
39
|
+
[](https://pypi.org/project/argbind-dbraun/)
|
|
40
|
+
[](https://pepy.tech/project/argbind-dbraun)
|
|
41
|
+
|
|
42
|
+
> **Note:** This is an extended fork of [pseeth/argbind](https://github.com/pseeth/argbind) that
|
|
43
|
+
> adds new features — modern type annotations (PEP 585/604), `Literal` and flexible boolean
|
|
44
|
+
> handling, dataclass `default_factory`, and YAML `$include` — published on PyPI as
|
|
45
|
+
> [`argbind-dbraun`](https://pypi.org/project/argbind-dbraun/). The import name is unchanged
|
|
46
|
+
> (`import argbind`), so it remains a drop-in replacement.
|
|
47
|
+
|
|
48
|
+
*ArgBind is a simple way to bind function or class arguments to the command line or to .yml files!*
|
|
49
|
+
It supports scoping of arguments, similar to other frameworks like
|
|
50
|
+
[Hydra](https://github.com/facebookresearch/hydra) and
|
|
51
|
+
[gin-config](https://github.com/google/gin-config).
|
|
52
|
+
ArgBind is *very* small (only ~800 lines of code, in one file), can be used to make complex and well-documented command line programs, and allows
|
|
53
|
+
you to configure program execution from .yml files.
|
|
54
|
+
|
|
55
|
+
If you're migrating from an ArgParse script to an ArgBind script, check out the
|
|
56
|
+
[migration guide](./examples/migration). Scroll down to see some [examples](#examples). Please also look at the
|
|
57
|
+
current known [limitations](#limitations-and-known-issues) of ArgBind.
|
|
58
|
+
|
|
59
|
+
## Why ArgBind?
|
|
60
|
+
|
|
61
|
+
ArgBind was written by [Prem Seetharaman](https://github.com/pseeth) to help configure machine
|
|
62
|
+
learning experiments. ML experiment configuration is often highly nested, and can get out of hand
|
|
63
|
+
quickly. Rather than switching workflows around too much to accommodate a new framework, the goal
|
|
64
|
+
was to make already-written scripts easily adaptable, to achieve a few things:
|
|
65
|
+
|
|
66
|
+
1. Configure scripts using `.yml` files. Be able to save `.yml` files that can be used to rerun scripts the exact same way twice.
|
|
67
|
+
2. Spend time writing actual functions needed to run experiments, not argument parsers.
|
|
68
|
+
3. Be able to run experiment code from other Python scripts, notebooks, or the command line.
|
|
69
|
+
4. Be able to specify arguments from the command line directly to various functions.
|
|
70
|
+
5. Be able to use scoping patterns, so a function can run inside a `train` scope and `test` scope, with different results (e.g., for getting a train dataset and a test dataset).
|
|
71
|
+
|
|
72
|
+
Nothing out there really fit the bill, so Prem wrote ArgBind. If you have
|
|
73
|
+
an `argparse` based script, converting it to ArgBind should be very quick! ArgBind is simple,
|
|
74
|
+
small, and easy to use. To get a feel for how it works, check out [usage](#usage), [design](#design), and [examples](#examples)!
|
|
75
|
+
|
|
76
|
+
## Installation
|
|
77
|
+
|
|
78
|
+
Install via `pip`:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
python -m pip install argbind-dbraun
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Or from source:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
git clone https://github.com/DBraun/argbind.git
|
|
88
|
+
cd argbind
|
|
89
|
+
python -m pip install -e .
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
This project uses [uv](https://docs.astral.sh/uv/). To create a dev environment with the
|
|
93
|
+
test dependencies and run the suite:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
uv sync --extra tests
|
|
97
|
+
uv run pytest
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Install the [pre-commit](https://pre-commit.com/) hooks (ruff format + lint) so they run
|
|
101
|
+
on every commit:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
uvx pre-commit install
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Examples
|
|
108
|
+
|
|
109
|
+
- [Example 1: Hello World](./examples/hello_world/)
|
|
110
|
+
- [Example 2: Scope patterns](./examples/scoping/)
|
|
111
|
+
- [Example 3: Typing](./examples/typing/)
|
|
112
|
+
- [Example 4: Modern type annotations (PEP 585/604)](./examples/modern_typing)
|
|
113
|
+
- [Example 5: Literal arguments](./examples/literal)
|
|
114
|
+
- [Example 6: Flexible boolean syntax](./examples/booleans)
|
|
115
|
+
- [Example 7: Using default_factory with dataclasses](./examples/default_factory)
|
|
116
|
+
- [Example 8: Loading, saving, and using .yml files](./examples/yaml)
|
|
117
|
+
- [Example 9: Nested .yml files with `$include`](./examples/nested_yaml)
|
|
118
|
+
- [Example 10: Multi-stage programs](./examples/multistage)
|
|
119
|
+
- [Example 11: Mimic more traditional CLI, without `func.arg` notation](./examples/without_prefix)
|
|
120
|
+
- [Example 12: Debug mode](./examples/debug)
|
|
121
|
+
- [Example 13: Migrating from ArgParse](./examples/migration)
|
|
122
|
+
- [Example 14: Binding existing functions and classes](./examples/bind_existing)
|
|
123
|
+
- [Example 15: Binding functions to specific groups](./examples/groups)
|
|
124
|
+
|
|
125
|
+
## Usage
|
|
126
|
+
|
|
127
|
+
There are six main functions.
|
|
128
|
+
|
|
129
|
+
- `bind`: Binds keyword arguments (and positional arguments if `positional=True`) of a function or class to ArgBind.
|
|
130
|
+
- `parse_args`: Actually parses command line arguments into a dictionary.
|
|
131
|
+
- `scope`: Context manager that scopes a dictionary containing function arguments to be used by the functions.
|
|
132
|
+
- `dump_args`: Dumps the args dictionary to a `.yml` file. Used internally when program is called with `--args.save path/to/save.yml`.
|
|
133
|
+
- `load_args`: Loads args from a `.yml` file. Used internally when program is called with `--args.load path/to/load.yml`.
|
|
134
|
+
- `get_used_args`: Gets arguments that have actually been used by call functions up to this point.
|
|
135
|
+
|
|
136
|
+
Your code with ArgBind generally follows this pattern:
|
|
137
|
+
|
|
138
|
+
1. Write a function with a good docstring, and typed arguments. If arguments are not typed, their type will be inferred from the type of the default.
|
|
139
|
+
2. Bind it via `bind`.
|
|
140
|
+
3. When program is called, parse the arguments via `parse_args`.
|
|
141
|
+
4. Scope the arguments, and call the bound function within the context block.
|
|
142
|
+
5. Optionally call program with `--args.save` to save the current execution configuration to a `.yml` file or `--args.load` to load arguments from a prior saved execution configuration to run it the same way twice.
|
|
143
|
+
6. Optionally, run your script with `--args.debug=1` to see exactly how every bound function is called.
|
|
144
|
+
|
|
145
|
+
In your program, you can call `get_used_args` to see which arguments were actually used. Here's a minimal example:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
import argbind
|
|
149
|
+
|
|
150
|
+
@argbind.bind()
|
|
151
|
+
def hello(
|
|
152
|
+
name : str = 'world'
|
|
153
|
+
):
|
|
154
|
+
"""Say hello to someone.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
name : str, optional
|
|
159
|
+
Who you're saying hello to, by default 'world'
|
|
160
|
+
"""
|
|
161
|
+
print("Hello " + name)
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
# Arguments for CLI automatically generated from bound functions under the pattern
|
|
165
|
+
# function_name.function_arg.
|
|
166
|
+
args = argbind.parse_args()
|
|
167
|
+
# When called within a scope, the keyword arguments map to those from CLI or
|
|
168
|
+
# from defaults.
|
|
169
|
+
with argbind.scope(args):
|
|
170
|
+
hello()
|
|
171
|
+
# get_used_args() returns the arguments that were actually used by the bound
|
|
172
|
+
# functions that ran -- here, {'hello.name': 'world'}.
|
|
173
|
+
print(argbind.get_used_args())
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Help text is automatically generated from the docstring:
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
❯ python examples/hello_world/with_argbind.py -h
|
|
180
|
+
usage: with_argbind.py [-h] [--args.save ARGS.SAVE] [--args.load ARGS.LOAD] [--args.debug ARGS.DEBUG] [--hello.name HELLO.NAME]
|
|
181
|
+
|
|
182
|
+
optional arguments:
|
|
183
|
+
-h, --help show this help message and exit
|
|
184
|
+
--args.save ARGS.SAVE
|
|
185
|
+
Path to save all arguments used to run script to.
|
|
186
|
+
--args.load ARGS.LOAD
|
|
187
|
+
Path to load arguments from, stored as a .yml file.
|
|
188
|
+
--args.debug ARGS.DEBUG
|
|
189
|
+
Print arguments as they are passed to each function.
|
|
190
|
+
|
|
191
|
+
Generated arguments for function hello:
|
|
192
|
+
Say hello to someone.
|
|
193
|
+
|
|
194
|
+
--hello.name HELLO.NAME
|
|
195
|
+
Who you're saying hello to, by default 'world'
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Execution of this could look like:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
# Default arguments
|
|
202
|
+
❯ python examples/hello_world/with_argbind.py
|
|
203
|
+
Hello world
|
|
204
|
+
# Binding name from the command line and saving the args.
|
|
205
|
+
❯ python examples/hello_world/with_argbind.py --hello.name=you --args.save=/tmp/args.yml
|
|
206
|
+
Hello you
|
|
207
|
+
# Loading saved arguments.
|
|
208
|
+
❯ python examples/hello_world/with_argbind.py --args.load=/tmp/args.yml
|
|
209
|
+
Hello you
|
|
210
|
+
# Loading saved arguments, and overriding via command line.
|
|
211
|
+
❯ python examples/hello_world/with_argbind.py --args.load=/tmp/args.yml --hello.name=me
|
|
212
|
+
Hello me
|
|
213
|
+
# See how each function is called with args.debug=1.
|
|
214
|
+
❯ python examples/hello_world/with_argbind.py --args.load=/tmp/args.yml --args.debug=1
|
|
215
|
+
hello(
|
|
216
|
+
name : str = you
|
|
217
|
+
)
|
|
218
|
+
Hello you
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
You can also run the `hello` function from another Python script or a Jupyter notebook:
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
import argbind
|
|
225
|
+
# Import the bound function
|
|
226
|
+
from .hello_world import hello
|
|
227
|
+
# Load the args
|
|
228
|
+
args = argbind.load_args('/tmp/args.yml')
|
|
229
|
+
# Scope the args
|
|
230
|
+
with argbind.scope(args):
|
|
231
|
+
# Run the bound function
|
|
232
|
+
hello() # Prints 'Hello you'.
|
|
233
|
+
hello() # Prints 'Hello world', as it's outside scope.
|
|
234
|
+
# Can edit the args before scoping again.
|
|
235
|
+
args['hello.name'] = 'me'
|
|
236
|
+
with argbind.scope(args):
|
|
237
|
+
hello() # Prints 'Hello me'.
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
You'll notice that ArgBind forces you to document and type your
|
|
241
|
+
function arguments, which is always a good idea!
|
|
242
|
+
Please check out the [examples](#examples) for more details!
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
## Design
|
|
246
|
+
|
|
247
|
+
ArgBind is designed around a decorator that can be used on
|
|
248
|
+
functions the user wants to expose to command line or to a .yml file.
|
|
249
|
+
The arguments to that function are
|
|
250
|
+
then bound to a dictionary. When the function is called,
|
|
251
|
+
each argument is looked up in the dictionary and its
|
|
252
|
+
value is replaced with the corresponding value in the dictionary. The
|
|
253
|
+
dictionary that the function looks for values in is controlled by
|
|
254
|
+
`scope`:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
import argbind
|
|
258
|
+
|
|
259
|
+
@argbind.bind()
|
|
260
|
+
def func(arg : str = 'default'):
|
|
261
|
+
print(arg)
|
|
262
|
+
|
|
263
|
+
dict1 = {
|
|
264
|
+
'func.arg': 1,
|
|
265
|
+
}
|
|
266
|
+
dict2 = {
|
|
267
|
+
'func.arg': 2
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
with argbind.scope(dict1):
|
|
271
|
+
func() # prints 1
|
|
272
|
+
with argbind.scope(dict2):
|
|
273
|
+
func() # prints 2
|
|
274
|
+
func(arg=3) # prints 3.
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The function arguments are bound to the command line. Continuing the
|
|
278
|
+
simple program from above:
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
args = argbind.parse_args()
|
|
283
|
+
with argbind.scope(args):
|
|
284
|
+
func()
|
|
285
|
+
with argbind.scope(args):
|
|
286
|
+
func(arg=3)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
You can call this function like so:
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
❯ python examples/readme_example.py --func.arg 5
|
|
293
|
+
1 # Looks up `arg` in dict1
|
|
294
|
+
2 # Looks up `arg` in dict2
|
|
295
|
+
3 # arg is passed in on python call `func(arg=3)`
|
|
296
|
+
5 # Looks up `arg` from command line call `--func.arg 5`
|
|
297
|
+
3 # arg is passed in from two places: `func(arg=3)` and `--func.arg 5`. Former overrides the latter.
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The logic here is that arguments that are bound that are closer to the actual function call get priority. From highest priority, to lowest, it goes:
|
|
301
|
+
|
|
302
|
+
1. Bound explicitly in Python code
|
|
303
|
+
2. Bound via command line
|
|
304
|
+
3. Bound via .yml file
|
|
305
|
+
4. Bound via default for kwarg
|
|
306
|
+
|
|
307
|
+
You can also use `bind` directly on classes - see [here](./examples/bind_existing).
|
|
308
|
+
|
|
309
|
+
# Limitations and known issues
|
|
310
|
+
|
|
311
|
+
There are some limitations to ArgBind, some due to how Python function decorators work,
|
|
312
|
+
and others out of a desire to keep ArgBind's code simple and straightforward.
|
|
313
|
+
|
|
314
|
+
## Bound function names should be unique
|
|
315
|
+
|
|
316
|
+
Functions that are bound must be unique, even if they are in different files. The
|
|
317
|
+
function name is resolved in the argument parser only using the immediate name, not
|
|
318
|
+
a path to the function etc.
|
|
319
|
+
|
|
320
|
+
## Supported docstring formats
|
|
321
|
+
|
|
322
|
+
ArgBind uses [docstring-parser](https://github.com/rr-/docstring_parser), and so
|
|
323
|
+
the only supported styles are: ReST, Google, and Numpydoc-style docstrings.
|
|
324
|
+
|
|
325
|
+
## Not all types are supported
|
|
326
|
+
|
|
327
|
+
ArgBind supports most types that might pop up in your script, but not all. The
|
|
328
|
+
supported types can be seen in the [typing](./examples/typing/) and
|
|
329
|
+
[modern annotations](./examples/modern_typing/) examples.
|
|
330
|
+
|
|
331
|
+
## Positional arguments should not be saved into .yml files
|
|
332
|
+
|
|
333
|
+
If a positional argument is saved into a .yml file and loaded via `--args.load`,
|
|
334
|
+
then any positional argument passed in the command line will be overridden. Take
|
|
335
|
+
care not to pass positional arguments via `.yml` files.
|
|
336
|
+
|
|
337
|
+
# Issues? Questions?
|
|
338
|
+
|
|
339
|
+
If you've run into some issues with ArgBind, or have some questions, please ask
|
|
340
|
+
via GitHub Issues. Projects like ArgBind are pretty tricky to get right, so there
|
|
341
|
+
may be some edge cases that have been missed.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
argbind/__init__.py,sha256=sBNHxroc2miAKC_di7g3C3wfHnmNRVwMIb_ozxI0WLo,298
|
|
2
|
+
argbind/argbind.py,sha256=n5pqzuj-ruB3jP72sOxcVlUjE7WLv3Z1HhyWly9EFvE,29570
|
|
3
|
+
argbind/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
argbind_dbraun-0.5.2.dist-info/licenses/LICENSE.md,sha256=hbhfuwy5BW5kON9X_wC23iw1qZxT96p2XKwtdh_LWz4,1129
|
|
5
|
+
argbind_dbraun-0.5.2.dist-info/METADATA,sha256=FzDoei0r2qHEzR_rFnJbE0-XpPnQIhocCJ1TbMIIwqo,13383
|
|
6
|
+
argbind_dbraun-0.5.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
argbind_dbraun-0.5.2.dist-info/top_level.txt,sha256=4I81NScLijEMVYA3LWvX4q2kAh618wG84JBLJLXYQwc,8
|
|
8
|
+
argbind_dbraun-0.5.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020, Prem Seetharaman
|
|
4
|
+
Copyright (c) 2024-2026, David Braun (fork maintainer)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
argbind
|