nyxa-forms 0.0.1__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.
nyxa_forms/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """nyxa-forms — web validation: FormRequest, rule strings, error bags, old input."""
2
+
3
+ from nyxa_forms.provider import FormsProvider
4
+ from nyxa_forms.request import FormRequest
5
+ from nyxa_forms.validator import (
6
+ Enum,
7
+ Password,
8
+ PasswordRule,
9
+ ValidationException,
10
+ Validator,
11
+ rule,
12
+ )
13
+
14
+ __all__ = [
15
+ "Enum",
16
+ "FormRequest",
17
+ "FormsProvider",
18
+ "Password",
19
+ "PasswordRule",
20
+ "ValidationException",
21
+ "Validator",
22
+ "rule",
23
+ ]
nyxa_forms/commands.py ADDED
@@ -0,0 +1,71 @@
1
+ """``make:request`` — scaffold a FormRequest under ``app/requests/``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from nyxa.command import Command
8
+
9
+
10
+ class MakeRequestCommand(Command):
11
+ """``nyxa make:request`` — generate a FormRequest class."""
12
+
13
+ signature = (
14
+ "make:request {name : resource/module name (post → app/requests/post.py)} "
15
+ "{--fields= : field specs name:type[:rules…],comma-separated} "
16
+ "{--update : emit only the Update class} "
17
+ "{--describe= : natural-language intent — AI/fallback fills rules} "
18
+ "{--force : overwrite existing}"
19
+ )
20
+ help = "Generate a FormRequest under app/requests/ (--fields for rule bodies)."
21
+ needs_app = False
22
+
23
+ def handle(
24
+ self,
25
+ name: str,
26
+ fields: str | None = None,
27
+ update: bool = False,
28
+ describe: str | None = None,
29
+ force: bool = False,
30
+ ) -> int:
31
+ """Write ``app/requests/<name>.py`` (optionally field-driven rules).
32
+
33
+ ``name`` is the resource/module name: ``post`` writes
34
+ ``app/requests/post.py`` holding the ``Store`` and ``Update``
35
+ FormRequest classes.
36
+
37
+ Args:
38
+ name: Resource/module name (must be a valid identifier).
39
+ fields: Optional ``name:type[:rules…]`` specs; when set, rules are
40
+ generated via the shared field grammar for both classes.
41
+ update: Emit only the ``Update`` class.
42
+ describe: Natural-language AI fill of the request rules.
43
+ force: Overwrite an existing file.
44
+
45
+ Returns:
46
+ ``0`` on success, ``1`` on invalid name or refusal to overwrite.
47
+ """
48
+ import re
49
+
50
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", name):
51
+ self.error(f"invalid resource name {name!r}")
52
+ return 1
53
+ from nyxa.cli.make import make_form_request
54
+
55
+ make_form_request(
56
+ name,
57
+ fields,
58
+ for_update=update,
59
+ io=self.io,
60
+ force=force,
61
+ )
62
+ if describe:
63
+ from nyxa.cli.ai_scaffold import fill
64
+
65
+ root = Path.cwd()
66
+ snake = "".join(f"_{c.lower()}" if c.isupper() else c for c in name).lstrip("_")
67
+ target = root / "app" / "requests" / f"{snake}.py"
68
+ if target.is_file():
69
+ body = fill(describe, "form request", name, target.read_text())
70
+ target.write_text(body)
71
+ return 0
nyxa_forms/handler.py ADDED
@@ -0,0 +1,35 @@
1
+ """``FormsExceptionHandler`` — HTML redirect-back vs JSON 422 for validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, override
6
+
7
+ from nyxa.exceptions import ExceptionHandler
8
+
9
+ if TYPE_CHECKING:
10
+ from nyxa.http.context import HttpContext
11
+ from nyxa.http.response import Response
12
+
13
+
14
+ class FormsExceptionHandler(ExceptionHandler):
15
+ """Wraps the app's handler — ``ValidationException`` becomes a
16
+ redirect-back + flashed errors/old-input for HTML requests, 422
17
+ JSON otherwise. Everything else delegates to the previous handler.
18
+ """
19
+
20
+ def __init__(self, inner: ExceptionHandler) -> None:
21
+ """Wrap ``inner`` — the app's previous handler."""
22
+ self.inner = inner
23
+
24
+ @override
25
+ async def handle(self, error: Exception, ctx: HttpContext) -> Response:
26
+ """Redirect-back with flash for HTML validation failures."""
27
+ from nyxa.http.response import Response
28
+ from nyxa_forms.helpers import flash_old, with_errors
29
+ from nyxa_forms.validator import ValidationException
30
+
31
+ if isinstance(error, ValidationException) and not ctx.request.wants_json():
32
+ with_errors(ctx, error.bag)
33
+ flash_old(ctx, error.data)
34
+ return Response.redirect(ctx.request.header("referer") or "/", status=303)
35
+ return await self.inner.handle(error, ctx)
nyxa_forms/helpers.py ADDED
@@ -0,0 +1,134 @@
1
+ """Flash/old-input + redirect helpers layered on ``nyxa-session``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import TYPE_CHECKING, Protocol, TypeGuard, overload, runtime_checkable
7
+
8
+ if TYPE_CHECKING:
9
+ from nyxa.http.context import HttpContext
10
+ from nyxa.http.response import Response
11
+
12
+
13
+ @runtime_checkable
14
+ class _FlashSession(Protocol):
15
+ """Session bag surface used for one-request form flashes."""
16
+
17
+ def get(self, key: str, default: object = None) -> object:
18
+ """Read one session value."""
19
+ ...
20
+
21
+ def flash(self, key: str, value: object) -> None:
22
+ """Persist one value for the next request."""
23
+ ...
24
+
25
+
26
+ def _session(ctx: HttpContext) -> _FlashSession | None:
27
+ """Narrow the optional session-store object held in request state.
28
+
29
+ Args:
30
+ ctx: Request context whose session middleware may have populated state.
31
+
32
+ Returns:
33
+ A compatible flash session, or ``None`` when unavailable.
34
+ """
35
+ value: object = ctx.state.get("session")
36
+ return value if isinstance(value, _FlashSession) else None
37
+
38
+
39
+ def _object_dict(value: object) -> TypeGuard[dict[str, object]]:
40
+ """Validate a dynamic form bag as a string-keyed mapping."""
41
+ return isinstance(value, dict) and all(isinstance(key, str) for key in value)
42
+
43
+
44
+ def _object_list(value: object) -> TypeGuard[list[object]]:
45
+ """Narrow one field's dynamic error messages to a list."""
46
+ return isinstance(value, list)
47
+
48
+
49
+ def old(ctx: HttpContext, key: str, default: object = "") -> object:
50
+ """Return a prior request's flashed value for one field.
51
+
52
+ Args:
53
+ ctx: Current request context.
54
+ key: Field name whose old input is requested.
55
+ default: Value returned when the field was not flashed.
56
+
57
+ Returns:
58
+ Flashed field value or ``default``.
59
+ """
60
+ session = _session(ctx)
61
+ if session is None:
62
+ return default
63
+ bag: object = session.get("_old", {})
64
+ return bag.get(key, default) if _object_dict(bag) else default
65
+
66
+
67
+ @overload
68
+ def errors(ctx: HttpContext, key: None = None) -> dict[str, object]: ...
69
+
70
+
71
+ @overload
72
+ def errors(ctx: HttpContext, key: str) -> list[object]: ...
73
+
74
+
75
+ def errors(ctx: HttpContext, key: str | None = None) -> dict[str, object] | list[object]:
76
+ """Return the flashed validation bag or messages for one field.
77
+
78
+ Args:
79
+ ctx: Current request context.
80
+ key: Optional field whose validation messages are requested.
81
+
82
+ Returns:
83
+ A copied error bag or a field's message list; malformed state yields empty data.
84
+ """
85
+ session = _session(ctx)
86
+ if session is None:
87
+ return {} if key is None else []
88
+ raw_bag: object = session.get("errors", {})
89
+ if not _object_dict(raw_bag):
90
+ return {} if key is None else []
91
+ if key is None:
92
+ return raw_bag
93
+ raw_messages: object = raw_bag.get(key, [])
94
+ return raw_messages if _object_list(raw_messages) else []
95
+
96
+
97
+ def with_errors(ctx: HttpContext, bag: Mapping[str, object]) -> None:
98
+ """Flash a validation error bag for the next request.
99
+
100
+ Args:
101
+ ctx: Current request context.
102
+ bag: Field names mapped to their validation messages.
103
+ """
104
+ session = _session(ctx)
105
+ if session is not None:
106
+ session.flash("errors", dict(bag))
107
+
108
+
109
+ def flash_old(ctx: HttpContext, data: Mapping[str, object]) -> None:
110
+ """Flash request input for ``old()`` repopulation.
111
+
112
+ Args:
113
+ ctx: Current request context.
114
+ data: Input fields to make available to the next request.
115
+ """
116
+ session = _session(ctx)
117
+ if session is not None:
118
+ session.flash("_old", dict(data))
119
+
120
+
121
+ def back(ctx: HttpContext, *, fallback: str = "/") -> Response:
122
+ """Redirect to the referer, using ``fallback`` when none is present.
123
+
124
+ Args:
125
+ ctx: Current request context.
126
+ fallback: Relative URL used when the request has no referer.
127
+
128
+ Returns:
129
+ Redirect response to the request referer or fallback URL.
130
+ """
131
+ from nyxa.http.response import Response
132
+
133
+ url = ctx.request.header("referer") or fallback
134
+ return Response.redirect(url)
nyxa_forms/kind.py ADDED
@@ -0,0 +1,58 @@
1
+ """``FormRequest`` contract kind — route injection via contract_kinds()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from nyxa_forms.request import FormRequest
8
+
9
+ if TYPE_CHECKING:
10
+ from nyxa.contracts import Decoder, Encoder
11
+ from nyxa.http.request import Request
12
+ from nyxa.types import JSONValue
13
+
14
+
15
+ class FormRequestKind:
16
+ """Contract kind resolving ``FormRequest`` subclasses on routes."""
17
+
18
+ name = "form_request"
19
+
20
+ def matches(self, model: object) -> bool:
21
+ """Whether ``model`` is a ``FormRequest`` subclass."""
22
+ return isinstance(model, type) and issubclass(model, FormRequest)
23
+
24
+ def request_decoder(self, model: type) -> Decoder:
25
+ """Build a decoder returning a validated ``FormRequest``."""
26
+ assert issubclass(model, FormRequest)
27
+
28
+ async def decode(request: Request) -> FormRequest:
29
+ """Validate the request payload into a FormRequest.
30
+
31
+ Awaitable by design — ``serve._dispatch`` awaits decoders that
32
+ return coroutines, which is how db-aware rules work.
33
+ """
34
+ from nyxa.http.context import current_ctx
35
+
36
+ ctx = current_ctx()
37
+ payload = request._payload()
38
+ data = dict(payload) if isinstance(payload, dict) else {}
39
+ instance = model()
40
+ await instance.validate_against(ctx, data)
41
+ return instance
42
+
43
+ return decode
44
+
45
+ def encoder(self, model: type) -> Encoder:
46
+ """Encode (unused — FormRequests are input contracts)."""
47
+
48
+ def encode(value: object) -> bytes:
49
+ """Serialize a value — FormRequests are input-only contracts."""
50
+ import orjson
51
+
52
+ return orjson.dumps(value)
53
+
54
+ return encode
55
+
56
+ def schema(self, model: type) -> dict[str, JSONValue]:
57
+ """No OpenAPI schema for rule-string contracts."""
58
+ return {}
nyxa_forms/provider.py ADDED
@@ -0,0 +1,109 @@
1
+ """FormsProvider — FormRequest contract kind, ``ctx.validate_web``, helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Coroutine, Iterable, Mapping
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, override
8
+
9
+ from nyxa.providers import ServiceProvider
10
+
11
+ if TYPE_CHECKING:
12
+ from nyxa.contracts import ContractKind
13
+ from nyxa.http.context import HttpContext
14
+
15
+ _STUBS = Path(__file__).parent / "stubs"
16
+
17
+
18
+ class FormsProvider(ServiceProvider):
19
+ """Registers the forms surface: validator macros + FormRequest kind."""
20
+
21
+ name = "forms"
22
+
23
+ @override
24
+ def contract_kinds(self) -> Iterable[ContractKind]:
25
+ """The ``FormRequest`` model family for route injection."""
26
+ from nyxa_forms.kind import FormRequestKind
27
+
28
+ return [FormRequestKind()]
29
+
30
+ @override
31
+ def boot(self) -> None:
32
+ """Wrap the exception handler — HTML validation → redirect+flash."""
33
+ from nyxa_forms.handler import FormsExceptionHandler
34
+
35
+ self.app.use_exception_handler(FormsExceptionHandler(self.app.exception_handler))
36
+
37
+ @override
38
+ def macros(self) -> Mapping[type, Mapping[str, object]]:
39
+ """``ctx.validate_web``, ``ctx.old``, ``ctx.errors``, ``ctx.back``."""
40
+ from nyxa.http.context import HttpContext
41
+ from nyxa_forms.helpers import back, errors, old
42
+
43
+ def _validate_web(
44
+ ctx: HttpContext,
45
+ ) -> Callable[[dict[str, str]], Coroutine[object, object, dict[str, object]]]:
46
+ """``ctx.validate_web`` — the request validator factory."""
47
+
48
+ async def validate(rules: dict[str, str]) -> dict[str, object]:
49
+ """Validate request input against ``rules`` — raises on fail."""
50
+ from nyxa_forms.validator import Validator
51
+
52
+ payload = ctx.request._payload()
53
+ data = dict(payload) if isinstance(payload, dict) else {}
54
+ v = Validator(data, rules, app=ctx.app, ctx=ctx)
55
+ return await v.validate()
56
+
57
+ return validate
58
+
59
+ def _old(ctx: HttpContext) -> Callable[[str], object]:
60
+ """``ctx.old`` — flashed-input getter factory."""
61
+
62
+ def get(key: str, default: object = "") -> object:
63
+ """``ctx.old("email")`` — flashed input or ``default``."""
64
+ return old(ctx, key, default)
65
+
66
+ return get
67
+
68
+ def _errors(ctx: HttpContext) -> Callable[[], object]:
69
+ """``ctx.errors`` — flashed error-bag getter factory."""
70
+
71
+ def get(key: str | None = None) -> object:
72
+ """``ctx.errors()`` bag or ``ctx.errors("email")`` messages."""
73
+ return errors(ctx, key)
74
+
75
+ return get
76
+
77
+ def _back(ctx: HttpContext) -> Callable[[], object]:
78
+ """``ctx.back`` — redirect-to-referer factory."""
79
+
80
+ def go(*, fallback: str = "/") -> object:
81
+ """``ctx.back()`` — redirect to the referer."""
82
+ return back(ctx, fallback=fallback)
83
+
84
+ return go
85
+
86
+ return {
87
+ HttpContext: {
88
+ "validate_web": property(_validate_web),
89
+ "old": property(_old),
90
+ "errors": property(_errors),
91
+ "back": property(_back),
92
+ }
93
+ }
94
+
95
+ @override
96
+ def commands(self) -> Iterable[type]:
97
+ """``make:request``."""
98
+ from nyxa_forms.commands import MakeRequestCommand
99
+
100
+ return [MakeRequestCommand]
101
+
102
+ @override
103
+ def configure(self) -> None:
104
+ """``nyxa add nyxa-forms`` — write ``config/forms.py`` + a request stub."""
105
+ target = self.app.root / "config" / "forms.py"
106
+ if not target.exists():
107
+ target.parent.mkdir(parents=True, exist_ok=True)
108
+ target.write_text((_STUBS / "config_forms.stub").read_text())
109
+ print(f"nyxa-forms configured — config at {target}")
nyxa_forms/request.py ADDED
@@ -0,0 +1,135 @@
1
+ """``FormRequest`` — authorize + rules + validated() for web forms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Awaitable, Mapping
7
+ from typing import TYPE_CHECKING
8
+
9
+ from nyxa_forms.validator import RuleSpec, Validator
10
+
11
+ if TYPE_CHECKING:
12
+ from nyxa.http.context import HttpContext
13
+
14
+
15
+ class FormRequest:
16
+ """Base class for injectable, self-validating web requests.
17
+
18
+ ::
19
+
20
+ class StorePost(FormRequest):
21
+ def rules(self) -> dict[str, str]:
22
+ return {"title": "required|max:255", "body": "required"}
23
+
24
+ def authorize(self, ctx) -> bool:
25
+ return ctx.user is not None
26
+
27
+ @app.router.post("/posts")
28
+ def store(ctx, request: StorePost):
29
+ Post.create(request.validated())
30
+
31
+ A handler param typed to a ``FormRequest`` subclass is injected by
32
+ the contract kind registered by ``FormsProvider``.
33
+
34
+ Pipeline: ``prepare_for_validation`` → ``authorize`` → build
35
+ validator (``with_validator`` hook) → rules + ``sometimes`` →
36
+ ``after`` hook → ``validated``.
37
+ """
38
+
39
+ _validated: dict[str, object]
40
+ _errors: dict[str, list[str]]
41
+ _data: dict[str, object]
42
+ ctx: HttpContext | None = None
43
+
44
+ #: halt all rule evaluation at the first failure when True
45
+ stop_on_first_failure = False
46
+
47
+ def authorize(self, ctx: HttpContext | None) -> bool:
48
+ """Whether the request is allowed — default: always."""
49
+ return True
50
+
51
+ def rules(self) -> Mapping[str, RuleSpec]:
52
+ """Validation rules for this request."""
53
+ return {}
54
+
55
+ def messages(self) -> Mapping[str, str]:
56
+ """Custom ``field.rule`` messages."""
57
+ return {}
58
+
59
+ def attributes(self) -> Mapping[str, str]:
60
+ """Field name → friendly name used in generated messages."""
61
+ return {}
62
+
63
+ def prepare_for_validation(
64
+ self, data: dict[str, object]
65
+ ) -> dict[str, object] | Awaitable[dict[str, object] | None] | None:
66
+ """Mutate/replace input before rules run (sync or async).
67
+
68
+ Return the new payload, or ``None`` to keep in-place edits —
69
+ for trimming, merging route params, or normalizing input.
70
+ """
71
+ return None
72
+
73
+ def with_validator(self, validator: Validator) -> Awaitable[None] | None:
74
+ """Configure the validator before rules run (e.g. ``sometimes()``)."""
75
+ return None
76
+
77
+ def after(self, validator: Validator) -> Awaitable[None] | None:
78
+ """Post-validation hook — ``validator.add_error`` fails the request."""
79
+ return None
80
+
81
+ def validated(self, *keys: str) -> dict[str, object]:
82
+ """Validated input — optionally filtered to top-level ``keys``."""
83
+ if keys:
84
+ return {k: self._validated[k] for k in keys if k in self._validated}
85
+ return dict(self._validated)
86
+
87
+ def errors(self) -> dict[str, list[str]]:
88
+ """The error bag after a failed validation (empty on success)."""
89
+ return self._errors
90
+
91
+ def input(self, key: str | None = None, default: object = None) -> object:
92
+ """Raw (prepared) request input — all, or one key."""
93
+ return self._data if key is None else self._data.get(key, default)
94
+
95
+ async def _maybe_await[T](self, result: T | Awaitable[T]) -> T:
96
+ """Await ``result`` when it is awaitable, else return it."""
97
+ if inspect.isawaitable(result):
98
+ return await result
99
+ return result
100
+
101
+ async def validate_against(self, ctx: HttpContext | None, data: dict[str, object]) -> None:
102
+ """Run the prepare → authorize → rules → after pipeline; raises."""
103
+ from nyxa.http.exceptions import ForbiddenException
104
+ from nyxa_forms.validator import ValidationException
105
+
106
+ self.ctx = ctx
107
+ prepared = await self._maybe_await(self.prepare_for_validation(data))
108
+ if prepared is not None:
109
+ data = prepared
110
+ self._data = data
111
+ if not self.authorize(ctx):
112
+ raise ForbiddenException("This action is unauthorized")
113
+ app = ctx.app if ctx is not None else None
114
+ telemetry = app.telemetry if app is not None else None
115
+ v = Validator(
116
+ data,
117
+ self.rules(),
118
+ app=app,
119
+ messages=self.messages(),
120
+ attributes=self.attributes(),
121
+ ctx=ctx,
122
+ stop_on_first_failure=self.stop_on_first_failure,
123
+ )
124
+ await self._maybe_await(self.with_validator(v))
125
+ await v.fails()
126
+ await self._maybe_await(self.after(v))
127
+ if v.errors():
128
+ self._errors = v.errors()
129
+ if telemetry is not None:
130
+ telemetry.emit("form.failed", request=type(self).__name__, fields=len(self._errors))
131
+ raise ValidationException(self._errors, data)
132
+ self._validated = v.validated()
133
+ self._errors = {}
134
+ if telemetry is not None:
135
+ telemetry.emit("form.validated", request=type(self).__name__)
@@ -0,0 +1,3 @@
1
+ """Forms configuration — validation behavior for web requests."""
2
+
3
+ config = {}