mininterface 1.0.2__tar.gz → 1.0.4__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 (65) hide show
  1. {mininterface-1.0.2 → mininterface-1.0.4}/PKG-INFO +5 -5
  2. {mininterface-1.0.2 → mininterface-1.0.4}/README.md +4 -4
  3. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/__init__.py +31 -21
  4. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/__main__.py +20 -8
  5. mininterface-1.0.4/mininterface/_lib/argparse_support.py +284 -0
  6. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_lib/auxiliary.py +27 -12
  7. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_lib/cli_parser.py +166 -130
  8. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_lib/form_dict.py +9 -9
  9. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_lib/redirectable.py +3 -2
  10. mininterface-1.0.4/mininterface/_lib/shortcuts.py +59 -0
  11. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_lib/showcase.py +6 -1
  12. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_lib/start.py +3 -2
  13. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_mininterface/__init__.py +83 -74
  14. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_mininterface/adaptor.py +40 -5
  15. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_mininterface/mixin.py +12 -12
  16. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_text_interface/__init__.py +21 -16
  17. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_text_interface/adaptor.py +30 -8
  18. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_textual_interface/__init__.py +3 -1
  19. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_textual_interface/adaptor.py +26 -14
  20. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_textual_interface/button_contents.py +3 -3
  21. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_textual_interface/facet.py +2 -0
  22. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_textual_interface/file_picker_input.py +2 -5
  23. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_textual_interface/form_contents.py +11 -7
  24. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_textual_interface/textual_app.py +1 -0
  25. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_textual_interface/widgets.py +16 -2
  26. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_tk_interface/__init__.py +8 -2
  27. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_tk_interface/adaptor.py +30 -25
  28. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_tk_interface/date_entry.py +41 -36
  29. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_tk_interface/external_fix.py +2 -6
  30. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_tk_interface/facet.py +1 -0
  31. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_tk_interface/redirect_text_tkinter.py +4 -4
  32. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_tk_interface/secret_entry.py +12 -13
  33. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_tk_interface/select_input.py +38 -29
  34. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_tk_interface/utils.py +123 -27
  35. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_web_interface/__init__.py +16 -10
  36. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_web_interface/app.py +1 -0
  37. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_web_interface/child_adaptor.py +1 -2
  38. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_web_interface/parent_adaptor.py +2 -7
  39. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/cli.py +9 -7
  40. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/exceptions.py +10 -8
  41. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/experimental.py +6 -4
  42. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/facet/__init__.py +8 -7
  43. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/interfaces.py +10 -4
  44. mininterface-1.0.4/mininterface/settings.py +126 -0
  45. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/tag/alias.py +2 -2
  46. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/tag/callback_tag.py +2 -1
  47. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/tag/datetime_tag.py +53 -8
  48. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/tag/flag.py +9 -5
  49. mininterface-1.0.4/mininterface/tag/internal.py +10 -0
  50. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/tag/path_tag.py +21 -2
  51. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/tag/select_tag.py +23 -11
  52. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/tag/tag.py +101 -74
  53. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/tag/tag_factory.py +14 -8
  54. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/tag/type_stubs.py +4 -2
  55. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/validators.py +27 -12
  56. {mininterface-1.0.2 → mininterface-1.0.4}/pyproject.toml +5 -1
  57. mininterface-1.0.2/mininterface/settings.py +0 -74
  58. mininterface-1.0.2/mininterface/tag/internal.py +0 -10
  59. {mininterface-1.0.2 → mininterface-1.0.4}/LICENSE +0 -0
  60. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_lib/__init__.py +0 -0
  61. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_text_interface/facet.py +0 -0
  62. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_textual_interface/secret_input.py +0 -0
  63. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/_textual_interface/style.tcss +0 -0
  64. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/tag/__init__.py +0 -0
  65. {mininterface-1.0.2 → mininterface-1.0.4}/mininterface/tag/secret_tag.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: mininterface
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: A minimal access to GUI, TUI, CLI and config
5
5
  License: LGPL-3.0-or-later
6
6
  Author: Edvard Rejthar
@@ -187,7 +187,7 @@ These projects have the code base reduced thanks to the mininterface:
187
187
  Take a look at the following example.
188
188
 
189
189
  1. We define any Env class.
