mininterface 1.0.4__tar.gz → 1.1.1__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 (73) hide show
  1. {mininterface-1.0.4 → mininterface-1.1.1}/PKG-INFO +6 -4
  2. {mininterface-1.0.4 → mininterface-1.1.1}/README.md +2 -2
  3. mininterface-1.1.1/mininterface/__init__.py +7 -0
  4. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/__main__.py +1 -1
  5. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_lib/argparse_support.py +29 -47
  6. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_lib/auxiliary.py +143 -8
  7. mininterface-1.1.1/mininterface/_lib/cli_flags.py +107 -0
  8. mininterface-1.1.1/mininterface/_lib/cli_parser.py +408 -0
  9. mininterface-1.0.4/mininterface/cli.py → mininterface-1.1.1/mininterface/_lib/cli_utils.py +4 -49
  10. mininterface-1.1.1/mininterface/_lib/config_file.py +101 -0
  11. mininterface-1.1.1/mininterface/_lib/dataclass_creation.py +286 -0
  12. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_lib/form_dict.py +35 -8
  13. mininterface-1.1.1/mininterface/_lib/future_compatibility.py +6 -0
  14. mininterface-1.0.4/mininterface/__init__.py → mininterface-1.1.1/mininterface/_lib/run.py +159 -75
  15. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_lib/showcase.py +3 -1
  16. mininterface-1.1.1/mininterface/_lib/start.py +135 -0
  17. mininterface-1.1.1/mininterface/_lib/tyro_patches.py +400 -0
  18. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_mininterface/__init__.py +27 -19
  19. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_mininterface/adaptor.py +5 -9
  20. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_tk_interface/adaptor.py +10 -3
  21. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_tk_interface/select_input.py +18 -3
  22. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_tk_interface/utils.py +5 -1
  23. mininterface-1.1.1/mininterface/cli.py +46 -0
  24. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/interfaces.py +3 -6
  25. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/settings.py +15 -15
  26. mininterface-1.1.1/mininterface/tag/flag.py +288 -0
  27. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/tag/select_tag.py +82 -22
  28. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/tag/tag.py +65 -21
  29. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/tag/tag_factory.py +41 -3
  30. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/validators.py +13 -9
  31. {mininterface-1.0.4 → mininterface-1.1.1}/pyproject.toml +1 -1
  32. mininterface-1.0.4/mininterface/_lib/cli_parser.py +0 -574
  33. mininterface-1.0.4/mininterface/_lib/start.py +0 -133
  34. mininterface-1.0.4/mininterface/tag/flag.py +0 -144
  35. {mininterface-1.0.4 → mininterface-1.1.1}/LICENSE +0 -0
  36. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_lib/__init__.py +0 -0
  37. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_lib/redirectable.py +0 -0
  38. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_lib/shortcuts.py +0 -0
  39. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_mininterface/mixin.py +0 -0
  40. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_text_interface/__init__.py +0 -0
  41. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_text_interface/adaptor.py +0 -0
  42. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_text_interface/facet.py +0 -0
  43. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_textual_interface/__init__.py +0 -0
  44. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_textual_interface/adaptor.py +0 -0
  45. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_textual_interface/button_contents.py +0 -0
  46. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_textual_interface/facet.py +0 -0
  47. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_textual_interface/file_picker_input.py +0 -0
  48. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_textual_interface/form_contents.py +0 -0
  49. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_textual_interface/secret_input.py +0 -0
  50. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_textual_interface/style.tcss +0 -0
  51. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_textual_interface/textual_app.py +0 -0
  52. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_textual_interface/widgets.py +0 -0
  53. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_tk_interface/__init__.py +0 -0
  54. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_tk_interface/date_entry.py +0 -0
  55. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_tk_interface/external_fix.py +0 -0
  56. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_tk_interface/facet.py +0 -0
  57. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_tk_interface/redirect_text_tkinter.py +0 -0
  58. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_tk_interface/secret_entry.py +0 -0
  59. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_web_interface/__init__.py +0 -0
  60. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_web_interface/app.py +0 -0
  61. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_web_interface/child_adaptor.py +0 -0
  62. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/_web_interface/parent_adaptor.py +0 -0
  63. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/exceptions.py +0 -0
  64. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/experimental.py +0 -0
  65. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/facet/__init__.py +0 -0
  66. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/tag/__init__.py +0 -0
  67. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/tag/alias.py +0 -0
  68. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/tag/callback_tag.py +0 -0
  69. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/tag/datetime_tag.py +0 -0
  70. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/tag/internal.py +0 -0
  71. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/tag/path_tag.py +0 -0
  72. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/tag/secret_tag.py +0 -0
  73. {mininterface-1.0.4 → mininterface-1.1.1}/mininterface/tag/type_stubs.py +0 -0
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: mininterface
3
- Version: 1.0.4
3
+ Version: 1.1.1
4
4
  Summary: A minimal access to GUI, TUI, CLI and config
