evenis 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.
evenis/__init__.py ADDED
@@ -0,0 +1,102 @@
1
+ """evenis — production-grade Python UI framework.
2
+
3
+ Двухслойная система:
4
+ • compile-time (Python): UI-дерево (AST) → layout → theme→CSS → HTML → JS inject;
5
+ • runtime (browser JS): SPA-роутинг, реактивный store, DOM-обновления, анимации.
6
+
7
+ Пользователь описывает UI ТОЛЬКО на Python — без HTML/CSS/JS.
8
+
9
+ Пример:
10
+ import evenis as ui
11
+
12
+ app = ui.App(title="Demo", state={"count": 0})
13
+
14
+ @app.page("/", title="Home")
15
+ def home():
16
+ return ui.Column(
17
+ ui.Heading("Счётчик"),
18
+ ui.Text("Значение: {{ count }}"),
19
+ ui.Button("+1", on_click=ui.increment("count"), variant="primary"),
20
+ )
21
+
22
+ app.build("dist")
23
+ """
24
+
25
+ from .core import App, Component, Element, For, Fragment, If, Node, Page, to_vnode
26
+ from .layout import (
27
+ AdaptiveLayout,
28
+ Column,
29
+ Container,
30
+ Grid,
31
+ Motion,
32
+ Responsive,
33
+ Row,
34
+ Scrollable,
35
+ Spacer,
36
+ Stack,
37
+ )
38
+ from .components import (
39
+ Button,
40
+ Card,
41
+ DangerButton,
42
+ Divider,
43
+ GhostButton,
44
+ Heading,
45
+ Image,
46
+ Input,
47
+ Link,
48
+ PrimaryButton,
49
+ Text,
50
+ )
51
+ from .style import Theme, default_theme, light_theme
52
+ from .state import (
53
+ append,
54
+ batch,
55
+ decrement,
56
+ fetch_json,
57
+ increment,
58
+ js,
59
+ log,
60
+ navigate,
61
+ prefetch,
62
+ redirect_if,
63
+ remove_at,
64
+ set_from_event,
65
+ set_value,
66
+ toggle,
67
+ )
68
+ from .router import Router
69
+ from .compiler import build_app, build_manifest
70
+ from .server import (
71
+ LiveState,
72
+ action,
73
+ enable_auth,
74
+ protected,
75
+ serve,
76
+ server,
77
+ )
78
+
79
+ __version__ = "0.1.0"
80
+
81
+ __all__ = [
82
+ # core
83
+ "App", "Page", "Component", "Element", "Node", "Fragment", "If", "For", "to_vnode",
84
+ # layout
85
+ "Row", "Column", "Grid", "Stack", "Container", "Spacer", "Responsive",
86
+ "AdaptiveLayout", "Scrollable", "Motion",
87
+ # components
88
+ "Text", "Heading", "Button", "PrimaryButton", "DangerButton", "GhostButton",
89
+ "Card", "Input", "Link", "Image", "Divider",
90
+ # style
91
+ "Theme", "default_theme", "light_theme",
92
+ # state / actions
93
+ "set_value", "increment", "decrement", "toggle", "set_from_event",
94
+ "append", "remove_at", "navigate", "js", "fetch_json", "batch",
95
+ # middleware
96
+ "redirect_if", "log", "prefetch",
97
+ # router / compiler
98
+ "Router", "build_app", "build_manifest",
99
+ # server (fullstack)
100
+ "server", "action", "LiveState", "protected", "enable_auth", "serve",
101
+ "__version__",
102
+ ]
evenis/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
evenis/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ """evenis.cli — одна команда для сборки и dev-режима.
2
+
3
+ evenis build app.py [--out dist] — собрать сайт в каталог.
4
+ evenis dev app.py [--out dist] [--port 8000]
5
+ — собрать с dev=True (hot reload), поднять статический сервер и
6
+ пересобирать при изменении исходников (polling watcher).
7
+
8
+ `app.py` должен экспортировать переменную `app` (экземпляр evenis.App) либо
9
+ функцию `app()`/`create_app()`, возвращающую App.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import importlib.util
16
+ import os
17
+ import sys
18
+ import time
19
+
20
+
21
+ def _load_app(path: str):
22
+ spec = importlib.util.spec_from_file_location("_evenis_user_app", path)
23
+ if spec is None or spec.loader is None:
24
+ raise SystemExit(f"Не удалось загрузить модуль: {path}")
25
+ mod = importlib.util.module_from_spec(spec)
26
+ spec.loader.exec_module(mod)
27
+ candidate = getattr(mod, "app", None) or getattr(mod, "create_app", None)
28
+ if candidate is None:
29
+ raise SystemExit("В модуле нет `app` (экземпляр App) или фабрики create_app().")
30
+ app = candidate() if callable(candidate) else candidate
31
+ return app
32
+
33
+
34
+ def _snapshot(root: str) -> float:
35
+ """Максимальное mtime по дереву *.py — простой watcher без зависимостей."""
36
+ latest = 0.0
37
+ for dirpath, _dirs, files in os.walk(root):
38
+ if "__pycache__" in dirpath or "/dist" in dirpath:
39
+ continue
40
+ for f in files:
41
+ if f.endswith(".py"):
42
+ try:
43
+ latest = max(latest, os.path.getmtime(os.path.join(dirpath, f)))
44
+ except OSError:
45
+ pass
46
+ return latest
47
+
48
+
49
+ def cmd_build(args) -> None:
50
+ app = _load_app(args.entry)
51
+ out = app.build(args.out)
52
+ print(f"evenis: собрано → {out}")
53
+
54
+
55
+ def cmd_dev(args) -> None:
56
+ import functools
57
+ import http.server
58
+ import socketserver
59
+ import threading
60
+
61
+ entry = os.path.abspath(args.entry)
62
+ watch_root = os.path.dirname(entry)
63
+
64
+ def rebuild():
65
+ app = _load_app(entry)
66
+ app.dev = True
67
+ app.build(args.out)
68
+
69
+ rebuild()
70
+ out_abs = os.path.abspath(args.out)
71
+ print(f"evenis: dev-сборка → {out_abs}")
72
+
73
+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=out_abs)
74
+ httpd = socketserver.TCPServer(("", args.port), handler)
75
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
76
+ print(f"evenis: http://localhost:{args.port} (Ctrl+C для выхода)")
77
+
78
+ last = _snapshot(watch_root)
79
+ try:
80
+ while True:
81
+ time.sleep(0.6)
82
+ cur = _snapshot(watch_root)
83
+ if cur > last:
84
+ last = cur
85
+ try:
86
+ rebuild()
87
+ print("evenis: пересобрано, обновляю браузер…")
88
+ except Exception as e: # noqa: BLE001 - dev-цикл не должен падать
89
+ print(f"evenis: ошибка сборки: {e}", file=sys.stderr)
90
+ except KeyboardInterrupt:
91
+ print("\nevenis: остановлено.")
92
+ httpd.shutdown()
93
+
94
+
95
+ def cmd_serve(args) -> None:
96
+ from .server import serve
97
+
98
+ app = _load_app(args.entry)
99
+ serve(app, outdir=args.out, port=args.port, host=args.host)
100
+
101
+
102
+ def main(argv=None) -> None:
103
+ parser = argparse.ArgumentParser(prog="evenis", description="evenis — Python UI framework")
104
+ sub = parser.add_subparsers(dest="cmd", required=True)
105
+
106
+ b = sub.add_parser("build", help="собрать сайт в каталог")
107
+ b.add_argument("entry", help="путь к app.py (экспортирует app)")
108
+ b.add_argument("--out", default="dist", help="каталог вывода (по умолчанию dist)")
109
+ b.set_defaults(func=cmd_build)
110
+
111
+ d = sub.add_parser("dev", help="dev-сервер с hot reload")
112
+ d.add_argument("entry", help="путь к app.py (экспортирует app)")
113
+ d.add_argument("--out", default="dist", help="каталог вывода")
114
+ d.add_argument("--port", type=int, default=8000, help="порт (по умолчанию 8000)")
115
+ d.set_defaults(func=cmd_dev)
116
+
117
+ s = sub.add_parser("serve", help="fullstack-сервер (фронт + FastAPI API + WebSocket)")
118
+ s.add_argument("entry", help="путь к app.py (экспортирует app)")
119
+ s.add_argument("--out", default="dist", help="каталог вывода")
120
+ s.add_argument("--port", type=int, default=8000, help="желаемый порт (свободный подберётся рядом)")
121
+ s.add_argument("--host", default="127.0.0.1", help="хост (по умолчанию 127.0.0.1)")
122
+ s.set_defaults(func=cmd_serve)
123
+
124
+ args = parser.parse_args(argv)
125
+ args.func(args)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -0,0 +1,134 @@
1
+ """evenis.compiler — конвейер компиляции (compile-time слой).
2
+
3
+ Этапы:
4
+ 1. AST: собрать VNode-дерево каждой страницы (App.pages → manifest маршрутов).
5
+ 2. layout resolve: уже выполнен при построении VNode (Row/Column/... → классы).
6
+ 3. CSS generator: theme → style.css.
7
+ 4. HTML generator: SSR начального маршрута → index.html (первый кадр).
8
+ 5. JS runtime injector: runtime + манифест приложения → app.js.
9
+
10
+ Результат пишется в каталог `outdir` (по умолчанию dist/):
11
+ dist/index.html, dist/style.css, dist/app.js
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ from typing import TYPE_CHECKING
19
+
20
+ from ..router import build_routes
21
+ from ..style.css import generate_css
22
+ from .html import render_page_html
23
+ from .runtime import RUNTIME_JS
24
+
25
+ if TYPE_CHECKING: # pragma: no cover
26
+ from ..core import App
27
+
28
+
29
+ def build_manifest(app: "App") -> dict:
30
+ """Собрать JSON-манифест приложения для рантайма."""
31
+ routes = build_routes(app.pages)
32
+ if not routes:
33
+ raise ValueError("В приложении нет страниц: добавьте хотя бы одну Page")
34
+ fallback = app.pages[0].path
35
+ computed = [{"name": k, "expr": v} for k, v in app.computed.items()]
36
+ manifest = {
37
+ "title": app.title,
38
+ "mount": "app",
39
+ "state": app.state,
40
+ "routes": routes,
41
+ "fallback": fallback,
42
+ "breakpoint": app.theme.breakpoint,
43
+ "computed": computed,
44
+ "middleware": app.middleware,
45
+ "features": app.features,
46
+ "dev": app.dev,
47
+ }
48
+ # Серверный слой (если использовались @server/@action/LiveState).
49
+ try:
50
+ from ..server import REGISTRY, server_manifest
51
+
52
+ if REGISTRY.endpoints or REGISTRY.livestates:
53
+ sm = server_manifest()
54
+ manifest["server"] = sm
55
+ # начальные значения LiveState кладём прямо в state, чтобы первый кадр
56
+ # уже показывал актуальные данные ещё до WebSocket-подключения.
57
+ for k, v in sm.get("live", {}).items():
58
+ manifest["state"].setdefault(k, v)
59
+ except Exception:
60
+ pass
61
+ # Загрузка данных при входе на страницу (Page.load → server endpoint).
62
+ for path, route in routes.items():
63
+ page = next((p for p in app.pages if p.path == path), None)
64
+ if page is not None and getattr(page, "load", None):
65
+ route["load"] = page.load
66
+ return manifest
67
+
68
+
69
+ _HOT_RELOAD = """
70
+ <script>
71
+ (function(){
72
+ var cur=null;
73
+ setInterval(function(){
74
+ fetch('__evenis_buildid?ts='+Date.now()).then(function(r){return r.text();}).then(function(id){
75
+ if(cur===null){cur=id;return;} if(id!==cur){location.reload();}
76
+ }).catch(function(){});
77
+ }, 800);
78
+ })();
79
+ </script>"""
80
+
81
+
82
+ def _index_html(app: "App", manifest: dict) -> str:
83
+ initial_path = manifest["fallback"]
84
+ initial_page = manifest["routes"][initial_path]
85
+ ssr = render_page_html(initial_page, app.state)
86
+ title = manifest["routes"][initial_path].get("title", app.title)
87
+ hot = _HOT_RELOAD if app.dev else ""
88
+ return f"""<!doctype html>
89
+ <html lang="ru">
90
+ <head>
91
+ <meta charset="utf-8" />
92
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
93
+ <title>{title} · {app.title}</title>
94
+ <link rel="stylesheet" href="style.css" />
95
+ </head>
96
+ <body>
97
+ <div id="app">{ssr}</div>
98
+ <script src="app.js"></script>{hot}
99
+ </body>
100
+ </html>
101
+ """
102
+
103
+
104
+ def _app_js(manifest: dict) -> str:
105
+ data = json.dumps(manifest, ensure_ascii=False)
106
+ return RUNTIME_JS + "\n;window.EVENIS.start(" + data + ");\n"
107
+
108
+
109
+ def build_app(app: "App", outdir: str = "dist") -> str:
110
+ """Скомпилировать App в готовый сайт. Возвращает абсолютный путь к outdir."""
111
+ os.makedirs(outdir, exist_ok=True)
112
+
113
+ manifest = build_manifest(app)
114
+
115
+ css = generate_css(app.theme, features=app.features)
116
+ html_doc = _index_html(app, manifest)
117
+ js = _app_js(manifest)
118
+
119
+ with open(os.path.join(outdir, "style.css"), "w", encoding="utf-8") as f:
120
+ f.write(css)
121
+ with open(os.path.join(outdir, "index.html"), "w", encoding="utf-8") as f:
122
+ f.write(html_doc)
123
+ with open(os.path.join(outdir, "app.js"), "w", encoding="utf-8") as f:
124
+ f.write(js)
125
+ if app.dev:
126
+ import time
127
+
128
+ with open(os.path.join(outdir, "__evenis_buildid"), "w", encoding="utf-8") as f:
129
+ f.write(str(time.time()))
130
+
131
+ return os.path.abspath(outdir)
132
+
133
+
134
+ __all__ = ["build_app", "build_manifest"]
@@ -0,0 +1,120 @@
1
+ """evenis.compiler.html — генератор HTML из VNode (server-side, первый кадр).
2
+
3
+ Рендерит VNode-дерево в строку HTML, вычисляя биндинги/if/for на начальном
4
+ state. Это нужно для мгновенного первого кадра и graceful-деградации без JS;
5
+ дальше управление перехватывает рантайм. Логика интерпретации намеренно
6
+ совпадает с рантаймом (см. runtime.py).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import html
12
+ import re
13
+ from typing import Any, Dict
14
+
15
+ _VOID = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"}
16
+ _BINDING = re.compile(r"\{\{\s*(.*?)\s*\}\}")
17
+
18
+
19
+ def _eval(expr: str, scope: Dict[str, Any]) -> Any:
20
+ """Безопасно вычислить выражение над scope (подмножество, без builtins)."""
21
+ try:
22
+ return eval(expr, {"__builtins__": {}}, dict(scope)) # noqa: S307 - sandboxed scope
23
+ except Exception:
24
+ return None
25
+
26
+
27
+ def _interpolate(text: str, scope: Dict[str, Any]) -> str:
28
+ def repl(m):
29
+ val = _eval(m.group(1), scope)
30
+ return "" if val is None else str(val)
31
+
32
+ return _BINDING.sub(repl, text)
33
+
34
+
35
+ def _render_attrs(props: Dict[str, Any], scope: Dict[str, Any]) -> str:
36
+ out = []
37
+ for k, v in props.items():
38
+ if v is True:
39
+ out.append(f" {k}")
40
+ elif v is False or v is None:
41
+ continue
42
+ else:
43
+ val = _interpolate(str(v), scope)
44
+ out.append(f' {k}="{html.escape(val, quote=True)}"')
45
+ return "".join(out)
46
+
47
+
48
+ def render_vnode(vnode: Any, scope: Dict[str, Any]) -> str:
49
+ if vnode is None:
50
+ return ""
51
+ t = vnode.get("type")
52
+
53
+ if t == "text":
54
+ return html.escape(_interpolate(vnode.get("value", ""), scope))
55
+
56
+ if t == "fragment":
57
+ return "".join(render_vnode(c, scope) for c in vnode.get("children", []))
58
+
59
+ if t == "if":
60
+ if _eval(vnode["cond"], scope):
61
+ return render_vnode(vnode["then"], scope)
62
+ return render_vnode(vnode["else"], scope) if vnode.get("else") else ""
63
+
64
+ if t == "for":
65
+ items = _eval(vnode["items"], scope)
66
+ if not isinstance(items, (list, tuple)):
67
+ return ""
68
+ var, idx = vnode.get("var", "item"), vnode.get("index", "index")
69
+ chunks = []
70
+ for i, it in enumerate(items):
71
+ inner = dict(scope)
72
+ inner[var] = it
73
+ inner[idx] = i
74
+ chunks.append(render_vnode(vnode["body"], inner))
75
+ return "".join(chunks)
76
+
77
+ if t == "responsive":
78
+ # SSR рендерит desktop-ветку (рантайм переопределит по ширине окна)
79
+ return render_vnode(vnode["desktop"], scope)
80
+
81
+ if t == "adaptive":
82
+ # SSR — статичная сетка; рантайм пересчитает число колонок по ширине окна
83
+ gap = vnode.get("gap", 16)
84
+ max_cols = vnode.get("maxCols", 6)
85
+ inner = "".join(render_vnode(c, scope) for c in vnode.get("children", []))
86
+ n = len(vnode.get("children", []))
87
+ cols = max(1, min(max_cols, n or 1))
88
+ style = (
89
+ f"display:grid; gap:{gap}px; "
90
+ f"grid-template-columns:repeat({cols},minmax(0,1fr)); padding:16px"
91
+ )
92
+ return f'<div class="evenis-adaptive" style="{html.escape(style, quote=True)}">{inner}</div>'
93
+
94
+ if t == "page":
95
+ cls = f"evenis-page evenis-anim-{vnode.get('transition', 'fade-in')}"
96
+ return f'<div class="{cls}" data-page="{html.escape(vnode.get("path",""))}">' + render_vnode(vnode["child"], scope) + "</div>"
97
+
98
+ if t == "element":
99
+ tag = vnode["tag"]
100
+ props = dict(vnode.get("props", {}))
101
+ # bindClass: добавить классы по условиям
102
+ bind_class = vnode.get("bindClass")
103
+ if bind_class:
104
+ extra = [cn for cn, expr in bind_class.items() if _eval(expr, scope)]
105
+ if extra:
106
+ props["class"] = (props.get("class", "") + " " + " ".join(extra)).strip()
107
+ attrs = _render_attrs(props, scope)
108
+ if tag in _VOID:
109
+ return f"<{tag}{attrs}>"
110
+ inner = "".join(render_vnode(c, scope) for c in vnode.get("children", []))
111
+ return f"<{tag}{attrs}>{inner}</{tag}>"
112
+
113
+ return ""
114
+
115
+
116
+ def render_page_html(page_vnode: dict, state: Dict[str, Any]) -> str:
117
+ return render_vnode(page_vnode, state)
118
+
119
+
120
+ __all__ = ["render_vnode", "render_page_html"]