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.
Files changed (79) hide show
  1. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/PKG-INFO +4 -4
  2. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/README.md +2 -2
  3. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/core/asynchronous.py +4 -3
  4. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/core/shared.py +33 -1
  5. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/core/sync.py +10 -4
  6. pydantic_forms-2.7.0/pydantic_forms/exception_handlers/fastapi.py +84 -0
  7. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/exceptions.py +13 -6
  8. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/types.py +24 -0
  9. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/utils/json.py +1 -0
  10. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/__init__.py +3 -0
  11. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/accept.py +7 -0
  12. pydantic_forms-2.7.0/pydantic_forms/validators/components/buttons.py +83 -0
  13. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/callout.py +6 -0
  14. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/choice.py +5 -2
  15. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/choice_list.py +3 -2
  16. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/contact_person.py +1 -0
  17. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/contact_person_list.py +5 -0
  18. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/display_subscription.py +4 -0
  19. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/divider.py +1 -0
  20. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/hidden.py +1 -0
  21. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/label.py +1 -0
  22. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/list_of_one.py +1 -0
  23. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/list_of_two.py +1 -0
  24. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/long_text.py +1 -0
  25. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/markdown.py +5 -0
  26. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/migration_summary.py +2 -0
  27. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/organisation_id.py +4 -0
  28. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/timestamp.py +7 -0
  29. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/unique_constrained_list.py +2 -0
  30. pydantic_forms-2.7.0/pyproject.toml +247 -0
  31. pydantic_forms-2.6.0/pyproject.toml → pydantic_forms-2.7.0/pyproject.toml.orig +10 -3
  32. pydantic_forms-2.7.0/tests/paths.py +21 -0
  33. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/exception_handlers/test_fastapi.py +15 -1
  34. pydantic_forms-2.7.0/tests/unit_tests/test_buttons.py +72 -0
  35. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_core.py +30 -2
  36. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_core_async.py +12 -2
  37. pydantic_forms-2.7.0/tests/unit_tests/test_documentation.py +203 -0
  38. pydantic_forms-2.7.0/tests/unit_tests/test_form_meta.py +37 -0
  39. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_read_only_field.py +9 -0
  40. pydantic_forms-2.6.0/pydantic_forms/exception_handlers/fastapi.py +0 -73
  41. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/LICENSE +0 -0
  42. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/__init__.py +0 -0
  43. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/core/__init__.py +0 -0
  44. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/core/translations.py +0 -0
  45. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/exception_handlers/__init__.py +0 -0
  46. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/py.typed +0 -0
  47. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/utils/__init__.py +0 -0
  48. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/utils/schema.py +0 -0
  49. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/__init__.py +0 -0
  50. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/read_only.py +0 -0
  51. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/constants.py +0 -0
  52. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/helpers.py +0 -0
  53. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/__init__.py +0 -0
  54. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/__init__.py +0 -0
  55. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/exception_handlers/__init__.py +0 -0
  56. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/helpers.py +0 -0
  57. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_accept.py +0 -0
  58. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_callout.py +0 -0
  59. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_choice.py +0 -0
  60. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_choice_list.py +0 -0
  61. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_constrained_list.py +0 -0
  62. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_contact_person.py +0 -0
  63. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_contact_person_list.py +0 -0
  64. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_default_factory.py +0 -0
  65. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_display_subscription.py +0 -0
  66. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_divider.py +0 -0
  67. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_hidden.py +0 -0
  68. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_label.py +0 -0
  69. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_list_of_one.py +0 -0
  70. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_list_of_two.py +0 -0
  71. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_long_text.py +0 -0
  72. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_markdown.py +0 -0
  73. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_migration_summary.py +0 -0
  74. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_organisation_id.py +0 -0
  75. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_shared.py +0 -0
  76. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_timestamp.py +0 -0
  77. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_utils_dict_without_none.py +0 -0
  78. {pydantic_forms-2.6.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_utils_from_serializable.py +0 -0
  79. {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.6.0
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://github.com/workfloworchestrator/pydantic-forms/blob/main/README.md
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
- [here](https://github.com/workfloworchestrator/orchestrator-core/blob/main/docs/architecture/application/forms-frontend.md)
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
- [here](https://github.com/workfloworchestrator/orchestrator-core/blob/main/docs/architecture/application/forms-frontend.md)
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=getattr(generated_form, "meta__", None),
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 FormException(f"Form {key} does not exist.")
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 FormException, FormNotCompleteError, FormOverflowError, FormValidationError
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=getattr(generated_form, "meta__", None),
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 FormException(f"Form {key} does not exist.")
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
- >>> try:
142
- ... raise Exception("Something went wrong")
143
- ... except Exception as e:
144
- ... print(show_ex(e))
145
- Exception: Something went wrong
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
 
@@ -74,6 +74,7 @@ decoding functions differently then `default` and `object_hook` is that they are
74
74
  :func:`json.dumps` and :func:`json.loads`.
75
75
 
76
76
  """
77
+
77
78
  import re
78
79
  from collections.abc import Callable
79
80
  from contextlib import suppress
@@ -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",
@@ -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()}
@@ -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,
@@ -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 and submit the label.
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
@@ -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
  ]
@@ -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
+ """
@@ -15,3 +15,4 @@ from typing import Annotated, Optional
15
15
  from pydantic import Field
16
16
 
17
17
  Divider = Annotated[Optional[str], Field(None, frozen=True, json_schema_extra={"format": "divider", "type": "string"})]
18
+ """A horizontal separator line. 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
  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."""
@@ -18,3 +18,4 @@ from pydantic import Field
18
18
  from pydantic_forms.validators.components.unique_constrained_list import T
19
19
 
20
20
  ListOfOne = Annotated[list[T], Field(min_length=1, max_length=1)]
21
+ """A list constrained to exactly one item."""
@@ -22,3 +22,4 @@ ListOfTwo = Annotated[
22
22
  AfterValidator(validate_unique_list),
23
23
  Field(min_length=2, max_length=2, json_schema_extra={"uniqueItems": True}),
24
24
  ]
25
+ """A list constrained to exactly two items, which must differ from each other."""
@@ -15,3 +15,4 @@ from typing import Annotated
15
15
  from pydantic import Field
16
16
 
17
17
  LongText = Annotated[str, Field(json_schema_extra={"format": "long"})]
18
+ """A multi-line text input."""
@@ -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)
@@ -19,3 +19,7 @@ OrganisationId = Annotated[
19
19
  str,
20
20
  Field(json_schema_extra={"format": "organisationId"}),
21
21
  ]
22
+ """A string tagged for organisation-ID rendering.
23
+
24
+ Deprecated: use `orchestrator.forms.validators.CustomerId` instead.
25
+ """