5
5
  License: LGPL-3.0-or-later
6
+ License-File: LICENSE
6
7
  Author: Edvard Rejthar
7
8
  Author-email: edvard.rejthar@nic.cz
8
9
  Requires-Python: >=3.10,<4.0
@@ -12,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.10
12
13
  Classifier: Programming Language :: Python :: 3.11
13
14
  Classifier: Programming Language :: Python :: 3.12
14
15
  Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
15
17
  Provides-Extra: all
16
18
  Provides-Extra: basic
17
19
  Provides-Extra: gui
@@ -37,7 +39,7 @@ Project-URL: Homepage, https://github.com/CZ-NIC/mininterface
37
39
  Description-Content-Type: text/markdown
38
40
 
39
41
  # Mininterface – access to GUI, TUI, web, CLI and config files
40
- [![Build Status](https://github.com/CZ-NIC/mininterface/actions/workflows/run-unittest.yml/badge.svg)](https://github.com/CZ-NIC/mininterface/actions)
42
+ [![Build Status](https://github.com/CZ-NIC/mininterface/actions/workflows/run-unittest.yml/badge.svg?branch=main)](https://github.com/CZ-NIC/mininterface/actions)
41
43
  [![Downloads](https://static.pepy.tech/badge/mininterface)](https://pepy.tech/project/mininterface)
42
44
 
43
45
  Write the program core, do not bother with the input/output.
@@ -144,7 +146,7 @@ The config variables needed by your program are kept in cozy dataclasses. Write
144
146
  Install with a single command from [PyPi](https://pypi.org/project/mininterface/).
145
147
 
146
148
  ```bash
147
- pip install mininterface[all] # GPLv3 and compatible
149
+ pip install "mininterface[all]<2" # GPLv3 and compatible
148
150
  ```
149
151
 
150
152
  ## Bundles
@@ -1,5 +1,5 @@
1
1
  # Mininterface – access to GUI, TUI, web, CLI and config files
2
- [![Build Status](https://github.com/CZ-NIC/mininterface/actions/workflows/run-unittest.yml/badge.svg)](https://github.com/CZ-NIC/mininterface/actions)
2
+ [![Build Status](https://github.com/CZ-NIC/mininterface/actions/workflows/run-unittest.yml/badge.svg?branch=main)](https://github.com/CZ-NIC/mininterface/actions)
3
3
  [![Downloads](https://static.pepy.tech/badge/mininterface)](https://pepy.tech/project/mininterface)
4
4
 
5
5
  Write the program core, do not bother with the input/output.
@@ -106,7 +106,7 @@ The config variables needed by your program are kept in cozy dataclasses. Write
106
106
  Install with a single command from [PyPi](https://pypi.org/project/mininterface/).
107
107
 
108
108
  ```bash
109
- pip install mininterface[all] # GPLv3 and compatible
109
+ pip install "mininterface[all]<2" # GPLv3 and compatible
110
110
  ```
111
111
 
112
112
  ## Bundles
@@ -0,0 +1,7 @@
1
+ from ._lib.run import run
2
+ from ._mininterface import Mininterface
3
+ from .exceptions import Cancelled
4
+ from .tag import Tag
5
+ from .tag.alias import Options, Validation
6
+
7
+ __all__ = ["run", "Mininterface", "Tag", "Cancelled", "Validation", "Options"]
@@ -12,7 +12,7 @@ except ImportError:
12
12
 
13
13
  raise DependencyRequired("basic").exit()
14
14
 
15
- from . import run
15
+ from ._lib.run import run
16
16
  from .cli import Command
17
17
  from .tag.flag import File
18
18
  from .tag.path_tag import PathTag
@@ -1,35 +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)
15
7
  from collections import defaultdict
16
8
  from dataclasses import MISSING, Field, dataclass, field, make_dataclass
17
9
  from functools import cached_property
18
- import re
19
- import sys
20
- from typing import Annotated, Callable, Literal, Optional
10
+ from typing import Annotated, Callable, Optional
21
11
  from warnings import warn
22
12
 
23
- from tyro.conf import OmitSubcommandPrefixes
24
-
25
- from .. import Options
26
-
13
+ from ..tag.alias import Options
27
14
  from .form_dict import DataClass
28
15
 
29
-
30
16
  try:
31
- from tyro.constructors import PrimitiveConstructorSpec
32
- from tyro.conf import Positional, arg
17
+ from tyro.conf import DisallowNone, OmitSubcommandPrefixes, Positional
33
18
  except ImportError:
34
19
  from ..exceptions import DependencyRequired
35
20
 
@@ -80,11 +65,16 @@ class ArgparseField:
80
65
  return self.action.dest in self.properties
81
66
 
82
67
 
83
- def parser_to_dataclass(parser: ArgumentParser, name: str = "Args") -> DataClass | list[DataClass]:
68
+ def parser_to_dataclass(parser: ArgumentParser, name: str = "Args") -> tuple[DataClass | list[DataClass], Optional[str]]:
84
69
  """
85
70
  Note: Ex. parser.add_argument("--time", type=time) -> does work at all in argparse, here it works.
71
+
72
+ Returns:
73
+ DataClass | list[DataClass]
74
+ Optional[str]: add_version flag
86
75
  """
87
76
  subparsers: list[_SubParsersAction] = []
77
+ add_version = None
88
78
 
89
79
  normal_actions: list[Action] = []
90
80
  has_positionals = False
@@ -98,6 +88,9 @@ def parser_to_dataclass(parser: ArgumentParser, name: str = "Args") -> DataClass
98
88
  "This CLI parser have a subcommand placed after positional arguments. The order of arguments changes, see --help."
99
89
  )
100
90
  subparsers.append(action)
91
+ case _VersionAction():
92
+ # We do not want the version to be part of the dataclass (and appear in `m.form()`).
93
+ add_version = action.version
101
94
  case _:
102
95
  if not action.option_strings:
103
96
  has_positionals = True
@@ -113,9 +106,9 @@ def parser_to_dataclass(parser: ArgumentParser, name: str = "Args") -> DataClass
113
106
  )
114
107
  for subparser in subparsers
115
108
  for subname, subactions, help_ in _loop_SubParsersAction(subparser)
116
- ]
109
+ ], add_version
117
110
  else:
118
- return _make_dataclass_from_actions(normal_actions, name, None, parser.description)
111
+ return _make_dataclass_from_actions(normal_actions, name, None, parser.description), add_version
119
112
 
120
113
 
121
114
  def _loop_SubParsersAction(subparser: _SubParsersAction):
@@ -144,22 +137,6 @@ def _make_dataclass_from_actions(
144
137
  match action:
145
138
  case _HelpAction():
146
139
  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
140
  case _SubParsersAction():
164
141
  # Note that there is only one _SubParsersAction in argparse
165
142
  # but to be sure, we allow multiple of them
@@ -216,12 +193,17 @@ def _make_dataclass_from_actions(
216
193
  else:
217
194
  arg_type = str
218
195
 
219
- metavar = None
220
196
  if "default" not in opt and "default_factory" not in opt:
221
197
  if action.choices:
222
- # With the drop of Python 3.10, use:
198
+ # With the drop of Python 3.10, use mere:
223
199
  # arg_type = Literal[*action.choices]
224
- arg_type = Annotated[arg_type, Options(*action.choices)]
200
+ if sys.version_info >= (3,11):
201
+ from .future_compatibility import literal
202
+ arg_type = literal(action.choices)
203
+ else:
204
+ # we do not prefer this option as tyro does not understand it
205
+ # and won't display options in the help
206
+ arg_type = Annotated[arg_type, Options(*action.choices)]
225
207
 
226
208
  if not action.option_strings and action.default is None and action.nargs != "?":
227
209
  opt["default"] = MISSING
@@ -281,4 +263,4 @@ def _make_dataclass_from_actions(
281
263
  separator = ": " if needs_colon else ("\n" if trimmed else "")
282
264
  dc.__doc__ = trimmed + separator + (description or "")
283
265
 
284
- return dc
266
+ return DisallowNone[dc]
@@ -1,12 +1,16 @@
1
- from typing import get_args, get_origin, Union
2
- from dataclasses import fields, is_dataclass
1
+ import logging
3
2
  import os
4
3
  import re
5
4
  from argparse import ArgumentParser
5
+ from dataclasses import fields, is_dataclass
6
+ from functools import lru_cache
6
7
  from types import UnionType
7
- from typing import Callable, Iterable, Optional, TypeVar, Union, get_args, get_origin
8
+ from typing import (Any, Callable, Iterable, Optional, TypeVar, Union, Literal,
9
+ get_args, get_origin, get_type_hints)
10
+
11
+ from annotated_types import Ge, Gt, Le, Len, Lt, MultipleOf
8
12
 
9
- from annotated_types import Gt, Ge, Lt, Le, MultipleOf, Len
13
+ logger = logging.getLogger(__name__)
10
14
 
11
15
  try:
12
16
  from tyro.extras import get_parser
@@ -71,14 +75,28 @@ def get_descriptions(parser: ArgumentParser) -> dict:
71
75
  """Load descriptions from the parser. Strip argparse info about the default value as it will be editable in the form."""
72
76
  # clean-up tyro stuff that may have a meaning in the CLI, but not in the UI
73
77
  return {
74
- action.dest.replace("-", "_"): re.sub(r"\((default|fixed to|required).*\)", "", action.help or "")
78
+ re.sub(r"\s\(positional\)$", "", action.dest).replace("-", "_"): re.sub(r"\((default|fixed to|required).*\)", "", action.help or "")
75
79
  for action in parser._actions
76
80
  }
77
81
 
78
82
 
79
- def get_description(obj, param: str) -> str:
83
+ @lru_cache
84
+ def _get_parser(obj):
80
85
  if get_parser:
81
- return get_descriptions(get_parser(obj))[param]
86
+ return get_parser(obj)
87
+
88
+
89
+ def get_description(obj, param: str) -> str:
90
+ if p := _get_parser(obj):
91
+ try:
92
+ d = get_descriptions(p)[param].strip()
93
+ except KeyError: # either fetching failed or user added no description
94
+ return ""
95
+ else:
96
+ if d.replace("-", "_") == param:
97
+ # field `bot_id` is reported as `bot-id` in tyro
98
+ return ""
99
+ return d
82
100
  else:
83
101
  # We are missing mininterface[basic] requirement. Tyro is missing.
84
102
  # Without tyro, we are not able to evaluate the class: m.form(Env),
@@ -91,6 +109,78 @@ def yield_annotations(dataclass):
91
109
  yield from (cl.__annotations__ for cl in dataclass.__mro__ if is_dataclass(cl))
92
110
 
93
111
 
112
+ def get_annotation(class_, dest: str, crawled: list):
113
+ """Get the attribute annotation according to the path in `dest` (dot means a nested subclass).
114
+ Works for dataclass, pydantic, attrs.
115
+
116
+ Ex: get_annotation(AppConfig, "bot.bot_id"))
117
+
118
+ Ex: get_annotation(AppConfig, "app.subcommand.bot_id"), "message")
119
+ class AppConfig:
120
+ subcommand: Message|Console
121
+
122
+ class Message:
123
+ bot_id: int
124
+ """
125
+ parts = dest.split(".")
126
+ current_cls = class_
127
+ for part, class_name in zip(parts, crawled):
128
+ if not isinstance(current_cls, type): # `(Message | Console)`
129
+ for cl in get_args(current_cls):
130
+ if cl.__name__.casefold() == class_name:
131
+ current_cls = cl
132
+ break
133
+ else:
134
+ raise KeyError(f"Field {part!r} not accessible in {current_cls}")
135
+
136
+ hints = get_type_hints(current_cls)
137
+
138
+ if part not in hints:
139
+ raise KeyError(f"Field {part!r} not found in {current_cls}")
140
+ current_cls = hints[part] # přejdi na typ dalšího levelu
141
+ return current_cls
142
+
143
+
144
+ def get_or_create_parent_dict(d: dict, fname: str, ignore_last=False) -> dict:
145
+ """
146
+ Return the subdict for the path in `fname`, but ignore the last part.
147
+ If a subdict does not exist, create it.
148
+ If `fname` has only one part, return `d` directly.
149
+ """
150
+ parts = fname.split(".")
151
+ # if len(parts) == 1:
152
+ # return d
153
+ if ignore_last:
154
+ parts = parts[:-1]
155
+
156
+ current = d
157
+ for part in parts:
158
+ if part not in current or not isinstance(current[part], dict):
159
+ current[part] = {}
160
+ current = current[part]
161
+ return current
162
+
163
+
164
+ # NOTE Deprecated
165
+ # def get_nested_class(class_: type, fname: str, ignore_last=False) -> type:
166
+ # """
167
+ # Traverse the class attributes according to the dot-separated path in `fname`
168
+ # and return the type of the most deeply nested attribute.
169
+ # Works for dataclasses, Pydantic models, and attrs classes.
170
+ # """
171
+ # parts = fname.split(".")
172
+ # if ignore_last:
173
+ # parts = parts[:-1]
174
+ # current = class_
175
+
176
+ # for part in parts:
177
+ # if not hasattr(current, part):
178
+ # raise AttributeError(f"{part} not found in {current}")
179
+ # current = getattr(current, part)
180
+
181
+ # return current
182
+
183
+
94
184
  def matches_annotation(value, annotation) -> bool:
95
185
  """Check whether the value type corresponds to the annotation.
96
186
  Because built-in isinstance is not enough, it cannot determine parametrized generics.
@@ -101,6 +191,8 @@ def matches_annotation(value, annotation) -> bool:
101
191
 
102
192
  # generics, ex. list, tuple
103
193
  origin = get_origin(annotation)
194
+ if origin is Literal:
195
+ return value in get_args(annotation)
104
196
  if origin:
105
197
  if not isinstance(value, origin):
106
198
  return False
@@ -109,6 +201,8 @@ def matches_annotation(value, annotation) -> bool:
109
201
  if origin is list:
110
202
  return all(matches_annotation(item, subtypes[0]) for item in value)
111
203
  elif origin is tuple:
204
+ if len(subtypes) == 2 and subtypes[1] is Ellipsis: # ex. tuple[int, ...]
205
+ return all(matches_annotation(v, subtypes[0]) for v in value)
112
206
  if len(subtypes) != len(value):
113
207
  return False
114
208
  return all(matches_annotation(v, t) for v, t in zip(value, subtypes))
@@ -192,6 +286,17 @@ def merge_dicts(d1: dict, d2: dict):
192
286
  d1[key] = value
193
287
  return d1
194
288
 
289
+ def dict_diff(a: dict, b: dict) -> dict:
290
+ """ Returns the B values where they differ. """
291
+ result = {}
292
+ for k in b:
293
+ if isinstance(a.get(k), dict) and isinstance(b.get(k), dict):
294
+ nested = dict_diff(a[k], b[k])
295
+ if nested:
296
+ result[k] = nested
297
+ elif a.get(k) != b.get(k):
298
+ result[k] = b[k]
299
+ return result
195
300
 
196
301
  def naturalsize(value: float | str, *args) -> str:
197
302
  """For a bare interface, humanize might not be installed."""
@@ -227,6 +332,7 @@ def validate_annotated_type(meta, value) -> bool:
227
332
  raise NotImplementedError(f"Unknown predicated {meta}")
228
333
  return True
229
334
 
335
+
230
336
  def allows_none(annotation) -> bool:
231
337
  """True, if annotation allows None: `int | None`, `Optional[int]`, `Union[int,None]`."""
232
338
  if annotation is None:
@@ -238,4 +344,33 @@ def allows_none(annotation) -> bool:
238
344
 
239
345
  if origin is Union or origin is UnionType:
240
346
  return any(arg is type(None) for arg in args)
241
- return False
347
+ return False
348
+
349
+ def strip_none(annotation):
350
+ """Return the same annotation but without NoneType inside a Union/Optional."""
351
+ origin = get_origin(annotation)
352
+
353
+ if origin is Union or origin is UnionType:
354
+ args = tuple(arg for arg in get_args(annotation) if arg is not type(None))
355
+ if len(args) == 1:
356
+ return args[0]
357
+ return Union[args] # nebo origin[args], aby se zachoval typ
358
+
359
+ return annotation
360
+
361
+ @lru_cache(maxsize=1024*10)
362
+ def _get_origin(tp: Any):
363
+ """
364
+ Cached version of typing.get_origin.
365
+ Faster when called repeatedly on the same type hints.
366
+ """
367
+ return get_origin(tp)
368
+
369
+
370
+ def remove_empty_dicts(d: dict):
371
+ """Recursively remove empty dicts from a nested dict, in place."""
372
+ for k in list(d):
373
+ if isinstance(d[k], dict):
374
+ remove_empty_dicts(d[k])
375
+ if not d[k]:
376
+ d.pop(k)
@@ -0,0 +1,107 @@
1
+ from functools import lru_cache
2
+ import logging
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Sequence
5
+
6
+ from .form_dict import EnvClass
7
+
8
+
9
+ class CliFlags:
10
+
11
+ _add_verbose: bool = False
12
+ version: bool | str = False
13
+ _add_quiet: bool = False
14
+
15
+ default_verbosity: int = logging.WARNING
16
+ _verbosity_sequence: Optional[Sequence[int]] = None
17
+
18
+ def __init__(
19
+ self,
20
+ add_verbose: bool | int | Sequence[int] = False,
21
+ add_version: Optional[str] = None,
22
+ add_version_package: Optional[str] = None,
23
+ add_quiet: bool = False,
24
+ ):
25
+ self._enabled = {"verbose": True, "version": True, "quiet": True}
26
+ # verbosity
27
+ match add_verbose:
28
+ case bool():
29
+ self._add_verbose = add_verbose
30
+ case int():
31
+ self._add_verbose = True
32
+ self.default_verbosity = add_verbose
33
+ self._verbosity_sequence = list(range(add_verbose-10, -1, -10))
34
+ case list() | tuple():
35
+ self._add_verbose = True
36
+ self.default_verbosity = add_verbose[0]
37
+ self._verbosity_sequence = add_verbose[1:]
38
+ self._add_quiet = add_quiet
39
+
40
+ # version
41
+ if add_version:
42
+ self.version = add_version
43
+ elif add_version_package:
44
+ try:
45
+ from importlib.metadata import PackageNotFoundError, version
46
+ except ImportError:
47
+ self.version = f"cannot determine version"
48
+ try:
49
+ self.version = version(add_version_package)
50
+ except PackageNotFoundError:
51
+ self.version = f"package {add_version_package} not found"
52
+
53
+ def should_add(self, env_classes: list[EnvClass]) -> bool:
54
+ # Flags are added only if neither the env_class nor any of the subcommands have the same-name flag already
55
+ self._enabled["verbose"] = self._add_verbose and self._attr_not_present("verbose", env_classes)
56
+ self._enabled["quiet"] = self._add_quiet and self._attr_not_present("quiet", env_classes)
57
+ self._enabled["version"] = self.version and self._attr_not_present("version", env_classes)
58
+
59
+ return self.add_verbose or self.add_version or self.add_quiet
60
+
61
+ def _attr_not_present(self, flag, env_classes):
62
+ return all(flag not in cl.__annotations__ for cl in env_classes)
63
+
64
+ @property
65
+ def add_verbose(self):
66
+ return self._add_verbose and self._enabled["verbose"]
67
+
68
+ @property
69
+ def add_version(self):
70
+ return self.version and self._enabled["version"]
71
+
72
+ @property
73
+ def add_quiet(self):
74
+ return self._add_quiet and self._enabled["quiet"]
75
+
76
+ def get_log_level(self, count):
77
+ """
78
+ Ex.
79
+ * add_verbose = True ( default level = WARNING )
80
+ * -v -> logging.INFO
81
+ * -vv -> logging.DEBUG
82
+ * -vvv -> logging.NOTSET
83
+ * add_verbose = default level INFO
84
+ * -v -> logging.DEBUG
85
+ * -vv -> logging.NOTSET
86
+ * add_verbose = (40, 35, 30, 25)
87
+ * -v -> 35
88
+ * -vv -> logging.INFO
89
+ * -vvv -> 25
90
+ * -vvv -> logging.NOTSET
91
+
92
+ Args:
93
+ count: number of times `--verbose` flag is used. Negative count means the `--quiet` flag is used.
94
+
95
+ Returns:
96
+ int: log level
97
+ """
98
+ if count == -1: # quiet flag
99
+ return logging.ERROR
100
+ if not count:
101
+ return self.default_verbosity
102
+ if not self._verbosity_sequence:
103
+ seq = logging.INFO, logging.DEBUG
104
+ else:
105
+ seq = self._verbosity_sequence
106
+ log_level = {i + 1: level for i, level in enumerate(seq)}.get(count, logging.NOTSET)
107
+ return log_level