readymade 0.0.1__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.
readymade/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """readymade: Visual Basic style forms for Python and Tkinter."""
2
+
3
+ from readymade.events import doevents, in_thread, pause, while_running
4
+ from readymade.dialogs import (color_dialog, folder_dialog, font_dialog, inputbox, msgbox, open_file_dialog,
5
+ save_file_dialog)
6
+ from readymade.form import Form, app_path
7
+
8
+ __all__ = [
9
+ "Form", "app_path", "color_dialog", "doevents", "folder_dialog", "font_dialog", "in_thread", "inputbox", "msgbox",
10
+ "open_file_dialog", "pause", "save_file_dialog", "while_running",
11
+ ]
@@ -0,0 +1,96 @@
1
+ """How controls look, in readymade's own terms: colors, fonts, alignment and mouse
2
+ cursors, and what they become in Tk.
3
+
4
+ Colors are "#rrggbb" strings, with "" for the usual color. A font is a string like
5
+ "Arial, 12, bold" (family, size in points, then any of bold, italic and underline),
6
+ or the same as a tuple, ("Arial", 12, "bold"); empty parts mean the usual font's.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ ALIGNMENTS = ("left", "center", "right")
14
+ VALIGNMENTS = ("top", "middle", "bottom")
15
+ FONT_STYLES = ("bold", "italic", "underline")
16
+ # Mouse cursors by readymade name, and Tk's names for them (which Tk maps to each system's own).
17
+ CURSORS = {
18
+ "default": "", # the control's usual cursor (a text box's is "text")
19
+ "arrow": "arrow",
20
+ "hand": "hand2",
21
+ "text": "xterm",
22
+ "cross": "crosshair",
23
+ "wait": "watch",
24
+ "move": "fleur",
25
+ "help": "question_arrow",
26
+ "resize_ew": "sb_h_double_arrow",
27
+ "resize_ns": "sb_v_double_arrow",
28
+ }
29
+
30
+ _HEX = re.compile(r"#[0-9a-fA-F]{6}")
31
+
32
+
33
+ def check_color(value):
34
+ """value as a "#rrggbb" color (or "" for the usual); ValueError if it isn't one."""
35
+ value = "" if value is None else str(value).strip()
36
+ if value and not _HEX.fullmatch(value):
37
+ raise ValueError(f"{value!r} isn't a color; use \"#rrggbb\", like \"#ff0000\" for red (or \"\" for the usual color)")
38
+ return value.lower()
39
+
40
+
41
+ def lighter(color, amount=0.2):
42
+ """A "#rrggbb" color mixed with some white (for a hovered button)."""
43
+ channels = [int(color[i:i + 2], 16) for i in (1, 3, 5)]
44
+ return "#" + "".join(f"{round(c + (255 - c) * amount):02x}" for c in channels)
45
+
46
+
47
+ def parse_font(value):
48
+ """(family, size, styles) from a font string or tuple; "" and 0 mean the usual."""
49
+ if value is None or value == "":
50
+ return "", 0, ()
51
+ parts = [p.strip() for p in value.split(",")] if isinstance(value, str) else [str(p).strip() for p in value]
52
+ family = parts[0] if parts else ""
53
+ size = 0
54
+ if len(parts) > 1 and parts[1]:
55
+ try:
56
+ size = int(float(parts[1]))
57
+ except ValueError:
58
+ raise ValueError(f"{value!r}: the font's size, after the first comma, must be a number") from None
59
+ if size < 0:
60
+ raise ValueError(f"{value!r}: the font's size can't be negative")
61
+ styles = []
62
+ for word in " ".join(parts[2:]).lower().split():
63
+ if word not in FONT_STYLES:
64
+ raise ValueError(f"{value!r}: {word!r} isn't a font style; use bold, italic or underline")
65
+ if word not in styles:
66
+ styles.append(word)
67
+ return family, size, tuple(s for s in FONT_STYLES if s in styles)
68
+
69
+
70
+ def format_font(value):
71
+ """The tidy string form of a font: "Arial, 12, bold"; "" for the usual font."""
72
+ family, size, styles = parse_font(value)
73
+ parts = [family, str(size) if size else "", " ".join(styles)]
74
+ while parts and not parts[-1]:
75
+ parts.pop()
76
+ return ", ".join(parts)
77
+
78
+
79
+ def tk_font(value, usual):
80
+ """A Tk font description for a font, filling empty parts from usual (a dict like
81
+ tkinter.font.Font.actual() gives)."""
82
+ family, size, styles = parse_font(value)
83
+ return (family or usual["family"], size or usual["size"], *styles)
84
+
85
+
86
+ def anchor(alignment, valignment="middle"):
87
+ """Tk's compass-point anchor for an alignment pair: ("left", "top") is "nw"."""
88
+ across = {"left": "w", "center": "", "right": "e"}[alignment]
89
+ down = {"top": "n", "middle": "", "bottom": "s"}[valignment]
90
+ return (down + across) or "center"
91
+
92
+
93
+ def check_choice(name, value, choices):
94
+ if value not in choices:
95
+ raise ValueError(f"{name} must be one of: {', '.join(choices)}")
96
+ return value
readymade/codegen.py ADDED
@@ -0,0 +1,104 @@
1
+ """Small source edits on a form's .py module: adding, finding and renaming handlers.
2
+
3
+ These work on lines and regular expressions rather than the ast, so they keep
4
+ working while the code has a syntax error in it, which is most of the time
5
+ during editing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ INDENT = " "
14
+
15
+
16
+ class CodeGenError(Exception):
17
+ pass
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Edit:
22
+ """Replace lines [start, end) (0-based) with text."""
23
+
24
+ start: int
25
+ end: int
26
+ text: str
27
+ method_line: int # 1-based line of the new def once the edit is applied
28
+
29
+ def apply(self, source):
30
+ lines = source.splitlines(keepends=True)
31
+ return "".join(lines[: self.start]) + self.text + "".join(lines[self.end :])
32
+
33
+
34
+ def new_module(class_name):
35
+ return (
36
+ "from readymade import Form\n"
37
+ "\n"
38
+ "\n"
39
+ f"class {class_name}(Form):\n"
40
+ f"{INDENT}pass\n"
41
+ "\n"
42
+ "\n"
43
+ 'if __name__ == "__main__":\n'
44
+ f"{INDENT}{class_name}.run()\n"
45
+ )
46
+
47
+
48
+ def defined_methods(source):
49
+ return set(re.findall(r"^[ \t]+(?:async[ \t]+)?def[ \t]+(\w+)[ \t]*\(", source, re.M))
50
+
51
+
52
+ def find_method(source, name):
53
+ """The 1-based line of `def name(`, or None."""
54
+ m = re.search(rf"^[ \t]+(?:async[ \t]+)?def[ \t]+{re.escape(name)}[ \t]*\(", source, re.M)
55
+ return source.count("\n", 0, m.start()) + 1 if m else None
56
+
57
+
58
+ def add_method(source, class_name, name, params=()):
59
+ """An Edit that adds an empty method at the end of the class."""
60
+ lines = source.splitlines(keepends=True)
61
+ class_re = re.compile(rf"class[ \t]+{re.escape(class_name)}\b")
62
+ start = next((i for i, line in enumerate(lines) if class_re.match(line)), None)
63
+ if start is None:
64
+ raise CodeGenError(f"Couldn't find 'class {class_name}' in the code.")
65
+
66
+ # The class body runs until the first non-blank, non-comment line at column 0.
67
+ last = start
68
+ for i in range(start + 1, len(lines)):
69
+ line = lines[i]
70
+ if not line.strip() or line.lstrip().startswith("#"):
71
+ continue
72
+ if line[0] not in " \t":
73
+ break
74
+ last = i
75
+
76
+ body = [i for i in range(start + 1, last + 1) if lines[i].strip() and not lines[i].lstrip().startswith("#")]
77
+ indent = INDENT
78
+ if body:
79
+ first = lines[body[0]]
80
+ indent = first[: len(first) - len(first.lstrip())]
81
+ signature = ", ".join(("self", *params))
82
+ method = f"{indent}def {name}({signature}):\n{indent}{indent}pass\n"
83
+
84
+ # A new class holds only a placeholder `pass`; replace it.
85
+ if len(body) == 1 and lines[body[0]].strip() == "pass":
86
+ return Edit(body[0], body[0] + 1, method, method_line=body[0] + 1)
87
+
88
+ insert_at = last + 1
89
+ prefix = "" if lines[last].endswith("\n") else "\n"
90
+ return Edit(insert_at, insert_at, prefix + "\n" + method, method_line=insert_at + 2)
91
+
92
+
93
+ def rename_control(source, old, new, events):
94
+ """Rename a control's handlers and its `self.old` references."""
95
+ if events:
96
+ names = "|".join(re.escape(e) for e in events)
97
+ pattern = rf"^([ \t]+(?:async[ \t]+)?def[ \t]+){re.escape(old)}_({names})(?=[ \t]*\()"
98
+ source = re.sub(pattern, rf"\g<1>{new}_\g<2>", source, flags=re.M)
99
+ return re.sub(rf"\bself\.{re.escape(old)}\b", f"self.{new}", source)
100
+
101
+
102
+ def rename_class(source, old, new):
103
+ """Rename the form class everywhere it appears as a whole word."""
104
+ return re.sub(rf"\b{re.escape(old)}\b", new, source)