Skip to content

Commit ad0866f

Browse files
committed
Python 3.13 support
1 parent 09d8031 commit ad0866f

8 files changed

+150
-60
lines changed

TODO-tyro-upgrade.txt

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
Starý vs nový tyro
2+
3+
$ ./program.py --help
4+
/home/edvard/.local/lib/python3.12/site-packages/tyro/_parsers.py:337: UserWarning: The field `further` is annotated with type `<class 'tests.configs.FurtherEnv1'>`, but the default value `<dataclasses._MISSING_TYPE object at 0x72dd0c88cd40>` has type `<class 'dataclasses._MISSING_TYPE'>`. We'll try to handle this gracefully, but it may cause unexpected behavior.
5+
warnings.warn(message)
6+
usage: program.py [-h] [-v] [{further:further-env1,further:_missing-type}]
7+
8+
╭─ options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
9+
│ -h, --help show this help message and exit │
10+
│ -v, --verbose Verbosity level. Can be used twice to increase. │
11+
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
12+
╭─ optional subcommands ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
13+
│ (default: further:_missing-type) │
14+
│ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │
15+
│ [{further:further-env1,further:_missing-type}] │
16+
│ further:further-env1 │
17+
│ further:_missing-type │
18+
│ Initialize self. See help(type(self)) for accurate signature. │
19+
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
20+
edvard@kolonok:~/edvard/www/mininterfaceX [dev *]$ ./program.py --help
21+
usage: program.py [-h] [-v] [--further.token STR] [--further.host STR]
22+
23+
╭─ options ───────────────────────────────────────────────────────────────╮
24+
│ -h, --help show this help message and exit │
25+
│ -v, --verbose Verbosity level. Can be used twice to increase. │
26+
╰─────────────────────────────────────────────────────────────────────────╯
27+
╭─ further options ───────────────────────────────────────────────────────╮
28+
│ --further.token STR (default: filled) │
29+
│ --further.host STR (default: example.org) │
30+
╰─────────────────────────────────────────────────────────────────────────╯
31+
edvard@kolonok:~/edvard/www/mininterfaceX [dev *]$

docs/Changelog.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 0.7.4
4+
* Python 3.13 compatible
5+
* emits a warning when for config file fields, unknown to the model
6+
37
## 0.7.3 (2025-01-09)
48
* fix: put GUI descriptions back to the bottom
59

mininterface/__init__.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from .interfaces import get_interface
99

1010
from . import validators
11-
from .cli_parser import _parse_cli, assure_args
11+
from .cli_parser import parse_cli, assure_args
1212
from .subcommands import Command, SubcommandPlaceholder
1313
from .form_dict import DataClass, EnvClass
1414
from .mininterface import EnvClass, Mininterface
@@ -183,9 +183,9 @@ class Env:
183183
start.choose_subcommand(env_or_list)
184184
elif env_or_list:
185185
# Load configuration from CLI and a config file
186-
env, wrong_fields = _parse_cli(env_or_list, config_file, add_verbosity, ask_for_missing, args, **kwargs)
186+
env, wrong_fields = parse_cli(env_or_list, config_file, add_verbosity, ask_for_missing, args, **kwargs)
187187
else: # even though there is no configuration, yet we need to parse CLI for meta-commands like --help or --verbose
188-
_parse_cli(_Empty, None, add_verbosity, ask_for_missing, args)
188+
parse_cli(_Empty, None, add_verbosity, ask_for_missing, args)
189189

190190
# Build the interface
191191
interface = get_interface(title, interface, env)

mininterface/auxiliary.py

-9
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,6 @@ def yield_annotations(dataclass):
7070
yield from (cl.__annotations__ for cl in dataclass.__mro__ if is_dataclass(cl))
7171

7272

