Skip to content

Commit df64a7e

Browse files
committed
feat: SelectTag None value
1 parent 3b8f227 commit df64a7e

File tree

15 files changed

+273
-109
lines changed

15 files changed

+273
-109
lines changed

docs/Changelog.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
* feat: add utility for generating unit tests
88
* feat: [annotated types][mininterface.tag.tag.ValidationCallback] for collections
99
* feat: [`Blank`][mininterface.tag.flag.Blank] flag marker default value
10+
* feat: None value in [SelectTag][mininterface.tag.SelectTag]
11+
* enh: custom validation is done at dataclass built, not later on the form call
1012
* fix: SelectTag preset value
1113
* fix: ArgumentParser parameters (like allow_abbrev)
12-
* enh: custom validation is done at dataclass built, not later on the form call
1314

1415
## 1.0.4 (2025-08-18)
1516
* enh: better argparse support (const support, store_false matching, subcommands)

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ The config variables needed by your program are kept in cozy dataclasses. Write
106106
Install with a single command from [PyPi](https://pypi.org/project/mininterface/).
107107

108108
```bash
109-
pip install mininterface[all]~=1.1 # GPLv3 and compatible
109+
pip install "mininterface[all]<2" # GPLv3 and compatible
110110
```
111111

112112
## Bundles

mininterface/_lib/argparse_support.py

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,20 @@
1-
from argparse import (
2-
SUPPRESS,
3-
_AppendAction,
4-
_AppendConstAction,
5-
_CountAction,
6-
_HelpAction,
7-
_StoreConstAction,
8-
_StoreFalseAction,
9-
_StoreTrueAction,
10-
_SubParsersAction,
11-
_VersionAction,
12-
Action,
13-
ArgumentParser,
14-
)
1+
import re
2+
import sys
3+
from argparse import (SUPPRESS, Action, ArgumentParser, _AppendAction,
4+
_AppendConstAction, _CountAction, _HelpAction,
5+
_StoreConstAction, _StoreFalseAction, _StoreTrueAction,
6+
_SubParsersAction, _VersionAction)
157
from collections import defaultdict
168
from dataclasses import MISSING, Field, dataclass, field, make_dataclass
179
from functools import cached_property
18-
import re
19-
import sys
2010
from typing import Annotated, Callable, Optional
2111
from warnings import warn
2212

23-
from tyro.conf import OmitSubcommandPrefixes
24-
2513
from ..tag.alias import Options
26-
2714
from .form_dict import DataClass
2815

29-
3016
try:
31-
from tyro.conf import Positional
17+
from tyro.conf import DisallowNone, OmitSubcommandPrefixes, Positional
3218
except ImportError:
3319
from ..exceptions import DependencyRequired
3420

@@ -277,4 +263,4 @@ def _make_dataclass_from_actions(
277263
separator = ": " if needs_colon else ("\n" if trimmed else "")
278264
dc.__doc__ = trimmed + separator + (description or "")
279265

280-
return dc
266+
return DisallowNone[dc]

mininterface/_lib/auxiliary.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,17 @@ def allows_none(annotation) -> bool:
343343
return any(arg is type(None) for arg in args)
344344
return False
345345

346+
def strip_none(annotation):
347+
"""Return the same annotation but without NoneType inside a Union/Optional."""
348+
origin = get_origin(annotation)
349+
350+
if origin is Union or origin is UnionType:
351+
args = tuple(arg for arg in get_args(annotation) if arg is not type(None))
352+
if len(args) == 1:
353+
return args[0]
354+
return Union[args] # nebo origin[args], aby se zachoval typ
355+
356+
return annotation
346357

347358
@lru_cache(maxsize=1024*10)
348359
def _get_origin(tp: Any):

mininterface/_lib/cli_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ def _fetch_currently_failed(requireds) -> TagDict:
345345
# Here, we pick the field unknown to the CLI parser too.
346346
# As whole subparser was unknown here, we safely consider all its fields wrong fields.
347347
if fname:
348-
missing_req[fname_raw] = get_or_create_parent_dict(requireds, fname)
348+
get_or_create_parent_dict(missing_req, fname, True)[fname_raw] = get_or_create_parent_dict(requireds, fname)
349349
else:
350350
# This is the default subparser, without a field name:
351351
# ex. `run([List, Run])`

mininterface/_lib/dataclass_creation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ def _process_field(fname, ftype, disk_value, wf, m, default_value=MISSING):
232232
# the subcommand has been already chosen by CLI parser
233233
if isinstance(disk_value, ChosenSubcommand): # `(class Message | class Console)`
234234
for _subcomm in get_args(_unwrap_annotated(ftype)):
235-
if disk_value.name == getattr(_subcomm, "__name__", "").casefold():
235+
if disk_value.name == to_kebab_case(_subcomm.__name__):
236236
ftype = _subcomm # `class Message` only
237237
disk_value = disk_value.subdict
238238
break

mininterface/_lib/tyro_patches.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -329,11 +329,13 @@ def _(self: TyroArgumentParser, *args, **kwargs):
329329
if cf.add_version:
330330
self.add_argument(
331331
default_prefix * 2 + "version",
332-
# I would use this native version, but it insert a blank line
333-
# action="version",
334-
# version=add_version,
335-
action="store_const",
336-
const=cf.version,
332+
# NOTE We use the native version, but it inserts a blank line
333+
action="version",
334+
version=cf.version,
335+
# Our custom version works bad with subcommands, we have to first resolve subcommands,
336+
# than it comes to the version
337+
# action="store_const",
338+
# const=cf.version,
337339
help=f"show program's version number ({cf.version}) and exit",
338340
)
339341

@@ -360,11 +362,12 @@ def _(self: TyroArgumentParser, args=None, namespace=None):
360362
logging.basicConfig(level=cf.default_verbosity, format="%(message)s", force=True)
361363
delattr(namespace, "verbose")
362364

363-
if cf.add_verbose and hasattr(namespace, "version"):
364-
if namespace.version:
365-
print(namespace.version)
366-
raise SystemExit(0)
367-
delattr(namespace, "version")
365+
# This code is now not used, see `custom_init`
366+
# if cf.add_verbose and hasattr(namespace, "version"):
367+
# if namespace.version:
368+
# print(namespace.version)
369+
# raise SystemExit(0)
370+
# delattr(namespace, "version")
368371

369372
if cf.add_quiet and hasattr(namespace, "quiet"):
370373
if namespace.quiet:

mininterface/tag/select_tag.py

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
from typing import Iterable, Literal, Optional, Type, get_args, get_origin
44
from warnings import warn
55

6+
7+
from .._lib.auxiliary import allows_none, strip_none
8+
69
from .tag import Tag, TagValue
710

811
OptionsReturnType = list[tuple[str, TagValue, bool, tuple[str]]]
912
""" label, choice value, is-tip, tupled-label """
1013
OptionLabel = str
11-
RichOptionLabel = OptionLabel | tuple[OptionLabel]
14+
RichOptionLabel = OptionLabel | tuple[OptionLabel, ...]
1215
OptionsType = (
1316
list[TagValue]
1417
| tuple[TagValue, ...]
@@ -171,8 +174,9 @@ def __post_init__(self):
171174

172175
# Determine options from annotation
173176
if not self.options:
174-
if get_origin(self.annotation) is Literal:
175-
self.options = get_args(self.annotation)
177+
pt = self._get_possible_types()
178+
if len(pt) == 1 and pt[0][0] is Literal:
179+
self.options = pt[0][1]
176180
elif self.annotation is not Enum:
177181
# We take whatever is in the annotation, hoping there are some values.
178182
# However, the symbol Enum itself (injected in Tag.__post_init__) will not bring us any benefit.
@@ -181,10 +185,14 @@ def __post_init__(self):
181185
#
182186
# @dataclass
183187
# class dc:
184-
# field: Color -> Tag(annotation=enum.Color)
185-
self.options = self.annotation
188+
# field: Color | None -> Tag(annotation=enum.Color)
189+
#
190+
# Why strip_none? The `| None` type will be readded later, we now clean it to pure Enum
191+
# for detection purposes.
192+
self.options = strip_none(self.annotation)
186193

187194
# Disabling annotation is not a nice workaround, but it is needed for the `super().update` to be processed
195+
orig_ann = self.annotation
188196
self.annotation = type(self)
189197
reset_name = not self.label
190198
super().__post_init__()
@@ -207,6 +215,24 @@ def __post_init__(self):
207215
self.options = self.val
208216
self.val = None
209217

218+
if (self.options and orig_ann is not None and allows_none(orig_ann)) or (
219+
not self.options and orig_ann is None
220+
):
221+
# None is among the options, like
222+
# * `Options("one", None, "two")`
223+
# * `Optional[Literal["one", "two"]]`
224+
# * `SelectTag()`
225+
# But ignore the case when the annotation is whole None with some options: `SelectTag(options=...)`.
226+
opt = self._build_options()
227+
if None not in opt.values():
228+
for char in ("∅", "-", "None"):
229+
if char not in opt:
230+
# Put the None option to the first place
231+
self.options = {char: None, **{k: v for k, v in opt.items()}}
232+
break
233+
else:
234+
raise ValueError("Cannot demark a None option.")
235+
210236
def __hash__(self): # every Tag child must have its own hash method to be used in Annotated
211237
return super().__hash__()
212238

@@ -231,7 +257,7 @@ def _get_selected_keys(self):
231257
return [k for k, val, *_ in self._get_options() if val in self.val]
232258

233259
@classmethod
234-
def _repr_val(cls, v):
260+
def _repr_val(cls, v) -> str:
235261
if cls._is_a_callable_val(v):
236262
return v.__name__
237263
if isinstance(v, Tag):
@@ -240,23 +266,28 @@ def _repr_val(cls, v):
240266
return str(v.value)
241267
return str(v)
242268

243-
def _build_options(self) -> dict[OptionLabel, TagValue]:
244-
"""Whereas self.options might have different format, this returns a canonic dict."""
269+
def _build_options(self) -> dict[RichOptionLabel, TagValue]:
270+
"""Whereas self.options might have different format,
271+
this returns a canonic dict.
272+
The keys are all strs or all tuples.
273+
"""
245274

246275
if self.options is None:
247276
return {}
248277
if isinstance(self.options, dict):
249-
# assure the key is a str or their tuple
250-
return {
251-
(tuple(str(k) for k in key) if isinstance(key, tuple) else str(key)): self._get_tag_val(v)
252-
for key, v in self.options.items()
253-
}
278+
# assure the keys are either strs or tuple of strs
279+
keys = self.options.keys()
280+
if any(isinstance(k, tuple) for k in keys):
281+
keys = ((tuple(str(k) for k in key) if isinstance(key, tuple) else (str(key),)) for key in keys)
282+
else:
283+
keys = (str(key) for key in keys)
284+
return {key: self._get_tag_val(v) for key, v in zip(keys, self.options.values())}
254285
if isinstance(self.options, Iterable):
255286
return {self._repr_val(v): self._get_tag_val(v) for v in self.options}
256287
if isinstance(self.options, type) and issubclass(self.options, Enum): # Enum type, ex: options=ColorEnum
257288
return {str(v.value): self._get_tag_val(v) for v in list(self.options)}
258289

259-
warn(f"Not implemented options: {self.options}")
290+
raise ValueError(f"Not implemented options: {self.options}")
260291

261292
def _get_options(self, delim=" - ") -> OptionsReturnType:
262293
"""Return a list of tuples (label, choice value, is tip, tupled-label).
@@ -281,10 +312,12 @@ def _get_options(self, delim=" - ") -> OptionsReturnType:
281312
options = self._build_options()
282313

283314
keys = options.keys()
284-
labels: Iterable[tuple[str, tuple[str]]]
315+
labels: Iterable[tuple[str, tuple[str, ...]]]
285316
""" First is the str-label, second is guaranteed to be a tupled label"""
286317

287318
if len(options) and isinstance(next(iter(options)), tuple):
319+
# As options come from the _build_options, we are sure that if the first is a tuple,
320+
# the others are tuples too.
288321
labels = self._span_to_lengths(keys, delim)
289322
else:
290323
labels = ((key, (key,)) for key in keys)

mininterface/tag/tag.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,9 @@ def _is_subclass(self, class_type: type | tuple[type]):
641641
return True
642642
elif subclass_matches_annotation(class_type, subtype): # tuple
643643
return True
644+
elif origin is None and subclass_matches_annotation(subtype, class_type):
645+
# ex. `ColorEnum | None` _is_subclass(Enum)
646+
return True
644647
return False
645648

646649
def _get_possible_types(self) -> list[tuple[type | None, type | list[type]]]:
@@ -666,11 +669,8 @@ def _(annot):
666669
return [_(subt) for subt in subtype]
667670
if origin is tuple:
668671
return origin, list(subtype)
669-
# elif origin is Literal:
670-
# ss=set(type(t) for t in subtype)
671-
# if len(ss) == 1:
672-
# return origin, ss.pop()
673-
# warn(f"This parametrized Literal generic not implemented: {annot}")
672+
elif origin is Literal:
673+
return origin, subtype
674674
elif len(subtype) == 1:
675675
return origin, subtype[0]
676676
else:

mininterface/tag/tag_factory.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ def _get_tag_type(tag: Tag) -> Type[Tag]:
3232
"""Return the most specific Tag child that a tag value can be expressed with.
3333
Ex. Return PathTag for a Tag having a Path as a value.
3434
"""
35-
if get_origin(tag.annotation) is Literal:
35+
pt = tag._get_possible_types()
36+
if len(pt) == 1 and pt[0][0] is Literal:
3637
return SelectTag
3738
if tag._is_subclass(Path):
3839
return PathTag

0 commit comments

Comments
 (0)