Skip to content

Commit

Permalink
Python 3.13 support
Browse files Browse the repository at this point in the history
  • Loading branch information
e3rd committed Jan 27, 2025
1 parent 09d8031 commit ad0866f
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 60 deletions.
31 changes: 31 additions & 0 deletions TODO-tyro-upgrade.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Starý vs nový tyro

$ ./program.py --help
/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.
warnings.warn(message)
usage: program.py [-h] [-v] [{further:further-env1,further:_missing-type}]

╭─ options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ -h, --help show this help message and exit │
│ -v, --verbose Verbosity level. Can be used twice to increase. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ optional subcommands ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ (default: further:_missing-type) │
│ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── │
│ [{further:further-env1,further:_missing-type}] │
│ further:further-env1 │
│ further:_missing-type │
│ Initialize self. See help(type(self)) for accurate signature. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
edvard@kolonok:~/edvard/www/mininterfaceX [dev *]$ ./program.py --help
usage: program.py [-h] [-v] [--further.token STR] [--further.host STR]

╭─ options ───────────────────────────────────────────────────────────────╮
│ -h, --help show this help message and exit │
│ -v, --verbose Verbosity level. Can be used twice to increase. │
╰─────────────────────────────────────────────────────────────────────────╯
╭─ further options ───────────────────────────────────────────────────────╮
│ --further.token STR (default: filled) │
│ --further.host STR (default: example.org) │
╰─────────────────────────────────────────────────────────────────────────╯
edvard@kolonok:~/edvard/www/mininterfaceX [dev *]$
4 changes: 4 additions & 0 deletions docs/Changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.7.4
* Python 3.13 compatible
* emits a warning when for config file fields, unknown to the model

## 0.7.3 (2025-01-09)
* fix: put GUI descriptions back to the bottom

Expand Down
6 changes: 3 additions & 3 deletions mininterface/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .interfaces import get_interface

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

# Build the interface
interface = get_interface(title, interface, env)
Expand Down
9 changes: 0 additions & 9 deletions mininterface/auxiliary.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,6 @@ def yield_annotations(dataclass):
yield from (cl.__annotations__ for cl in dataclass.__mro__ if is_dataclass(cl))


def yield_defaults(dataclass):
""" Return tuple(name, type, default value or MISSING).
(Default factory is automatically resolved.)
"""
return ((f.name,
f.default_factory() if f.default_factory is not MISSING else f.default)
for f in fields(dataclass))


