Skip to content

Commit 00f6f45

Browse files
committed
enh: argparse subcommands, Path|None
1 parent 785c04a commit 00f6f45

19 files changed

+389
-105
lines changed

docs/Argparse-caveats.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
Be aware that in contrast to the argparse:
22

3-
* We create default values. This does make sense for most values but might pose a confusion for ex. `parser.add_argument("--path", type=Path)` which becomes `Path('.')`, not `None`.
43
* When storing multiple `store_const` action to the same `dest`, order does not matter.
54
* When using positional arguments before the subcommands, the order changes
65

docs/Changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* enh: better argparse support (const support, store_false matching, subcommands)
55
* feat: Added time constraint to the DatetimeTag #28
66
* feat: implement unified toggle widget shortcut system across interfaces #29
7+
* feat: correctly handles `Path|None` and `datetime|None` syntax
78

89
## 1.0.3 (2025-06-18)
910
* enh: Tk better file dialog

docs/index.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,11 @@ from pathlib import Path
242242
from mininterface import run
243243

244244
parser = ArgumentParser()
245-
parser.add_argument("input_file", type=Path, help="Path to the input file.")
246-
parser.add_argument("--time", type=time, help="Given time")
247245
subparsers = parser.add_subparsers(dest="command", required=True)
248246
sub1 = subparsers.add_parser("build", help="Build something.")
249247
sub1.add_argument("--optimize", action="store_true", help="Enable optimizations.")
248+
parser.add_argument("input_file", type=Path, help="Path to the input file.")
249+
parser.add_argument("--time", type=time, help="Given time")
250250

251251
# Old version
252252
# env = parser.parse_args()

mininterface/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,11 @@ class Env:
212212
if superform_args is not None:
213213
# Run Superform as multiple subcommands exist and we have to decide which one to run.
214214
m = get_interface(interface, title, settings, None)
215-
ChooseSubcommandOverview(env_or_list, m, args=superform_args, ask_for_missing=ask_for_missing)
216-
return m # m with added `m.env`
215+
try:
216+
ChooseSubcommandOverview(env_or_list, m, args=superform_args, ask_for_missing=ask_for_missing)
217+
return m # m with added `m.env`
218+
except Exception as e: # some nested subcommands would fail in overview
219+
env_or_list = m.select({cl.__name__: cl for cl in env_or_list if cl is not SubcommandPlaceholder})
217220

218221
# B) A single Env object, or a list of such objects (with one is being selected via args)
219222
# C) No Env object

mininterface/_lib/argparse_support.py

Lines changed: 82 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,28 @@
88
_StoreFalseAction,
99
_StoreTrueAction,
1010
_SubParsersAction,
11+
_VersionAction,
1112
Action,
1213
ArgumentParser,
1314
)
1415
from collections import defaultdict
15-
from dataclasses import Field, dataclass, field, make_dataclass
16+
from dataclasses import MISSING, Field, dataclass, field, make_dataclass
1617
from functools import cached_property
1718
import re
18-
from typing import Callable
19+
import sys
20+
from typing import Annotated, Callable, Literal, Optional
1921
from warnings import warn
2022

23+
from tyro.conf import OmitSubcommandPrefixes
24+
25+
from .. import Options
26+
2127
from .form_dict import DataClass
22-
from ..tag import Tag
2328

2429

2530
try:
26-
from tyro.conf import Positional
31+
from tyro.constructors import PrimitiveConstructorSpec
32+
from tyro.conf import Positional, arg
2733
except ImportError:
2834
from ..exceptions import DependencyRequired
2935

@@ -75,14 +81,8 @@ def has_property(self):
7581

7682

