dataclass-args 1.2.0__tar.gz → 1.2.2__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.
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/PKG-INFO +1 -1
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/dataclass_args/__init__.py +1 -1
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/dataclass_args/builder.py +34 -7
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/dataclass_args/utils.py +5 -5
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/dataclass_args.egg-info/PKG-INFO +1 -1
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/dataclass_args.egg-info/SOURCES.txt +2 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/pyproject.toml +1 -2
- dataclass_args-1.2.2/tests/test_boolean_base_configs.py +326 -0
- dataclass_args-1.2.2/tests/test_description.py +243 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/LICENSE +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/README.md +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/dataclass_args/annotations.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/dataclass_args/exceptions.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/dataclass_args/file_loading.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/dataclass_args.egg-info/dependency_links.txt +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/dataclass_args.egg-info/requires.txt +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/dataclass_args.egg-info/top_level.txt +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/setup.cfg +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/tests/test_annotations.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/tests/test_basic.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/tests/test_boolean_flags.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/tests/test_builder_advanced.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/tests/test_cli_choices.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/tests/test_cli_short.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/tests/test_combine_annotations.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/tests/test_config_merging_simple.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/tests/test_file_loading.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/tests/test_positional.py +0 -0
- {dataclass_args-1.2.0 → dataclass_args-1.2.2}/tests/test_utils.py +0 -0
|
@@ -65,7 +65,7 @@ from .exceptions import ConfigBuilderError, ConfigurationError, FileLoadingError
|
|
|
65
65
|
from .file_loading import is_file_loadable_value, load_file_content
|
|
66
66
|
from .utils import load_structured_file
|
|
67
67
|
|
|
68
|
-
__version__ = "1.2.
|
|
68
|
+
__version__ = "1.2.2"
|
|
69
69
|
|
|
70
70
|
__all__ = [
|
|
71
71
|
# Main API
|
|
@@ -55,12 +55,15 @@ class GenericConfigBuilder:
|
|
|
55
55
|
def __init__(
|
|
56
56
|
self,
|
|
57
57
|
config_class: Type,
|
|
58
|
+
description: Optional[str] = None,
|
|
58
59
|
):
|
|
59
60
|
"""
|
|
60
61
|
Initialize builder for a specific dataclass type.
|
|
61
62
|
|
|
62
63
|
Args:
|
|
63
64
|
config_class: Dataclass type to build configurations for
|
|
65
|
+
description: Optional description for ArgumentParser help text.
|
|
66
|
+
If not provided, uses "Build {ClassName} from CLI"
|
|
64
67
|
|
|
65
68
|
Raises:
|
|
66
69
|
ConfigBuilderError: If config_class is not a dataclass
|
|
@@ -71,6 +74,7 @@ class GenericConfigBuilder:
|
|
|
71
74
|
)
|
|
72
75
|
|
|
73
76
|
self.config_class = config_class
|
|
77
|
+
self.description = description
|
|
74
78
|
self._config_fields = self._analyze_config_fields()
|
|
75
79
|
|
|
76
80
|
def _should_include_field(
|
|
@@ -387,14 +391,12 @@ class GenericConfigBuilder:
|
|
|
387
391
|
# Get default value
|
|
388
392
|
default_value = info.get("default", False)
|
|
389
393
|
|
|
390
|
-
# Set parser default to the field's default value
|
|
391
|
-
parser.set_defaults(**{dest_name: default_value})
|
|
392
|
-
|
|
393
394
|
# Add positive form (--flag or -f)
|
|
394
395
|
parser.add_argument(
|
|
395
396
|
*positive_args,
|
|
396
397
|
action="store_true",
|
|
397
398
|
dest=dest_name,
|
|
399
|
+
default=argparse.SUPPRESS,
|
|
398
400
|
help=f"{help_text} (default: {default_value})",
|
|
399
401
|
)
|
|
400
402
|
|
|
@@ -404,6 +406,7 @@ class GenericConfigBuilder:
|
|
|
404
406
|
negative_name,
|
|
405
407
|
action="store_false",
|
|
406
408
|
dest=dest_name,
|
|
409
|
+
default=argparse.SUPPRESS,
|
|
407
410
|
help=f"Disable {help_text}",
|
|
408
411
|
)
|
|
409
412
|
|
|
@@ -722,6 +725,7 @@ def build_config_from_cli(
|
|
|
722
725
|
args: Optional[List[str]] = None,
|
|
723
726
|
base_config_name: str = "config",
|
|
724
727
|
base_configs: Optional[BaseConfigInput] = None,
|
|
728
|
+
description: Optional[str] = None,
|
|
725
729
|
) -> Any:
|
|
726
730
|
"""
|
|
727
731
|
Build dataclass instance from CLI arguments with optional base configs.
|
|
@@ -739,6 +743,8 @@ def build_config_from_cli(
|
|
|
739
743
|
- str: Path to a single config file
|
|
740
744
|
- dict: A single configuration dictionary
|
|
741
745
|
- List[Union[str, dict]]: Multiple configs applied in order
|
|
746
|
+
description: Optional description for ArgumentParser help text.
|
|
747
|
+
If not provided, uses "Build {ClassName} from CLI"
|
|
742
748
|
|
|
743
749
|
Returns:
|
|
744
750
|
Instance of config_class built from merged configurations
|
|
@@ -750,6 +756,12 @@ def build_config_from_cli(
|
|
|
750
756
|
# Single dict
|
|
751
757
|
config = build_config_from_cli(MyConfig, base_configs={'debug': True})
|
|
752
758
|
|
|
759
|
+
# With custom description
|
|
760
|
+
config = build_config_from_cli(
|
|
761
|
+
MyConfig,
|
|
762
|
+
description="Configure the application server"
|
|
763
|
+
)
|
|
764
|
+
|
|
753
765
|
# Mixed list
|
|
754
766
|
config = build_config_from_cli(
|
|
755
767
|
MyConfig,
|
|
@@ -764,10 +776,14 @@ def build_config_from_cli(
|
|
|
764
776
|
if args is None:
|
|
765
777
|
args = sys.argv[1:]
|
|
766
778
|
|
|
767
|
-
builder = GenericConfigBuilder(config_class)
|
|
768
|
-
|
|
769
|
-
|
|
779
|
+
builder = GenericConfigBuilder(config_class, description=description)
|
|
780
|
+
|
|
781
|
+
desc = (
|
|
782
|
+
builder.description
|
|
783
|
+
if builder.description is not None
|
|
784
|
+
else f"Build {config_class.__name__} from CLI"
|
|
770
785
|
)
|
|
786
|
+
parser = argparse.ArgumentParser(description=desc)
|
|
771
787
|
builder.add_arguments(parser, base_config_name)
|
|
772
788
|
|
|
773
789
|
parsed_args = parser.parse_args(args)
|
|
@@ -778,6 +794,7 @@ def build_config(
|
|
|
778
794
|
config_class: Type,
|
|
779
795
|
args: Optional[List[str]] = None,
|
|
780
796
|
base_configs: Optional[BaseConfigInput] = None,
|
|
797
|
+
description: Optional[str] = None,
|
|
781
798
|
) -> Any:
|
|
782
799
|
"""
|
|
783
800
|
Simplified convenience function to build dataclass from CLI arguments.
|
|
@@ -794,6 +811,8 @@ def build_config(
|
|
|
794
811
|
- str: Path to a single config file
|
|
795
812
|
- dict: A single configuration dictionary
|
|
796
813
|
- List[Union[str, dict]]: Multiple configs applied in order
|
|
814
|
+
description: Optional description for ArgumentParser help text.
|
|
815
|
+
If not provided, uses "Build {ClassName} from CLI"
|
|
797
816
|
|
|
798
817
|
Returns:
|
|
799
818
|
Instance of config_class built from merged configurations
|
|
@@ -805,6 +824,12 @@ def build_config(
|
|
|
805
824
|
# With base config file
|
|
806
825
|
config = build_config(Config, base_configs='defaults.yaml')
|
|
807
826
|
|
|
827
|
+
# With custom description
|
|
828
|
+
config = build_config(
|
|
829
|
+
Config,
|
|
830
|
+
description="My application configuration tool"
|
|
831
|
+
)
|
|
832
|
+
|
|
808
833
|
# With mixed sources
|
|
809
834
|
config = build_config(
|
|
810
835
|
Config,
|
|
@@ -815,4 +840,6 @@ def build_config(
|
|
|
815
840
|
]
|
|
816
841
|
)
|
|
817
842
|
"""
|
|
818
|
-
return build_config_from_cli(
|
|
843
|
+
return build_config_from_cli(
|
|
844
|
+
config_class, args, base_configs=base_configs, description=description
|
|
845
|
+
)
|
|
@@ -3,6 +3,7 @@ Utility functions for dataclass CLI configuration.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import sys
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Any, Dict, Union
|
|
8
9
|
|
|
@@ -13,15 +14,14 @@ try:
|
|
|
13
14
|
except ImportError:
|
|
14
15
|
HAS_YAML = False
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
# Handle TOML imports for different Python versions
|
|
18
|
+
if sys.version_info >= (3, 11):
|
|
18
19
|
import tomllib
|
|
19
20
|
|
|
20
21
|
HAS_TOML = True
|
|
21
|
-
|
|
22
|
+
else:
|
|
22
23
|
try:
|
|
23
|
-
#
|
|
24
|
-
import tomli as tomllib
|
|
24
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
25
25
|
|
|
26
26
|
HAS_TOML = True
|
|
27
27
|
except ImportError:
|
|
@@ -14,12 +14,14 @@ dataclass_args.egg-info/requires.txt
|
|
|
14
14
|
dataclass_args.egg-info/top_level.txt
|
|
15
15
|
tests/test_annotations.py
|
|
16
16
|
tests/test_basic.py
|
|
17
|
+
tests/test_boolean_base_configs.py
|
|
17
18
|
tests/test_boolean_flags.py
|
|
18
19
|
tests/test_builder_advanced.py
|
|
19
20
|
tests/test_cli_choices.py
|
|
20
21
|
tests/test_cli_short.py
|
|
21
22
|
tests/test_combine_annotations.py
|
|
22
23
|
tests/test_config_merging_simple.py
|
|
24
|
+
tests/test_description.py
|
|
23
25
|
tests/test_file_loading.py
|
|
24
26
|
tests/test_positional.py
|
|
25
27
|
tests/test_utils.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dataclass-args"
|
|
7
|
-
version = "1.2.
|
|
7
|
+
version = "1.2.2"
|
|
8
8
|
description = "Zero-boilerplate CLI generation from Python dataclasses with advanced type support and file loading"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -153,7 +153,6 @@ line_length = 88
|
|
|
153
153
|
known_first_party = ["dataclass_args"]
|
|
154
154
|
|
|
155
155
|
[tool.mypy]
|
|
156
|
-
python_version = "3.9"
|
|
157
156
|
warn_return_any = false
|
|
158
157
|
warn_unused_configs = true
|
|
159
158
|
disallow_untyped_defs = false
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for boolean field handling with base_configs.
|
|
3
|
+
|
|
4
|
+
This module tests that boolean fields from base_configs are preserved correctly
|
|
5
|
+
when not overridden by CLI flags.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from dataclass_args import build_config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class BoolConfig:
|
|
19
|
+
"""Test config with various boolean fields."""
|
|
20
|
+
|
|
21
|
+
debug: bool = False
|
|
22
|
+
verbose: bool = True
|
|
23
|
+
enabled: bool = False
|
|
24
|
+
disabled: bool = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestBooleanFromDict:
|
|
28
|
+
"""Test boolean values from base_configs dict."""
|
|
29
|
+
|
|
30
|
+
def test_bool_true_overrides_false_default(self):
|
|
31
|
+
"""Boolean True from dict overrides False default."""
|
|
32
|
+
config = build_config(BoolConfig, args=[], base_configs={"debug": True})
|
|
33
|
+
assert config.debug is True
|
|
34
|
+
|
|
35
|
+
def test_bool_false_overrides_true_default(self):
|
|
36
|
+
"""Boolean False from dict overrides True default."""
|
|
37
|
+
config = build_config(BoolConfig, args=[], base_configs={"verbose": False})
|
|
38
|
+
assert config.verbose is False
|
|
39
|
+
|
|
40
|
+
def test_multiple_bool_overrides(self):
|
|
41
|
+
"""Multiple boolean overrides from dict."""
|
|
42
|
+
config = build_config(
|
|
43
|
+
BoolConfig,
|
|
44
|
+
args=[],
|
|
45
|
+
base_configs={
|
|
46
|
+
"debug": True,
|
|
47
|
+
"verbose": False,
|
|
48
|
+
"enabled": True,
|
|
49
|
+
"disabled": False,
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
assert config.debug is True
|
|
53
|
+
assert config.verbose is False
|
|
54
|
+
assert config.enabled is True
|
|
55
|
+
assert config.disabled is False
|
|
56
|
+
|
|
57
|
+
def test_partial_bool_overrides(self):
|
|
58
|
+
"""Some fields from dict, others use defaults."""
|
|
59
|
+
config = build_config(
|
|
60
|
+
BoolConfig, args=[], base_configs={"debug": True, "enabled": True}
|
|
61
|
+
)
|
|
62
|
+
assert config.debug is True # from dict
|
|
63
|
+
assert config.verbose is True # default
|
|
64
|
+
assert config.enabled is True # from dict
|
|
65
|
+
assert config.disabled is True # default
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestBooleanFromFile:
|
|
69
|
+
"""Test boolean values from base_configs file."""
|
|
70
|
+
|
|
71
|
+
def test_bool_from_json_file(self):
|
|
72
|
+
"""Boolean values from JSON file."""
|
|
73
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
74
|
+
f.write('{"debug": true, "verbose": false}')
|
|
75
|
+
config_file = f.name
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
config = build_config(BoolConfig, args=[], base_configs=config_file)
|
|
79
|
+
assert config.debug is True
|
|
80
|
+
assert config.verbose is False
|
|
81
|
+
finally:
|
|
82
|
+
Path(config_file).unlink()
|
|
83
|
+
|
|
84
|
+
def test_bool_from_yaml_file(self):
|
|
85
|
+
"""Boolean values from YAML file."""
|
|
86
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
|
87
|
+
f.write("debug: true\nverbose: false\n")
|
|
88
|
+
config_file = f.name
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
config = build_config(BoolConfig, args=[], base_configs=config_file)
|
|
92
|
+
assert config.debug is True
|
|
93
|
+
assert config.verbose is False
|
|
94
|
+
finally:
|
|
95
|
+
Path(config_file).unlink()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestBooleanCLIOverride:
|
|
99
|
+
"""Test that CLI arguments override base_configs."""
|
|
100
|
+
|
|
101
|
+
def test_cli_flag_overrides_dict(self):
|
|
102
|
+
"""CLI --flag overrides dict value."""
|
|
103
|
+
config = build_config(
|
|
104
|
+
BoolConfig, args=["--debug"], base_configs={"debug": False}
|
|
105
|
+
)
|
|
106
|
+
assert config.debug is True
|
|
107
|
+
|
|
108
|
+
def test_cli_no_flag_overrides_dict(self):
|
|
109
|
+
"""CLI --no-flag overrides dict value."""
|
|
110
|
+
config = build_config(
|
|
111
|
+
BoolConfig, args=["--no-verbose"], base_configs={"verbose": True}
|
|
112
|
+
)
|
|
113
|
+
assert config.verbose is False
|
|
114
|
+
|
|
115
|
+
def test_multiple_cli_overrides(self):
|
|
116
|
+
"""Multiple CLI flags override dict values."""
|
|
117
|
+
config = build_config(
|
|
118
|
+
BoolConfig,
|
|
119
|
+
args=["--debug", "--no-verbose", "--enabled"],
|
|
120
|
+
base_configs={"debug": False, "verbose": True, "enabled": False},
|
|
121
|
+
)
|
|
122
|
+
assert config.debug is True
|
|
123
|
+
assert config.verbose is False
|
|
124
|
+
assert config.enabled is True
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TestBooleanMixed:
|
|
128
|
+
"""Test mixed scenarios: some CLI, some base_configs, some defaults."""
|
|
129
|
+
|
|
130
|
+
def test_cli_partial_dict_rest(self):
|
|
131
|
+
"""Some from CLI, some from dict, some defaults."""
|
|
132
|
+
config = build_config(
|
|
133
|
+
BoolConfig,
|
|
134
|
+
args=["--debug"], # Only debug from CLI
|
|
135
|
+
base_configs={
|
|
136
|
+
"verbose": False,
|
|
137
|
+
"enabled": True,
|
|
138
|
+
}, # verbose and enabled from dict
|
|
139
|
+
)
|
|
140
|
+
assert config.debug is True # from CLI
|
|
141
|
+
assert config.verbose is False # from dict
|
|
142
|
+
assert config.enabled is True # from dict
|
|
143
|
+
assert config.disabled is True # default
|
|
144
|
+
|
|
145
|
+
def test_dict_without_cli_preserves_values(self):
|
|
146
|
+
"""Dict values preserved when no CLI flag for that field."""
|
|
147
|
+
config = build_config(
|
|
148
|
+
BoolConfig,
|
|
149
|
+
args=["--enabled"], # Only set enabled via CLI
|
|
150
|
+
base_configs={
|
|
151
|
+
"debug": True, # Should be preserved
|
|
152
|
+
"verbose": False, # Should be preserved
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
assert config.debug is True # from dict (no CLI override)
|
|
156
|
+
assert config.verbose is False # from dict (no CLI override)
|
|
157
|
+
assert config.enabled is True # from CLI
|
|
158
|
+
assert config.disabled is True # default
|
|
159
|
+
|
|
160
|
+
def test_cli_overrides_one_dict_preserves_others(self):
|
|
161
|
+
"""CLI overrides one field, dict values preserved for others."""
|
|
162
|
+
config = build_config(
|
|
163
|
+
BoolConfig,
|
|
164
|
+
args=["--no-verbose"], # Override only verbose
|
|
165
|
+
base_configs={
|
|
166
|
+
"debug": True,
|
|
167
|
+
"verbose": True, # Will be overridden
|
|
168
|
+
"enabled": True,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
assert config.debug is True # from dict
|
|
172
|
+
assert config.verbose is False # from CLI (overrides dict)
|
|
173
|
+
assert config.enabled is True # from dict
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class TestBooleanDefaultBehavior:
|
|
177
|
+
"""Test that defaults work correctly without base_configs or CLI."""
|
|
178
|
+
|
|
179
|
+
def test_no_args_no_config_uses_defaults(self):
|
|
180
|
+
"""No args, no config uses dataclass defaults."""
|
|
181
|
+
config = build_config(BoolConfig, args=[])
|
|
182
|
+
assert config.debug is False
|
|
183
|
+
assert config.verbose is True
|
|
184
|
+
assert config.enabled is False
|
|
185
|
+
assert config.disabled is True
|
|
186
|
+
|
|
187
|
+
def test_empty_dict_uses_defaults(self):
|
|
188
|
+
"""Empty dict uses dataclass defaults."""
|
|
189
|
+
config = build_config(BoolConfig, args=[], base_configs={})
|
|
190
|
+
assert config.debug is False
|
|
191
|
+
assert config.verbose is True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class TestBooleanConfigFilePlusCLI:
|
|
195
|
+
"""Test --config file argument combined with CLI flags."""
|
|
196
|
+
|
|
197
|
+
def test_config_file_plus_cli_override(self):
|
|
198
|
+
"""--config file combined with CLI override."""
|
|
199
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
200
|
+
f.write('{"debug": true, "verbose": false}')
|
|
201
|
+
config_file = f.name
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
config = build_config(
|
|
205
|
+
BoolConfig,
|
|
206
|
+
args=["--config", config_file, "--debug"], # CLI overrides
|
|
207
|
+
)
|
|
208
|
+
# CLI explicitly set debug=True (same as file, but from CLI)
|
|
209
|
+
assert config.debug is True
|
|
210
|
+
# File set verbose=False
|
|
211
|
+
assert config.verbose is False
|
|
212
|
+
finally:
|
|
213
|
+
Path(config_file).unlink()
|
|
214
|
+
|
|
215
|
+
def test_base_configs_plus_config_file_plus_cli(self):
|
|
216
|
+
"""Hierarchy: base_configs < --config file < CLI."""
|
|
217
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
218
|
+
f.write('{"verbose": false}')
|
|
219
|
+
config_file = f.name
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
config = build_config(
|
|
223
|
+
BoolConfig,
|
|
224
|
+
args=["--config", config_file, "--enabled"],
|
|
225
|
+
base_configs={"debug": True}, # Lowest priority
|
|
226
|
+
)
|
|
227
|
+
assert config.debug is True # from base_configs
|
|
228
|
+
assert config.verbose is False # from --config file
|
|
229
|
+
assert config.enabled is True # from CLI
|
|
230
|
+
finally:
|
|
231
|
+
Path(config_file).unlink()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class TestBooleanEdgeCases:
|
|
235
|
+
"""Test edge cases for boolean handling."""
|
|
236
|
+
|
|
237
|
+
def test_bool_explicitly_set_to_default_via_cli(self):
|
|
238
|
+
"""CLI flag explicitly sets value to match default."""
|
|
239
|
+
# Default is debug=False, explicitly set --no-debug
|
|
240
|
+
config = build_config(
|
|
241
|
+
BoolConfig,
|
|
242
|
+
args=["--no-debug"], # Explicitly False
|
|
243
|
+
base_configs={"debug": True}, # Try to override
|
|
244
|
+
)
|
|
245
|
+
# CLI should win even though it matches default
|
|
246
|
+
assert config.debug is False
|
|
247
|
+
|
|
248
|
+
def test_bool_both_positive_and_negative_flags(self):
|
|
249
|
+
"""Last flag wins if both positive and negative specified."""
|
|
250
|
+
config = build_config(
|
|
251
|
+
BoolConfig,
|
|
252
|
+
args=["--debug", "--no-debug"], # Conflicting flags
|
|
253
|
+
)
|
|
254
|
+
# argparse behavior: last one wins
|
|
255
|
+
assert config.debug is False
|
|
256
|
+
|
|
257
|
+
def test_all_false_values(self):
|
|
258
|
+
"""All booleans set to False."""
|
|
259
|
+
config = build_config(
|
|
260
|
+
BoolConfig,
|
|
261
|
+
args=[],
|
|
262
|
+
base_configs={
|
|
263
|
+
"debug": False,
|
|
264
|
+
"verbose": False,
|
|
265
|
+
"enabled": False,
|
|
266
|
+
"disabled": False,
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
assert config.debug is False
|
|
270
|
+
assert config.verbose is False
|
|
271
|
+
assert config.enabled is False
|
|
272
|
+
assert config.disabled is False
|
|
273
|
+
|
|
274
|
+
def test_all_true_values(self):
|
|
275
|
+
"""All booleans set to True."""
|
|
276
|
+
config = build_config(
|
|
277
|
+
BoolConfig,
|
|
278
|
+
args=[],
|
|
279
|
+
base_configs={
|
|
280
|
+
"debug": True,
|
|
281
|
+
"verbose": True,
|
|
282
|
+
"enabled": True,
|
|
283
|
+
"disabled": True,
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
assert config.debug is True
|
|
287
|
+
assert config.verbose is True
|
|
288
|
+
assert config.enabled is True
|
|
289
|
+
assert config.disabled is True
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class TestBooleanMultipleBasConfigs:
|
|
293
|
+
"""Test boolean handling with multiple base_configs."""
|
|
294
|
+
|
|
295
|
+
def test_list_of_dicts_later_overrides_earlier(self):
|
|
296
|
+
"""Later dict in list overrides earlier dict."""
|
|
297
|
+
config = build_config(
|
|
298
|
+
BoolConfig,
|
|
299
|
+
args=[],
|
|
300
|
+
base_configs=[
|
|
301
|
+
{"debug": True, "verbose": False},
|
|
302
|
+
{"debug": False}, # Overrides debug only
|
|
303
|
+
],
|
|
304
|
+
)
|
|
305
|
+
assert config.debug is False # from second dict
|
|
306
|
+
assert config.verbose is False # from first dict
|
|
307
|
+
|
|
308
|
+
def test_file_and_dict_combination(self):
|
|
309
|
+
"""Mix files and dicts in base_configs list."""
|
|
310
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
311
|
+
f.write('{"debug": true}')
|
|
312
|
+
config_file = f.name
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
config = build_config(
|
|
316
|
+
BoolConfig,
|
|
317
|
+
args=[],
|
|
318
|
+
base_configs=[
|
|
319
|
+
config_file, # File first
|
|
320
|
+
{"verbose": False}, # Dict second
|
|
321
|
+
],
|
|
322
|
+
)
|
|
323
|
+
assert config.debug is True # from file
|
|
324
|
+
assert config.verbose is False # from dict
|
|
325
|
+
finally:
|
|
326
|
+
Path(config_file).unlink()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Tests for custom description parameter."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from dataclass_args import GenericConfigBuilder, build_config, build_config_from_cli
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SimpleConfig:
|
|
15
|
+
"""Simple config for testing."""
|
|
16
|
+
|
|
17
|
+
name: str = "default"
|
|
18
|
+
count: int = 10
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestCustomDescription:
|
|
22
|
+
"""Test custom description parameter functionality."""
|
|
23
|
+
|
|
24
|
+
def test_builder_with_custom_description(self):
|
|
25
|
+
"""Test GenericConfigBuilder with custom description."""
|
|
26
|
+
custom_desc = "My custom application configuration"
|
|
27
|
+
builder = GenericConfigBuilder(SimpleConfig, description=custom_desc)
|
|
28
|
+
|
|
29
|
+
assert builder.description == custom_desc
|
|
30
|
+
assert builder.config_class == SimpleConfig
|
|
31
|
+
|
|
32
|
+
def test_builder_with_no_description(self):
|
|
33
|
+
"""Test GenericConfigBuilder without description defaults to None."""
|
|
34
|
+
builder = GenericConfigBuilder(SimpleConfig)
|
|
35
|
+
|
|
36
|
+
assert builder.description is None
|
|
37
|
+
assert builder.config_class == SimpleConfig
|
|
38
|
+
|
|
39
|
+
def test_build_config_with_custom_description(self, capsys):
|
|
40
|
+
"""Test build_config with custom description in help."""
|
|
41
|
+
custom_desc = "Configure the application server"
|
|
42
|
+
|
|
43
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
44
|
+
build_config(SimpleConfig, args=["--help"], description=custom_desc)
|
|
45
|
+
|
|
46
|
+
assert exc_info.value.code == 0
|
|
47
|
+
captured = capsys.readouterr()
|
|
48
|
+
assert custom_desc in captured.out
|
|
49
|
+
|
|
50
|
+
def test_build_config_with_default_description(self, capsys):
|
|
51
|
+
"""Test build_config without description uses default format."""
|
|
52
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
53
|
+
build_config(SimpleConfig, args=["--help"])
|
|
54
|
+
|
|
55
|
+
assert exc_info.value.code == 0
|
|
56
|
+
captured = capsys.readouterr()
|
|
57
|
+
assert "Build SimpleConfig from CLI" in captured.out
|
|
58
|
+
|
|
59
|
+
def test_build_config_from_cli_with_custom_description(self, capsys):
|
|
60
|
+
"""Test build_config_from_cli with custom description."""
|
|
61
|
+
custom_desc = "Server configuration utility"
|
|
62
|
+
|
|
63
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
64
|
+
build_config_from_cli(
|
|
65
|
+
SimpleConfig, args=["--help"], description=custom_desc
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert exc_info.value.code == 0
|
|
69
|
+
captured = capsys.readouterr()
|
|
70
|
+
assert custom_desc in captured.out
|
|
71
|
+
|
|
72
|
+
def test_build_config_from_cli_with_default_description(self, capsys):
|
|
73
|
+
"""Test build_config_from_cli without description uses default."""
|
|
74
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
75
|
+
build_config_from_cli(SimpleConfig, args=["--help"])
|
|
76
|
+
|
|
77
|
+
assert exc_info.value.code == 0
|
|
78
|
+
captured = capsys.readouterr()
|
|
79
|
+
assert "Build SimpleConfig from CLI" in captured.out
|
|
80
|
+
|
|
81
|
+
def test_custom_description_does_not_affect_functionality(self):
|
|
82
|
+
"""Test that custom description doesn't change parsing behavior."""
|
|
83
|
+
# Without description
|
|
84
|
+
config1 = build_config(SimpleConfig, args=["--name", "test1", "--count", "100"])
|
|
85
|
+
|
|
86
|
+
# With description
|
|
87
|
+
config2 = build_config(
|
|
88
|
+
SimpleConfig,
|
|
89
|
+
args=["--name", "test1", "--count", "100"],
|
|
90
|
+
description="Custom description",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert config1.name == config2.name == "test1"
|
|
94
|
+
assert config1.count == config2.count == 100
|
|
95
|
+
|
|
96
|
+
def test_backward_compatibility_direct_builder(self):
|
|
97
|
+
"""Test that existing code using GenericConfigBuilder still works."""
|
|
98
|
+
import argparse
|
|
99
|
+
|
|
100
|
+
# Old pattern: GenericConfigBuilder without description
|
|
101
|
+
builder = GenericConfigBuilder(SimpleConfig)
|
|
102
|
+
parser = argparse.ArgumentParser(description="Custom parser")
|
|
103
|
+
builder.add_arguments(parser)
|
|
104
|
+
|
|
105
|
+
args = parser.parse_args(["--name", "legacy", "--count", "42"])
|
|
106
|
+
config = builder.build_config(args)
|
|
107
|
+
|
|
108
|
+
assert config.name == "legacy"
|
|
109
|
+
assert config.count == 42
|
|
110
|
+
|
|
111
|
+
def test_backward_compatibility_convenience_functions(self):
|
|
112
|
+
"""Test that existing convenience function calls still work."""
|
|
113
|
+
# Old pattern: build_config without description
|
|
114
|
+
config = build_config(SimpleConfig, args=["--name", "oldstyle"])
|
|
115
|
+
|
|
116
|
+
assert config.name == "oldstyle"
|
|
117
|
+
assert config.count == 10 # default
|
|
118
|
+
|
|
119
|
+
def test_multiline_description(self, capsys):
|
|
120
|
+
"""Test that multiline descriptions work correctly."""
|
|
121
|
+
multiline_desc = """Configure the application server.
|
|
122
|
+
|
|
123
|
+
This tool allows you to set various server parameters
|
|
124
|
+
including name, port, and other settings."""
|
|
125
|
+
|
|
126
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
127
|
+
build_config(SimpleConfig, args=["--help"], description=multiline_desc)
|
|
128
|
+
|
|
129
|
+
assert exc_info.value.code == 0
|
|
130
|
+
captured = capsys.readouterr()
|
|
131
|
+
# argparse may format this, but key parts should be present
|
|
132
|
+
assert "Configure the application server" in captured.out
|
|
133
|
+
|
|
134
|
+
def test_empty_string_description(self, capsys):
|
|
135
|
+
"""Test that empty string description is handled."""
|
|
136
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
137
|
+
build_config(SimpleConfig, args=["--help"], description="")
|
|
138
|
+
|
|
139
|
+
assert exc_info.value.code == 0
|
|
140
|
+
captured = capsys.readouterr()
|
|
141
|
+
# Empty description should be used (not default)
|
|
142
|
+
assert "Build SimpleConfig from CLI" not in captured.out
|
|
143
|
+
|
|
144
|
+
def test_description_with_special_characters(self, capsys):
|
|
145
|
+
"""Test description with special characters."""
|
|
146
|
+
special_desc = "Config tool: <options> [required] {advanced}"
|
|
147
|
+
|
|
148
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
149
|
+
build_config(SimpleConfig, args=["--help"], description=special_desc)
|
|
150
|
+
|
|
151
|
+
assert exc_info.value.code == 0
|
|
152
|
+
captured = capsys.readouterr()
|
|
153
|
+
assert "Config tool:" in captured.out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestDescriptionWithComplexConfig:
|
|
157
|
+
"""Test description parameter with more complex configurations."""
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class ComplexConfig:
|
|
161
|
+
"""Complex config with various field types."""
|
|
162
|
+
|
|
163
|
+
host: str = "localhost"
|
|
164
|
+
port: int = 8080
|
|
165
|
+
debug: bool = False
|
|
166
|
+
tags: Optional[List[str]] = None
|
|
167
|
+
|
|
168
|
+
def __post_init__(self):
|
|
169
|
+
if self.tags is None:
|
|
170
|
+
self.tags = []
|
|
171
|
+
|
|
172
|
+
def test_complex_config_with_description(self, capsys):
|
|
173
|
+
"""Test complex config with custom description."""
|
|
174
|
+
desc = "Advanced server configuration with multiple options"
|
|
175
|
+
|
|
176
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
177
|
+
build_config(self.ComplexConfig, args=["--help"], description=desc)
|
|
178
|
+
|
|
179
|
+
assert exc_info.value.code == 0
|
|
180
|
+
captured = capsys.readouterr()
|
|
181
|
+
assert desc in captured.out
|
|
182
|
+
# Should still show all the arguments
|
|
183
|
+
assert "--host" in captured.out
|
|
184
|
+
assert "--port" in captured.out
|
|
185
|
+
assert "--debug" in captured.out
|
|
186
|
+
|
|
187
|
+
def test_complex_config_parsing_with_description(self):
|
|
188
|
+
"""Test that complex config parsing works with description."""
|
|
189
|
+
config = build_config(
|
|
190
|
+
self.ComplexConfig,
|
|
191
|
+
args=["--host", "0.0.0.0", "--port", "9000", "--debug"],
|
|
192
|
+
description="Test configuration",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
assert config.host == "0.0.0.0"
|
|
196
|
+
assert config.port == 9000
|
|
197
|
+
assert config.debug is True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TestDescriptionEdgeCases:
|
|
201
|
+
"""Test edge cases and error conditions."""
|
|
202
|
+
|
|
203
|
+
def test_none_description_explicitly(self):
|
|
204
|
+
"""Test explicitly passing None as description."""
|
|
205
|
+
builder = GenericConfigBuilder(SimpleConfig, description=None)
|
|
206
|
+
assert builder.description is None
|
|
207
|
+
|
|
208
|
+
def test_description_type_validation(self):
|
|
209
|
+
"""Test that non-string descriptions work (argparse coerces to str)."""
|
|
210
|
+
# argparse will convert to string, so this should work
|
|
211
|
+
builder = GenericConfigBuilder(SimpleConfig, description=123)
|
|
212
|
+
assert builder.description == 123
|
|
213
|
+
|
|
214
|
+
# When used in ArgumentParser, argparse will handle conversion
|
|
215
|
+
import argparse
|
|
216
|
+
|
|
217
|
+
desc = builder.description or f"Build {builder.config_class.__name__} from CLI"
|
|
218
|
+
parser = argparse.ArgumentParser(description=str(desc))
|
|
219
|
+
assert "123" in parser.format_help()
|
|
220
|
+
|
|
221
|
+
def test_very_long_description(self, capsys):
|
|
222
|
+
"""Test with a very long description."""
|
|
223
|
+
long_desc = "A " + "very " * 100 + "long description."
|
|
224
|
+
|
|
225
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
226
|
+
build_config(SimpleConfig, args=["--help"], description=long_desc)
|
|
227
|
+
|
|
228
|
+
assert exc_info.value.code == 0
|
|
229
|
+
captured = capsys.readouterr()
|
|
230
|
+
# argparse will format it, but it should appear
|
|
231
|
+
assert "very" in captured.out
|
|
232
|
+
|
|
233
|
+
def test_description_with_unicode(self, capsys):
|
|
234
|
+
"""Test description with unicode characters."""
|
|
235
|
+
unicode_desc = "配置工具 - Configuration Tool 🚀"
|
|
236
|
+
|
|
237
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
238
|
+
build_config(SimpleConfig, args=["--help"], description=unicode_desc)
|
|
239
|
+
|
|
240
|
+
assert exc_info.value.code == 0
|
|
241
|
+
captured = capsys.readouterr()
|
|
242
|
+
# Check for parts of the unicode string
|
|
243
|
+
assert "Configuration Tool" in captured.out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|