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 ADDED
@@ -0,0 +1,5 @@
1
+ """Nexa Core browser/Python communication runtime."""
2
+
3
+ from .core import Core
4
+
5
+ __all__ = ["Core"]
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
@@ -0,0 +1,4 @@
1
+ from .nexa import app
2
+ if __name__ == "__main__":
3
+ app()
4
+
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"))