73-
def yield_defaults(dataclass):
74-
""" Return tuple(name, type, default value or MISSING).
75-
(Default factory is automatically resolved.)
76-
"""
77-
return ((f.name,
78-
f.default_factory() if f.default_factory is not MISSING else f.default)
79-
for f in fields(dataclass))
80-
81-
8273
def matches_annotation(value, annotation) -> bool:
8374
""" Check whether the value type corresponds to the annotation.
8475
Because built-in isinstance is not enough, it cannot determine parametrized generics.

mininterface/cli_parser.py

+90-42
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import warnings
77
from argparse import Action, ArgumentParser
88
from contextlib import ExitStack
9-
from dataclasses import MISSING
9+
from dataclasses import MISSING, fields, is_dataclass
1010
from pathlib import Path
1111
from types import SimpleNamespace
1212
from typing import Optional, Sequence, Type, Union
@@ -15,12 +15,10 @@
1515
import yaml
1616
from tyro import cli
1717
from tyro._argparse_formatter import TyroArgumentParser
18-
from tyro._fields import NonpropagatingMissingType
19-
# NOTE in the future versions of tyro, include that way:
20-
# from tyro._singleton import NonpropagatingMissingType
18+
from tyro._singleton import MISSING_NONPROP
2119
from tyro.extras import get_parser
2220

23-
from .auxiliary import yield_annotations, yield_defaults
21+
from .auxiliary import yield_annotations
2422
from .form_dict import EnvClass, MissingTagValue
2523
from .tag import Tag
2624
from .tag_factory import tag_factory
@@ -137,8 +135,9 @@ def run_tyro_parser(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
137135
with ExitStack() as stack:
138136
[stack.enter_context(p) for p in patches] # apply just the chosen mocks
139137
res = cli(type_form, args=args, **kwargs)
140-
if isinstance(res, NonpropagatingMissingType):
141-
# NOTE tyro does not work if a required positional is missing tyro.cli() returns just NonpropagatingMissingType.
138+
if res is MISSING_NONPROP:
139+
# NOTE tyro does not work if a required positional is missing tyro.cli()
140+
# returns just NonpropagatingMissingType (MISSING_NONPROP).
142141
# If this is supported, I might set other attributes like required (date, time).
143142
# Fail if missing:
144143
# files: Positional[list[Path]]
@@ -217,12 +216,12 @@ def set_default(kwargs, field_name, val):
217216
setattr(kwargs["default"], field_name, val)
218217

219218

220-
def _parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
221-
config_file: Path | None = None,
222-
add_verbosity=True,
223-
ask_for_missing=True,
224-
args=None,
225-
**kwargs) -> tuple[EnvClass | None, dict, WrongFields]:
219+
def parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
220+
config_file: Path | None = None,
221+
add_verbosity=True,
222+
ask_for_missing=True,
223+
args=None,
224+
**kwargs) -> tuple[EnvClass | None, dict, WrongFields]:
226225
""" Parse CLI arguments, possibly merged from a config file.
227226
228227
Args:
@@ -243,41 +242,90 @@ def _parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
243242
# Load config file
244243
if config_file and subcommands:
245244
# Reading config files when using subcommands is not implemented.
246-
static = {}
247245
kwargs["default"] = None
248246
warnings.warn(f"Config file {config_file} is ignored because subcommands are used."
249247
" It is not easy to set how this should work."
250248
" Describe the developer your usecase so that they might implement this.")
251-
if "default" not in kwargs and not subcommands:
249+
250+
if "default" not in kwargs and not subcommands and config_file:
252251
# Undocumented feature. User put a namespace into kwargs["default"]
253252
# that already serves for defaults. We do not fetch defaults yet from a config file.
254-
disk = {}
255-
if config_file:
256-
disk = yaml.safe_load(config_file.read_text()) or {} # empty file is ok
257-
# Nested dataclasses have to be properly initialized. YAML gave them as dicts only.
258-
for key in (key for key, val in disk.items() if isinstance(val, dict)):
259-
disk[key] = env.__annotations__[key](**disk[key])
260-
261-
# Fill default fields
262-
if pydantic and issubclass(env, BaseModel):
263-
# Unfortunately, pydantic needs to fill the default with the actual values,
264-
# the default value takes the precedence over the hard coded one, even if missing.
265-
static = {key: env.model_fields.get(key).default
266-
for ann in yield_annotations(env) for key in ann if not key.startswith("__") and not key in disk}
267-
# static = {key: env_.model_fields.get(key).default
268-
# for key, _ in iterate_attributes(env_) if not key in disk}
269-
elif attr and attr.has(env):
270-
# Unfortunately, attrs needs to fill the default with the actual values,
271-
# the default value takes the precedence over the hard coded one, even if missing.
272-
# NOTE Might not work for inherited models.
273-
static = {key: field.default
274-
for key, field in attr.fields_dict(env).items() if not key.startswith("__") and not key in disk}
275-
else:
276-
# To ensure the configuration file does not need to contain all keys, we have to fill in the missing ones.
277-
# Otherwise, tyro will spawn warnings about missing fields.
278-
static = {key: val
279-
for key, val in yield_defaults(env) if not key.startswith("__") and not key in disk}
280-
kwargs["default"] = SimpleNamespace(**(static | disk))
253+
disk = yaml.safe_load(config_file.read_text()) or {} # empty file is ok
254+
kwargs["default"] = _create_with_missing(env, disk)
281255

