dataclass-args 1.2.0__tar.gz → 1.3.0__tar.gz

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.
Files changed (32) hide show
  1. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/PKG-INFO +106 -2
  2. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/README.md +105 -1
  3. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/dataclass_args/__init__.py +13 -1
  4. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/dataclass_args/annotations.py +217 -0
  5. dataclass_args-1.3.0/dataclass_args/append_action.py +48 -0
  6. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/dataclass_args/builder.py +186 -7
  7. dataclass_args-1.3.0/dataclass_args/formatter.py +45 -0
  8. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/dataclass_args/utils.py +5 -5
  9. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/dataclass_args.egg-info/PKG-INFO +106 -2
  10. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/dataclass_args.egg-info/SOURCES.txt +5 -0
  11. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/pyproject.toml +1 -2
  12. dataclass_args-1.3.0/tests/test_boolean_base_configs.py +326 -0
  13. dataclass_args-1.3.0/tests/test_cli_append.py +841 -0
  14. dataclass_args-1.3.0/tests/test_description.py +243 -0
  15. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/LICENSE +0 -0
  16. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/dataclass_args/exceptions.py +0 -0
  17. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/dataclass_args/file_loading.py +0 -0
  18. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/dataclass_args.egg-info/dependency_links.txt +0 -0
  19. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/dataclass_args.egg-info/requires.txt +0 -0
  20. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/dataclass_args.egg-info/top_level.txt +0 -0
  21. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/setup.cfg +0 -0
  22. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/tests/test_annotations.py +0 -0
  23. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/tests/test_basic.py +0 -0
  24. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/tests/test_boolean_flags.py +0 -0
  25. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/tests/test_builder_advanced.py +0 -0
  26. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/tests/test_cli_choices.py +0 -0
  27. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/tests/test_cli_short.py +0 -0
  28. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/tests/test_combine_annotations.py +0 -0
  29. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/tests/test_config_merging_simple.py +0 -0
  30. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/tests/test_file_loading.py +0 -0
  31. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/tests/test_positional.py +0 -0
  32. {dataclass_args-1.2.0 → dataclass_args-1.3.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataclass-args
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Zero-boilerplate CLI generation from Python dataclasses with advanced type support and file loading
5
5
  Author-email: Martin Bartlett <martin.j.bartlett@gmail.com>
6
6
  License: MIT
@@ -52,7 +52,8 @@ Dynamic: license-file
52
52
  Generate command-line interfaces from Python dataclasses.
53
53
 
54
54
  [![Tests](https://github.com/bassmanitram/dataclass-args/actions/workflows/test.yml/badge.svg)](https://github.com/bassmanitram/dataclass-args/actions/workflows/test.yml)
55
- [![Code Quality](https://github.com/bassmanitram/dataclass-args/actions/workflows/lint.yml/badge.svg)](https://github.com/bassmanitram/dataclass-args/actions/workflows/lint.yml)
55
+ [![Lint](https://github.com/bassmanitram/dataclass-args/actions/workflows/lint.yml/badge.svg)](https://github.com/bassmanitram/dataclass-args/actions/workflows/lint.yml)
56
+ [![Code Quality](https://github.com/bassmanitram/dataclass-args/actions/workflows/quality.yml/badge.svg)](https://github.com/bassmanitram/dataclass-args/actions/workflows/quality.yml)
56
57
  [![Examples](https://github.com/bassmanitram/dataclass-args/actions/workflows/examples.yml/badge.svg)](https://github.com/bassmanitram/dataclass-args/actions/workflows/examples.yml)
57
58
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
58
59
  [![PyPI version](https://img.shields.io/pypi/v/dataclass-args.svg)](https://pypi.org/project/dataclass-args/)
@@ -66,6 +67,7 @@ Generate command-line interfaces from Python dataclasses.
66
67
  - **[Short Options](#short-options)** - Concise `-n` flags in addition to `--name`
67
68
  - **[Boolean Flags](#boolean-flags)** - Proper `--flag` and `--no-flag` boolean handling
68
69
  - **[Value Validation](#value-choices)** - Restrict values with `cli_choices()`
70
+ - **[Repeatable Options](#repeatable-options)** - Allow options to be specified multiple times with `cli_append()`
69
71
  - **[File Loading](#file-loadable-parameters)** - Load parameters from files using `@filename` syntax
70
72
  - **[Config Merging](#configuration-merging)** - Combine configuration sources with hierarchical overrides
71
73
  - **[Flexible Types](#type-support)** - Support for `List`, `Dict`, `Optional`, and custom types
@@ -209,6 +211,108 @@ error: argument --environment: invalid choice: 'invalid' (choose from 'dev', 'st
209
211
 
210
212
 
211
213
 
214
+ ### Repeatable Options
215
+
216
+ Use `cli_append()` to allow an option to be specified multiple times, with each occurrence collecting its own arguments:
217
+
218
+ ```python
219
+ from dataclass_args import cli_append
220
+
221
+ @dataclass
222
+ class Config:
223
+ # Simple tags: each -t adds one value
224
+ tags: List[str] = combine_annotations(
225
+ cli_short('t'),
226
+ cli_append(),
227
+ cli_help("Add a tag"),
228
+ default_factory=list
229
+ )
230
+ ```
231
+
232
+ ```bash
233
+ # Each -t occurrence accumulates
234
+ $ python app.py -t python -t cli -t tool
235
+ # Result: ['python', 'cli', 'tool']
236
+ ```
237
+
238
+ #### Repeatable with Multiple Arguments
239
+
240
+ Each occurrence can take multiple arguments using `nargs`:
241
+
242
+ ```python
243
+ @dataclass
244
+ class DockerConfig:
245
+ # Each -p takes exactly 2 arguments (HOST CONTAINER)
246
+ ports: List[List[str]] = combine_annotations(
247
+ cli_short('p'),
248
+ cli_append(nargs=2),
249
+ cli_help("Port mapping (HOST CONTAINER)"),
250
+ default_factory=list
251
+ )
252
+
253
+ # Each -v takes exactly 2 arguments (SOURCE TARGET)
254
+ volumes: List[List[str]] = combine_annotations(
255
+ cli_short('v'),
256
+ cli_append(nargs=2),
257
+ cli_help("Volume mount (SOURCE TARGET)"),
258
+ default_factory=list
259
+ )
260
+ ```
261
+
262
+ ```bash
263
+ $ python docker.py -p 8080 80 -p 8443 443 -v /host/data /container/data
264
+ ```
265
+
266
+ #### Variable Arguments with Validation
267
+
268
+ Use `min_args` and `max_args` for flexible argument counts with automatic validation:
269
+
270
+ ```python
271
+ @dataclass
272
+ class UploadConfig:
273
+ files: List[List[str]] = combine_annotations(
274
+ cli_short('f'),
275
+ cli_append(min_args=1, max_args=2, metavar="FILE [MIMETYPE]"),
276
+ cli_help("File with optional MIME type"),
277
+ default_factory=list
278
+ )
279
+ # No __post_init__ needed - validation is automatic!
280
+ ```
281
+
282
+ ```bash
283
+ # Mix files with and without MIME types
284
+ $ python upload.py -f doc.pdf application/pdf -f image.png -f video.mp4 video/mp4
285
+ # Result: [['doc.pdf', 'application/pdf'], ['image.png'], ['video.mp4', 'video/mp4']]
286
+
287
+ # Validation catches errors automatically
288
+ $ python upload.py -f file1 arg2 arg3 arg4
289
+ # Error: Expected at most 2 argument(s), got 4
290
+ ```
291
+
292
+ **Clean help display:**
293
+ ```
294
+ -f FILE [MIMETYPE], --files FILE [MIMETYPE]
295
+ File with optional MIME type (can be repeated, 1-2 args each)
296
+ ```
297
+
298
+ **Parameters:**
299
+ - `min_args`: Minimum arguments per occurrence
300
+ - `max_args`: Maximum arguments per occurrence
301
+ - Must be used together (both or neither)
302
+ - Mutually exclusive with `nargs`
303
+
304
+ **nargs Options:**
305
+ - `None` - One value per occurrence → `List[T]`
306
+ - `int` (e.g., `2`) - Exact count per occurrence → `List[List[T]]`
307
+ - `'+'` - One or more per occurrence → `List[List[T]]`
308
+ - `'*'` - Zero or more per occurrence → `List[List[T]]`
309
+
310
+ **Use Cases:**
311
+ - Docker-style options: `-p 8080:80 -p 8443:443 -v /host:/container -e KEY=value`
312
+ - File operations: `-f file1 type1 -f file2 -f file3 type3`
313
+ - Server pools: `-s host1 port1 -s host2 port2`
314
+ - Build systems: `-I dir1 -I dir2 --define KEY VAL`
315
+
212
316
  ### Positional Arguments
213
317
 
214
318
  Add positional arguments that don't require `--` prefixes:
@@ -3,7 +3,8 @@
3
3
  Generate command-line interfaces from Python dataclasses.
4
4
 
5
5
  [![Tests](https://github.com/bassmanitram/dataclass-args/actions/workflows/test.yml/badge.svg)](https://github.com/bassmanitram/dataclass-args/actions/workflows/test.yml)
6
- [![Code Quality](https://github.com/bassmanitram/dataclass-args/actions/workflows/lint.yml/badge.svg)](https://github.com/bassmanitram/dataclass-args/actions/workflows/lint.yml)
6
+ [![Lint](https://github.com/bassmanitram/dataclass-args/actions/workflows/lint.yml/badge.svg)](https://github.com/bassmanitram/dataclass-args/actions/workflows/lint.yml)
7
+ [![Code Quality](https://github.com/bassmanitram/dataclass-args/actions/workflows/quality.yml/badge.svg)](https://github.com/bassmanitram/dataclass-args/actions/workflows/quality.yml)
7
8
  [![Examples](https://github.com/bassmanitram/dataclass-args/actions/workflows/examples.yml/badge.svg)](https://github.com/bassmanitram/dataclass-args/actions/workflows/examples.yml)
8
9
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
9
10
  [![PyPI version](https://img.shields.io/pypi/v/dataclass-args.svg)](https://pypi.org/project/dataclass-args/)
@@ -17,6 +18,7 @@ Generate command-line interfaces from Python dataclasses.
17
18
  - **[Short Options](#short-options)** - Concise `-n` flags in addition to `--name`
18
19
  - **[Boolean Flags](#boolean-flags)** - Proper `--flag` and `--no-flag` boolean handling
19
20
  - **[Value Validation](#value-choices)** - Restrict values with `cli_choices()`
21
+ - **[Repeatable Options](#repeatable-options)** - Allow options to be specified multiple times with `cli_append()`
20
22
  - **[File Loading](#file-loadable-parameters)** - Load parameters from files using `@filename` syntax
21
23
  - **[Config Merging](#configuration-merging)** - Combine configuration sources with hierarchical overrides
22
24
  - **[Flexible Types](#type-support)** - Support for `List`, `Dict`, `Optional`, and custom types
@@ -160,6 +162,108 @@ error: argument --environment: invalid choice: 'invalid' (choose from 'dev', 'st
160
162
 
161
163
 
162
164
 
165
+ ### Repeatable Options
166
+
167
+ Use `cli_append()` to allow an option to be specified multiple times, with each occurrence collecting its own arguments:
168
+
169
+ ```python
170
+ from dataclass_args import cli_append
171
+
172
+ @dataclass
173
+ class Config:
174
+ # Simple tags: each -t adds one value
175
+ tags: List[str] = combine_annotations(
176
+ cli_short('t'),
177
+ cli_append(),
178
+ cli_help("Add a tag"),
179
+ default_factory=list
180
+ )
181
+ ```
182
+
183
+ ```bash
184
+ # Each -t occurrence accumulates
185
+ $ python app.py -t python -t cli -t tool
186
+ # Result: ['python', 'cli', 'tool']
187
+ ```
188
+
189
+ #### Repeatable with Multiple Arguments
190
+
191
+ Each occurrence can take multiple arguments using `nargs`:
192
+
193
+ ```python
194
+ @dataclass
195
+ class DockerConfig:
196
+ # Each -p takes exactly 2 arguments (HOST CONTAINER)
197
+ ports: List[List[str]] = combine_annotations(
198
+ cli_short('p'),
199
+ cli_append(nargs=2),
200
+ cli_help("Port mapping (HOST CONTAINER)"),
201
+ default_factory=list
202
+ )
203
+
204
+ # Each -v takes exactly 2 arguments (SOURCE TARGET)
205
+ volumes: List[List[str]] = combine_annotations(
206
+ cli_short('v'),
207
+ cli_append(nargs=2),
208
+ cli_help("Volume mount (SOURCE TARGET)"),
209
+ default_factory=list
210
+ )
211
+ ```
212
+
213
+ ```bash
214
+ $ python docker.py -p 8080 80 -p 8443 443 -v /host/data /container/data
215
+ ```
216
+
217
+ #### Variable Arguments with Validation
218
+
219
+ Use `min_args` and `max_args` for flexible argument counts with automatic validation:
220
+
221
+ ```python
222
+ @dataclass
223
+ class UploadConfig:
224
+ files: List[List[str]] = combine_annotations(
225
+ cli_short('f'),
226
+ cli_append(min_args=1, max_args=2, metavar="FILE [MIMETYPE]"),
227
+ cli_help("File with optional MIME type"),
228
+ default_factory=list
229
+ )
230
+ # No __post_init__ needed - validation is automatic!
231
+ ```
232
+
233
+ ```bash
234
+ # Mix files with and without MIME types
235
+ $ python upload.py -f doc.pdf application/pdf -f image.png -f video.mp4 video/mp4
236
+ # Result: [['doc.pdf', 'application/pdf'], ['image.png'], ['video.mp4', 'video/mp4']]
237
+
238
+ # Validation catches errors automatically
239
+ $ python upload.py -f file1 arg2 arg3 arg4
240
+ # Error: Expected at most 2 argument(s), got 4
241
+ ```
242
+
243
+ **Clean help display:**
244
+ ```
245
+ -f FILE [MIMETYPE], --files FILE [MIMETYPE]
246
+ File with optional MIME type (can be repeated, 1-2 args each)
247
+ ```
248
+
249
+ **Parameters:**
250
+ - `min_args`: Minimum arguments per occurrence
251
+ - `max_args`: Maximum arguments per occurrence
252
+ - Must be used together (both or neither)
253
+ - Mutually exclusive with `nargs`
254
+
255
+ **nargs Options:**
256
+ - `None` - One value per occurrence → `List[T]`
257
+ - `int` (e.g., `2`) - Exact count per occurrence → `List[List[T]]`
258
+ - `'+'` - One or more per occurrence → `List[List[T]]`
259
+ - `'*'` - Zero or more per occurrence → `List[List[T]]`
260
+
261
+ **Use Cases:**
262
+ - Docker-style options: `-p 8080:80 -p 8443:443 -v /host:/container -e KEY=value`
263
+ - File operations: `-f file1 type1 -f file2 -f file3 type3`
264
+ - Server pools: `-s host1 port1 -s host2 port2`
265
+ - Build systems: `-I dir1 -I dir2 --define KEY VAL`
266
+
163
267
  ### Positional Arguments
164
268
 
165
269
  Add positional arguments that don't require `--` prefixes:
@@ -43,6 +43,7 @@ Advanced Usage:
43
43
  """
44
44
 
45
45
  from .annotations import (
46
+ cli_append,
46
47
  cli_choices,
47
48
  cli_exclude,
48
49
  cli_file_loadable,
@@ -51,10 +52,15 @@ from .annotations import (
51
52
  cli_positional,
52
53
  cli_short,
53
54
  combine_annotations,
55
+ get_cli_append_max_args,
56
+ get_cli_append_metavar,
57
+ get_cli_append_min_args,
58
+ get_cli_append_nargs,
54
59
  get_cli_choices,
55
60
  get_cli_positional_metavar,
56
61
  get_cli_positional_nargs,
57
62
  get_cli_short,
63
+ is_cli_append,
58
64
  is_cli_excluded,
59
65
  is_cli_file_loadable,
60
66
  is_cli_included,
@@ -65,7 +71,7 @@ from .exceptions import ConfigBuilderError, ConfigurationError, FileLoadingError
65
71
  from .file_loading import is_file_loadable_value, load_file_content
66
72
  from .utils import load_structured_file
67
73
 
68
- __version__ = "1.2.0"
74
+ __version__ = "1.3.0"
69
75
 
70
76
  __all__ = [
71
77
  # Main API
@@ -80,15 +86,21 @@ __all__ = [
80
86
  "cli_include",
81
87
  "cli_file_loadable",
82
88
  "cli_positional",
89
+ "cli_append",
83
90
  "combine_annotations",
84
91
  "get_cli_short",
85
92
  "get_cli_choices",
86
93
  "get_cli_positional_nargs",
87
94
  "get_cli_positional_metavar",
95
+ "get_cli_append_nargs",
96
+ "get_cli_append_metavar",
97
+ "get_cli_append_min_args",
98
+ "get_cli_append_max_args",
88
99
  "is_cli_file_loadable",
89
100
  "is_cli_excluded",
90
101
  "is_cli_included",
91
102
  "is_cli_positional",
103
+ "is_cli_append",
92
104
  # File loading
93
105
  "load_file_content",
94
106
  "is_file_loadable_value",
@@ -219,6 +219,143 @@ def cli_file_loadable(**kwargs) -> Any:
219
219
  return field(**field_kwargs)
220
220
 
221
221
 
222
+ def cli_append(
223
+ nargs: Optional[Any] = None,
224
+ min_args: Optional[int] = None,
225
+ max_args: Optional[int] = None,
226
+ metavar: Optional[str] = None,
227
+ **kwargs,
228
+ ) -> Any:
229
+ """
230
+ Mark a field for append action - allows repeating the option multiple times.
231
+
232
+ Each occurrence of the option collects its arguments into a sub-list,
233
+ and all sub-lists are collected into the final list.
234
+
235
+ Args:
236
+ nargs: Number of arguments per option occurrence (traditional argparse style)
237
+ None = exactly one (each -f takes 1 arg)
238
+ '?' = zero or one
239
+ '*' = zero or more
240
+ '+' = one or more
241
+ int = exact count (e.g., 2 for pairs)
242
+ Mutually exclusive with min_args/max_args
243
+ min_args: Minimum arguments per occurrence (e.g., 1 for "at least 1")
244
+ Must be used together with max_args
245
+ Mutually exclusive with nargs
246
+ max_args: Maximum arguments per occurrence (e.g., 2 for "at most 2")
247
+ Must be used together with min_args
248
+ Mutually exclusive with nargs
249
+ metavar: Name for display in help text (e.g., "FILE [MIMETYPE]")
250
+ **kwargs: Additional field parameters (default_factory, etc.)
251
+
252
+ Returns:
253
+ Field object with append metadata
254
+
255
+ Examples:
256
+ Basic append with single values:
257
+
258
+ >>> @dataclass
259
+ ... class Config:
260
+ ... tags: List[str] = cli_append()
261
+
262
+ CLI: -t python -t cli -t dataclass
263
+ Result: ['python', 'cli', 'dataclass']
264
+
265
+ Append with pairs (nargs=2):
266
+
267
+ >>> @dataclass
268
+ ... class Config:
269
+ ... files: List[List[str]] = cli_append(nargs=2)
270
+
271
+ CLI: -f file1.txt text/plain -f file2.jpg image/jpeg
272
+ Result: [['file1.txt', 'text/plain'], ['file2.jpg', 'image/jpeg']]
273
+
274
+ Append with variable args (nargs='+'):
275
+
276
+ >>> @dataclass
277
+ ... class Config:
278
+ ... groups: List[List[str]] = cli_append(nargs='+')
279
+
280
+ CLI: -g file1 file2 -g file3 -g file4 file5 file6
281
+ Result: [['file1', 'file2'], ['file3'], ['file4', 'file5', 'file6']]
282
+
283
+ Combined with other annotations:
284
+
285
+ >>> @dataclass
286
+ ... class Config:
287
+ ... files: List[List[str]] = combine_annotations(
288
+ ... cli_short('f'),
289
+ ... cli_append(nargs='+'),
290
+ ... cli_help("File with optional MIME type"),
291
+ ... default_factory=list
292
+ ... )
293
+
294
+ CLI: -f doc.pdf application/pdf -f image.png -f video.mp4 video/mp4
295
+ Result: [['doc.pdf', 'application/pdf'], ['image.png'], ['video.mp4', 'video/mp4']]
296
+
297
+ With metavar for better help text:
298
+
299
+ >>> @dataclass
300
+ ... class Config:
301
+ ... files: List[List[str]] = combine_annotations(
302
+ ... cli_short('f'),
303
+ ... cli_append(nargs='+', metavar="FILE [MIMETYPE]"),
304
+ ... cli_help("File with optional MIME type"),
305
+ ... default_factory=list
306
+ ... )
307
+
308
+ Note:
309
+ - Field type should be List[T] for nargs=None, or List[List[T]] for nargs with multiple values
310
+ - Always use default_factory=list for append fields
311
+ - Cannot be combined with cli_positional()
312
+ """
313
+ # Validate mutually exclusive parameters
314
+ if nargs is not None and (min_args is not None or max_args is not None):
315
+ raise ValueError(
316
+ "cli_append: 'nargs' and 'min_args'/'max_args' are mutually exclusive. "
317
+ "Use either nargs for standard argparse behavior, or min_args/max_args for range validation."
318
+ )
319
+
320
+ # Validate min_args/max_args must be used together
321
+ if (min_args is not None) != (max_args is not None):
322
+ raise ValueError(
323
+ "cli_append: 'min_args' and 'max_args' must be used together. "
324
+ f"Got min_args={min_args}, max_args={max_args}"
325
+ )
326
+
327
+ # Validate range constraints
328
+ if min_args is not None and max_args is not None:
329
+ if min_args < 1:
330
+ raise ValueError(f"cli_append: 'min_args' must be >= 1, got {min_args}")
331
+ if max_args < min_args:
332
+ raise ValueError(
333
+ f"cli_append: 'max_args' ({max_args}) must be >= min_args ({min_args})"
334
+ )
335
+
336
+ field_kwargs = kwargs.copy()
337
+ metadata = field_kwargs.pop("metadata", {})
338
+ metadata["cli_append"] = True
339
+
340
+ if nargs is not None:
341
+ metadata["cli_append_nargs"] = nargs
342
+
343
+ if metavar is not None:
344
+ metadata["cli_append_metavar"] = metavar
345
+
346
+ if min_args is not None:
347
+ metadata["cli_append_min_args"] = min_args
348
+
349
+ if max_args is not None:
350
+ metadata["cli_append_max_args"] = max_args
351
+ # Move 'help' to metadata if present (dataclass field() doesn't accept it)
352
+ if "help" in field_kwargs:
353
+ metadata["cli_help"] = field_kwargs.pop("help")
354
+
355
+ field_kwargs["metadata"] = metadata
356
+ return field(**field_kwargs)
357
+
358
+
222
359
  def combine_annotations(*annotations, **field_kwargs) -> Any:
223
360
  """
224
361
  Combine multiple CLI annotations into a single field.
@@ -316,6 +453,22 @@ def is_cli_file_loadable(field_info: Dict[str, Any]) -> bool:
316
453
  return False
317
454
 
318
455
 
456
+ def is_cli_append(field_info: Dict[str, Any]) -> bool:
457
+ """
458
+ Check if a field uses append action for repeated options.
459
+
460
+ Args:
461
+ field_info: Field information dictionary from GenericConfigBuilder
462
+
463
+ Returns:
464
+ True if field uses append action
465
+ """
466
+ field_obj = field_info.get("field_obj")
467
+ if field_obj and hasattr(field_obj, "metadata"):
468
+ return field_obj.metadata.get("cli_append", False)
469
+ return False
470
+
471
+
319
472
  def get_cli_short(field_info: Dict[str, Any]) -> Optional[str]:
320
473
  """
321
474
  Get short option character for a CLI argument.
@@ -348,6 +501,38 @@ def get_cli_choices(field_info: Dict[str, Any]) -> Optional[List[Any]]:
348
501
  return None
349
502
 
350
503
 
504
+ def get_cli_append_nargs(field_info: Dict[str, Any]) -> Optional[Any]:
505
+ """
506
+ Get nargs value for an append CLI argument.
507
+
508
+ Args:
509
+ field_info: Field information dictionary from GenericConfigBuilder
510
+
511
+ Returns:
512
+ nargs value if specified, otherwise None (meaning exactly one per occurrence)
513
+ """
514
+ field_obj = field_info.get("field_obj")
515
+ if field_obj and hasattr(field_obj, "metadata"):
516
+ return field_obj.metadata.get("cli_append_nargs")
517
+ return None
518
+
519
+
520
+ def get_cli_append_metavar(field_info: Dict[str, Any]) -> Optional[str]:
521
+ """
522
+ Get metavar for an append CLI argument.
523
+
524
+ Args:
525
+ field_info: Field information dictionary from GenericConfigBuilder
526
+
527
+ Returns:
528
+ Metavar string if specified, otherwise None
529
+ """
530
+ field_obj = field_info.get("field_obj")
531
+ if field_obj and hasattr(field_obj, "metadata"):
532
+ return field_obj.metadata.get("cli_append_metavar")
533
+ return None
534
+
535
+
351
536
  def get_cli_help(field_info: Dict[str, Any]) -> str:
352
537
  """
353
538
  Get custom help text for a CLI argument.
@@ -521,3 +706,35 @@ def get_cli_positional_metavar(field_info: Dict[str, Any]) -> Optional[str]:
521
706
  if field_obj and hasattr(field_obj, "metadata"):
522
707
  return field_obj.metadata.get("cli_positional_metavar")
523
708
  return None
709
+
710
+
711
+ def get_cli_append_min_args(field_info: Dict[str, Any]) -> Optional[int]:
712
+ """
713
+ Get minimum arguments for an append CLI argument.
714
+
715
+ Args:
716
+ field_info: Field information dictionary from GenericConfigBuilder
717
+
718
+ Returns:
719
+ Minimum argument count if specified, otherwise None
720
+ """
721
+ field_obj = field_info.get("field_obj")
722
+ if field_obj and hasattr(field_obj, "metadata"):
723
+ return field_obj.metadata.get("cli_append_min_args")
724
+ return None
725
+
726
+
727
+ def get_cli_append_max_args(field_info: Dict[str, Any]) -> Optional[int]:
728
+ """
729
+ Get maximum arguments for an append CLI argument.
730
+
731
+ Args:
732
+ field_info: Field information dictionary from GenericConfigBuilder
733
+
734
+ Returns:
735
+ Maximum argument count if specified, otherwise None
736
+ """
737
+ field_obj = field_info.get("field_obj")
738
+ if field_obj and hasattr(field_obj, "metadata"):
739
+ return field_obj.metadata.get("cli_append_max_args")
740
+ return None
@@ -0,0 +1,48 @@
1
+ """Custom argparse action for range-validated append arguments."""
2
+
3
+ import argparse
4
+ from typing import Any, Optional, Sequence, Union
5
+
6
+
7
+ class RangeAppendAction(argparse._AppendAction): # type: ignore[misc]
8
+ """
9
+ Append action for fields with min/max argument validation.
10
+
11
+ Works with RangeAppendHelpFormatter to display metavar cleanly without repetition.
12
+
13
+ Standard: -f X Y [X Y ...]
14
+ With this: -f X Y
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ option_strings: Sequence[str],
20
+ dest: str,
21
+ nargs: Optional[Union[int, str]] = None,
22
+ const: Any = None,
23
+ default: Any = None,
24
+ type: Any = None,
25
+ choices: Optional[Sequence[Any]] = None,
26
+ required: bool = False,
27
+ help: Optional[str] = None,
28
+ metavar: Optional[str] = None,
29
+ **kwargs: Any,
30
+ ):
31
+ """Initialize with custom metavar storage."""
32
+ # Store metavar for custom formatter to use
33
+ self._custom_metavar = metavar
34
+
35
+ # Call parent without metavar to avoid argparse's automatic handling
36
+ super().__init__(
37
+ option_strings=option_strings,
38
+ dest=dest,
39
+ nargs=nargs,
40
+ const=const,
41
+ default=default,
42
+ type=type,
43
+ choices=choices,
44
+ required=required,
45
+ help=help,
46
+ metavar=None, # Don't pass to parent
47
+ **kwargs,
48
+ )