7783
def parser_to_dataclass(parser: ArgumentParser, name: str = "Args") -> DataClass | list[DataClass]:
78-
"""Note that in contrast to the argparse, we create default values.
79-
When an optional flag is not used, argparse put None, we have a default value.
80-
81-
This does make sense for most values and should not pose problems for truthy-values.
82-
Ex. checking `if namespace.my_int` still returns False for both argparse-None and our-0.
83-
84-
Be aware that for Path this might pose a big difference:
85-
parser.add_argument("--path", type=Path) -> becomes Path('.'), not None!
84+
"""
85+
Note: Ex. parser.add_argument("--time", type=time) -> does work at all in argparse, here it works.
8686
"""
8787
subparsers: list[_SubParsersAction] = []
8888

@@ -144,6 +144,22 @@ def _make_dataclass_from_actions(
144144
match action:
145145
case _HelpAction():
146146
continue
147+
case _VersionAction():
148+
# NOTE Should be probably implemented in tyro. Here that way:
149+
# run(add_version="1.2.3")
150+
# run(add_version_package="intelmq") -> get pip version
151+
arg_type = Annotated[
152+
None,
153+
PrimitiveConstructorSpec(
154+
nargs="*",
155+
metavar="",
156+
instance_from_str=lambda _, v=action.version: print(v) or sys.exit(0),
157+
is_instance=lambda _: True,
158+
# NOTE tyro might not diplay anything here,
159+
# but it displays `(default: )`
160+
str_from_instance=(lambda _, v=action.version: [str(v)]),
161+
),
162+
]
147163
case _SubParsersAction():
148164
# Note that there is only one _SubParsersAction in argparse
149165
# but to be sure, we allow multiple of them
@@ -156,6 +172,14 @@ def _make_dataclass_from_actions(
156172
subparser.description,
157173
)
158174
subparser_fields.append((subname, sub_dc)) # required, no default
175+
176+
from functools import reduce
177+
178+
union_type = reduce(lambda a, b: a | b, [aa[1] for aa in subparser_fields])
179+
180+
result = OmitSubcommandPrefixes[Positional[union_type]]
181+
pos_fields.append(("_subparsers", result))
182+
subparser_fields.clear()
159183
continue
160184
case _AppendAction():
161185
arg_type = list[action.type or str]
@@ -185,36 +209,69 @@ def _make_dataclass_from_actions(
185209
case _CountAction():
186210
arg_type = int
187211
case _:
188-
arg_type = action.type or str
212+
if action.type:
213+
arg_type = action.type
214+
elif action.default:
215+
arg_type = type(action.default)
216+
else:
217+
arg_type = str
218+
219+
metavar = None
220+
if "default" not in opt and "default_factory" not in opt:
221+
if action.choices:
222+
# With the drop of Python 3.10, use:
223+
# arg_type = Literal[*action.choices]
224+
arg_type = Annotated[arg_type, Options(*action.choices)]
225+
226+
if not action.option_strings and action.default is None and action.nargs != "?":
227+
opt["default"] = MISSING
228+
else:
189229
if action.default is None:
190-
# parser.add_argument("--path", type=Path) -> becomes Path('.'), not None!
230+
# parser.add_argument("--path", type=Path) -> becomes None, not Path('.').
191231
# By default, argparse put None if not used in the CLI.
192232
# Which makes tyro output the warning: annotated with type `<class 'str'>`, but the default value `None`
193233
# We either make None an option by `arg_type |= None`
194234
# or else we default the value.
195-
# This creates a slightly different behaviour, however, the behaviour is slightly different
196-
# nevertheless.
197-
# Ex. parser.add_argument("--time", type=time) -> does work poorly in argparse.
198-
action.default = Tag(annotation=arg_type)._make_default_value()
199-
if "default" not in opt and "default_factory" not in opt:
200-
opt["default"] = action.default if action.default != SUPPRESS else None
235+
if arg_type is not None:
236+
arg_type |= None
237+
opt["default"] = action.default if action.default != SUPPRESS else None
201238

202239
# build a dataclass field, either optional, or positional
203-
met = {"metadata": {"help": action.help}}
240+
opt["metadata"] = {"help": action.help}
204241
if action.option_strings:
205242
# normal_fields.append((action.dest, arg_type, field(**opt, **met)))
206-
normal_fields.append((af.name, arg_type, field(**opt, **met)))
243+
# Annotated[arg_type, arg(metavar=metavar)]
244+
normal_fields.append((af.name, arg_type, field(**opt)))
207245

208246
# Generate back-compatible property if dest != field_name
209247
if af.name != action.dest and not af.has_property:
210248
af.add(lambda self, field_name=af.name: getattr(self, field_name))
211-
212249
else:
213-
pos_fields.append((action.dest, Positional[arg_type], field(**met)))
250+
pos_fields.append((action.dest, Positional[arg_type], field(**opt)))
251+
252+
# Subparser can have the same field name as the parser. We use the latter.
253+
# Ex:
254+
# parser.add_argument('--level', type=int, default=1)
255+
# subparsers = parser.add_subparsers(dest='command')
256+
# run_parser = subparsers.add_parser('run')
257+
# run_parser.add_argument('--level', type=int, default=5)
258+
uniq_fields = []
259+
seen = set()
260+
# for f in reversed(subparser_fields + pos_fields + normal_fields):
261+
for f in reversed(pos_fields + normal_fields):
262+
if f[0] not in seen:
263+
seen.add(f[0])
264+
uniq_fields.append(f)
265+
266+
# if subparser_fields:
267+
# from functools import reduce
268+
# union_type = reduce(lambda a, b: a | b, [aa[1] for aa in subparser_fields])
269+
# result = OmitSubcommandPrefixes[Positional[union_type]]
270+
# uniq_fields.append(("_subparsers", result ))
214271

215272
dc = make_dataclass(
216273
name,
217-
subparser_fields + pos_fields + normal_fields,
274+
reversed(uniq_fields),
218275
namespace={k: prop.generate_property() for k, prop in properties.items()},
219276
)
220277
if helptext or description:

mininterface/_lib/auxiliary.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,16 @@ def validate_annotated_type(meta, value) -> bool:
226226
else:
227227
raise NotImplementedError(f"Unknown predicated {meta}")
228228
return True
229+
230+
def allows_none(annotation) -> bool:
231+
"""True, if annotation allows None: `int | None`, `Optional[int]`, `Union[int,None]`."""
232+
if annotation is None:
233+
return True
234+
origin = get_origin(annotation)
235+
args = get_args(annotation)
236+
237+
# if NoneType in get_args(self.annotation):
238+
239+
if origin is Union or origin is UnionType:
240+
return any(arg is type(None) for arg in args)
241+
return False

mininterface/_lib/cli_parser.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
""" Used to intercept an error message from tyro """
7171
reraise: Optional[Callable] = None
7272
""" Reraise the intercepted tyro error message """
73+
subparser_used = None
7374

7475

7576
class Patches:
@@ -82,9 +83,10 @@ def custom_error(self: TyroArgumentParser, message: str):
8283
the error message function. Then, we reconstruct the missing options.
8384
Thanks to this we will be able to invoke a UI dialog with the missing options only.
8485
"""
85-
global eavesdrop, reraise
86+
global eavesdrop, reraise, subparser_used
8687
if not message.startswith("the following arguments are required:"):
8788
return super(TyroArgumentParser, self).error(message)
89+
subparser_used = self
8890
eavesdrop = message
8991