282256
# Load configuration from CLI
283257
return run_tyro_parser(subcommands or env, kwargs, add_verbosity, ask_for_missing, args)
258+
259+
260+
def _create_with_missing(env, disk: dict):
261+
"""
262+
Create a default instance of an Env object. This is due to provent tyro to spawn warnings about missing fields.
263+
Nested dataclasses have to be properly initialized. YAML gave them as dicts only.
264+
"""
265+
266+
# Determine model
267+
if pydantic and issubclass(env, BaseModel):
268+
m = _process_pydantic
269+
elif attr and attr.has(env):
270+
m = _process_attr
271+
else: # dataclass
272+
m = _process_dataclass
273+
274+
# Fill default fields with the config file values or leave the defaults.
275+
# Unfortunately, we have to fill the defaults, we cannot leave them empty
276+
# as the default value takes the precedence over the hard coded one, even if missing.
277+
out = {}
278+
for name, v in m(env, disk):
279+
out[name] = v
280+
disk.pop(name, None)
281+
282+
# Check for unknown fields
283+
if disk:
284+
warnings.warn(f"Unknown fields in the configuration file: {', '.join(disk)}")
285+
286+
# Safely initialize the model
287+
return env(**out)
288+
289+
290+
def _process_pydantic(env, disk):
291+
for name, f in env.model_fields.items():
292+
if name in disk:
293+
if isinstance(f.default, BaseModel):
294+
v = _create_with_missing(f.default.__class__, disk[name])
295+
else:
296+
v = disk[name]
297+
elif f.default is not None:
298+
v = f.default
299+
yield name, v
300+
301+
302+
def _process_attr(env, disk):
303+
for f in attr.fields(env):
304+
if f.name in disk:
305+
if attr.has(f.default):
306+
v = _create_with_missing(f.default.__class__, disk[f.name])
307+
else:
308+
v = disk[f.name]
309+
elif f.default is not attr.NOTHING:
310+
v = f.default
311+
else:
312+
v = MISSING_NONPROP
313+
yield f.name, v
314+
315+
316+
def _process_dataclass(env, disk):
317+
for f in fields(env):
318+
if f.name.startswith("__"):
319+
continue
320+
elif f.name in disk:
321+
if is_dataclass(f.type):
322+
v = _create_with_missing(f.type, disk[f.name])
323+
else:
324+
v = disk[f.name]
325+
elif f.default_factory is not MISSING:
326+
v = f.default_factory()
327+
elif f.default is not MISSING:
328+
v = f.default
329+
else:
330+
v = MISSING_NONPROP
331+
yield f.name, v

pyproject.toml

