├── .gitignore ├── LICENSE ├── README.md ├── dcv ├── __init__.py ├── exceptions.py └── fields │ ├── __init__.py │ ├── abstract.py │ ├── bool.py │ ├── datetime.py │ ├── enum.py │ ├── number.py │ └── text.py └── t ├── __init__.py └── unit ├── __init__.py ├── test_bool.py ├── test_datetime.py ├── test_enum.py ├── test_field.py ├── test_number.py └── test_text.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Dataclasses Validation 2 | 3 | ### Validation for dataclasses. No dependencies. Field-specific configuration. 4 | 5 | Dataclasses are powerful, but we still need to validate incoming data. 6 | Validation libraries make you either subclass a 3rd party class or use a schema class. 7 | **Now, you can easily validate fields in dataclasses by using field-specific validation**. 8 | 9 | ## ![#f03c15](https://via.placeholder.com/15/f03c15/000000?text=+) This is a work in progress. 10 | Please check the [project board](https://github.com/rmcomplexity/dataclasses-validation/projects/1) to see pendining tasks in case there isn't a proper release yet. 11 | 12 | - [Example](#example) 13 | - [Rationale](#rationale) 14 | - [Runtime Type Hint Checking](#runtime-type-hint-checking) 15 | - [Available Fields](#available-fields) 16 | - [Custom Fields](#custom-fields) 17 | - [Future Work](#future-work) 18 | 19 | ## Example: 20 | 21 | ```python 22 | import logging 23 | from dataclasses import dataclass, field, asdict 24 | from typing import Optional 25 | from enum import Enum 26 | from dcv.fields import TextField, IntField 27 | 28 | 29 | logging.basicConfig(level=logging.INFO) 30 | 31 | 32 | @dataclass 33 | class User: 34 | # trailing/leading blank spaces will be removed 35 | name: str = TextField(min_length=1, trim=True) 36 | 37 | # 'last_name' can be None or an empty string. 38 | # Optional fields have a default value of None. 39 | last_name: Optional[str] = TextField(min_length=1, trim=" ", optional=True, blank=True) 40 | 41 | # A user cannot be born before 1800. Time travelers are not considered here :(. 42 | year_of_birth: Optional[int] = IntField(gt=1800, optional=True) 43 | 44 | # 'opt_out' has a default value of "Yes", uses a regex 45 | # and it's not used in __init__ 46 | opt_out: str = field(default=TextField(default="Yes", regex="(Yes|No)"), init=False) 47 | 48 | # Insantiation without any issues 49 | >>> user = User(name="Josué", last_name="Balandrano", year_of_birth=1985) 50 | >>> logging.info(user) 51 | ... INFO:root:User(name="Josué", last_name="Balandrano", opt_out="Yes") 52 | 53 | # We get a ValueError if we try to set an invalid value on a non-init attr. 54 | >>> user.opt_out = "Maybe" 55 | ... ValueError: 'opt_out' does not match regex: (Yes|No) . 56 | 57 | # We automatically have serialization with dataclasses 58 | >>> asdict(user) 59 | ... {'name': 'Josué', 'last_name': 'Balandrano', 'opt_out': 'Yes'} 60 | 61 | # We get a ValueError if an invalid value is used on init 62 | >>> User(name = "", last_name="Balandrano", year_of_birth=1755) 63 | ... ValueError: 'name' cannot be blank. 64 | >>> User(name = "Josué", last_name="Balandrano", year_of_birth=1775) 65 | ... ValueError: 'year_of_birth' value '1775' must be greater than 1800. 66 | ``` 67 | 68 | ## Features of `dcv` 69 | 70 | - Works with dataclasses out of the box. 71 | - Validation is implemented in descriptors and not in the class. 72 | - Validation happens when a value is assigned to an attribute, could be on `__init__` or afterwards. 73 | - Easily nest objects simply by using more dataclasses. 74 | - No need to sublcass anything. 75 | - No need to create another class to define the schema. 76 | - Basic [runtime type hint checking](#runtime-type-hint-checking). 77 | 78 | ## Rationale 79 | 80 | Current validation libraries (like [pydantic](https://pydantic-docs.helpmanual.io/)) 81 | modify classes to be aware of the data that is being stored on each instance. 82 | Some other libraries(like [marshmallow](https://marshmallow.readthedocs.io/en/stable/)) 83 | makes you use a schema (specialized class) for validation and data storage. 84 | 85 | [Python descriptors](https://docs.python.org/3/howto/descriptor.html) 86 | give us the power to specify how data is looked up, stored and deleted. 87 | And this is seameless to the main class. 88 | [Python dataclasses](https://docs.python.org/3/library/dataclasses.html) 89 | are powerfull classes tailored to hold data. 90 | `dcv` implementation leverages descriptors and dataclasses 91 | to implement a less obtrusive validation and to be able to specify 92 | which fields will be validated instead of having a one-or-nothing solution. 93 | 94 | ### Runtime type hint checking 95 | 96 | `dcv` checks typehints in two instances. 97 | 98 | First, when a field is instantiated and assigned to a dataclass field. 99 | The type hint used in the dataclass field will be used to make sure it matches 100 | the `dcv` field supported `TYPES`. 101 | 102 | Second, when a value is assigned to a dataclass attribute managed by a `dcv` field. 103 | This could happen on `__init__` or afterwards. 104 | 105 | A type hint matches a `dcv` field if the 106 | origin of the type hint is present in the `Field.TYPES` class variable or 107 | if the origin is a subclass of an object present in the `Field.TYPES` class variable. 108 | The origin is retrieved by using [`typing.get_origin`](https://docs.python.org/3/library/typing.html#typing.get_origin) 109 | 110 | If the origin cannot be retrieved then it means the type hint is a `Generic` container 111 | e.g. `Optional`, `Union`, etc. In this case the arguments of the type hint are 112 | checked against the objects in the `Field.TYPES` tuple. 113 | 114 | #### Examples 115 | 116 | - `field_name: str` - Will check if any object in `Field.TYPES` is `str` or a subclass of `str`. 117 | - `field_name: Optional[str]` - `Optional` will be discarded and `str` will be used to check values. 118 | - `field_name: List[str]` - `list` will be used to check values. 119 | - `field_name: Optional[List[int]] - `list` will be used to check values. 120 | 121 | ## Available Fields 122 | 123 | | Name | Types Supported | Implemented | Parent Field | 124 | |--------------------|----------------------------------------|------------------------|--------------------| 125 | | `TextField` | `str`, `bytes` | :heavy_check_mark: Yes | `Field` | 126 | | `NumberField` | `int`, `float`, `complex`, `Decimal` | :heavy_check_mark: Yes | `Field` | 127 | | `IntField` | `int` | :heavy_check_mark: Yes | `NumberField` | 128 | | `FloatField` | `float` | :heavy_check_mark: Yes | `NumberField` | 129 | | `ComplexField` | `complex` | :heavy_check_mark: Yes | `NumberField` | 130 | | `DecimalField` | `Decimal` | :heavy_check_mark: Yes | `NumberField` | 131 | | `EnumField` | `Enum` | :heavy_check_mark: Yes | `Field` | 132 | | `BooleanField` | `bool` | :heavy_check_mark: Yes | `Field` | 133 | | `DateTimeBaseField`| `date`, `time`, `datetime`, `timedelta`| :heavy_check_mark: Yes | `Field` | 134 | | `DateField` | `date` | :heavy_check_mark: Yes | `DateTimeBaseField`| 135 | | `TimeField` | `time` | :heavy_check_mark: Yes | `DateTimeBaseField`| 136 | | `DateTimeField` | `datetime` | :heavy_check_mark: Yes | `DateTimeBaseField`| 137 | | `TimeDeltaField` | `timedelta` | :heavy_check_mark: Yes | `DateTimeBaseField`| 138 | | `ContianerField` | `collections.abc.Container` | :x: No | | 139 | | `SequenceField` | `collections.abc.Sequence` | :x: No | | 140 | | `SetField` | `collections.abc.Set` | :x: No | | 141 | | `MappingField` | `collections.abc.Mapping` | :x: No | | 142 | 143 | ## Custom Fields 144 | 145 | #### Subclassing existing field 146 | 147 | Custom fields can be created by subclassing any of the existing ones. This is recommended when you want to 148 | have the same functionality but check for another specific value type. 149 | 150 | For instance, you might want to validate a date field but you want to use another library and not python's 151 | `datetime`: 152 | 153 | ```python 154 | 155 | from dcv.fields import DateTimeField 156 | from arrow import arrow 157 | 158 | class ArrowDTField(DateTimeField): 159 | TYPES = (arrow.Arrow,) 160 | ``` 161 | 162 | #### Subclassing abstract `Field` 163 | 164 | You can also subclass the `Field` abstract class which already implements everything a field validation descriptor 165 | needs. The only required method to implement is `validate` which accepts the value being set: 166 | 167 | ```python 168 | from dcv.fields.abstract import Field 169 | from app.models import User 170 | 171 | class UserField(Field): 172 | TYPES = (User,) 173 | 174 | def validate(self, value: User) -> None: 175 | validate_user(value) 176 | 177 | ``` 178 | 179 | ## Future Work 180 | 181 | Check the [project board](https://github.com/rmcomplexity/dataclasses-validation/projects/1) for in-flight and future work. 182 | 183 | If you have a specific question or request, please create a github issue. 184 | -------------------------------------------------------------------------------- /dcv/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logging.basicConfig(level=logging.DEBUG) 4 | -------------------------------------------------------------------------------- /dcv/exceptions.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmcomplexity/dataclasses-validation/7537bbbb585fed702f1ef449c43e4429e40a703f/dcv/exceptions.py -------------------------------------------------------------------------------- /dcv/fields/__init__.py: -------------------------------------------------------------------------------- 1 | from dcv.fields.abstract import Field, MISSING 2 | from dcv.fields.text import TextField 3 | from dcv.fields.number import ( 4 | NumberField, 5 | IntField, 6 | FloatField, 7 | DecimalField, 8 | ComplexField 9 | ) 10 | from dcv.fields.enum import EnumField 11 | from dcv.fields.bool import BoolField 12 | from dcv.fields.datetime import ( 13 | DateTimeBaseField, 14 | DateTimeField, 15 | TimeDeltaField, 16 | DateField, 17 | TimeField 18 | ) 19 | 20 | 21 | __all__ = [ 22 | "MISSING", 23 | "Field", 24 | "TextField", 25 | "NumberField", 26 | "IntField", 27 | "FloatField", 28 | "DecimalField", 29 | "ComplexField", 30 | "EnumField", 31 | "BoolField", 32 | "DateTimeBaseField", 33 | "DateTimeField", 34 | "TimeDeltaField", 35 | "DateField", 36 | "TimeField" 37 | ] 38 | -------------------------------------------------------------------------------- /dcv/fields/abstract.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC, abstractmethod, ABCMeta 3 | from typing import Any, get_origin, get_args, get_type_hints 4 | 5 | LOG = logging.getLogger(__name__) 6 | 7 | class _MISSING_TYPE: 8 | pass 9 | 10 | MISSING = _MISSING_TYPE() 11 | 12 | class Field(ABC): 13 | """Abstract Field class. 14 | 15 | Every field should inherit this class, even user defined fields. 16 | 17 | By default every field can have a default and an optional flag. 18 | 19 | If `optional` is set to true and no default value is given, 20 | `default` is set to `None`. 21 | 22 | If `default` is set, `optional` is automatically set to `True`. 23 | 24 | `TYPES` should always be a tuple of valid object types and not generics. 25 | """ 26 | __slots__ = ( 27 | 'optional', 'use_private_attr', 'default', 28 | 'public_attr_name', 'private_attr_name', '_stored_value', 29 | '_annotation' 30 | ) 31 | 32 | # Type to verify value set. 33 | TYPES: tuple = (None, ) 34 | 35 | def __init__( 36 | self, 37 | default: Any=MISSING, 38 | optional: bool=False, 39 | use_private_attr: bool=False 40 | ) -> None: 41 | self.optional = optional 42 | self.use_private_attr = use_private_attr 43 | if optional and default is MISSING: 44 | default = None 45 | 46 | if not optional and default is not MISSING: 47 | self.optional = True 48 | 49 | self.default = default 50 | self._stored_value = None 51 | self._annotation = None 52 | 53 | def __set_name__(self, owner: Any, name: str) -> None: 54 | """Store necessary values.""" 55 | self.public_attr_name = name 56 | self.private_attr_name = f"_{name}" 57 | self._annotation = get_type_hints(owner).get(name, None) 58 | self._get_annotation_valid_classes() 59 | 60 | def __get__(self, obj: Any, objtype: Any=None) -> Any: 61 | """Get value. 62 | 63 | `MISSING` is used as a sentinel to identify when a value has not been set. 64 | """ 65 | value: Any = self._get_value(obj) 66 | 67 | self._check_value_has_been_set_or_optional(value) 68 | 69 | value = self._compute_default_value(value) 70 | 71 | return value 72 | 73 | def __set__(self, obj: Any, value: Any) -> None: 74 | """Set value to private attribute. 75 | 76 | Validation and transformation happen here. 77 | A `transform` method will be called on the value. 78 | After `transform` a `validate` method will be called on the transformed value. 79 | 80 | Custom fields MUST implement `validate` but `transform` is optional. 81 | 82 | If a field is marked as optional and it has a value of None, no validation is run. 83 | """ 84 | value = self._compute_default_value(value) 85 | 86 | if not self._check_value_is_optional_none(value): 87 | value = self.transform(value) 88 | self.validate(value) 89 | 90 | self._set_value(obj, value) 91 | 92 | @abstractmethod 93 | def validate(self, value: Any) -> None: 94 | """Every field should implement this method.""" 95 | raise NotImplemented 96 | 97 | def transform(self, value: Any) -> Any: 98 | """Implement if you want to transform value after validation.""" 99 | return value 100 | 101 | def _get_value(self, obj: Any) -> Any: 102 | """Retrieve value from object. 103 | 104 | If `use_private_attr` the value will be read from `_{public_attr_name}`. 105 | Else the value will be read from `{public_attr_name}`. 106 | """ 107 | if self.use_private_attr: 108 | return getattr(obj, self.private_attr_name, MISSING) 109 | 110 | if hasattr(obj, "__dict__"): 111 | return obj.__dict__.get(self.public_attr_name, MISSING) 112 | 113 | return MISSING 114 | 115 | def _set_value(self, obj: Any, value: Any) -> Any: 116 | """Set value to attribute in object. 117 | 118 | If `use_private_attr` the value will be set to `_{public_attr_name}`. 119 | Else the value will be set to `{public_attr_name}`. 120 | """ 121 | if self.use_private_attr: 122 | setattr(obj, self.private_attr_name, value) 123 | else: 124 | obj.__dict__[self.public_attr_name] = value 125 | 126 | def _check_type(self, value: Any) -> None: 127 | types = self._get_annotation_valid_classes() 128 | if not isinstance(value, types): 129 | raise TypeError( 130 | f"Value ({value}) set to field {self.public_attr_name} " 131 | f"must be of type {types} and not {type(value)}." 132 | ) 133 | 134 | def _validate_optional(self, value:Any) -> None: 135 | if not self.optional and value is None: 136 | raise ValueError(f"{self.public_attr_name} cannot be 'None'.") 137 | 138 | def _check_value_is_optional_none(self, value:Any) -> bool: 139 | """Check if value of attribute is 'None' and CAN be none.""" 140 | if self.optional and value is None: 141 | return True 142 | 143 | return False 144 | 145 | def _check_value_has_been_set_or_optional(self, value: Any) -> None: 146 | """Check if value has been set and there is not a default value we can use. 147 | 148 | An `ValueError` will be raised if the value has not been set and 149 | we cannot use a `default` value. 150 | """ 151 | if not self.optional and value is MISSING and self.default is MISSING: 152 | raise AttributeError( 153 | f"Attribute {self.public_attr_name} on object {type(self).__name__} " 154 | "has not been set." 155 | ) 156 | 157 | def _compute_default_value(self, value: Any) -> Any: 158 | """Check if we have to return a default value.""" 159 | if self.default is not MISSING and (value is MISSING or value is None): 160 | value = self.default 161 | 162 | if self.optional and self.default is MISSING: 163 | value = None 164 | 165 | return value 166 | 167 | def _check_typehint_match_field_types(self, valid_type: Any, hint_arguments: tuple) -> bool: 168 | """Check if typehint arguments are valid field types. 169 | 170 | A field `TYPES` can appear directly in a typehint argument tuple or 171 | a typehint argument could be a subclass of a valid field typehint. 172 | """ 173 | for argument in hint_arguments: 174 | try: 175 | type_cls = self._get_annotation_valid_classes(argument) 176 | if issubclass(type_cls, valid_type): 177 | return True 178 | except TypeError: 179 | continue 180 | 181 | if valid_type in hint_arguments: 182 | return True 183 | 184 | return False 185 | 186 | def _get_annotation_valid_classes(self, type_hint: Any=MISSING): 187 | """Get origin class or type for field type hint. 188 | 189 | If the type hint has arguments then we want to focus on those because 190 | that is waht we can use to check for an obj type unless 191 | the type hint origin is already a built-in type. 192 | 193 | If `get_origin` returns `None` it means we could have a built-in type already. 194 | """ 195 | if type_hint is MISSING: 196 | type_hint = self._annotation 197 | 198 | origin = get_origin(type_hint) 199 | hint_arguments = get_args(type_hint) 200 | 201 | # Could be a valid type, check if it is part of the valid types for this field 202 | # or a sublcass of a valid type. 203 | if (origin is None and 204 | (type_hint in self.TYPES or 205 | any([issubclass(type_hint, valid_type) for valid_type in self.TYPES]))): 206 | return type_hint 207 | 208 | # type_hint might be a generic that resolves to a valid type, e.g. List => list 209 | elif origin is not None and origin in self.TYPES: 210 | return origin 211 | 212 | # If type_hint is not a valid type then recurse over the arguments. 213 | # The valid types would be the arguments. 214 | elif type(origin) is not type and any( 215 | [ 216 | self._check_typehint_match_field_types(valid_type, hint_arguments) 217 | for valid_type in self.TYPES 218 | ] 219 | ): 220 | return hint_arguments 221 | 222 | raise TypeError( 223 | f"Attribute '{self.public_attr_name}' has an invalid type hint of '{self._annotation}' ." 224 | f"Type hint should be '{self.TYPES}' or a subclass." 225 | ) 226 | 227 | 228 | def __str__(self): 229 | ret = f"{self.__class__.__name__}(" 230 | attrs = [] 231 | for attribute_name in self.__slots__: 232 | val = getattr(self, attribute_name, MISSING) 233 | if val is MISSING: 234 | continue 235 | attrs.append(f"{attribute_name}={val}") 236 | 237 | return f"{self.__class__.__name__}({', '.join(attrs)})" 238 | 239 | def __repr__(self): 240 | return self.__str__() 241 | -------------------------------------------------------------------------------- /dcv/fields/bool.py: -------------------------------------------------------------------------------- 1 | from dcv.fields import Field, MISSING 2 | from typing import Optional, Union, cast 3 | 4 | 5 | class BoolField(Field): 6 | """Field validation for bool values.""" 7 | 8 | ERROR_MSGS = {} 9 | TYPES = (bool, ) 10 | 11 | def __init__( 12 | self, 13 | default: Optional[bool] = cast(bool, MISSING), 14 | optional: bool=False, 15 | use_private_attr: bool=False 16 | ): 17 | super().__init__( 18 | default=default, 19 | optional=optional, 20 | use_private_attr=use_private_attr 21 | ) 22 | 23 | def validate(self, value: bool) -> None: 24 | self._validate_optional(value) 25 | 26 | self._check_type(value) 27 | -------------------------------------------------------------------------------- /dcv/fields/datetime.py: -------------------------------------------------------------------------------- 1 | from dcv.fields import Field, MISSING 2 | from typing import Optional, Union, cast 3 | from datetime import datetime, timedelta, date, time 4 | 5 | class DateTimeBaseField(Field): 6 | """Datetime field validation.""" 7 | __slots__ = ('gt', 'lt', 'ge', 'le') 8 | 9 | ERROR_MSGS = { 10 | "gt": "'{attr_name}' value '{value}' must be greater than {limit}.", 11 | "lt": "'{attr_name}' value '{value}' must be less than {limit}.", 12 | "ge": "'{attr_name}' value '{value}' must be greater than or equals to {limit}.", 13 | "le": "'{attr_name}' value '{value}' must be less than or equals to {limit}.", 14 | } 15 | TYPES = (datetime, timedelta, date, time) 16 | 17 | def __init__( 18 | self, 19 | default: Optional[datetime] = cast(datetime, MISSING), 20 | optional: bool=False, 21 | use_private_attr: bool=False, 22 | gt: Union[datetime, timedelta, date, time, None]=None, 23 | lt: Union[datetime, timedelta, date, time, None]=None, 24 | ge: Union[datetime, timedelta, date, time, None]=None, 25 | le: Union[datetime, timedelta, date, time, None]=None 26 | ): 27 | super().__init__( 28 | default=default, 29 | optional=optional, 30 | use_private_attr=use_private_attr 31 | ) 32 | self.gt = gt 33 | self.lt = lt 34 | self.ge = ge 35 | self.le = le 36 | 37 | def validate(self, value: Union[datetime, timedelta, date, time, None]) -> None: 38 | self._validate_optional(value) 39 | 40 | self._check_type(value) 41 | 42 | if self.gt is not None: 43 | self._validate_gt(value, self.gt) 44 | 45 | if self.lt is not None: 46 | self._validate_lt(value, self.lt) 47 | 48 | if self.ge is not None: 49 | self._validate_ge(value, self.ge) 50 | 51 | if self.le is not None: 52 | self._validate_le(value, self.le) 53 | 54 | def _validate_gt( 55 | self, 56 | value: Union[datetime, timedelta, date, time], 57 | limit: Union[datetime, timedelta, date, time] 58 | ): 59 | if not value > limit: 60 | raise ValueError( 61 | self.ERROR_MSGS["gt"].format( 62 | attr_name=self.public_attr_name, 63 | value=value, 64 | limit=limit 65 | ) 66 | ) 67 | 68 | def _validate_lt( 69 | self, 70 | value: Union[datetime, timedelta, date, time], 71 | limit: Union[datetime, timedelta, date, time] 72 | ): 73 | if not value < limit: 74 | raise ValueError( 75 | self.ERROR_MSGS["lt"].format( 76 | attr_name=self.public_attr_name, 77 | value=value, 78 | limit=limit 79 | ) 80 | ) 81 | 82 | def _validate_ge( 83 | self, 84 | value: Union[datetime, timedelta, date, time], 85 | limit: Union[datetime, timedelta, date, time] 86 | ): 87 | if not value >= limit: 88 | raise ValueError( 89 | self.ERROR_MSGS["ge"].format( 90 | attr_name=self.public_attr_name, 91 | value=value, 92 | limit=limit 93 | ) 94 | ) 95 | 96 | def _validate_le( 97 | self, 98 | value: Union[datetime, timedelta, date, time], 99 | limit: Union[datetime, timedelta, date, time] 100 | ): 101 | if not value <= limit: 102 | raise ValueError( 103 | self.ERROR_MSGS["le"].format( 104 | attr_name=self.public_attr_name, 105 | value=value, 106 | limit=limit 107 | ) 108 | ) 109 | 110 | def _check_limits_type(self): 111 | """Verify the values given as limits match the field type.""" 112 | for limit_attr_name in ["gt", "lt", "ge", "le"]: 113 | self._check_type(getattr(self, limit_attr_name)) 114 | 115 | 116 | class DateTimeField(DateTimeBaseField): 117 | TYPES = (datetime,) 118 | 119 | 120 | class TimeDeltaField(DateTimeBaseField): 121 | TYPES = (timedelta,) 122 | 123 | 124 | class DateField(DateTimeBaseField): 125 | TYPES = (date,) 126 | 127 | 128 | class TimeField(DateTimeBaseField): 129 | TYPES = (time,) 130 | -------------------------------------------------------------------------------- /dcv/fields/enum.py: -------------------------------------------------------------------------------- 1 | from dcv.fields import Field, MISSING 2 | from typing import Optional, Union, cast 3 | from enum import Enum 4 | 5 | 6 | class EnumField(Field): 7 | """Field validation for enum values.""" 8 | 9 | ERROR_MSGS = {} 10 | TYPES = (Enum, ) 11 | 12 | def __init__( 13 | self, 14 | default: Optional[Enum] = cast(Enum, MISSING), 15 | optional: bool=False, 16 | use_private_attr: bool=False 17 | ): 18 | super().__init__( 19 | default=default, 20 | optional=optional, 21 | use_private_attr=use_private_attr 22 | ) 23 | 24 | def validate(self, value: Enum) -> None: 25 | self._validate_optional(value) 26 | 27 | self._check_type(value) 28 | -------------------------------------------------------------------------------- /dcv/fields/number.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union, cast 2 | from decimal import Decimal 3 | from dcv.fields import Field, MISSING 4 | 5 | class NumberField(Field): 6 | """Field validation for number values.""" 7 | __slots__ = ('gt', 'lt', 'ge', 'le') 8 | 9 | ERROR_MSGS = { 10 | "nan": "'{attr_name}' value '{value}' is not a number.", 11 | "gt": "'{attr_name}' value '{value}' must be greater than {limit}.", 12 | "lt": "'{attr_name}' value '{value}' must be less than {limit}.", 13 | "ge": "'{attr_name}' value '{value}' must be greater than or equals to {limit}.", 14 | "le": "'{attr_name}' value '{value}' must be less than or equals to {limit}.", 15 | } 16 | TYPES = (int, float, complex, Decimal) 17 | 18 | def __init__( 19 | self, 20 | default: Union[int, float, complex, Decimal, None] = cast(int, MISSING), 21 | optional: bool=False, 22 | use_private_attr: bool=False, 23 | gt: Union[int, float, complex, Decimal, None]=None, 24 | lt: Union[int, float, complex, Decimal, None]=None, 25 | ge: Union[int, float, complex, Decimal, None]=None, 26 | le: Union[int, float, complex, Decimal, None]=None 27 | ): 28 | super().__init__( 29 | default=default, 30 | optional=optional, 31 | use_private_attr=use_private_attr 32 | ) 33 | self.gt = gt 34 | self.lt = lt 35 | self.ge = ge 36 | self.le = le 37 | 38 | def validate(self, value: Union[int, float, complex, Decimal, None]) -> None: 39 | self._validate_optional(value) 40 | 41 | self._check_type(value) 42 | 43 | if self.gt is not None: 44 | self._validate_gt(value, self.gt) 45 | 46 | if self.lt is not None: 47 | self._validate_lt(value, self.lt) 48 | 49 | if self.ge is not None: 50 | self._validate_ge(value, self.ge) 51 | 52 | if self.le is not None: 53 | self._validate_le(value, self.le) 54 | 55 | def _validate_gt( 56 | self, 57 | value: Union[int, float, complex, Decimal], 58 | limit: Union[int, float, complex, Decimal] 59 | ): 60 | if not value > limit: 61 | raise ValueError( 62 | self.ERROR_MSGS["gt"].format( 63 | attr_name=self.public_attr_name, 64 | value=value, 65 | limit=limit 66 | ) 67 | ) 68 | 69 | def _validate_lt( 70 | self, 71 | value: Union[int, float, complex, Decimal], 72 | limit: Union[int, float, complex, Decimal] 73 | ): 74 | if not value < limit: 75 | raise ValueError( 76 | self.ERROR_MSGS["lt"].format( 77 | attr_name=self.public_attr_name, 78 | value=value, 79 | limit=limit 80 | ) 81 | ) 82 | 83 | def _validate_ge( 84 | self, 85 | value: Union[int, float, complex, Decimal], 86 | limit: Union[int, float, complex, Decimal] 87 | ): 88 | if not value >= limit: 89 | raise ValueError( 90 | self.ERROR_MSGS["ge"].format( 91 | attr_name=self.public_attr_name, 92 | value=value, 93 | limit=limit 94 | ) 95 | ) 96 | 97 | def _validate_le( 98 | self, 99 | value: Union[int, float, complex, Decimal], 100 | limit: Union[int, float, complex, Decimal] 101 | ): 102 | if not value <= limit: 103 | raise ValueError( 104 | self.ERROR_MSGS["le"].format( 105 | attr_name=self.public_attr_name, 106 | value=value, 107 | limit=limit 108 | ) 109 | ) 110 | 111 | 112 | class IntField(NumberField): 113 | TYPES = (int, ) 114 | 115 | 116 | class FloatField(NumberField): 117 | TYPES = (float, ) 118 | 119 | 120 | class DecimalField(NumberField): 121 | TYPES = (Decimal, ) 122 | 123 | 124 | class ComplexField(NumberField): 125 | """Complex numbers cannot be compared.""" 126 | 127 | TYPES = (complex, ) 128 | 129 | def __init__( 130 | self, 131 | default: Optional[complex] = cast(complex, MISSING), 132 | optional: bool=False, 133 | use_private_attr: bool=False 134 | ): 135 | super().__init__( 136 | default=default, 137 | optional=optional, 138 | use_private_attr=use_private_attr 139 | ) 140 | -------------------------------------------------------------------------------- /dcv/fields/text.py: -------------------------------------------------------------------------------- 1 | from dcv.fields import Field, MISSING 2 | from typing import Optional, Union, cast 3 | import re 4 | 5 | class TextField(Field): 6 | """Field validation for string values.""" 7 | __slots__ = ('max_length', 'min_length', 'blank', 'trim', 'regex', 'compiled') 8 | 9 | ERROR_MSGS = { 10 | "max_length": "'{attr_name}' length cannot be more than {length}.", 11 | "min_length": "'{attr_name}' length cannot be less than {length}.", 12 | "blank": "'{attr_name}' cannot be blank.", 13 | "regex": "'{attr_name}' does not match regex: {regex} .", 14 | } 15 | TYPES = (str, bytes) 16 | 17 | def __init__( 18 | self, 19 | default: Optional[str] = cast(str, MISSING), 20 | optional: bool=False, 21 | use_private_attr: bool=False, 22 | max_length: Optional[int]=None, 23 | min_length: Optional[int]=None, *, 24 | blank: bool=False, 25 | regex: Optional[str]=None, 26 | trim: Union[str, bool]=False 27 | ): 28 | super().__init__( 29 | default=default, 30 | optional=optional, 31 | use_private_attr=use_private_attr 32 | ) 33 | self.max_length = max_length 34 | self.min_length = min_length 35 | self.blank = blank 36 | self.trim = trim 37 | self.regex = None 38 | if regex: 39 | self.regex = regex 40 | self.compiled: re.Pattern = re.compile(regex) 41 | 42 | def validate(self, value: str) -> None: 43 | self._validate_optional(value) 44 | 45 | self._check_type(value) 46 | 47 | self._validate_blank(value) 48 | 49 | if self.max_length is not None: 50 | self._validate_max_length(value, self.max_length) 51 | 52 | if self.min_length is not None: 53 | self._validate_min_length(value, self.min_length) 54 | 55 | if self.regex is not None: 56 | self._validate_regex(value) 57 | 58 | def transform(self, value: str) -> str: 59 | if self.trim is True: 60 | return value.strip() 61 | elif isinstance(self.trim, str): 62 | return value.strip(self.trim) 63 | 64 | return value 65 | 66 | def _validate_max_length(self, value: str, max_length: int) -> None: 67 | if len(value) > max_length: 68 | raise ValueError( 69 | self.ERROR_MSGS["max_length"].format( 70 | attr_name=self.public_attr_name, 71 | length=self.max_length 72 | ) 73 | ) 74 | 75 | def _validate_min_length(self, value: str, min_length: int) -> None: 76 | if len(value) < min_length: 77 | raise ValueError( 78 | self.ERROR_MSGS["min_length"].format( 79 | attr_name=self.public_attr_name, 80 | length=self.min_length 81 | ) 82 | ) 83 | def _validate_blank(self, value: str) -> None: 84 | if not self.blank and not len(value): 85 | raise ValueError(self.ERROR_MSGS["blank"].format(attr_name=self.public_attr_name)) 86 | 87 | def _validate_regex(self, value: str) -> None: 88 | if self.regex and not self.compiled.match(value): 89 | raise ValueError( 90 | self.ERROR_MSGS["regex"].format( 91 | attr_name=self.public_attr_name, 92 | regex=self.regex 93 | ) 94 | ) 95 | 96 | -------------------------------------------------------------------------------- /t/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmcomplexity/dataclasses-validation/7537bbbb585fed702f1ef449c43e4429e40a703f/t/__init__.py -------------------------------------------------------------------------------- /t/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmcomplexity/dataclasses-validation/7537bbbb585fed702f1ef449c43e4429e40a703f/t/unit/__init__.py -------------------------------------------------------------------------------- /t/unit/test_bool.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from enum import Enum 3 | from typing import Optional 4 | import pytest 5 | from dcv.fields import BoolField 6 | 7 | def test_bool(): 8 | """Test bool field. 9 | 10 | GIVEN a dataclass with a `bool` field 11 | WHEN a valid bool value is given 12 | THEN it should validate input on assign. 13 | """ 14 | @dataclass 15 | class T: 16 | flag: bool = BoolField() 17 | flag_op: Optional[bool] = BoolField(optional=True) 18 | flag_f: bool = field(default=BoolField()) 19 | 20 | t = T(flag=True, flag_f=False) 21 | 22 | assert t.flag 23 | assert not t.flag_f 24 | assert t.flag_op is None 25 | 26 | t = T(flag=True, flag_op=False, flag_f=False) 27 | 28 | assert t.flag 29 | assert not t.flag_op 30 | assert not t.flag_f 31 | 32 | with pytest.raises(TypeError): 33 | T(flag="false", flag_=False) 34 | 35 | with pytest.raises(TypeError): 36 | T(flag=False, flag_="False") 37 | 38 | with pytest.raises(TypeError): 39 | T(flag=False, flag_=False).flag_op = "false" 40 | -------------------------------------------------------------------------------- /t/unit/test_datetime.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import date, datetime, timedelta, date, time 3 | from typing import Union 4 | import pytest 5 | from dcv.fields import ( 6 | DateTimeField, TimeDeltaField, 7 | TimeField, DateField 8 | ) 9 | 10 | 11 | def test_base_datetime(): 12 | """Test DateTimeBaseField 13 | 14 | GIVEN a dataclass with a `Union[datetime, timedelta, date, time]` field 15 | WHEN a valid value is given 16 | THEN it should validate the input on init. 17 | """ 18 | @dataclass 19 | class T: 20 | date_time: datetime = DateTimeField() 21 | time_delta: timedelta = TimeDeltaField() 22 | date_val: date = DateField() 23 | time_val: time = TimeField() 24 | 25 | date_time: datetime = datetime(2021, 1, 1, 1, 1, 1) 26 | time_delta: timedelta = timedelta(days=1) 27 | date_val: date = date(2021,1,1) 28 | time_val: time = time(2) 29 | t = T( 30 | date_time=date_time, 31 | time_delta=time_delta, 32 | date_val=date_val, 33 | time_val=time_val 34 | ) 35 | assert t.date_time == date_time 36 | assert t.time_delta == time_delta 37 | assert t.date_val == date_val 38 | assert t.time_val == time_val 39 | 40 | with pytest.raises(TypeError): 41 | t = T(date_time='asdf', 42 | time_delta=time_delta, 43 | date_val=date_val, 44 | time_val=time_val 45 | ) 46 | 47 | with pytest.raises(TypeError): 48 | t = T(date_time=date_time, 49 | time_delta='asdf', 50 | date_val=date_val, 51 | time_val=time_val 52 | ) 53 | 54 | with pytest.raises(TypeError): 55 | t = T(date_time=date_time, 56 | time_delta=time_delta, 57 | date_val='asdf', 58 | time_val=time_val 59 | ) 60 | 61 | with pytest.raises(TypeError): 62 | t = T(date_time=date_time, 63 | time_delta=time_delta, 64 | date_val=date_val, 65 | time_val='asdf' 66 | ) 67 | 68 | def test_datetime_gt(): 69 | """Test datetime fields gt 70 | 71 | GTIVEN a dataclass with a date time field 72 | WHEN a `gt` limit is set 73 | THEN any value that's set should be validated correctly 74 | """ 75 | @dataclass 76 | class T: 77 | date_time: datetime = DateTimeField(gt=datetime(2021,1,1)) 78 | time_delta: timedelta = TimeDeltaField(gt=timedelta(hours=1)) 79 | date_val: date = DateField(gt=date(2021,1,1)) 80 | time_val: time = TimeField(gt=time(hour=1)) 81 | 82 | valid_dt: datetime = datetime(2021, 2, 2) 83 | invalid_dt: datetime = datetime(2020, 1, 1) 84 | valid_td: timedelta = timedelta(hours=2) 85 | invalid_td: timedelta = timedelta(minutes=50) 86 | valid_da: date = date(2021,2,2) 87 | invalid_da: date = date(2020,1,1) 88 | valid_ti: time = time(hour=2) 89 | invalid_ti: time = time(hour=0,minute=50) 90 | 91 | t = T( 92 | date_time=valid_dt, 93 | time_delta=valid_td, 94 | date_val=valid_da, 95 | time_val=valid_ti 96 | ) 97 | 98 | assert t.date_time == valid_dt 99 | assert t.time_delta == valid_td 100 | assert t.date_val == valid_da 101 | assert t.time_val == valid_ti 102 | 103 | with pytest.raises(ValueError): 104 | t = T( 105 | date_time=invalid_dt, 106 | time_delta=valid_td, 107 | date_val=valid_da, 108 | time_val=valid_ti 109 | ) 110 | 111 | 112 | with pytest.raises(ValueError): 113 | t = T( 114 | date_time=valid_dt, 115 | time_delta=invalid_td, 116 | date_val=valid_da, 117 | time_val=valid_ti 118 | ) 119 | 120 | with pytest.raises(ValueError): 121 | t = T( 122 | date_time=valid_dt, 123 | time_delta=valid_td, 124 | date_val=invalid_da, 125 | time_val=valid_ti 126 | ) 127 | 128 | with pytest.raises(ValueError): 129 | t = T( 130 | date_time=valid_dt, 131 | time_delta=valid_td, 132 | date_val=valid_da, 133 | time_val=invalid_ti 134 | ) 135 | 136 | 137 | def test_datetime_lt(): 138 | """Test datetime fields lt 139 | 140 | GTIVEN a dataclass with a date time field 141 | WHEN a `lt` limit is set 142 | THEN any value that's set should be validated correctly 143 | """ 144 | @dataclass 145 | class T: 146 | date_time: datetime = DateTimeField(lt=datetime(2021,1,1)) 147 | time_delta: timedelta = TimeDeltaField(lt=timedelta(hours=1)) 148 | date_val: date = DateField(lt=date(2021,1,1)) 149 | time_val: time = TimeField(lt=time(hour=1)) 150 | 151 | valid_dt: datetime = datetime(2020, 2, 2) 152 | invalid_dt: datetime = datetime(2021, 1, 2) 153 | valid_td: timedelta = timedelta(minutes=50) 154 | invalid_td: timedelta = timedelta(hours=2) 155 | valid_da: date = date(2020,2,2) 156 | invalid_da: date = date(2021,1,1) 157 | valid_ti: time = time(hour=0, minute=50) 158 | invalid_ti: time = time(hour=2) 159 | 160 | t = T( 161 | date_time=valid_dt, 162 | time_delta=valid_td, 163 | date_val=valid_da, 164 | time_val=valid_ti 165 | ) 166 | 167 | assert t.date_time == valid_dt 168 | assert t.time_delta == valid_td 169 | assert t.date_val == valid_da 170 | assert t.time_val == valid_ti 171 | 172 | with pytest.raises(ValueError): 173 | t = T( 174 | date_time=invalid_dt, 175 | time_delta=valid_td, 176 | date_val=valid_da, 177 | time_val=valid_ti 178 | ) 179 | 180 | 181 | with pytest.raises(ValueError): 182 | t = T( 183 | date_time=valid_dt, 184 | time_delta=invalid_td, 185 | date_val=valid_da, 186 | time_val=valid_ti 187 | ) 188 | 189 | with pytest.raises(ValueError): 190 | t = T( 191 | date_time=valid_dt, 192 | time_delta=valid_td, 193 | date_val=invalid_da, 194 | time_val=valid_ti 195 | ) 196 | 197 | with pytest.raises(ValueError): 198 | t = T( 199 | date_time=valid_dt, 200 | time_delta=valid_td, 201 | date_val=valid_da, 202 | time_val=invalid_ti 203 | ) 204 | 205 | 206 | def test_datetime_ge(): 207 | """Test datetime fields ge 208 | 209 | GTIVEN a dataclass with a date time field 210 | WHEN a `ge` limit is set 211 | THEN any value that's set should be validated correctly 212 | """ 213 | @dataclass 214 | class T: 215 | date_time: datetime = DateTimeField(ge=datetime(2021,1,1)) 216 | time_delta: timedelta = TimeDeltaField(ge=timedelta(hours=1)) 217 | date_val: date = DateField(ge=date(2021,1,1)) 218 | time_val: time = TimeField(ge=time(hour=1)) 219 | 220 | valid_dt: datetime = datetime(2021, 1, 1) 221 | invalid_dt: datetime = datetime(2020, 1, 1) 222 | valid_td: timedelta = timedelta(hours=1) 223 | invalid_td: timedelta = timedelta(minutes=50) 224 | valid_da: date = date(2021,1,1) 225 | invalid_da: date = date(2020,1,1) 226 | valid_ti: time = time(hour=1) 227 | invalid_ti: time = time(hour=0,minute=50) 228 | 229 | t = T( 230 | date_time=valid_dt, 231 | time_delta=valid_td, 232 | date_val=valid_da, 233 | time_val=valid_ti 234 | ) 235 | 236 | assert t.date_time == valid_dt 237 | assert t.time_delta == valid_td 238 | assert t.date_val == valid_da 239 | assert t.time_val == valid_ti 240 | 241 | with pytest.raises(ValueError): 242 | t = T( 243 | date_time=invalid_dt, 244 | time_delta=valid_td, 245 | date_val=valid_da, 246 | time_val=valid_ti 247 | ) 248 | 249 | 250 | with pytest.raises(ValueError): 251 | t = T( 252 | date_time=valid_dt, 253 | time_delta=invalid_td, 254 | date_val=valid_da, 255 | time_val=valid_ti 256 | ) 257 | 258 | with pytest.raises(ValueError): 259 | t = T( 260 | date_time=valid_dt, 261 | time_delta=valid_td, 262 | date_val=invalid_da, 263 | time_val=valid_ti 264 | ) 265 | 266 | with pytest.raises(ValueError): 267 | t = T( 268 | date_time=valid_dt, 269 | time_delta=valid_td, 270 | date_val=valid_da, 271 | time_val=invalid_ti 272 | ) 273 | 274 | 275 | def test_datetime_le(): 276 | """Test datetime fields le 277 | 278 | GTIVEN a dataclass with a date time field 279 | WHEN a `le` limit is set 280 | THEN any value that's set should be validated correctly 281 | """ 282 | @dataclass 283 | class T: 284 | date_time: datetime = DateTimeField(le=datetime(2021,1,1)) 285 | time_delta: timedelta = TimeDeltaField(le=timedelta(hours=1)) 286 | date_val: date = DateField(le=date(2021,1,1)) 287 | time_val: time = TimeField(le=time(hour=1)) 288 | 289 | valid_dt: datetime = datetime(2020, 1, 1) 290 | invalid_dt: datetime = datetime(2021, 1, 2) 291 | valid_td: timedelta = timedelta(hours=1) 292 | invalid_td: timedelta = timedelta(hours=2) 293 | valid_da: date = date(2020,1,1) 294 | invalid_da: date = date(2021,1,2) 295 | valid_ti: time = time(hour=1) 296 | invalid_ti: time = time(hour=2) 297 | 298 | t = T( 299 | date_time=valid_dt, 300 | time_delta=valid_td, 301 | date_val=valid_da, 302 | time_val=valid_ti 303 | ) 304 | 305 | assert t.date_time == valid_dt 306 | assert t.time_delta == valid_td 307 | assert t.date_val == valid_da 308 | assert t.time_val == valid_ti 309 | 310 | with pytest.raises(ValueError): 311 | t = T( 312 | date_time=invalid_dt, 313 | time_delta=valid_td, 314 | date_val=valid_da, 315 | time_val=valid_ti 316 | ) 317 | 318 | 319 | with pytest.raises(ValueError): 320 | t = T( 321 | date_time=valid_dt, 322 | time_delta=invalid_td, 323 | date_val=valid_da, 324 | time_val=valid_ti 325 | ) 326 | 327 | with pytest.raises(ValueError): 328 | t = T( 329 | date_time=valid_dt, 330 | time_delta=valid_td, 331 | date_val=invalid_da, 332 | time_val=valid_ti 333 | ) 334 | 335 | with pytest.raises(ValueError): 336 | t = T( 337 | date_time=valid_dt, 338 | time_delta=valid_td, 339 | date_val=valid_da, 340 | time_val=invalid_ti 341 | ) 342 | -------------------------------------------------------------------------------- /t/unit/test_enum.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from enum import Enum 3 | from typing import Optional, Any 4 | import pytest 5 | from dcv.fields import EnumField 6 | 7 | class Size(Enum): 8 | XS = 'x-small' 9 | S = 'small' 10 | M = 'medium' 11 | L = 'large' 12 | XL = 'x-large' 13 | 14 | def test_enums(): 15 | """Test enum field. 16 | 17 | GIVEN a dataclass with a `Enum` field 18 | WHEN a valid enum value is given 19 | THEN it should validate input on init. 20 | """ 21 | class NotASize(Enum): 22 | XS = 'xs' 23 | 24 | @dataclass 25 | class T: 26 | enumeration: Enum = EnumField() 27 | size: Size = EnumField() 28 | optional_size: Optional[Size] = EnumField() 29 | enumeration_field: Enum = field(default=EnumField()) 30 | size_field: Size = field(default=EnumField()) 31 | 32 | t = T( 33 | enumeration=Size.XS, 34 | enumeration_field=Size.S, 35 | size=Size.L, 36 | size_field=Size.XL, 37 | optional_size=Size.M, 38 | ) 39 | 40 | assert t.enumeration == Size.XS 41 | assert t.enumeration_field == Size.S 42 | assert t.size == Size.L 43 | assert t.size_field == Size.XL 44 | 45 | with pytest.raises(TypeError): 46 | t = T( 47 | enumeration="small", 48 | enumeration_field="x-small", 49 | size="large", 50 | size_field="x-large" 51 | ) 52 | 53 | with pytest.raises(TypeError): 54 | t = T( 55 | enumeration=Size.XS, 56 | enumeration_field=Size.S, 57 | size=Size.L, 58 | size_field=Size.XL, 59 | optional_size=NotASize.XS, 60 | ) 61 | -------------------------------------------------------------------------------- /t/unit/test_field.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, fields, field 2 | import pytest 3 | from typing import Optional, cast, List 4 | from inspect import signature 5 | from dcv.fields import Field 6 | import types 7 | 8 | class MyField(Field): 9 | """Custom field.""" 10 | TYPES = (str, ) 11 | 12 | def validate(self, value): 13 | assert value == "x" or value is None 14 | 15 | 16 | def test_field(): 17 | """Base field. 18 | 19 | GIVEN a custom field 20 | WHEN a dataclass uses it 21 | THEN it should run validate method. 22 | """ 23 | @dataclass 24 | class T: 25 | name: str = MyField() 26 | last_name: str = field(default=MyField(), init=False) 27 | 28 | @dataclass 29 | class OT: 30 | name: str = field(default=MyField()) 31 | 32 | t = T(name="x") 33 | t.last_name = "x" 34 | assert t.name == "x", "Custom field does not accept a valid string." 35 | assert t.last_name == "x" 36 | 37 | ot = OT(name="x") 38 | assert ot.name == "x", "Custom field does not accept a valid string." 39 | 40 | with pytest.raises(AssertionError): 41 | t = T(name="invalid string") 42 | 43 | with pytest.raises(AssertionError): 44 | ot = T(name="invalid string") 45 | 46 | with pytest.raises(AssertionError): 47 | t.last_name = "asdf" 48 | 49 | 50 | def test_field_optional_with_default(): 51 | """Base field. 52 | 53 | GIVEN a custom field with optional set to `True` and a default value 54 | WHEN a dataclass uses it 55 | THEN it should validate correctly. 56 | """ 57 | @dataclass 58 | class T: 59 | name: Optional[str] = MyField(optional=True, default="x") 60 | 61 | t = T() 62 | assert t.name == "x" 63 | 64 | def test_field_optional_no_default(): 65 | """Base field. 66 | 67 | GIVEN a custom field with with optional set to `True` and no default value 68 | WHEN a dataclass uses it 69 | THEN it should use `None` as `default`. 70 | """ 71 | @dataclass 72 | class T: 73 | name: Optional[str] = MyField(optional=True) 74 | 75 | @dataclass 76 | class OT: 77 | name: Optional[str] = MyField(optional=True) 78 | 79 | t = T() 80 | assert t.name is None 81 | 82 | ot = OT() 83 | assert ot.name is None 84 | 85 | def test_field_default_and_optional_False(): 86 | """Base field. 87 | 88 | GIVEN a custom field with a `default` value and `optional` set to False. 89 | WHEN a dataclass uses it 90 | THEN it should correctly initialize class. 91 | """ 92 | @dataclass 93 | class T: 94 | name: Optional[str] = MyField(default="x") 95 | 96 | @dataclass 97 | class OT: 98 | name: Optional[str] = MyField(default="x") 99 | 100 | t = T() 101 | assert t.name == "x" 102 | 103 | ot = OT() 104 | assert ot.name == "x" 105 | 106 | 107 | def test_field_use_private_attr(): 108 | """Base field. 109 | 110 | GIVEN a custom field with a `default` value and `optional` set to False. 111 | WHEN a dataclass uses it 112 | THEN it should correctly initialize class. 113 | """ 114 | @dataclass 115 | class T: 116 | name: str = MyField(use_private_attr=True) 117 | 118 | @dataclass 119 | class OT: 120 | name: str = MyField(use_private_attr=True) 121 | 122 | t = T(name="x") 123 | assert t.name == "x" 124 | assert t.__dict__["_name"] == "x" 125 | assert ("name" in t.__dict__) == False 126 | 127 | ot = OT(name="x") 128 | assert ot.name == "x" 129 | assert ot.__dict__["_name"] == "x" 130 | assert ("name" in ot.__dict__) == False 131 | 132 | 133 | def test_field_with_wrong_typehint(): 134 | """Base field. 135 | 136 | GIVEN a custom field with a defined tuple of supported types 137 | WHEN a dataclass uses it in a field with the wrong typehint 138 | THEN it should raise a TypeError 139 | """ 140 | with pytest.raises(RuntimeError): 141 | @dataclass 142 | class T: 143 | name: int = MyField() 144 | 145 | with pytest.raises(RuntimeError): 146 | @dataclass 147 | class T: 148 | name: Optional[int] = MyField() 149 | 150 | with pytest.raises(RuntimeError): 151 | @dataclass 152 | class T: 153 | name: List[str] = MyField() 154 | 155 | class S(str): 156 | pass 157 | 158 | @dataclass 159 | class T: 160 | name: S = MyField() 161 | 162 | assert isinstance(vars(T)['name'], MyField) 163 | -------------------------------------------------------------------------------- /t/unit/test_number.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from decimal import Decimal 3 | from typing import Union 4 | import pytest 5 | from dcv.fields import NumberField, ComplexField 6 | 7 | def test_numbers(): 8 | """Test all number types. 9 | 10 | GIVEN a dataclass with a `Union[int, float, complex, Decimal]` field 11 | WHEN a valid value is given 12 | THEN it should validate the input on init. 13 | """ 14 | @dataclass 15 | class T: 16 | co_num: complex = ComplexField() 17 | num: Union[int, float, complex, Decimal] = NumberField() 18 | second_co_num: complex = field(default=ComplexField()) 19 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField()) 20 | 21 | t = T(num=1, second_num=1, co_num=1+2j, second_co_num=1+2j) 22 | assert t.num == 1 23 | assert t.second_num == 1 24 | assert t.co_num == 1+2j 25 | assert t.second_co_num == 1+2j 26 | 27 | t = T(num=1.0, second_num=1.0, co_num=1+2j, second_co_num=1+2j) 28 | assert t.num == 1.0 29 | assert t.second_num == 1.0 30 | 31 | t = T(num=complex("1+2j"), second_num=1+2j, co_num=1+2j, second_co_num=1+2j) 32 | assert t.num == 1+2j 33 | assert t.second_num == 1+2j 34 | 35 | t = T(num=Decimal("1.0"), second_num=Decimal(1.0), co_num=1+2j, second_co_num=1+2j) 36 | assert t.num == Decimal("1.0") 37 | assert t.second_num == Decimal("1.0") 38 | 39 | with pytest.raises(TypeError): 40 | T(num="asdf") 41 | 42 | with pytest.raises(TypeError): 43 | T(second_num={}) 44 | 45 | with pytest.raises(TypeError): 46 | T(num=[]) 47 | 48 | 49 | def test_lt(): 50 | """Test less than. 51 | 52 | GIVEN a dataclass with a `Union[int, float, complex, Decimal]` field and a 53 | validator with a lt 54 | WHEN a valid value is given 55 | THEN it should validate the input on init. 56 | """ 57 | @dataclass 58 | class TInt: 59 | num: Union[int, float, complex, Decimal] = NumberField(lt=3) 60 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(lt=3)) 61 | 62 | @dataclass 63 | class TFloat: 64 | num: Union[int, float, complex, Decimal] = NumberField(lt=3) 65 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(lt=3)) 66 | 67 | @dataclass 68 | class TDecimal: 69 | num: Union[int, float, complex, Decimal] = NumberField(lt=Decimal(3.0)) 70 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(lt=3)) 71 | 72 | t_int = TInt(num=1, second_num=1) 73 | assert t_int.num == 1 74 | assert t_int.second_num == 1 75 | 76 | t_float = TFloat(num=1.0, second_num=1.0) 77 | assert t_float.num == 1.0 78 | assert t_float.second_num == 1.0 79 | 80 | t_dec = TDecimal(num=Decimal(1.0), second_num=Decimal(1.0)) 81 | assert t_dec.num == 1 82 | assert t_dec.second_num == 1 83 | 84 | with pytest.raises(ValueError): 85 | TInt(num=4) 86 | 87 | with pytest.raises(ValueError): 88 | TFloat(second_num=3.1, num=3.1) 89 | 90 | with pytest.raises(ValueError): 91 | TDecimal(second_num=Decimal(3.1), num=3.1) 92 | 93 | 94 | def test_gt(): 95 | """Test gt. 96 | 97 | GIVEN a dataclass with a `Union[int, float, complex, Decimal]` field and a 98 | validator with a max limit 99 | WHEN a valid value is given 100 | THEN it should validate the input on init. 101 | """ 102 | @dataclass 103 | class TInt: 104 | num: Union[int, float, complex, Decimal] = NumberField(gt=3) 105 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(gt=3)) 106 | 107 | @dataclass 108 | class TFloat: 109 | num: Union[int, float, complex, Decimal] = NumberField(gt=3) 110 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(gt=3)) 111 | 112 | @dataclass 113 | class TDecimal: 114 | num: Union[int, float, complex, Decimal] = NumberField(gt=Decimal(3.0)) 115 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(gt=3)) 116 | 117 | t_int = TInt(num=4, second_num=4) 118 | assert t_int.num == 4 119 | assert t_int.second_num == 4 120 | 121 | t_float = TFloat(num=3.1, second_num=3.1) 122 | assert t_float.num == 3.1 123 | assert t_float.second_num == 3.1 124 | 125 | t_dec = TDecimal(num=Decimal(3.1), second_num=Decimal(3.1)) 126 | assert t_dec.num == 3.1 127 | assert t_dec.second_num == 3.1 128 | 129 | with pytest.raises(ValueError): 130 | TInt(num=3) 131 | 132 | with pytest.raises(ValueError): 133 | TFloat(second_num=3.0, num=3.0) 134 | 135 | with pytest.raises(ValueError): 136 | TDecimal(second_num=Decimal(3.0), num=3.0) 137 | 138 | 139 | def test_le(): 140 | """Test less than or equal. 141 | 142 | GIVEN a dataclass with a `Union[int, float, complex, Decimal]` field and a 143 | validator with le 144 | WHEN a valid value is given 145 | THEN it should validate the input on init. 146 | """ 147 | @dataclass 148 | class TInt: 149 | num: Union[int, float, complex, Decimal] = NumberField(le=3) 150 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(le=3)) 151 | 152 | @dataclass 153 | class TFloat: 154 | num: Union[int, float, complex, Decimal] = NumberField(le=3) 155 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(le=3)) 156 | 157 | @dataclass 158 | class TDecimal: 159 | num: Union[int, float, complex, Decimal] = NumberField(le=Decimal(3.0)) 160 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(le=3)) 161 | 162 | t_int = TInt(num=3, second_num=2) 163 | assert t_int.num == 3 164 | assert t_int.second_num == 2 165 | 166 | t_float = TFloat(num=2.9, second_num=3.0) 167 | assert t_float.num == 2.9 168 | assert t_float.second_num == 3.0 169 | 170 | t_dec = TDecimal(num=Decimal(2.9), second_num=Decimal(3.0)) 171 | assert t_dec.num == 2.9 172 | assert t_dec.second_num == 3.0 173 | 174 | with pytest.raises(ValueError): 175 | TInt(num=4) 176 | 177 | with pytest.raises(ValueError): 178 | TFloat(second_num=3.1, num=3.0) 179 | 180 | with pytest.raises(ValueError): 181 | TDecimal(second_num=Decimal(3.0), num=3.1) 182 | 183 | 184 | def test_le(): 185 | """Test less than or equal. 186 | 187 | GIVEN a dataclass with a `Union[int, float, complex, Decimal]` field and a 188 | validator with le 189 | WHEN a valid value is given 190 | THEN it should validate the input on init. 191 | """ 192 | @dataclass 193 | class TInt: 194 | num: Union[int, float, complex, Decimal] = NumberField(le=3) 195 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(le=3)) 196 | 197 | @dataclass 198 | class TFloat: 199 | num: Union[int, float, complex, Decimal] = NumberField(le=3) 200 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(le=3)) 201 | 202 | @dataclass 203 | class TDecimal: 204 | num: Union[int, float, complex, Decimal] = NumberField(le=Decimal(3.0)) 205 | second_num: Union[int, float, complex, Decimal] = field(default=NumberField(le=3)) 206 | 207 | t_int = TInt(num=3, second_num=2) 208 | assert t_int.num == 3 209 | assert t_int.second_num == 2 210 | 211 | t_float = TFloat(num=2.9, second_num=3.0) 212 | assert t_float.num == 2.9 213 | assert t_float.second_num == 3.0 214 | 215 | t_dec = TDecimal(num=Decimal(2.9), second_num=Decimal(3.0)) 216 | assert t_dec.num == 2.9 217 | assert t_dec.second_num == 3.0 218 | 219 | with pytest.raises(ValueError): 220 | TInt(num=4) 221 | 222 | with pytest.raises(ValueError): 223 | TFloat(second_num=3.1, num=3.0) 224 | 225 | with pytest.raises(ValueError): 226 | TDecimal(second_num=Decimal(3.0), num=3.1) 227 | -------------------------------------------------------------------------------- /t/unit/test_text.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | import pytest 3 | from dcv.fields import TextField 4 | 5 | def test_str(): 6 | """Str and text fields. 7 | 8 | GIVEN a dataclass with an `str` field and a text validator 9 | WHEN a valid value is given 10 | THEN it should validate the input on instantiation 11 | """ 12 | @dataclass 13 | class T: 14 | name: str = TextField() 15 | 16 | t = T(name="valid string") 17 | assert t.name == "valid string", "Validation does not accept a valid string." 18 | 19 | with pytest.raises(TypeError): 20 | t = T(name=123) 21 | 22 | 23 | def test_str_max_length(): 24 | """Max length. 25 | 26 | GIVEN a dataclass with an `str` field and a text validator with max_length 27 | WHEN a valid value is given 28 | THEN it should validate the input on instantiation 29 | """ 30 | @dataclass 31 | class T: 32 | name: str = TextField(max_length=5) 33 | 34 | t = T(name="names") 35 | assert t.name == "names" 36 | 37 | with pytest.raises(ValueError): 38 | t = T(name="invalid string") 39 | 40 | 41 | def test_str_min_length(): 42 | """Min length. 43 | 44 | GIVEN a dataclass with an `str` field and a text validator with min_length 45 | WHEN a valid value is given 46 | THEN it should validate the input on instantiation 47 | """ 48 | @dataclass 49 | class T: 50 | name: str = TextField(min_length=3) 51 | 52 | t = T(name="name") 53 | assert t.name == "name" 54 | 55 | with pytest.raises(ValueError): 56 | t = T(name="x") 57 | 58 | 59 | def test_str_max_min_length(): 60 | """Max and min. 61 | 62 | GIVEN a dataclass with an `str` field and a text validator with min and max length 63 | WHEN a valid value is given 64 | THEN it should validate the input on instantiation 65 | """ 66 | @dataclass 67 | class T: 68 | name: str = TextField(min_length=3, max_length=5) 69 | 70 | t = T(name="names") 71 | assert t.name == "names" 72 | 73 | with pytest.raises(ValueError): 74 | t = T(name="x") 75 | 76 | with pytest.raises(ValueError): 77 | t = T(name="invalid string") 78 | 79 | def test_str_blank(): 80 | """blank paremeter 81 | 82 | GIVEN a dataclass with an `str` field and a text validator with blank=True 83 | WHEN a valid value is given 84 | THEN it should validate the input on instantiation 85 | """ 86 | @dataclass 87 | class T: 88 | name: str = TextField(blank=True) 89 | 90 | t = T(name="") 91 | assert t.name == "" 92 | 93 | with pytest.raises(ValueError): 94 | t = T(name=None) 95 | 96 | def test_str_trim(): 97 | """trim paremeter 98 | 99 | GIVEN a dataclass with an `str` field and a text validator with trim 100 | WHEN a valid value is given 101 | THEN it should transform the value 102 | """ 103 | @dataclass 104 | class T: 105 | name: str = TextField(trim=" ") 106 | 107 | t = T(name=" A ") 108 | assert t.name == "A" 109 | 110 | def test_str_regex(): 111 | """regex parameter 112 | 113 | GIVEN a dataclass with an `str` field and a text validator with trim 114 | WHEN a valid value is given 115 | THEN it should transform the value 116 | """ 117 | @dataclass 118 | class T: 119 | name: str = TextField(regex="^[aA]") 120 | 121 | t = T(name="arturo") 122 | assert t.name == "arturo" 123 | t1 = T(name="Arturo") 124 | assert t1.name == "Arturo" 125 | with pytest.raises(ValueError): 126 | t2 = T(name="Pedro") 127 | --------------------------------------------------------------------------------