190
- 2. Then, we initialize mininterface with [`run(Env)`][mininterface.run] – the missing fields will be prompter for
190
+ 2. Then, we initialize mininterface with [`run(Env)`][mininterface.run] – the missing fields will be prompted for
191
191
  3. Then, we use various dialog methods, like [`confirm`][mininterface.Mininterface.confirm], [`select`][mininterface.Mininterface.select] or [`form`][mininterface.Mininterface.form].
192
192
 
193
193
  Below, you find the screenshots how the program looks in various environments ([graphic](Interfaces.md#guiinterface-or-tkinterface-or-gui) interface, [web](Interfaces.md#webinterface-or-web) interface...).
@@ -280,11 +280,11 @@ from pathlib import Path
280
280
  from mininterface import run
281
281
 
282
282
  parser = ArgumentParser()
283
- parser.add_argument("input_file", type=Path, help="Path to the input file.")
284
- parser.add_argument("--time", type=time, help="Given time")
285
283
  subparsers = parser.add_subparsers(dest="command", required=True)
286
284
  sub1 = subparsers.add_parser("build", help="Build something.")
287
285
  sub1.add_argument("--optimize", action="store_true", help="Enable optimizations.")
286
+ parser.add_argument("input_file", type=Path, help="Path to the input file.")
287
+ parser.add_argument("--time", type=time, help="Given time")
288
288
 
289
289
  # Old version
290
290
  # env = parser.parse_args()
@@ -335,4 +335,4 @@ print(m.env.time) # -> 14:21
335
335
  If you're sure enough to start using *Mininterface*, convert the argparse into a dataclass. Then, the IDE will auto-complete the hints as you type.
336
336
 
337
337
  !!! warning
338
- Be aware that in contrast to the argparse, 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`.
338
+ The argparse support is considered mostly for your evaluation as there are some [`differences`](Argparse-caveats.md) for advanced argparse CLI interfaces.
@@ -149,7 +149,7 @@ These projects have the code base reduced thanks to the mininterface:
149
149
  Take a look at the following example.
150
150
 
151
151
  1. We define any Env class.
152
- 2. Then, we initialize mininterface with [`run(Env)`][mininterface.run] – the missing fields will be prompter for
152
+ 2. Then, we initialize mininterface with [`run(Env)`][mininterface.run] – the missing fields will be prompted for
153
153
  3. Then, we use various dialog methods, like [`confirm`][mininterface.Mininterface.confirm], [`select`][mininterface.Mininterface.select] or [`form`][mininterface.Mininterface.form].
154
154
 
155
155
  Below, you find the screenshots how the program looks in various environments ([graphic](Interfaces.md#guiinterface-or-tkinterface-or-gui) interface, [web](Interfaces.md#webinterface-or-web) interface...).
@@ -242,11 +242,11 @@ from pathlib import Path
242
242
  from mininterface import run
243
243
 
244
244
  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")
247
245
  subparsers = parser.add_subparsers(dest="command", required=True)
248
246
  sub1 = subparsers.add_parser("build", help="Build something.")
249
247
  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")
250
250
 
251
251
  # Old version
252
252
  # env = parser.parse_args()
@@ -297,4 +297,4 @@ print(m.env.time) # -> 14:21
297
297
  If you're sure enough to start using *Mininterface*, convert the argparse into a dataclass. Then, the IDE will auto-complete the hints as you type.
298
298
 
299
299
  !!! warning
300
- Be aware that in contrast to the argparse, 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`.
300
+ The argparse support is considered mostly for your evaluation as there are some [`differences`](Argparse-caveats.md) for advanced argparse CLI interfaces.
@@ -5,6 +5,7 @@ from dataclasses import dataclass
5
5
  from pathlib import Path
6
6
  from typing import Literal, Optional, Sequence, Type
7
7
 
8
+
8
9
  from .exceptions import Cancelled, DependencyRequired, InterfaceNotAvailable
9
10
  from ._lib.form_dict import DataClass, EnvClass
10
11
  from .interfaces import get_interface
@@ -16,7 +17,12 @@ from .tag.alias import Options, Validation
16
17
  try:
17
18
  from ._lib.start import ChooseSubcommandOverview, Start
18
19
  from .cli import Command, SubcommandPlaceholder
19
- from ._lib.cli_parser import assure_args, parse_cli, parse_config_file, parser_to_dataclass
20
+ from ._lib.argparse_support import parser_to_dataclass
21
+ from ._lib.cli_parser import (
22
+ assure_args,
23
+ parse_cli,
24
+ parse_config_file,
25
+ )
20
26
  except DependencyRequired as e:
21
27
  assure_args, parse_cli, parse_config_file, parser_to_dataclass = (e,) * 4
22
28
  ChooseSubcommandOverview, Start, SubcommandPlaceholder = (e,) * 3
@@ -27,18 +33,20 @@ class _Empty:
27
33
  pass
28
34
 
29
35
 
30
- def run(env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | None = None,
31
- ask_on_empty_cli: bool = False,
32
- title: str = "",
33
- config_file: Path | str | bool = True,
34
- add_verbose: bool = True,
35
- ask_for_missing: bool = True,
36
- # We do not use InterfaceType as a type here because we want the documentation to show full alias:
37
- interface: Type[Mininterface] | Literal["gui"] | Literal["tui"] | Literal["text"] | Literal["web"] | None = None,
38
- args: Optional[Sequence[str]] = None,
39
- settings: Optional[MininterfaceSettings] = None,
40
- **kwargs) -> Mininterface[EnvClass]:
41
- """ The main access, start here.
36
+ def run(
37
+ env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | None = None,
38
+ ask_on_empty_cli: bool = False,
39
+ title: str = "",
40
+ config_file: Path | str | bool = True,
41
+ add_verbose: bool = True,
42
+ ask_for_missing: bool = True,
43
+ # We do not use InterfaceType as a type here because we want the documentation to show full alias:
44
+ interface: Type[Mininterface] | Literal["gui"] | Literal["tui"] | Literal["text"] | Literal["web"] | None = None,
45
+ args: Optional[Sequence[str]] = None,
46
+ settings: Optional[MininterfaceSettings] = None,
47
+ **kwargs
48
+ ) -> Mininterface[EnvClass]:
49
+ """The main access, start here.
42
50
  Wrap your configuration dataclass into `run` to access the interface. An interface is chosen automatically,
43
51
  with the preference of the graphical one, regressed to a text interface for machines without display.
44
52
  Besides, if given a configuration dataclass, the function enriches it with the CLI commands and possibly
@@ -59,9 +67,9 @@ def run(env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | No
59
67
  ```python
60
68
  @dataclass
61
69
  class Env:
62
- number: int = 3
63
- text: str = ""
64
- m = run(Env, ask_on_empty=True)
70
+ number: int = 3
71
+ text: str = ""
72
+ m = run(Env, ask_on_empty=True)
65
73
  ```
66
74
 
67
75
  ```bash
@@ -77,6 +85,7 @@ def run(env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | No
77
85
  whose name stem is the same as the program's.
78
86
  Ex: `program.py` will search for `program.yaml`.
79
87
  If False, no config file is used.
88
+ See the [Config file](Config-file.md) section.
80
89
  add_verbose: Adds the verbose flag that automatically sets the level to `logging.INFO` (*-v*) or `logging.DEBUG` (*-vv*).
81
90
 
82
91
  ```python
@@ -203,8 +212,11 @@ def run(env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | No
203
212
  if superform_args is not None:
204
213
  # Run Superform as multiple subcommands exist and we have to decide which one to run.
205
214
  m = get_interface(interface, title, settings, None)
206
- ChooseSubcommandOverview(env_or_list, m, args=superform_args, ask_for_missing=ask_for_missing)
207
- 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})
208
220
 
209
221
  # B) A single Env object, or a list of such objects (with one is being selected via args)
210
222
  # C) No Env object
@@ -243,6 +255,4 @@ def run(env_or_list: Type[EnvClass] | list[Type[EnvClass]] | ArgumentParser | No
243
255
  return m
244
256
 
245
257
 
246
- __all__ = ["run", "Mininterface", "Tag",
247
- "Cancelled",
248
- "Validation", "Options"]
258
+ __all__ = ["run", "Mininterface", "Tag", "Cancelled", "Validation", "Options"]
@@ -9,6 +9,7 @@ try:
9
9
  from tyro.conf import Positional
10
10
  except ImportError:
11
11
  from .exceptions import DependencyRequired
12
+
12
13
  raise DependencyRequired("basic").exit()
13
14
 
14
15
  from . import run
@@ -36,7 +37,7 @@ Showcase_Type = Literal[1, 2]
36
37
 
37
38
  @dataclass
38
39
  class Alert(Command):
39
- """ Dialog: Display the OK dialog with text. """
40
+ """Dialog: Display the OK dialog with text."""
40
41
 
41
42
  text: Positional[str]
42
43
 
@@ -46,7 +47,7 @@ class Alert(Command):
46
47
 
47
48
  @dataclass
48
49
  class Ask(Command):
49
- """ Dialog: Prompt the user to input a value.
50
+ """Dialog: Prompt the user to input a value.
50
51
  By default, we input a str, by the second parameter, you can infer a type,
51
52
  ex. `mininterface --ask 'My heading' int`
52
53
  """
@@ -77,12 +78,15 @@ class Ask(Command):
77
78
  v = Path
78
79
  case "date":
79
80
  from datetime import date
81
+
80
82
  v = date
81
83
  case "datetime":
82
84
  from datetime import datetime
85
+
83
86
  v = datetime
84
87
  case "time":
85
88
  from datetime import time
89
+
86
90
  v = time
87
91
  case "file":
88
92
  v = PathTag(is_file=True)
@@ -95,7 +99,7 @@ class Ask(Command):
95
99
 
96
100
  @dataclass
97
101
  class Confirm(Command):
98
- """ Dialog: Display confirm box. Returns 0 / 1. """
102
+ """Dialog: Display confirm box. Returns 0 / 1."""
99
103
 
100
104
  text: Positional[str]
101
105
  focus: Positional[Literal["yes", "no"]] = "yes"
@@ -108,7 +112,8 @@ class Confirm(Command):
108
112
 
109
113
  @dataclass
110
114
  class Select(Command):
111
- """ Dialog: Prompt the user to select. """
115
+ """Dialog: Prompt the user to select."""
116
+
112
117
  options: Positional[list[str]]
113
118
  title: str = ""
114
119
 
@@ -118,7 +123,7 @@ class Select(Command):
118
123
 
119
124
  @dataclass
120
125
  class Integrate(Command):
121
- """ Integrate to the system. Generates a bash completion for the given program. """
126
+ """Integrate to the system. Generates a bash completion for the given program."""
122
127
 
123
128
  cmd: Positional[File]
124
129
  """Path to the program using mininterface.
@@ -126,17 +131,18 @@ class Integrate(Command):
126
131
  """
127
132
 
128
133
  def run(self):
129
- environ["MININTERFACE_INTEGRATE_TO_SYSTEM"] = '1'
134
+ environ["MININTERFACE_INTEGRATE_TO_SYSTEM"] = "1"
130
135
  srun(self.cmd.absolute(), env=environ)
131
136
  quit()
132
137
 
133
138
 
134
139
  @dataclass
135
140
  class Showcase:
136
- """ Prints various form just to show what's possible.
141
+ """Prints various form just to show what's possible.
137
142
  Choose the interface by MININTERFACE_INTERFACE=...
138
143
  Ex. MININTERFACE_INTERFACE=tui mininterface showcase 2
139
144
  """
145
+
140
146
  showcase: Positional[Showcase_Type] = 1
141
147
 
142
148
 
@@ -151,11 +157,17 @@ class Web(Command):
151
157
 
152
158
  def run(self):
153
159
  from ._web_interface import WebInterface
160
+
154
161
  WebInterface(cmd=self.cmd, port=self.port)
155
162
 
156
163
 
157
164
  def main():
158
- with run([Alert, Ask, Confirm, Select, Integrate, Showcase, Web], prog="Mininterface", description=__doc__, ask_for_missing=False) as m:
165
+ with run(
166
+ [Alert, Ask, Confirm, Select, Integrate, Showcase, Web],
167
+ prog="Mininterface",
168
+ description=__doc__,
169
+ ask_for_missing=False,
170
+ ) as m:
159
171
  pass
160
172
 
161
173
  if isinstance(m.env, Showcase):
@@ -0,0 +1,284 @@
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
+ )
15
+ from collections import defaultdict
16
+ from dataclasses import MISSING, Field, dataclass, field, make_dataclass
17
+ from functools import cached_property
18
+ import re
19
+ import sys
20
+ from typing import Annotated, Callable, Literal, Optional
21
+ from warnings import warn
22
+
23
+ from tyro.conf import OmitSubcommandPrefixes
24
+
25
+ from .. import Options
26
+
27
+ from .form_dict import DataClass
28
+
29
+
30
+ try:
31
+ from tyro.constructors import PrimitiveConstructorSpec
32
+ from tyro.conf import Positional, arg
33
+ except ImportError:
34
+ from ..exceptions import DependencyRequired
35
+
36
+ raise DependencyRequired("basic")
37
+
38
+
39
+ class Property:
40
+ def __init__(self):
41
+ self._usages = []
42
+
43
+ def add(self, callback: Callable):
44
+ self._usages.append(callback)
45
+
46
+ def generate_property(self):
47
+ def _(this):
48
+ for clb in self._usages:
49
+ v = clb(this)
50
+ if v is not None:
51
+ return v
52
+
53
+ return property(_)
54
+
55
+
56
+ @dataclass
57
+ class ArgparseField:
58
+
59
+ action: Action
60
+ properties: dict[str, Property]
61
+
62
+ @cached_property
63
+ def name(self):
64
+ if n := self.action.option_strings:
65
+ # --get-one → get_one
66
+ return re.sub(r"^--?", "", self.action.option_strings[0]).replace("-", "_")
67
+ else:
68
+ raise ValueError(f"Cannot load argparse, due to field {self.action}")
69
+
70
+ def add(self, callback: Callable):
71
+ if self.action.dest == self.name:
72
+ raise NotImplementedError(
73
+ f"Cannot load argparse, due to field {self.action}. It must be visible from CLI and cannot"
74
+ "be read directly from the program. Solution: Do not use argparse or add a .dest parameter."
75
+ )
76
+ self.properties[self.action.dest].add(callback)
77
+
78
+ @property
79
+ def has_property(self):
80
+ return self.action.dest in self.properties
81
+
82
+
83
+ def parser_to_dataclass(parser: ArgumentParser, name: str = "Args") -> DataClass | list[DataClass]:
84
+ """
85
+ Note: Ex. parser.add_argument("--time", type=time) -> does work at all in argparse, here it works.
86
+ """
87
+ subparsers: list[_SubParsersAction] = []
88
+
89
+ normal_actions: list[Action] = []
90
+ has_positionals = False
91
+ for action in parser._actions:
92
+ match action:
93
+ case _HelpAction():
94
+ continue
95
+ case _SubParsersAction():
96
+ if has_positionals:
97
+ warn(
98
+ "This CLI parser have a subcommand placed after positional arguments. The order of arguments changes, see --help."
99
+ )
100
+ subparsers.append(action)
101
+ case _:
102
+ if not action.option_strings:
103
+ has_positionals = True
104
+ normal_actions.append(action)
105
+
106
+ if subparsers:
107
+ return [
108
+ _make_dataclass_from_actions(
109
+ normal_actions + subactions._actions,
110
+ subname,
111
+ help_,
112
+ subactions.description,
113
+ )
114
+ for subparser in subparsers
115
+ for subname, subactions, help_ in _loop_SubParsersAction(subparser)
116
+ ]
117
+ else:
118
+ return _make_dataclass_from_actions(normal_actions, name, None, parser.description)
119
+
120
+
121
+ def _loop_SubParsersAction(subparser: _SubParsersAction):
122
+ return [
123
+ (subname, subactions, ch_act.help)
124
+ for (subname, subactions), ch_act in zip(subparser.choices.items(), subparser._choices_actions)
125
+ ]
126
+
127
+
128
+ def _make_dataclass_from_actions(
129
+ actions: list[Action], name, helptext: str | None, description: str | None
130
+ ) -> DataClass:
131
+ const_actions = defaultdict(list[ArgparseField])
132
+ normal_fields: list[tuple[str, type, Field]] = []
133
+ pos_fields: list[tuple[str, type, Field]] = []
134
+ properties = defaultdict(Property)
135
+ """ Sometimes, the action.dest differs from the field name.
136
+ Field name is exposed to the CLI, action.dest is used in the program.
137
+ """
138
+ subparser_fields: list[tuple[str, type]] = []
139
+
140
+ for action in actions:
141
+ af = ArgparseField(action, properties)
142
+ opt = {}
143
+
144
+ match action:
145
+ case _HelpAction():
146
+ 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
+ ]
163
+ case _SubParsersAction():
164
+ # Note that there is only one _SubParsersAction in argparse
165
+ # but to be sure, we allow multiple of them
166
+ # This probably makes a different CLI output than the original argparse but should work.
167
+ for subname, subparser, help_ in _loop_SubParsersAction(action):
168
+ sub_dc = _make_dataclass_from_actions(
169
+ subparser._actions,
170
+ subname.capitalize(),
171
+ help_,
172
+ subparser.description,
173
+ )
174
+ 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()
183
+ continue
184
+ case _AppendAction():
185
+ arg_type = list[action.type or str]
186
+ opt["default_factory"] = list
187
+ case _AppendConstAction():
188
+ # `--one --two` -> env.section = [one, two]
189
+ arg_type = bool
190
+ opt["default"] = False
191
+ const_actions[af.action.dest].append(af)
192
+ af.add(
193
+ lambda self, af=af: (
194
+ [_af.action.const for _af in const_actions[af.action.dest] if getattr(self, _af.name)]
195
+ )
196
+ )
197
+ case _StoreTrueAction():
198
+ arg_type = bool
199
+ case _StoreFalseAction():
200
+ arg_type = bool
201
+ opt["default"] = False
202
+ af.add(lambda self, field_name=af.name: not getattr(self, field_name))
203
+ case _StoreConstAction():
204
+ arg_type = bool
205
+ opt["default"] = False
206
+ af.add(
207
+ lambda self, field_name=af.name, const=action.const: (const if getattr(self, field_name) else None)
208
+ )
209
+ case _CountAction():
210
+ arg_type = int
211
+ case _:
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:
229
+ if action.default is None:
230
+ # parser.add_argument("--path", type=Path) -> becomes None, not Path('.').
231
+ # By default, argparse put None if not used in the CLI.
232
+ # Which makes tyro output the warning: annotated with type `<class 'str'>`, but the default value `None`
233
+ # We either make None an option by `arg_type |= None`
234
+ # or else we default the value.
235
+ if arg_type is not None:
236
+ arg_type |= None
237
+ opt["default"] = action.default if action.default != SUPPRESS else None
238
+
239
+ # build a dataclass field, either optional, or positional
240
+ opt["metadata"] = {"help": action.help}
241
+ if action.option_strings:
242
+ # normal_fields.append((action.dest, arg_type, field(**opt, **met)))
243
+ # Annotated[arg_type, arg(metavar=metavar)]
244
+ normal_fields.append((af.name, arg_type, field(**opt)))
245
+
246
+ # Generate back-compatible property if dest != field_name
247
+ if af.name != action.dest and not af.has_property:
248
+ af.add(lambda self, field_name=af.name: getattr(self, field_name))
249
+ else:
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 ))
271
+
272
+ dc = make_dataclass(
273
+ name,
274
+ reversed(uniq_fields),
275
+ namespace={k: prop.generate_property() for k, prop in properties.items()},
276
+ )
277
+ if helptext or description:
278
+ trimmed = (helptext or "").strip()
279
+ needs_colon = trimmed and description and trimmed[-1] not in (".", ":", "!", "?", "…")
280
+
281
+ separator = ": " if needs_colon else ("\n" if trimmed else "")
282
+ dc.__doc__ = trimmed + separator + (description or "")
283
+
284
+ return dc
@@ -25,7 +25,7 @@ common_iterables = list, tuple, set
25
25
 
