pyguibud 0.1.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.
- pyguibud/__main__.py +8 -0
- pyguibud/_argparse.py +205 -0
- pyguibud/cli.py +155 -0
- pyguibud/codegen/__init__.py +198 -0
- pyguibud/codegen/_emit.py +516 -0
- pyguibud/codegen/_errors.py +81 -0
- pyguibud/codegen/_plan.py +116 -0
- pyguibud/codegen/_pot.py +150 -0
- pyguibud/codegen/_support.py +738 -0
- pyguibud/codegen/output.py +219 -0
- pyguibud/codegen/py.typed +0 -0
- pyguibud/credits.py +15 -0
- pyguibud/designer/__init__.py +48 -0
- pyguibud/designer/actions.py +273 -0
- pyguibud/designer/app.py +1074 -0
- pyguibud/designer/canvas/__init__.py +1 -0
- pyguibud/designer/canvas/design.py +107 -0
- pyguibud/designer/canvas/drag.py +682 -0
- pyguibud/designer/canvas/engine.py +433 -0
- pyguibud/designer/canvas/overlay.py +335 -0
- pyguibud/designer/clipboard.py +557 -0
- pyguibud/designer/clipboard_actions.py +163 -0
- pyguibud/designer/dialogs/__init__.py +1 -0
- pyguibud/designer/dialogs/about.py +52 -0
- pyguibud/designer/dialogs/base.py +235 -0
- pyguibud/designer/dialogs/confirm.py +57 -0
- pyguibud/designer/dialogs/export.py +297 -0
- pyguibud/designer/dialogs/files.py +91 -0
- pyguibud/designer/dialogs/message.py +81 -0
- pyguibud/designer/dialogs/new_file.py +114 -0
- pyguibud/designer/dialogs/plugins.py +183 -0
- pyguibud/designer/dialogs/recovery.py +215 -0
- pyguibud/designer/dialogs/rename.py +103 -0
- pyguibud/designer/dialogs/save_changes.py +34 -0
- pyguibud/designer/dialogs/shortcuts.py +84 -0
- pyguibud/designer/dialogs/text.py +126 -0
- pyguibud/designer/edit.py +327 -0
- pyguibud/designer/editing.py +705 -0
- pyguibud/designer/export.py +342 -0
- pyguibud/designer/files.py +466 -0
- pyguibud/designer/geometry.py +84 -0
- pyguibud/designer/icons/LICENSE-tabler.txt +21 -0
- pyguibud/designer/icons/icons.json +311 -0
- pyguibud/designer/icons.py +111 -0
- pyguibud/designer/icons_tk.py +102 -0
- pyguibud/designer/panels/__init__.py +1 -0
- pyguibud/designer/panels/base.py +136 -0
- pyguibud/designer/panels/canvas.py +793 -0
- pyguibud/designer/panels/editors.py +474 -0
- pyguibud/designer/panels/events.py +170 -0
- pyguibud/designer/panels/focus.py +41 -0
- pyguibud/designer/panels/inspector.py +528 -0
- pyguibud/designer/panels/palette.py +242 -0
- pyguibud/designer/panels/tree.py +424 -0
- pyguibud/designer/panels/variables.py +395 -0
- pyguibud/designer/present/__init__.py +95 -0
- pyguibud/designer/present/canvas.py +84 -0
- pyguibud/designer/present/focus.py +18 -0
- pyguibud/designer/present/icons.py +50 -0
- pyguibud/designer/present/inspector.py +174 -0
- pyguibud/designer/present/new_file.py +31 -0
- pyguibud/designer/present/palette.py +45 -0
- pyguibud/designer/present/plugins.py +137 -0
- pyguibud/designer/present/status.py +35 -0
- pyguibud/designer/present/tree.py +111 -0
- pyguibud/designer/preview.py +413 -0
- pyguibud/designer/preview_tk.py +541 -0
- pyguibud/designer/properties.py +467 -0
- pyguibud/designer/py.typed +0 -0
- pyguibud/designer/recovery.py +341 -0
- pyguibud/designer/render_plan.py +316 -0
- pyguibud/designer/screen.py +72 -0
- pyguibud/designer/session.py +341 -0
- pyguibud/designer/settings.py +403 -0
- pyguibud/designer/shell.py +869 -0
- pyguibud/designer/signals.py +42 -0
- pyguibud/designer/sizing.py +402 -0
- pyguibud/designer/standard_actions.py +569 -0
- pyguibud/designer/targets.py +246 -0
- pyguibud/designer/templates/__init__.py +68 -0
- pyguibud/designer/templates/dialog.ui +15 -0
- pyguibud/designer/templates/empty_component.ui +7 -0
- pyguibud/designer/templates/login_form.ui +35 -0
- pyguibud/designer/templates/main_window.ui +22 -0
- pyguibud/designer/templates/settings.ui +34 -0
- pyguibud/designer/theme.py +366 -0
- pyguibud/designer/theme_tk.py +335 -0
- pyguibud/designer/themes/__init__.py +95 -0
- pyguibud/designer/themes/pyguibud.png +0 -0
- pyguibud/designer/themes/sape.json +113 -0
- pyguibud/designer/tree_dnd.py +158 -0
- pyguibud/designer/windows.py +149 -0
- pyguibud/preview/__init__.py +264 -0
- pyguibud/preview/__main__.py +9 -0
- pyguibud/preview/_describe.py +372 -0
- pyguibud/preview/_describe_qt.py +199 -0
- pyguibud/preview/_host.py +236 -0
- pyguibud/preview/_host_qt.py +112 -0
- pyguibud/preview/py.typed +0 -0
- pyguibud/uic.py +309 -0
- pyguibud-0.1.0.dist-info/METADATA +155 -0
- pyguibud-0.1.0.dist-info/RECORD +106 -0
- pyguibud-0.1.0.dist-info/WHEEL +4 -0
- pyguibud-0.1.0.dist-info/entry_points.txt +3 -0
- pyguibud-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyguibud-0.1.0.dist-info/licenses/NOTICE.md +28 -0
pyguibud/__main__.py
ADDED
pyguibud/_argparse.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Translated argparse plumbing shared by ``pyguibud``, ``pyguibud-uic`` and the preview child.
|
|
2
|
+
|
|
3
|
+
It lives outside :mod:`pyguibud.cli` because the preview child must not import the CLI
|
|
4
|
+
(ADR-007/013 import boundary); like the CLI it loads no GUI toolkit.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import contextlib
|
|
11
|
+
import functools
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any, NoReturn, TypeVar, cast
|
|
17
|
+
|
|
18
|
+
from .credits import credit
|
|
19
|
+
from .i18n import (
|
|
20
|
+
LANGUAGE_CODE_RE,
|
|
21
|
+
N_,
|
|
22
|
+
NP_,
|
|
23
|
+
_,
|
|
24
|
+
detect_language,
|
|
25
|
+
ngettext,
|
|
26
|
+
normalise_language,
|
|
27
|
+
set_language,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_F = TypeVar("_F", bound=Callable[..., Any])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _PreParseError(Exception):
|
|
34
|
+
"""Raised by the pre-parser instead of exiting; the real parser reports the problem."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _QuietParser(argparse.ArgumentParser):
|
|
38
|
+
def error(self, message: str) -> NoReturn:
|
|
39
|
+
raise _PreParseError(message)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Every message of argparse that this parser can emit, marked so that ``pybabel extract`` keeps
|
|
43
|
+
# it in the catalogue (argparse's own catalogue has no pyguibud language). The set is the union of
|
|
44
|
+
# Python 3.10 to 3.13; unreachable texts (sub-commands, FileType, programmer errors) are left out.
|
|
45
|
+
# Translators: these are the texts of Python's argparse module. Keep every placeholder (%s,
|
|
46
|
+
# %(prog)s, %(value)r, ...) and the final newline of the "error" line exactly as in the source.
|
|
47
|
+
_ARGPARSE_MSGIDS = (
|
|
48
|
+
N_("%(heading)s:"),
|
|
49
|
+
N_("%(prog)s: error: %(message)s\n"),
|
|
50
|
+
N_(" (default: %(default)s)"),
|
|
51
|
+
N_("ambiguous option: %(option)s could match %(matches)s"),
|
|
52
|
+
N_("argument %(argument_name)s: %(message)s"),
|
|
53
|
+
N_("expected at least one argument"),
|
|
54
|
+
N_("expected at most one argument"),
|
|
55
|
+
N_("expected one argument"),
|
|
56
|
+
NP_("expected %s argument", "expected %s arguments"),
|
|
57
|
+
N_("ignored explicit argument %r"),
|
|
58
|
+
N_("invalid %(type)s value: %(value)r"),
|
|
59
|
+
N_("invalid choice: %(value)r (choose from %(choices)s)"),
|
|
60
|
+
N_("not allowed with argument %s"),
|
|
61
|
+
N_("one of the arguments %s is required"),
|
|
62
|
+
N_("optional arguments"), # Python 3.10 and older
|
|
63
|
+
N_("options"),
|
|
64
|
+
N_("positional arguments"),
|
|
65
|
+
N_("show program's version number and exit"),
|
|
66
|
+
N_("show this help message and exit"),
|
|
67
|
+
N_("the following arguments are required: %s"),
|
|
68
|
+
N_("unexpected option string: %s"),
|
|
69
|
+
N_("unrecognized arguments: %s"),
|
|
70
|
+
N_("usage: "),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _Swap:
|
|
75
|
+
"""argparse's ``_``/``ngettext`` while any wrapped call runs, in any thread (QA-19e-03).
|
|
76
|
+
|
|
77
|
+
The first call to enter saves and replaces them and the last to leave restores them, so
|
|
78
|
+
concurrent or nested parses never restore each other's state. The lock guards only the
|
|
79
|
+
swap: holding it during a parse would serialise every parser.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
lock = threading.Lock()
|
|
83
|
+
depth = 0
|
|
84
|
+
saved: tuple[Any, Any] = (None, None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _bound(method: _F) -> _F:
|
|
88
|
+
"""Run ``method`` with argparse's ``_`` and ``ngettext`` replaced by ours, then restore them."""
|
|
89
|
+
|
|
90
|
+
@functools.wraps(method)
|
|
91
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
|
92
|
+
with _Swap.lock:
|
|
93
|
+
if _Swap.depth == 0:
|
|
94
|
+
_Swap.saved = argparse._, argparse.ngettext # type: ignore[attr-defined]
|
|
95
|
+
argparse._, argparse.ngettext = _, ngettext # type: ignore[attr-defined]
|
|
96
|
+
_Swap.depth += 1
|
|
97
|
+
try:
|
|
98
|
+
return method(*args, **kwargs)
|
|
99
|
+
finally:
|
|
100
|
+
with _Swap.lock:
|
|
101
|
+
_Swap.depth -= 1
|
|
102
|
+
if _Swap.depth == 0:
|
|
103
|
+
argparse._, argparse.ngettext = _Swap.saved # type: ignore[attr-defined]
|
|
104
|
+
|
|
105
|
+
return cast(_F, wrapper)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _Formatter(argparse.HelpFormatter):
|
|
109
|
+
"""Help formatter that keeps line breaks in texts and translates hard-coded colons."""
|
|
110
|
+
|
|
111
|
+
class _Section(argparse.HelpFormatter._Section):
|
|
112
|
+
"""A help section whose heading colon is translated (``%(heading)s:``; French puts a space).
|
|
113
|
+
|
|
114
|
+
argparse translates it itself only from 3.11.9 and 3.12.3 (not 3.12.0), so an untranslated
|
|
115
|
+
heading line is replaced: a feature test, not a version test (QA-19e-02). Where argparse
|
|
116
|
+
translated the heading, the untranslated line is absent.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def format_help(self) -> str:
|
|
120
|
+
text = super().format_help()
|
|
121
|
+
if self.heading is None or self.heading is argparse.SUPPRESS:
|
|
122
|
+
return text
|
|
123
|
+
indent = " " * self.formatter._current_indent # as argparse indented the heading
|
|
124
|
+
plain = f"\n{indent}{self.heading}:\n"
|
|
125
|
+
if not text.startswith(plain):
|
|
126
|
+
return text
|
|
127
|
+
heading = _("%(heading)s:") % {"heading": self.heading}
|
|
128
|
+
return f"\n{indent}{heading}\n{text[len(plain) :]}"
|
|
129
|
+
|
|
130
|
+
def _fill_text(self, text: str, width: int, indent: str) -> str:
|
|
131
|
+
# The credit is never wrapped (--help, --version): a break could split "Claude Code".
|
|
132
|
+
keep, fill = credit(), super()._fill_text
|
|
133
|
+
lines = text.split("\n")
|
|
134
|
+
return "\n".join(
|
|
135
|
+
indent + line if line == keep else fill(line, width, indent) for line in lines
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_PLAIN_ARGUMENT = re.compile(r"argument (?P<argument_name>.+?): (?P<message>.*)", re.DOTALL)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class PyGUIbudParser(argparse.ArgumentParser):
|
|
143
|
+
"""An argument parser whose own texts (usage, titles, -h, errors) are translated.
|
|
144
|
+
|
|
145
|
+
argparse translates them with its private catalogue, which has no pyguibud languages, so
|
|
146
|
+
its ``_`` is bound to ours while this parser works. Build it only after the language is
|
|
147
|
+
chosen (``_select_initial_language``).
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
@_bound
|
|
151
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 - argparse's own
|
|
152
|
+
kwargs.setdefault("formatter_class", _Formatter)
|
|
153
|
+
kwargs["add_help"] = False
|
|
154
|
+
super().__init__(*args, **kwargs)
|
|
155
|
+
self.add_argument("-h", "--help", action="help", help=_("show this help message and exit"))
|
|
156
|
+
|
|
157
|
+
parse_known_args = _bound(argparse.ArgumentParser.parse_known_args)
|
|
158
|
+
parse_args = _bound(argparse.ArgumentParser.parse_args)
|
|
159
|
+
format_help = _bound(argparse.ArgumentParser.format_help)
|
|
160
|
+
format_usage = _bound(argparse.ArgumentParser.format_usage)
|
|
161
|
+
|
|
162
|
+
@_bound
|
|
163
|
+
def error(self, message: str) -> NoReturn:
|
|
164
|
+
# Before 3.11.9/3.12.3 ArgumentError.__str__ does not translate its prefix: a feature
|
|
165
|
+
# test, as for the heading colon (a translated prefix never matches the English one).
|
|
166
|
+
plain = _PLAIN_ARGUMENT.fullmatch(message)
|
|
167
|
+
if plain is not None:
|
|
168
|
+
message = _("argument %(argument_name)s: %(message)s") % plain.groupdict()
|
|
169
|
+
argparse.ArgumentParser.error(self, message)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _make_stdout_safe() -> None:
|
|
173
|
+
"""Never crash on characters the console encoding cannot represent."""
|
|
174
|
+
for stream in (sys.stdout, sys.stderr):
|
|
175
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
176
|
+
if callable(reconfigure):
|
|
177
|
+
with contextlib.suppress(ValueError, OSError):
|
|
178
|
+
reconfigure(errors="replace")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _select_initial_language(argv: list[str] | None, option: str = "--lang") -> None:
|
|
182
|
+
"""Set the language before the real parser is built so --help and errors are translated.
|
|
183
|
+
|
|
184
|
+
``option`` names the interface-language option (the preview child's is ``--ui-lang``).
|
|
185
|
+
"""
|
|
186
|
+
pre = _QuietParser(add_help=False, allow_abbrev=False)
|
|
187
|
+
pre.add_argument(option, dest="lang", default=None)
|
|
188
|
+
requested: str | None = None
|
|
189
|
+
try:
|
|
190
|
+
known, _rest = pre.parse_known_args(argv)
|
|
191
|
+
requested = known.lang
|
|
192
|
+
except _PreParseError:
|
|
193
|
+
pass
|
|
194
|
+
set_language(_requested_language(requested) or detect_language())
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _requested_language(value: str | None) -> str | None:
|
|
198
|
+
"""The supported code of a ``--lang`` value, or None.
|
|
199
|
+
|
|
200
|
+
Unlike an environment variable (``de_DE.UTF-8``), the option must be a plain language code,
|
|
201
|
+
so ``de_DE/../x`` or ``en_XA;rm`` is refused instead of being cut down to a language (QA-11-12).
|
|
202
|
+
"""
|
|
203
|
+
if value is None or LANGUAGE_CODE_RE.fullmatch(value) is None:
|
|
204
|
+
return None
|
|
205
|
+
return normalise_language(value)
|
pyguibud/cli.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""PyGUIbud command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .__about__ import __version__
|
|
12
|
+
from ._argparse import (
|
|
13
|
+
PyGUIbudParser,
|
|
14
|
+
_make_stdout_safe,
|
|
15
|
+
_requested_language,
|
|
16
|
+
_select_initial_language,
|
|
17
|
+
)
|
|
18
|
+
from .credits import credit
|
|
19
|
+
from .i18n import (
|
|
20
|
+
LANGUAGES,
|
|
21
|
+
PSEUDO_LANGUAGE,
|
|
22
|
+
_,
|
|
23
|
+
get_language,
|
|
24
|
+
set_language,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def version_text() -> str:
|
|
29
|
+
"""The ``--version`` text: program, version and the author credit (``%`` escaped)."""
|
|
30
|
+
return "%(prog)s " + f"{__version__}\n{credit()}".replace("%", "%%")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _language_arg(value: str) -> str:
|
|
34
|
+
code = _requested_language(value)
|
|
35
|
+
if code is None:
|
|
36
|
+
valid = ", ".join((*LANGUAGES, PSEUDO_LANGUAGE))
|
|
37
|
+
raise argparse.ArgumentTypeError(
|
|
38
|
+
# Translators: {code} is what the user typed, {valid} a list of language codes.
|
|
39
|
+
_("unknown language '{code}'; valid codes are: {valid}").format(code=value, valid=valid)
|
|
40
|
+
)
|
|
41
|
+
return code
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _print_languages() -> None:
|
|
45
|
+
active = get_language()
|
|
46
|
+
# Translators: heading above the list of selectable interface languages.
|
|
47
|
+
print(_("Available interface languages (* marks the active one):"))
|
|
48
|
+
for code, autonym in LANGUAGES.items():
|
|
49
|
+
print(f"{'*' if code == active else ' '} {code} {autonym}")
|
|
50
|
+
print()
|
|
51
|
+
# Translators: {code} is the pseudo-locale code; it is not a real language.
|
|
52
|
+
print(
|
|
53
|
+
_("The pseudo-locale {code} is also accepted; it is meant for testing layouts.").format(
|
|
54
|
+
code=PSEUDO_LANGUAGE
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
#: The ``--theme`` choices are fixed tokens (never translated), like option names.
|
|
60
|
+
THEME_CHOICES = ("light", "dark", "high-contrast")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _default_launch(lang: str | None, theme: str | None, **keywords: Any) -> int: # noqa: ANN401
|
|
64
|
+
"""Open the designer; Tk is imported only here, so --version and --help never load it."""
|
|
65
|
+
from pyguibud.designer import launch
|
|
66
|
+
|
|
67
|
+
return launch(lang, theme, **keywords)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv: list[str] | None = None, *, launch: Callable[..., int] | None = None) -> int:
|
|
71
|
+
"""Entry point for the PyGUIbud CLI.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
argv: Command-line arguments; defaults to sys.argv[1:].
|
|
75
|
+
launch: Opens the designer as ``launch(language, theme)`` (plus ``path=`` when a FILE
|
|
76
|
+
is given) and returns the exit code; ``language`` and ``theme`` are None when not
|
|
77
|
+
given, so the designer's settings decide; ``no_plugins=True`` only with
|
|
78
|
+
``--no-plugins``. Tests inject a fake. Defaults to
|
|
79
|
+
:func:`pyguibud.designer.launch`.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Exit code (0 on success).
|
|
83
|
+
"""
|
|
84
|
+
_make_stdout_safe()
|
|
85
|
+
_select_initial_language(argv)
|
|
86
|
+
|
|
87
|
+
parser = PyGUIbudParser(
|
|
88
|
+
prog="pyguibud",
|
|
89
|
+
description=_("PyGUIbud: drag-and-drop GUI builder for Python."),
|
|
90
|
+
epilog=credit(),
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--version",
|
|
94
|
+
action="version",
|
|
95
|
+
version=version_text(),
|
|
96
|
+
help=_("show the version number and exit"),
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--lang",
|
|
100
|
+
metavar="CODE",
|
|
101
|
+
type=_language_arg,
|
|
102
|
+
default=None,
|
|
103
|
+
help=_("interface language code, for example de_DE or en_XA"),
|
|
104
|
+
)
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--list-languages",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help=_("list the available interface languages and exit"),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--theme",
|
|
113
|
+
choices=THEME_CHOICES,
|
|
114
|
+
default=None,
|
|
115
|
+
# Translators: {default} is the --theme value used by default (light), inserted as it
|
|
116
|
+
# is: the option accepts only the untranslated names shown in its list of choices.
|
|
117
|
+
help=_("designer colour theme (default: the last one used, else {default})").format(
|
|
118
|
+
default=THEME_CHOICES[0]
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--no-plugins",
|
|
123
|
+
action="store_true",
|
|
124
|
+
# Translators: the safe-mode option; plugins are widget packages installed separately.
|
|
125
|
+
help=_("start without loading any plugin (safe mode)"),
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"file",
|
|
129
|
+
nargs="?",
|
|
130
|
+
metavar="FILE",
|
|
131
|
+
default=None,
|
|
132
|
+
help=_("a .ui file to open"),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
args = parser.parse_args(argv)
|
|
136
|
+
|
|
137
|
+
if args.lang is not None and args.lang != get_language():
|
|
138
|
+
set_language(args.lang)
|
|
139
|
+
|
|
140
|
+
if args.list_languages:
|
|
141
|
+
_print_languages()
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
keywords: dict[str, Any] = {"no_plugins": True} if args.no_plugins else {}
|
|
145
|
+
if args.file is not None:
|
|
146
|
+
from .core.files import is_network_or_device_path
|
|
147
|
+
|
|
148
|
+
# Never touch a network path, not even to test that it exists (R82).
|
|
149
|
+
if is_network_or_device_path(args.file):
|
|
150
|
+
parser.error(_("Network and device paths cannot be opened."))
|
|
151
|
+
if not os.path.exists(args.file):
|
|
152
|
+
# Translators: {path} is the file named on the command line.
|
|
153
|
+
parser.error(_("The file '{path}' does not exist.").format(path=args.file))
|
|
154
|
+
keywords["path"] = Path(os.path.abspath(args.file))
|
|
155
|
+
return (launch or _default_launch)(args.lang, args.theme, **keywords)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""``pyguibud.codegen``: Python code from ``.ui`` files (spec ``docs/specs/codegen.md``, ADR-008).
|
|
2
|
+
|
|
3
|
+
The generated module needs only the standard library and ``ttkbootstrap``. This package never
|
|
4
|
+
imports a toolkit, ``pyguibud_runtime`` or its own ``_support`` module: it copies the *text* of
|
|
5
|
+
``_support.py``. The output depends only on the ``.ui`` files (and their images), never on the
|
|
6
|
+
CLI language, the machine, the working directory, the hash seed or the time.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import keyword
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Final
|
|
17
|
+
|
|
18
|
+
from pyguibud.__about__ import __version__
|
|
19
|
+
from pyguibud.codegen._emit import Emitter, image_constant, lit
|
|
20
|
+
from pyguibud.codegen._errors import (
|
|
21
|
+
M_INVALID_ONE,
|
|
22
|
+
M_NOTHING,
|
|
23
|
+
M_OUTSIDE,
|
|
24
|
+
M_READ_ONLY,
|
|
25
|
+
M_UNSAFE,
|
|
26
|
+
CodegenError,
|
|
27
|
+
)
|
|
28
|
+
from pyguibud.codegen._plan import Plan
|
|
29
|
+
from pyguibud.codegen._pot import extract_pot
|
|
30
|
+
from pyguibud.core.diagnostics import Finding
|
|
31
|
+
from pyguibud.core.files import DiskFiles
|
|
32
|
+
from pyguibud.core.registry import default_registry
|
|
33
|
+
from pyguibud.core.validate import validate
|
|
34
|
+
from pyguibud.core.xml_io import ReadResult
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"FORMAT",
|
|
38
|
+
"GENERATED_MARKER",
|
|
39
|
+
"CodegenError",
|
|
40
|
+
"GeneratedModule",
|
|
41
|
+
"default_output",
|
|
42
|
+
"extract_pot",
|
|
43
|
+
"generate_python",
|
|
44
|
+
"support_text",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
GENERATED_MARKER: Final = "# Generated by pyguibud-uic"
|
|
48
|
+
FORMAT: Final = 1
|
|
49
|
+
|
|
50
|
+
_SUPPORT_FILE: Final = Path(__file__).with_name("_support.py")
|
|
51
|
+
_BEGIN: Final = "# --- BEGIN SUPPORT ---"
|
|
52
|
+
_END: Final = "# --- END SUPPORT ---"
|
|
53
|
+
_NOT_NAME: Final = re.compile(r"[^A-Za-z0-9_]")
|
|
54
|
+
# Warnings for --check and load_ui, refused by the code generator unless allowed (QA-11-03).
|
|
55
|
+
_OUTSIDE_CODES: Final = frozenset({"validate.absolute_path", "validate.path_escapes"})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class GeneratedModule:
|
|
60
|
+
"""The text of a generated module (LF newlines, ends with one newline)."""
|
|
61
|
+
|
|
62
|
+
text: str
|
|
63
|
+
class_name: str
|
|
64
|
+
warnings: tuple[Finding, ...]
|
|
65
|
+
#: Every .ui file the module was generated from (the input and its included files).
|
|
66
|
+
sources: tuple[str, ...] = ()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def support_text() -> str:
|
|
70
|
+
"""The support region of ``_support.py``, without its markers (CRLF-safe)."""
|
|
71
|
+
text = _SUPPORT_FILE.read_bytes().decode("utf-8").replace("\r\n", "\n")
|
|
72
|
+
lines = text.split("\n")
|
|
73
|
+
region = lines[lines.index(_BEGIN) + 1 : lines.index(_END)]
|
|
74
|
+
return "\n".join(region).strip("\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _stem_name(path: str) -> str:
|
|
78
|
+
stem = _NOT_NAME.sub("_", os.path.splitext(os.path.basename(path))[0])
|
|
79
|
+
return "_" + stem if stem[:1].isdigit() else stem
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def default_output(path: str) -> str:
|
|
83
|
+
"""``"dir/my-form.ui"`` -> ``"dir/my_form_ui.py"`` (an importable module name)."""
|
|
84
|
+
return os.path.join(os.path.dirname(path), _stem_name(path) + "_ui.py")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _class_name(main_id: str | None, path: str) -> str:
|
|
88
|
+
if main_id:
|
|
89
|
+
base = main_id[:1].upper() + main_id[1:]
|
|
90
|
+
else:
|
|
91
|
+
# A stem without ASCII letters or digits ("日本.ui", "__.ui") gives a fixed name.
|
|
92
|
+
stem = _stem_name(path)
|
|
93
|
+
base = stem if stem.strip("_") else "Form"
|
|
94
|
+
name = "Ui_" + base
|
|
95
|
+
if not name.isascii() or not name.isidentifier() or keyword.iskeyword(name):
|
|
96
|
+
raise CodegenError("codegen.unsafe_name", M_UNSAFE, name=name)
|
|
97
|
+
return name
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def generate_python(
|
|
101
|
+
result: ReadResult, path: str, *, output: str, allow_outside: bool = False
|
|
102
|
+
) -> GeneratedModule:
|
|
103
|
+
"""Validate ``result`` (read from ``path``) with its includes and images, then generate.
|
|
104
|
+
|
|
105
|
+
``output`` is the path the module will be written to; it is used only to compute the
|
|
106
|
+
relative catalogue folders and never appears in the text. An image or include path that is
|
|
107
|
+
absolute or leaves the folder of its ``.ui`` file is only a warning for ``--check`` and
|
|
108
|
+
``load_ui``, but the code generator refuses it unless ``allow_outside`` (``--force``).
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
CodegenError: ``codegen.invalid_document``, ``codegen.read_only``,
|
|
112
|
+
``codegen.outside_path``, ``codegen.nothing_to_generate``,
|
|
113
|
+
``codegen.unsafe_name`` or ``codegen.image_changed``.
|
|
114
|
+
"""
|
|
115
|
+
registry = default_registry()
|
|
116
|
+
files = DiskFiles(path, result)
|
|
117
|
+
report = validate(
|
|
118
|
+
result.document,
|
|
119
|
+
registry,
|
|
120
|
+
source=result.source,
|
|
121
|
+
positions=result.positions,
|
|
122
|
+
files=files,
|
|
123
|
+
)
|
|
124
|
+
errors = report.errors
|
|
125
|
+
if errors:
|
|
126
|
+
raise CodegenError(
|
|
127
|
+
"codegen.invalid_document", M_INVALID_ONE, findings=errors, path=path, count=len(errors)
|
|
128
|
+
)
|
|
129
|
+
if result.read_only:
|
|
130
|
+
raise CodegenError(
|
|
131
|
+
"codegen.read_only", M_READ_ONLY, path=path, version=result.source_version
|
|
132
|
+
)
|
|
133
|
+
outside = tuple(f for f in report.warnings if f.code in _OUTSIDE_CODES)
|
|
134
|
+
if outside and not allow_outside: # QA-11-03: the module would embed files from elsewhere
|
|
135
|
+
raise CodegenError(
|
|
136
|
+
"codegen.outside_path",
|
|
137
|
+
M_OUTSIDE,
|
|
138
|
+
findings=outside,
|
|
139
|
+
path=path,
|
|
140
|
+
src=outside[0].params.get("path", ""),
|
|
141
|
+
force="--force",
|
|
142
|
+
)
|
|
143
|
+
document = result.document
|
|
144
|
+
mains = [w for w in document.children if w.kind == "main"]
|
|
145
|
+
windows = mains + [w for w in document.children if w.kind == "dialog"]
|
|
146
|
+
if not windows:
|
|
147
|
+
raise CodegenError("codegen.nothing_to_generate", M_NOTHING, path=path)
|
|
148
|
+
class_name = _class_name(mains[0].id if mains else None, path)
|
|
149
|
+
plan = Plan(files, document, result.source, output)
|
|
150
|
+
emitter = Emitter(plan, registry)
|
|
151
|
+
build = emitter.build(windows)
|
|
152
|
+
docs = tuple(
|
|
153
|
+
(
|
|
154
|
+
doc.document.meta.i18n_domain,
|
|
155
|
+
doc.document.meta.source_locale,
|
|
156
|
+
plan.locale_dir(doc, output),
|
|
157
|
+
)
|
|
158
|
+
for doc in plan.docs
|
|
159
|
+
)
|
|
160
|
+
lines = [
|
|
161
|
+
f"{GENERATED_MARKER} {__version__} (format {FORMAT}) from "
|
|
162
|
+
f"{os.path.basename(path)!a}. Do not edit: changes are lost on regeneration.",
|
|
163
|
+
"# Requires Python >= 3.10 and ttkbootstrap >= 2.2, < 3. This module does not import "
|
|
164
|
+
"PyGUIbud.",
|
|
165
|
+
"from __future__ import annotations",
|
|
166
|
+
"",
|
|
167
|
+
f"# --- support: identical in every generated module (pyguibud-uic format {FORMAT}) ---",
|
|
168
|
+
support_text(),
|
|
169
|
+
"# --- end of support ---",
|
|
170
|
+
"",
|
|
171
|
+
f"_DOCS = {lit(docs)}",
|
|
172
|
+
f"_HANDLERS = {lit(tuple(sorted(emitter.handlers)))}",
|
|
173
|
+
f"_THEME = {lit(document.meta.theme)}",
|
|
174
|
+
]
|
|
175
|
+
for image in plan.images:
|
|
176
|
+
lines += image_constant(image.number, image.data)
|
|
177
|
+
lines += [
|
|
178
|
+
"",
|
|
179
|
+
"",
|
|
180
|
+
f"class {class_name}(_Ui):",
|
|
181
|
+
' """Generated interface; build it with setup_ui()."""',
|
|
182
|
+
"",
|
|
183
|
+
" _docs = _DOCS",
|
|
184
|
+
" _handler_names = _HANDLERS",
|
|
185
|
+
" _theme_name = _THEME",
|
|
186
|
+
*([] if mains else [" _has_main = False"]),
|
|
187
|
+
"",
|
|
188
|
+
*build,
|
|
189
|
+
]
|
|
190
|
+
for number in sorted(emitter.methods):
|
|
191
|
+
lines += ["", *emitter.methods[number]]
|
|
192
|
+
lines += ["", "", 'if __name__ == "__main__":', f" _preview({class_name})"]
|
|
193
|
+
return GeneratedModule(
|
|
194
|
+
text="\n".join(lines) + "\n",
|
|
195
|
+
class_name=class_name,
|
|
196
|
+
warnings=(*report.warnings, *plan.warnings),
|
|
197
|
+
sources=tuple(files.path_of(doc.key) for doc in plan.docs),
|
|
198
|
+
)
|