tealui 0.3.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.
tealui/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """A tiny, clean, pythonic UI library."""
2
+ from . import tags as _tags
3
+ from .core import El, Tag, Raw, render
4
+ from .tags import *
5
+ from .look import use, using, current, r, make_look, check_look, look_problems
6
+ from .looks import (TEAL, PAPER, SLATE, INDIGO, MIDNIGHT, LEDGER, ENTERPRISE, CONTRAST,
7
+ FOREST, SUNSET, NORDIC, SOFT, TERMINAL, BRUTAL)
8
+ from .theme import with_accent, with_fonts
9
+ from .icons import register_icon
10
+ from .page import Page
11
+ from .ui.foundation import Icon, Button, Badge, Avatar, Card, Kbd
12
+ from .ui.charts import Sparkline, AreaChart
13
+ from .ui.data import Delta, Stat, Progress, Table, BarList
14
+ from .ui.nav import Tabs, Breadcrumb
15
+ from .ui.shell import NavItem, Sidebar, Topbar, PageHeader, DarkToggle, Shell
16
+ from .ui.forms import Segmented, SearchBox, TagFilter, tag_show
17
+ from .ui.content import SiteHeader, Prose, CodeBlock, Callout
18
+ from .ui.forms2 import TextInput, SelectBox, Checkbox, Switch, Field, TextBox, RadioGroup
19
+ from .ui.overlay import Modal, Dropdown, Toaster, toast_js
20
+ from .ui.mobile import AppScreen, LargeTitle, ListGroup, ListRow, TabBar
21
+ from .ui.article import Toc, ReadingProgress, ArticleHeader, ShareBar, PostCard, Pager, DocsNav
22
+ use(TEAL)
23
+ __all__ = list(dict.fromkeys([
24
+ "El", "Tag", "Raw", "render", "use", "using", "current", "r",
25
+ "TEAL", "PAPER", "SLATE", "INDIGO", "MIDNIGHT", "LEDGER", "ENTERPRISE", "CONTRAST",
26
+ "FOREST", "SUNSET", "NORDIC", "SOFT", "TERMINAL", "BRUTAL",
27
+ "make_look", "check_look", "look_problems", "with_accent", "Page", "Icon",
28
+ "Button", "Badge", "Avatar", "Card", "Kbd",
29
+ "Sparkline", "AreaChart", "Delta", "Stat", "Progress", "Table", "BarList",
30
+ "Tabs", "Breadcrumb",
31
+ "NavItem", "Sidebar", "Topbar", "PageHeader", "DarkToggle", "Shell",
32
+ "Segmented", "SearchBox",
33
+ "SiteHeader", "Prose", "CodeBlock", "Callout",
34
+ "TextInput", "SelectBox", "Checkbox", "Switch", "Field",
35
+ "Modal",
36
+ "Dropdown", "Toaster", "toast_js",
37
+ "Toc", "ReadingProgress", "ArticleHeader", "ShareBar", "PostCard", "Pager", "DocsNav",
38
+ "AppScreen", "LargeTitle", "ListGroup", "ListRow", "TabBar",
39
+ "with_fonts", "register_icon",
40
+ "TextBox", "RadioGroup", "TagFilter", "tag_show",
41
+ *_tags.__all__
42
+ ]))
43
+ __version__ = "0.3.0"
tealui/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
tealui/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .theme import tailwind_input
10
+ from .versions import TAILWIND_RANGE, ALPINE_RANGE, HTMX_RANGE
11
+ from .devtools import _run_test, _run_export
12
+ from . import toolchain
13
+ from . import lookcli
14
+
15
+ _FONTS = ("inter.woff2", "serif.woff2", "serif-italic.woff2", "mono.woff2")
16
+
17
+
18
+ def _fonts_dir():
19
+ return Path(__file__).resolve().parent / "fonts"
20
+
21
+
22
+ def _write_input_and_fonts(out, srcs, input_path, looks=()):
23
+ out_dir = Path(out).parent
24
+ out_dir.mkdir(parents=True, exist_ok=True)
25
+ abs_paths = [Path(p).resolve().as_posix() for p in (srcs or [os.getcwd()])]
26
+ if looks:
27
+ # Third-party looks may live outside the scanned folders (site-packages): list their classes in a file.
28
+ looks_txt = out_dir / "tealui.looks.txt"
29
+ looks_txt.write_text(lookcli.classes(looks), encoding="utf-8")
30
+ abs_paths.append(looks_txt.resolve().as_posix())
31
+ print(f"included {len(looks)} look(s): {', '.join(x.get('name', '?') for x in looks)}")
32
+ if input_path is None:
33
+ input_path = out_dir / "tealui.input.css"
34
+ input_path = Path(input_path)
35
+ input_path.parent.mkdir(parents=True, exist_ok=True)
36
+ input_path.write_text(tailwind_input(*abs_paths), encoding="utf-8")
37
+ fonts_out = out_dir / "fonts"
38
+ fonts_out.mkdir(parents=True, exist_ok=True)
39
+ for name in _FONTS:
40
+ shutil.copyfile(_fonts_dir() / name, fonts_out / name)
41
+ return input_path
42
+
43
+
44
+ def _run_css(args):
45
+ input_path = _write_input_and_fonts(args.out, args.src, args.input, lookcli.build_looks(args.look))
46
+ print(f"wrote {input_path}")
47
+ print(f"copied fonts to {Path(args.out).parent / 'fonts'}")
48
+ print(f"next: tealui build {args.out} --src <your source folders>")
49
+ print(f"or run any Tailwind v4 CLI: tailwindcss -i {input_path} -o {args.out} --minify")
50
+ return 0
51
+
52
+
53
+ def _run_build(args):
54
+ cache = toolchain.cache_root(args.cache)
55
+ input_path = _write_input_and_fonts(args.out, args.src, None, lookcli.build_looks(args.look))
56
+ exe = toolchain.ensure_tailwind(cache, TAILWIND_RANGE)
57
+ if args.alpine:
58
+ alpine_path = toolchain.ensure_alpine(cache, ALPINE_RANGE)
59
+ dest = Path(args.alpine)
60
+ dest.parent.mkdir(parents=True, exist_ok=True)
61
+ shutil.copyfile(alpine_path, dest)
62
+ version = toolchain.installed(cache, "alpine", ALPINE_RANGE)
63
+ print(f"copied Alpine {version} to {args.alpine}")
64
+ if args.htmx:
65
+ htmx_path = toolchain.ensure_htmx(cache, HTMX_RANGE)
66
+ dest = Path(args.htmx)
67
+ dest.parent.mkdir(parents=True, exist_ok=True)
68
+ shutil.copyfile(htmx_path, dest)
69
+ version = toolchain.installed(cache, "htmx", HTMX_RANGE)
70
+ print(f"copied HTMX {version} to {args.htmx}")
71
+ cmd = [str(exe), "-i", str(input_path), "-o", str(Path(args.out).resolve()), "--minify"]
72
+ if args.watch:
73
+ cmd.append("--watch")
74
+ return subprocess.run(cmd).returncode
75
+
76
+
77
+ def _run_update(args):
78
+ cache = toolchain.cache_root(args.cache)
79
+ for name, major, ensure_fn in [("tailwind", TAILWIND_RANGE, toolchain.ensure_tailwind),
80
+ ("alpine", ALPINE_RANGE, toolchain.ensure_alpine),
81
+ ("htmx", HTMX_RANGE, toolchain.ensure_htmx)]:
82
+ before = toolchain.installed(cache, name, major)
83
+ ensure_fn(cache, major, update=True)
84
+ after = toolchain.installed(cache, name, major)
85
+ pkg_name = {"tailwind": "tailwindcss", "alpine": "alpinejs", "htmx": "htmx.org"}[name]
86
+ if before is None:
87
+ print(f"{pkg_name} installed {after}")
88
+ elif before != after:
89
+ print(f"{pkg_name} {before} -> {after}")
90
+ else:
91
+ print(f"{pkg_name} {after} (up to date)")
92
+ return 0
93
+
94
+
95
+ def _run_versions(args):
96
+ cache = toolchain.cache_root(args.cache)
97
+ for pkg, name, major in [("tailwindcss", "tailwind", TAILWIND_RANGE), ("alpinejs", "alpine", ALPINE_RANGE), ("htmx.org", "htmx", HTMX_RANGE)]:
98
+ inst = toolchain.installed(cache, name, major)
99
+ if inst:
100
+ inst_str = f"installed {inst}"
101
+ else:
102
+ inst_str = "not installed"
103
+ try:
104
+ if pkg == "tailwindcss":
105
+ latest = toolchain.latest_tailwind(major)
106
+ else:
107
+ latest, _ = toolchain.npm_latest(pkg, major)
108
+ latest_str = latest or "unknown"
109
+ except toolchain.ToolchainError:
110
+ latest_str = "unknown"
111
+ print(f"{pkg} {inst_str} latest {latest_str} tracking {major}.x")
112
+ try:
113
+ dist_tags = json.loads(toolchain._get(f"https://registry.npmjs.org/-/package/{pkg}/dist-tags"))
114
+ overall_latest = dist_tags.get("latest")
115
+ if overall_latest and not overall_latest.startswith(f"{major}."):
116
+ print(f"note: a newer major version exists ({overall_latest}); TealUI keeps tracking {major}.x until it has been tested")
117
+ except (toolchain.ToolchainError, json.JSONDecodeError, KeyError):
118
+ pass
119
+ return 0
120
+
121
+
122
+ def main(argv=None):
123
+ """Run the tealui command line interface."""
124
+ parser = argparse.ArgumentParser(prog="tealui")
125
+ sub = parser.add_subparsers(dest="cmd")
126
+ css = sub.add_parser("css", help="write the Tailwind input file and copy fonts")
127
+ css.add_argument("out")
128
+ css.add_argument("--src", action="append", default=[])
129
+ css.add_argument("--input")
130
+ css.add_argument("--look", action="append", default=[], help="third-party look to include (module:NAME)")
131
+ build = sub.add_parser("build", help="write inputs, then run the Tailwind CLI")
132
+ build.add_argument("out")
133
+ build.add_argument("--src", action="append", default=[])
134
+ build.add_argument("--cache")
135
+ build.add_argument("--watch", action="store_true")
136
+ build.add_argument("--alpine")
137
+ build.add_argument("--htmx")
138
+ build.add_argument("--look", action="append", default=[], help="third-party look to include (module:NAME)")
139
+ upd = sub.add_parser("update", help="update the cached toolchain")
140
+ upd.add_argument("--cache")
141
+ ver = sub.add_parser("versions", help="show installed and latest versions")
142
+ ver.add_argument("--cache")
143
+ test = sub.add_parser("test", help="run the unit tests")
144
+ test.add_argument("--browser", action="store_true")
145
+ export = sub.add_parser("export", help="build wheel, sdist and source zip")
146
+ export.add_argument("outdir", nargs="?", default="dist")
147
+ export.add_argument("--no-test", action="store_true")
148
+ export.add_argument("--wheel-only", action="store_true")
149
+ lookcli.add_parsers(sub)
150
+ args = parser.parse_args(argv)
151
+ try:
152
+ if args.cmd == "css":
153
+ return _run_css(args)
154
+ if args.cmd == "build":
155
+ return _run_build(args)
156
+ if args.cmd == "update":
157
+ return _run_update(args)
158
+ if args.cmd == "versions":
159
+ return _run_versions(args)
160
+ if args.cmd == "test":
161
+ return _run_test(args)
162
+ if args.cmd == "export":
163
+ return _run_export(args)
164
+ if args.cmd in ("look", "looks"):
165
+ return lookcli.run(args)
166
+ parser.error("a subcommand is required")
167
+ except (toolchain.ToolchainError, ValueError, OSError) as e:
168
+ print(f"tealui: {e}", file=sys.stderr)
169
+ return 2
170
+
171
+
172
+ if __name__ == "__main__":
173
+ raise SystemExit(main())
tealui/core.py ADDED
@@ -0,0 +1,113 @@
1
+ import html
2
+ import re
3
+ from typing import Any
4
+
5
+ class Raw(str):
6
+ """Trusted markup, never escaped as child."""
7
+
8
+ _VOID = frozenset("area base br col embed hr img input link meta source track wbr".split())
9
+
10
+ def attr_name(key: str) -> str:
11
+ """Convert Python kwarg to HTML/Alpine attribute name."""
12
+ if any(c in key for c in "@:.-"):
13
+ if not re.fullmatch(r"[A-Za-z_:@][A-Za-z0-9_:.@-]*", key):
14
+ raise ValueError(f"bad attribute name: {key!r}")
15
+ return key
16
+ if key == "cls":
17
+ key = "class"
18
+ key = key.rstrip("_")
19
+ base, *mods = key.split("__")
20
+ if base.startswith("on_"):
21
+ base = "@" + base[3:]
22
+ elif base.startswith("bind_"):
23
+ base = ":" + base[5:]
24
+ parts = [p.replace("_", "-") for p in [base] + mods]
25
+ res = ".".join(parts)
26
+ if not re.fullmatch(r"[A-Za-z_:@][A-Za-z0-9_:.@-]*", res):
27
+ raise ValueError(f"bad attribute name: {res!r}")
28
+ return res
29
+
30
+ def _freeze(x):
31
+ """Turn one-shot iterators (generators, map, zip...) into tuples so an element renders the same every time."""
32
+ if isinstance(x, (str, bytes, El)) or not hasattr(x, "__next__"):
33
+ if isinstance(x, list):
34
+ return [_freeze(i) for i in x]
35
+ return x
36
+ return tuple(_freeze(i) for i in x)
37
+
38
+ def _flat(v):
39
+ for x in v:
40
+ if isinstance(x, (list, tuple)):
41
+ yield from _flat(x)
42
+ elif x is not None and x is not False and x != "":
43
+ yield str(x)
44
+
45
+ _URL_ATTRS = frozenset(("href", "src", "action", "formaction", "poster", "xlink:href", "data", "background"))
46
+ # SVG <set>/<animate> can write a URL into href through these attributes.
47
+ _ANIM_ATTRS = frozenset(("to", "from", "by", "values"))
48
+
49
+ def _bad_url(v: str) -> bool:
50
+ check = "".join(c for c in v if ord(c) > 32 and not c.isspace()).lower()
51
+ return check.startswith(("javascript:", "vbscript:")) or \
52
+ (check.startswith("data:") and not re.fullmatch(r"data:image/(png|jpe?g|gif|webp|avif)[;,].*", check))
53
+
54
+ def _url_safe(k: str, v: str) -> str:
55
+ """Check URL safety for specific attributes."""
56
+ # bound attributes (bind_href) are dynamic and are not checked.
57
+ k = k.lower()
58
+ if k in _URL_ATTRS and _bad_url(v):
59
+ return "#"
60
+ if k in _ANIM_ATTRS and any(_bad_url(part) for part in v.split(";")):
61
+ return "#"
62
+ return v
63
+
64
+ class El:
65
+ """An HTML element."""
66
+ def __init__(self, tag: str, *children: Any, **attrs: Any):
67
+ """Initialize the element with a tag, children, and attributes."""
68
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", tag):
69
+ raise ValueError(f"bad tag name: {tag!r}")
70
+ if tag.lower() in _VOID and children:
71
+ raise ValueError(f"void element {tag!r} cannot have children")
72
+ self.tag, self.children, self.attrs = tag, tuple(_freeze(c) for c in children), attrs
73
+
74
+ def __str__(self) -> str:
75
+ """Render the element to HTML."""
76
+ is_void = self.tag.lower() in _VOID
77
+ # script/style children follow normal rules: str escaped, Raw not.
78
+ a = []
79
+ for k, v in self.attrs.items():
80
+ if v is False or v is None:
81
+ continue
82
+ name = attr_name(k)
83
+ if v is True:
84
+ a.append(f" {name}")
85
+ else:
86
+ if name == "class" and isinstance(v, (list, tuple)):
87
+ v = " ".join(_flat(v))
88
+ if not v:
89
+ continue
90
+ sv = html.escape(str(v), quote=True)
91
+ sv = _url_safe(name, sv)
92
+ a.append(f' {name}="{sv}"')
93
+ o = f"<{self.tag}{''.join(a)}>"
94
+ if is_void:
95
+ return o
96
+ c = "".join(render(x) for x in self.children)
97
+ return f"{o}{c}</{self.tag}>"
98
+
99
+ Tag = El
100
+
101
+ def render(x: Any) -> str:
102
+ """Render any child value to HTML."""
103
+ if isinstance(x, Raw):
104
+ return x
105
+ if isinstance(x, str):
106
+ return html.escape(x, quote=True)
107
+ if isinstance(x, El):
108
+ return str(x)
109
+ if x is None or x is False:
110
+ return ""
111
+ if isinstance(x, (list, tuple)) or hasattr(x, "__next__"):
112
+ return "".join(render(i) for i in x)
113
+ return html.escape(str(x), quote=True)
tealui/devtools.py ADDED
@@ -0,0 +1,126 @@
1
+ import hashlib
2
+ import importlib.util
3
+ import subprocess
4
+ import sys
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import tealui
9
+
10
+
11
+ def _project_root(start=None):
12
+ """Walk up from start (or this file) to find the project root."""
13
+ p = Path(start) if start else Path(__file__).resolve().parent
14
+ for parent in [p] + list(p.parents):
15
+ if (parent / "pyproject.toml").is_file() and (parent / "tests").is_dir():
16
+ return parent
17
+ return None
18
+
19
+
20
+ def _run_test(args):
21
+ root = _project_root()
22
+ if root is None:
23
+ print("tealui: tests are only available in a source checkout", file=sys.stderr)
24
+ return 2
25
+ res = subprocess.run([sys.executable, "-m", "unittest", "discover", "-s", "tests"], cwd=root)
26
+ if res.returncode != 0 or not getattr(args, "browser", False):
27
+ return res.returncode
28
+ compat = root / "tools" / "compat.py"
29
+ if not compat.is_file():
30
+ print("tealui: tools/compat.py not found", file=sys.stderr)
31
+ return 2
32
+ code = subprocess.run([sys.executable, str(compat)], cwd=root).returncode
33
+ for name in ("a11y_suite.py", "looks_suite.py"):
34
+ suite = root / "tools" / name
35
+ if code == 0 and suite.is_file():
36
+ code = subprocess.run([sys.executable, str(suite)], cwd=root).returncode
37
+ return code
38
+
39
+
40
+ def _wheel_problems(path):
41
+ problems = []
42
+ required = {
43
+ "tealui/fonts/inter.woff2", "tealui/fonts/serif.woff2",
44
+ "tealui/fonts/serif-italic.woff2", "tealui/fonts/mono.woff2",
45
+ "tealui/fonts/LICENSES.txt", "tealui/icons-LICENSE.txt", "tealui/py.typed"
46
+ }
47
+ with zipfile.ZipFile(path) as zf:
48
+ names = set(zf.namelist())
49
+ for req in required:
50
+ if req not in names:
51
+ problems.append(f"missing {req}")
52
+ for name in names:
53
+ if name.startswith("tests/"):
54
+ problems.append(f"unexpected {name}")
55
+ if "__pycache__" in name:
56
+ problems.append(f"unexpected {name}")
57
+ return problems
58
+
59
+
60
+ def _write_source_zip(root, outdir, version):
61
+ zip_path = Path(outdir) / f"tealui-{version}-src.zip"
62
+ prefix = f"tealui-{version}/"
63
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
64
+ for item in ["tealui", "pyproject.toml", "README.md"]:
65
+ p = root / item
66
+ if p.is_file():
67
+ zf.write(p, prefix + item)
68
+ elif p.is_dir():
69
+ for sub in p.rglob("*"):
70
+ if sub.is_file() and "__pycache__" not in sub.parts and not sub.name.endswith(".pyc"):
71
+ zf.write(sub, prefix + str(sub.relative_to(root)))
72
+ if (root / "LICENSE").is_file():
73
+ zf.write(root / "LICENSE", prefix + "LICENSE")
74
+ return zip_path
75
+
76
+
77
+ def _describe(paths):
78
+ lines = []
79
+ for p in sorted(paths):
80
+ size_kb = p.stat().st_size / 1024
81
+ h = hashlib.sha256(p.read_bytes()).hexdigest()[:12]
82
+ lines.append(f"{size_kb:.1f} KB {h} {p}")
83
+ return lines
84
+
85
+
86
+ def _run_export(args):
87
+ root = _project_root()
88
+ if root is None:
89
+ print("tealui: tests are only available in a source checkout", file=sys.stderr)
90
+ return 2
91
+ outdir = Path(args.outdir)
92
+ if not args.no_test and _run_test(type("A", (), {"browser": False})()) != 0:
93
+ print("tealui: tests failed, nothing exported", file=sys.stderr)
94
+ return 1
95
+ if importlib.util.find_spec("build") is None:
96
+ print("tealui: install the build tool first: pip install build", file=sys.stderr)
97
+ return 2
98
+ outdir.mkdir(parents=True, exist_ok=True)
99
+ cmd = [sys.executable, "-m", "build", "--outdir", str(outdir), str(root)]
100
+ if args.wheel_only:
101
+ cmd.insert(3, "--wheel")
102
+ if subprocess.run(cmd).returncode != 0:
103
+ print("tealui: build failed", file=sys.stderr)
104
+ return 1
105
+ return _finalize_export(outdir, root, args.wheel_only)
106
+
107
+
108
+ def _finalize_export(outdir, root, wheel_only):
109
+ wheels = sorted(outdir.glob("*.whl"))
110
+ if not wheels:
111
+ print("tealui: build failed", file=sys.stderr)
112
+ return 1
113
+ problems = _wheel_problems(wheels[-1])
114
+ if problems:
115
+ for p in problems:
116
+ print(f"tealui: wheel problem: {p}", file=sys.stderr)
117
+ return 1
118
+ produced = [wheels[-1]]
119
+ if not wheel_only:
120
+ sdist = sorted(outdir.glob("*.tar.gz"))
121
+ if sdist:
122
+ produced.append(sdist[-1])
123
+ produced.append(_write_source_zip(root, outdir, tealui.__version__))
124
+ for line in _describe(produced):
125
+ print(line)
126
+ return 0