Skip to content

Commit 61694f5

Browse files
committed
fix: objects in config files
1 parent 36f5986 commit 61694f5

File tree

7 files changed

+69
-26
lines changed

7 files changed

+69
-26
lines changed

docs/Changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 1.1.1
44
* enh: `list[tuple]` support (along with `list[tuple[int]]` and `list[tuple[int, ...]]`)
5+
* fix: objects in config files
56

67
## 1.1.0 (2025-09-12)
78
* CHANGED – some [`run`][mininterface.run] arguments are no longer positional and can only be passed as keyword arguments

mininterface/_lib/auxiliary.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from dataclasses import fields, is_dataclass
66
from functools import lru_cache
77
from types import UnionType
8-
from typing import (Any, Callable, Iterable, Optional, TypeVar, Union,
8+
from typing import (Any, Callable, Iterable, Optional, TypeVar, Union, Literal,
99
get_args, get_origin, get_type_hints)
1010

1111
from annotated_types import Ge, Gt, Le, Len, Lt, MultipleOf
@@ -90,8 +90,7 @@ def get_description(obj, param: str) -> str:
9090
if p := _get_parser(obj):
9191
try:
9292
d = get_descriptions(p)[param].strip()
93-
except KeyError:
94-
logger.warning("Cannot fetch description for '%s'", param)
93+
except KeyError: # either fetching failed or user added no description
9594
return ""
9695
else:
9796
if d.replace("-", "_") == param:
@@ -192,6 +191,8 @@ def matches_annotation(value, annotation) -> bool:
192191

193192
# generics, ex. list, tuple
194193
origin = get_origin(annotation)
194+
if origin is Literal:
195+
return value in get_args(annotation)
195196
if origin:
196197
if not isinstance(value, origin):
197198
return False

mininterface/_lib/dataclass_creation.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ def coerce_type_to_annotation(value, annotation):
7474
}
7575

7676
# For nested dataclass or BaseModel etc.
77-
return value
77+
try: # ex. `Path(value)`
78+
return annotation(value)
79+
except Exception:
80+
return value
7881

7982