9092
def reraise():
@@ -168,20 +170,17 @@ def parse_cli(
168170
patches.append(patch.object(TyroArgumentParser, "error", Patches.custom_error))
169171
if add_verbose: # Mock parser to add verbosity
170172
# The verbose flag is added only if neither the env_class nor any of the subcommands have the verbose flag already
171-
try:
172-
if all("verbose" not in cl.__annotations__ for cl in env_classes):
173-
patches.extend(
174-
(
175-
patch.object(TyroArgumentParser, "__init__", Patches.custom_init),
176-
patch.object(
177-
TyroArgumentParser,
178-
"parse_known_args",
179-
Patches.custom_parse_known_args,
180-
),
181-
)
173+
if all("verbose" not in cl.__annotations__ for cl in env_classes):
174+
patches.extend(
175+
(
176+
patch.object(TyroArgumentParser, "__init__", Patches.custom_init),
177+
patch.object(
178+
TyroArgumentParser,
179+
"parse_known_args",
180+
Patches.custom_parse_known_args,
181+
),
182182
)
183-
except Exception as e: # TODO
184-
warnings.warn("Cannot add verbose flag")
183+
)
185184

186185
# Run the parser, with the mocks
187186
try:
@@ -204,7 +203,7 @@ def parse_cli(
204203
# Some required arguments are missing. Determine which.
205204
wf: dict[str, Tag] = {}
206205

207-
# There are multiple dataclasses, query which is chosen
206+
# # There are multiple dataclasses, query which is chosen
208207
if len(env_classes) == 1:
209208
env = env_classes[0]
210209
parser: ArgumentParser = get_parser(type_form, **kwargs)
@@ -219,6 +218,8 @@ def parse_cli(
219218
subargs = args[1:]
220219
if not env:
221220
raise NotImplementedError("This case of nested dataclasses is not implemented. Raise an issue please.")
221+
# NOTE For subparsers, I might directly use the subparser that failed.
222+
# parser = subparser_used
222223

223224
# Determine missing argument of the given dataclass
224225
positionals = (p for p in parser._actions if p.default != argparse.SUPPRESS)
@@ -228,6 +229,7 @@ def parse_cli(
228229
if "--" not in arg:
229230
# Positional
230231
# Ex: `The following arguments are required: PATH, INT, STR`
232+
# Ex: `The following arguments are required: {get,pop,send}`
231233
argument = next(positionals)
232234
register_wrong_field(env, kwargs, wf, argument, exception, eavesdrop)
233235
else:
@@ -296,6 +298,7 @@ def argument_to_field_name(env_class: EnvClass, argument: Action):
296298
# Why using mro? Find the field in the dataclass and all of its parents.
297299
# Useful when handling subcommands, they share a common field.
298300
field_name = argument.dest
301+
field_name = field_name.replace(" (positional)", "")
299302
if not any(field_name in ann for ann in yield_annotations(env_class)):
300303
field_name = field_name.replace("-", "_")
301304
if not any(field_name in ann for ann in yield_annotations(env_class)):

mininterface/_tk_interface/adaptor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import sys
2-
from tkinter import LEFT, Button, Frame, Label, TclError, Text, Tk, Widget
2+
from tkinter import LEFT, Button, Frame, Label, StringVar, TclError, Text, Tk, Widget
33
from typing import TYPE_CHECKING, Any, Callable
44

55
try:
@@ -125,7 +125,7 @@ def run_dialog(self, form: TagDict, title: str = "", submit: bool | str = True)
125125

126126
# status bar would look like this
127127
# status_var = StringVar()
128-
# status_var.set("F1 – help")
128+
# status_var.set("|".join(self.shortcuts))
129129
# status_label = Label(self.frame, textvariable=status_var, relief="sunken", anchor="w", padx=5)
130130
# status_label.pack(side="bottom", fill="x", pady=(20, 0))
131131

mininterface/tag/datetime_tag.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from dataclasses import dataclass
2-
from datetime import date, datetime, time, date as date_
2+
from datetime import date, datetime, time, date as date_, time as time_
33
from typing import Union
4+
5+
from .._lib.auxiliary import allows_none
6+
47
from .tag import Tag, TagValue, UiValue
58

69

@@ -69,12 +72,14 @@ class Env:
6972
full_precision: bool = False
7073
""" Include full time precison, seconds, microseconds. """
7174

72-
before: Union[date_, None] = None
75+
before: date_ | None = None
7376
""" The maximum allowed date/datetime value. """
7477

75-
after: Union[date_, None] = None
78+
after: date_ | None = None
7679
""" The minimum allowed date/datetime value. """
7780

81+
_caster: type[date_|time_|datetime] = date_
82+
7883
# NOTE calling DatetimeTag("2025-02") should convert str to date?
7984
def __post_init__(self):
8085
super().__post_init__()
@@ -122,12 +127,20 @@ def validate_date_range(tag: Tag) -> Union[bool, str]:
122127

123128
self._add_validation(validate_date_range)
124129

130+
self._caster = self.annotation
131+
if allows_none(self.annotation):
132+
for _, subtype in self._get_possible_types():
133+
self._caster = subtype
134+
break
135+
125136
def __hash__(
126137
self,
127138
): # every Tag child must have its own hash method to be used in Annotated
128139
return super().__hash__()
129140

130141
def _make_default_value(self):
142+
if allows_none(self.annotation):
143+
return None
131144
if self.annotation is datetime:
132145
return datetime.now()
133146
if self.annotation is date:
@@ -137,11 +150,12 @@ def _make_default_value(self):
137150
raise ValueError("Could not determine the default value for %s", self)
138151

139152
def update(self, ui_value: UiValue) -> bool:
153+
if ui_value == "" and allows_none(self.annotation):
154+
return True
140155
if isinstance(ui_value, str):
141156
try:
142-
ui_value = self.annotation.fromisoformat(ui_value)
143-
except ValueError:
144-
# allow annotations like `time | None`
145-
# Empty input will still have chance to be resolved further.
146-
pass
157+
ui_value = self._caster.fromisoformat(ui_value)
158+
except ValueError as e:
159+
self.set_error_text(str(e))
160+
return False
147161
return super().update(ui_value)

0 commit comments

Comments
 (0)