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.
Files changed (106) hide show
  1. pyguibud/__main__.py +8 -0
  2. pyguibud/_argparse.py +205 -0
  3. pyguibud/cli.py +155 -0
  4. pyguibud/codegen/__init__.py +198 -0
  5. pyguibud/codegen/_emit.py +516 -0
  6. pyguibud/codegen/_errors.py +81 -0
  7. pyguibud/codegen/_plan.py +116 -0
  8. pyguibud/codegen/_pot.py +150 -0
  9. pyguibud/codegen/_support.py +738 -0
  10. pyguibud/codegen/output.py +219 -0
  11. pyguibud/codegen/py.typed +0 -0
  12. pyguibud/credits.py +15 -0
  13. pyguibud/designer/__init__.py +48 -0
  14. pyguibud/designer/actions.py +273 -0
  15. pyguibud/designer/app.py +1074 -0
  16. pyguibud/designer/canvas/__init__.py +1 -0
  17. pyguibud/designer/canvas/design.py +107 -0
  18. pyguibud/designer/canvas/drag.py +682 -0
  19. pyguibud/designer/canvas/engine.py +433 -0
  20. pyguibud/designer/canvas/overlay.py +335 -0
  21. pyguibud/designer/clipboard.py +557 -0
  22. pyguibud/designer/clipboard_actions.py +163 -0
  23. pyguibud/designer/dialogs/__init__.py +1 -0
  24. pyguibud/designer/dialogs/about.py +52 -0
  25. pyguibud/designer/dialogs/base.py +235 -0
  26. pyguibud/designer/dialogs/confirm.py +57 -0
  27. pyguibud/designer/dialogs/export.py +297 -0
  28. pyguibud/designer/dialogs/files.py +91 -0
  29. pyguibud/designer/dialogs/message.py +81 -0
  30. pyguibud/designer/dialogs/new_file.py +114 -0
  31. pyguibud/designer/dialogs/plugins.py +183 -0
  32. pyguibud/designer/dialogs/recovery.py +215 -0
  33. pyguibud/designer/dialogs/rename.py +103 -0
  34. pyguibud/designer/dialogs/save_changes.py +34 -0
  35. pyguibud/designer/dialogs/shortcuts.py +84 -0
  36. pyguibud/designer/dialogs/text.py +126 -0
  37. pyguibud/designer/edit.py +327 -0
  38. pyguibud/designer/editing.py +705 -0
  39. pyguibud/designer/export.py +342 -0
  40. pyguibud/designer/files.py +466 -0
  41. pyguibud/designer/geometry.py +84 -0
  42. pyguibud/designer/icons/LICENSE-tabler.txt +21 -0
  43. pyguibud/designer/icons/icons.json +311 -0
  44. pyguibud/designer/icons.py +111 -0
  45. pyguibud/designer/icons_tk.py +102 -0
  46. pyguibud/designer/panels/__init__.py +1 -0
  47. pyguibud/designer/panels/base.py +136 -0
  48. pyguibud/designer/panels/canvas.py +793 -0
  49. pyguibud/designer/panels/editors.py +474 -0
  50. pyguibud/designer/panels/events.py +170 -0
  51. pyguibud/designer/panels/focus.py +41 -0
  52. pyguibud/designer/panels/inspector.py +528 -0
  53. pyguibud/designer/panels/palette.py +242 -0
  54. pyguibud/designer/panels/tree.py +424 -0
  55. pyguibud/designer/panels/variables.py +395 -0
  56. pyguibud/designer/present/__init__.py +95 -0
  57. pyguibud/designer/present/canvas.py +84 -0
  58. pyguibud/designer/present/focus.py +18 -0
  59. pyguibud/designer/present/icons.py +50 -0
  60. pyguibud/designer/present/inspector.py +174 -0
  61. pyguibud/designer/present/new_file.py +31 -0
  62. pyguibud/designer/present/palette.py +45 -0
  63. pyguibud/designer/present/plugins.py +137 -0
  64. pyguibud/designer/present/status.py +35 -0
  65. pyguibud/designer/present/tree.py +111 -0
  66. pyguibud/designer/preview.py +413 -0
  67. pyguibud/designer/preview_tk.py +541 -0
  68. pyguibud/designer/properties.py +467 -0
  69. pyguibud/designer/py.typed +0 -0
  70. pyguibud/designer/recovery.py +341 -0
  71. pyguibud/designer/render_plan.py +316 -0
  72. pyguibud/designer/screen.py +72 -0
  73. pyguibud/designer/session.py +341 -0
  74. pyguibud/designer/settings.py +403 -0
  75. pyguibud/designer/shell.py +869 -0
  76. pyguibud/designer/signals.py +42 -0
  77. pyguibud/designer/sizing.py +402 -0
  78. pyguibud/designer/standard_actions.py +569 -0
  79. pyguibud/designer/targets.py +246 -0
  80. pyguibud/designer/templates/__init__.py +68 -0
  81. pyguibud/designer/templates/dialog.ui +15 -0
  82. pyguibud/designer/templates/empty_component.ui +7 -0
  83. pyguibud/designer/templates/login_form.ui +35 -0
  84. pyguibud/designer/templates/main_window.ui +22 -0
  85. pyguibud/designer/templates/settings.ui +34 -0
  86. pyguibud/designer/theme.py +366 -0
  87. pyguibud/designer/theme_tk.py +335 -0
  88. pyguibud/designer/themes/__init__.py +95 -0
  89. pyguibud/designer/themes/pyguibud.png +0 -0
  90. pyguibud/designer/themes/sape.json +113 -0
  91. pyguibud/designer/tree_dnd.py +158 -0
  92. pyguibud/designer/windows.py +149 -0
  93. pyguibud/preview/__init__.py +264 -0
  94. pyguibud/preview/__main__.py +9 -0
  95. pyguibud/preview/_describe.py +372 -0
  96. pyguibud/preview/_describe_qt.py +199 -0
  97. pyguibud/preview/_host.py +236 -0
  98. pyguibud/preview/_host_qt.py +112 -0
  99. pyguibud/preview/py.typed +0 -0
  100. pyguibud/uic.py +309 -0
  101. pyguibud-0.1.0.dist-info/METADATA +155 -0
  102. pyguibud-0.1.0.dist-info/RECORD +106 -0
  103. pyguibud-0.1.0.dist-info/WHEEL +4 -0
  104. pyguibud-0.1.0.dist-info/entry_points.txt +3 -0
  105. pyguibud-0.1.0.dist-info/licenses/LICENSE +21 -0
  106. pyguibud-0.1.0.dist-info/licenses/NOTICE.md +28 -0
pyguibud/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """PyGUIbud command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
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
+ )