Nexa.py 0.5.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.
- Nexa/Core/__init__.py +5 -0
- Nexa/Core/core.py +80 -0
- Nexa/LIB/__init__.py +0 -0
- Nexa/LIB/env.py +43 -0
- Nexa/NEXA/__init__.py +0 -0
- Nexa/NEXA/nexa_stp.py +118 -0
- Nexa/__init__.py +79 -0
- Nexa/__json.py +33 -0
- Nexa/__main__.py +4 -0
- Nexa/blueprint.py +88 -0
- Nexa/compiler.py +217 -0
- Nexa/nexa.py +161 -0
- Nexa/nforms.py +1012 -0
- Nexa/nserver.py +45 -0
- Nexa/nx.py +49 -0
- Nexa/nxscript.py +953 -0
- Nexa/nxserver.py +1343 -0
- Nexa/nxsql.py +362 -0
- Nexa/template.py +288 -0
- nexa_py-0.5.0.dist-info/METADATA +150 -0
- nexa_py-0.5.0.dist-info/RECORD +24 -0
- nexa_py-0.5.0.dist-info/WHEEL +5 -0
- nexa_py-0.5.0.dist-info/entry_points.txt +2 -0
- nexa_py-0.5.0.dist-info/top_level.txt +1 -0
Nexa/Core/__init__.py
ADDED
Nexa/Core/core.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Event-based browser/Python communication over an NXserver instance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
from weakref import WeakKeyDictionary
|
|
7
|
+
|
|
8
|
+
from ..nxserver import NXserver, jsonify, request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Core:
|
|
12
|
+
"""Register browser-callable Python events on the existing NXserver.
|
|
13
|
+
|
|
14
|
+
Browser clients POST ``{"event": "name", "data": {...}}`` to the
|
|
15
|
+
app-specific ``core_event_endpoint`` installed by :meth:`init_app`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
_event_handlers: dict[str, Callable[[dict[str, Any]], Any]] = {}
|
|
19
|
+
_apps: WeakKeyDictionary[NXserver, str] = WeakKeyDictionary()
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def event(cls, name: str):
|
|
23
|
+
"""Register a Python handler callable from a browser event request."""
|
|
24
|
+
if not isinstance(name, str) or not name.strip():
|
|
25
|
+
raise ValueError("Core event names must be non-empty strings.")
|
|
26
|
+
name = name.strip()
|
|
27
|
+
|
|
28
|
+
def decorator(handler: Callable[[dict[str, Any]], Any]):
|
|
29
|
+
if name in cls._event_handlers:
|
|
30
|
+
raise ValueError(f"Core event {name!r} is already registered.")
|
|
31
|
+
cls._event_handlers[name] = handler
|
|
32
|
+
return handler
|
|
33
|
+
|
|
34
|
+
return decorator
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def init_app(cls, app: NXserver) -> NXserver:
|
|
38
|
+
"""Attach Core's event endpoint to *app* without starting another server."""
|
|
39
|
+
if not isinstance(app, NXserver):
|
|
40
|
+
raise TypeError("Core.init_app() requires an NXserver instance.")
|
|
41
|
+
if app in cls._apps:
|
|
42
|
+
return app
|
|
43
|
+
|
|
44
|
+
endpoint = "/__nexa/core/events"
|
|
45
|
+
suffix = 1
|
|
46
|
+
while app.router.route_for_path(endpoint) is not None:
|
|
47
|
+
endpoint = f"/__nexa/core/events-{suffix}"
|
|
48
|
+
suffix += 1
|
|
49
|
+
|
|
50
|
+
@app.post(endpoint)
|
|
51
|
+
def dispatch_event():
|
|
52
|
+
payload = request.json
|
|
53
|
+
if not isinstance(payload, dict):
|
|
54
|
+
return jsonify(
|
|
55
|
+
{"error": "Request body must be a JSON object."}, status=400
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
event_name = payload.get("event")
|
|
59
|
+
if not isinstance(event_name, str) or not event_name:
|
|
60
|
+
return jsonify(
|
|
61
|
+
{"error": "A non-empty 'event' name is required."}, status=400
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
handler = cls._event_handlers.get(event_name)
|
|
65
|
+
if handler is None:
|
|
66
|
+
return jsonify(
|
|
67
|
+
{"error": f"Unknown Core event {event_name!r}."}, status=404
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
data = payload.get("data", {})
|
|
71
|
+
if not isinstance(data, dict):
|
|
72
|
+
return jsonify(
|
|
73
|
+
{"error": "Core event 'data' must be a JSON object."}, status=400
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return jsonify(handler(data))
|
|
77
|
+
|
|
78
|
+
app.core_event_endpoint = endpoint
|
|
79
|
+
cls._apps[app] = endpoint
|
|
80
|
+
return app
|
Nexa/LIB/__init__.py
ADDED
|
File without changes
|
Nexa/LIB/env.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
|
|
2
|
+
import sys
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class __env_init_err__(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def __get_lib__bin(script: str):
|
|
12
|
+
try:
|
|
13
|
+
import importlib
|
|
14
|
+
return importlib.import_module(f"nexa.BIN.{script}")
|
|
15
|
+
except ModuleNotFoundError as exc:
|
|
16
|
+
if exc.name == f"nexa.BIN.{script}" or exc.name.startswith("nexa.BIN."):
|
|
17
|
+
raise __env_init_err__(
|
|
18
|
+
f"CDE CNTFNDCRPT: Cannot find nexa.BIN.{script}"
|
|
19
|
+
) from exc
|
|
20
|
+
raise
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def __env_setup__():
|
|
24
|
+
cwd = Path.cwd()
|
|
25
|
+
cwd_str = str(cwd)
|
|
26
|
+
|
|
27
|
+
if cwd_str not in sys.path:
|
|
28
|
+
sys.path.insert(0, cwd_str)
|
|
29
|
+
|
|
30
|
+
config_path = cwd / "nexa.toml"
|
|
31
|
+
|
|
32
|
+
if not config_path.is_file():
|
|
33
|
+
raise __env_init_err__(
|
|
34
|
+
f"CDE CNTFNDTOML: nexa.toml not found in {cwd}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
with config_path.open("rb") as file:
|
|
39
|
+
return tomllib.load(file)
|
|
40
|
+
except tomllib.TOMLDecodeError as exc:
|
|
41
|
+
raise __env_init_err__(
|
|
42
|
+
f"CDE INVALIDTOML: {exc}"
|
|
43
|
+
) from exc
|
Nexa/NEXA/__init__.py
ADDED
|
File without changes
|
Nexa/NEXA/nexa_stp.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import runpy
|
|
4
|
+
import sys
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AbordErr(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ScriptError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def getYNinput(text: str) -> bool:
|
|
17
|
+
while True:
|
|
18
|
+
try:
|
|
19
|
+
inp = input(text).strip().lower()
|
|
20
|
+
except KeyboardInterrupt:
|
|
21
|
+
print()
|
|
22
|
+
continue
|
|
23
|
+
|
|
24
|
+
if inp == "y":
|
|
25
|
+
return True
|
|
26
|
+
if inp == "n":
|
|
27
|
+
return False
|
|
28
|
+
print("Please enter y or n.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Scripts:
|
|
32
|
+
def __init__(self, config_path="nexa.toml"):
|
|
33
|
+
self.project_dir = Path.cwd().resolve()
|
|
34
|
+
self.config_path = self.project_dir / config_path
|
|
35
|
+
|
|
36
|
+
if not self.config_path.is_file():
|
|
37
|
+
raise ScriptError(
|
|
38
|
+
f"Nexa configuration not found: {self.config_path}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
with self.config_path.open("rb") as file:
|
|
43
|
+
self.config = tomllib.load(file)
|
|
44
|
+
except tomllib.TOMLDecodeError as exc:
|
|
45
|
+
raise ScriptError(f"Invalid nexa.toml: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
self.scripts = self.config.get("scripts", {})
|
|
48
|
+
|
|
49
|
+
def resolve(self, script_name: str) -> Path:
|
|
50
|
+
# Use an alias if one exists; otherwise treat it as a file path.
|
|
51
|
+
target = self.scripts.get(script_name, script_name)
|
|
52
|
+
path = Path(target)
|
|
53
|
+
|
|
54
|
+
if not path.is_absolute():
|
|
55
|
+
path = self.project_dir / path
|
|
56
|
+
|
|
57
|
+
path = path.resolve()
|
|
58
|
+
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
available = ", ".join(self.scripts) or "(none)"
|
|
61
|
+
raise ScriptError(
|
|
62
|
+
f"Script file not found: {path}\n"
|
|
63
|
+
f"Configured targets: {available}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if path.suffix.lower() not in {".py", ".nx"}:
|
|
67
|
+
raise ScriptError(f"Unsupported script type: {path.suffix}")
|
|
68
|
+
|
|
69
|
+
return path
|
|
70
|
+
|
|
71
|
+
def toexec(self, script_name: str, noenv: bool = False):
|
|
72
|
+
project = str(self.project_dir)
|
|
73
|
+
if project not in sys.path:
|
|
74
|
+
sys.path.insert(0, project)
|
|
75
|
+
|
|
76
|
+
path = self.resolve(script_name)
|
|
77
|
+
|
|
78
|
+
if not noenv:
|
|
79
|
+
try:
|
|
80
|
+
from ..LIB.env import __env_setup__
|
|
81
|
+
__env_setup__()
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
raise ScriptError(
|
|
84
|
+
f"Environment initialization failed: {exc}"
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
sys.argv = [str(path)]
|
|
88
|
+
|
|
89
|
+
def execute():
|
|
90
|
+
if path.suffix.lower() == ".py":
|
|
91
|
+
runpy.run_path(str(path), run_name="__main__")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
from compiler import compile_file
|
|
96
|
+
except ImportError as exc:
|
|
97
|
+
raise ScriptError(
|
|
98
|
+
f"Cannot import compile_file from compiler.py: {exc}"
|
|
99
|
+
) from exc
|
|
100
|
+
|
|
101
|
+
source = compile_file(path)
|
|
102
|
+
code = compile(source, str(path), "exec")
|
|
103
|
+
namespace = {
|
|
104
|
+
"__name__": "__main__",
|
|
105
|
+
"__file__": str(path),
|
|
106
|
+
"__package__": None,
|
|
107
|
+
}
|
|
108
|
+
exec(code, namespace)
|
|
109
|
+
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
execute()
|
|
113
|
+
return
|
|
114
|
+
except KeyboardInterrupt:
|
|
115
|
+
print()
|
|
116
|
+
if getYNinput("Are you sure you want to abort? [y/n]: "):
|
|
117
|
+
raise AbordErr("Execution aborted by user.")
|
|
118
|
+
print("Resuming execution...")
|
Nexa/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
|
|
2
|
+
try:
|
|
3
|
+
from .nexa import app
|
|
4
|
+
except ImportError:
|
|
5
|
+
app = None # typer not installed
|
|
6
|
+
from .nx import App, Element, Fragment
|
|
7
|
+
from .nxserver import NXserver, Nserver, jsonify, render_template,request
|
|
8
|
+
from .blueprint import Blueprint
|
|
9
|
+
from .Core import Core
|
|
10
|
+
from .nforms import (
|
|
11
|
+
Form,
|
|
12
|
+
ValidationError,
|
|
13
|
+
CharField,
|
|
14
|
+
EmailField,
|
|
15
|
+
IntegerField,
|
|
16
|
+
FloatField,
|
|
17
|
+
BooleanField,
|
|
18
|
+
DateField,
|
|
19
|
+
DateTimeField,
|
|
20
|
+
ChoiceField,
|
|
21
|
+
MultipleChoiceField,
|
|
22
|
+
PasswordField,
|
|
23
|
+
TextInput,
|
|
24
|
+
EmailInput,
|
|
25
|
+
PasswordInput,
|
|
26
|
+
NumberInput,
|
|
27
|
+
CheckboxInput,
|
|
28
|
+
Select,
|
|
29
|
+
Textarea,
|
|
30
|
+
HiddenInput,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Create Nforms namespace class for cleaner imports
|
|
34
|
+
class Nforms:
|
|
35
|
+
'''Nexa's native form-handling system.'''
|
|
36
|
+
Form = Form
|
|
37
|
+
ValidationError = ValidationError
|
|
38
|
+
CharField = CharField
|
|
39
|
+
EmailField = EmailField
|
|
40
|
+
IntegerField = IntegerField
|
|
41
|
+
FloatField = FloatField
|
|
42
|
+
BooleanField = BooleanField
|
|
43
|
+
DateField = DateField
|
|
44
|
+
DateTimeField = DateTimeField
|
|
45
|
+
ChoiceField = ChoiceField
|
|
46
|
+
MultipleChoiceField = MultipleChoiceField
|
|
47
|
+
PasswordField = PasswordField
|
|
48
|
+
TextInput = TextInput
|
|
49
|
+
EmailInput = EmailInput
|
|
50
|
+
PasswordInput = PasswordInput
|
|
51
|
+
NumberInput = NumberInput
|
|
52
|
+
CheckboxInput = CheckboxInput
|
|
53
|
+
Select = Select
|
|
54
|
+
Textarea = Textarea
|
|
55
|
+
HiddenInput = HiddenInput
|
|
56
|
+
|
|
57
|
+
# Also expose widgets that map to field types
|
|
58
|
+
DateInput = TextInput # DateField uses TextInput for now
|
|
59
|
+
|
|
60
|
+
from os import getenv as _env
|
|
61
|
+
|
|
62
|
+
def getenv(key:str,default):
|
|
63
|
+
return _env(key,default)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"app",
|
|
68
|
+
"App",
|
|
69
|
+
"Element",
|
|
70
|
+
"Fragment",
|
|
71
|
+
"NXserver",
|
|
72
|
+
"Nserver",
|
|
73
|
+
"Core",
|
|
74
|
+
"jsonify",
|
|
75
|
+
"render_template",
|
|
76
|
+
"Blueprint",
|
|
77
|
+
"getenv",
|
|
78
|
+
"Nforms"
|
|
79
|
+
]
|
Nexa/__json.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class JSONResponse:
|
|
7
|
+
"""A response that renders its data as JSON.
|
|
8
|
+
|
|
9
|
+
This class is also re-exported by nxserver.jsonify() and can be used
|
|
10
|
+
directly if you prefer the explicit style.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, data: Any, status: int = 200) -> None:
|
|
14
|
+
self.data = data
|
|
15
|
+
self.status = status
|
|
16
|
+
|
|
17
|
+
def render(self) -> str:
|
|
18
|
+
"""Return the JSON-encoded body for this response."""
|
|
19
|
+
return json.dumps(self.data, ensure_ascii=False)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def jsonify(data: Any, status: int = 200) -> JSONResponse:
|
|
23
|
+
"""Create a JSON response for a Nexa route.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
data: Any JSON-serializable value (dicts, lists, strings, numbers,
|
|
27
|
+
booleans, and None all work).
|
|
28
|
+
status: HTTP status code to attach to the response. Defaults to 200.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A JSONResponse instance that the Nexa server can render.
|
|
32
|
+
"""
|
|
33
|
+
return JSONResponse(data=data, status=status)
|
Nexa/__main__.py
ADDED
Nexa/blueprint.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional
|
|
4
|
+
|
|
5
|
+
_METHOD_NAMES = {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Blueprint:
|
|
9
|
+
"""A Blueprint groups related routes under a common prefix.
|
|
10
|
+
|
|
11
|
+
Blueprints use the same decorator-style API as :class:`NXserver`::
|
|
12
|
+
|
|
13
|
+
api = Blueprint("api", prefix="/api")
|
|
14
|
+
|
|
15
|
+
@api.get("/ping")
|
|
16
|
+
def pong():
|
|
17
|
+
return jsonify({"status": "ok"})
|
|
18
|
+
|
|
19
|
+
@api.route("/users", methods=["GET", "POST"])
|
|
20
|
+
def users():
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
The blueprint is registered on the server with
|
|
24
|
+
:meth:`NXserver.register_blueprint`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, name: str, prefix: str = "") -> None:
|
|
28
|
+
if not name.isidentifier():
|
|
29
|
+
raise ValueError(f"Blueprint name {name!r} must be a valid Python identifier")
|
|
30
|
+
self.name = name
|
|
31
|
+
self.prefix = prefix.rstrip("/")
|
|
32
|
+
self._routes: list[tuple[str, Callable, set[str]]] = []
|
|
33
|
+
|
|
34
|
+
def _add_route(self, path: str, methods: Optional[set[str]] = None):
|
|
35
|
+
"""Return a decorator that registers a route."""
|
|
36
|
+
if not path.startswith("/"):
|
|
37
|
+
raise ValueError("Routes must start with '/'")
|
|
38
|
+
|
|
39
|
+
if methods is None:
|
|
40
|
+
method_set = {"GET", "HEAD"}
|
|
41
|
+
else:
|
|
42
|
+
method_set = {m.upper() for m in methods}
|
|
43
|
+
for m in method_set:
|
|
44
|
+
if m not in _METHOD_NAMES and m != "HEAD":
|
|
45
|
+
raise ValueError(f"Unsupported HTTP method {m!r}")
|
|
46
|
+
|
|
47
|
+
if "HEAD" not in method_set:
|
|
48
|
+
method_set.add("HEAD")
|
|
49
|
+
|
|
50
|
+
def decorator(handler: Callable) -> Callable:
|
|
51
|
+
self._routes.append((path, handler, method_set))
|
|
52
|
+
return handler
|
|
53
|
+
|
|
54
|
+
return decorator
|
|
55
|
+
|
|
56
|
+
def route(self, path: str, methods: Optional[list[str]] = None):
|
|
57
|
+
"""Register a handler for *path* on this blueprint.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
path: URL path relative to the blueprint prefix, e.g. ``"/ping"``.
|
|
61
|
+
methods: Optional explicit list of HTTP methods. When omitted the
|
|
62
|
+
handler responds to GET and HEAD.
|
|
63
|
+
"""
|
|
64
|
+
method_set: Optional[set[str]] = None
|
|
65
|
+
if methods is not None:
|
|
66
|
+
method_set = {m.upper() for m in methods}
|
|
67
|
+
return self._add_route(path, method_set)
|
|
68
|
+
|
|
69
|
+
def get(self, path: str):
|
|
70
|
+
return self._add_route(path, {"GET"})
|
|
71
|
+
|
|
72
|
+
def post(self, path: str):
|
|
73
|
+
return self._add_route(path, {"POST"})
|
|
74
|
+
|
|
75
|
+
def put(self, path: str):
|
|
76
|
+
return self._add_route(path, {"PUT"})
|
|
77
|
+
|
|
78
|
+
def patch(self, path: str):
|
|
79
|
+
return self._add_route(path, {"PATCH"})
|
|
80
|
+
|
|
81
|
+
def delete(self, path: str):
|
|
82
|
+
return self._add_route(path, {"DELETE"})
|
|
83
|
+
|
|
84
|
+
def head(self, path: str):
|
|
85
|
+
return self._add_route(path, {"HEAD"})
|
|
86
|
+
|
|
87
|
+
def options(self, path: str):
|
|
88
|
+
return self._add_route(path, {"OPTIONS"})
|
Nexa/compiler.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NexaSyntaxError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TAG_OPEN = re.compile(
|
|
11
|
+
r"^\s*<([A-Za-z][\w.-]*)(.*?)>\s*$"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
TAG_INLINE = re.compile(
|
|
15
|
+
r"^\s*<([A-Za-z][\w.-]*)(.*?)>(.*?)</\1>\s*$"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
FOR_LINE = re.compile(r"^for\s+(.+?)\s*:\s*$")
|
|
19
|
+
|
|
20
|
+
ATTRIBUTE = re.compile(
|
|
21
|
+
r"""([:\w-]+)\s*=\s*("([^"]*)"|'([^']*)'|\{([^{}]*)\})"""
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def indentation(line):
|
|
26
|
+
return len(line) - len(line.lstrip())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compile_text(text):
|
|
30
|
+
"""Convert text with {expressions} into a Python expression."""
|
|
31
|
+
parts = []
|
|
32
|
+
position = 0
|
|
33
|
+
|
|
34
|
+
for match in re.finditer(r"\{([^{}]+)\}", text):
|
|
35
|
+
literal = text[position:match.start()]
|
|
36
|
+
expression = match.group(1).strip()
|
|
37
|
+
|
|
38
|
+
if literal:
|
|
39
|
+
parts.append(repr(literal))
|
|
40
|
+
|
|
41
|
+
if not expression:
|
|
42
|
+
raise NexaSyntaxError("Empty expression inside {}")
|
|
43
|
+
|
|
44
|
+
parts.append(f"str({expression})")
|
|
45
|
+
position = match.end()
|
|
46
|
+
|
|
47
|
+
remaining = text[position:]
|
|
48
|
+
|
|
49
|
+
if remaining:
|
|
50
|
+
parts.append(repr(remaining))
|
|
51
|
+
|
|
52
|
+
if not parts:
|
|
53
|
+
return "''"
|
|
54
|
+
|
|
55
|
+
return " + ".join(parts)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def compile_attributes(source):
|
|
59
|
+
props = {}
|
|
60
|
+
|
|
61
|
+
for match in ATTRIBUTE.finditer(source):
|
|
62
|
+
name = match.group(1)
|
|
63
|
+
|
|
64
|
+
if match.group(5) is not None:
|
|
65
|
+
value = match.group(5).strip()
|
|
66
|
+
if not value:
|
|
67
|
+
raise NexaSyntaxError(
|
|
68
|
+
f"Empty expression for attribute {name!r}"
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
value = repr(
|
|
72
|
+
match.group(3)
|
|
73
|
+
if match.group(3) is not None
|
|
74
|
+
else match.group(4)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
props[name] = value
|
|
78
|
+
|
|
79
|
+
# Reject attribute syntax this prototype doesn't understand.
|
|
80
|
+
residue = ATTRIBUTE.sub("", source).strip()
|
|
81
|
+
if residue:
|
|
82
|
+
raise NexaSyntaxError(
|
|
83
|
+
f"Unsupported attribute syntax: {residue}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return "{" + ", ".join(
|
|
87
|
+
f"{name!r}: {value}"
|
|
88
|
+
for name, value in props.items()
|
|
89
|
+
) + "}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_node(lines, index):
|
|
93
|
+
"""Parse one JSX-like element or a loop containing one element."""
|
|
94
|
+
while index < len(lines) and not lines[index].strip():
|
|
95
|
+
index += 1
|
|
96
|
+
|
|
97
|
+
if index >= len(lines):
|
|
98
|
+
raise NexaSyntaxError("Expected an element")
|
|
99
|
+
|
|
100
|
+
line = lines[index]
|
|
101
|
+
indent = indentation(line)
|
|
102
|
+
stripped = line.strip()
|
|
103
|
+
|
|
104
|
+
loop_match = FOR_LINE.fullmatch(stripped)
|
|
105
|
+
|
|
106
|
+
if loop_match:
|
|
107
|
+
target = loop_match.group(1)
|
|
108
|
+
|
|
109
|
+
body_index = index + 1
|
|
110
|
+
while body_index < len(lines) and not lines[body_index].strip():
|
|
111
|
+
body_index += 1
|
|
112
|
+
|
|
113
|
+
if body_index >= len(lines):
|
|
114
|
+
raise NexaSyntaxError("Loop has no body")
|
|
115
|
+
|
|
116
|
+
if indentation(lines[body_index]) <= indent:
|
|
117
|
+
raise NexaSyntaxError(
|
|
118
|
+
f"Loop body must be indented: {stripped}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
body_expr, next_index = parse_node(lines, body_index)
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
f"*({body_expr} for {target})",
|
|
125
|
+
next_index,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
inline = TAG_INLINE.fullmatch(line)
|
|
129
|
+
|
|
130
|
+
if inline:
|
|
131
|
+
tag = inline.group(1)
|
|
132
|
+
raw_attrs = opening.group(2).strip()
|
|
133
|
+
|
|
134
|
+
# Remove the trailing slash from self-closing tags.
|
|
135
|
+
if raw_attrs.endswith("/"):
|
|
136
|
+
raw_attrs = raw_attrs[:-1].rstrip()
|
|
137
|
+
|
|
138
|
+
attrs = compile_attributes(raw_attrs)
|
|
139
|
+
content = compile_text(inline.group(3))
|
|
140
|
+
|
|
141
|
+
return f"Element({tag!r}, {attrs}, {content})", index + 1
|
|
142
|
+
|
|
143
|
+
opening = TAG_OPEN.fullmatch(line)
|
|
144
|
+
|
|
145
|
+
if not opening:
|
|
146
|
+
raise NexaSyntaxError(
|
|
147
|
+
f"Expected a JSX element at line {index + 1}: {stripped}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
tag = opening.group(1)
|
|
151
|
+
attrs = compile_attributes(opening.group(2))
|
|
152
|
+
children = []
|
|
153
|
+
index += 1
|
|
154
|
+
|
|
155
|
+
while index < len(lines):
|
|
156
|
+
current = lines[index]
|
|
157
|
+
current_text = current.strip()
|
|
158
|
+
|
|
159
|
+
if not current_text:
|
|
160
|
+
index += 1
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if current_text == f"</{tag}>":
|
|
164
|
+
index += 1
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
if current_text.startswith("</"):
|
|
168
|
+
raise NexaSyntaxError(
|
|
169
|
+
f"Unexpected closing tag at line {index + 1}: "
|
|
170
|
+
f"{current_text}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if indentation(current) <= indent:
|
|
174
|
+
raise NexaSyntaxError(
|
|
175
|
+
f"Missing closing tag </{tag}>"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
child_expr, index = parse_node(lines, index)
|
|
179
|
+
children.append(child_expr)
|
|
180
|
+
|
|
181
|
+
else:
|
|
182
|
+
raise NexaSyntaxError(f"Missing closing tag </{tag}>")
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
f"Element({tag!r}, {attrs}"
|
|
186
|
+
+ (", " + ", ".join(children) if children else "")
|
|
187
|
+
+ ")",
|
|
188
|
+
index,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def compile_source(source):
|
|
193
|
+
"""Translate supported .nx markup into Python source."""
|
|
194
|
+
lines = source.splitlines()
|
|
195
|
+
output = []
|
|
196
|
+
index = 0
|
|
197
|
+
|
|
198
|
+
while index < len(lines):
|
|
199
|
+
line = lines[index]
|
|
200
|
+
stripped = line.strip()
|
|
201
|
+
|
|
202
|
+
if stripped.startswith("<") and not stripped.startswith("</"):
|
|
203
|
+
indent = indentation(line)
|
|
204
|
+
expression, next_index = parse_node(lines, index)
|
|
205
|
+
|
|
206
|
+
output.append(" " * indent + expression)
|
|
207
|
+
index = next_index
|
|
208
|
+
else:
|
|
209
|
+
output.append(line)
|
|
210
|
+
index += 1
|
|
211
|
+
|
|
212
|
+
return "\n".join(output) + "\n"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def compile_file(path):
|
|
216
|
+
path = Path(path)
|
|
217
|
+
return compile_source(path.read_text(encoding="utf-8"))
|