pydantic-forms 2.5.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 (94) hide show
  1. pydantic_forms-2.7.0/PKG-INFO +132 -0
  2. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/README.md +19 -44
  3. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/__init__.py +3 -1
  4. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/core/asynchronous.py +7 -3
  5. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/core/shared.py +64 -1
  6. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/core/sync.py +13 -4
  7. pydantic_forms-2.7.0/pydantic_forms/exception_handlers/fastapi.py +84 -0
  8. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/exceptions.py +33 -10
  9. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/types.py +24 -0
  10. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/utils/json.py +1 -0
  11. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/__init__.py +3 -0
  12. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/accept.py +7 -0
  13. pydantic_forms-2.7.0/pydantic_forms/validators/components/buttons.py +83 -0
  14. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/callout.py +6 -0
  15. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/choice.py +5 -2
  16. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/choice_list.py +3 -2
  17. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/contact_person.py +1 -0
  18. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/contact_person_list.py +5 -0
  19. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/display_subscription.py +4 -0
  20. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/divider.py +1 -0
  21. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/hidden.py +1 -0
  22. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/label.py +1 -0
  23. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/list_of_one.py +1 -0
  24. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/list_of_two.py +1 -0
  25. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/long_text.py +1 -0
  26. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/markdown.py +5 -0
  27. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/migration_summary.py +2 -0
  28. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/organisation_id.py +4 -0
  29. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/timestamp.py +7 -0
  30. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/unique_constrained_list.py +2 -0
  31. pydantic_forms-2.7.0/pyproject.toml +247 -0
  32. pydantic_forms-2.7.0/pyproject.toml.orig +245 -0
  33. pydantic_forms-2.7.0/tests/paths.py +21 -0
  34. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/exception_handlers/test_fastapi.py +15 -1
  35. pydantic_forms-2.7.0/tests/unit_tests/test_buttons.py +72 -0
  36. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_core.py +137 -4
  37. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_core_async.py +12 -2
  38. pydantic_forms-2.7.0/tests/unit_tests/test_default_factory.py +57 -0
  39. pydantic_forms-2.7.0/tests/unit_tests/test_documentation.py +203 -0
  40. pydantic_forms-2.7.0/tests/unit_tests/test_form_meta.py +37 -0
  41. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_read_only_field.py +9 -0
  42. pydantic_forms-2.5.0/.bumpversion.cfg +0 -12
  43. pydantic_forms-2.5.0/.claude/.gitignore +0 -1
  44. pydantic_forms-2.5.0/.coveragerc +0 -3
  45. pydantic_forms-2.5.0/.github/dependabot.yml +0 -11
  46. pydantic_forms-2.5.0/.github/workflows/publish-release.yaml +0 -41
  47. pydantic_forms-2.5.0/.github/workflows/run-linting-tests.yml +0 -37
  48. pydantic_forms-2.5.0/.github/workflows/run-unit-tests.yml +0 -73
  49. pydantic_forms-2.5.0/.gitignore +0 -130
  50. pydantic_forms-2.5.0/.pre-commit-config.yaml +0 -53
  51. pydantic_forms-2.5.0/.stignore +0 -117
  52. pydantic_forms-2.5.0/PKG-INFO +0 -188
  53. pydantic_forms-2.5.0/codecov.yml +0 -2
  54. pydantic_forms-2.5.0/pydantic_forms/exception_handlers/fastapi.py +0 -73
  55. pydantic_forms-2.5.0/pyproject.toml +0 -171
  56. pydantic_forms-2.5.0/setup.cfg +0 -61
  57. pydantic_forms-2.5.0/setup.py +0 -20
  58. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/LICENSE +0 -0
  59. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/core/__init__.py +0 -0
  60. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/core/translations.py +0 -0
  61. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/exception_handlers/__init__.py +0 -0
  62. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/py.typed +0 -0
  63. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/utils/__init__.py +0 -0
  64. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/utils/schema.py +0 -0
  65. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/__init__.py +0 -0
  66. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/components/read_only.py +0 -0
  67. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/constants.py +0 -0
  68. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/pydantic_forms/validators/helpers.py +0 -0
  69. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/__init__.py +0 -0
  70. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/__init__.py +0 -0
  71. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/exception_handlers/__init__.py +0 -0
  72. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/helpers.py +0 -0
  73. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_accept.py +0 -0
  74. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_callout.py +0 -0
  75. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_choice.py +0 -0
  76. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_choice_list.py +0 -0
  77. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_constrained_list.py +0 -0
  78. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_contact_person.py +0 -0
  79. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_contact_person_list.py +0 -0
  80. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_display_subscription.py +0 -0
  81. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_divider.py +0 -0
  82. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_hidden.py +0 -0
  83. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_label.py +0 -0
  84. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_list_of_one.py +0 -0
  85. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_list_of_two.py +0 -0
  86. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_long_text.py +0 -0
  87. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_markdown.py +0 -0
  88. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_migration_summary.py +0 -0
  89. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_organisation_id.py +0 -0
  90. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_shared.py +0 -0
  91. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_timestamp.py +0 -0
  92. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_utils_dict_without_none.py +0 -0
  93. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_utils_from_serializable.py +0 -0
  94. {pydantic_forms-2.5.0 → pydantic_forms-2.7.0}/tests/unit_tests/test_utils_to_serializable.py +0 -0
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: pydantic-forms
3
+ Version: 2.7.0
4
+ Summary: Pydantic-forms engine.
5
+ Author: SURF
6
+ Author-email: SURF <automation-beheer@surf.nl>
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Framework :: Flask
14
+ Classifier: Framework :: Pydantic :: 2
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Information Technology
17
+ Classifier: Intended Audience :: System Administrators
18
+ Classifier: Intended Audience :: Telecommunications Industry
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.10
23
+ Classifier: Programming Language :: Python :: 3.11
24
+ Classifier: Programming Language :: Python :: 3.12
25
+ Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Programming Language :: Python :: 3.14
27
+ Classifier: Programming Language :: Python
28
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
29
+ Classifier: Topic :: Internet :: WWW/HTTP
30
+ Classifier: Topic :: Internet
31
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
32
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
33
+ Classifier: Topic :: Software Development :: Libraries
34
+ Classifier: Topic :: Software Development
35
+ Classifier: Typing :: Typed
36
+ Requires-Dist: more-itertools>=10.0.0
37
+ Requires-Dist: pydantic[email]>=2.9.0
38
+ Requires-Dist: pydantic-i18n==0.4.5
39
+ Requires-Dist: fastapi>=0.103.2 ; extra == 'fastapi'
40
+ Requires-Dist: orjson>=3.10.7,<4.0.0 ; extra == 'orjson'
41
+ Requires-Python: >=3.10, <3.15
42
+ Project-URL: Documentation, https://workfloworchestrator.org/pydantic-forms/
43
+ Project-URL: Homepage, https://github.com/workfloworchestrator/pydantic-forms
44
+ Project-URL: Source, https://github.com/workfloworchestrator/pydantic-forms
45
+ Provides-Extra: fastapi
46
+ Provides-Extra: orjson
47
+ Description-Content-Type: text/markdown
48
+
49
+ # Pydantic forms
50
+
51
+ [![pypi_version](https://img.shields.io/pypi/v/pydantic-forms?color=%2334D058&label=pypi%20package)](https://pypi.org/project/pydantic-forms)
52
+ [![Supported python versions](https://img.shields.io/pypi/pyversions/pydantic-forms.svg?color=%2334D058)](https://pypi.org/project/pydantic-forms)
53
+ [![Downloads](https://static.pepy.tech/badge/pydantic-forms/month)](https://pepy.tech/project/pydantic-forms)
54
+ [![codecov](https://codecov.io/gh/workfloworchestrator/pydantic-forms/branch/main/graph/badge.svg?token=AJMOSWPHQX)](https://codecov.io/gh/workfloworchestrator/pydantic-forms)
55
+
56
+ A Python package that lets you add smart forms to [FastAPI](https://fastapi.tiangolo.com/)
57
+ and [Flask](https://palletsprojects.com/p/flask/). Forms will respond with a JSON scheme that
58
+ contains all info needed in a React frontend with uniforms to render the forms and handle all validation tasks.
59
+
60
+ Forms can also consist out of a wizard, so you can create complex form flows consisting out of multiple
61
+ consecutive forms. The forms and the validation logic are defined by
62
+ using [Pydantic](https://pydantic-docs.helpmanual.io/) models.
63
+
64
+ Documentation regarding the usage of Forms can be found at
65
+ [workfloworchestrator.org/pydantic-forms](https://workfloworchestrator.org/pydantic-forms/).
66
+
67
+ ### Installation (Development standalone)
68
+
69
+ This project uses [uv](https://docs.astral.sh/uv/). Install the project and its dependencies to develop on the code:
70
+
71
+ ```shell
72
+ uv sync --all-extras
73
+ ```
74
+
75
+ This creates a `.venv`, installs `pydantic-forms` in editable mode, and installs the `dev` dependency group
76
+ (which chains in `test` and `doc`) plus the `fastapi` and `orjson` extras. There is no need to create or
77
+ activate a virtualenv yourself -- prefix commands with `uv run` and uv will use the right interpreter.
78
+
79
+ ### Running tests
80
+ Run the unit-test suite to verify a correct setup.
81
+
82
+ ```shell
83
+ uv run pytest tests/unit_tests
84
+ ```
85
+
86
+ or with xdist:
87
+
88
+ ```shell
89
+ uv run pytest -n auto tests/unit_tests
90
+ ```
91
+
92
+ If you do not encounter any failures in the test, you should be able to develop features in the pydantic-forms.
93
+
94
+ ### Installation (Development version used by a project that depends on pydantic-forms)
95
+
96
+ If you are working on a project that already uses `pydantic-forms` and you want to test your new form features
97
+ against it, point that project at your local checkout. Run this **from the other project**:
98
+
99
+ ```shell
100
+ uv add --editable /path/to/pydantic-forms
101
+ ```
102
+
103
+ This replaces the PyPI dependency with an editable install of your working copy and records it under
104
+ `[tool.uv.sources]` in that project's `pyproject.toml`. Undo it with:
105
+
106
+ ```shell
107
+ uv remove pydantic-forms && uv add pydantic-forms
108
+ ```
109
+
110
+ # Increasing the version number for a (pre) release.
111
+
112
+ When your PR is accepted you will get a version number.
113
+
114
+ You can do the necessary change with a clean, e.g. every change committed, branch:
115
+
116
+ ```shell
117
+ uv version 0.0.1
118
+ ```
119
+
120
+ Note: specifying the version explicitly, instead of relying on `uv version --bump patch` to increase it, allows
121
+ you to set a "RC1" version if needed -- e.g. `uv version 0.0.1rc1`.
122
+
123
+ # Debugging Form behaviour
124
+
125
+ If you want/need the traceback of pydantic in a Form response you can add an env variable:
126
+
127
+ `
128
+ LOG_LEVEL_PYDANTIC_FORMS=DEBUG
129
+ `
130
+
131
+ This will add the traceback to the `JSONResponse`. If the loglevel is set to DEBUG the library will also add the
132
+ traceback to the logger.
@@ -13,75 +13,50 @@ 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
- Install the project and its dependencies to develop on the code.
21
20
 
22
- #### Step 1 - install flit:
21
+ This project uses [uv](https://docs.astral.sh/uv/). Install the project and its dependencies to develop on the code:
23
22
 
24
23
  ```shell
25
- python3 -m venv venv
26
- source venv/bin/activate
27
- pip install flit
28
- ```
29
-
30
- #### Step 2 - install the development code:
31
- ```shell
32
- flit install --deps develop --symlink --python venv/bin/python
33
- ```
34
-
35
- !!! danger
36
- Make sure to use the flit binary that is installed in your environment. You can check the correct
37
- path by running
38
- ```shell
39
- which flit
40
- ```
41
-
42
- To be sure that the packages will be installed against the correct venv you can also prepend the python interpreter
43
- that you want to use:
44
-
45
- ```shell
46
- flit install --deps develop --symlink --python venv/bin/python
24
+ uv sync --all-extras
47
25
  ```
48
26
 
27
+ This creates a `.venv`, installs `pydantic-forms` in editable mode, and installs the `dev` dependency group
28
+ (which chains in `test` and `doc`) plus the `fastapi` and `orjson` extras. There is no need to create or
29
+ activate a virtualenv yourself -- prefix commands with `uv run` and uv will use the right interpreter.
49
30
 
50
31
  ### Running tests
51
32
  Run the unit-test suite to verify a correct setup.
52
33
 
53
- #### Step 2 - Run tests
54
34
  ```shell
55
- pytest tests/unit_tests
35
+ uv run pytest tests/unit_tests
56
36
  ```
57
37
 
58
38
  or with xdist:
59
39
 
60
40
  ```shell
61
- pytest -n auto tests/unit_tests
41
+ uv run pytest -n auto tests/unit_tests
62
42
  ```
63
43
 
64
44
  If you do not encounter any failures in the test, you should be able to develop features in the pydantic-forms.
65
45
 
66
- ### Installation (Development symlinked into project that use pydantic-forms)
67
-
68
- If you are working on a project that already uses the `pydantic-forms` and you want to test your new form features
69
- against it, you can use some `flit` magic to symlink the dev version of the forms to your project. It will
70
- automatically replace the pypi dep with a symlink to the development version
71
- of the core and update/downgrade all required packages in your own project.
46
+ ### Installation (Development version used by a project that depends on pydantic-forms)
72
47
 
73
- #### Step 1 - install flit:
48
+ If you are working on a project that already uses `pydantic-forms` and you want to test your new form features
49
+ against it, point that project at your local checkout. Run this **from the other project**:
74
50
 
75
51
  ```shell
76
- python - m venv venv
77
- source venv/bin/activate
78
- pip install flit
52
+ uv add --editable /path/to/pydantic-forms
79
53
  ```
80
54
 
81
- ### Step 2 - symlink pydantic-forms to your own project
55
+ This replaces the PyPI dependency with an editable install of your working copy and records it under
56
+ `[tool.uv.sources]` in that project's `pyproject.toml`. Undo it with:
82
57
 
83
58
  ```shell
84
- flit install --deps develop --symlink --python /path/to/a/project/venv/bin/python
59
+ uv remove pydantic-forms && uv add pydantic-forms
85
60
  ```
86
61
 
87
62
  # Increasing the version number for a (pre) release.
@@ -91,11 +66,11 @@ When your PR is accepted you will get a version number.
91
66
  You can do the necessary change with a clean, e.g. every change committed, branch:
92
67
 
93
68
  ```shell
94
- bumpversion patch --new-version 0.0.1
69
+ uv version 0.0.1
95
70
  ```
96
71
 
97
- Note: specifying it like this, instead of relying on bumpversion itself to increase the version, allows you to
98
- set a "RC1" version if needed.
72
+ Note: specifying the version explicitly, instead of relying on `uv version --bump patch` to increase it, allows
73
+ you to set a "RC1" version if needed -- e.g. `uv version 0.0.1rc1`.
99
74
 
100
75
  # Debugging Form behaviour
101
76
 
@@ -13,4 +13,6 @@
13
13
 
14
14
  """Pydantic-forms engine."""
15
15
 
16
- __version__ = "2.5.0"
16
+ from importlib.metadata import version
17
+
18
+ __version__ = version("pydantic-forms")
@@ -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
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
  )
@@ -102,12 +103,15 @@ async def post_form(
102
103
  return generated_form
103
104
 
104
105
  # Form is not completely filled raise next form
105
- raise FormNotCompleteError(generated_form.model_json_schema(), meta=getattr(generated_form, "meta__", None))
106
+ raise FormNotCompleteError(
107
+ generated_form.model_json_schema(schema_generator=GenerateFormJsonSchema),
108
+ meta=get_form_meta(generated_form),
109
+ )
106
110
 
107
111
 
108
112
  def _get_form(key: str) -> StateInputFormGeneratorAsync:
109
113
  if not (func := FORMS.get(key)):
110
- raise FormException(f"Form {key} does not exist.")
114
+ raise FormNotFoundError(f"Form {key} does not exist.")
111
115
 
112
116
  if not isasyncgenfunction(func):
113
117
  raise FormException(f"Form {key} is not an asyncgenerator function")
@@ -11,10 +11,15 @@
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
14
+ from typing import Any, Callable, ClassVar, cast
15
15
 
16
16
  import structlog
17
17
  from pydantic import BaseModel, ConfigDict, PydanticUndefinedAnnotation, version
18
+ from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
19
+ from pydantic_core import core_schema
20
+
21
+ from pydantic_forms.types import FormMeta
22
+ from pydantic_forms.validators.components.buttons import merge_buttons_into_meta
18
23
 
19
24
  logger = structlog.get_logger(__name__)
20
25
 
@@ -22,6 +27,35 @@ logger = structlog.get_logger(__name__)
22
27
  PYDANTIC_VERSION = version.version_short()
23
28
 
24
29
 
30
+ class GenerateFormJsonSchema(GenerateJsonSchema):
31
+ """JSON schema generator that also emits ``default`` values produced by ``default_factory``.
32
+
33
+ Pydantic intentionally leaves ``default_factory`` results out of the generated JSON schema. For
34
+ forms this is a problem: ``default_factory`` is the recommended way to declare mutable defaults
35
+ (``Field(default_factory=list)`` and friends), and frontends pre-fill fields from the schema
36
+ ``default``. Without it such fields render uninitialised, and dynamic array/object widgets reject
37
+ submission until the user performs a dummy add/remove interaction to materialise an empty value.
38
+
39
+ This generator evaluates argument-less factories and adds their result as the schema ``default``.
40
+ Factories that need the already-validated data (``default_factory_takes_data``) cannot be
41
+ evaluated at schema-generation time and are skipped, as is any factory that raises.
42
+ """
43
+
44
+ def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaValue:
45
+ json_schema = super().default_schema(schema)
46
+ if (
47
+ "default" not in json_schema
48
+ and "default_factory" in schema
49
+ and not schema.get("default_factory_takes_data", False)
50
+ ):
51
+ factory = cast(Callable[[], Any], schema["default_factory"])
52
+ try:
53
+ json_schema["default"] = self.encode_default(factory())
54
+ except Exception: # never let a factory break schema generation
55
+ logger.debug("Could not evaluate default_factory for form schema", exc_info=True)
56
+ return json_schema
57
+
58
+
25
59
  class DisplayOnlyFieldType:
26
60
  pass
27
61
 
@@ -34,6 +68,18 @@ class FormPage(BaseModel):
34
68
  validate_default=True,
35
69
  )
36
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
+
37
83
  def __init__(self, **data: Any):
38
84
  frozen_fields = {k: v for k, v in self.__class__.model_fields.items() if v.frozen}
39
85
 
@@ -45,6 +91,11 @@ class FormPage(BaseModel):
45
91
  mutable_data = {k: get_value(k, v) for k, v in data.items()}
46
92
  super().__init__(**mutable_data)
47
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
+
48
99
  if PYDANTIC_VERSION in ("2.9", "2.10", "2.11"):
49
100
 
50
101
  @classmethod
@@ -52,6 +103,7 @@ class FormPage(BaseModel):
52
103
  # The default and requiredness of a field is not a property of a field
53
104
  # In the case of DisplayOnlyFieldTypes, we do kind of want that.
54
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
55
107
  needs_rebuild = False
56
108
 
57
109
  for field in cls.model_fields.values():
@@ -84,6 +136,7 @@ class FormPage(BaseModel):
84
136
  # The default and requiredness of a field is not a property of a field
85
137
  # In the case of DisplayOnlyFieldTypes, we do kind of want that.
86
138
  # Using this method we set the right properties after the form is created
139
+ cls.form_meta()
87
140
  needs_rebuild = False
88
141
 
89
142
  for field in cls.model_fields.values():
@@ -111,3 +164,13 @@ def register_form(key: str, form: Callable) -> None:
111
164
 
112
165
  def list_forms() -> list[str]:
113
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
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__)
@@ -88,7 +94,10 @@ def post_form(
88
94
  generated_form = generator.send(form_validated_data)
89
95
 
90
96
  # Form is not completely filled; raise next form
91
- raise FormNotCompleteError(generated_form.model_json_schema(), meta=getattr(generated_form, "meta__", None))
97
+ raise FormNotCompleteError(
98
+ generated_form.model_json_schema(schema_generator=GenerateFormJsonSchema),
99
+ meta=get_form_meta(generated_form),
100
+ )
92
101
  except StopIteration as e:
93
102
  if user_inputs:
94
103
  raise FormOverflowError(f"Did not process all user_inputs ({len(user_inputs)} remaining)")
@@ -99,7 +108,7 @@ def post_form(
99
108
 
100
109
  def _get_form(key: str) -> StateInputFormGenerator:
101
110
  if not (func := FORMS.get(key)):
102
- raise FormException(f"Form {key} does not exist.")
111
+ raise FormNotFoundError(f"Form {key} does not exist.")
103
112
 
104
113
  if not isgeneratorfunction(func):
105
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
@@ -1,5 +1,6 @@
1
1
  import os
2
2
  import traceback
3
+ from collections.abc import Mapping
3
4
  from typing import Any, Iterable, Optional, TypedDict, Union, cast
4
5
 
5
6
  import structlog
@@ -36,9 +37,26 @@ class FormOverflowError(FormException):
36
37
  """Raised when more inputs are provided than the form can process."""
37
38
 
38
39
 
40
+ class FormNotFoundError(FormException):
41
+ """Raised when the requested form key is not registered."""
42
+
43
+
44
+ Loc = tuple[Union[int, str], ...]
45
+
46
+
47
+ def _error_is_nested_object_validator(error: ErrorDetails) -> bool:
48
+ """Return True for custom errors raised for an object payload instead of a leaf field."""
49
+ exc = error.get("ctx", {}).get("error")
50
+ return (
51
+ bool(error["loc"]) and isinstance(exc, (ValueError, AssertionError)) and isinstance(error.get("input"), Mapping)
52
+ )
53
+
54
+
39
55
  # def convert_errors(validation_error: ValidationError) -> Iterable[ErrorDetails]:
40
56
  def convert_errors(
41
- validation_error: ValidationError, tr: PydanticI18n, locale: str = "en_US"
57
+ validation_error: ValidationError,
58
+ tr: PydanticI18n,
59
+ locale: str = "en_US",
42
60
  ) -> Iterable[ErrorDetails]:
43
61
  """Convert Pydantic's error messages to our needs.
44
62
 
@@ -55,6 +73,11 @@ def convert_errors(
55
73
  # Reinstate the "__root__" location for validation errors raised by Pydantic v1's root_validator.
56
74
  # With Pydantic v2's model_validator the location is an empty string, which breaks frontend clients.
57
75
  error["loc"] = ("__root__",)
76
+ elif _error_is_nested_object_validator(error):
77
+ # Errors raised by a model_validator on a nested model point at the nested model itself, which
78
+ # frontend clients cannot map to an input field. Mark them as model-level errors so that clients
79
+ # display the custom message, like they do for the form's own "__root__" errors.
80
+ error["loc"] = (*error["loc"], "__root__")
58
81
  return
59
82
 
60
83
  if LOG_LEVEL_PYDANTIC_FORMS == "DEBUG":
@@ -90,9 +113,6 @@ class FormValidationError(FormException):
90
113
  )
91
114
 
92
115
 
93
- Loc = tuple[Union[int, str], ...]
94
-
95
-
96
116
  class _ErrorDictRequired(TypedDict):
97
117
  loc: Loc
98
118
  msg: str
@@ -122,12 +142,15 @@ def _display_error_type_and_ctx(error: "ErrorDict") -> str:
122
142
  def show_ex(ex: Exception, stacklimit: Union[int, None] = None) -> str:
123
143
  """Show an exception, including its class name, message and (limited) stacktrace.
124
144
 
125
- >>> try:
126
- ... raise Exception("Something went wrong")
127
- ... except Exception as e:
128
- ... print(show_ex(e))
129
- Exception: Something went wrong
130
- ...
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
+
131
154
  """
132
155
  tbfmt = "".join(traceback.format_tb(ex.__traceback__, stacklimit))
133
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)