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.
- formkrafter-0.1.0/.gitignore +6 -0
- formkrafter-0.1.0/LICENSE +21 -0
- formkrafter-0.1.0/PKG-INFO +116 -0
- formkrafter-0.1.0/README.md +94 -0
- formkrafter-0.1.0/pyproject.toml +51 -0
- formkrafter-0.1.0/src/formkrafter/__init__.py +39 -0
- formkrafter-0.1.0/src/formkrafter/_js.py +150 -0
- formkrafter-0.1.0/src/formkrafter/lint.py +99 -0
- formkrafter-0.1.0/src/formkrafter/messages.py +68 -0
- formkrafter-0.1.0/src/formkrafter/models.py +64 -0
- formkrafter-0.1.0/src/formkrafter/py.typed +0 -0
- formkrafter-0.1.0/src/formkrafter/spec.py +186 -0
- formkrafter-0.1.0/src/formkrafter/testing.py +137 -0
- formkrafter-0.1.0/src/formkrafter/validate.py +285 -0
- formkrafter-0.1.0/tests/parity/cases.json +1434 -0
- formkrafter-0.1.0/tests/parity/expected.json +2080 -0
- formkrafter-0.1.0/tests/test_api.py +168 -0
- formkrafter-0.1.0/tests/test_parity.py +52 -0
- formkrafter-0.1.0/uv.lock +621 -0
|
@@ -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)
|