├── .flake8 ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── pydantic_collections ├── __init__.py ├── __init__.pyi ├── _v1.py └── _v2.py ├── pyproject.toml ├── requirements-dev.txt ├── setup.py └── tests ├── __init__.py ├── test_v1.py └── test_v2.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = N805,W503 3 | max-line-length = 99 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | jobs: 10 | build: 11 | name: "Python ${{ matrix.python-version }} ${{ matrix.pydantic-version }} ${{ matrix.os }} " 12 | runs-on: "${{ matrix.os }}" 13 | strategy: 14 | matrix: 15 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] 16 | os: [ubuntu-latest] 17 | pydantic-version: ["pydantic-v1", "pydantic-v2"] 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | - name: Setup Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v3 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip setuptools 28 | pip install -r requirements-dev.txt 29 | - name: Install Pydantic v1 30 | if: matrix.pydantic-version == 'pydantic-v1' 31 | run: pip install "pydantic>=1.10.0,<2.0.0" 32 | - name: Install Pydantic v2 33 | if: matrix.pydantic-version == 'pydantic-v2' 34 | run: pip install "pydantic>=2.0.2,<3.0.0" 35 | - name: Lint with flake8 36 | run: | 37 | python -m flake8 pydantic_collections tests 38 | continue-on-error: true 39 | - name: Run tests 40 | # run: python -m pytest tests --cov=./pydantic_collections --cov-report term-missing -s 41 | run: python -m pytest tests --cov=./pydantic_collections --cov-report xml 42 | - name: Upload coverage 43 | uses: codecov/codecov-action@v3 44 | with: 45 | file: ./coverage.xml 46 | flags: unit 47 | fail_ci_if_error: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bak 2 | *.egg 3 | *.egg-info 4 | *.eggs 5 | *.pyc 6 | *.pyd 7 | *.pyo 8 | *.so 9 | *.tar.gz 10 | *~ 11 | .DS_Store 12 | .Python 13 | .cache 14 | .coverage 15 | .coverage.* 16 | .idea 17 | .installed.cfg 18 | .noseids 19 | .tox 20 | .vimrc 21 | # bin 22 | build 23 | cover 24 | coverage 25 | develop-eggs 26 | dist 27 | docs/_build/ 28 | eggs 29 | include/ 30 | lib/ 31 | man/ 32 | nosetests.xml 33 | parts 34 | pyvenv 35 | sources 36 | var/* 37 | venv 38 | virtualenv.py 39 | .install-deps 40 | .develop 41 | .idea/ 42 | usage*.py -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the license file 2 | include LICENSE.txt 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pydantic-collections 2 | 3 | [![CI](https://github.com/romis2012/pydantic-collections/actions/workflows/ci.yml/badge.svg)](https://github.com/romis2012/pydantic-collections/actions/workflows/ci.yml) 4 | [![Coverage Status](https://codecov.io/gh/romis2012/pydantic-collections/branch/master/graph/badge.svg)](https://codecov.io/gh/romis2012/pydantic-collections) 5 | [![PyPI version](https://badge.fury.io/py/pydantic-collections.svg)](https://pypi.python.org/pypi/pydantic-collections) 6 | 7 | The `pydantic-collections` package provides `BaseCollectionModel` class that allows you 8 | to manipulate collections of [pydantic](https://github.com/samuelcolvin/pydantic) models 9 | (and any other types supported by pydantic). 10 | 11 | 12 | ## Requirements 13 | - Python>=3.7 14 | - pydantic>=1.8.2,<3.0 15 | 16 | 17 | ## Installation 18 | 19 | ``` 20 | pip install pydantic-collections 21 | ``` 22 | 23 | ## Usage 24 | 25 | #### Basic usage 26 | ```python 27 | 28 | from datetime import datetime 29 | 30 | from pydantic import BaseModel 31 | from pydantic_collections import BaseCollectionModel 32 | 33 | 34 | class User(BaseModel): 35 | id: int 36 | name: str 37 | birth_date: datetime 38 | 39 | 40 | class UserCollection(BaseCollectionModel[User]): 41 | pass 42 | 43 | 44 | user_data = [ 45 | {'id': 1, 'name': 'Bender', 'birth_date': '2010-04-01T12:59:59'}, 46 | {'id': 2, 'name': 'Balaganov', 'birth_date': '2020-04-01T12:59:59'}, 47 | ] 48 | 49 | users = UserCollection(user_data) 50 | 51 | print(users) 52 | #> UserCollection([User(id=1, name='Bender', birth_date=datetime.datetime(2010, 4, 1, 12, 59, 59)), User(id=2, name='Balaganov', birth_date=datetime.datetime(2020, 4, 1, 12, 59, 59))]) 53 | 54 | print(users.dict()) # pydantic v1.x 55 | print(users.model_dump()) # pydantic v2.x 56 | #> [{'id': 1, 'name': 'Bender', 'birth_date': datetime.datetime(2010, 4, 1, 12, 59, 59)}, {'id': 2, 'name': 'Balaganov', 'birth_date': datetime.datetime(2020, 4, 1, 12, 59, 59)}] 57 | 58 | print(users.json()) # pydantic v1.x 59 | print(users.model_dump_json()) # pydantic v2.x 60 | #> [{"id": 1, "name": "Bender", "birth_date": "2010-04-01T12:59:59"}, {"id": 2, "name": "Balaganov", "birth_date": "2020-04-01T12:59:59"}] 61 | ``` 62 | 63 | #### Strict assignment validation 64 | 65 | By default `BaseCollectionModel` has a strict assignment check 66 | ```python 67 | ... 68 | users = UserCollection() 69 | users.append(User(id=1, name='Bender', birth_date=datetime.utcnow())) # OK 70 | users.append({'id': 1, 'name': 'Bender', 'birth_date': '2010-04-01T12:59:59'}) 71 | #> pydantic.error_wrappers.ValidationError: 1 validation error for UserCollection 72 | #> __root__ -> 2 73 | #> instance of User expected (type=type_error.arbitrary_type; expected_arbitrary_type=User) 74 | ``` 75 | 76 | This behavior can be changed via Model Config 77 | 78 | Pydantic v1.x 79 | ```python 80 | from pydantic_collections import BaseCollectionModel 81 | ... 82 | class UserCollection(BaseCollectionModel[User]): 83 | class Config: 84 | validate_assignment_strict = False 85 | ``` 86 | 87 | Pydantic v2.x 88 | ```python 89 | from pydantic_collections import BaseCollectionModel, CollectionModelConfig 90 | ... 91 | class UserCollection(BaseCollectionModel[User]): 92 | model_config = CollectionModelConfig(validate_assignment_strict=False) 93 | ``` 94 | 95 | ```python 96 | users = UserCollection() 97 | users.append({'id': 1, 'name': 'Bender', 'birth_date': '2010-04-01T12:59:59'}) # OK 98 | assert users[0].__class__ is User 99 | assert users[0].id == 1 100 | ``` 101 | 102 | #### Using as a model field 103 | 104 | `BaseCollectionModel` is a subclass of `BaseModel`, so you can use it as a model field 105 | ```python 106 | ... 107 | class UserContainer(BaseModel): 108 | users: UserCollection = [] 109 | 110 | data = { 111 | 'users': [ 112 | {'id': 1, 'name': 'Bender', 'birth_date': '2010-04-01T12:59:59'}, 113 | {'id': 2, 'name': 'Balaganov', 'birth_date': '2020-04-01T12:59:59'}, 114 | ] 115 | } 116 | 117 | container = UserContainer(**data) 118 | container.users.append(User(...)) 119 | ... 120 | ``` 121 | -------------------------------------------------------------------------------- /pydantic_collections/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = 'pydantic-collections' 2 | __version__ = '0.6.0' 3 | 4 | from pydantic.version import VERSION as PYDANTIC_VERSION 5 | 6 | PYDANTIC_V2 = PYDANTIC_VERSION.startswith('2.') 7 | 8 | 9 | if PYDANTIC_V2: 10 | from ._v2 import BaseCollectionModel, CollectionModelConfig # noqa: F401 11 | 12 | __all_v__ = ('CollectionModelConfig',) 13 | else: 14 | from ._v1 import BaseCollectionModel # noqa: F401 15 | 16 | __all_v__ = () 17 | 18 | __all__ = ( 19 | '__title__', 20 | '__version__', 21 | 'BaseCollectionModel', 22 | ) + __all_v__ 23 | -------------------------------------------------------------------------------- /pydantic_collections/__init__.pyi: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, MutableSequence, Optional, List, Union, overload, Any 2 | 3 | from pydantic import BaseModel, ConfigDict 4 | 5 | PYDANTIC_V2: bool 6 | __version__: str 7 | 8 | class CollectionModelConfig(ConfigDict): 9 | validate_assignment_strict: bool 10 | 11 | T = TypeVar('T') 12 | 13 | class BaseCollectionModel(MutableSequence[T], BaseModel): 14 | def __init__(self, data: Optional[List[Union[T, dict]]] = None): ... 15 | def insert(self, index: int, value: Union[T, dict]) -> None: ... 16 | def append(self, value: Union[T, dict]) -> None: ... 17 | def sort(self, key: Any, reverse: bool = False): ... 18 | @overload 19 | def __getitem__(self, index: int) -> T: ... 20 | @overload 21 | def __getitem__(self, index: slice) -> 'BaseCollectionModel[T]': ... 22 | @overload 23 | def __setitem__(self, index: int, value: Union[T, dict]) -> None: ... 24 | @overload 25 | def __delitem__(self, index: int) -> None: ... 26 | @overload 27 | def __delitem__(self, index: slice) -> None: ... 28 | def __len__(self) -> int: ... 29 | -------------------------------------------------------------------------------- /pydantic_collections/_v1.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import warnings 3 | from typing import Optional, List, MutableSequence, Type, TypeVar, Any, Callable, TYPE_CHECKING 4 | 5 | from pydantic import BaseModel, BaseConfig, ValidationError 6 | from pydantic.error_wrappers import ErrorWrapper 7 | from pydantic.errors import ArbitraryTypeError 8 | from pydantic.fields import ModelField, Undefined 9 | 10 | # noinspection PyProtectedMember 11 | from pydantic.main import Extra 12 | 13 | 14 | class CollectionModelConfig(BaseConfig): 15 | validate_assignment_strict = False 16 | 17 | 18 | def tp_cache(func): 19 | """Internal wrapper caching __getitem__ of generic types with a fallback to 20 | original function for non-hashable arguments. 21 | """ 22 | cached = functools.lru_cache(maxsize=None, typed=True)(func) 23 | 24 | @functools.wraps(func) 25 | def inner(*args, **kwargs): 26 | try: 27 | return cached(*args, **kwargs) 28 | except TypeError: # pragma: no cover 29 | pass # All real errors (not unhashable args) are raised below. 30 | return func(*args, **kwargs) # pragma: no cover 31 | 32 | return inner 33 | 34 | 35 | TElement = TypeVar('TElement') 36 | 37 | 38 | class BaseCollectionModel(BaseModel, MutableSequence[TElement]): 39 | if TYPE_CHECKING: # pragma: no cover 40 | __el_field__: ModelField 41 | __config__: Type[CollectionModelConfig] 42 | __root__: List[TElement] 43 | 44 | class Config(CollectionModelConfig): 45 | extra = Extra.forbid 46 | validate_assignment = True 47 | validate_assignment_strict = True 48 | 49 | @tp_cache 50 | def __class_getitem__(cls, el_type): 51 | if not issubclass(cls, BaseCollectionModel): 52 | raise TypeError('{!r} is not a BaseCollectionModel'.format(cls)) # pragma: no cover 53 | 54 | return type( 55 | '{}[{}]'.format(cls.__name__, el_type), 56 | (cls,), 57 | { 58 | '__el_field__': ModelField.infer( 59 | name='{}[{}]:element'.format(cls.__name__, el_type), 60 | annotation=el_type, 61 | value=Undefined, 62 | class_validators=None, 63 | config=BaseConfig, 64 | # model_config=cls.__config__, 65 | ), 66 | '__annotations__': {'__root__': List[el_type]}, 67 | }, 68 | ) 69 | 70 | def __init__(self, data: list = None, **kwargs): 71 | __root__ = kwargs.get('__root__') 72 | if __root__ is None: 73 | if data is None: 74 | __root__ = [] 75 | else: 76 | __root__ = data 77 | 78 | super(BaseCollectionModel, self).__init__(__root__=__root__) 79 | 80 | def _validate_element(self, value, index): 81 | if not self.__config__.validate_assignment: 82 | return value # pragma: no cover 83 | 84 | if self.__config__.validate_assignment_strict: 85 | if self.__el_field__.allow_none and value is None: 86 | pass # pragma: no cover 87 | else: 88 | self._validate_element_type(self.__el_field__, value, index) 89 | 90 | value, err = self.__el_field__.validate( 91 | value, 92 | {}, 93 | loc='{} -> {}'.format('__root__', index), 94 | cls=self.__class__, 95 | ) 96 | 97 | errors = [] 98 | if isinstance(err, ErrorWrapper): 99 | errors.append(err) 100 | elif isinstance(err, list): # pragma: no cover 101 | errors.extend(err) 102 | 103 | if errors: 104 | raise ValidationError(errors, self.__class__) 105 | 106 | return value 107 | 108 | def _validate_element_type(self, field: ModelField, value: Any, index: int): 109 | def get_field_types(fld: ModelField): 110 | if fld.sub_fields: 111 | for sub_field in fld.sub_fields: 112 | yield from get_field_types(sub_field) 113 | else: 114 | yield fld.type_ 115 | 116 | if not isinstance(value, tuple(get_field_types(field))): 117 | error = ArbitraryTypeError(expected_arbitrary_type=field.type_) 118 | raise ValidationError( 119 | [ErrorWrapper(exc=error, loc='{} -> {}'.format('__root__', index))], 120 | self.__class__, 121 | ) 122 | 123 | def __len__(self): 124 | return len(self.__root__) 125 | 126 | def __getitem__(self, index): 127 | if isinstance(index, slice): 128 | return self.__class__(self.__root__[index]) 129 | else: 130 | return self.__root__[index] 131 | 132 | def __setitem__(self, index, value): 133 | self.__root__[index] = self._validate_element(value, index) 134 | return self.__root__[index] 135 | 136 | def __delitem__(self, index): 137 | del self.__root__[index] 138 | 139 | def __iter__(self) -> List[TElement]: 140 | yield from self.__root__ 141 | 142 | def __repr__(self): 143 | return '{}({!r})'.format(self.__class__.__name__, self.__root__) # pragma: no cover 144 | 145 | def __str__(self): 146 | return repr(self) # pragma: no cover 147 | 148 | def insert(self, index, value): 149 | self.__root__.insert(index, self._validate_element(value, index)) 150 | 151 | def append(self, value): 152 | index = len(self.__root__) + 1 153 | self.__root__.append(self._validate_element(value, index)) 154 | 155 | def sort(self, key, reverse=False): 156 | data = sorted(self.__root__, key=key, reverse=reverse) 157 | return self.__class__(data) 158 | 159 | def dict( 160 | self, 161 | *, 162 | by_alias=False, 163 | skip_defaults: bool = None, 164 | exclude_unset: bool = False, 165 | exclude_defaults: bool = False, 166 | exclude_none: bool = False, 167 | **kwargs, 168 | ) -> List[TElement]: 169 | data = super().dict( 170 | by_alias=by_alias, 171 | skip_defaults=skip_defaults, 172 | exclude_unset=exclude_unset, 173 | exclude_defaults=exclude_defaults, 174 | exclude_none=exclude_none, 175 | ) 176 | # Original pydantic dict(...) returns a dict of the form {'__root__': [...]} 177 | # this behavior will be change in ver 2.0 178 | # https://github.com/samuelcolvin/pydantic/issues/1193 179 | if isinstance(data, dict): 180 | return data['__root__'] 181 | else: 182 | return data # noqa; #pragma: no cover 183 | 184 | def json( 185 | self, 186 | *, 187 | include=None, 188 | exclude=None, 189 | by_alias=False, 190 | skip_defaults=None, 191 | exclude_unset=False, 192 | exclude_defaults=False, 193 | exclude_none=False, 194 | encoder: Optional[Callable[[Any], Any]] = None, 195 | **dumps_kwargs: Any, 196 | ) -> str: 197 | if skip_defaults is not None: # pragma: no cover 198 | warnings.warn( 199 | f'{self.__class__.__name__}.json(): "skip_defaults" is deprecated ' 200 | 'and replaced by "exclude_unset"', 201 | DeprecationWarning, 202 | ) 203 | exclude_unset = skip_defaults 204 | 205 | data = self.dict( 206 | include=include, 207 | exclude=exclude, 208 | by_alias=by_alias, 209 | exclude_unset=exclude_unset, 210 | exclude_defaults=exclude_defaults, 211 | exclude_none=exclude_none, 212 | ) 213 | 214 | encoder = encoder or self.__json_encoder__ 215 | return self.__config__.json_dumps(data, default=encoder, **dumps_kwargs) 216 | -------------------------------------------------------------------------------- /pydantic_collections/_v2.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import types 3 | from dataclasses import dataclass 4 | from typing import ( 5 | List, 6 | TYPE_CHECKING, 7 | Any, 8 | Tuple, 9 | Union, 10 | Dict, 11 | TypeVar, 12 | MutableSequence, 13 | ) 14 | 15 | from pydantic import RootModel, TypeAdapter, ConfigDict, ValidationError 16 | from pydantic_core import PydanticUndefined, ErrorDetails 17 | from typing_extensions import get_origin, get_args 18 | 19 | UnionType = getattr(types, 'UnionType', Union) 20 | 21 | 22 | def get_types_from_annotation(tp: Any): 23 | origin = get_origin(tp) 24 | if origin is Union or origin is UnionType: 25 | for sub_tp in get_args(tp): 26 | yield from get_types_from_annotation(sub_tp) 27 | elif isinstance(tp, type): 28 | yield tp 29 | else: 30 | yield origin 31 | 32 | 33 | def wrap_errors_with_loc( 34 | *, 35 | errors: List[ErrorDetails], 36 | loc_prefix: Tuple[Union[str, int], ...], 37 | ) -> List[Dict[str, Any]]: 38 | return [{**err, 'loc': loc_prefix + err.get('loc', ())} for err in errors] 39 | 40 | 41 | def tp_cache(func): 42 | """Internal wrapper caching __getitem__ of generic types with a fallback to 43 | original function for non-hashable arguments. 44 | """ 45 | cached = functools.lru_cache(maxsize=None, typed=True)(func) 46 | 47 | @functools.wraps(func) 48 | def inner(*args, **kwargs): 49 | try: 50 | return cached(*args, **kwargs) 51 | except TypeError: # pragma: no cover 52 | pass # All real errors (not unhashable args) are raised below. 53 | return func(*args, **kwargs) # pragma: no cover 54 | 55 | return inner 56 | 57 | 58 | class CollectionModelConfig(ConfigDict): 59 | validate_assignment_strict: bool 60 | 61 | 62 | @dataclass 63 | class Element: 64 | annotation: Any 65 | adapter: TypeAdapter 66 | 67 | 68 | TElement = TypeVar("TElement") 69 | 70 | 71 | class BaseCollectionModel(MutableSequence[TElement], RootModel[List[TElement]]): 72 | if TYPE_CHECKING: # pragma: no cover 73 | __element__: Element 74 | 75 | # noinspection Pydantic 76 | model_config = CollectionModelConfig( 77 | validate_assignment=True, 78 | validate_assignment_strict=True, 79 | ) 80 | 81 | @tp_cache 82 | def __class_getitem__(cls, el_type): 83 | if not issubclass(cls, BaseCollectionModel): 84 | raise TypeError('{!r} is not a BaseCollectionModel'.format(cls)) # pragma: no cover 85 | 86 | element = Element(annotation=el_type, adapter=TypeAdapter(el_type)) 87 | return type( 88 | '{}[{}]'.format(cls.__name__, el_type), 89 | (cls,), 90 | { 91 | '__element__': element, 92 | '__annotations__': {'root': List[el_type]}, 93 | }, 94 | ) 95 | 96 | def __init__(self, data: list = None, root=PydanticUndefined, **kwargs): 97 | if root is PydanticUndefined: 98 | if data is None: 99 | root = [] 100 | else: 101 | root = data 102 | 103 | super(BaseCollectionModel, self).__init__(root=root, **kwargs) 104 | 105 | def _validate_element_type(self, value: Any, index: int): 106 | tps = get_types_from_annotation(self.__element__.annotation) 107 | if not isinstance(value, tuple(tps)): 108 | error = { 109 | 'type': 'is_instance_of', 110 | 'loc': (index,), 111 | 'input': value, 112 | 'ctx': {'class': str(self.__element__.annotation)}, 113 | } 114 | raise ValidationError.from_exception_data( 115 | title=self.__class__.__name__, 116 | line_errors=[error], 117 | ) 118 | 119 | def _validate_element(self, value: Any, index: int): 120 | if not self.model_config['validate_assignment']: 121 | return value 122 | 123 | strict = False 124 | if self.model_config['validate_assignment_strict']: 125 | self._validate_element_type(value, index) 126 | strict = True 127 | 128 | try: 129 | return self.__element__.adapter.validate_python( 130 | value, 131 | strict=strict, 132 | from_attributes=True, 133 | ) 134 | except ValidationError as e: 135 | errors = wrap_errors_with_loc( 136 | errors=e.errors(), 137 | loc_prefix=(index,), 138 | ) 139 | raise ValidationError.from_exception_data( 140 | title=self.__class__.__name__, 141 | line_errors=errors, 142 | ) 143 | 144 | def __len__(self): 145 | return len(self.root) 146 | 147 | def __getitem__(self, index): 148 | if isinstance(index, slice): 149 | return self.__class__(self.root[index]) 150 | else: 151 | return self.root[index] 152 | 153 | def __setitem__(self, index, value): 154 | self.root[index] = self._validate_element(value, index) 155 | return self.root[index] 156 | 157 | def __delitem__(self, index): 158 | del self.root[index] 159 | 160 | def __iter__(self): 161 | yield from self.root 162 | 163 | def __repr__(self): 164 | return '{}({!r})'.format(self.__class__.__name__, self.root) # pragma: no cover 165 | 166 | def __str__(self): 167 | return repr(self) # pragma: no cover 168 | 169 | def insert(self, index, value): 170 | self.root.insert(index, self._validate_element(value, index)) 171 | 172 | def append(self, value): 173 | index = len(self.root) + 1 174 | self.root.append(self._validate_element(value, index)) 175 | 176 | def sort(self, key, reverse=False): 177 | data = sorted(self.root, key=key, reverse=reverse) 178 | return self.__class__(data) 179 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 99 3 | target-version = ['py37', 'py38', 'py39'] 4 | skip-string-normalization = true 5 | preview = true 6 | verbose = true 7 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # pydantic>=1.8.2,<2.0 2 | typing_extensions>=4.7.1 3 | flake8>=3.9.1 4 | pytest==7.0.1; python_version < "3.8" 5 | pytest-cov==3.0.0; python_version < "3.8" 6 | pytest==8.0.2; python_version >= "3.8" 7 | pytest-cov==4.1.0; python_version >= "3.8" 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | 5 | from setuptools import setup 6 | 7 | if sys.version_info < (3, 7, 1): 8 | raise RuntimeError('pydantic-collections requires Python 3.7.1+') 9 | 10 | 11 | def get_version(): 12 | here = os.path.dirname(os.path.abspath(__file__)) 13 | filename = os.path.join(here, 'pydantic_collections', '__init__.py') 14 | contents = open(filename).read() 15 | pattern = r"^__version__ = '(.*?)'$" 16 | return re.search(pattern, contents, re.MULTILINE).group(1) 17 | 18 | 19 | def get_long_description(): 20 | with open('README.md', mode='r', encoding='utf8') as f: 21 | return f.read() 22 | 23 | 24 | setup( 25 | name='pydantic-collections', 26 | author='Roman Snegirev', 27 | author_email='snegiryev@gmail.com', 28 | version=get_version(), 29 | license='Apache 2', 30 | url='https://github.com/romis2012/pydantic-collections', 31 | description='Collections of pydantic models', 32 | long_description=get_long_description(), 33 | long_description_content_type='text/markdown', 34 | packages=['pydantic_collections'], 35 | keywords='python pydantic validation parsing serialization models', 36 | install_requires=['pydantic>=1.8.2,<3.0', 'typing_extensions>=4.7.1'], 37 | ) 38 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/romis2012/pydantic-collections/e85426c81a66e7a45de15fa22337b64f5fb7f81b/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_v1.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic_collections import PYDANTIC_V2 3 | 4 | if PYDANTIC_V2: 5 | pytest.skip('Skipped', allow_module_level=True) 6 | 7 | from typing import Optional, Union 8 | from datetime import datetime 9 | 10 | from pydantic import BaseModel, ValidationError 11 | from pydantic_collections import BaseCollectionModel 12 | 13 | 14 | class User(BaseModel): 15 | id: int 16 | name: str 17 | birth_date: datetime 18 | 19 | def __hash__(self): 20 | return hash((self.id, self.name, self.birth_date)) 21 | 22 | def __eq__(self, other: 'User'): 23 | return ( 24 | self.__class__ == other.__class__ 25 | and self.id == other.id 26 | and self.name == other.name 27 | and self.birth_date == other.birth_date 28 | ) 29 | 30 | 31 | class UserCollection(BaseCollectionModel[User]): 32 | pass 33 | 34 | 35 | class WeakUserCollection(BaseCollectionModel[User]): 36 | class Config: 37 | validate_assignment_strict = False 38 | 39 | 40 | class OptionalIntCollection(BaseCollectionModel[Optional[int]]): 41 | pass 42 | 43 | 44 | class IntOrOptionalDatetimeCollection(BaseCollectionModel[Union[int, Optional[datetime]]]): 45 | pass 46 | 47 | 48 | user_data = [ 49 | { 50 | 'id': 1, 51 | 'name': 'Bender', 52 | 'birth_date': '2010-04-01T12:59:59', 53 | }, 54 | { 55 | 'id': 2, 56 | 'name': 'Balaganov', 57 | 'birth_date': '2020-04-01T12:59:59', 58 | }, 59 | ] 60 | 61 | 62 | def test_collection_validation_serialization(): 63 | user0 = User(**user_data[0]) 64 | user1 = User(**user_data[1]) 65 | 66 | users = UserCollection(user_data) 67 | assert len(users) == len(user_data) 68 | assert users[0] == user0 69 | assert users[1] == user1 70 | 71 | assert users.dict() == [user0.dict(), user1.dict()] 72 | 73 | users2 = UserCollection.parse_raw(users.json()) 74 | for (u1, u2) in zip(users, users2): 75 | assert u1 == u2 76 | 77 | users3 = UserCollection.parse_obj(users.dict()) 78 | for (u1, u2) in zip(users, users3): 79 | assert u1 == u2 80 | 81 | 82 | def test_collection_sort(): 83 | users = UserCollection(user_data) 84 | reversed_users = users.sort(key=lambda u: u.id, reverse=True) 85 | assert reversed_users[0] == users[1] 86 | assert reversed_users[1] == users[0] 87 | 88 | 89 | def test_collection_assignment_validation(): 90 | users = UserCollection() 91 | for item in user_data: 92 | users.append(User(**item)) 93 | 94 | with pytest.raises(ValidationError): 95 | users.append(user_data[0]) # noqa 96 | 97 | with pytest.raises(ValidationError): 98 | users[0] = user_data[0] 99 | 100 | weak_users = WeakUserCollection() 101 | for d in user_data: 102 | weak_users.append(d) # noqa 103 | 104 | for user in weak_users: 105 | assert user.__class__ is User 106 | 107 | for (u1, u2) in zip(weak_users, user_data): 108 | assert u1 == User(**u2) 109 | 110 | with pytest.raises(ValidationError): 111 | weak_users.append('user') # noqa 112 | 113 | 114 | def test_optional_collection(): 115 | data = [1, None] 116 | c = OptionalIntCollection() 117 | for el in data: 118 | c.append(el) 119 | 120 | for (item1, item2) in zip(c, data): 121 | assert item1 == item2 122 | 123 | 124 | def test_union_collection(): 125 | data = [1, datetime.utcnow(), None] 126 | c = IntOrOptionalDatetimeCollection() 127 | for el in data: 128 | c.append(el) 129 | 130 | for (item1, item2) in zip(c, data): 131 | assert item1 == item2 132 | 133 | with pytest.raises(ValidationError): 134 | c.append('data') # noqa 135 | 136 | 137 | def test_collection_sequence_methods(): 138 | users = UserCollection() 139 | for item in user_data: 140 | users.append(User(**item)) 141 | 142 | assert len(users) == len(user_data) 143 | 144 | user0 = User(**user_data[0]) 145 | users.insert(0, user0) 146 | assert users[0] == user0 147 | assert len(users) == len(user_data) + 1 148 | 149 | users[-1] = user0 150 | assert users[-1] == user0 151 | 152 | users.clear() 153 | assert len(users) == 0 154 | -------------------------------------------------------------------------------- /tests/test_v2.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic_collections import PYDANTIC_V2 3 | 4 | if not PYDANTIC_V2: 5 | pytest.skip('Skipped', allow_module_level=True) 6 | 7 | from typing import Optional, Union 8 | from datetime import datetime 9 | 10 | from pydantic import BaseModel, ValidationError 11 | from pydantic_collections import BaseCollectionModel, CollectionModelConfig 12 | 13 | 14 | class User(BaseModel): 15 | id: int 16 | name: str 17 | birth_date: datetime 18 | 19 | def __hash__(self): 20 | return hash((self.id, self.name, self.birth_date)) 21 | 22 | def __eq__(self, other: 'User'): 23 | return ( 24 | self.__class__ == other.__class__ 25 | and self.id == other.id 26 | and self.name == other.name 27 | and self.birth_date == other.birth_date 28 | ) 29 | 30 | 31 | class UserCollection(BaseCollectionModel[User]): 32 | pass 33 | 34 | 35 | class WeakUserCollection(BaseCollectionModel[User]): 36 | model_config = CollectionModelConfig(validate_assignment_strict=False) 37 | 38 | 39 | class OptionalIntCollection(BaseCollectionModel[Optional[int]]): 40 | pass 41 | 42 | 43 | class IntOrOptionalDatetimeCollection(BaseCollectionModel[Union[int, Optional[datetime]]]): 44 | pass 45 | 46 | 47 | user_data = [ 48 | { 49 | 'id': 1, 50 | 'name': 'Bender', 51 | 'birth_date': '2010-04-01T12:59:59', 52 | }, 53 | { 54 | 'id': 2, 55 | 'name': 'Balaganov', 56 | 'birth_date': '2020-04-01T12:59:59', 57 | }, 58 | ] 59 | 60 | 61 | def test_collection_validation_serialization(): 62 | user0 = User(**user_data[0]) 63 | user1 = User(**user_data[1]) 64 | 65 | users = UserCollection(user_data) 66 | assert len(users) == len(user_data) 67 | assert users[0] == user0 68 | assert users[1] == user1 69 | 70 | assert users.model_dump() == [user0.model_dump(), user1.model_dump()] 71 | 72 | users2 = UserCollection.model_validate_json(users.model_dump_json()) 73 | for (u1, u2) in zip(users, users2): 74 | assert u1 == u2 75 | 76 | users3 = UserCollection.model_validate(users.model_dump()) 77 | for (u1, u2) in zip(users, users3): 78 | assert u1 == u2 79 | 80 | 81 | def test_collection_sort(): 82 | users = UserCollection(user_data) 83 | reversed_users = users.sort(key=lambda u: u.id, reverse=True) 84 | assert reversed_users[0] == users[1] 85 | assert reversed_users[1] == users[0] 86 | 87 | 88 | def test_collection_assignment_validation(): 89 | users = UserCollection() 90 | for item in user_data: 91 | users.append(User(**item)) 92 | 93 | with pytest.raises(ValidationError): 94 | users.append(user_data[0]) # noqa 95 | 96 | with pytest.raises(ValidationError): 97 | users[0] = user_data[0] 98 | 99 | weak_users = WeakUserCollection() 100 | for d in user_data: 101 | weak_users.append(d) # noqa 102 | 103 | for user in weak_users: 104 | assert user.__class__ is User 105 | 106 | for (u1, u2) in zip(weak_users, user_data): 107 | assert u1 == User(**u2) 108 | 109 | with pytest.raises(ValidationError): 110 | weak_users.append('user') # noqa 111 | 112 | 113 | def test_optional_collection(): 114 | data = [1, None] 115 | c = OptionalIntCollection() 116 | for el in data: 117 | c.append(el) 118 | 119 | for (item1, item2) in zip(c, data): 120 | assert item1 == item2 121 | 122 | 123 | def test_union_collection(): 124 | data = [1, datetime.utcnow(), None] 125 | c = IntOrOptionalDatetimeCollection() 126 | for el in data: 127 | c.append(el) 128 | 129 | for (item1, item2) in zip(c, data): 130 | assert item1 == item2 131 | 132 | with pytest.raises(ValidationError): 133 | c.append('data') # noqa 134 | 135 | 136 | def test_collection_sequence_methods(): 137 | users = UserCollection() 138 | for item in user_data: 139 | users.append(User(**item)) 140 | 141 | assert len(users) == len(user_data) 142 | 143 | user0 = User(**user_data[0]) 144 | users.insert(0, user0) 145 | assert users[0] == user0 146 | assert len(users) == len(user_data) + 1 147 | 148 | users[-1] = user0 149 | assert users[-1] == user0 150 | 151 | users.clear() 152 | assert len(users) == 0 153 | --------------------------------------------------------------------------------