data-validation-engine 0.8.4__tar.gz → 0.9.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.
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/PKG-INFO +17 -39
- data_validation_engine-0.9.0/README.md +59 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/pyproject.toml +6 -5
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/base/auditing.py +8 -8
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/auditing.py +8 -8
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/contract.py +7 -10
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/duckdb_helpers.py +11 -3
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/readers/csv.py +4 -4
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/readers/json.py +2 -2
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/readers/xml.py +2 -2
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/auditing.py +1 -1
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/backend.py +1 -1
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/contract.py +7 -7
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/spark_helpers.py +28 -21
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/utilities.py +1 -1
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/metadata/contract.py +2 -2
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/metadata/reporting.py +9 -7
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/metadata/rules.py +22 -30
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/readers/csv.py +3 -3
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/readers/utilities.py +3 -2
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/readers/xml.py +22 -8
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/utilities.py +38 -6
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/configuration/v1/__init__.py +6 -6
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/configuration/v1/steps.py +8 -9
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/engine.py +47 -34
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/loggers.py +1 -1
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/message.py +12 -14
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/models.py +32 -18
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/validation.py +4 -5
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/metadata_parser/domain_types.py +143 -93
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/metadata_parser/function_library.py +2 -2
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/metadata_parser/function_wrapper.py +17 -24
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/metadata_parser/model_generator.py +7 -9
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/metadata_parser/models.py +51 -45
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/metadata_parser/utilities.py +12 -1
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/file_handling/implementations/file.py +4 -4
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/pipeline/pipeline.py +3 -3
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/pipeline/utils.py +3 -3
- data_validation_engine-0.8.4/README.md +0 -81
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/LICENSE +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/common/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/common/error_utils.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/base/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/base/backend.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/base/contract.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/base/core.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/base/reader.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/base/reference_data.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/base/rules.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/base/utilities.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/exceptions.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/readers/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/reference_data.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/rules.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/types.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/duckdb/utilities.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/readers/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/readers/csv.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/readers/json.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/readers/xml.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/reference_data.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/rules.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/implementations/spark/types.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/metadata/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/readers/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/readers/xml_linting.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/backends/types.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/configuration/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/configuration/base.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/configuration/v1/filters.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/configuration/v1/rule_stores/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/configuration/v1/rule_stores/models.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/constants.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/exceptions.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/functions/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/functions/implementations.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/templating.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/core_engine/type_hints.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/metadata_parser/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/metadata_parser/exc.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/exceptions.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/file_handling/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/file_handling/helpers.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/file_handling/implementations/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/file_handling/implementations/base.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/file_handling/implementations/dbfs.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/file_handling/implementations/s3.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/file_handling/log_handler.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/file_handling/service.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/file_handling/utilities.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/type_hints.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/parser/utilities.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/pipeline/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/pipeline/duckdb_pipeline.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/pipeline/foundry_ddb_pipeline.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/pipeline/spark_pipeline.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/reporting/__init__.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/reporting/constants.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/reporting/error_report.py +0 -0
- {data_validation_engine-0.8.4 → data_validation_engine-0.9.0}/src/dve/reporting/excel_report.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: data-validation-engine
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: `nhs data validation engine` is a framework used to validate data
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3.10
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
-
Classifier: Operating System ::
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
15
|
Classifier: Topic :: Software Development :: Libraries
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
17
|
Requires-Dist: Jinja2 (==3.1.6)
|
|
@@ -25,7 +25,7 @@ Requires-Dist: openpyxl (==3.1.5)
|
|
|
25
25
|
Requires-Dist: pandas (==2.3.3)
|
|
26
26
|
Requires-Dist: polars (==0.20.31)
|
|
27
27
|
Requires-Dist: pyarrow (==23.0.1)
|
|
28
|
-
Requires-Dist: pydantic (==
|
|
28
|
+
Requires-Dist: pydantic (==2.13.4)
|
|
29
29
|
Requires-Dist: pyspark (>=3.5.0,<=3.5.5)
|
|
30
30
|
Requires-Dist: typing_extensions (==4.15.0)
|
|
31
31
|
Requires-Dist: urllib3 (==2.7.0)
|
|
@@ -37,7 +37,7 @@ Project-URL: Repository, https://github.com/NHSDigital/data-validation-engine.gi
|
|
|
37
37
|
Description-Content-Type: text/markdown
|
|
38
38
|
|
|
39
39
|
<h1 style="display: flex; align-items: center; gap: 10px;">
|
|
40
|
-
<img src="https://
|
|
40
|
+
<img src="https://raw.githubusercontent.com/NHSDigital/data-validation-engine/616b55890306db4546177f7effac48ca241857ec/overrides/.icons/nhseng.svg" alt="" width="5%" height="100%" align="left">
|
|
41
41
|
Data Validation Engine
|
|
42
42
|
</h1>
|
|
43
43
|
|
|
@@ -47,49 +47,27 @@ Description-Content-Type: text/markdown
|
|
|
47
47
|
[](https://github.com/NHSDigital/data-validation-engine/actions/workflows/ci_testing.yml)
|
|
48
48
|
[](https://github.com/NHSDigital/data-validation-engine/actions/workflows/ci_linting.yml)
|
|
49
49
|
|
|
50
|
-
The Data Validation Engine (DVE) is a configuration
|
|
50
|
+
The Data Validation Engine (DVE) is a configuration-driven data validation library created and used by NHS England. It lets users define validation rules once and apply them across multiple dataset collections - supporting consistent, accurate data checks.
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
__The DVE offers__:
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
- SQL configuration-based validations
|
|
55
|
+
- Format normalization to Parquet for a unified data representation
|
|
56
|
+
- Data modelling and typecasting
|
|
57
|
+
- Business-rule validations executed on supported backends such as Spark and DuckDB, with the option to add custom backends
|
|
58
|
+
- Deriving new fields and entities
|
|
59
|
+
- Clear validation reporting, including summary insights and record-level error messages
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
| -- | ------- | ------- |
|
|
58
|
-
| 1. | File Transformation | This service will take submitted files and turn them into stringified parquet file(s) to ensure that a consistent data structure can be passed through the other services. |
|
|
59
|
-
| 2. | Data Contract | This service will validate and perform type casting against a stringified parquet file using [pydantic models](https://docs.pydantic.dev/1.10/). |
|
|
60
|
-
| 3. | Business Rules | The business rules service will perform more complex validations such as comparisons between fields and tables, aggregations, filters etc to generate new entities. |
|
|
61
|
-
| 4. | Error Reports | The error reports service will take all the errors raised in previous services and surface them into a readable format for a downstream users/service. Currently, this implemented to be an excel spreadsheet but could be reconfigured to meet other requirements/use cases. |
|
|
62
|
-
|
|
63
|
-
If you'd like more detailed documentation around these services the please read the extended documentation [here](https://nhsdigital.github.io/data-validation-engine/).
|
|
64
|
-
|
|
65
|
-
The DVE has been designed in a way that's modular and can support users who just want to utilise specific "services" from the DVE (i.e. just the file transformation + data contract). Additionally, the DVE is designed to support different backend implementations. As part of the base installation of DVE, you will find backend support for `Spark` and `DuckDB`. So, if you need a `MySQL` backend implementation, you can implement this yourself. Given our organisations requirements, it will be unlikely that we add anymore specific backend implementations into the base package beyond Spark and DuckDB. So, if you are unable to implement this yourself, I would recommend reading the guidance on [requesting new features and raising bug reports here](#requesting-new-features-and-raising-bug-reports).
|
|
66
|
-
|
|
67
|
-
Additionally, if you'd like to contribute a new backend implementation into the base DVE package, then please look at the [Contributing](#Contributing) section.
|
|
61
|
+
As mentioned above, the DVE is "configuration driven" which means the majority of development for you as a user will be building a JSON document to describe how the data will be validated. The JSON document is known as a `dischema` (data ingest schema) file and example files can be accessed [here ↗️](https://github.com/NHSDigital/data-validation-engine/tree/main/tests/testdata). If you'd like to learn more about JSON document and how to build one from scratch, then please read the documentation [here ↗️](https://nhsdigital.github.io/data-validation-engine/).
|
|
68
62
|
|
|
69
63
|
## Installation and usage
|
|
70
64
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
If you're planning to use the Spark backend implementation, you will also need OpenJDK 11 installed.
|
|
74
|
-
|
|
75
|
-
Python dependencies are listed in `pyproject.toml`.
|
|
76
|
-
|
|
77
|
-
To install the DVE package you can simply install using a package manager such as [pip](https://pypi.org/project/pip/).
|
|
78
|
-
|
|
79
|
-
```
|
|
80
|
-
pip install data-validation-engine
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
*Note - Only versions >=0.6.2 are available on PyPi. For older versions please install directly from the git repo or build from source.*
|
|
84
|
-
|
|
85
|
-
Once you have installed the DVE you are ready to use it. For guidance on how to create your dischema JSON document (configuration), please read the [documentation](https://nhsdigital.github.io/data-validation-engine/).
|
|
86
|
-
|
|
87
|
-
Version 0.0.1 does support a working Python 3.7 installation. However, we will not be supporting any issues with that version of the DVE if you choose to use it. __Use at your own risk__.
|
|
65
|
+
Please see the documentation [here ↗️](https://nhsdigital.github.io/data-validation-engine/user_guidance/install/).
|
|
88
66
|
|
|
89
67
|
## Requesting new features and raising bug reports
|
|
90
68
|
**Before creating new issues, please check to see if the same bug/feature has been created already. Where a duplicate is created, the ticket will be closed and referenced to an existing issue.**
|
|
91
69
|
|
|
92
|
-
If you have spotted a bug with the DVE then please raise an issue [here](https://github.com/nhsengland/Data-Validation-Engine/issues) using the "bug template".
|
|
70
|
+
If you have spotted a bug with the DVE then please raise an issue [here ↗️](https://github.com/nhsengland/Data-Validation-Engine/issues) using the "bug template".
|
|
93
71
|
|
|
94
72
|
If you have feature request then please follow the same process whilst using the "Feature request template".
|
|
95
73
|
|
|
@@ -101,7 +79,7 @@ Below is a list of features that we would like to implement or have been request
|
|
|
101
79
|
| Uplift to Python 3.11 | 0.2.0 | Yes |
|
|
102
80
|
| Uplift Pyspark to 3.5 | 0.8.0 | Yes |
|
|
103
81
|
| Allow DVE to run on Python 3.12+ | 0.8.0 | Yes |
|
|
104
|
-
| Upgrade to Pydantic 2.0 | 0.9.0 |
|
|
82
|
+
| Upgrade to Pydantic 2.0 | 0.9.0 | Yes |
|
|
105
83
|
| Uplift Pyspark to 4.0+ | TBA | No |
|
|
106
84
|
| Polars upgrade to v1+ | TBA | No |
|
|
107
85
|
| DuckDB upgrade to v1.5+ | TBA | No |
|
|
@@ -111,7 +89,7 @@ Below is a list of features that we would like to implement or have been request
|
|
|
111
89
|
If you are interested in getting any of the unreleased features listed above available, then please read the [Contributing](#Contributing) section and then submit us a pull request.
|
|
112
90
|
|
|
113
91
|
## Contributing
|
|
114
|
-
Please see guidance [here](https://github.com/NHSDigital/data-validation-engine/blob/main/CONTRIBUTE.md).
|
|
92
|
+
Please see guidance [here ↗️](https://github.com/NHSDigital/data-validation-engine/blob/main/CONTRIBUTE.md).
|
|
115
93
|
|
|
116
94
|
## Legal
|
|
117
95
|
This codebase is released under the MIT License. This covers both the codebase and any sample code in the documentation.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<h1 style="display: flex; align-items: center; gap: 10px;">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/NHSDigital/data-validation-engine/616b55890306db4546177f7effac48ca241857ec/overrides/.icons/nhseng.svg" alt="" width="5%" height="100%" align="left">
|
|
3
|
+
Data Validation Engine
|
|
4
|
+
</h1>
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
[](https://github.com/NHSDigital/data-validation-engine/actions/workflows/ci_testing.yml)
|
|
10
|
+
[](https://github.com/NHSDigital/data-validation-engine/actions/workflows/ci_linting.yml)
|
|
11
|
+
|
|
12
|
+
The Data Validation Engine (DVE) is a configuration-driven data validation library created and used by NHS England. It lets users define validation rules once and apply them across multiple dataset collections - supporting consistent, accurate data checks.
|
|
13
|
+
|
|
14
|
+
__The DVE offers__:
|
|
15
|
+
|
|
16
|
+
- SQL configuration-based validations
|
|
17
|
+
- Format normalization to Parquet for a unified data representation
|
|
18
|
+
- Data modelling and typecasting
|
|
19
|
+
- Business-rule validations executed on supported backends such as Spark and DuckDB, with the option to add custom backends
|
|
20
|
+
- Deriving new fields and entities
|
|
21
|
+
- Clear validation reporting, including summary insights and record-level error messages
|
|
22
|
+
|
|
23
|
+
As mentioned above, the DVE is "configuration driven" which means the majority of development for you as a user will be building a JSON document to describe how the data will be validated. The JSON document is known as a `dischema` (data ingest schema) file and example files can be accessed [here ↗️](https://github.com/NHSDigital/data-validation-engine/tree/main/tests/testdata). If you'd like to learn more about JSON document and how to build one from scratch, then please read the documentation [here ↗️](https://nhsdigital.github.io/data-validation-engine/).
|
|
24
|
+
|
|
25
|
+
## Installation and usage
|
|
26
|
+
|
|
27
|
+
Please see the documentation [here ↗️](https://nhsdigital.github.io/data-validation-engine/user_guidance/install/).
|
|
28
|
+
|
|
29
|
+
## Requesting new features and raising bug reports
|
|
30
|
+
**Before creating new issues, please check to see if the same bug/feature has been created already. Where a duplicate is created, the ticket will be closed and referenced to an existing issue.**
|
|
31
|
+
|
|
32
|
+
If you have spotted a bug with the DVE then please raise an issue [here ↗️](https://github.com/nhsengland/Data-Validation-Engine/issues) using the "bug template".
|
|
33
|
+
|
|
34
|
+
If you have feature request then please follow the same process whilst using the "Feature request template".
|
|
35
|
+
|
|
36
|
+
## Upcoming features
|
|
37
|
+
Below is a list of features that we would like to implement or have been requested.
|
|
38
|
+
| Feature | Release Version | Released? |
|
|
39
|
+
| ------------------------------------------------------------------------------- | ----------------- | --------- |
|
|
40
|
+
| Open source release | 0.1.0 | Yes |
|
|
41
|
+
| Uplift to Python 3.11 | 0.2.0 | Yes |
|
|
42
|
+
| Uplift Pyspark to 3.5 | 0.8.0 | Yes |
|
|
43
|
+
| Allow DVE to run on Python 3.12+ | 0.8.0 | Yes |
|
|
44
|
+
| Upgrade to Pydantic 2.0 | 0.9.0 | Yes |
|
|
45
|
+
| Uplift Pyspark to 4.0+ | TBA | No |
|
|
46
|
+
| Polars upgrade to v1+ | TBA | No |
|
|
47
|
+
| DuckDB upgrade to v1.5+ | TBA | No |
|
|
48
|
+
| Python 3.13 & 3.14 upgrade | TBA | No |
|
|
49
|
+
| Create a more user friendly interface for building and modifying dischema files | TBA | No |
|
|
50
|
+
|
|
51
|
+
If you are interested in getting any of the unreleased features listed above available, then please read the [Contributing](#Contributing) section and then submit us a pull request.
|
|
52
|
+
|
|
53
|
+
## Contributing
|
|
54
|
+
Please see guidance [here ↗️](https://github.com/NHSDigital/data-validation-engine/blob/main/CONTRIBUTE.md).
|
|
55
|
+
|
|
56
|
+
## Legal
|
|
57
|
+
This codebase is released under the MIT License. This covers both the codebase and any sample code in the documentation.
|
|
58
|
+
|
|
59
|
+
Any HTML or Markdown documentation is [© Crown copyright](https://www.nationalarchives.gov.uk/information-management/re-using-public-sector-information/uk-government-licensing-framework/crown-copyright/) and available under the terms of the [Open Government 3.0 licence](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/).
|
|
@@ -11,7 +11,7 @@ classifiers = [
|
|
|
11
11
|
"Programming Language :: Python :: 3.10",
|
|
12
12
|
"Programming Language :: Python :: 3.11",
|
|
13
13
|
"Programming Language :: Python :: 3.12",
|
|
14
|
-
"Operating System ::
|
|
14
|
+
"Operating System :: POSIX :: Linux",
|
|
15
15
|
"Topic :: Software Development :: Libraries",
|
|
16
16
|
"Typing :: Typed",
|
|
17
17
|
]
|
|
@@ -25,7 +25,7 @@ Issues = "https://github.com/NHSDigital/data-validation-engine/issues"
|
|
|
25
25
|
Changelog = "https://github.com/NHSDigital/data-validation-engine/blob/main/CHANGELOG.md"
|
|
26
26
|
|
|
27
27
|
[tool.poetry]
|
|
28
|
-
version = "0.
|
|
28
|
+
version = "0.9.0"
|
|
29
29
|
packages = [
|
|
30
30
|
{ include = "dve", from = "src" },
|
|
31
31
|
]
|
|
@@ -43,7 +43,7 @@ openpyxl = "3.1.5"
|
|
|
43
43
|
pandas = "2.3.3"
|
|
44
44
|
polars = "0.20.31"
|
|
45
45
|
pyarrow = "23.0.1"
|
|
46
|
-
pydantic = "
|
|
46
|
+
pydantic = "2.13.4"
|
|
47
47
|
pyspark = ">=3.5.0,<=3.5.5"
|
|
48
48
|
typing_extensions = "4.15.0"
|
|
49
49
|
urllib3 = "2.7.0" # dependency of boto3 & botocore
|
|
@@ -84,7 +84,8 @@ black = "26.3.1"
|
|
|
84
84
|
astroid = "3.3.9"
|
|
85
85
|
isort = "5.13.2"
|
|
86
86
|
pylint = "3.3.9"
|
|
87
|
-
mypy = "1.
|
|
87
|
+
mypy = "1.20.2"
|
|
88
|
+
librt = "0.11.0" # mypy dependency
|
|
88
89
|
boto3-stubs = {extras = ["essential"], version = "1.26.72"}
|
|
89
90
|
botocore-stubs = "1.29.72"
|
|
90
91
|
pandas-stubs = "1.2.0.62"
|
|
@@ -291,7 +292,7 @@ redefining-builtins-modules = [
|
|
|
291
292
|
|
|
292
293
|
[tool.commitizen]
|
|
293
294
|
name = "cz_conventional_commits"
|
|
294
|
-
tag_format = "v$major.$minor.$patch"
|
|
295
|
+
tag_format = "v$major.$minor.${patch}$prerelease"
|
|
295
296
|
version_scheme = "pep440"
|
|
296
297
|
version_provider = "poetry"
|
|
297
298
|
update_changelog_on_bump = true
|
|
@@ -15,7 +15,7 @@ from queue import Queue as ThreadQueue
|
|
|
15
15
|
from types import TracebackType
|
|
16
16
|
from typing import Any, ClassVar, Generic, Optional, TypeVar, Union
|
|
17
17
|
|
|
18
|
-
from pydantic import ValidationError,
|
|
18
|
+
from pydantic import ValidationError, validate_call
|
|
19
19
|
from typing_extensions import Literal, get_origin
|
|
20
20
|
|
|
21
21
|
from dve.core_engine.models import (
|
|
@@ -99,8 +99,8 @@ class BaseAuditor(Generic[AuditReturnType]):
|
|
|
99
99
|
def schema(self) -> dict[str, type]:
|
|
100
100
|
"""Determine python schema of auditor"""
|
|
101
101
|
return {
|
|
102
|
-
fld: str if get_origin(mdl.
|
|
103
|
-
for fld, mdl in self._record_type.
|
|
102
|
+
fld: str if get_origin(mdl.annotation) == Literal else mdl.annotation # type: ignore
|
|
103
|
+
for fld, mdl in self._record_type.model_fields.items()
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
@staticmethod
|
|
@@ -196,7 +196,7 @@ class BaseAuditingManager(
|
|
|
196
196
|
"""Convert AuditReturnType to iterable of dictionaries"""
|
|
197
197
|
raise NotImplementedError()
|
|
198
198
|
|
|
199
|
-
@
|
|
199
|
+
@validate_call
|
|
200
200
|
def add_processing_records(self, processing_records: list[ProcessingStatusRecord]):
|
|
201
201
|
"""Add an entry to the processing_status auditor."""
|
|
202
202
|
if self.pool:
|
|
@@ -208,7 +208,7 @@ class BaseAuditingManager(
|
|
|
208
208
|
records=[dict(rec) for rec in processing_records]
|
|
209
209
|
)
|
|
210
210
|
|
|
211
|
-
@
|
|
211
|
+
@validate_call
|
|
212
212
|
def add_submission_statistics_records(self, sub_stats: list[SubmissionStatisticsRecord]):
|
|
213
213
|
"""Add an entry to the submission statistics auditor."""
|
|
214
214
|
if self.pool:
|
|
@@ -218,7 +218,7 @@ class BaseAuditingManager(
|
|
|
218
218
|
)
|
|
219
219
|
return self._submission_statistics.add_records(records=[dict(rec) for rec in sub_stats])
|
|
220
220
|
|
|
221
|
-
@
|
|
221
|
+
@validate_call
|
|
222
222
|
def add_transfer_records(self, transfer_records: list[TransferRecord]):
|
|
223
223
|
"""Add an entry to the transfers auditor"""
|
|
224
224
|
if self.pool:
|
|
@@ -227,7 +227,7 @@ class BaseAuditingManager(
|
|
|
227
227
|
)
|
|
228
228
|
return self._transfers.add_records(records=[dict(rec) for rec in transfer_records])
|
|
229
229
|
|
|
230
|
-
@
|
|
230
|
+
@validate_call
|
|
231
231
|
def add_new_submissions(
|
|
232
232
|
self,
|
|
233
233
|
submissions: list[SubmissionMetadata],
|
|
@@ -250,7 +250,7 @@ class BaseAuditingManager(
|
|
|
250
250
|
processing_status="received",
|
|
251
251
|
job_run_id=job_run_id,
|
|
252
252
|
**ts_info,
|
|
253
|
-
).
|
|
253
|
+
).model_dump(),
|
|
254
254
|
}
|
|
255
255
|
processing_status_recs.append(processing_rec)
|
|
256
256
|
if sub_info:
|
|
@@ -14,10 +14,10 @@ from dve.core_engine.backends.base.auditing import (
|
|
|
14
14
|
OrderCriteria,
|
|
15
15
|
)
|
|
16
16
|
from dve.core_engine.backends.implementations.duckdb.duckdb_helpers import (
|
|
17
|
-
|
|
17
|
+
get_duckdb_type_from_annotation,
|
|
18
18
|
table_exists,
|
|
19
19
|
)
|
|
20
|
-
from dve.core_engine.backends.utilities import
|
|
20
|
+
from dve.core_engine.backends.utilities import get_polars_type_from_annotation
|
|
21
21
|
from dve.core_engine.models import (
|
|
22
22
|
AuditRecord,
|
|
23
23
|
ProcessingStatusRecord,
|
|
@@ -62,7 +62,10 @@ class DDBAuditor(BaseAuditor[DuckDBPyRelation]):
|
|
|
62
62
|
"""Generate create table sql script for auditor"""
|
|
63
63
|
_sql_expression = f"CREATE TABLE {self._name} ("
|
|
64
64
|
_sql_expression += ", ".join(
|
|
65
|
-
[
|
|
65
|
+
[
|
|
66
|
+
f"{fld} {get_duckdb_type_from_annotation(dtype)}"
|
|
67
|
+
for fld, dtype in self.schema.items()
|
|
68
|
+
]
|
|
66
69
|
)
|
|
67
70
|
_sql_expression += ")"
|
|
68
71
|
return _sql_expression
|
|
@@ -70,10 +73,7 @@ class DDBAuditor(BaseAuditor[DuckDBPyRelation]):
|
|
|
70
73
|
@property
|
|
71
74
|
def polars_schema(self) -> dict[str, PolarsType]:
|
|
72
75
|
"""Get polars dataframe schema for auditor"""
|
|
73
|
-
return {
|
|
74
|
-
fld: PYTHON_TYPE_TO_POLARS_TYPE.get(dtype, pl.Utf8) # type: ignore
|
|
75
|
-
for fld, dtype in self.schema.items()
|
|
76
|
-
}
|
|
76
|
+
return {fld: get_polars_type_from_annotation(dtype) for fld, dtype in self.schema.items()}
|
|
77
77
|
|
|
78
78
|
def get_relation(self) -> DuckDBPyRelation:
|
|
79
79
|
"""Get a relation to interact with the auditor duckdb table"""
|
|
@@ -106,7 +106,7 @@ class DDBAuditor(BaseAuditor[DuckDBPyRelation]):
|
|
|
106
106
|
"""Convert a list of audit records to a relation"""
|
|
107
107
|
# pylint: disable=W0612
|
|
108
108
|
rec_df = pl.DataFrame( # type: ignore
|
|
109
|
-
[rec.
|
|
109
|
+
[rec.model_dump() for rec in recs],
|
|
110
110
|
schema=self.polars_schema,
|
|
111
111
|
)
|
|
112
112
|
return self._connection.sql("select * from rec_df")
|
|
@@ -14,7 +14,6 @@ from duckdb import DuckDBPyConnection, DuckDBPyRelation
|
|
|
14
14
|
from duckdb.typing import DuckDBPyType
|
|
15
15
|
from polars.datatypes.classes import DataTypeClass as PolarsType
|
|
16
16
|
from pydantic import BaseModel
|
|
17
|
-
from pydantic.fields import ModelField
|
|
18
17
|
|
|
19
18
|
import dve.parser.file_handling as fh
|
|
20
19
|
from dve.common.error_utils import (
|
|
@@ -96,8 +95,8 @@ class DuckDBDataContract(BaseDataContract[DuckDBPyRelation]):
|
|
|
96
95
|
) -> DuckDBPyRelation:
|
|
97
96
|
"""Create DuckDB Relation from iterator of records"""
|
|
98
97
|
polars_schema: dict[str, PolarsType] = {
|
|
99
|
-
|
|
100
|
-
for fld in stringify_model(schema).
|
|
98
|
+
name: get_polars_type_from_annotation(fld.annotation)
|
|
99
|
+
for name, fld in stringify_model(schema).model_fields.items()
|
|
101
100
|
}
|
|
102
101
|
_lazy_df = pl.LazyFrame(records, polars_schema) # type: ignore # pylint: disable=unused-variable
|
|
103
102
|
return self._connection.sql("select * from _lazy_df")
|
|
@@ -130,17 +129,15 @@ class DuckDBDataContract(BaseDataContract[DuckDBPyRelation]):
|
|
|
130
129
|
) as msg_writer:
|
|
131
130
|
for entity_name, relation in entities.items():
|
|
132
131
|
# get dtypes for all fields -> python data types or use with relation
|
|
133
|
-
entity_fields
|
|
134
|
-
entity_name
|
|
135
|
-
].__fields__
|
|
132
|
+
entity_fields = contract_metadata.schemas[entity_name].model_fields
|
|
136
133
|
ddb_schema: dict[str, DuckDBPyType] = {
|
|
137
|
-
|
|
138
|
-
for fld in entity_fields.
|
|
134
|
+
name: get_duckdb_type_from_annotation(fld.annotation)
|
|
135
|
+
for name, fld in entity_fields.items()
|
|
139
136
|
}
|
|
140
137
|
ddb_schema[RECORD_INDEX_COLUMN_NAME] = get_duckdb_type_from_annotation(int)
|
|
141
138
|
polars_schema: dict[str, PolarsType] = {
|
|
142
|
-
|
|
143
|
-
for fld in entity_fields.
|
|
139
|
+
name: get_polars_type_from_annotation(fld.annotation)
|
|
140
|
+
for name, fld in entity_fields.items()
|
|
144
141
|
}
|
|
145
142
|
polars_schema[RECORD_INDEX_COLUMN_NAME] = get_polars_type_from_annotation(int)
|
|
146
143
|
if relation_is_empty(relation):
|
|
@@ -8,7 +8,7 @@ from dataclasses import is_dataclass
|
|
|
8
8
|
from datetime import date, datetime, time
|
|
9
9
|
from decimal import Decimal
|
|
10
10
|
from pathlib import Path
|
|
11
|
-
from typing import Any, ClassVar, Union
|
|
11
|
+
from typing import Any, ClassVar, Literal, Union
|
|
12
12
|
from urllib.parse import urlparse
|
|
13
13
|
|
|
14
14
|
import duckdb.typing as ddbtyp
|
|
@@ -129,8 +129,8 @@ def get_duckdb_type_from_annotation(type_annotation: Any) -> DuckDBPyType:
|
|
|
129
129
|
'optional' wrapper and return the inner type
|
|
130
130
|
- A subclass of `typing.TypedDict` with values typed using supported types. This
|
|
131
131
|
will parse the value types as Polars types and return a duckdb STRUCT.
|
|
132
|
-
- A dataclass or `pydantic.
|
|
133
|
-
This will parse the field types as
|
|
132
|
+
- A dataclass or `pydantic.BaseModel` with values typed using supported types.
|
|
133
|
+
This will parse the field types as duckdb types and return a duckdb STRUCT.
|
|
134
134
|
- Any supported type, with a `typing_extensions.Annotated` wrapper.
|
|
135
135
|
|
|
136
136
|
Any `ClassVar` types within `TypedDict`s, dataclasses, or `pydantic` models will be
|
|
@@ -139,6 +139,14 @@ def get_duckdb_type_from_annotation(type_annotation: Any) -> DuckDBPyType:
|
|
|
139
139
|
"""
|
|
140
140
|
type_origin = get_origin(type_annotation)
|
|
141
141
|
|
|
142
|
+
if type_origin is Literal:
|
|
143
|
+
ddb_types = [get_duckdb_type_from_annotation(type(t)) for t in get_args(type_annotation)]
|
|
144
|
+
if not ddb_types or not all(t == ddb_types[0] for t in ddb_types):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"Unable to determine a single concrete type for Literal. Got {type_annotation!r}"
|
|
147
|
+
)
|
|
148
|
+
return ddb_types[0]
|
|
149
|
+
|
|
142
150
|
# An `Optional` or `Union` type, check to ensure non-heterogenity.
|
|
143
151
|
if type_origin is Union:
|
|
144
152
|
python_type = _get_non_heterogenous_type(get_args(type_annotation))
|
|
@@ -111,8 +111,8 @@ class DuckDBCSVReader(CSVFileReader):
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
ddb_schema: dict[str, SQLType] = {
|
|
114
|
-
|
|
115
|
-
for fld in schema.
|
|
114
|
+
name: str(get_duckdb_type_from_annotation(fld.annotation)) # type: ignore
|
|
115
|
+
for name, fld in schema.model_fields.items()
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
reader_options["columns"] = ddb_schema
|
|
@@ -166,8 +166,8 @@ class PolarsToDuckDBCSVReader(DuckDBCSVReader):
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
polars_types = {
|
|
169
|
-
|
|
170
|
-
for fld in schema.
|
|
169
|
+
name: get_polars_type_from_annotation(fld.annotation) # type: ignore
|
|
170
|
+
for name, fld in schema.model_fields.items()
|
|
171
171
|
}
|
|
172
172
|
reader_options["dtypes"] = polars_types
|
|
173
173
|
|
|
@@ -56,8 +56,8 @@ class DuckDBJSONReader(BaseFileReader):
|
|
|
56
56
|
"""Returns a relation object from the source json"""
|
|
57
57
|
|
|
58
58
|
ddb_schema: dict[str, SQLType] = {
|
|
59
|
-
|
|
60
|
-
for fld in schema.
|
|
59
|
+
name: str(get_duckdb_type_from_annotation(fld.annotation)) # type: ignore
|
|
60
|
+
for name, fld in schema.model_fields.items()
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
return self.add_record_index(
|
|
@@ -47,8 +47,8 @@ class DuckDBXMLStreamReader(XMLStreamReader):
|
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
polars_schema: dict[str, pl.DataType] = { # type: ignore
|
|
50
|
-
|
|
51
|
-
for fld in stringify_model(schema).
|
|
50
|
+
name: get_polars_type_from_annotation(fld.annotation)
|
|
51
|
+
for name, fld in stringify_model(schema).model_fields.items()
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
_lazy_frame = self.add_record_index(
|
|
@@ -116,7 +116,7 @@ class SparkAuditor(BaseAuditor[DataFrame]):
|
|
|
116
116
|
def conv_to_entity(self, recs: list[AuditRecord]) -> DataFrame:
|
|
117
117
|
"""Convert the dataframe to an iterable of the related audit record"""
|
|
118
118
|
return self._spark.createDataFrame( # type: ignore
|
|
119
|
-
[rec.
|
|
119
|
+
[rec.model_dump() for rec in recs], schema=self.spark_schema
|
|
120
120
|
)
|
|
121
121
|
|
|
122
122
|
def add_records(self, records: Iterable[dict[str, Any]]):
|
|
@@ -92,5 +92,5 @@ class SparkBackend(BaseBackend[DataFrame]):
|
|
|
92
92
|
|
|
93
93
|
def convert_submission_info(self, submission_info: SubmissionInfo) -> DataFrame:
|
|
94
94
|
return self.spark_session.createDataFrame( # type: ignore
|
|
95
|
-
[submission_info.
|
|
95
|
+
[submission_info.model_dump()], schema=get_type_from_annotation(type(submission_info))
|
|
96
96
|
)
|
|
@@ -7,7 +7,7 @@ from typing import Any, Optional
|
|
|
7
7
|
from uuid import uuid4
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
|
-
from pydantic.fields import
|
|
10
|
+
from pydantic.fields import FieldInfo
|
|
11
11
|
from pyspark.sql import DataFrame, SparkSession
|
|
12
12
|
from pyspark.sql import functions as sf
|
|
13
13
|
from pyspark.sql.functions import col, lit
|
|
@@ -104,7 +104,9 @@ class SparkDataContract(BaseDataContract[DataFrame]):
|
|
|
104
104
|
|
|
105
105
|
successful = True
|
|
106
106
|
for entity_name, record_df in entities.items():
|
|
107
|
-
entity_fields: dict[str,
|
|
107
|
+
entity_fields: dict[str, FieldInfo] = contract_metadata.schemas[
|
|
108
|
+
entity_name
|
|
109
|
+
].model_fields
|
|
108
110
|
spark_schema = get_type_from_annotation(contract_metadata.schemas[entity_name])
|
|
109
111
|
spark_schema.add(StructField(RECORD_INDEX_COLUMN_NAME, LongType()))
|
|
110
112
|
if df_is_empty(record_df):
|
|
@@ -147,19 +149,17 @@ class SparkDataContract(BaseDataContract[DataFrame]):
|
|
|
147
149
|
self.logger.info(f"Data contract found {msg_count} issues in {entity_name}")
|
|
148
150
|
|
|
149
151
|
try:
|
|
150
|
-
# TODO: will need to revisit in pydantic v2 bump as model field no longer available
|
|
151
152
|
record_df = record_df.select(
|
|
152
153
|
*[
|
|
153
154
|
(
|
|
154
155
|
get_spark_cast_statement_from_annotation(
|
|
155
|
-
fld,
|
|
156
|
+
fld, fld_info.annotation
|
|
156
157
|
).alias(fld)
|
|
157
158
|
if fld in record_df.columns
|
|
158
159
|
else lit(None).cast(
|
|
159
|
-
get_type_from_annotation(
|
|
160
|
-
)
|
|
160
|
+
get_type_from_annotation(fld_info.annotation)).alias(fld)
|
|
161
161
|
)
|
|
162
|
-
for fld,
|
|
162
|
+
for fld, fld_info in entity_fields.items()
|
|
163
163
|
],
|
|
164
164
|
col(RECORD_INDEX_COLUMN_NAME).cast(LongType()).alias(RECORD_INDEX_COLUMN_NAME),
|
|
165
165
|
)
|
|
@@ -13,11 +13,10 @@ from dataclasses import dataclass, is_dataclass
|
|
|
13
13
|
from decimal import Decimal
|
|
14
14
|
from functools import wraps
|
|
15
15
|
from pathlib import Path
|
|
16
|
-
from typing import Any, ClassVar, Optional, TypeVar, Union, overload
|
|
16
|
+
from typing import Any, ClassVar, Literal, Optional, TypeVar, Union, overload
|
|
17
17
|
|
|
18
18
|
from delta.exceptions import ConcurrentAppendException, DeltaConcurrentModificationException
|
|
19
19
|
from pydantic import BaseModel
|
|
20
|
-
from pydantic.types import ConstrainedDecimal
|
|
21
20
|
from pyspark.sql import DataFrame, Row, SparkSession
|
|
22
21
|
from pyspark.sql import functions as sf
|
|
23
22
|
from pyspark.sql import types as st
|
|
@@ -52,6 +51,7 @@ FourArgWrapped = Callable[[Column, Column, Column, Column], Column]
|
|
|
52
51
|
"""A wrapped function (Spark UDF) taking four args."""
|
|
53
52
|
|
|
54
53
|
|
|
54
|
+
# TODO - lets see if we can bin this off as it's a bit overkill
|
|
55
55
|
@dataclass(frozen=True)
|
|
56
56
|
class DecimalConfig:
|
|
57
57
|
"""Configuration for a Python decimal to enable it to be mapped to a
|
|
@@ -64,13 +64,13 @@ class DecimalConfig:
|
|
|
64
64
|
|
|
65
65
|
"""
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
max_digits: int = 38
|
|
68
68
|
"""
|
|
69
69
|
The precision of the decimal. This is the total number of digits in the
|
|
70
70
|
decimal.
|
|
71
71
|
|
|
72
72
|
"""
|
|
73
|
-
|
|
73
|
+
decimal_places: int = 18
|
|
74
74
|
"""
|
|
75
75
|
The scale of the decimal. This is the number of digits to the right of the
|
|
76
76
|
decimal point.
|
|
@@ -78,10 +78,12 @@ class DecimalConfig:
|
|
|
78
78
|
"""
|
|
79
79
|
|
|
80
80
|
def __post_init__(self):
|
|
81
|
-
if not 0 < self.
|
|
82
|
-
raise ValueError("
|
|
83
|
-
if not 0 <= self.
|
|
84
|
-
raise ValueError(
|
|
81
|
+
if not 0 < self.max_digits <= 38:
|
|
82
|
+
raise ValueError("Max digits must be between 1 and 38 (inclusive)")
|
|
83
|
+
if not 0 <= self.decimal_places <= self.max_digits:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
"Decimal Places must be between 0 and the specified number of digits (inclusive)"
|
|
86
|
+
)
|
|
85
87
|
|
|
86
88
|
|
|
87
89
|
DEFAULT_DECIMAL_CONFIG = DecimalConfig()
|
|
@@ -96,7 +98,9 @@ PYTHON_TYPE_TO_SPARK_TYPE: dict[type, st.DataType] = {
|
|
|
96
98
|
bytes: st.BinaryType(),
|
|
97
99
|
dt.date: st.DateType(),
|
|
98
100
|
dt.datetime: st.TimestampType(),
|
|
99
|
-
Decimal: st.DecimalType(
|
|
101
|
+
Decimal: st.DecimalType(
|
|
102
|
+
DEFAULT_DECIMAL_CONFIG.max_digits, DEFAULT_DECIMAL_CONFIG.decimal_places
|
|
103
|
+
),
|
|
100
104
|
}
|
|
101
105
|
"""A mapping of Python types to the equivalent Spark types."""
|
|
102
106
|
|
|
@@ -164,7 +168,7 @@ def get_type_from_annotation(type_annotation: Any) -> st.DataType:
|
|
|
164
168
|
'optional' wrapper and return the inner type (Spark types are all nullable).
|
|
165
169
|
- A subclass of `typing.TypedDict` with values typed using supported types. This
|
|
166
170
|
will parse the value types as Spark types and return a Spark `StructType`.
|
|
167
|
-
- A dataclass or `pydantic.
|
|
171
|
+
- A dataclass or `pydantic.BaseModel` with values typed using supported types.
|
|
168
172
|
This will parse the field types as Spark types and return a Spark `StructType`.
|
|
169
173
|
- Any supported type, with a `typing_extensions.Annotated` wrapper.
|
|
170
174
|
- A `decimal.Decimal` wrapped with `typing_extensions.Annotated` with a `DecimalConfig`
|
|
@@ -178,6 +182,14 @@ def get_type_from_annotation(type_annotation: Any) -> st.DataType:
|
|
|
178
182
|
"""
|
|
179
183
|
type_origin = get_origin(type_annotation)
|
|
180
184
|
|
|
185
|
+
if type_origin is Literal:
|
|
186
|
+
types = [get_type_from_annotation(type(t)) for t in get_args(type_annotation)]
|
|
187
|
+
if not types or not all(t == types[0] for t in types):
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Unable to determine a single concrete type for Literal. Got {type_annotation!r}"
|
|
190
|
+
)
|
|
191
|
+
return types[0]
|
|
192
|
+
|
|
181
193
|
# An `Optional` or `Union` type, check to ensure non-heterogenity.
|
|
182
194
|
if type_origin is Union:
|
|
183
195
|
python_type = _get_non_heterogenous_type(get_args(type_annotation))
|
|
@@ -194,13 +206,13 @@ def get_type_from_annotation(type_annotation: Any) -> st.DataType:
|
|
|
194
206
|
if python_type is not Decimal:
|
|
195
207
|
return get_type_from_annotation(python_type)
|
|
196
208
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
except StopIteration:
|
|
209
|
+
config_options = [arg for arg in other_args if hasattr(arg, "max_digits")]
|
|
210
|
+
if config_options:
|
|
211
|
+
configuration = config_options[0]
|
|
212
|
+
else:
|
|
202
213
|
configuration = DEFAULT_DECIMAL_CONFIG
|
|
203
|
-
|
|
214
|
+
|
|
215
|
+
return st.DecimalType(configuration.max_digits, configuration.decimal_places)
|
|
204
216
|
|
|
205
217
|
# Ensure that we have a concrete type at this point.
|
|
206
218
|
if not isinstance(type_annotation, type):
|
|
@@ -234,11 +246,6 @@ def get_type_from_annotation(type_annotation: Any) -> st.DataType:
|
|
|
234
246
|
|
|
235
247
|
return st.StructType(fields)
|
|
236
248
|
|
|
237
|
-
if issubclass(type_annotation, ConstrainedDecimal):
|
|
238
|
-
precision = int(type_annotation.max_digits or 38)
|
|
239
|
-
scale = int(type_annotation.decimal_places or precision)
|
|
240
|
-
return st.DecimalType(precision, scale)
|
|
241
|
-
|
|
242
249
|
if type_annotation is list:
|
|
243
250
|
raise ValueError(
|
|
244
251
|
f"list must have type annotation (e.g. `list[str]`), got {type_annotation!r}"
|