dataclass-args 1.0.0__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.
- dataclass_args/__init__.py +102 -0
- dataclass_args/annotations.py +522 -0
- dataclass_args/builder.py +672 -0
- dataclass_args/exceptions.py +21 -0
- dataclass_args/file_loading.py +113 -0
- dataclass_args/utils.py +148 -0
- dataclass_args-1.0.0.dist-info/METADATA +931 -0
- dataclass_args-1.0.0.dist-info/RECORD +11 -0
- dataclass_args-1.0.0.dist-info/WHEEL +5 -0
- dataclass_args-1.0.0.dist-info/licenses/LICENSE +21 -0
- dataclass_args-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic configuration builder for dataclass types from CLI arguments.
|
|
3
|
+
|
|
4
|
+
Provides type-aware parsing of command-line arguments and merging
|
|
5
|
+
with optional base configuration files for any dataclass.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import MISSING, fields, is_dataclass
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Type, Union
|
|
13
|
+
|
|
14
|
+
# Import typing utilities with Python 3.8+ compatibility
|
|
15
|
+
try:
|
|
16
|
+
from typing import ( # type: ignore[attr-defined,no-redef]
|
|
17
|
+
get_args,
|
|
18
|
+
get_origin,
|
|
19
|
+
get_type_hints,
|
|
20
|
+
)
|
|
21
|
+
except ImportError:
|
|
22
|
+
from typing_extensions import get_args, get_origin, get_type_hints # type: ignore[assignment,no-redef]
|
|
23
|
+
|
|
24
|
+
from .annotations import (
|
|
25
|
+
get_cli_choices,
|
|
26
|
+
get_cli_help,
|
|
27
|
+
get_cli_positional_metavar,
|
|
28
|
+
get_cli_positional_nargs,
|
|
29
|
+
get_cli_short,
|
|
30
|
+
is_cli_excluded,
|
|
31
|
+
is_cli_positional,
|
|
32
|
+
)
|
|
33
|
+
from .exceptions import ConfigBuilderError, ConfigurationError
|
|
34
|
+
from .file_loading import process_file_loadable_value
|
|
35
|
+
from .utils import load_structured_file
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GenericConfigBuilder:
|
|
39
|
+
"""
|
|
40
|
+
Builds dataclass instances from CLI arguments and optional base config file.
|
|
41
|
+
|
|
42
|
+
Supports any dataclass type with:
|
|
43
|
+
- Optional base config file loading
|
|
44
|
+
- Type-aware CLI argument parsing
|
|
45
|
+
- List parameter accumulation
|
|
46
|
+
- Object parameter file loading with property overrides
|
|
47
|
+
- File-loadable string parameters via '@' prefix
|
|
48
|
+
- Hierarchical merging of configuration sources
|
|
49
|
+
- Field filtering via annotations or custom filters
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
config_class: Type,
|
|
55
|
+
exclude_fields: Optional[Set[str]] = None,
|
|
56
|
+
include_fields: Optional[Set[str]] = None,
|
|
57
|
+
field_filter: Optional[Callable[[str, Dict[str, Any]], bool]] = None,
|
|
58
|
+
use_annotations: bool = True,
|
|
59
|
+
):
|
|
60
|
+
"""
|
|
61
|
+
Initialize builder for a specific dataclass type.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
config_class: Dataclass type to build configurations for
|
|
65
|
+
exclude_fields: Set of field names to exclude from CLI
|
|
66
|
+
include_fields: Set of field names to include in CLI (exclusive with exclude_fields)
|
|
67
|
+
field_filter: Custom function to determine field inclusion
|
|
68
|
+
use_annotations: Whether to respect cli_exclude() annotations (default: True)
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
ConfigBuilderError: If config_class is not a dataclass or conflicting filters provided
|
|
72
|
+
"""
|
|
73
|
+
if not is_dataclass(config_class):
|
|
74
|
+
raise ConfigBuilderError(
|
|
75
|
+
f"config_class must be a dataclass, got {config_class}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if exclude_fields and include_fields:
|
|
79
|
+
raise ConfigBuilderError(
|
|
80
|
+
"Cannot specify both exclude_fields and include_fields"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
self.config_class = config_class
|
|
84
|
+
self.exclude_fields = exclude_fields or set()
|
|
85
|
+
self.include_fields = include_fields
|
|
86
|
+
self.field_filter = field_filter
|
|
87
|
+
self.use_annotations = use_annotations
|
|
88
|
+
self._config_fields = self._analyze_config_fields()
|
|
89
|
+
|
|
90
|
+
def _should_include_field(
|
|
91
|
+
self, field_name: str, field_info: Dict[str, Any]
|
|
92
|
+
) -> bool:
|
|
93
|
+
"""Determine if a field should be included in CLI arguments."""
|
|
94
|
+
|
|
95
|
+
# Apply annotation filter first if enabled
|
|
96
|
+
if self.use_annotations and is_cli_excluded(field_info):
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
# Apply custom filter if provided
|
|
100
|
+
if self.field_filter:
|
|
101
|
+
return self.field_filter(field_name, field_info)
|
|
102
|
+
|
|
103
|
+
# Apply include_fields filter (exclusive)
|
|
104
|
+
if self.include_fields is not None:
|
|
105
|
+
return field_name in self.include_fields
|
|
106
|
+
|
|
107
|
+
# Apply exclude_fields filter
|
|
108
|
+
if field_name in self.exclude_fields:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
# Default: include all fields
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
def _analyze_config_fields(self) -> Dict[str, Dict[str, Any]]:
|
|
115
|
+
"""Analyze dataclass fields for type information."""
|
|
116
|
+
fields_info = {}
|
|
117
|
+
type_hints = get_type_hints(self.config_class)
|
|
118
|
+
|
|
119
|
+
for field_obj in fields(self.config_class):
|
|
120
|
+
field_type = type_hints.get(field_obj.name, field_obj.type)
|
|
121
|
+
origin = get_origin(field_type)
|
|
122
|
+
args = get_args(field_type)
|
|
123
|
+
|
|
124
|
+
# Determine field category
|
|
125
|
+
is_optional = origin is Union and type(None) in args
|
|
126
|
+
if is_optional:
|
|
127
|
+
# Extract the non-None type from Optional[T]
|
|
128
|
+
field_type = next(arg for arg in args if arg is not type(None))
|
|
129
|
+
origin = get_origin(field_type)
|
|
130
|
+
args = get_args(field_type)
|
|
131
|
+
|
|
132
|
+
is_list = origin is list
|
|
133
|
+
is_dict = origin is dict
|
|
134
|
+
|
|
135
|
+
# Extract default value or factory
|
|
136
|
+
has_default = field_obj.default is not MISSING
|
|
137
|
+
has_default_factory = field_obj.default_factory is not MISSING
|
|
138
|
+
default_value = None
|
|
139
|
+
if has_default:
|
|
140
|
+
default_value = field_obj.default
|
|
141
|
+
elif has_default_factory and callable(field_obj.default_factory):
|
|
142
|
+
# Call factory to get default value
|
|
143
|
+
default_value = field_obj.default_factory()
|
|
144
|
+
|
|
145
|
+
field_info = {
|
|
146
|
+
"type": field_type,
|
|
147
|
+
"origin": origin,
|
|
148
|
+
"args": args,
|
|
149
|
+
"is_optional": is_optional,
|
|
150
|
+
"is_list": is_list,
|
|
151
|
+
"is_dict": is_dict,
|
|
152
|
+
"default": default_value,
|
|
153
|
+
"has_default": has_default or has_default_factory,
|
|
154
|
+
"cli_name": self._field_to_cli_name(field_obj.name),
|
|
155
|
+
"override_name": self._field_to_override_name(field_obj.name),
|
|
156
|
+
"field_obj": field_obj, # Include field object for metadata access
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# Only include field if it passes filtering
|
|
160
|
+
if self._should_include_field(field_obj.name, field_info):
|
|
161
|
+
fields_info[field_obj.name] = field_info
|
|
162
|
+
|
|
163
|
+
# Validate positional arguments
|
|
164
|
+
self._validate_positional_arguments(fields_info)
|
|
165
|
+
|
|
166
|
+
return fields_info
|
|
167
|
+
|
|
168
|
+
def _validate_positional_arguments(
|
|
169
|
+
self, fields_info: Dict[str, Dict[str, Any]]
|
|
170
|
+
) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Validate positional argument constraints.
|
|
173
|
+
|
|
174
|
+
Rules:
|
|
175
|
+
1. At most ONE positional field can use nargs='*' or '+'
|
|
176
|
+
2. If present, positional list must be the LAST positional argument
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
ConfigBuilderError: If validation fails
|
|
180
|
+
"""
|
|
181
|
+
positional_fields = []
|
|
182
|
+
positional_list_fields = []
|
|
183
|
+
|
|
184
|
+
for field_name, info in fields_info.items():
|
|
185
|
+
if is_cli_positional(info):
|
|
186
|
+
positional_fields.append((field_name, info))
|
|
187
|
+
|
|
188
|
+
nargs = get_cli_positional_nargs(info)
|
|
189
|
+
# Check if this is a "list" positional (greedy)
|
|
190
|
+
if nargs in ("*", "+"):
|
|
191
|
+
positional_list_fields.append((field_name, nargs))
|
|
192
|
+
|
|
193
|
+
# Rule 1: At most one positional list
|
|
194
|
+
if len(positional_list_fields) > 1:
|
|
195
|
+
field_names = [
|
|
196
|
+
f"'{name}' (nargs='{nargs}')" for name, nargs in positional_list_fields
|
|
197
|
+
]
|
|
198
|
+
raise ConfigBuilderError(
|
|
199
|
+
f"Only one positional list argument allowed, found {len(positional_list_fields)}: "
|
|
200
|
+
f"{', '.join(field_names)}. Use optional lists with flags for additional lists:\n"
|
|
201
|
+
f" Example: field: List[str] = cli_short('f') # Use --field instead"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Rule 2: Positional list must be last
|
|
205
|
+
if positional_list_fields:
|
|
206
|
+
list_field_name, list_nargs = positional_list_fields[0]
|
|
207
|
+
list_field_index = next(
|
|
208
|
+
i
|
|
209
|
+
for i, (name, _) in enumerate(positional_fields)
|
|
210
|
+
if name == list_field_name
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Check if there are any positionals after the list
|
|
214
|
+
if list_field_index < len(positional_fields) - 1:
|
|
215
|
+
later_fields = [
|
|
216
|
+
name for name, _ in positional_fields[list_field_index + 1 :]
|
|
217
|
+
]
|
|
218
|
+
raise ConfigBuilderError(
|
|
219
|
+
f"Positional list argument '{list_field_name}' (nargs='{list_nargs}') must be last.\n"
|
|
220
|
+
f"Found positional argument(s) after it: {', '.join([repr(f) for f in later_fields])}.\n"
|
|
221
|
+
f"Consider making them optional arguments with flags:\n"
|
|
222
|
+
f" Example: {later_fields[0]}: str = cli_short('{later_fields[0][0]}', default='value')"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def _field_to_cli_name(self, field_name: str) -> str:
|
|
226
|
+
"""Convert field name to CLI argument name."""
|
|
227
|
+
return "--" + field_name.replace("_", "-")
|
|
228
|
+
|
|
229
|
+
def _field_to_override_name(self, field_name: str) -> str:
|
|
230
|
+
"""Convert field name to override argument name."""
|
|
231
|
+
# Use abbreviation for override arguments
|
|
232
|
+
words = field_name.split("_")
|
|
233
|
+
if len(words) == 1:
|
|
234
|
+
return "--" + field_name[0]
|
|
235
|
+
else:
|
|
236
|
+
return "--" + "".join(word[0] for word in words if word)
|
|
237
|
+
|
|
238
|
+
def add_arguments(
|
|
239
|
+
self,
|
|
240
|
+
parser: argparse.ArgumentParser,
|
|
241
|
+
base_config_name: str = "config",
|
|
242
|
+
base_config_help: str = "Base configuration file (JSON, YAML, or TOML)",
|
|
243
|
+
) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Add all dataclass arguments to parser.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
parser: ArgumentParser to add arguments to
|
|
249
|
+
base_config_name: Name for base config file argument
|
|
250
|
+
base_config_help: Help text for base config file argument
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
# Base config file argument
|
|
254
|
+
parser.add_argument(f"--{base_config_name}", type=str, help=base_config_help)
|
|
255
|
+
|
|
256
|
+
# IMPORTANT: Add positional arguments first (argparse requirement)
|
|
257
|
+
for field_name, info in self._config_fields.items():
|
|
258
|
+
if is_cli_positional(info):
|
|
259
|
+
self._add_positional_argument(parser, field_name, info)
|
|
260
|
+
|
|
261
|
+
# Then add optional arguments
|
|
262
|
+
for field_name, info in self._config_fields.items():
|
|
263
|
+
if not is_cli_positional(info):
|
|
264
|
+
self._add_field_argument(parser, field_name, info)
|
|
265
|
+
|
|
266
|
+
def _add_positional_argument(
|
|
267
|
+
self, parser: argparse.ArgumentParser, field_name: str, info: Dict[str, Any]
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Add positional argument to parser."""
|
|
270
|
+
# Positional arguments use the field name directly (no -- prefix)
|
|
271
|
+
arg_name = field_name
|
|
272
|
+
|
|
273
|
+
# Get nargs from metadata
|
|
274
|
+
nargs = get_cli_positional_nargs(info)
|
|
275
|
+
|
|
276
|
+
# Get metavar from metadata or default to uppercase field name
|
|
277
|
+
metavar = get_cli_positional_metavar(info)
|
|
278
|
+
if not metavar:
|
|
279
|
+
metavar = field_name.upper()
|
|
280
|
+
|
|
281
|
+
# Get help text
|
|
282
|
+
help_text = get_cli_help(info) or f"{field_name}"
|
|
283
|
+
|
|
284
|
+
# Get choices if specified
|
|
285
|
+
choices = get_cli_choices(info)
|
|
286
|
+
|
|
287
|
+
# Get type converter - for lists, need to convert element type
|
|
288
|
+
if info["is_list"] and info["args"]:
|
|
289
|
+
# Get the element type from List[T]
|
|
290
|
+
element_type = info["args"][0]
|
|
291
|
+
arg_type = self._get_argument_type(element_type)
|
|
292
|
+
else:
|
|
293
|
+
arg_type = self._get_argument_type(info["type"])
|
|
294
|
+
|
|
295
|
+
# Build kwargs
|
|
296
|
+
# Build kwargs with explicit type for mypy
|
|
297
|
+
kwargs: Dict[str, Any] = {
|
|
298
|
+
"help": help_text,
|
|
299
|
+
"metavar": metavar,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if nargs is not None:
|
|
303
|
+
kwargs["nargs"] = nargs
|
|
304
|
+
|
|
305
|
+
if choices:
|
|
306
|
+
kwargs["choices"] = choices
|
|
307
|
+
|
|
308
|
+
# Type handling: for list-like nargs, type applies to each element
|
|
309
|
+
kwargs["type"] = arg_type
|
|
310
|
+
|
|
311
|
+
# Add default if specified and nargs allows it
|
|
312
|
+
if nargs in ("?", "*"):
|
|
313
|
+
default = info.get("default")
|
|
314
|
+
if default is not None:
|
|
315
|
+
kwargs["default"] = default
|
|
316
|
+
|
|
317
|
+
parser.add_argument(arg_name, **kwargs)
|
|
318
|
+
|
|
319
|
+
def _add_field_argument(
|
|
320
|
+
self, parser: argparse.ArgumentParser, field_name: str, info: Dict[str, Any]
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Add CLI argument for a specific config field."""
|
|
323
|
+
# Special handling for boolean fields
|
|
324
|
+
if info["type"] == bool:
|
|
325
|
+
self._add_boolean_argument(parser, field_name, info)
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
cli_name = info["cli_name"]
|
|
329
|
+
|
|
330
|
+
# Get short option from metadata
|
|
331
|
+
short_option = get_cli_short(info)
|
|
332
|
+
|
|
333
|
+
# Build argument names list
|
|
334
|
+
arg_names = []
|
|
335
|
+
if short_option:
|
|
336
|
+
arg_names.append(f"-{short_option}") # Short comes first: -n
|
|
337
|
+
arg_names.append(cli_name) # Then long: --name
|
|
338
|
+
|
|
339
|
+
# Get custom help text from annotations or use default
|
|
340
|
+
custom_help = get_cli_help(info)
|
|
341
|
+
help_text = custom_help if custom_help else f"{field_name}"
|
|
342
|
+
|
|
343
|
+
# Get restricted choices if specified
|
|
344
|
+
choices = get_cli_choices(info)
|
|
345
|
+
if choices:
|
|
346
|
+
# Add choices hint to help text
|
|
347
|
+
choices_str = ", ".join(str(c) for c in choices)
|
|
348
|
+
if help_text:
|
|
349
|
+
help_text += f" (choices: {choices_str})"
|
|
350
|
+
else:
|
|
351
|
+
help_text = f"choices: {choices_str}"
|
|
352
|
+
|
|
353
|
+
if info["is_list"]:
|
|
354
|
+
# List parameters accept multiple values after a single flag
|
|
355
|
+
# Use nargs='+' for required lists (one or more values)
|
|
356
|
+
# Use nargs='*' for optional lists (zero or more values)
|
|
357
|
+
if info["is_optional"]:
|
|
358
|
+
nargs_val = "*" # Zero or more values for Optional[List[T]]
|
|
359
|
+
help_text += " (specify zero or more values)"
|
|
360
|
+
else:
|
|
361
|
+
nargs_val = "+" # One or more values for List[T]
|
|
362
|
+
help_text += " (specify one or more values)"
|
|
363
|
+
|
|
364
|
+
parser.add_argument(
|
|
365
|
+
*arg_names, nargs=nargs_val, choices=choices, help=help_text
|
|
366
|
+
)
|
|
367
|
+
elif info["is_dict"]:
|
|
368
|
+
# Dict parameters are file paths
|
|
369
|
+
dict_help = (
|
|
370
|
+
f"{help_text} configuration file path"
|
|
371
|
+
if help_text
|
|
372
|
+
else "configuration file path"
|
|
373
|
+
)
|
|
374
|
+
parser.add_argument(*arg_names, type=str, help=dict_help)
|
|
375
|
+
# Add override argument for dict fields (no short form for overrides)
|
|
376
|
+
override_help = (
|
|
377
|
+
f"{help_text} property override (format: key.path:value)"
|
|
378
|
+
if help_text
|
|
379
|
+
else "property override (format: key.path:value)"
|
|
380
|
+
)
|
|
381
|
+
parser.add_argument(
|
|
382
|
+
info["override_name"],
|
|
383
|
+
action="append",
|
|
384
|
+
help=override_help,
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
# Simple scalar parameters
|
|
388
|
+
arg_type = self._get_argument_type(info["type"])
|
|
389
|
+
parser.add_argument(
|
|
390
|
+
*arg_names, type=arg_type, choices=choices, help=help_text
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def _add_boolean_argument(
|
|
394
|
+
self, parser: argparse.ArgumentParser, field_name: str, info: Dict[str, Any]
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Add boolean argument with positive and negative forms."""
|
|
397
|
+
cli_name = info["cli_name"]
|
|
398
|
+
dest_name = field_name.replace("-", "_")
|
|
399
|
+
|
|
400
|
+
# Get short option from metadata
|
|
401
|
+
short_option = get_cli_short(info)
|
|
402
|
+
|
|
403
|
+
# Build argument names for positive form
|
|
404
|
+
positive_args = []
|
|
405
|
+
if short_option:
|
|
406
|
+
positive_args.append(f"-{short_option}")
|
|
407
|
+
positive_args.append(cli_name)
|
|
408
|
+
|
|
409
|
+
# Get custom help text
|
|
410
|
+
custom_help = get_cli_help(info)
|
|
411
|
+
help_text = custom_help if custom_help else field_name
|
|
412
|
+
|
|
413
|
+
# Get default value
|
|
414
|
+
default_value = info.get("default", False)
|
|
415
|
+
|
|
416
|
+
# Set parser default to the field's default value
|
|
417
|
+
parser.set_defaults(**{dest_name: default_value})
|
|
418
|
+
|
|
419
|
+
# Add positive form (--flag or -f)
|
|
420
|
+
parser.add_argument(
|
|
421
|
+
*positive_args,
|
|
422
|
+
action="store_true",
|
|
423
|
+
dest=dest_name,
|
|
424
|
+
help=f"{help_text} (default: {default_value})",
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Add negative form (--no-flag)
|
|
428
|
+
negative_name = f"--no-{field_name.replace('_', '-')}"
|
|
429
|
+
parser.add_argument(
|
|
430
|
+
negative_name,
|
|
431
|
+
action="store_false",
|
|
432
|
+
dest=dest_name,
|
|
433
|
+
help=f"Disable {help_text}",
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
def _get_argument_type(self, field_type: Type) -> Callable[[str], Any]:
|
|
437
|
+
"""Get appropriate argparse type for field type."""
|
|
438
|
+
# Note: bool is handled separately in _add_boolean_argument
|
|
439
|
+
if field_type in (int, float, str):
|
|
440
|
+
return field_type
|
|
441
|
+
else:
|
|
442
|
+
# For complex types, use string and let validation handle it
|
|
443
|
+
return str
|
|
444
|
+
|
|
445
|
+
def build_config(
|
|
446
|
+
self, args: argparse.Namespace, base_config_name: str = "config"
|
|
447
|
+
) -> Any:
|
|
448
|
+
"""
|
|
449
|
+
Build dataclass instance from parsed CLI arguments.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
args: Parsed CLI arguments
|
|
453
|
+
base_config_name: Name of base config argument
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Instance of the configured dataclass type
|
|
457
|
+
|
|
458
|
+
Raises:
|
|
459
|
+
ConfigurationError: If configuration is invalid
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
# Start with base config
|
|
463
|
+
base_config = {}
|
|
464
|
+
base_config_value = getattr(args, base_config_name.replace("-", "_"), None)
|
|
465
|
+
if base_config_value:
|
|
466
|
+
try:
|
|
467
|
+
base_config = load_structured_file(base_config_value)
|
|
468
|
+
except Exception as e:
|
|
469
|
+
raise ConfigurationError(
|
|
470
|
+
f"Failed to load base config from {base_config_value}: {e}"
|
|
471
|
+
) from e
|
|
472
|
+
|
|
473
|
+
# Apply CLI argument overrides (only for included fields)
|
|
474
|
+
config_dict = self._merge_cli_args(base_config, args)
|
|
475
|
+
|
|
476
|
+
# Create and return config
|
|
477
|
+
try:
|
|
478
|
+
return self.config_class(**config_dict)
|
|
479
|
+
except Exception as e:
|
|
480
|
+
raise ConfigurationError(
|
|
481
|
+
f"Failed to create {self.config_class.__name__}: {e}"
|
|
482
|
+
) from e
|
|
483
|
+
|
|
484
|
+
def _merge_cli_args(
|
|
485
|
+
self, base_config: Dict[str, Any], args: argparse.Namespace
|
|
486
|
+
) -> Dict[str, Any]:
|
|
487
|
+
"""Merge CLI arguments into base config."""
|
|
488
|
+
config = base_config.copy()
|
|
489
|
+
|
|
490
|
+
# Only process fields that were included in CLI
|
|
491
|
+
for field_name, info in self._config_fields.items():
|
|
492
|
+
# Convert CLI arg name back to field name
|
|
493
|
+
arg_name = field_name.replace("-", "_")
|
|
494
|
+
cli_value = getattr(args, arg_name, None)
|
|
495
|
+
|
|
496
|
+
# Get override argument name
|
|
497
|
+
override_arg_name = info["override_name"][2:].replace("-", "_")
|
|
498
|
+
override_value = getattr(args, override_arg_name, None)
|
|
499
|
+
|
|
500
|
+
if cli_value is not None:
|
|
501
|
+
if info["is_list"]:
|
|
502
|
+
# CLI values replace base config values (standard argparse behavior)
|
|
503
|
+
# With nargs='+' or '*', cli_value is already a list
|
|
504
|
+
config[field_name] = cli_value
|
|
505
|
+
elif info["is_dict"]:
|
|
506
|
+
# For dicts, load from file
|
|
507
|
+
try:
|
|
508
|
+
dict_config = load_structured_file(cli_value)
|
|
509
|
+
existing = config.get(field_name, {})
|
|
510
|
+
if isinstance(existing, dict):
|
|
511
|
+
existing.update(dict_config)
|
|
512
|
+
config[field_name] = existing
|
|
513
|
+
else:
|
|
514
|
+
config[field_name] = dict_config
|
|
515
|
+
except Exception as e:
|
|
516
|
+
raise ConfigurationError(
|
|
517
|
+
f"Failed to load dictionary config for field '{field_name}' from {cli_value}: {e}"
|
|
518
|
+
) from e
|
|
519
|
+
else:
|
|
520
|
+
# Simple override - check for file-loadable fields
|
|
521
|
+
try:
|
|
522
|
+
processed_value = process_file_loadable_value(
|
|
523
|
+
cli_value, field_name, info
|
|
524
|
+
)
|
|
525
|
+
config[field_name] = processed_value
|
|
526
|
+
except (ValueError, Exception) as e:
|
|
527
|
+
raise ConfigurationError(
|
|
528
|
+
f"Failed to process field '{field_name}': {e}"
|
|
529
|
+
) from e
|
|
530
|
+
|
|
531
|
+
# Apply property overrides for dict fields
|
|
532
|
+
if info["is_dict"] and override_value:
|
|
533
|
+
if field_name not in config:
|
|
534
|
+
config[field_name] = {}
|
|
535
|
+
try:
|
|
536
|
+
self._apply_property_overrides(config[field_name], override_value)
|
|
537
|
+
except Exception as e:
|
|
538
|
+
raise ConfigurationError(
|
|
539
|
+
f"Failed to apply property overrides for field '{field_name}': {e}"
|
|
540
|
+
) from e
|
|
541
|
+
|
|
542
|
+
return config
|
|
543
|
+
|
|
544
|
+
def _apply_property_overrides(
|
|
545
|
+
self, target_dict: Dict[str, Any], overrides: List[str]
|
|
546
|
+
) -> None:
|
|
547
|
+
"""Apply property path overrides to target dictionary."""
|
|
548
|
+
for override in overrides:
|
|
549
|
+
if ":" not in override:
|
|
550
|
+
raise ValueError(
|
|
551
|
+
f"Invalid override format: {override} (expected key.path:value)"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
path, value = override.split(":", 1)
|
|
555
|
+
self._set_nested_property(target_dict, path, self._parse_value(value))
|
|
556
|
+
|
|
557
|
+
def _set_nested_property(
|
|
558
|
+
self, target: Dict[str, Any], path: str, value: Any
|
|
559
|
+
) -> None:
|
|
560
|
+
"""Set nested property using dot notation."""
|
|
561
|
+
keys = path.split(".")
|
|
562
|
+
current = target
|
|
563
|
+
|
|
564
|
+
# Navigate to parent of target key
|
|
565
|
+
for key in keys[:-1]:
|
|
566
|
+
if key not in current:
|
|
567
|
+
current[key] = {}
|
|
568
|
+
current = current[key]
|
|
569
|
+
|
|
570
|
+
# Set final value
|
|
571
|
+
current[keys[-1]] = value
|
|
572
|
+
|
|
573
|
+
def _parse_value(self, value_str: str) -> Any:
|
|
574
|
+
"""Parse string value to appropriate type."""
|
|
575
|
+
# Try to parse as JSON first (handles numbers, booleans, etc.)
|
|
576
|
+
try:
|
|
577
|
+
return json.loads(value_str)
|
|
578
|
+
except json.JSONDecodeError:
|
|
579
|
+
# Return as string if not valid JSON
|
|
580
|
+
return value_str
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# Convenience functions
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def build_config_from_cli(
|
|
587
|
+
config_class: Type,
|
|
588
|
+
args: Optional[List[str]] = None,
|
|
589
|
+
base_config_name: str = "config",
|
|
590
|
+
exclude_fields: Optional[Set[str]] = None,
|
|
591
|
+
include_fields: Optional[Set[str]] = None,
|
|
592
|
+
field_filter: Optional[Callable[[str, Dict[str, Any]], bool]] = None,
|
|
593
|
+
use_annotations: bool = True,
|
|
594
|
+
) -> Any:
|
|
595
|
+
"""
|
|
596
|
+
Convenience function to build any dataclass from CLI arguments.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
config_class: Dataclass type to build
|
|
600
|
+
args: Command-line arguments (defaults to sys.argv[1:])
|
|
601
|
+
base_config_name: Name for base config file argument
|
|
602
|
+
exclude_fields: Set of field names to exclude from CLI
|
|
603
|
+
include_fields: Set of field names to include in CLI
|
|
604
|
+
field_filter: Custom function to determine field inclusion
|
|
605
|
+
use_annotations: Whether to respect cli_exclude() annotations (default: True)
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
Instance of config_class built from CLI arguments
|
|
609
|
+
|
|
610
|
+
Example:
|
|
611
|
+
from dataclasses import dataclass
|
|
612
|
+
from dataclass_args import cli_exclude, cli_help, cli_file_loadable
|
|
613
|
+
|
|
614
|
+
@dataclass
|
|
615
|
+
class MyConfig:
|
|
616
|
+
name: str = cli_help("Service name")
|
|
617
|
+
message: str = cli_file_loadable(cli_help("Message text"))
|
|
618
|
+
items: Optional[List[str]] = None
|
|
619
|
+
settings: Optional[dict] = None
|
|
620
|
+
_secret: str = cli_exclude(default="hidden")
|
|
621
|
+
|
|
622
|
+
# Usage:
|
|
623
|
+
config = build_config_from_cli(MyConfig, [
|
|
624
|
+
'--name', 'test',
|
|
625
|
+
'--items', 'a', 'b', 'c',
|
|
626
|
+
'--message', '@/path/to/message.txt'
|
|
627
|
+
])
|
|
628
|
+
"""
|
|
629
|
+
if args is None:
|
|
630
|
+
args = sys.argv[1:]
|
|
631
|
+
|
|
632
|
+
builder = GenericConfigBuilder(
|
|
633
|
+
config_class,
|
|
634
|
+
exclude_fields=exclude_fields,
|
|
635
|
+
include_fields=include_fields,
|
|
636
|
+
field_filter=field_filter,
|
|
637
|
+
use_annotations=use_annotations,
|
|
638
|
+
)
|
|
639
|
+
parser = argparse.ArgumentParser(
|
|
640
|
+
description=f"Build {config_class.__name__} from CLI"
|
|
641
|
+
)
|
|
642
|
+
builder.add_arguments(parser, base_config_name)
|
|
643
|
+
|
|
644
|
+
parsed_args = parser.parse_args(args)
|
|
645
|
+
return builder.build_config(parsed_args, base_config_name)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def build_config(config_class: Type, args: Optional[List[str]] = None) -> Any:
|
|
649
|
+
"""
|
|
650
|
+
Simplified convenience function to build any dataclass from CLI arguments.
|
|
651
|
+
|
|
652
|
+
Uses default settings suitable for most use cases.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
config_class: Dataclass type to build
|
|
656
|
+
args: Command-line arguments (defaults to sys.argv[1:])
|
|
657
|
+
|
|
658
|
+
Returns:
|
|
659
|
+
Instance of config_class built from CLI arguments
|
|
660
|
+
|
|
661
|
+
Example:
|
|
662
|
+
from dataclasses import dataclass
|
|
663
|
+
from dataclass_args import build_config
|
|
664
|
+
|
|
665
|
+
@dataclass
|
|
666
|
+
class Config:
|
|
667
|
+
name: str
|
|
668
|
+
count: int = 10
|
|
669
|
+
|
|
670
|
+
config = build_config(Config) # Parses sys.argv automatically
|
|
671
|
+
"""
|
|
672
|
+
return build_config_from_cli(config_class, args)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exceptions for dataclass CLI configuration system.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConfigBuilderError(Exception):
|
|
7
|
+
"""Base exception for configuration builder errors."""
|
|
8
|
+
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigurationError(ConfigBuilderError):
|
|
13
|
+
"""Exception raised for configuration validation errors."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FileLoadingError(ConfigBuilderError):
|
|
19
|
+
"""Exception raised when file loading fails."""
|
|
20
|
+
|
|
21
|
+
pass
|