26
26
 
27
27
  def flatten(d: dict[str, T | dict], include_keys: Optional[Callable[[str], list]] = None) -> Iterable[T]:
28
- """ Recursively traverse whole dict """
28
+ """Recursively traverse whole dict"""
29
29
  for k, v in d.items():
30
30
  if isinstance(v, dict):
31
31
  if include_keys:
@@ -37,7 +37,7 @@ def flatten(d: dict[str, T | dict], include_keys: Optional[Callable[[str], list]
37
37
 
38
38
  # NOTE: Not used.
39
39
  def flatten_keys(d: dict[KT, T | dict]) -> Iterable[tuple[KT, T]]:
40
- """ Recursively traverse whole dict """
40
+ """Recursively traverse whole dict"""
41
41
  for k, v in d.items():
42
42
  if isinstance(v, dict):
43
43
  yield from flatten_keys(v)
@@ -61,17 +61,19 @@ def get_terminal_size():
61
61
  # stty: 'standard input': Inappropriate ioctl for device
62
62
  # I do not know how to suppress this warning.
63
63
  # NOTE why not using os.get_terminal_size()
64
- height, width = (int(s) for s in os.popen('stty size', 'r').read().split())
64
+ height, width = (int(s) for s in os.popen("stty size", "r").read().split())
65
65
  return height, width
66
66
  except (OSError, ValueError):
67
67
  return 0, 0
68
68
 
69
69
 
70
70
  def get_descriptions(parser: ArgumentParser) -> dict:
71
- """ Load descriptions from the parser. Strip argparse info about the default value as it will be editable in the form. """
71
+ """Load descriptions from the parser. Strip argparse info about the default value as it will be editable in the form."""
72
72
  # clean-up tyro stuff that may have a meaning in the CLI, but not in the UI
73
- return {action.dest.replace("-", "_"): re.sub(r"\((default|fixed to|required).*\)", "", action.help or "")
74
- for action in parser._actions}
73
+ return {
74
+ action.dest.replace("-", "_"): re.sub(r"\((default|fixed to|required).*\)", "", action.help or "")
75
+ for action in parser._actions
76
+ }
75
77
 
76
78
 
77
79
  def get_description(obj, param: str) -> str:
@@ -90,7 +92,7 @@ def yield_annotations(dataclass):
90
92
 
91
93
 
92
94
  def matches_annotation(value, annotation) -> bool:
93
- """ Check whether the value type corresponds to the annotation.
95
+ """Check whether the value type corresponds to the annotation.
94
96
  Because built-in isinstance is not enough, it cannot determine parametrized generics.
95
97
  """
96
98
  # union, including Optional and UnionType
@@ -155,7 +157,7 @@ def subclass_matches_annotation(cls, annotation) -> bool:
155
157
 
156
158
 
157
159
  def serialize_structure(obj):
158
- """ Ex: [Path("/tmp"), Path("/usr"), 1] -> ["/tmp", "/usr", 1]. """
160
+ """Ex: [Path("/tmp"), Path("/usr"), 1] -> ["/tmp", "/usr", 1]."""
159
161
  if isinstance(obj, (str, int, float)):
160
162
  return obj
161
163
  elif isinstance(obj, Iterable) and not isinstance(obj, (str, bytes)):
@@ -165,7 +167,7 @@ def serialize_structure(obj):
165
167
 
166
168
 
167
169
  def dataclass_asdict_no_defaults(obj) -> dict:
168
- """ Ignore the dataclass default values. """
170
+ """Ignore the dataclass default values."""
169
171
  if not hasattr(obj, "__dataclass_fields__"):
170
172
  return obj
171
173
 
@@ -182,7 +184,7 @@ def dataclass_asdict_no_defaults(obj) -> dict:
182
184
 
183
185
 
184
186
  def merge_dicts(d1: dict, d2: dict):
185
- """ Recursively merge second dict to the first. """
187
+ """Recursively merge second dict to the first."""
186
188
  for key, value in d2.items():
187
189
  if isinstance(value, dict) and isinstance(d1.get(key), dict):
188
190
  merge_dicts(d1[key], value)
@@ -192,14 +194,14 @@ def merge_dicts(d1: dict, d2: dict):
192
194
 
193
195
 
194
196
  def naturalsize(value: float | str, *args) -> str:
195
- """ For a bare interface, humanize might not be installed. """
197
+ """For a bare interface, humanize might not be installed."""
196
198
  if naturalsize_:
197
199
  return naturalsize_(value, *args)
198
200
  return str(value)
199
201
 
200
202
 
201
203
  def validate_annotated_type(meta, value) -> bool:
202
- """ Raises: ValueError, NotImplementedError """
204
+ """Raises: ValueError, NotImplementedError"""
203
205
  if isinstance(meta, Gt):
204
206
  if not value > meta.gt:
205
207
  raise ValueError(f"Value {value} must be > {meta.gt}")
@@ -224,3 +226,16 @@ def validate_annotated_type(meta, value) -> bool:
224
226
  else:
225
227
  raise NotImplementedError(f"Unknown predicated {meta}")
226
228
  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