8083
def _get_wrong_field(

mininterface/tag/flag.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def _assure_blank_or_bool(args):
105105

106106
_custom_registry = ConstructorRegistry()
107107

108-
Blank = Annotated[T | None, None]
108+
Blank = Annotated[T | bool | None, None]
109109
"""
110110
This marker specifies:
111111
@@ -147,7 +147,7 @@ class Env:
147147
$ program.py --test 5 # 5
148148
```
149149
150-
The default blank value might be specified by a `Literal` in the `Annotated` statement.
150+
The default blank value might be specified by a `Literal` present in the `Annotated` statement.
151151
152152
```python
153153
@dataclass
@@ -188,14 +188,15 @@ class Env:
188188
189189
190190
!!! Warning
191-
Experimental.
191+
Experimental. It adds `bool` as a valid type too.
192192
193193
??? Discussion
194194
The design is working but syntax `Annotated[str, Blank(True)]` might be a cleaner design. Do you have an opinion? Let us know.
195195
"""
196196

197197
# NOTE untested
198198
# NOTE Should we move rather to mininterface.cli?
199+
# NOTE Now it adds `bool` too. Otherwise we could not set the value to True.
199200

200201
# NOTE Python 3.13 would allow
201202
# type Blank[T, U = None] = Optional[T]
@@ -211,7 +212,7 @@ class Env:
211212

212213
class _Blank(_Marker):
213214
def __getitem__(self, key):
214-
return Annotated[(key | None, self)]
215+
return Annotated[(key | bool | None, self)]
215216

216217
def __init__(self, description: str):
217218
self.description = description
@@ -256,12 +257,14 @@ def _(
256257
if len(types) == 1:
257258
metavar = getattr(type_, "__name__", repr(type_))
258259
else:
259-
metavar = "|".join(getattr(s, "__name__", repr(s)) for s in types if s is not NoneType)
260+
# NOTE we now suppress bool even if user `Blank[str|bool]` explicitely set it
261+
metavar = "|".join(getattr(s, "__name__", repr(s)) for s in types if s not in (NoneType, bool))
260262

261263
def instance_from_str(args):
262264
if not args:
263265
return default_val
264266
val = args[0]
267+
# NOTE bool is now always in types
265268
if bool in types:
266269
if val == "True":
267270
return True

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
44

55
[tool.poetry]
66
name = "mininterface"
7-
version = "1.1.0"
7+
version = "1.1.1"
88
description = "A minimal access to GUI, TUI, CLI and config"
99
authors = ["Edvard Rejthar <[email protected]>"]
1010
license = "LGPL-3.0-or-later"

tests/test_flag.py

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class Env:
1212
t2: Blank[int] = None
1313
t3: Annotated[Blank[int], Literal[3]] = None
1414
t4: Annotated[Blank[int | bool], Literal[3]] = 2
15+
t5: Blank[int | Literal["foo"]] = None
1516

1617

1718
class TestFlag(TestAbstract):
@@ -20,10 +21,11 @@ def test_flag(self):
2021
(
2122
{
2223
"": {
23-
"t1": Tag(val=MISSING, description="", annotation=int | None, label="t1"),
24-
"t2": Tag(val=None, description="", annotation=int | None, label="t2"),
25-
"t3": Tag(val=None, description="", annotation=int | None, label="t3"),
24+
"t1": Tag(val=MISSING, description="", annotation=int | bool | None, label="t1"),
25+
"t2": Tag(val=None, description="", annotation=int | bool | None, label="t2"),
26+
"t3": Tag(val=None, description="", annotation=int | bool | None, label="t3"),
2627
"t4": Tag(val=2, description="", annotation=int | bool | None, label="t4"),
28+
"t5": Tag(val=None, description="", annotation=int | bool | Literal["foo"] | None, label="t5"),
2729
}
2830
},
2931
{"": {"t1": None}},
@@ -34,20 +36,40 @@ def test_flag(self):
3436
def r(*args):
3537
return asdict(runm(Env, args=args).env)
3638

37-
self.assertDictEqual({"t1": 1, "t2": None, "t3": None, "t4": 2}, r("--t1", "1"))
38-
self.assertDictEqual({"t1": 1, "t2": 3, "t3": None, "t4": 2}, r("--t1", "1", "--t2", "3"))
39-
self.assertDictEqual({"t1": 1, "t2": True, "t3": 3, "t4": 3}, r("--t1", "1", "--t2", "--t3", "--t4"))
40-
self.assertDictEqual({"t1": 10, "t2": None, "t3": None, "t4": False}, r("--t1", "10", "--t4", "False"))
41-
self.assertDictEqual({"t1": 10, "t2": None, "t3": None, "t4": True}, r("--t1", "10", "--t4", "True"))
42-
self.assertDictEqual({"t1": 10, "t2": None, "t3": None, "t4": 44}, r("--t1", "10", "--t4", "44"))
39+
self.assertDictEqual({"t1": 1, "t2": None, "t3": None, "t4": 2, "t5": None}, r("--t1", "1"))
40+
self.assertDictEqual({"t1": 1, "t2": 3, "t3": None, "t4": 2, "t5": None}, r("--t1", "1", "--t2", "3"))
41+
self.assertDictEqual(
42+
{"t1": 1, "t2": True, "t3": 3, "t4": 3, "t5": True}, r("--t1", "1", "--t2", "--t3", "--t4" ,"--t5")
43+
)
44+
self.assertDictEqual(
45+
{"t1": 10, "t2": None, "t3": None, "t4": False, "t5": None}, r("--t1", "10", "--t4", "False")
46+
)
47+
self.assertDictEqual(
48+
{"t1": 10, "t2": None, "t3": None, "t4": True, "t5": 100}, r("--t1", "10", "--t5", "100", "--t4", "True")
49+
)
50+
self.assertDictEqual({"t1": 10, "t2": None, "t3": None, "t4": 44, "t5": None}, r("--t1", "10", "--t4", "44"))
4351

4452

53+
# NOTE this should work too, Literals now canot be constructed
54+
# self.assertDictEqual(
55+
# {"t1": 10, "t2": None, "t3": None, "t4": False, "t5": "foo"}, r("--t1", "10", "--t4", "False", "--t5", "foo")
56+
# )
57+
58+
with self.assertStderr(contains="Error parsing --t5"), self.assertRaises(SystemExit):
59+
# NOTE this might rather raise a wrong field dialog
60+
r("--t1", "10", "--t5", "invalid")
61+
4562
with (
46-
self.assertOutputs(contains=["--t1 [int] (required)",
47-
"--t2 [int] (default: 'None / or if left blank: True') ",
48-
"--t3 [int] (default: 'None / or if left blank: 3')",
49-
"--t4 [int|bool] (default: '2 / or if left blank: 3')"
50-
]),
51-
self.assertRaises(SystemExit),
52-
):
63+
self.assertOutputs(
64+
contains=[
65+
"--t1 [int] (required)",
66+
"--t2 [int] (default: 'None / or if left blank: True') ",
67+
"--t3 [int] (default: 'None / or if left blank: 3')",
68+
"--t4 [int] (default: '2 / or if left blank: 3')",
69+
"--t5 [int|Literal] (default: 'None / or if left blank: True')",
70+
]
71+
),
72+
# NOTE t5 should display the mere Literal value
73+
self.assertRaises(SystemExit),
74+
):
5375
runm(Env, args=["--help"])

tests/test_run.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from tempfile import NamedTemporaryFile
12
from unittest import skipUnless
23
from mininterface import Mininterface
34
from mininterface._lib.config_file import _merge_settings
@@ -15,6 +16,7 @@
1516
MissingPositional,
1617
MissingPositionalScalar,
1718
MissingUnderscore,
19+
ParametrizedGeneric,
1820
SimpleEnv,
1921
)
2022
from dumb_settings import (
@@ -220,6 +222,17 @@ def test_complex_config(self):
220222
)
221223
self.assertEqual(pattern, runm(ComplexEnv, config_file="tests/complex.yaml").env)
222224

225+
def test_object_config(self):
226+
with NamedTemporaryFile(delete=False, mode="w", encoding="utf-8") as tmp:
227+
tmp.write("""paths:
228+
- /tmp
229+
- /var/log/syslog
230+
""")
231+
tmp.flush()
232+
env = runm(ParametrizedGeneric, config_file=tmp.name).env
233+
self.assertEqual(ParametrizedGeneric(paths=[Path('/tmp'), Path('/var/log/syslog')]), env)
234+
235+
223236
def test_run_annotated(self):
224237
m = run(FlagConversionOff[OmitArgPrefixes[SimpleEnv]])
225238
self.assertEqual(4, m.env.important_number)

0 commit comments

Comments
 (0)