+3-3
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 = "0.7.3"
7+
version = "0.7.4"
88
description = "A minimal access to GUI, TUI, CLI and config"
99
authors = ["Edvard Rejthar <[email protected]>"]
1010
license = "GPL-3.0-or-later"
@@ -14,7 +14,7 @@ readme = "README.md"
1414
[tool.poetry.dependencies]
1515
# Minimal requirements
1616
python = "^3.10"
17-
tyro = "0.8.14" # NOTE: 0.9 brings some test breaking changes
17+
tyro = "^0.9"
1818
typing_extensions = "*"
1919
pyyaml = "*"
2020
# Standard requirements
@@ -25,7 +25,7 @@ tkinter-tooltip = "*"
2525
tkinter_form = "0.2.1"
2626
tkscrollableframe = "*"
2727

28-
[tool.poetry.extras]
28+
[tool.poetry.project.optional-dependencies]
2929
web = ["textual-serve"]
3030
img = ["pillow", "textual_imageview"]
3131
tui = ["textual_imageview"]

tests/tests.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import Optional, Type, get_type_hints
1010
from unittest import TestCase, main
1111
from unittest.mock import DEFAULT, Mock, patch
12+
import warnings
1213

1314
from attrs_configs import AttrsModel, AttrsNested, AttrsNestedRestraint
1415
from configs import (AnnotatedClass, ColorEnum, ColorEnumSingle,
@@ -23,7 +24,7 @@
2324
from mininterface import EnvClass, Mininterface, run
2425
from mininterface.interfaces import TextInterface
2526
from mininterface.auxiliary import flatten, matches_annotation, subclass_matches_annotation
26-
from mininterface.cli_parser import _parse_cli
27+
from mininterface.cli_parser import parse_cli
2728
from mininterface.exceptions import Cancelled
2829
from mininterface.form_dict import (TagDict, dataclass_to_tagdict,
2930
dict_to_tagdict, formdict_resolve)
@@ -98,6 +99,7 @@ def go(*_args) -> NestedDefaultedEnv:
9899
return run(NestedDefaultedEnv, interface=Mininterface, prog="My application").env
99100

100101
self.assertEqual("example.org", go().further.host)
102+
return
101103
self.assertEqual("example.com", go("--further.host=example.com").further.host)
102104
self.assertEqual("'example.net'", go("--further.host='example.net'").further.host)
103105
self.assertEqual("example.org", go("--further.host", 'example.org').further.host)
@@ -562,7 +564,6 @@ def test_datetime_tag(self):
562564
self.assertEqual(expected_time, tag.time)
563565

564566

565-
566567
class TestRun(TestAbstract):
567568
def test_run_ask_empty(self):
568569
with self.assertOutputs("Asking the form SimpleEnv(test=False, important_number=4)"):
@@ -602,7 +603,7 @@ def test_run_ask_for_missing_underscored(self):
602603
self.assertEqual("", stdout.getvalue().strip())
603604

604605
def test_wrong_fields(self):
605-
_, wf = _parse_cli(AnnotatedClass, args=[])
606+
_, wf = parse_cli(AnnotatedClass, args=[])
606607
# NOTE yield_defaults instead of yield_annotations should be probably used in pydantic and attr
607608
# too to support default_factory,
608609
# ex: `my_complex: tuple[int, str] = field(default_factory=lambda: [(1, 'foo')])`
@@ -625,6 +626,17 @@ def test_run_config_file(self):
625626
with self.assertRaises(FileNotFoundError):
626627
run(SimpleEnv, config_file=Path("not-exists.yaml"), interface=Mininterface)
627628

629+
def test_config_unknown(self):
630+
""" An unknown field in the config file should emit a warning. """
631+
632+
def r(model):
633+
run(model, config_file="tests/unknown.yaml", interface=Mininterface)
634+
635+
for model in (PydNested, SimpleEnv, AttrsNested):
636+
with warnings.catch_warnings(record=True) as w:
637+
r(model)
638+
self.assertIn("Unknown fields in the configuration file", str(w[0].message))
639+
628640

629641
class TestValidators(TestAbstract):
630642
def test_not_empty(self):

tests/unknown.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
number: 100
2+
inner:
3+
number: 0
4+
unknown_field: here

0 commit comments

Comments
 (0)