taut.tk 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.
taut/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """A Solid-style layer for Tkinter powered by reaktiv."""
2
+
3
+ from . import context
4
+ from . import control
5
+ from . import layout
6
+ from . import props
7
+ from . import reactive
8
+ from . import resource
9
+ from . import runtime
10
+ from . import stores
11
+ from . import style
12
+ from . import tk
13
+ from . import tk_props
14
+ from . import ttk
15
+ from .component import Component
16
+ from .component import component
17
+ from .runtime import Fragment
18
+
19
+ __all__ = [
20
+ "Component",
21
+ "Fragment",
22
+ "component",
23
+ "context",
24
+ "control",
25
+ "layout",
26
+ "props",
27
+ "reactive",
28
+ "resource",
29
+ "runtime",
30
+ "style",
31
+ "stores",
32
+ "tk",
33
+ "tk_props",
34
+ "ttk",
35
+ ]
taut/cli/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ import argparse
2
+ from pathlib import Path
3
+
4
+ from taut.cli.stubs import main_cli as stubby
5
+ from taut.cli.watch import main as watcher
6
+
7
+
8
+ def main():
9
+ parser = argparse.ArgumentParser(description="taut.tk development tools.")
10
+ subparsers = parser.add_subparsers(dest="command", required=True)
11
+
12
+ parser_stubs = subparsers.add_parser("stubs", help="Generate taut.tk .pyi stubs.")
13
+ parser_stubs.add_argument("paths", nargs="+", type=Path)
14
+ parser_stubs.add_argument(
15
+ "-o",
16
+ "--out-dir",
17
+ default="typings",
18
+ type=Path,
19
+ help="Directory for generated stubs. Defaults to typings.",
20
+ )
21
+ parser_stubs.add_argument(
22
+ "--in-place",
23
+ action="store_true",
24
+ help="Write sibling .pyi files next to source files.",
25
+ )
26
+ parser_stubs.set_defaults(func=stubby)
27
+
28
+ parser_watch = subparsers.add_parser("watch", help="Regenerate .pyi stubs on edits.")
29
+ parser_watch.set_defaults(func=watcher)
30
+
31
+ args = parser.parse_args()
32
+ args.func(args)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
taut/cli/stub_model.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class PropField:
9
+ name: str
10
+ internal_type: str
11
+ external_type: str
12
+
13
+
14
+ @dataclass
15
+ class ComponentStub:
16
+ name: str
17
+ fields: list[PropField]
18
+
19
+
20
+ @dataclass
21
+ class ImportStub:
22
+ module: str
23
+ name: str
24
+ export_name: str
25
+
26
+
27
+ def collect_protocols(module: ast.Module) -> dict[str, list[PropField]]:
28
+ protocols: dict[str, list[PropField]] = {}
29
+ for node in module.body:
30
+ if not isinstance(node, ast.ClassDef):
31
+ continue
32
+ if not any(base_name(base) == "Protocol" for base in node.bases):
33
+ continue
34
+
35
+ protocols[node.name] = [
36
+ PropField(
37
+ statement.target.id,
38
+ ast.unparse(statement.annotation),
39
+ external_prop_type(ast.unparse(statement.annotation)),
40
+ )
41
+ for statement in node.body
42
+ if isinstance(statement, ast.AnnAssign)
43
+ and isinstance(statement.target, ast.Name)
44
+ ]
45
+ return protocols
46
+
47
+
48
+ def collect_components(
49
+ module: ast.Module,
50
+ protocols: dict[str, list[PropField]],
51
+ ) -> list[ComponentStub]:
52
+ components: list[ComponentStub] = []
53
+ for node in module.body:
54
+ if isinstance(node, ast.FunctionDef) and is_component_function(node):
55
+ protocol_name = first_arg_annotation(node)
56
+ elif isinstance(node, ast.ClassDef) and is_component_class(node):
57
+ protocol_name = init_props_annotation(node)
58
+ else:
59
+ continue
60
+
61
+ if protocol_name is None:
62
+ components.append(ComponentStub(node.name, []))
63
+ elif protocol_name in protocols:
64
+ components.append(ComponentStub(node.name, protocols[protocol_name]))
65
+ return components
66
+
67
+
68
+ def collect_public_imports(module: ast.Module) -> list[ImportStub]:
69
+ imports: list[ImportStub] = []
70
+ for node in module.body:
71
+ if not isinstance(node, ast.ImportFrom) or node.module is None:
72
+ continue
73
+ if node.module in {"__future__", "typing"} or node.module.startswith("solid_tk"):
74
+ continue
75
+ module_name = "." * node.level + node.module
76
+ for alias in node.names:
77
+ export_name = alias.asname or alias.name
78
+ if export_name.startswith("_"):
79
+ continue
80
+ imports.append(ImportStub(module_name, alias.name, export_name))
81
+ return imports
82
+
83
+
84
+ def render_component(component: ComponentStub) -> list[str]:
85
+ fields = [field for field in component.fields if field.name != "children"]
86
+ children_field = next(
87
+ (field for field in component.fields if field.name == "children"),
88
+ None,
89
+ )
90
+
91
+ if not component.fields:
92
+ return [
93
+ f"def {component.name}("
94
+ "*child_nodes: Any, children: Any = ..."
95
+ ") -> runtime.Node: ..."
96
+ ]
97
+
98
+ lines = [f"def {component.name}(", " *child_nodes: Any,"]
99
+ lines.extend(f" {field.name}: {field.external_type}," for field in fields)
100
+ if children_field is not None:
101
+ lines.append(f" children: {children_field.external_type},")
102
+ else:
103
+ lines.append(" children: Any = ...,")
104
+ lines.append(") -> runtime.Node: ...")
105
+ return lines
106
+
107
+
108
+ def is_component_function(node: ast.FunctionDef) -> bool:
109
+ return any(base_name(decorator) == "component" for decorator in node.decorator_list)
110
+
111
+
112
+ def is_component_class(node: ast.ClassDef) -> bool:
113
+ return any(base_name(base) == "Component" for base in node.bases)
114
+
115
+
116
+ def first_arg_annotation(node: ast.FunctionDef) -> str | None:
117
+ if not node.args.args:
118
+ return None
119
+ annotation = node.args.args[0].annotation
120
+ return ast.unparse(annotation) if annotation is not None else None
121
+
122
+
123
+ def init_props_annotation(node: ast.ClassDef) -> str | None:
124
+ for statement in node.body:
125
+ if not isinstance(statement, ast.FunctionDef) or statement.name != "__init__":
126
+ continue
127
+ if len(statement.args.args) < 2:
128
+ return None
129
+ annotation = statement.args.args[1].annotation
130
+ return ast.unparse(annotation) if annotation is not None else None
131
+ return None
132
+
133
+
134
+ def external_prop_type(internal_type: str) -> str:
135
+ if internal_type.startswith("Accessor[") and internal_type.endswith("]"):
136
+ inner = internal_type.removeprefix("Accessor[").removesuffix("]")
137
+ return f"{inner} | reactive.Accessor[{inner}]"
138
+ if internal_type.startswith("reactive.Accessor[") and internal_type.endswith("]"):
139
+ inner = internal_type.removeprefix("reactive.Accessor[").removesuffix("]")
140
+ return f"{inner} | reactive.Accessor[{inner}]"
141
+ if internal_type.startswith("Mutator[") and internal_type.endswith("]"):
142
+ inner = internal_type.removeprefix("Mutator[").removesuffix("]")
143
+ return f"reactive.Mutator[{inner}]"
144
+ if internal_type.startswith("reactive.Mutator[") and internal_type.endswith("]"):
145
+ return internal_type
146
+ return "Any"
147
+
148
+
149
+ def base_name(node: ast.AST) -> str:
150
+ match node:
151
+ case ast.Name(id=name):
152
+ return name
153
+ case ast.Attribute(attr=name):
154
+ return name
155
+ case ast.Call(func=func):
156
+ return base_name(func)
157
+ case ast.Subscript(value=value):
158
+ return base_name(value)
159
+ case _:
160
+ return ""
161
+
taut/cli/stubs.py ADDED
@@ -0,0 +1,393 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import shutil
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from .stub_model import ComponentStub
10
+ from .stub_model import ImportStub
11
+ from .stub_model import collect_components
12
+ from .stub_model import collect_protocols
13
+ from .stub_model import collect_public_imports
14
+ from .stub_model import render_component
15
+
16
+ DEFAULT_OUT_DIR = Path("typings")
17
+ SKIPPED_DIRS = {
18
+ ".git",
19
+ ".mypy_cache",
20
+ ".pytest_cache",
21
+ ".ruff_cache",
22
+ ".venv",
23
+ "__pycache__",
24
+ "build",
25
+ "dist",
26
+ "typings",
27
+ }
28
+
29
+
30
+ @dataclass
31
+ class GeneratedModule:
32
+ output_path: Path
33
+ import_path: str
34
+ components: list[ComponentStub]
35
+ public_imports: list[ImportStub]
36
+
37
+
38
+ def main_cli(args):
39
+ main(args.paths, args.out_dir, args.in_place)
40
+
41
+
42
+ def main(paths: list[Path], out_dir: Path = DEFAULT_OUT_DIR, in_place=False) -> None:
43
+ all_source_paths = list(expand_paths(paths))
44
+ modules = [
45
+ module
46
+ for path in all_source_paths
47
+ if (module := generate_module(path, out_dir, in_place=in_place)) is not None
48
+ ]
49
+ generated_import_paths = {module.import_path for module in modules}
50
+
51
+ for module in modules:
52
+ module.output_path.parent.mkdir(parents=True, exist_ok=True)
53
+ module.output_path.write_text(
54
+ render_stub(
55
+ module.components,
56
+ module.public_imports,
57
+ generated_import_paths=generated_import_paths,
58
+ ),
59
+ encoding="utf-8",
60
+ )
61
+
62
+ if not in_place:
63
+ write_package_exports(modules, all_source_paths, out_dir)
64
+
65
+
66
+ def expand_paths(paths: Iterable[Path]) -> Iterable[Path]:
67
+ for path in paths:
68
+ if path.is_dir():
69
+ yield from sorted(
70
+ child for child in path.rglob("*.py") if not is_skipped_path(child)
71
+ )
72
+ elif path.suffix == ".py":
73
+ yield path
74
+
75
+
76
+ def is_skipped_path(path: Path) -> bool:
77
+ return any(part in SKIPPED_DIRS for part in path.parts)
78
+
79
+
80
+ def output_path_for(path: Path, out_dir: Path, in_place: bool) -> Path:
81
+ if in_place:
82
+ return path.with_suffix(".pyi")
83
+
84
+ rel = path.resolve().relative_to(Path.cwd())
85
+ return out_dir / rel.with_suffix(".pyi")
86
+
87
+
88
+ def read_module(path: Path) -> ast.Module | None:
89
+ try:
90
+ return ast.parse(path.read_text(encoding="utf-8"))
91
+ except (OSError, SyntaxError):
92
+ return None
93
+
94
+
95
+ def remove_stub_for_source(
96
+ path: Path,
97
+ out_dir: Path = DEFAULT_OUT_DIR,
98
+ in_place: bool = False,
99
+ ) -> bool:
100
+ try:
101
+ output_path = output_path_for(path, out_dir, in_place)
102
+ except ValueError:
103
+ return False
104
+ try:
105
+ output_path.unlink()
106
+ except FileNotFoundError:
107
+ return False
108
+ return True
109
+
110
+
111
+ def remove_stubs_for_source_dir(
112
+ path: Path,
113
+ out_dir: Path = DEFAULT_OUT_DIR,
114
+ ) -> bool:
115
+ try:
116
+ rel = path.resolve().relative_to(Path.cwd())
117
+ except ValueError:
118
+ return False
119
+ output_dir = out_dir / rel
120
+ if not output_dir.is_dir():
121
+ return False
122
+
123
+ shutil.rmtree(output_dir)
124
+ return True
125
+
126
+
127
+ def generate_module(
128
+ path: Path,
129
+ out_dir: Path,
130
+ *,
131
+ in_place: bool,
132
+ ) -> GeneratedModule | None:
133
+ module = read_module(path)
134
+ if module is None:
135
+ return None
136
+ components = collect_components(module, collect_protocols(module))
137
+ if not components:
138
+ return None
139
+ output_path = output_path_for(path, out_dir, in_place)
140
+ return GeneratedModule(
141
+ output_path=output_path,
142
+ import_path=module_import_path(path),
143
+ components=components,
144
+ public_imports=collect_public_imports(module),
145
+ )
146
+
147
+
148
+ def contains_component_def(path: Path) -> bool:
149
+ return bool(component_stubs_for(path))
150
+
151
+
152
+ def component_stubs_for(path: Path) -> list[ComponentStub]:
153
+ module = read_module(path)
154
+ if module is None:
155
+ return []
156
+ return collect_components(module, collect_protocols(module))
157
+
158
+
159
+ def module_import_path(path: Path) -> str:
160
+ module_path = path.with_suffix("") if path.suffix == ".py" else path
161
+ try:
162
+ module_path = module_path.resolve().relative_to(Path.cwd().resolve())
163
+ except ValueError:
164
+ pass
165
+ return ".".join(module_path.parts)
166
+
167
+
168
+ def render_stub(
169
+ components: list[ComponentStub],
170
+ public_imports: list[ImportStub] | None = None,
171
+ generated_import_paths: set[str] | None = None,
172
+ ) -> str:
173
+ imports = collect_stub_imports(components)
174
+ public_imports = public_imports or []
175
+ generated_import_paths = generated_import_paths or set()
176
+ public_import_lines = render_public_imports(public_imports, generated_import_paths)
177
+ lines = ["from __future__ import annotations", ""]
178
+ import_lines = format_imports([*imports, *public_import_lines])
179
+ if import_lines:
180
+ lines.extend([*import_lines, ""])
181
+ for component in components:
182
+ lines.extend(render_component(component))
183
+ lines.append("")
184
+ return "\n".join(lines).rstrip() + "\n"
185
+
186
+
187
+ def render_public_imports(
188
+ public_imports: list[ImportStub],
189
+ generated_import_paths: set[str],
190
+ ) -> list[str]:
191
+ lines: list[str] = []
192
+ for import_stub in public_imports:
193
+ import_path = f"{import_stub.module}.{import_stub.name}"
194
+ if import_path in generated_import_paths:
195
+ lines.append(
196
+ f"from {import_stub.module} import {import_stub.name} "
197
+ f"as {import_stub.export_name}"
198
+ )
199
+ return lines
200
+
201
+
202
+ def format_imports(imports: list[str]) -> list[str]:
203
+ stdlib = sorted(line for line in imports if is_stdlib_import(line))
204
+ relative = sorted(line for line in imports if line.startswith("from ."))
205
+ absolute = sorted(
206
+ line
207
+ for line in imports
208
+ if line not in stdlib and line not in relative
209
+ )
210
+ sections = [stdlib, absolute, relative]
211
+ lines: list[str] = []
212
+ for section in sections:
213
+ if not section:
214
+ continue
215
+ if lines:
216
+ lines.append("")
217
+ lines.extend(section)
218
+ return lines
219
+
220
+
221
+ def is_stdlib_import(line: str) -> bool:
222
+ return line.startswith("from collections.") or line.startswith("from typing ")
223
+
224
+
225
+ def collect_stub_imports(components: list[ComponentStub]) -> list[str]:
226
+ external_types = {
227
+ field.external_type for component in components for field in component.fields
228
+ }
229
+ imports = []
230
+ if any("Callable" in external_type for external_type in external_types):
231
+ imports.append("from collections.abc import Callable")
232
+ if components or "Any" in external_types:
233
+ imports.append("from typing import Any")
234
+ if components:
235
+ imports.append("from taut import runtime")
236
+ if any("reactive." in external_type for external_type in external_types):
237
+ imports.append("from taut import reactive")
238
+ return imports
239
+
240
+
241
+ def write_package_exports(
242
+ modules: list[GeneratedModule],
243
+ source_paths: list[Path],
244
+ out_dir: Path,
245
+ ) -> None:
246
+ packages = package_marker_dirs(modules, source_paths, out_dir)
247
+ components_by_module = {
248
+ module.import_path: {component.name for component in module.components}
249
+ for module in modules
250
+ }
251
+ generated_modules = {module.import_path for module in modules}
252
+ attrs_by_package = public_attrs_by_package(modules)
253
+
254
+ for package_dir in sorted(packages):
255
+ marker = package_dir / "__init__.pyi"
256
+ marker.parent.mkdir(parents=True, exist_ok=True)
257
+ source_init_path = source_init_for_marker(package_dir, out_dir)
258
+ marker.write_text(
259
+ render_init_stub(
260
+ source_init_path,
261
+ components_by_module,
262
+ generated_modules,
263
+ attrs_by_package.get(module_import_path(source_init_path.parent), set()),
264
+ ),
265
+ encoding="utf-8",
266
+ )
267
+
268
+
269
+ def public_attrs_by_package(modules: list[GeneratedModule]) -> dict[str, set[str]]:
270
+ attrs_by_package: dict[str, set[str]] = {}
271
+ for module in modules:
272
+ package = module_import_path(Path(*module.import_path.split(".")[:-1]))
273
+ if not package:
274
+ continue
275
+ for import_stub in module.public_imports:
276
+ if import_stub.module == package:
277
+ attrs_by_package.setdefault(package, set()).add(import_stub.export_name)
278
+ return attrs_by_package
279
+
280
+
281
+ def package_marker_dirs(
282
+ modules: list[GeneratedModule],
283
+ source_paths: list[Path],
284
+ out_dir: Path,
285
+ ) -> set[Path]:
286
+ package_dirs: set[Path] = set()
287
+ out_dir = out_dir.resolve()
288
+ for module in modules:
289
+ directory = module.output_path.parent.resolve()
290
+ while directory != out_dir and out_dir in directory.parents:
291
+ package_dirs.add(directory)
292
+ directory = directory.parent
293
+
294
+ for init_path in source_paths:
295
+ if init_path.name != "__init__.py":
296
+ continue
297
+ try:
298
+ package_dirs.add(output_path_for(init_path, out_dir, in_place=False).parent)
299
+ except ValueError:
300
+ pass
301
+ return package_dirs
302
+
303
+
304
+ def source_init_for_marker(package_dir: Path, out_dir: Path) -> Path:
305
+ rel = package_dir.resolve().relative_to(out_dir.resolve())
306
+ return Path.cwd() / rel / "__init__.py"
307
+
308
+
309
+ def render_init_stub(
310
+ source_init_path: Path,
311
+ components_by_module: dict[str, set[str]],
312
+ generated_modules: set[str],
313
+ package_attrs: set[str] | None = None,
314
+ ) -> str:
315
+ reexports: list[str] = []
316
+ functions: list[str] = []
317
+ attrs = sorted(package_attrs or set())
318
+ module = read_module(source_init_path)
319
+
320
+ package_import_path = module_import_path(source_init_path.parent)
321
+ if module is not None:
322
+ for node in module.body:
323
+ if isinstance(node, ast.ImportFrom) and node.module is not None:
324
+ import_path = resolve_import_from(source_init_path, node)
325
+ component_names = components_by_module.get(import_path)
326
+ module_ref = marker_import_ref(package_import_path, import_path)
327
+ for alias in node.names:
328
+ export_name = alias.asname or alias.name
329
+ if component_names and alias.name in component_names:
330
+ reexports.append(
331
+ f"from {module_ref} import {alias.name} as {export_name}"
332
+ )
333
+ elif f"{import_path}.{alias.name}" in generated_modules:
334
+ reexports.append(
335
+ f"from {module_ref} import {alias.name} as {export_name}"
336
+ )
337
+ elif isinstance(node, ast.FunctionDef) and is_public_name(node.name):
338
+ functions.append(render_function_stub(node))
339
+
340
+ reexports.extend(
341
+ f"from . import {name} as {name}"
342
+ for name in attrs
343
+ if f"{package_import_path}.{name}" in generated_modules
344
+ )
345
+ imports = (
346
+ ["from typing import Any"]
347
+ if any_function_uses_any(functions)
348
+ else []
349
+ )
350
+ import_lines = format_imports([*imports, *reexports])
351
+ sections = [["from __future__ import annotations"], import_lines, functions]
352
+ lines = [line for section in sections if section for line in (*section, "")]
353
+ return "\n".join(lines).rstrip() + "\n"
354
+
355
+
356
+ def is_public_name(name: str) -> bool:
357
+ return not name.startswith("_")
358
+
359
+
360
+ def render_function_stub(node: ast.FunctionDef) -> str:
361
+ returns = ast.unparse(node.returns) if node.returns is not None else "Any"
362
+ return f"def {node.name}({ast.unparse(node.args)}) -> {returns}: ..."
363
+
364
+
365
+ def any_function_uses_any(functions: list[str]) -> bool:
366
+ return any(" -> Any:" in function for function in functions)
367
+
368
+
369
+ def resolve_import_from(source_path: Path, node: ast.ImportFrom) -> str:
370
+ if node.level == 0:
371
+ return node.module or ""
372
+
373
+ package_path = source_path.parent
374
+ for _ in range(node.level - 1):
375
+ package_path = package_path.parent
376
+ package_import_path = module_import_path(package_path)
377
+ if not node.module:
378
+ return package_import_path
379
+ return f"{package_import_path}.{node.module}"
380
+
381
+
382
+ def marker_import_ref(package_import_path: str, import_path: str) -> str:
383
+ package_parts = package_import_path.split(".")
384
+ import_parts = import_path.split(".")
385
+ common = 0
386
+ for package_part, import_part in zip(package_parts, import_parts):
387
+ if package_part != import_part:
388
+ break
389
+ common += 1
390
+
391
+ up_levels = len(package_parts) - common
392
+ remainder = ".".join(import_parts[common:])
393
+ return "." * (up_levels + 1) + remainder