def matches_annotation(value, annotation) -> bool:
""" Check whether the value type corresponds to the annotation.
Because built-in isinstance is not enough, it cannot determine parametrized generics.
Expand Down
132 changes: 90 additions & 42 deletions mininterface/cli_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import warnings
from argparse import Action, ArgumentParser
from contextlib import ExitStack
from dataclasses import MISSING
from dataclasses import MISSING, fields, is_dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Optional, Sequence, Type, Union
Expand All @@ -15,12 +15,10 @@
import yaml
from tyro import cli
from tyro._argparse_formatter import TyroArgumentParser
from tyro._fields import NonpropagatingMissingType
# NOTE in the future versions of tyro, include that way:
# from tyro._singleton import NonpropagatingMissingType
from tyro._singleton import MISSING_NONPROP
from tyro.extras import get_parser

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


def _parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
config_file: Path | None = None,
add_verbosity=True,
ask_for_missing=True,
args=None,
**kwargs) -> tuple[EnvClass | None, dict, WrongFields]:
def parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
config_file: Path | None = None,
add_verbosity=True,
ask_for_missing=True,
args=None,
**kwargs) -> tuple[EnvClass | None, dict, WrongFields]:
""" Parse CLI arguments, possibly merged from a config file.
Args:
Expand All @@ -243,41 +242,90 @@ def _parse_cli(env_or_list: Type[EnvClass] | list[Type[EnvClass]],
# Load config file
if config_file and subcommands:
# Reading config files when using subcommands is not implemented.
static = {}
kwargs["default"] = None
warnings.warn(f"Config file {config_file} is ignored because subcommands are used."
" It is not easy to set how this should work."
" Describe the developer your usecase so that they might implement this.")
if "default" not in kwargs and not subcommands:

if "default" not in kwargs and not subcommands and config_file:
# Undocumented feature. User put a namespace into kwargs["default"]
# that already serves for defaults. We do not fetch defaults yet from a config file.
disk = {}
if config_file:
disk = yaml.safe_load(config_file.read_text()) or {} # empty file is ok
# Nested dataclasses have to be properly initialized. YAML gave them as dicts only.
for key in (key for key, val in disk.items() if isinstance(val, dict)):
disk[key] = env.__annotations__[key](**disk[key])

# Fill default fields
if pydantic and issubclass(env, BaseModel):
# Unfortunately, pydantic needs to fill the default with the actual values,
# the default value takes the precedence over the hard coded one, even if missing.
static = {key: env.model_fields.get(key).default
for ann in yield_annotations(env) for key in ann if not key.startswith("__") and not key in disk}
# static = {key: env_.model_fields.get(key).default
# for key, _ in iterate_attributes(env_) if not key in disk}
elif attr and attr.has(env):
# Unfortunately, attrs needs to fill the default with the actual values,
# the default value takes the precedence over the hard coded one, even if missing.
# NOTE Might not work for inherited models.
static = {key: field.default
for key, field in attr.fields_dict(env).items() if not key.startswith("__") and not key in disk}
else:
# To ensure the configuration file does not need to contain all keys, we have to fill in the missing ones.
# Otherwise, tyro will spawn warnings about missing fields.
static = {key: val
for key, val in yield_defaults(env) if not key.startswith("__") and not key in disk}
kwargs["default"] = SimpleNamespace(**(static | disk))
disk = yaml.safe_load(config_file.read_text()) or {} # empty file is ok
kwargs["default"] = _create_with_missing(env, disk)

# Load configuration from CLI
return run_tyro_parser(subcommands or env, kwargs, add_verbosity, ask_for_missing, args)


def _create_with_missing(env, disk: dict):
"""
Create a default instance of an Env object. This is due to provent tyro to spawn warnings about missing fields.
Nested dataclasses have to be properly initialized. YAML gave them as dicts only.
"""

# Determine model
if pydantic and issubclass(env, BaseModel):
m = _process_pydantic
elif attr and attr.has(env):
m = _process_attr
else: # dataclass
m = _process_dataclass

# Fill default fields with the config file values or leave the defaults.
# Unfortunately, we have to fill the defaults, we cannot leave them empty
# as the default value takes the precedence over the hard coded one, even if missing.
out = {}
for name, v in m(env, disk):
out[name] = v
disk.pop(name, None)

# Check for unknown fields
if disk:
warnings.warn(f"Unknown fields in the configuration file: {', '.join(disk)}")

# Safely initialize the model
return env(**out)


def _process_pydantic(env, disk):
for name, f in env.model_fields.items():
if name in disk:
if isinstance(f.default, BaseModel):
v = _create_with_missing(f.default.__class__, disk[name])
else:
v = disk[name]
elif f.default is not None:
v = f.default
yield name, v


def _process_attr(env, disk):
for f in attr.fields(env):
if f.name in disk:
if attr.has(f.default):
v = _create_with_missing(f.default.__class__, disk[f.name])
else:
v = disk[f.name]
elif f.default is not attr.NOTHING:
v = f.default
else:
v = MISSING_NONPROP
yield f.name, v


def _process_dataclass(env, disk):
for f in fields(env):
if f.name.startswith("__"):
continue
elif f.name in disk:
if is_dataclass(f.type):
v = _create_with_missing(f.type, disk[f.name])
else:
v = disk[f.name]
elif f.default_factory is not MISSING:
v = f.default_factory()
elif f.default is not MISSING:
v = f.default
else:
v = MISSING_NONPROP
yield f.name, v
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "mininterface"
version = "0.7.3"
version = "0.7.4"
description = "A minimal access to GUI, TUI, CLI and config"
authors = ["Edvard Rejthar <edvard.rejthar@nic.cz>"]
license = "GPL-3.0-or-later"
Expand All @@ -14,7 +14,7 @@ readme = "README.md"
[tool.poetry.dependencies]
# Minimal requirements
python = "^3.10"
tyro = "0.8.14" # NOTE: 0.9 brings some test breaking changes
tyro = "^0.9"
typing_extensions = "*"
pyyaml = "*"
# Standard requirements
Expand All @@ -25,7 +25,7 @@ tkinter-tooltip = "*"
tkinter_form = "0.2.1"
tkscrollableframe = "*"

[tool.poetry.extras]
[tool.poetry.project.optional-dependencies]
web = ["textual-serve"]
img = ["pillow", "textual_imageview"]
tui = ["textual_imageview"]
Expand Down
18 changes: 15 additions & 3 deletions tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Optional, Type, get_type_hints
from unittest import TestCase, main
from unittest.mock import DEFAULT, Mock, patch
import warnings

from attrs_configs import AttrsModel, AttrsNested, AttrsNestedRestraint
from configs import (AnnotatedClass, ColorEnum, ColorEnumSingle,
Expand All @@ -23,7 +24,7 @@
from mininterface import EnvClass, Mininterface, run
from mininterface.interfaces import TextInterface
from mininterface.auxiliary import flatten, matches_annotation, subclass_matches_annotation
from mininterface.cli_parser import _parse_cli
from mininterface.cli_parser import parse_cli
from mininterface.exceptions import Cancelled
from mininterface.form_dict import (TagDict, dataclass_to_tagdict,
dict_to_tagdict, formdict_resolve)
Expand Down Expand Up @@ -98,6 +99,7 @@ def go(*_args) -> NestedDefaultedEnv:
return run(NestedDefaultedEnv, interface=Mininterface, prog="My application").env

self.assertEqual("example.org", go().further.host)
return
self.assertEqual("example.com", go("--further.host=example.com").further.host)
self.assertEqual("'example.net'", go("--further.host='example.net'").further.host)
self.assertEqual("example.org", go("--further.host", 'example.org').further.host)
Expand Down Expand Up @@ -562,7 +564,6 @@ def test_datetime_tag(self):
self.assertEqual(expected_time, tag.time)



class TestRun(TestAbstract):
def test_run_ask_empty(self):
with self.assertOutputs("Asking the form SimpleEnv(test=False, important_number=4)"):
Expand Down Expand Up @@ -602,7 +603,7 @@ def test_run_ask_for_missing_underscored(self):
self.assertEqual("", stdout.getvalue().strip())

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

def test_config_unknown(self):
""" An unknown field in the config file should emit a warning. """

def r(model):
run(model, config_file="tests/unknown.yaml", interface=Mininterface)

for model in (PydNested, SimpleEnv, AttrsNested):
with warnings.catch_warnings(record=True) as w:
r(model)
self.assertIn("Unknown fields in the configuration file", str(w[0].message))


class TestValidators(TestAbstract):
def test_not_empty(self):
Expand Down
4 changes: 4 additions & 0 deletions tests/unknown.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
number: 100
inner:
number: 0
unknown_field: here

0 comments on commit ad0866f

Please sign in to comment.