menuet 1.0.0__py3-none-any.whl
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.
- menuet/__init__.py +25 -0
- menuet/action.py +93 -0
- menuet/builders/__init__.py +0 -0
- menuet/builders/qt.py +113 -0
- menuet/builders/text.py +128 -0
- menuet/menu.py +59 -0
- menuet/model.py +280 -0
- menuet/py.typed +0 -0
- menuet/utils.py +119 -0
- menuet-1.0.0.dist-info/METADATA +91 -0
- menuet-1.0.0.dist-info/RECORD +13 -0
- menuet-1.0.0.dist-info/WHEEL +4 -0
- menuet-1.0.0.dist-info/licenses/LICENSE +21 -0
menuet/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from menuet.action import Action
|
|
4
|
+
from menuet.menu import Menu
|
|
5
|
+
from menuet.model import (
|
|
6
|
+
ItemAction,
|
|
7
|
+
ItemGroup,
|
|
8
|
+
ItemMenu,
|
|
9
|
+
Model,
|
|
10
|
+
deserialize,
|
|
11
|
+
load,
|
|
12
|
+
loads,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = (
|
|
16
|
+
"Action",
|
|
17
|
+
"ItemAction",
|
|
18
|
+
"ItemGroup",
|
|
19
|
+
"ItemMenu",
|
|
20
|
+
"Menu",
|
|
21
|
+
"Model",
|
|
22
|
+
"deserialize",
|
|
23
|
+
"load",
|
|
24
|
+
"loads",
|
|
25
|
+
)
|
menuet/action.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from attrs import define, field, validators
|
|
6
|
+
from typing_extensions import Self
|
|
7
|
+
|
|
8
|
+
from menuet.utils import (
|
|
9
|
+
passthrough,
|
|
10
|
+
to_cb_converter,
|
|
11
|
+
to_icon_converter,
|
|
12
|
+
to_tuple_converter,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from importlib.abc import Traversable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ID_PATTERN = r"[a-z](?:[a-z-]*[a-z])?"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@define(frozen=True, kw_only=True)
|
|
24
|
+
class Action:
|
|
25
|
+
"""Runtime action definition."""
|
|
26
|
+
|
|
27
|
+
id: str = field(validator=validators.matches_re(ID_PATTERN))
|
|
28
|
+
"""Action identifier.
|
|
29
|
+
|
|
30
|
+
Identifier must abide by these rules:
|
|
31
|
+
|
|
32
|
+
- Must be **unique** in its [`Model`][menuet.Model]
|
|
33
|
+
- The **first** and **last** characters must be ascii lowercase `[a-z]`
|
|
34
|
+
- Other characters must be ascii lowercase or dash `[a-z-]`
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
cb: Callable[[], Any] = field(
|
|
38
|
+
converter=to_cb_converter,
|
|
39
|
+
eq=False,
|
|
40
|
+
default=passthrough,
|
|
41
|
+
)
|
|
42
|
+
"""Callback to execute when action is requested."""
|
|
43
|
+
|
|
44
|
+
enabled: bool = field(default=True) # TODO(tga): unused
|
|
45
|
+
"""Whether the action is enabled."""
|
|
46
|
+
|
|
47
|
+
visible: bool = field(default=True) # TODO(tga): unused
|
|
48
|
+
"""Whether the action is visible in the menu."""
|
|
49
|
+
|
|
50
|
+
menu: tuple[str, ...] = field(default=(), converter=to_tuple_converter)
|
|
51
|
+
"""Menu labels hierarchy.
|
|
52
|
+
|
|
53
|
+
The root menu is represented by an empty tuple `()`.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
label: str | None = field(default=None)
|
|
57
|
+
"""Display name."""
|
|
58
|
+
|
|
59
|
+
group: str | None = field(default=None)
|
|
60
|
+
"""A group under `menu`.
|
|
61
|
+
|
|
62
|
+
Items under the same `menu` can be grouped together.
|
|
63
|
+
Groups are represented by separators.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
icon: Traversable | None = field(default=None, converter=to_icon_converter)
|
|
67
|
+
"""Path to an icon.
|
|
68
|
+
|
|
69
|
+
Icons are displayed alongside the `label`, if supported by the menu builder.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
desc: str | None = field(default=None)
|
|
73
|
+
"""Short description.
|
|
74
|
+
|
|
75
|
+
Displayed as a menu tooltip.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
# TODO(tga): extra: Mapping[str, object]
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def deserialize(cls, config: dict[str, Any]) -> Self:
|
|
82
|
+
"""Deserialize `config` into a new instance."""
|
|
83
|
+
return cls(
|
|
84
|
+
id=config["id"],
|
|
85
|
+
cb=config.get("cb", passthrough),
|
|
86
|
+
enabled=config.get("enabled", True),
|
|
87
|
+
visible=config.get("visible", True),
|
|
88
|
+
menu=config.get("menu", ()),
|
|
89
|
+
label=config.get("label"),
|
|
90
|
+
group=config.get("group"),
|
|
91
|
+
icon=config.get("icon"),
|
|
92
|
+
desc=config.get("desc"),
|
|
93
|
+
)
|
|
File without changes
|
menuet/builders/qt.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from PySide6 import QtCore, QtGui, QtWidgets
|
|
6
|
+
from PySide6.QtGui import QAction
|
|
7
|
+
|
|
8
|
+
from menuet.model import ItemAction, ItemGroup, ItemMenu
|
|
9
|
+
from menuet.utils import logger
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from importlib.abc import Traversable
|
|
13
|
+
|
|
14
|
+
from menuet.model import MenuSortKey, Model
|
|
15
|
+
|
|
16
|
+
__all__ = ("QMenuBuilder",)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class QMenuBuilder:
|
|
20
|
+
"""Qt Menu Builder."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
model: Model,
|
|
25
|
+
*,
|
|
26
|
+
root_menu: QtWidgets.QMenu | str,
|
|
27
|
+
sort_key: MenuSortKey | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._model: Model = model
|
|
30
|
+
self._sort_key = sort_key
|
|
31
|
+
self._root_menu = root_menu
|
|
32
|
+
|
|
33
|
+
def build(self) -> QtWidgets.QMenu:
|
|
34
|
+
"""Build menu."""
|
|
35
|
+
menus: dict[tuple[str, ...], QtWidgets.QMenu] = {}
|
|
36
|
+
|
|
37
|
+
if isinstance(self._root_menu, QtWidgets.QMenu):
|
|
38
|
+
root = self._root_menu
|
|
39
|
+
root.clear()
|
|
40
|
+
else:
|
|
41
|
+
root = QtWidgets.QMenu(self._root_menu)
|
|
42
|
+
menus[()] = root
|
|
43
|
+
|
|
44
|
+
for item in self._model.iter(sort_key=self._sort_key, recursive=True):
|
|
45
|
+
parent = menus[item.menu]
|
|
46
|
+
|
|
47
|
+
if isinstance(item, ItemGroup):
|
|
48
|
+
parent.addSection(item.inner or "")
|
|
49
|
+
|
|
50
|
+
elif isinstance(item, ItemMenu):
|
|
51
|
+
menu = QtWidgets.QMenu(item.inner.label)
|
|
52
|
+
menu.setIcon(qicon_from_file(item.inner.icon))
|
|
53
|
+
menu.setToolTipsVisible(True)
|
|
54
|
+
menu.setTearOffEnabled(False)
|
|
55
|
+
menu.setParent(parent, menu.windowFlags())
|
|
56
|
+
parent.addMenu(menu)
|
|
57
|
+
menus[item.path] = menu
|
|
58
|
+
|
|
59
|
+
elif isinstance(item, ItemAction):
|
|
60
|
+
action = QAction()
|
|
61
|
+
action.setText(item.inner.label or item.inner.id)
|
|
62
|
+
action.setIconVisibleInMenu(True)
|
|
63
|
+
action.setIcon(qicon_from_file(item.inner.icon))
|
|
64
|
+
action.setToolTip(item.inner.desc or "")
|
|
65
|
+
action.triggered.connect(item.inner.cb)
|
|
66
|
+
action.setParent(parent)
|
|
67
|
+
parent.addAction(action)
|
|
68
|
+
|
|
69
|
+
else: # pragma: no cover
|
|
70
|
+
raise TypeError(item)
|
|
71
|
+
|
|
72
|
+
for menu in menus.values():
|
|
73
|
+
_fix_first_separator(menu)
|
|
74
|
+
|
|
75
|
+
return root
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def qicon_from_file(file: Traversable | None, size: int = 16) -> QtGui.QIcon:
|
|
79
|
+
"""Initialize `QIcon` from `file` bytes.
|
|
80
|
+
|
|
81
|
+
If `file` is `None`, or contains an invalid pixmap,
|
|
82
|
+
an empty `QIcon` is returned.
|
|
83
|
+
"""
|
|
84
|
+
if file is None:
|
|
85
|
+
return QtGui.QIcon()
|
|
86
|
+
|
|
87
|
+
if not file.is_file():
|
|
88
|
+
logger.debug("icon file does not exist or is not a file: '%s'", file)
|
|
89
|
+
return QtGui.QIcon()
|
|
90
|
+
|
|
91
|
+
pixmap = QtGui.QPixmap()
|
|
92
|
+
if not pixmap.loadFromData(file.read_bytes()):
|
|
93
|
+
logger.debug("failed to load icon file: '%s'", file)
|
|
94
|
+
return QtGui.QIcon()
|
|
95
|
+
|
|
96
|
+
pixmap = pixmap.scaled(QtCore.QSize(size, size))
|
|
97
|
+
return QtGui.QIcon(pixmap)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _fix_first_separator(menu: QtWidgets.QMenu) -> None:
|
|
101
|
+
"""Fix cases where the first menu separator is not displayed.
|
|
102
|
+
|
|
103
|
+
If the first `menu` item is a separator,
|
|
104
|
+
prepend menu with a fake zero-sized action,
|
|
105
|
+
making the separator the second `menu` item, and visible.
|
|
106
|
+
"""
|
|
107
|
+
first = menu.actions()[0] if menu.actions() else None
|
|
108
|
+
if first and first.isSeparator() and first.text() and first.isVisible():
|
|
109
|
+
widget = QtWidgets.QWidget(menu)
|
|
110
|
+
widget.setFixedSize(0, 0)
|
|
111
|
+
action = QtWidgets.QWidgetAction(menu)
|
|
112
|
+
action.setDefaultWidget(widget)
|
|
113
|
+
menu.insertAction(first, action)
|
menuet/builders/text.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
from itertools import accumulate
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from attrs import frozen
|
|
8
|
+
|
|
9
|
+
from menuet.model import ItemAction, ItemGroup, ItemMenu
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from menuet.model import MenuSortKey, Model
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = ("Render", "TextMenuBuilder")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@frozen
|
|
19
|
+
class _Render:
|
|
20
|
+
child: str
|
|
21
|
+
last_child: str
|
|
22
|
+
menu: str
|
|
23
|
+
empty: str
|
|
24
|
+
group_suffix: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Render(enum.Enum):
|
|
28
|
+
"""[`TextMenuBuilder`][menuet.builders.text.TextMenuBuilder] render options."""
|
|
29
|
+
|
|
30
|
+
UTF8 = _Render(
|
|
31
|
+
child="├── ",
|
|
32
|
+
last_child="└── ",
|
|
33
|
+
menu="│ ",
|
|
34
|
+
empty=" ",
|
|
35
|
+
group_suffix=" ───",
|
|
36
|
+
)
|
|
37
|
+
ASCII = _Render(
|
|
38
|
+
child="|-- ",
|
|
39
|
+
last_child="`-- ",
|
|
40
|
+
menu="| ",
|
|
41
|
+
empty=" ",
|
|
42
|
+
group_suffix=" ---",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_item_label(item: ItemMenu | ItemAction | ItemGroup) -> str:
|
|
47
|
+
if isinstance(item, ItemMenu):
|
|
48
|
+
return item.inner.label
|
|
49
|
+
if isinstance(item, ItemAction):
|
|
50
|
+
return item.inner.label or item.inner.id
|
|
51
|
+
if isinstance(item, ItemGroup):
|
|
52
|
+
return item.inner or ""
|
|
53
|
+
raise TypeError(type(item)) # pragma: no cover
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TextMenuBuilder:
|
|
57
|
+
"""Text menu builder."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
model: Model,
|
|
62
|
+
*,
|
|
63
|
+
root_menu: str | None = None,
|
|
64
|
+
sort_key: MenuSortKey | None = None,
|
|
65
|
+
render: Render = Render.ASCII,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._model: Model = model
|
|
68
|
+
self._render: _Render = render.value
|
|
69
|
+
self._sort_key: MenuSortKey | None = sort_key
|
|
70
|
+
self._root_menu: str | None = root_menu
|
|
71
|
+
|
|
72
|
+
def build(self) -> str:
|
|
73
|
+
"""Build menu."""
|
|
74
|
+
items = list(self._model.iter(sort_key=self._sort_key, recursive=True))
|
|
75
|
+
root = (self._root_menu,) if self._root_menu is not None else ()
|
|
76
|
+
|
|
77
|
+
# mapping of `{menu_path: item_path}` of last element for each `menu_path`
|
|
78
|
+
lasts: dict[tuple[str, ...], tuple[str, ...]] = {}
|
|
79
|
+
for item in items:
|
|
80
|
+
path = (*root, *item.path)
|
|
81
|
+
lasts[path[:-1]] = path
|
|
82
|
+
|
|
83
|
+
lasts_ = set(lasts.values())
|
|
84
|
+
lines: list[str] = []
|
|
85
|
+
|
|
86
|
+
if self._root_menu is not None:
|
|
87
|
+
line = self._get_ascii_line(label=self._root_menu, path=(), lasts=lasts_)
|
|
88
|
+
lines.append(line)
|
|
89
|
+
|
|
90
|
+
for item in items:
|
|
91
|
+
line = self._get_ascii_line(
|
|
92
|
+
label=_get_item_label(item),
|
|
93
|
+
path=(*root, *item.path),
|
|
94
|
+
lasts=lasts_,
|
|
95
|
+
)
|
|
96
|
+
if isinstance(item, ItemGroup):
|
|
97
|
+
line = f"{line}{self._render.group_suffix}"
|
|
98
|
+
lines.append(line)
|
|
99
|
+
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
def _get_ascii_line(
|
|
103
|
+
self,
|
|
104
|
+
label: str,
|
|
105
|
+
*,
|
|
106
|
+
path: tuple[str, ...] = (),
|
|
107
|
+
lasts: set[tuple[str, ...]],
|
|
108
|
+
) -> str:
|
|
109
|
+
chunks: list[str] = []
|
|
110
|
+
|
|
111
|
+
parents = path[:-1]
|
|
112
|
+
if parents:
|
|
113
|
+
# ("a", "b", "c") -> ("a", "b") ("a", "b", "c")
|
|
114
|
+
it: accumulate[tuple[str, ...]] = accumulate(
|
|
115
|
+
parents,
|
|
116
|
+
lambda t, s: (*t, s),
|
|
117
|
+
initial=(),
|
|
118
|
+
)
|
|
119
|
+
next(it) # skip initial
|
|
120
|
+
next(it) # skip root
|
|
121
|
+
for menu in it:
|
|
122
|
+
chunk = self._render.empty if menu in lasts else self._render.menu
|
|
123
|
+
chunks.append(chunk)
|
|
124
|
+
chunk = self._render.last_child if path in lasts else self._render.child
|
|
125
|
+
chunks.append(chunk)
|
|
126
|
+
|
|
127
|
+
chunks.append(label)
|
|
128
|
+
return "".join(chunks)
|
menuet/menu.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from attrs import define, field
|
|
6
|
+
from typing_extensions import Self
|
|
7
|
+
|
|
8
|
+
from menuet.utils import to_icon_converter, to_tuple_converter
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from importlib.abc import Traversable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@define(frozen=True, kw_only=True)
|
|
15
|
+
class Menu:
|
|
16
|
+
"""Menu node definition."""
|
|
17
|
+
|
|
18
|
+
label: str = field()
|
|
19
|
+
"""Display name."""
|
|
20
|
+
|
|
21
|
+
menu: tuple[str, ...] = field(default=(), converter=to_tuple_converter)
|
|
22
|
+
"""Menu labels hierarchy.
|
|
23
|
+
|
|
24
|
+
The root menu is represented by an empty tuple `()`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
group: str | None = field(default=None)
|
|
28
|
+
"""A group under `menu`.
|
|
29
|
+
|
|
30
|
+
Items under the same `menu` can be grouped together.
|
|
31
|
+
Groups are represented by menu separators.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
icon: Traversable | None = field(default=None, converter=to_icon_converter)
|
|
35
|
+
"""Path to an icon.
|
|
36
|
+
|
|
37
|
+
Icons are displayed alongside the `label`, if supported by the menu builder.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
desc: str | None = field(default=None)
|
|
41
|
+
"""Short description.
|
|
42
|
+
|
|
43
|
+
Displayed as a menu tooltip.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def deserialize(cls, config: dict[str, Any]) -> Self:
|
|
48
|
+
"""Deserialize `config` into a new instance."""
|
|
49
|
+
return cls(
|
|
50
|
+
label=config["label"],
|
|
51
|
+
menu=config.get("menu", ()),
|
|
52
|
+
group=config.get("group"),
|
|
53
|
+
icon=config.get("icon"),
|
|
54
|
+
desc=config.get("desc"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def is_configured(self) -> bool:
|
|
58
|
+
"""Whether this menu has any configuration beside `menu` and `label`."""
|
|
59
|
+
return self.icon is not None or self.group is not None or self.desc is not None
|
menuet/model.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from functools import reduce
|
|
5
|
+
from itertools import chain
|
|
6
|
+
from operator import getitem
|
|
7
|
+
from typing import TYPE_CHECKING, Any, NamedTuple
|
|
8
|
+
|
|
9
|
+
import attr
|
|
10
|
+
from attrs import define, field
|
|
11
|
+
|
|
12
|
+
from menuet.action import Action
|
|
13
|
+
from menuet.menu import Menu
|
|
14
|
+
|
|
15
|
+
if sys.version_info < (3, 11):
|
|
16
|
+
import tomli as tomllib # ty: ignore[unresolved-import]
|
|
17
|
+
else:
|
|
18
|
+
import tomllib
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
22
|
+
|
|
23
|
+
from _typeshed import SupportsRead, SupportsRichComparison
|
|
24
|
+
|
|
25
|
+
MenuSortKey = Callable[[Menu | Action], SupportsRichComparison]
|
|
26
|
+
|
|
27
|
+
__all__ = (
|
|
28
|
+
"ItemAction",
|
|
29
|
+
"ItemGroup",
|
|
30
|
+
"ItemMenu",
|
|
31
|
+
"Model",
|
|
32
|
+
"deserialize",
|
|
33
|
+
"load",
|
|
34
|
+
"loads",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@define
|
|
39
|
+
class _MenuNode:
|
|
40
|
+
inner: Menu
|
|
41
|
+
parent: _MenuNode | None
|
|
42
|
+
menus: dict[str, _MenuNode] = field(factory=dict, init=False)
|
|
43
|
+
actions: dict[str, _ActionNode] = field(factory=dict, init=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@define
|
|
47
|
+
class _ActionNode:
|
|
48
|
+
inner: Action = field(on_setattr=attr.setters.frozen)
|
|
49
|
+
parent: _MenuNode
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load(
|
|
53
|
+
fp: SupportsRead[bytes],
|
|
54
|
+
model: Model,
|
|
55
|
+
root_keys: tuple[str, ...] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Load `fp` and deserialize its content to [`model`][menuet.Model].
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
fp: a `.read()`-supporting file-like object containing a TOML document.
|
|
61
|
+
model: Target model.
|
|
62
|
+
root_keys: The sequence of keys that lead to the root configuration structure.
|
|
63
|
+
For example, a `[tool.myapp.mymenu]` table (`("tool", "myapp", "mymenu")`)
|
|
64
|
+
in `pyproject.toml`.
|
|
65
|
+
"""
|
|
66
|
+
config = tomllib.load(fp)
|
|
67
|
+
deserialize(config, model, root_keys)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def loads(s: str, model: Model, root_keys: tuple[str, ...] | None = None) -> None:
|
|
71
|
+
"""Load `s` and deserialize its content to [`model`][menuet.Model].
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
s: a `str` containing a TOML document.
|
|
75
|
+
model: Target model.
|
|
76
|
+
root_keys: The sequence of keys that lead to the root configuration structure.
|
|
77
|
+
For example, a `[tool.myapp.mymenu]` table (`("tool", "myapp", "mymenu")`)
|
|
78
|
+
in `pyproject.toml`.
|
|
79
|
+
"""
|
|
80
|
+
config = tomllib.loads(s)
|
|
81
|
+
deserialize(config, model, root_keys)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def deserialize(
|
|
85
|
+
config: dict[str, Any],
|
|
86
|
+
model: Model,
|
|
87
|
+
root_keys: tuple[str, ...] | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Deserialize `config` and add its content to the model.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
config: A dict containing a configuration of menus and actions.
|
|
93
|
+
model: Target model.
|
|
94
|
+
root_keys: The sequence of keys that lead to the root configuration structure.
|
|
95
|
+
For example, a `[tool.myapp.mymenu]` table (`("tool", "myapp", "mymenu")`)
|
|
96
|
+
in `pyproject.toml`.
|
|
97
|
+
"""
|
|
98
|
+
root_keys = root_keys if root_keys is not None else ()
|
|
99
|
+
# TODO(tga): try/except KeyError
|
|
100
|
+
config = reduce(getitem, root_keys, config)
|
|
101
|
+
for c in config.get("menu", []):
|
|
102
|
+
model.add_menu(Menu.deserialize(c))
|
|
103
|
+
for c in config.get("action", []):
|
|
104
|
+
model.add_action(Action.deserialize(c))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ItemGroup(NamedTuple):
|
|
108
|
+
"""Group wrapper, returned by [`Model.iter`][menuet.Model.iter]."""
|
|
109
|
+
|
|
110
|
+
inner: str | None
|
|
111
|
+
menu: tuple[str, ...]
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def path(self) -> tuple[str, ...]: # noqa: D102
|
|
115
|
+
return (*self.menu, self.inner or "")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ItemMenu(NamedTuple):
|
|
119
|
+
"""Menu wrapper, returned by [`Model.iter`][menuet.Model.iter]."""
|
|
120
|
+
|
|
121
|
+
inner: Menu
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def menu(self) -> tuple[str, ...]: # noqa: D102
|
|
125
|
+
return self.inner.menu
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def path(self) -> tuple[str, ...]: # noqa: D102
|
|
129
|
+
return (*self.inner.menu, self.inner.label)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ItemAction(NamedTuple):
|
|
133
|
+
"""Action wrapper, returned by [`Model.iter`][menuet.Model.iter]."""
|
|
134
|
+
|
|
135
|
+
inner: Action
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def menu(self) -> tuple[str, ...]: # noqa: D102
|
|
139
|
+
return self.inner.menu
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def path(self) -> tuple[str, ...]: # noqa: D102
|
|
143
|
+
return (*self.inner.menu, self.inner.id)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _default_sort_key(node: Menu | Action) -> SupportsRichComparison:
|
|
147
|
+
"""Sort key for `Menu` items.
|
|
148
|
+
|
|
149
|
+
Sort, in order:
|
|
150
|
+
|
|
151
|
+
1. Sort groups alphabetically
|
|
152
|
+
2. Menus before actions in the current group
|
|
153
|
+
3. Finally, sort alphabetically
|
|
154
|
+
"""
|
|
155
|
+
label = (node.label or node.id) if isinstance(node, Action) else node.label
|
|
156
|
+
return (
|
|
157
|
+
(node.group or "").lower(),
|
|
158
|
+
not isinstance(node, Menu),
|
|
159
|
+
label.lower(),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Model:
|
|
164
|
+
"""The main `Action` and `Menu` storage abstraction."""
|
|
165
|
+
|
|
166
|
+
def __init__(self) -> None:
|
|
167
|
+
self._actions: dict[str, Action] = {}
|
|
168
|
+
self._menus: dict[tuple[str, ...], _MenuNode] = {}
|
|
169
|
+
self._menus[()] = _MenuNode(Menu(label="", menu=()), parent=None)
|
|
170
|
+
|
|
171
|
+
def add_action(self, action: Action) -> None:
|
|
172
|
+
"""Add `action` to model."""
|
|
173
|
+
self._add_action(action)
|
|
174
|
+
|
|
175
|
+
def add_menu(self, menu: Menu) -> None:
|
|
176
|
+
"""Add `menu` to model."""
|
|
177
|
+
self._add_menu(menu)
|
|
178
|
+
|
|
179
|
+
def get_action(self, id: str) -> Action: # noqa: A002
|
|
180
|
+
"""Return `Action` with given `id` in model.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
KeyError: `id` not found.
|
|
184
|
+
"""
|
|
185
|
+
return self._actions[id]
|
|
186
|
+
|
|
187
|
+
def iter(
|
|
188
|
+
self,
|
|
189
|
+
menu: tuple[str, ...] = (),
|
|
190
|
+
*,
|
|
191
|
+
sort_key: MenuSortKey | None = None,
|
|
192
|
+
recursive: bool = False,
|
|
193
|
+
) -> Iterator[ItemGroup | ItemMenu | ItemAction]:
|
|
194
|
+
"""Iter menu items.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
menu: The start menu. An empty tuple `()` (the default), starts at the top.
|
|
198
|
+
sort_key: Customize the sort order of menu items.
|
|
199
|
+
recursive: Iter sub-menus in depth-first search.
|
|
200
|
+
"""
|
|
201
|
+
sort_key = sort_key or _default_sort_key
|
|
202
|
+
parent = self._menus[menu]
|
|
203
|
+
for item in self._iter_menu(parent, sort_key):
|
|
204
|
+
yield item
|
|
205
|
+
if isinstance(item, ItemMenu) and recursive:
|
|
206
|
+
yield from self.iter(
|
|
207
|
+
menu=(*item.inner.menu, item.inner.label),
|
|
208
|
+
sort_key=sort_key,
|
|
209
|
+
recursive=True,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _iter_menu(
|
|
213
|
+
self,
|
|
214
|
+
menu: _MenuNode,
|
|
215
|
+
sort_key: MenuSortKey,
|
|
216
|
+
) -> Iterator[ItemGroup | ItemMenu | ItemAction]:
|
|
217
|
+
previous_group = None
|
|
218
|
+
|
|
219
|
+
nodes: Iterable[Menu | Action] = chain(
|
|
220
|
+
(node.inner for node in menu.menus.values()),
|
|
221
|
+
(node.inner for node in menu.actions.values()),
|
|
222
|
+
)
|
|
223
|
+
nodes = sorted(nodes, key=sort_key)
|
|
224
|
+
|
|
225
|
+
for node in nodes:
|
|
226
|
+
group = node.group
|
|
227
|
+
if group is not None and group != previous_group:
|
|
228
|
+
yield ItemGroup(group, menu=node.menu)
|
|
229
|
+
previous_group = group
|
|
230
|
+
|
|
231
|
+
yield ItemMenu(node) if isinstance(node, Menu) else ItemAction(node)
|
|
232
|
+
|
|
233
|
+
def _add_action(self, action: Action) -> _ActionNode:
|
|
234
|
+
if action.id in self._actions:
|
|
235
|
+
msg = f"Action {action.id!r} already exists in model"
|
|
236
|
+
raise ValueError(msg)
|
|
237
|
+
|
|
238
|
+
# add to tree
|
|
239
|
+
if action.menu:
|
|
240
|
+
parent = self._menus.get(action.menu)
|
|
241
|
+
if not parent:
|
|
242
|
+
parent = self._add_menu(
|
|
243
|
+
Menu(label=action.menu[-1], menu=action.menu[:-1]),
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
parent = self._menus[()]
|
|
247
|
+
node = _ActionNode(action, parent=parent)
|
|
248
|
+
parent.actions[action.id] = node
|
|
249
|
+
|
|
250
|
+
# add to map
|
|
251
|
+
self._actions[action.id] = action
|
|
252
|
+
|
|
253
|
+
return node
|
|
254
|
+
|
|
255
|
+
def _add_menu(self, menu: Menu) -> _MenuNode:
|
|
256
|
+
path = (*menu.menu, menu.label)
|
|
257
|
+
if path in self._menus:
|
|
258
|
+
msg = f"Menu {path!r} already exists in model"
|
|
259
|
+
raise ValueError(msg)
|
|
260
|
+
|
|
261
|
+
# add parent to tree
|
|
262
|
+
parent = self._menus.get(menu.menu)
|
|
263
|
+
if parent is None:
|
|
264
|
+
if menu.menu:
|
|
265
|
+
parent = self._add_menu(Menu(label=menu.menu[-1], menu=menu.menu[:-1]))
|
|
266
|
+
else:
|
|
267
|
+
parent = self._menus[()]
|
|
268
|
+
|
|
269
|
+
# add leaf to tree
|
|
270
|
+
node = parent.menus.get(menu.label)
|
|
271
|
+
if node is None:
|
|
272
|
+
node = _MenuNode(menu, parent=parent)
|
|
273
|
+
parent.menus[menu.label] = node
|
|
274
|
+
elif menu.is_configured() and not node.inner.is_configured():
|
|
275
|
+
node.inner = menu
|
|
276
|
+
|
|
277
|
+
# add to map
|
|
278
|
+
self._menus[path] = node
|
|
279
|
+
|
|
280
|
+
return node
|
menuet/py.typed
ADDED
|
File without changes
|
menuet/utils.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
import logging
|
|
5
|
+
import webbrowser
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from functools import partial
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Final, TypeVar
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from importlib.abc import Traversable
|
|
14
|
+
|
|
15
|
+
_T = TypeVar("_T")
|
|
16
|
+
|
|
17
|
+
logger: Final[logging.Logger] = logging.getLogger("menuet")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_entry_point(value: str, /) -> Any: # noqa: ANN401
|
|
21
|
+
"""Create, load, and execute `value` entry point."""
|
|
22
|
+
from importlib.metadata import EntryPoint # noqa: PLC0415
|
|
23
|
+
|
|
24
|
+
func = EntryPoint(name="", group="", value=value).load()
|
|
25
|
+
func()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_resource(value: str, /) -> Traversable:
|
|
29
|
+
"""Load `value` resource."""
|
|
30
|
+
package, _, file = value.partition(":")
|
|
31
|
+
return importlib.resources.files(package).joinpath(file)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def copy_to_clipboard(value: str, /) -> None:
|
|
35
|
+
"""Copy `value` into the clipboard."""
|
|
36
|
+
try:
|
|
37
|
+
import copykitten # noqa: PLC0415
|
|
38
|
+
except ImportError as exc:
|
|
39
|
+
msg = "Unable to import 'copykitten', install extra 'menuet[copy]'"
|
|
40
|
+
raise RuntimeError(msg) from exc
|
|
41
|
+
|
|
42
|
+
copykitten.copy(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
CB_SCHEMES: dict[str, Callable[[str], Any]] = {
|
|
46
|
+
"exec": exec,
|
|
47
|
+
"ep": load_entry_point,
|
|
48
|
+
"url": webbrowser.open,
|
|
49
|
+
"copy": copy_to_clipboard,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ICON_SCHEMES: dict[str, Callable[[str], Traversable]] = {
|
|
53
|
+
"path": Path,
|
|
54
|
+
"res": load_resource,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_tuple_converter(value: Any) -> tuple[Any, ...]: # noqa: ANN401
|
|
59
|
+
"""Convert `value` into a tuple."""
|
|
60
|
+
if is_iterable(value):
|
|
61
|
+
return tuple(value)
|
|
62
|
+
return (value,)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_iterable(obj: Any) -> bool: # noqa: ANN401
|
|
66
|
+
"""Returns `True` if `obj` is Iterable."""
|
|
67
|
+
if isinstance(obj, list | tuple | set | dict):
|
|
68
|
+
return True
|
|
69
|
+
return not isinstance(obj, str) and isinstance(obj, Iterable)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def passthrough() -> None:
|
|
73
|
+
"""Callable that does nothing."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def to_cb_converter(value: Any) -> Callable[[], Any]: # noqa: ANN401
|
|
77
|
+
"""Convert `value` to a Callable that takes no argument."""
|
|
78
|
+
if callable(value):
|
|
79
|
+
return value # type: ignore[no-any-return]
|
|
80
|
+
|
|
81
|
+
if isinstance(value, str):
|
|
82
|
+
scheme, sep, rest = value.partition(":")
|
|
83
|
+
if not sep:
|
|
84
|
+
return partial(exec, scheme)
|
|
85
|
+
return partial(CB_SCHEMES.get(scheme, exec), rest)
|
|
86
|
+
|
|
87
|
+
raise TypeError(type(value))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def to_icon_converter(value: Any) -> Traversable | None: # noqa: ANN401
|
|
91
|
+
"""Convert `value` to icon path."""
|
|
92
|
+
if value is None:
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
if isinstance(value, Path):
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
if isinstance(value, str):
|
|
99
|
+
scheme, sep, rest = value.partition(":")
|
|
100
|
+
if not sep:
|
|
101
|
+
return Path(scheme)
|
|
102
|
+
|
|
103
|
+
converter = ICON_SCHEMES[scheme]
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
return converter(rest)
|
|
107
|
+
except Exception:
|
|
108
|
+
logger.exception("Failed to parse icon '%s'", value)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
raise TypeError(type(value))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def skip_n(iterable: Iterable[_T], *, n: int = 1) -> Iterable[_T]:
|
|
115
|
+
"""Drops `n` elements from the `iterable`."""
|
|
116
|
+
it = iterable.__iter__()
|
|
117
|
+
for _ in range(n):
|
|
118
|
+
next(it)
|
|
119
|
+
yield from it
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: menuet
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Declarative menu builder for DCC applications
|
|
5
|
+
Project-URL: Repository, https://codeberg.org/tahv/menuet
|
|
6
|
+
Author: Thibaud Gambier
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: attrs>=26.1.0
|
|
12
|
+
Requires-Dist: tomli>=2.3.0; python_version < '3.11'
|
|
13
|
+
Requires-Dist: typing-extensions>=4.15.0
|
|
14
|
+
Provides-Extra: copy
|
|
15
|
+
Requires-Dist: copykitten>=2.0.0; extra == 'copy'
|
|
16
|
+
Provides-Extra: qt
|
|
17
|
+
Requires-Dist: pyside6>=6.11.0; extra == 'qt'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# menuet
|
|
21
|
+
|
|
22
|
+
[](https://codeberg.org/tahv/menuet)
|
|
23
|
+
[](https://tahv.codeberg.page/menuet/)
|
|
24
|
+
|
|
25
|
+
Menuet (`/mə.nɥɛ/`) is a declarative menu builder for DCC applications.
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- Supports, 3ds Max, Maya, MotionBuilder and other any PySide6 application.
|
|
30
|
+
- Load menu from a TOML or JSON configuration, from a dict, from entry points,
|
|
31
|
+
or build it programmatically.
|
|
32
|
+
- Declare one or more menus in a dedicated `.toml` file.
|
|
33
|
+
- Compose menu from multiple `.toml` files.
|
|
34
|
+
- Declare a menu in a `pyproject.toml` directly.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```console
|
|
39
|
+
pip install menuet
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
Create a menu configuration in [TOML](https://toml.io/) format.
|
|
45
|
+
|
|
46
|
+
```toml
|
|
47
|
+
# menu.toml
|
|
48
|
+
[[action]]
|
|
49
|
+
id = "print-hello"
|
|
50
|
+
label = "Print Hello"
|
|
51
|
+
cb = "print('Hello')"
|
|
52
|
+
group = "Some Separator"
|
|
53
|
+
|
|
54
|
+
[[action]]
|
|
55
|
+
id = "open-gui"
|
|
56
|
+
label = "Open GUI"
|
|
57
|
+
cb = "ep:myapp.gui:open_gui"
|
|
58
|
+
menu = ["Foo", "Bar"]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Load the above configuration into a `Model` and pass
|
|
62
|
+
that model to a Menu Builder to create a menu.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from pathlib import Path
|
|
66
|
+
from menuet.builders.text import Render, TextMenuBuilder
|
|
67
|
+
from menuet.model import Model, loads
|
|
68
|
+
|
|
69
|
+
model = Model()
|
|
70
|
+
loads(Path("menu.toml").read_text(), model)
|
|
71
|
+
|
|
72
|
+
builder = TextMenuBuilder(model, root_menu="Demo", render=Render.UTF8)
|
|
73
|
+
print(builder.build())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
Demo
|
|
78
|
+
├── Foo
|
|
79
|
+
│ └── Bar
|
|
80
|
+
│ └── Open GUI
|
|
81
|
+
├── Some Separator ───
|
|
82
|
+
└── Print Hello
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
See the [documentation](https://tahv.codeberg.page/menuet/)
|
|
86
|
+
for more advanced patterns.
|
|
87
|
+
|
|
88
|
+
## Contributing
|
|
89
|
+
|
|
90
|
+
For guidance on setting up a development environment and contributing, see
|
|
91
|
+
[CONTRIBUTING.md](https://codeberg.org/tahv/menuet/src/branch/main/CONTRIBUTING.md).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
menuet/__init__.py,sha256=yScrmDmGM7uQeKA64Ot50_-wtURFN8yHcuGR0ai36V0,370
|
|
2
|
+
menuet/action.py,sha256=sKDJq47Rp-MjGrenMscoLzJgim5_UVeLHGlGCFsp5HY,2543
|
|
3
|
+
menuet/menu.py,sha256=XGmcH4DWtgf_E9ensAD3BwYR_wrUHadLhu4lC938pY0,1623
|
|
4
|
+
menuet/model.py,sha256=A3AJlH-hh0DUZQzy68oAQ9fUk04sI901E9ixRqJfYCI,8251
|
|
5
|
+
menuet/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
menuet/utils.py,sha256=wNZMslkjZ4YErb8dOqBbuU6KsUMdxlMv6kOYXxVz1-M,3180
|
|
7
|
+
menuet/builders/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
menuet/builders/qt.py,sha256=rdMBi0io5FLArakC8nrdWklZ-DGHciz4tQNLJ5euVq0,3569
|
|
9
|
+
menuet/builders/text.py,sha256=5OoiBNzHpGBFmeijCcyVswvIPI6Yp1njihOTg3xwH_8,3534
|
|
10
|
+
menuet-1.0.0.dist-info/METADATA,sha256=zkRNeBXdGuqe-UUp7X3uzkEeJKVYVIndafFXVk56cwE,2417
|
|
11
|
+
menuet-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
menuet-1.0.0.dist-info/licenses/LICENSE,sha256=-c8oQ0RpKfibPQPRBvKkx7AOe6Jo03v2XMC2pJfJNFI,1072
|
|
13
|
+
menuet-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thibaud Gambier
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|