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 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)
@@ -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
+ [![Source](https://img.shields.io/badge/Codeberg-%232185D0?logo=codeberg&logoColor=white)](https://codeberg.org/tahv/menuet)
23
+ [![Documentation](https://img.shields.io/badge/Documentation-teal)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.