pydantic-forms 2.6.0__tar.gz → 2.7.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.
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/PKG-INFO +4 -4
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/README.md +2 -2
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/core/asynchronous.py +4 -3
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/core/shared.py +33 -1
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/core/sync.py +10 -4
- pydantic_forms-2.7.0/pydantic_forms/exception_handlers/fastapi.py +84 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/exceptions.py +13 -6
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/types.py +24 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/utils/json.py +1 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/__init__.py +3 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/accept.py +7 -0
- pydantic_forms-2.7.0/pydantic_forms/validators/components/buttons.py +83 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/callout.py +6 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/choice.py +5 -2
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/choice_list.py +3 -2
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/contact_person.py +1 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/contact_person_list.py +5 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/display_subscription.py +4 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/divider.py +1 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/hidden.py +1 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/label.py +1 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/list_of_one.py +1 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/list_of_two.py +1 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/long_text.py +1 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/markdown.py +5 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/migration_summary.py +2 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/organisation_id.py +4 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/timestamp.py +7 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/unique_constrained_list.py +2 -0
- pydantic_forms-2.7.0/pyproject.toml +247 -0
- pydantic_forms-2.6.0/pyproject.toml → pydantic_forms-2.7.0/pyproject.toml.orig +10 -3
- pydantic_forms-2.7.0/tests/paths.py +21 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/exception_handlers/test_fastapi.py +15 -1
- pydantic_forms-2.7.0/tests/unit_tests/test_buttons.py +72 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_core.py +30 -2
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_core_async.py +12 -2
- pydantic_forms-2.7.0/tests/unit_tests/test_documentation.py +203 -0
- pydantic_forms-2.7.0/tests/unit_tests/test_form_meta.py +37 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_read_only_field.py +9 -0
- pydantic_forms-2.6.0/pydantic_forms/exception_handlers/fastapi.py +0 -73
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/LICENSE +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/__init__.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/core/__init__.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/core/translations.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/exception_handlers/__init__.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/py.typed +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/utils/__init__.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/utils/schema.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/__init__.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/read_only.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/constants.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/helpers.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/__init__.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/__init__.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/exception_handlers/__init__.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/helpers.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_accept.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_callout.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_choice.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_choice_list.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_constrained_list.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_contact_person.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_contact_person_list.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_default_factory.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_display_subscription.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_divider.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_hidden.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_label.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_list_of_one.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_list_of_two.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_long_text.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_markdown.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_migration_summary.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_organisation_id.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_shared.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_timestamp.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_utils_dict_without_none.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_utils_from_serializable.py +0 -0
- {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_utils_to_serializable.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pydantic-forms
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.0
|
|
4
4
|
Summary: Pydantic-forms engine.
|
|
5
5
|
Author: SURF
|
|
6
6
|
Author-email: SURF <automation-beheer@surf.nl>
|
|
@@ -39,7 +39,7 @@ Requires-Dist: pydantic-i18n==0.4.5
|
|
|
39
39
|
Requires-Dist: fastapi>=0.103.2 ; extra == 'fastapi'
|
|
40
40
|
Requires-Dist: orjson>=3.10.7,<4.0.0 ; extra == 'orjson'
|
|
41
41
|
Requires-Python: >=3.10, <3.15
|
|
42
|
-
Project-URL: Documentation, https://
|
|
42
|
+
Project-URL: Documentation, https://workfloworchestrator.org/pydantic-forms/
|
|
43
43
|
Project-URL: Homepage, https://github.com/workfloworchestrator/pydantic-forms
|
|
44
44
|
Project-URL: Source, https://github.com/workfloworchestrator/pydantic-forms
|
|
45
45
|
Provides-Extra: fastapi
|
|
@@ -61,8 +61,8 @@ Forms can also consist out of a wizard, so you can create complex form flows con
|
|
|
61
61
|
consecutive forms. The forms and the validation logic are defined by
|
|
62
62
|
using [Pydantic](https://pydantic-docs.helpmanual.io/) models.
|
|
63
63
|
|
|
64
|
-
Documentation regarding the usage of Forms can be found
|
|
65
|
-
[
|
|
64
|
+
Documentation regarding the usage of Forms can be found at
|
|
65
|
+
[workfloworchestrator.org/pydantic-forms](https://workfloworchestrator.org/pydantic-forms/).
|
|
66
66
|
|
|
67
67
|
### Installation (Development standalone)
|
|
68
68
|
|
|
@@ -13,8 +13,8 @@ Forms can also consist out of a wizard, so you can create complex form flows con
|
|
|
13
13
|
consecutive forms. The forms and the validation logic are defined by
|
|
14
14
|
using [Pydantic](https://pydantic-docs.helpmanual.io/) models.
|
|
15
15
|
|
|
16
|
-
Documentation regarding the usage of Forms can be found
|
|
17
|
-
[
|
|
16
|
+
Documentation regarding the usage of Forms can be found at
|
|
17
|
+
[workfloworchestrator.org/pydantic-forms](https://workfloworchestrator.org/pydantic-forms/).
|
|
18
18
|
|
|
19
19
|
### Installation (Development standalone)
|
|
20
20
|
|
|
@@ -18,11 +18,12 @@ import structlog
|
|
|
18
18
|
from pydantic import ValidationError
|
|
19
19
|
from pydantic_i18n import PydanticI18n
|
|
20
20
|
|
|
21
|
-
from pydantic_forms.core.shared import FORMS, GenerateFormJsonSchema
|
|
21
|
+
from pydantic_forms.core.shared import FORMS, GenerateFormJsonSchema, get_form_meta
|
|
22
22
|
from pydantic_forms.core.translations import translations
|
|
23
23
|
from pydantic_forms.exceptions import (
|
|
24
24
|
FormException,
|
|
25
25
|
FormNotCompleteError,
|
|
26
|
+
FormNotFoundError,
|
|
26
27
|
FormOverflowError,
|
|
27
28
|
FormValidationError,
|
|
28
29
|
)
|
|
@@ -104,13 +105,13 @@ async def post_form(
|
|
|
104
105
|
# Form is not completely filled raise next form
|
|
105
106
|
raise FormNotCompleteError(
|
|
106
107
|
generated_form.model_json_schema(schema_generator=GenerateFormJsonSchema),
|
|
107
|
-
meta=
|
|
108
|
+
meta=get_form_meta(generated_form),
|
|
108
109
|
)
|
|
109
110
|
|
|
110
111
|
|
|
111
112
|
def _get_form(key: str) -> StateInputFormGeneratorAsync:
|
|
112
113
|
if not (func := FORMS.get(key)):
|
|
113
|
-
raise
|
|
114
|
+
raise FormNotFoundError(f"Form {key} does not exist.")
|
|
114
115
|
|
|
115
116
|
if not isasyncgenfunction(func):
|
|
116
117
|
raise FormException(f"Form {key} is not an asyncgenerator function")
|
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
# See the License for the specific language governing permissions and
|
|
12
12
|
# limitations under the License.
|
|
13
13
|
from inspect import isasyncgenfunction, isgeneratorfunction
|
|
14
|
-
from typing import Any, Callable, cast
|
|
14
|
+
from typing import Any, Callable, ClassVar, cast
|
|
15
15
|
|
|
16
16
|
import structlog
|
|
17
17
|
from pydantic import BaseModel, ConfigDict, PydanticUndefinedAnnotation, version
|
|
18
18
|
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
|
|
19
19
|
from pydantic_core import core_schema
|
|
20
20
|
|
|
21
|
+
from pydantic_forms.types import FormMeta
|
|
22
|
+
from pydantic_forms.validators.components.buttons import merge_buttons_into_meta
|
|
23
|
+
|
|
21
24
|
logger = structlog.get_logger(__name__)
|
|
22
25
|
|
|
23
26
|
|
|
@@ -65,6 +68,18 @@ class FormPage(BaseModel):
|
|
|
65
68
|
validate_default=True,
|
|
66
69
|
)
|
|
67
70
|
|
|
71
|
+
meta__: ClassVar[FormMeta | None] = None
|
|
72
|
+
"""Data about the page itself, passed to the frontend alongside its JSON schema.
|
|
73
|
+
|
|
74
|
+
Set it on a subclass to tell the frontend something the schema cannot express, such as whether
|
|
75
|
+
another page follows (`hasNext`). It travels out as the `meta` key of the `FormNotCompleteError`
|
|
76
|
+
response, see `form_meta()`. Being a `ClassVar` it is not a form field, so it stays out of the
|
|
77
|
+
schema and the validated result.
|
|
78
|
+
|
|
79
|
+
Relabel or restyle the previous/next buttons with a `ButtonsConfig` field, or else with
|
|
80
|
+
`customButtons` here. The two can't be combined.
|
|
81
|
+
"""
|
|
82
|
+
|
|
68
83
|
def __init__(self, **data: Any):
|
|
69
84
|
frozen_fields = {k: v for k, v in self.__class__.model_fields.items() if v.frozen}
|
|
70
85
|
|
|
@@ -76,6 +91,11 @@ class FormPage(BaseModel):
|
|
|
76
91
|
mutable_data = {k: get_value(k, v) for k, v in data.items()}
|
|
77
92
|
super().__init__(**mutable_data)
|
|
78
93
|
|
|
94
|
+
@classmethod
|
|
95
|
+
def form_meta(cls) -> FormMeta | None:
|
|
96
|
+
"""Return `meta__` with the page's `ButtonsConfig` field added."""
|
|
97
|
+
return merge_buttons_into_meta(cls.__name__, cls.meta__, cls.model_fields)
|
|
98
|
+
|
|
79
99
|
if PYDANTIC_VERSION in ("2.9", "2.10", "2.11"):
|
|
80
100
|
|
|
81
101
|
@classmethod
|
|
@@ -83,6 +103,7 @@ class FormPage(BaseModel):
|
|
|
83
103
|
# The default and requiredness of a field is not a property of a field
|
|
84
104
|
# In the case of DisplayOnlyFieldTypes, we do kind of want that.
|
|
85
105
|
# Using this method we set the right properties after the form is created
|
|
106
|
+
cls.form_meta() # run now so a conflicting declaration fails on import, not when the page is first sent
|
|
86
107
|
needs_rebuild = False
|
|
87
108
|
|
|
88
109
|
for field in cls.model_fields.values():
|
|
@@ -115,6 +136,7 @@ class FormPage(BaseModel):
|
|
|
115
136
|
# The default and requiredness of a field is not a property of a field
|
|
116
137
|
# In the case of DisplayOnlyFieldTypes, we do kind of want that.
|
|
117
138
|
# Using this method we set the right properties after the form is created
|
|
139
|
+
cls.form_meta()
|
|
118
140
|
needs_rebuild = False
|
|
119
141
|
|
|
120
142
|
for field in cls.model_fields.values():
|
|
@@ -142,3 +164,13 @@ def register_form(key: str, form: Callable) -> None:
|
|
|
142
164
|
|
|
143
165
|
def list_forms() -> list[str]:
|
|
144
166
|
return list(FORMS.keys())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_form_meta(form: type[BaseModel]) -> Any:
|
|
170
|
+
"""Return the meta to send to the frontend with `form`.
|
|
171
|
+
|
|
172
|
+
A generator may yield any pydantic model, not only a `FormPage`; such a model can still carry a `meta__`.
|
|
173
|
+
"""
|
|
174
|
+
if issubclass(form, FormPage):
|
|
175
|
+
return form.form_meta()
|
|
176
|
+
return getattr(form, "meta__", None)
|
|
@@ -18,9 +18,15 @@ import structlog
|
|
|
18
18
|
from pydantic import ValidationError
|
|
19
19
|
from pydantic_i18n import PydanticI18n
|
|
20
20
|
|
|
21
|
-
from pydantic_forms.core.shared import FORMS, GenerateFormJsonSchema
|
|
21
|
+
from pydantic_forms.core.shared import FORMS, GenerateFormJsonSchema, get_form_meta
|
|
22
22
|
from pydantic_forms.core.translations import translations
|
|
23
|
-
from pydantic_forms.exceptions import
|
|
23
|
+
from pydantic_forms.exceptions import (
|
|
24
|
+
FormException,
|
|
25
|
+
FormNotCompleteError,
|
|
26
|
+
FormNotFoundError,
|
|
27
|
+
FormOverflowError,
|
|
28
|
+
FormValidationError,
|
|
29
|
+
)
|
|
24
30
|
from pydantic_forms.types import InputForm, State, StateInputFormGenerator
|
|
25
31
|
|
|
26
32
|
logger = structlog.get_logger(__name__)
|
|
@@ -90,7 +96,7 @@ def post_form(
|
|
|
90
96
|
# Form is not completely filled; raise next form
|
|
91
97
|
raise FormNotCompleteError(
|
|
92
98
|
generated_form.model_json_schema(schema_generator=GenerateFormJsonSchema),
|
|
93
|
-
meta=
|
|
99
|
+
meta=get_form_meta(generated_form),
|
|
94
100
|
)
|
|
95
101
|
except StopIteration as e:
|
|
96
102
|
if user_inputs:
|
|
@@ -102,7 +108,7 @@ def post_form(
|
|
|
102
108
|
|
|
103
109
|
def _get_form(key: str) -> StateInputFormGenerator:
|
|
104
110
|
if not (func := FORMS.get(key)):
|
|
105
|
-
raise
|
|
111
|
+
raise FormNotFoundError(f"Form {key} does not exist.")
|
|
106
112
|
|
|
107
113
|
if not isgeneratorfunction(func):
|
|
108
114
|
raise FormException(f"Form {key} is not a generator function")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Copyright 2019-2023 SURF.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
# TODO Decide how to expose this so pydantic-forms can be framework agnostic
|
|
16
|
+
from http import HTTPStatus
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import structlog
|
|
20
|
+
from fastapi.requests import Request
|
|
21
|
+
from fastapi.responses import JSONResponse
|
|
22
|
+
|
|
23
|
+
from pydantic_forms.exceptions import (
|
|
24
|
+
FormException,
|
|
25
|
+
FormNotCompleteError,
|
|
26
|
+
FormNotFoundError,
|
|
27
|
+
FormValidationError,
|
|
28
|
+
show_ex,
|
|
29
|
+
)
|
|
30
|
+
from pydantic_forms.utils.json import json_dumps, json_loads
|
|
31
|
+
|
|
32
|
+
logger = structlog.get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def form_error_handler(request: Request, exc: FormException) -> JSONResponse:
|
|
36
|
+
"""FastAPI exception handler that turns a FormException into a HTTP 4xx/5xx response with JSON body."""
|
|
37
|
+
match exc:
|
|
38
|
+
case FormValidationError():
|
|
39
|
+
status = HTTPStatus.BAD_REQUEST
|
|
40
|
+
base_content = _create_content(exc, status, "Form not valid")
|
|
41
|
+
detail_content = base_content | {
|
|
42
|
+
"validation_errors": json_loads(json_dumps(exc.errors)),
|
|
43
|
+
}
|
|
44
|
+
debug_content = _add_traceback(exc, detail_content)
|
|
45
|
+
return JSONResponse(debug_content, status_code=status)
|
|
46
|
+
|
|
47
|
+
case FormNotCompleteError():
|
|
48
|
+
status = HTTPStatus.NOT_EXTENDED
|
|
49
|
+
base_content = _create_content(exc, status, "Form not complete")
|
|
50
|
+
detail_content = base_content | {
|
|
51
|
+
"form": json_loads(json_dumps(exc.form)),
|
|
52
|
+
"meta": getattr(exc, "meta", None),
|
|
53
|
+
}
|
|
54
|
+
debug_content = _add_traceback(exc, detail_content)
|
|
55
|
+
return JSONResponse(debug_content, status_code=status)
|
|
56
|
+
|
|
57
|
+
case FormNotFoundError():
|
|
58
|
+
status = HTTPStatus.NOT_FOUND
|
|
59
|
+
base_content = _create_content(exc, status, "Form not found")
|
|
60
|
+
return JSONResponse(base_content, status_code=status)
|
|
61
|
+
|
|
62
|
+
case _:
|
|
63
|
+
status = HTTPStatus.INTERNAL_SERVER_ERROR
|
|
64
|
+
base_content = _create_content(exc, status, "Internal Server Error")
|
|
65
|
+
return JSONResponse(base_content, status_code=status)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _create_content(exc: FormException, status: HTTPStatus, title: str) -> dict[str, str | HTTPStatus]:
|
|
69
|
+
return {
|
|
70
|
+
"type": type(exc).__name__,
|
|
71
|
+
"detail": str(exc),
|
|
72
|
+
"title": title,
|
|
73
|
+
"status": status,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _add_traceback(exc: FormException, content: dict[str, Any]) -> dict[str, Any]:
|
|
78
|
+
LOG_LEVEL_PYDANTIC_FORMS = "DEBUG" if os.getenv("LOG_LEVEL_PYDANTIC_FORMS", "INFO").upper() == "DEBUG" else "INFO"
|
|
79
|
+
if LOG_LEVEL_PYDANTIC_FORMS == "DEBUG":
|
|
80
|
+
content_with_traceback = content | {"traceback": show_ex(exc)}
|
|
81
|
+
logger.debug("Form validation Response", result=content_with_traceback)
|
|
82
|
+
return content_with_traceback
|
|
83
|
+
|
|
84
|
+
return content
|
|
@@ -37,6 +37,10 @@ class FormOverflowError(FormException):
|
|
|
37
37
|
"""Raised when more inputs are provided than the form can process."""
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
class FormNotFoundError(FormException):
|
|
41
|
+
"""Raised when the requested form key is not registered."""
|
|
42
|
+
|
|
43
|
+
|
|
40
44
|
Loc = tuple[Union[int, str], ...]
|
|
41
45
|
|
|
42
46
|
|
|
@@ -138,12 +142,15 @@ def _display_error_type_and_ctx(error: "ErrorDict") -> str:
|
|
|
138
142
|
def show_ex(ex: Exception, stacklimit: Union[int, None] = None) -> str:
|
|
139
143
|
"""Show an exception, including its class name, message and (limited) stacktrace.
|
|
140
144
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
Examples:
|
|
146
|
+
--------
|
|
147
|
+
>>> try:
|
|
148
|
+
... raise Exception("Something went wrong")
|
|
149
|
+
... except Exception as e:
|
|
150
|
+
... print(show_ex(e))
|
|
151
|
+
Exception: Something went wrong
|
|
152
|
+
...
|
|
153
|
+
|
|
147
154
|
"""
|
|
148
155
|
tbfmt = "".join(traceback.format_tb(ex.__traceback__, stacklimit))
|
|
149
156
|
return "{}: {}\n{}".format(type(ex).__name__, ex, tbfmt)
|
|
@@ -41,6 +41,30 @@ class SummaryData(TypedDict, total=False):
|
|
|
41
41
|
columns: list[list[Union[str, int, bool, float]]]
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
class ButtonConfig(TypedDict, total=False):
|
|
45
|
+
text: str
|
|
46
|
+
color: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CustomButtons(TypedDict, total=False):
|
|
50
|
+
previous: ButtonConfig
|
|
51
|
+
next: ButtonConfig
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FormMeta(TypedDict, total=False):
|
|
55
|
+
"""Documented keys for ``FormPage.meta__``, not enforced at runtime; extra keys are allowed.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
hasNext: Whether another page follows, so the frontend can label the submit button
|
|
59
|
+
accordingly.
|
|
60
|
+
customButtons: Per-button overrides (label, color) for "previous" and "next". Prefer a `ButtonsConfig`
|
|
61
|
+
field on the page, which sets this key; the two cannot be combined.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
hasNext: bool
|
|
65
|
+
customButtons: CustomButtons
|
|
66
|
+
|
|
67
|
+
|
|
44
68
|
InputForm = Type[BaseModel]
|
|
45
69
|
AcceptData = list[Union[tuple[str, AcceptItemType], tuple[str, AcceptItemType, dict]]]
|
|
46
70
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from pydantic_forms.validators.components.accept import Accept, AcceptValues
|
|
2
|
+
from pydantic_forms.validators.components.buttons import Button, ButtonsConfig
|
|
2
3
|
from pydantic_forms.validators.components.callout import Callout, callout
|
|
3
4
|
from pydantic_forms.validators.components.choice import Choice
|
|
4
5
|
from pydantic_forms.validators.components.choice_list import choice_list
|
|
@@ -21,6 +22,8 @@ from pydantic_forms.validators.components.unique_constrained_list import unique_
|
|
|
21
22
|
__all__ = (
|
|
22
23
|
"Accept",
|
|
23
24
|
"AcceptValues",
|
|
25
|
+
"Button",
|
|
26
|
+
"ButtonsConfig",
|
|
24
27
|
"Choice",
|
|
25
28
|
"choice_list",
|
|
26
29
|
"contact_person_list",
|
{pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/accept.py
RENAMED
|
@@ -19,11 +19,18 @@ from pydantic_forms.types import AcceptData, strEnum
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class AcceptValues(strEnum):
|
|
22
|
+
|
|
22
23
|
ACCEPTED = "ACCEPTED"
|
|
23
24
|
INCOMPLETE = "INCOMPLETE"
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
class Accept(str):
|
|
28
|
+
"""A checklist the user works through before the form can be submitted.
|
|
29
|
+
|
|
30
|
+
Validation fails while the value is still `"INCOMPLETE"`. Subclass it and set `data` to
|
|
31
|
+
describe the items to display.
|
|
32
|
+
"""
|
|
33
|
+
|
|
27
34
|
data: ClassVar[Optional[AcceptData]] = None
|
|
28
35
|
|
|
29
36
|
_enum_adapter = TypeAdapter(AcceptValues)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Copyright 2019-2026 SURF.
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from typing import Any, Optional, cast
|
|
15
|
+
|
|
16
|
+
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
|
|
17
|
+
from pydantic.fields import FieldInfo
|
|
18
|
+
from pydantic.json_schema import JsonSchemaValue
|
|
19
|
+
from pydantic_core import PydanticOmit, core_schema
|
|
20
|
+
|
|
21
|
+
from pydantic_forms.types import ButtonConfig, CustomButtons, FormMeta
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Button:
|
|
26
|
+
"""Label and/or color for one button. Left as `None`, the frontend keeps its default."""
|
|
27
|
+
|
|
28
|
+
text: Optional[str] = None
|
|
29
|
+
color: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
def to_meta(self) -> ButtonConfig:
|
|
32
|
+
return cast(ButtonConfig, {key: value for key, value in asdict(self).items() if value is not None})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ButtonsConfig:
|
|
37
|
+
"""Overrides for the previous/next buttons, set as the default of a form field.
|
|
38
|
+
|
|
39
|
+
`custom_buttons: ButtonsConfig = ButtonsConfig(next=Button(text="Confirm"))` sends the overrides to the
|
|
40
|
+
frontend as `customButtons` in the page's meta. The field takes no input and stays out of the schema and
|
|
41
|
+
the validated result. It can't be combined with `customButtons` in the page's `meta__`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
previous: Optional[Button] = None
|
|
45
|
+
next: Optional[Button] = None
|
|
46
|
+
|
|
47
|
+
def to_meta(self) -> CustomButtons:
|
|
48
|
+
buttons = {"previous": self.previous, "next": self.next}
|
|
49
|
+
return cast(CustomButtons, {name: button.to_meta() for name, button in buttons.items() if button is not None})
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def __get_pydantic_core_schema__(cls, source: Any, handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
|
|
53
|
+
# Display-only: any input is dropped. The config is read from the field's default by `FormPage.form_meta()`.
|
|
54
|
+
return core_schema.no_info_plain_validator_function(lambda _: None)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def __get_pydantic_json_schema__(
|
|
58
|
+
cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
|
|
59
|
+
) -> JsonSchemaValue:
|
|
60
|
+
raise PydanticOmit
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def merge_buttons_into_meta(
|
|
64
|
+
form_name: str, meta: Optional[FormMeta], fields: dict[str, FieldInfo]
|
|
65
|
+
) -> Optional[FormMeta]:
|
|
66
|
+
"""Return `meta` with the `ButtonsConfig` default among `fields` set as its `customButtons`.
|
|
67
|
+
|
|
68
|
+
`meta` itself is never modified, and is returned as-is when no field is a `ButtonsConfig`.
|
|
69
|
+
"""
|
|
70
|
+
buttons_fields = {name: field for name, field in fields.items() if field.annotation is ButtonsConfig}
|
|
71
|
+
if not buttons_fields:
|
|
72
|
+
return meta
|
|
73
|
+
if missing := [name for name, field in buttons_fields.items() if not isinstance(field.default, ButtonsConfig)]:
|
|
74
|
+
raise TypeError(f"{form_name}.{missing[0]} needs a ButtonsConfig(...) default")
|
|
75
|
+
if len(buttons_fields) > 1:
|
|
76
|
+
raise TypeError(f"{form_name} has more than one ButtonsConfig field; declare all buttons in a single one")
|
|
77
|
+
if meta and "customButtons" in meta:
|
|
78
|
+
raise TypeError(
|
|
79
|
+
f"{form_name} declares its buttons both in meta__['customButtons'] and in a ButtonsConfig field"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
(field,) = buttons_fields.values()
|
|
83
|
+
return {**(meta or {}), "customButtons": field.default.to_meta()}
|
{pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/callout.py
RENAMED
|
@@ -38,6 +38,7 @@ class _Callout(BaseModel):
|
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
Callout = Annotated[_Callout, Field(frozen=True, default=None, validate_default=False)]
|
|
41
|
+
"""A callout box. Use `callout()` to set its content and styling."""
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
def create_callout_schema(data: CalloutData, schema: dict[str, Any]) -> None:
|
|
@@ -57,6 +58,11 @@ def callout(
|
|
|
57
58
|
icon_type: Union[str, None] = "info",
|
|
58
59
|
message_type: Union[CalloutMessageType, str] = CalloutMessageType.PRIMARY,
|
|
59
60
|
) -> type[Callout]:
|
|
61
|
+
"""Create a callout box.
|
|
62
|
+
|
|
63
|
+
`message_type` selects the styling and accepts a `CalloutMessageType`: `primary`, `success`,
|
|
64
|
+
`warning`, `danger` or `accent`. `icon_type` names the icon to show, and defaults to `"info"`.
|
|
65
|
+
"""
|
|
60
66
|
|
|
61
67
|
data: CalloutData = {
|
|
62
68
|
"header": header,
|
{pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/choice.py
RENAMED
|
@@ -19,7 +19,10 @@ from pydantic_forms.types import strEnum
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class Choice(strEnum):
|
|
22
|
-
"""Let the user choose from an enum
|
|
22
|
+
"""Let the user choose from an enum, showing a label that may differ from the stored value.
|
|
23
|
+
|
|
24
|
+
Each member is a `(value, label)` pair: the value is what the form submits, the label is what
|
|
25
|
+
the frontend displays. A member declared without a label uses its value for both.
|
|
23
26
|
|
|
24
27
|
As of March 2023 mypy does not yet support functional API on Enum subclasses
|
|
25
28
|
https://github.com/python/mypy/issues/6037
|
|
@@ -41,7 +44,7 @@ class Choice(strEnum):
|
|
|
41
44
|
def __new__(cls, value: str, label: Optional[str] = None) -> "Choice":
|
|
42
45
|
obj = str.__new__(cls, value)
|
|
43
46
|
obj._value_ = value
|
|
44
|
-
obj.label = label or value # type:ignore
|
|
47
|
+
obj.label = label or value # type: ignore
|
|
45
48
|
return obj
|
|
46
49
|
|
|
47
50
|
@classmethod
|
{pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/choice_list.py
RENAMED
|
@@ -26,6 +26,7 @@ def choice_list(
|
|
|
26
26
|
max_items: Optional[int] = None,
|
|
27
27
|
unique_items: Optional[bool] = None,
|
|
28
28
|
) -> type[list[Choice]]:
|
|
29
|
+
"""Create a multi-select list of `Choice` values."""
|
|
29
30
|
if unique_items:
|
|
30
31
|
from pydantic_forms.validators import unique_conlist
|
|
31
32
|
|
|
@@ -34,12 +35,12 @@ def choice_list(
|
|
|
34
35
|
# Note: min_items always need to be there to remain backward compatible with frontend components
|
|
35
36
|
if max_items:
|
|
36
37
|
return Annotated[ # type: ignore[return-value]
|
|
37
|
-
list[item_type], # type:ignore[valid-type]
|
|
38
|
+
list[item_type], # type: ignore[valid-type]
|
|
38
39
|
MinLen(min_items or 0),
|
|
39
40
|
MaxLen(max_items),
|
|
40
41
|
]
|
|
41
42
|
|
|
42
43
|
return Annotated[ # type: ignore[return-value]
|
|
43
|
-
list[item_type], # type:ignore[valid-type]
|
|
44
|
+
list[item_type], # type: ignore[valid-type]
|
|
44
45
|
MinLen(min_items or 0),
|
|
45
46
|
]
|
{pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/contact_person.py
RENAMED
|
@@ -15,6 +15,7 @@ from typing import Annotated
|
|
|
15
15
|
from pydantic import BaseModel, EmailStr, Field
|
|
16
16
|
|
|
17
17
|
ContactPersonName = Annotated[str, Field(json_schema_extra={"format": "contactPersonName"})]
|
|
18
|
+
"""A string tagged so the frontend renders it as a contact person name."""
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
class ContactPerson(BaseModel):
|
|
@@ -28,6 +28,11 @@ def contact_person_list(
|
|
|
28
28
|
min_items: Optional[int] = None,
|
|
29
29
|
max_items: Optional[int] = None,
|
|
30
30
|
) -> type[list[T]]:
|
|
31
|
+
"""Create a list of `ContactPerson` entries.
|
|
32
|
+
|
|
33
|
+
Deprecated: use `orchestrator.forms.validators.customer_contact_list` instead.
|
|
34
|
+
"""
|
|
35
|
+
|
|
31
36
|
def json_schema_extra() -> Generator:
|
|
32
37
|
if organisation:
|
|
33
38
|
yield "organisationId", organisation
|
|
@@ -20,3 +20,7 @@ DisplaySubscription = Annotated[
|
|
|
20
20
|
UUID,
|
|
21
21
|
Field(frozen=True, json_schema_extra={"format": "subscription", "type": "string"}),
|
|
22
22
|
]
|
|
23
|
+
"""Displays a subscription, identified by UUID. Display-only: it collects no input.
|
|
24
|
+
|
|
25
|
+
Deprecated: use `orchestrator.forms.validators.DisplaySubscription` instead.
|
|
26
|
+
"""
|
{pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/hidden.py
RENAMED
|
@@ -15,3 +15,4 @@ from typing import Annotated, Optional
|
|
|
15
15
|
from pydantic import Field
|
|
16
16
|
|
|
17
17
|
Hidden = Annotated[Optional[str], Field(None, frozen=True, json_schema_extra={"format": "hidden", "type": "string"})]
|
|
18
|
+
"""A field that is not shown to the user. Display-only: it collects no input."""
|
|
@@ -15,3 +15,4 @@ from typing import Annotated, Optional
|
|
|
15
15
|
from pydantic import Field
|
|
16
16
|
|
|
17
17
|
Label = Annotated[Optional[str], Field(None, frozen=True, json_schema_extra={"format": "label", "type": "string"})]
|
|
18
|
+
"""A plain string formatted as a label. Display-only: it collects no input."""
|
{pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/markdown.py
RENAMED
|
@@ -36,6 +36,7 @@ class _Markdown(BaseModel):
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
Markdown = Annotated[_Markdown, Field(frozen=True, default=None, validate_default=False)]
|
|
39
|
+
"""A block of rendered Markdown. Use `markdown()` to set its content."""
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
def create_markdown_schema(data: MarkdownData, schema: dict[str, Any]) -> None:
|
|
@@ -53,6 +54,10 @@ def markdown(
|
|
|
53
54
|
content: Union[str, None] = None,
|
|
54
55
|
color: Union[MarkdownColor, str] = MarkdownColor.PRIMARY,
|
|
55
56
|
) -> type[Markdown]:
|
|
57
|
+
"""Create a block of rendered Markdown.
|
|
58
|
+
|
|
59
|
+
`color` accepts a `MarkdownColor`: `primary`, `success`, `warning`, `danger` or `accent`.
|
|
60
|
+
"""
|
|
56
61
|
|
|
57
62
|
data: MarkdownData = {
|
|
58
63
|
"content": content,
|
|
@@ -25,6 +25,7 @@ class _MigrationSummary(BaseModel):
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
MigrationSummary = Annotated[_MigrationSummary, Field(frozen=True, default=None, validate_default=False)]
|
|
28
|
+
"""A static summary table. Use `migration_summary()` to supply its data."""
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def create_json_extra_schema(data: SummaryData, schema: dict[str, Any]) -> None:
|
|
@@ -40,6 +41,7 @@ def create_json_extra_schema(data: SummaryData, schema: dict[str, Any]) -> None:
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def migration_summary(data: SummaryData) -> type[MigrationSummary]:
|
|
44
|
+
"""Create a static table from a `{headers, labels, columns}` mapping."""
|
|
43
45
|
namespace = {"data": data}
|
|
44
46
|
klass: type[MigrationSummary] = new_class(
|
|
45
47
|
"MigrationSummaryValue", (_MigrationSummary,), {}, lambda ns: ns.update(namespace)
|