formkrafter 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Streamline Pulse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.5
2
+ Name: formkrafter
3
+ Version: 0.1.0
4
+ Summary: FormKrafter for Python backends: validate form submissions exactly like @streamline-pulse/formkrafter-core does in the browser
5
+ Project-URL: Homepage, https://github.com/streamline-pulse/formkrafter/tree/main/python#readme
6
+ Project-URL: Repository, https://github.com/streamline-pulse/formkrafter
7
+ Project-URL: Issues, https://github.com/streamline-pulse/formkrafter/issues
8
+ Author: Streamline Pulse
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: form-builder,formkrafter,forms,json-schema,validation
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.12
17
+ Provides-Extra: pydantic
18
+ Requires-Dist: pydantic>=2.6; extra == 'pydantic'
19
+ Provides-Extra: regex
20
+ Requires-Dist: regex>=2024.4; extra == 'regex'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # formkrafter (Python)
24
+
25
+ FormKrafter for Python backends. The server validates a submission with the same spec the browser renders, and
26
+ reaches the same verdict: same error keys (`items[0].hs_code`), same messages, same warnings as
27
+ [`@streamline-pulse/formkrafter-core`](../packages/core).
28
+
29
+ ```bash
30
+ pip install formkrafter # no dependency
31
+ pip install "formkrafter[pydantic]" # Pydantic models for specs built in code
32
+ ```
33
+
34
+ ```python
35
+ from formkrafter import validate_form_data
36
+
37
+ result = validate_form_data(spec, payload, locale="fr")
38
+ if not result.valid:
39
+ # {"items[0].declared_value": "Minimum 0", "origin": "Ce champ est obligatoire"}
40
+ return 422, result.errors
41
+ ```
42
+
43
+ ## What it does
44
+
45
+ | Function | Core equivalent |
46
+ |---|---|
47
+ | `build_validation_schema(spec, locale)` | `buildValidationSchema`: the same Ajv schema, key for key |
48
+ | `validate_form_data(spec, data, locale)` | `validateFormData`: empty strings and nulls count as missing, collections are validated row by row |
49
+ | `lint_spec(spec)` | `lintSpec`: keyless inputs, validations without a dataType, duplicate keys, empty collections |
50
+ | `register_validation_messages(locale, messages)` | `registerValidationMessages` |
51
+ | `default_form_data`, `iterate_bricks`, `iterate_schema_bricks`, `resolve_localized_text` | same names in camelCase |
52
+
53
+ `validate_form_data` returns a `ValidationResult`:
54
+
55
+ - `valid`, `errors` (key → message) and `warnings`, as in the browser; `to_dict()` gives the core's shape;
56
+ - `details` (key → `ErrorDetail(validator, message, value)`), so an API can return a stable code and let the
57
+ client word it: `{"items[0].declared_value": {"validator": "min", "value": 0, "message": "Minimum 0"}}`.
58
+
59
+ Every function takes a plain dict or a `formkrafter.models.BrickSpec`.
60
+
61
+ ### Supported
62
+
63
+ - **Validations:** `required`, `min`/`max`, `minLength`/`maxLength`, `pattern`, `minItems`/`maxItems`, `email`, `url`.
64
+ - **Bricks:** panels, inputs, selects (multiple included), collections (data grids, nested at any depth), outputs.
65
+ On top of those, `date-range` must not end before it starts.
66
+ - **Rules:** rules that only disable, prefill or require a field are accepted. They do not change the verdict,
67
+ in the browser either.
68
+
69
+ The details that usually make a Python port drift are reproduced:
70
+
71
+ - Ajv's `email` and `uri` formats, copied verbatim;
72
+ - the browser's `RegExp`: `$` never matches before a trailing newline, and `\d` is ASCII;
73
+ - `String(1e-7)` is `1e-7`;
74
+ - the core compares lengths in code points;
75
+ - a schema Ajv refuses to compile enforces no rule (with a warning).
76
+
77
+ ### Refused
78
+
79
+ `validate_form_data` raises `SpecNotSupported` rather than half-validating a spec that needs:
80
+
81
+ - **JavaScript custom validators** (`validator: "custom"`). They run in the browser's sandbox and are not executed
82
+ server-side: move the check to the server, or to a declarative validation.
83
+ - **Rules that hide data-carrying fields.** A hidden field's errors are dropped in the browser. json-logic
84
+ evaluation is planned.
85
+ - **The checks not ported yet:** identity numbers (`national-id`, `tax-id`, `iban`), `vehicle-plate`,
86
+ `company-registry`, `mrz`.
87
+
88
+ `build_validation_schema` and `lint_spec` work on every spec. `assert_supported(spec)` checks up front, for
89
+ instance in the tests that cover the specs a backend serves.
90
+
91
+ ## Parity with the core
92
+
93
+ `tests/parity/cases.json` holds specs and data samples. The core compiles and validates them:
94
+
95
+ ```bash
96
+ bun scripts/python-parity.ts # from the repository root: rewrites tests/parity/expected.json
97
+ uv run pytest # the port must reach the same schemas, lint issues and verdicts
98
+ ```
99
+
100
+ CI regenerates the fixtures and fails when they are stale, so a change in the core shows up in the Python tests.
101
+ `formkrafter.CORE_VERSION` names the core release a version follows.
102
+
103
+ ### In your own project
104
+
105
+ `formkrafter.testing` checks your specs against the real core, through Node and your web app's installed
106
+ `@streamline-pulse/formkrafter-core`:
107
+
108
+ ```python
109
+ from formkrafter.testing import assert_matches_core
110
+
111
+ def test_served_forms_match_the_browser():
112
+ assert_matches_core(SPEC, samples=[VALID, {}, {"items": []}], locales=["fr", "en"], node_project="../web")
113
+ ```
114
+
115
+ `export_contract(spec, locales)` returns the spec with its schema per locale. Write it where your web tests read
116
+ it to assert that `buildValidationSchema(spec, locale)` gives the same.
@@ -0,0 +1,94 @@
1
+ # formkrafter (Python)
2
+
3
+ FormKrafter for Python backends. The server validates a submission with the same spec the browser renders, and
4
+ reaches the same verdict: same error keys (`items[0].hs_code`), same messages, same warnings as
5
+ [`@streamline-pulse/formkrafter-core`](../packages/core).
6
+
7
+ ```bash
8
+ pip install formkrafter # no dependency
9
+ pip install "formkrafter[pydantic]" # Pydantic models for specs built in code
10
+ ```
11
+
12
+ ```python
13
+ from formkrafter import validate_form_data
14
+
15
+ result = validate_form_data(spec, payload, locale="fr")
16
+ if not result.valid:
17
+ # {"items[0].declared_value": "Minimum 0", "origin": "Ce champ est obligatoire"}
18
+ return 422, result.errors
19
+ ```
20
+
21
+ ## What it does
22
+
23
+ | Function | Core equivalent |
24
+ |---|---|
25
+ | `build_validation_schema(spec, locale)` | `buildValidationSchema`: the same Ajv schema, key for key |
26
+ | `validate_form_data(spec, data, locale)` | `validateFormData`: empty strings and nulls count as missing, collections are validated row by row |
27
+ | `lint_spec(spec)` | `lintSpec`: keyless inputs, validations without a dataType, duplicate keys, empty collections |
28
+ | `register_validation_messages(locale, messages)` | `registerValidationMessages` |
29
+ | `default_form_data`, `iterate_bricks`, `iterate_schema_bricks`, `resolve_localized_text` | same names in camelCase |
30
+
31
+ `validate_form_data` returns a `ValidationResult`:
32
+
33
+ - `valid`, `errors` (key → message) and `warnings`, as in the browser; `to_dict()` gives the core's shape;
34
+ - `details` (key → `ErrorDetail(validator, message, value)`), so an API can return a stable code and let the
35
+ client word it: `{"items[0].declared_value": {"validator": "min", "value": 0, "message": "Minimum 0"}}`.
36
+
37
+ Every function takes a plain dict or a `formkrafter.models.BrickSpec`.
38
+
39
+ ### Supported
40
+
41
+ - **Validations:** `required`, `min`/`max`, `minLength`/`maxLength`, `pattern`, `minItems`/`maxItems`, `email`, `url`.
42
+ - **Bricks:** panels, inputs, selects (multiple included), collections (data grids, nested at any depth), outputs.
43
+ On top of those, `date-range` must not end before it starts.
44
+ - **Rules:** rules that only disable, prefill or require a field are accepted. They do not change the verdict,
45
+ in the browser either.
46
+
47
+ The details that usually make a Python port drift are reproduced:
48
+
49
+ - Ajv's `email` and `uri` formats, copied verbatim;
50
+ - the browser's `RegExp`: `$` never matches before a trailing newline, and `\d` is ASCII;
51
+ - `String(1e-7)` is `1e-7`;
52
+ - the core compares lengths in code points;
53
+ - a schema Ajv refuses to compile enforces no rule (with a warning).
54
+
55
+ ### Refused
56
+
57
+ `validate_form_data` raises `SpecNotSupported` rather than half-validating a spec that needs:
58
+
59
+ - **JavaScript custom validators** (`validator: "custom"`). They run in the browser's sandbox and are not executed
60
+ server-side: move the check to the server, or to a declarative validation.
61
+ - **Rules that hide data-carrying fields.** A hidden field's errors are dropped in the browser. json-logic
62
+ evaluation is planned.
63
+ - **The checks not ported yet:** identity numbers (`national-id`, `tax-id`, `iban`), `vehicle-plate`,
64
+ `company-registry`, `mrz`.
65
+
66
+ `build_validation_schema` and `lint_spec` work on every spec. `assert_supported(spec)` checks up front, for
67
+ instance in the tests that cover the specs a backend serves.
68
+
69
+ ## Parity with the core
70
+
71
+ `tests/parity/cases.json` holds specs and data samples. The core compiles and validates them:
72
+
73
+ ```bash
74
+ bun scripts/python-parity.ts # from the repository root: rewrites tests/parity/expected.json
75
+ uv run pytest # the port must reach the same schemas, lint issues and verdicts
76
+ ```
77
+
78
+ CI regenerates the fixtures and fails when they are stale, so a change in the core shows up in the Python tests.
79
+ `formkrafter.CORE_VERSION` names the core release a version follows.
80
+
81
+ ### In your own project
82
+
83
+ `formkrafter.testing` checks your specs against the real core, through Node and your web app's installed
84
+ `@streamline-pulse/formkrafter-core`:
85
+
86
+ ```python
87
+ from formkrafter.testing import assert_matches_core
88
+
89
+ def test_served_forms_match_the_browser():
90
+ assert_matches_core(SPEC, samples=[VALID, {}, {"items": []}], locales=["fr", "en"], node_project="../web")
91
+ ```
92
+
93
+ `export_contract(spec, locales)` returns the spec with its schema per locale. Write it where your web tests read
94
+ it to assert that `buildValidationSchema(spec, locale)` gives the same.
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "formkrafter"
3
+ version = "0.1.0"
4
+ description = "FormKrafter for Python backends: validate form submissions exactly like @streamline-pulse/formkrafter-core does in the browser"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Streamline Pulse" }]
10
+ keywords = ["formkrafter", "forms", "form-builder", "json-schema", "validation"]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Typing :: Typed",
16
+ ]
17
+ dependencies = []
18
+
19
+ [project.optional-dependencies]
20
+ pydantic = ["pydantic>=2.6"]
21
+ # Unicode property classes (\p{L}) in `pattern` validations, as the browser's RegExp `u` flag allows
22
+ regex = ["regex>=2024.4"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/streamline-pulse/formkrafter/tree/main/python#readme"
26
+ Repository = "https://github.com/streamline-pulse/formkrafter"
27
+ Issues = "https://github.com/streamline-pulse/formkrafter/issues"
28
+
29
+ [dependency-groups]
30
+ dev = ["pytest>=8", "pydantic>=2.6", "ruff>=0.6", "mypy>=1.11"]
31
+
32
+ [build-system]
33
+ requires = ["hatchling>=1.26"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/formkrafter"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+
42
+ [tool.ruff]
43
+ line-length = 120
44
+ target-version = "py312"
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I", "UP", "B"]
48
+
49
+ [tool.mypy]
50
+ strict = true
51
+ files = ["src"]
@@ -0,0 +1,39 @@
1
+ """FormKrafter for Python backends: the spec compiled to the browser's validation schema, and submitted
2
+ data validated with it — same error keys, same messages, same verdict as `@streamline-pulse/formkrafter-core`.
3
+ """
4
+
5
+ from formkrafter.lint import SpecIssue, lint_spec, validation_warnings
6
+ from formkrafter.messages import default_validation_message, register_validation_messages
7
+ from formkrafter.spec import (
8
+ BrickSpec,
9
+ build_validation_schema,
10
+ default_form_data,
11
+ iterate_bricks,
12
+ iterate_schema_bricks,
13
+ resolve_localized_text,
14
+ )
15
+ from formkrafter.validate import ErrorDetail, SpecNotSupported, ValidationResult, assert_supported, validate_form_data
16
+
17
+ __version__ = "0.1.0"
18
+ # The core release whose behaviour this version reproduces (python/tests/parity/expected.json).
19
+ CORE_VERSION = "0.33.0"
20
+
21
+ __all__ = [
22
+ "CORE_VERSION",
23
+ "BrickSpec",
24
+ "ErrorDetail",
25
+ "SpecIssue",
26
+ "SpecNotSupported",
27
+ "ValidationResult",
28
+ "assert_supported",
29
+ "build_validation_schema",
30
+ "default_form_data",
31
+ "default_validation_message",
32
+ "iterate_bricks",
33
+ "iterate_schema_bricks",
34
+ "lint_spec",
35
+ "register_validation_messages",
36
+ "resolve_localized_text",
37
+ "validate_form_data",
38
+ "validation_warnings",
39
+ ]
@@ -0,0 +1,150 @@
1
+ """JavaScript semantics the core relies on: `Number()`, `String()` of a number, Ajv's formats and `RegExp`.
2
+
3
+ The core runs in a JavaScript engine and Ajv; the port reproduces the few places where Python would
4
+ otherwise disagree (`String(1e-7)` is "1e-7", `$` never matches before a trailing newline, `\\d` is ASCII).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from decimal import Decimal
11
+ from typing import Any
12
+
13
+ try: # `\p{L}` and other `u`-flag classes, when the optional `regex` module is installed
14
+ import regex as _re # type: ignore[import-not-found, import-untyped, unused-ignore]
15
+ except ImportError: # pragma: no cover - exercised when regex is absent
16
+ import re as _re
17
+
18
+ Number = int | float
19
+
20
+
21
+ def to_number(value: Any) -> Number:
22
+ """`Number(value)`: "12" is 12, "" and null are 0, true is 1, anything unreadable is NaN."""
23
+ if isinstance(value, bool):
24
+ return int(value)
25
+ if value is None:
26
+ return 0
27
+ if isinstance(value, int | float):
28
+ return _tidy(float(value)) if isinstance(value, float) else value
29
+ if isinstance(value, str):
30
+ text = value.strip()
31
+ if not text:
32
+ return 0
33
+ lowered = text.lower()
34
+ for prefix, base in (("0x", 16), ("0o", 8), ("0b", 2)):
35
+ if lowered.startswith(prefix):
36
+ try:
37
+ return int(text[2:], base)
38
+ except ValueError:
39
+ return math.nan
40
+ if text in ("Infinity", "+Infinity"):
41
+ return math.inf
42
+ if text == "-Infinity":
43
+ return -math.inf
44
+ if "_" in text or lowered.lstrip("+-") in ("inf", "infinity", "nan"):
45
+ return math.nan
46
+ try:
47
+ return _tidy(float(text))
48
+ except ValueError:
49
+ return math.nan
50
+ if isinstance(value, list) and len(value) <= 1:
51
+ return to_number(value[0]) if value else 0
52
+ return math.nan
53
+
54
+
55
+ def _tidy(n: float) -> Number:
56
+ """An integral float as an int (JSON writes 3, not 3.0), within the range JavaScript counts exactly."""
57
+ return int(n) if n.is_integer() and abs(n) < 2**53 else n
58
+
59
+
60
+ def number_to_string(n: Number) -> str:
61
+ """`String(n)`: 1 → "1", 0.5 → "0.5", 1e-7 → "1e-7", 1e21 → "1e+21"."""
62
+ if isinstance(n, bool):
63
+ return "true" if n else "false"
64
+ if isinstance(n, int):
65
+ n_float = float(n)
66
+ if abs(n) < 10**21:
67
+ return str(n)
68
+ n = n_float
69
+ if math.isnan(n):
70
+ return "NaN"
71
+ if math.isinf(n):
72
+ return "Infinity" if n > 0 else "-Infinity"
73
+ if n == 0:
74
+ return "0"
75
+ digits = Decimal(repr(n))
76
+ if 1e-6 <= abs(n) < 1e21:
77
+ text = format(digits, "f")
78
+ return text.rstrip("0").rstrip(".") if "." in text else text
79
+ sign, coefficient, exponent = digits.normalize().as_tuple()
80
+ mantissa = "".join(map(str, coefficient))
81
+ exp = exponent + len(mantissa) - 1 # type: ignore[operator]
82
+ body = mantissa[0] + ("." + mantissa[1:] if len(mantissa) > 1 else "")
83
+ return f"{'-' if sign else ''}{body}e{'+' if exp >= 0 else '-'}{abs(exp)}"
84
+
85
+
86
+ def to_string(value: Any) -> str:
87
+ """`String(value ?? "")`, as the default messages interpolate `{value}`."""
88
+ if value is None:
89
+ return ""
90
+ if isinstance(value, bool):
91
+ return "true" if value else "false"
92
+ if isinstance(value, int | float):
93
+ return number_to_string(value)
94
+ if isinstance(value, list):
95
+ return ",".join("" if v is None else to_string(v) for v in value)
96
+ if isinstance(value, dict):
97
+ return "[object Object]"
98
+ return str(value)
99
+
100
+
101
+ def compile_pattern(pattern: str) -> Any:
102
+ """`new RegExp(pattern, "u")` as a Python pattern: `$` anchors the end only, named groups, ASCII `\\d` and `\\w`."""
103
+ out: list[str] = []
104
+ in_class = False
105
+ i = 0
106
+ while i < len(pattern):
107
+ c = pattern[i]
108
+ if c == "\\" and i + 1 < len(pattern):
109
+ escape = pattern[i + 1]
110
+ if in_class:
111
+ out.append(_CLASS_ESCAPES.get(escape, pattern[i : i + 2]))
112
+ else:
113
+ out.append(_ESCAPES.get(escape, pattern[i : i + 2]))
114
+ i += 2
115
+ continue
116
+ if in_class:
117
+ if c == "]":
118
+ in_class = False
119
+ elif c == "[":
120
+ in_class = True
121
+ elif c == "$":
122
+ c = r"\Z"
123
+ elif pattern.startswith("(?<", i) and not pattern.startswith(("(?<=", "(?<!"), i):
124
+ c, i = "(?P<", i + 2
125
+ out.append(c)
126
+ i += 1
127
+ return _re.compile("".join(out))
128
+
129
+
130
+ # Under the `u` flag \d and \w are ASCII, while Python's are Unicode; \s stays Unicode in both.
131
+ _ESCAPES = {"d": "[0-9]", "D": "[^0-9]", "w": "[A-Za-z0-9_]", "W": "[^A-Za-z0-9_]"}
132
+ _CLASS_ESCAPES = {"d": "0-9", "w": "A-Za-z0-9_"}
133
+
134
+
135
+ # ajv-formats 3, "full" mode, verbatim (the core registers them with `addFormats(ajv, ["email", "uri"])`).
136
+ # They carry no `u` flag, so digits and case folding are ASCII.
137
+ _EMAIL = _re.compile(
138
+ r"^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\Z",
139
+ _re.IGNORECASE | _re.ASCII,
140
+ )
141
+ _NOT_URI_FRAGMENT = _re.compile(r"/|:")
142
+ _URI = _re.compile(
143
+ r"^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?\Z",
144
+ _re.IGNORECASE | _re.ASCII,
145
+ )
146
+
147
+ FORMATS = {
148
+ "email": lambda s: bool(_EMAIL.search(s)),
149
+ "uri": lambda s: bool(_NOT_URI_FRAGMENT.search(s)) and bool(_URI.search(s)),
150
+ }
@@ -0,0 +1,99 @@
1
+ """Spec checks — `utils/lint-spec.ts` in the core: what silently breaks validation, on the client and the server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ from formkrafter.spec import configs_of, iterate_bricks, truthy
9
+
10
+ SpecIssueCode = Literal[
11
+ "validations-without-data-type",
12
+ "input-without-key",
13
+ "duplicate-key",
14
+ "collection-without-children",
15
+ ]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class SpecIssue:
20
+ code: SpecIssueCode
21
+ path: str
22
+ message: str
23
+ key: str | None = field(default=None)
24
+
25
+ def to_dict(self) -> dict[str, Any]:
26
+ """The issue as the core returns it (no `key` when the brick has none)."""
27
+ out: dict[str, Any] = {"code": self.code, "path": self.path}
28
+ if self.key is not None:
29
+ out["key"] = self.key
30
+ out["message"] = self.message
31
+ return out
32
+
33
+
34
+ def lint_spec(spec: Any) -> list[SpecIssue]:
35
+ """Issues of a spec: keyless inputs, validations without a dataType, duplicate keys, empty collections."""
36
+ issues: list[SpecIssue] = []
37
+ seen: dict[str, str] = {}
38
+
39
+ for brick, path in iterate_bricks(spec):
40
+ key: Any = configs_of(brick).get("key")
41
+ has_key = truthy(key)
42
+ brick_type = brick.get("type")
43
+ carries_data = brick_type in ("input", "collection")
44
+
45
+ if carries_data:
46
+ if not has_key:
47
+ issues.append(
48
+ SpecIssue(
49
+ "input-without-key",
50
+ path,
51
+ f'{brick_type} brick "{brick.get("id")}" has no configs.key — '
52
+ "it carries no data and is skipped by validation",
53
+ )
54
+ )
55
+ elif not truthy(brick.get("dataType")) or brick.get("dataType") == "void":
56
+ validations = brick.get("validations") or []
57
+ if validations:
58
+ issues.append(
59
+ SpecIssue(
60
+ "validations-without-data-type",
61
+ path,
62
+ f'"{key}" declares {len(validations)} validation(s) but has no dataType — '
63
+ "they are silently skipped, on the client and on the server",
64
+ key,
65
+ )
66
+ )
67
+
68
+ if has_key and carries_data:
69
+ previous = seen.get(key)
70
+ if previous:
71
+ issues.append(
72
+ SpecIssue(
73
+ "duplicate-key",
74
+ path,
75
+ f'key "{key}" is used by two bricks ({previous} and {path}) — '
76
+ "their values collide in the form data",
77
+ key,
78
+ )
79
+ )
80
+ else:
81
+ seen[key] = path
82
+
83
+ if brick_type == "collection" and not brick.get("children"):
84
+ issues.append(
85
+ SpecIssue(
86
+ "collection-without-children",
87
+ path,
88
+ f'collection "{key if key is not None else brick.get("id")}" has no row template — '
89
+ "it can never produce data",
90
+ key,
91
+ )
92
+ )
93
+
94
+ return issues
95
+
96
+
97
+ def validation_warnings(spec: Any) -> list[str]:
98
+ """The lint messages that `validate_form_data` returns as warnings."""
99
+ return [issue.message for issue in lint_spec(spec) if issue.code == "validations-without-data-type"]
@@ -0,0 +1,68 @@
1
+ """Default validation messages, per locale — `validators/default-messages.ts` in the core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from formkrafter._js import to_string
9
+
10
+ _MESSAGES: dict[str, dict[str, str]] = {
11
+ "en": {
12
+ "required": "This field is required",
13
+ "minLength": "Must be at least {value} characters",
14
+ "maxLength": "Must be at most {value} characters",
15
+ "min": "Must be at least {value}",
16
+ "max": "Must be at most {value}",
17
+ "pattern": "Invalid format",
18
+ "minItems": "Select at least {value} item(s)",
19
+ "maxItems": "Select at most {value} item(s)",
20
+ "email": "Invalid email address",
21
+ "url": "Invalid URL",
22
+ "type": "Invalid value",
23
+ "custom": "Invalid value",
24
+ "identity": "Invalid identifier for this format",
25
+ "iban": "Invalid IBAN",
26
+ "dateRange": "The end before the start is not allowed",
27
+ "mrz": "Machine-readable zone unreadable or inconsistent",
28
+ "plate": "Invalid registration plate for this format",
29
+ "registry": "Invalid registration number for this format",
30
+ },
31
+ "fr": {
32
+ "required": "Ce champ est obligatoire",
33
+ "minLength": "Minimum {value} caractères",
34
+ "maxLength": "Maximum {value} caractères",
35
+ "min": "Minimum {value}",
36
+ "max": "Maximum {value}",
37
+ "pattern": "Format invalide",
38
+ "minItems": "Sélectionnez au moins {value} élément(s)",
39
+ "maxItems": "Sélectionnez au plus {value} élément(s)",
40
+ "email": "Adresse email invalide",
41
+ "url": "URL invalide",
42
+ "type": "Valeur invalide",
43
+ "custom": "Valeur invalide",
44
+ "identity": "Identifiant invalide pour ce format",
45
+ "iban": "IBAN invalide",
46
+ "dateRange": "La fin ne peut pas précéder le début",
47
+ "mrz": "Zone de lecture illisible ou incohérente",
48
+ "plate": "Immatriculation invalide pour ce format",
49
+ "registry": "Numéro d’immatriculation invalide pour ce format",
50
+ },
51
+ }
52
+
53
+
54
+ def register_validation_messages(locale: str, messages: Mapping[str, str]) -> None:
55
+ """Add or override the default messages of a locale (`registerValidationMessages`).
56
+
57
+ Register the same messages on the client, or the server and the browser word their errors differently.
58
+ """
59
+ _MESSAGES[locale] = {**_MESSAGES.get(locale, {}), **messages}
60
+
61
+
62
+ def default_validation_message(validator: str, locale: str | None = None, value: Any = None) -> str:
63
+ """The default message of a validator, with `{value}` filled in; English when the locale has none."""
64
+ pack = _MESSAGES.get("en" if locale is None else locale, _MESSAGES["en"])
65
+ template = pack.get(validator)
66
+ if template is None:
67
+ template = _MESSAGES["en"].get(validator, "Invalid value")
68
+ return template.replace("{value}", to_string(value), 1)